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