1use reqsign_core::{Result, SigningCredential as KeyTrait, time::Timestamp, utils::Redact};
19use std::fmt::{self, Debug};
20use std::time::Duration;
21
22const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(120);
23
24#[derive(Clone, serde::Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub struct ServiceAccount {
28 pub private_key: String,
30 pub client_email: String,
32}
33
34impl Debug for ServiceAccount {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 f.debug_struct("ServiceAccount")
37 .field("client_email", &self.client_email)
38 .field("private_key", &Redact::from(&self.private_key))
39 .finish()
40 }
41}
42
43impl ServiceAccount {
44 pub(crate) fn is_valid(&self) -> bool {
45 !self.private_key.is_empty() && !self.client_email.is_empty()
46 }
47}
48
49#[derive(Clone, serde::Deserialize, Debug)]
51#[serde(rename_all = "snake_case")]
52pub struct ImpersonatedServiceAccount {
53 pub service_account_impersonation_url: String,
55 pub source_credentials: OAuth2Credentials,
57 #[serde(default)]
59 pub delegates: Vec<String>,
60}
61
62#[derive(Clone, serde::Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub struct OAuth2Credentials {
66 pub client_id: String,
68 pub client_secret: String,
70 pub refresh_token: String,
72}
73
74impl Debug for OAuth2Credentials {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.debug_struct("OAuth2Credentials")
77 .field("client_id", &self.client_id)
78 .field("client_secret", &Redact::from(&self.client_secret))
79 .field("refresh_token", &Redact::from(&self.refresh_token))
80 .finish()
81 }
82}
83
84#[derive(Clone, serde::Deserialize, Debug)]
86#[serde(rename_all = "snake_case")]
87pub struct ExternalAccount {
88 pub audience: String,
90 pub subject_token_type: String,
92 pub token_url: String,
94 pub credential_source: external_account::Source,
96 pub service_account_impersonation_url: Option<String>,
98 pub service_account_impersonation: Option<external_account::ImpersonationOptions>,
100}
101
102pub mod external_account {
104 use reqsign_core::Result;
105 use serde::Deserialize;
106
107 #[derive(Clone, Deserialize, Debug)]
109 #[serde(untagged)]
110 pub enum Source {
111 #[serde(rename_all = "snake_case")]
113 Aws(AwsSource),
114 #[serde(rename_all = "snake_case")]
116 Url(UrlSource),
117 #[serde(rename_all = "snake_case")]
119 File(FileSource),
120 #[serde(rename_all = "snake_case")]
122 Executable(ExecutableSource),
123 }
124
125 #[derive(Clone, Deserialize, Debug)]
127 #[serde(rename_all = "snake_case")]
128 pub struct UrlSource {
129 pub url: String,
131 pub format: Format,
133 pub headers: Option<std::collections::HashMap<String, String>>,
135 }
136
137 #[derive(Clone, Deserialize, Debug)]
139 #[serde(rename_all = "snake_case")]
140 pub struct FileSource {
141 pub file: String,
143 pub format: Format,
145 }
146
147 #[derive(Clone, Deserialize, Debug)]
149 #[serde(rename_all = "snake_case")]
150 pub struct AwsSource {
151 pub environment_id: String,
153 pub region_url: Option<String>,
155 pub url: Option<String>,
157 pub regional_cred_verification_url: String,
159 pub imdsv2_session_token_url: Option<String>,
161 }
162
163 #[derive(Clone, Deserialize, Debug)]
165 #[serde(rename_all = "snake_case")]
166 pub struct ExecutableSource {
167 pub executable: ExecutableConfig,
169 }
170
171 #[derive(Clone, Deserialize, Debug)]
173 #[serde(rename_all = "snake_case")]
174 pub struct ExecutableConfig {
175 pub command: String,
177 pub timeout_millis: Option<u64>,
179 pub output_file: Option<String>,
181 }
182
183 #[derive(Clone, Deserialize, Debug)]
185 #[serde(tag = "type", rename_all = "snake_case")]
186 pub enum Format {
187 Json {
189 subject_token_field_name: String,
191 },
192 Text,
194 }
195
196 impl Format {
197 pub fn parse(&self, slice: &[u8]) -> Result<String> {
199 match &self {
200 Self::Text => Ok(String::from_utf8(slice.to_vec()).map_err(|e| {
201 reqsign_core::Error::unexpected("invalid UTF-8").with_source(e)
202 })?),
203 Self::Json {
204 subject_token_field_name,
205 } => {
206 let value: serde_json::Value = serde_json::from_slice(slice).map_err(|e| {
207 reqsign_core::Error::unexpected("failed to parse JSON").with_source(e)
208 })?;
209 match value.get(subject_token_field_name) {
210 Some(serde_json::Value::String(access_token)) => Ok(access_token.clone()),
211 _ => Err(reqsign_core::Error::unexpected(format!(
212 "JSON missing token field {subject_token_field_name}"
213 ))),
214 }
215 }
216 }
217 }
218 }
219
220 #[derive(Clone, Deserialize, Debug)]
222 #[serde(rename_all = "snake_case")]
223 pub struct ImpersonationOptions {
224 pub token_lifetime_seconds: Option<usize>,
226 }
227}
228
229#[derive(Clone, Default)]
231pub struct Token {
232 pub access_token: String,
234 pub expires_at: Option<Timestamp>,
236}
237
238impl Debug for Token {
239 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 f.debug_struct("Token")
241 .field("access_token", &Redact::from(&self.access_token))
242 .field("expires_at", &self.expires_at)
243 .finish()
244 }
245}
246
247impl KeyTrait for Token {
248 fn is_valid(&self) -> bool {
249 self.is_valid_at(Timestamp::now() + TOKEN_REFRESH_BUFFER)
250 }
251
252 fn is_valid_at(&self, timestamp: Timestamp) -> bool {
253 if self.access_token.is_empty() {
254 return false;
255 }
256
257 self.expires_at
258 .is_none_or(|expires_at| expires_at > timestamp)
259 }
260}
261
262#[derive(Clone, Debug, Default)]
276pub struct Credential {
277 pub service_account: Option<ServiceAccount>,
279 pub token: Option<Token>,
281}
282
283impl Credential {
284 pub fn with_service_account(service_account: ServiceAccount) -> Self {
286 Self {
287 service_account: Some(service_account),
288 token: None,
289 }
290 }
291
292 pub fn with_token(token: Token) -> Self {
294 Self {
295 service_account: None,
296 token: Some(token),
297 }
298 }
299
300 pub fn has_service_account(&self) -> bool {
302 self.service_account.is_some()
303 }
304
305 pub fn has_token(&self) -> bool {
307 self.token.is_some()
308 }
309
310 pub fn has_valid_token(&self) -> bool {
312 self.token.as_ref().is_some_and(|t| t.is_valid())
313 }
314}
315
316impl KeyTrait for Credential {
317 fn is_valid(&self) -> bool {
318 self.service_account
319 .as_ref()
320 .is_some_and(ServiceAccount::is_valid)
321 || self.has_valid_token()
322 }
323
324 fn is_valid_at(&self, timestamp: Timestamp) -> bool {
325 self.service_account
326 .as_ref()
327 .is_some_and(ServiceAccount::is_valid)
328 || self
329 .token
330 .as_ref()
331 .is_some_and(|token| token.is_valid_at(timestamp))
332 }
333}
334
335#[derive(Clone, Debug, serde::Deserialize)]
337#[serde(tag = "type", rename_all = "snake_case")]
338pub enum CredentialFile {
339 ServiceAccount(ServiceAccount),
341 ExternalAccount(ExternalAccount),
343 ImpersonatedServiceAccount(ImpersonatedServiceAccount),
345 AuthorizedUser(OAuth2Credentials),
347}
348
349impl CredentialFile {
350 pub fn from_slice(v: &[u8]) -> Result<Self> {
352 serde_json::from_slice(v).map_err(|e| {
353 reqsign_core::Error::unexpected("failed to parse credential file").with_source(e)
354 })
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn test_external_account_format_parse_text() {
364 let format = external_account::Format::Text;
365 let data = b"test-token";
366 let result = format.parse(data).unwrap();
367 assert_eq!("test-token", result);
368 }
369
370 #[test]
371 fn test_external_account_format_parse_json() {
372 let format = external_account::Format::Json {
373 subject_token_field_name: "access_token".to_string(),
374 };
375 let data = br#"{"access_token": "test-token", "expires_in": 3600}"#;
376 let result = format.parse(data).unwrap();
377 assert_eq!("test-token", result);
378 }
379
380 #[test]
381 fn test_external_account_format_parse_json_missing_field() {
382 let format = external_account::Format::Json {
383 subject_token_field_name: "access_token".to_string(),
384 };
385 let data = br#"{"wrong_field": "test-token"}"#;
386 let result = format.parse(data);
387 assert!(result.is_err());
388 }
389
390 #[test]
391 fn test_token_is_valid() {
392 let mut token = Token {
393 access_token: "test".to_string(),
394 expires_at: None,
395 };
396 assert!(token.is_valid());
397
398 token.expires_at = Some(Timestamp::now() + Duration::from_secs(3600));
400 assert!(token.is_valid());
401
402 token.expires_at = Some(Timestamp::now() + Duration::from_secs(30));
404 assert!(!token.is_valid());
405 assert!(token.is_valid_at(Timestamp::now() + Duration::from_secs(10)));
406
407 token.expires_at = Some(Timestamp::now() - Duration::from_secs(3600));
409 assert!(!token.is_valid());
410 assert!(!token.is_valid_at(Timestamp::now()));
411
412 token.access_token = String::new();
414 assert!(!token.is_valid());
415 }
416
417 #[test]
418 fn test_credential_file_deserialize() {
419 let sa_json = r#"{
421 "type": "service_account",
422 "private_key": "test_key",
423 "client_email": "test@example.com"
424 }"#;
425 let cred = CredentialFile::from_slice(sa_json.as_bytes()).unwrap();
426 match cred {
427 CredentialFile::ServiceAccount(sa) => {
428 assert_eq!(sa.client_email, "test@example.com");
429 }
430 _ => panic!("Expected ServiceAccount"),
431 }
432
433 let ea_json = r#"{
435 "type": "external_account",
436 "audience": "test_audience",
437 "subject_token_type": "test_type",
438 "token_url": "https://example.com/token",
439 "credential_source": {
440 "file": "/path/to/file",
441 "format": {
442 "type": "text"
443 }
444 }
445 }"#;
446 let cred = CredentialFile::from_slice(ea_json.as_bytes()).unwrap();
447 assert!(matches!(cred, CredentialFile::ExternalAccount(_)));
448
449 let aws_ea_json = r#"{
450 "type": "external_account",
451 "audience": "test_audience",
452 "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
453 "token_url": "https://example.com/token",
454 "credential_source": {
455 "environment_id": "aws1",
456 "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
457 "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials",
458 "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
459 "imdsv2_session_token_url": "http://169.254.169.254/latest/api/token"
460 }
461 }"#;
462 let cred = CredentialFile::from_slice(aws_ea_json.as_bytes()).unwrap();
463 match cred {
464 CredentialFile::ExternalAccount(external_account) => match external_account
465 .credential_source
466 {
467 external_account::Source::Aws(source) => {
468 assert_eq!(source.environment_id, "aws1");
469 assert_eq!(
470 source.region_url.as_deref(),
471 Some("http://169.254.169.254/latest/meta-data/placement/availability-zone")
472 );
473 assert_eq!(
474 source.url.as_deref(),
475 Some("http://169.254.169.254/latest/meta-data/iam/security-credentials")
476 );
477 assert_eq!(
478 source.regional_cred_verification_url,
479 "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
480 );
481 assert_eq!(
482 source.imdsv2_session_token_url.as_deref(),
483 Some("http://169.254.169.254/latest/api/token")
484 );
485 }
486 _ => panic!("Expected Aws source"),
487 },
488 _ => panic!("Expected ExternalAccount"),
489 }
490
491 let exec_ea_json = r#"{
492 "type": "external_account",
493 "audience": "test_audience",
494 "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
495 "token_url": "https://example.com/token",
496 "credential_source": {
497 "executable": {
498 "command": "/usr/bin/fetch-token --flag",
499 "timeout_millis": 5000,
500 "output_file": "/tmp/token-cache.json"
501 }
502 }
503 }"#;
504 let cred = CredentialFile::from_slice(exec_ea_json.as_bytes()).unwrap();
505 match cred {
506 CredentialFile::ExternalAccount(external_account) => {
507 match external_account.credential_source {
508 external_account::Source::Executable(source) => {
509 assert_eq!(source.executable.command, "/usr/bin/fetch-token --flag");
510 assert_eq!(source.executable.timeout_millis, Some(5000));
511 assert_eq!(
512 source.executable.output_file.as_deref(),
513 Some("/tmp/token-cache.json")
514 );
515 }
516 _ => panic!("Expected Executable source"),
517 }
518 }
519 _ => panic!("Expected ExternalAccount"),
520 }
521
522 let au_json = r#"{
524 "type": "authorized_user",
525 "client_id": "test_id",
526 "client_secret": "test_secret",
527 "refresh_token": "test_token"
528 }"#;
529 let cred = CredentialFile::from_slice(au_json.as_bytes()).unwrap();
530 match cred {
531 CredentialFile::AuthorizedUser(oauth2) => {
532 assert_eq!(oauth2.client_id, "test_id");
533 assert_eq!(oauth2.client_secret, "test_secret");
534 assert_eq!(oauth2.refresh_token, "test_token");
535 }
536 _ => panic!("Expected AuthorizedUser"),
537 }
538 }
539
540 #[test]
541 fn test_credential_is_valid() {
542 let cred = Credential::with_service_account(ServiceAccount {
544 client_email: "test@example.com".to_string(),
545 private_key: "key".to_string(),
546 });
547 assert!(cred.is_valid());
548 assert!(cred.has_service_account());
549 assert!(!cred.has_token());
550
551 let cred = Credential::with_service_account(ServiceAccount {
553 client_email: String::new(),
554 private_key: "key".to_string(),
555 });
556 assert!(!cred.is_valid());
557 assert!(!cred.is_valid_at(Timestamp::now()));
558
559 let cred = Credential::with_token(Token {
561 access_token: "test".to_string(),
562 expires_at: Some(Timestamp::now() + Duration::from_secs(3600)),
563 });
564 assert!(cred.is_valid());
565 assert!(!cred.has_service_account());
566 assert!(cred.has_token());
567 assert!(cred.has_valid_token());
568
569 let cred = Credential::with_token(Token {
571 access_token: String::new(),
572 expires_at: None,
573 });
574 assert!(!cred.is_valid());
575 assert!(!cred.has_valid_token());
576
577 let mut cred = Credential::with_service_account(ServiceAccount {
579 client_email: "test@example.com".to_string(),
580 private_key: "key".to_string(),
581 });
582 cred.token = Some(Token {
583 access_token: "test".to_string(),
584 expires_at: Some(Timestamp::now() + Duration::from_secs(3600)),
585 });
586 assert!(cred.is_valid());
587 assert!(cred.has_service_account());
588 assert!(cred.has_valid_token());
589
590 let mut cred = Credential::with_service_account(ServiceAccount {
592 client_email: "test@example.com".to_string(),
593 private_key: "key".to_string(),
594 });
595 cred.token = Some(Token {
596 access_token: "test".to_string(),
597 expires_at: Some(Timestamp::now() - Duration::from_secs(3600)),
598 });
599 assert!(cred.is_valid()); assert!(!cred.has_valid_token());
601 }
602}