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)]
279pub struct Credential {
280 pub service_account: Option<ServiceAccount>,
282 pub token: Option<Token>,
284 pub signer_email: Option<String>,
288}
289
290impl Credential {
291 pub fn with_service_account(service_account: ServiceAccount) -> Self {
293 Self {
294 service_account: Some(service_account),
295 token: None,
296 signer_email: None,
297 }
298 }
299
300 pub fn with_token(token: Token) -> Self {
302 Self {
303 service_account: None,
304 token: Some(token),
305 signer_email: None,
306 }
307 }
308
309 pub fn with_signer_email(mut self, signer_email: impl Into<String>) -> Self {
313 self.signer_email = Some(signer_email.into());
314 self
315 }
316
317 pub fn has_service_account(&self) -> bool {
319 self.service_account.is_some()
320 }
321
322 pub fn has_token(&self) -> bool {
324 self.token.is_some()
325 }
326
327 pub fn has_valid_token(&self) -> bool {
329 self.token.as_ref().is_some_and(|t| t.is_valid())
330 }
331}
332
333pub(crate) fn parse_service_account_impersonation_url(url: &str) -> Result<String> {
334 let marker = "/serviceAccounts/";
335 let start = url.find(marker).ok_or_else(|| {
336 reqsign_core::Error::config_invalid(format!(
337 "service_account_impersonation_url missing {marker}: {url}"
338 ))
339 })?;
340 let rest = &url[start + marker.len()..];
341 let end = rest.find(':').ok_or_else(|| {
342 reqsign_core::Error::config_invalid(format!(
343 "service_account_impersonation_url missing action separator: {url}"
344 ))
345 })?;
346
347 let email = percent_encoding::percent_decode_str(&rest[..end])
348 .decode_utf8()
349 .map_err(|e| {
350 reqsign_core::Error::config_invalid(
351 "service_account_impersonation_url contains invalid UTF-8 email",
352 )
353 .with_source(e)
354 })?;
355 if email.is_empty() {
356 return Err(reqsign_core::Error::config_invalid(
357 "service_account_impersonation_url resolved empty service account email",
358 ));
359 }
360
361 Ok(email.into_owned())
362}
363
364impl KeyTrait for Credential {
365 fn is_valid(&self) -> bool {
366 self.service_account
367 .as_ref()
368 .is_some_and(ServiceAccount::is_valid)
369 || self.has_valid_token()
370 }
371
372 fn is_valid_at(&self, timestamp: Timestamp) -> bool {
373 self.service_account
374 .as_ref()
375 .is_some_and(ServiceAccount::is_valid)
376 || self
377 .token
378 .as_ref()
379 .is_some_and(|token| token.is_valid_at(timestamp))
380 }
381}
382
383#[derive(Clone, Debug, serde::Deserialize)]
385#[serde(tag = "type", rename_all = "snake_case")]
386pub enum CredentialFile {
387 ServiceAccount(ServiceAccount),
389 ExternalAccount(ExternalAccount),
391 ImpersonatedServiceAccount(ImpersonatedServiceAccount),
393 AuthorizedUser(OAuth2Credentials),
395}
396
397impl CredentialFile {
398 pub fn from_slice(v: &[u8]) -> Result<Self> {
400 serde_json::from_slice(v).map_err(|e| {
401 reqsign_core::Error::unexpected("failed to parse credential file").with_source(e)
402 })
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn test_external_account_format_parse_text() {
412 let format = external_account::Format::Text;
413 let data = b"test-token";
414 let result = format.parse(data).unwrap();
415 assert_eq!("test-token", result);
416 }
417
418 #[test]
419 fn test_external_account_format_parse_json() {
420 let format = external_account::Format::Json {
421 subject_token_field_name: "access_token".to_string(),
422 };
423 let data = br#"{"access_token": "test-token", "expires_in": 3600}"#;
424 let result = format.parse(data).unwrap();
425 assert_eq!("test-token", result);
426 }
427
428 #[test]
429 fn test_external_account_format_parse_json_missing_field() {
430 let format = external_account::Format::Json {
431 subject_token_field_name: "access_token".to_string(),
432 };
433 let data = br#"{"wrong_field": "test-token"}"#;
434 let result = format.parse(data);
435 assert!(result.is_err());
436 }
437
438 #[test]
439 fn test_parse_service_account_impersonation_url() {
440 let url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/signer%40example.com:generateAccessToken";
441 assert_eq!(
442 parse_service_account_impersonation_url(url).unwrap(),
443 "signer@example.com"
444 );
445 }
446
447 #[test]
448 fn test_token_is_valid() {
449 let mut token = Token {
450 access_token: "test".to_string(),
451 expires_at: None,
452 };
453 assert!(token.is_valid());
454
455 token.expires_at = Some(Timestamp::now() + Duration::from_secs(3600));
457 assert!(token.is_valid());
458
459 token.expires_at = Some(Timestamp::now() + Duration::from_secs(30));
461 assert!(!token.is_valid());
462 assert!(token.is_valid_at(Timestamp::now() + Duration::from_secs(10)));
463
464 token.expires_at = Some(Timestamp::now() - Duration::from_secs(3600));
466 assert!(!token.is_valid());
467 assert!(!token.is_valid_at(Timestamp::now()));
468
469 token.access_token = String::new();
471 assert!(!token.is_valid());
472 }
473
474 #[test]
475 fn test_credential_file_deserialize() {
476 let sa_json = r#"{
478 "type": "service_account",
479 "private_key": "test_key",
480 "client_email": "test@example.com"
481 }"#;
482 let cred = CredentialFile::from_slice(sa_json.as_bytes()).unwrap();
483 match cred {
484 CredentialFile::ServiceAccount(sa) => {
485 assert_eq!(sa.client_email, "test@example.com");
486 }
487 _ => panic!("Expected ServiceAccount"),
488 }
489
490 let ea_json = r#"{
492 "type": "external_account",
493 "audience": "test_audience",
494 "subject_token_type": "test_type",
495 "token_url": "https://example.com/token",
496 "credential_source": {
497 "file": "/path/to/file",
498 "format": {
499 "type": "text"
500 }
501 }
502 }"#;
503 let cred = CredentialFile::from_slice(ea_json.as_bytes()).unwrap();
504 assert!(matches!(cred, CredentialFile::ExternalAccount(_)));
505
506 let aws_ea_json = r#"{
507 "type": "external_account",
508 "audience": "test_audience",
509 "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
510 "token_url": "https://example.com/token",
511 "credential_source": {
512 "environment_id": "aws1",
513 "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
514 "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials",
515 "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
516 "imdsv2_session_token_url": "http://169.254.169.254/latest/api/token"
517 }
518 }"#;
519 let cred = CredentialFile::from_slice(aws_ea_json.as_bytes()).unwrap();
520 match cred {
521 CredentialFile::ExternalAccount(external_account) => match external_account
522 .credential_source
523 {
524 external_account::Source::Aws(source) => {
525 assert_eq!(source.environment_id, "aws1");
526 assert_eq!(
527 source.region_url.as_deref(),
528 Some("http://169.254.169.254/latest/meta-data/placement/availability-zone")
529 );
530 assert_eq!(
531 source.url.as_deref(),
532 Some("http://169.254.169.254/latest/meta-data/iam/security-credentials")
533 );
534 assert_eq!(
535 source.regional_cred_verification_url,
536 "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
537 );
538 assert_eq!(
539 source.imdsv2_session_token_url.as_deref(),
540 Some("http://169.254.169.254/latest/api/token")
541 );
542 }
543 _ => panic!("Expected Aws source"),
544 },
545 _ => panic!("Expected ExternalAccount"),
546 }
547
548 let exec_ea_json = r#"{
549 "type": "external_account",
550 "audience": "test_audience",
551 "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
552 "token_url": "https://example.com/token",
553 "credential_source": {
554 "executable": {
555 "command": "/usr/bin/fetch-token --flag",
556 "timeout_millis": 5000,
557 "output_file": "/tmp/token-cache.json"
558 }
559 }
560 }"#;
561 let cred = CredentialFile::from_slice(exec_ea_json.as_bytes()).unwrap();
562 match cred {
563 CredentialFile::ExternalAccount(external_account) => {
564 match external_account.credential_source {
565 external_account::Source::Executable(source) => {
566 assert_eq!(source.executable.command, "/usr/bin/fetch-token --flag");
567 assert_eq!(source.executable.timeout_millis, Some(5000));
568 assert_eq!(
569 source.executable.output_file.as_deref(),
570 Some("/tmp/token-cache.json")
571 );
572 }
573 _ => panic!("Expected Executable source"),
574 }
575 }
576 _ => panic!("Expected ExternalAccount"),
577 }
578
579 let au_json = r#"{
581 "type": "authorized_user",
582 "client_id": "test_id",
583 "client_secret": "test_secret",
584 "refresh_token": "test_token"
585 }"#;
586 let cred = CredentialFile::from_slice(au_json.as_bytes()).unwrap();
587 match cred {
588 CredentialFile::AuthorizedUser(oauth2) => {
589 assert_eq!(oauth2.client_id, "test_id");
590 assert_eq!(oauth2.client_secret, "test_secret");
591 assert_eq!(oauth2.refresh_token, "test_token");
592 }
593 _ => panic!("Expected AuthorizedUser"),
594 }
595 }
596
597 #[test]
598 fn test_credential_is_valid() {
599 let cred = Credential::with_service_account(ServiceAccount {
601 client_email: "test@example.com".to_string(),
602 private_key: "key".to_string(),
603 });
604 assert!(cred.is_valid());
605 assert!(cred.has_service_account());
606 assert!(!cred.has_token());
607
608 let cred = Credential::with_service_account(ServiceAccount {
610 client_email: String::new(),
611 private_key: "key".to_string(),
612 });
613 assert!(!cred.is_valid());
614 assert!(!cred.is_valid_at(Timestamp::now()));
615
616 let cred = Credential::with_token(Token {
618 access_token: "test".to_string(),
619 expires_at: Some(Timestamp::now() + Duration::from_secs(3600)),
620 });
621 assert!(cred.is_valid());
622 assert!(!cred.has_service_account());
623 assert!(cred.has_token());
624 assert!(cred.has_valid_token());
625 assert!(cred.signer_email.is_none());
626
627 let cred = cred.with_signer_email("signer@example.com");
628 assert_eq!(cred.signer_email.as_deref(), Some("signer@example.com"));
629
630 let cred = Credential::with_token(Token {
632 access_token: String::new(),
633 expires_at: None,
634 });
635 assert!(!cred.is_valid());
636 assert!(!cred.has_valid_token());
637
638 let mut cred = Credential::with_service_account(ServiceAccount {
640 client_email: "test@example.com".to_string(),
641 private_key: "key".to_string(),
642 });
643 cred.token = Some(Token {
644 access_token: "test".to_string(),
645 expires_at: Some(Timestamp::now() + Duration::from_secs(3600)),
646 });
647 assert!(cred.is_valid());
648 assert!(cred.has_service_account());
649 assert!(cred.has_valid_token());
650
651 let mut cred = Credential::with_service_account(ServiceAccount {
653 client_email: "test@example.com".to_string(),
654 private_key: "key".to_string(),
655 });
656 cred.token = Some(Token {
657 access_token: "test".to_string(),
658 expires_at: Some(Timestamp::now() - Duration::from_secs(3600)),
659 });
660 assert!(cred.is_valid()); assert!(!cred.has_valid_token());
662 }
663}