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 Url(UrlSource),
106 #[serde(rename_all = "snake_case")]
108 File(FileSource),
109 }
110
111 #[derive(Clone, Deserialize, Debug)]
113 #[serde(rename_all = "snake_case")]
114 pub struct UrlSource {
115 pub url: String,
117 pub format: Format,
119 pub headers: Option<std::collections::HashMap<String, String>>,
121 }
122
123 #[derive(Clone, Deserialize, Debug)]
125 #[serde(rename_all = "snake_case")]
126 pub struct FileSource {
127 pub file: String,
129 pub format: Format,
131 }
132
133 #[derive(Clone, Deserialize, Debug)]
135 #[serde(tag = "type", rename_all = "snake_case")]
136 pub enum Format {
137 Json {
139 subject_token_field_name: String,
141 },
142 Text,
144 }
145
146 impl Format {
147 pub fn parse(&self, slice: &[u8]) -> Result<String> {
149 match &self {
150 Self::Text => Ok(String::from_utf8(slice.to_vec()).map_err(|e| {
151 reqsign_core::Error::unexpected("invalid UTF-8").with_source(e)
152 })?),
153 Self::Json {
154 subject_token_field_name,
155 } => {
156 let value: serde_json::Value = serde_json::from_slice(slice).map_err(|e| {
157 reqsign_core::Error::unexpected("failed to parse JSON").with_source(e)
158 })?;
159 match value.get(subject_token_field_name) {
160 Some(serde_json::Value::String(access_token)) => Ok(access_token.clone()),
161 _ => Err(reqsign_core::Error::unexpected(format!(
162 "JSON missing token field {subject_token_field_name}"
163 ))),
164 }
165 }
166 }
167 }
168 }
169
170 #[derive(Clone, Deserialize, Debug)]
172 #[serde(rename_all = "snake_case")]
173 pub struct ImpersonationOptions {
174 pub token_lifetime_seconds: Option<usize>,
176 }
177}
178
179#[derive(Clone, Default)]
181pub struct Token {
182 pub access_token: String,
184 pub expires_at: Option<Timestamp>,
186}
187
188impl Debug for Token {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 f.debug_struct("Token")
191 .field("access_token", &Redact::from(&self.access_token))
192 .field("expires_at", &self.expires_at)
193 .finish()
194 }
195}
196
197impl KeyTrait for Token {
198 fn is_valid(&self) -> bool {
199 if self.access_token.is_empty() {
200 return false;
201 }
202
203 match self.expires_at {
204 Some(expires_at) => {
205 let buffer = Duration::from_secs(120);
207 Timestamp::now() < expires_at - buffer
208 }
209 None => true, }
211 }
212}
213
214#[derive(Clone, Debug, Default)]
228pub struct Credential {
229 pub service_account: Option<ServiceAccount>,
231 pub token: Option<Token>,
233}
234
235impl Credential {
236 pub fn with_service_account(service_account: ServiceAccount) -> Self {
238 Self {
239 service_account: Some(service_account),
240 token: None,
241 }
242 }
243
244 pub fn with_token(token: Token) -> Self {
246 Self {
247 service_account: None,
248 token: Some(token),
249 }
250 }
251
252 pub fn has_service_account(&self) -> bool {
254 self.service_account.is_some()
255 }
256
257 pub fn has_token(&self) -> bool {
259 self.token.is_some()
260 }
261
262 pub fn has_valid_token(&self) -> bool {
264 self.token.as_ref().is_some_and(|t| t.is_valid())
265 }
266}
267
268impl KeyTrait for Credential {
269 fn is_valid(&self) -> bool {
270 self.service_account.is_some() || self.has_valid_token()
272 }
273}
274
275#[derive(Clone, Debug, serde::Deserialize)]
277#[serde(tag = "type", rename_all = "snake_case")]
278pub enum CredentialFile {
279 ServiceAccount(ServiceAccount),
281 ExternalAccount(ExternalAccount),
283 ImpersonatedServiceAccount(ImpersonatedServiceAccount),
285 AuthorizedUser(OAuth2Credentials),
287}
288
289impl CredentialFile {
290 pub fn from_slice(v: &[u8]) -> Result<Self> {
292 serde_json::from_slice(v).map_err(|e| {
293 reqsign_core::Error::unexpected("failed to parse credential file").with_source(e)
294 })
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn test_external_account_format_parse_text() {
304 let format = external_account::Format::Text;
305 let data = b"test-token";
306 let result = format.parse(data).unwrap();
307 assert_eq!("test-token", result);
308 }
309
310 #[test]
311 fn test_external_account_format_parse_json() {
312 let format = external_account::Format::Json {
313 subject_token_field_name: "access_token".to_string(),
314 };
315 let data = br#"{"access_token": "test-token", "expires_in": 3600}"#;
316 let result = format.parse(data).unwrap();
317 assert_eq!("test-token", result);
318 }
319
320 #[test]
321 fn test_external_account_format_parse_json_missing_field() {
322 let format = external_account::Format::Json {
323 subject_token_field_name: "access_token".to_string(),
324 };
325 let data = br#"{"wrong_field": "test-token"}"#;
326 let result = format.parse(data);
327 assert!(result.is_err());
328 }
329
330 #[test]
331 fn test_token_is_valid() {
332 let mut token = Token {
333 access_token: "test".to_string(),
334 expires_at: None,
335 };
336 assert!(token.is_valid());
337
338 token.expires_at = Some(Timestamp::now() + Duration::from_secs(3600));
340 assert!(token.is_valid());
341
342 token.expires_at = Some(Timestamp::now() + Duration::from_secs(30));
344 assert!(!token.is_valid());
345
346 token.expires_at = Some(Timestamp::now() - Duration::from_secs(3600));
348 assert!(!token.is_valid());
349
350 token.access_token = String::new();
352 assert!(!token.is_valid());
353 }
354
355 #[test]
356 fn test_credential_file_deserialize() {
357 let sa_json = r#"{
359 "type": "service_account",
360 "private_key": "test_key",
361 "client_email": "test@example.com"
362 }"#;
363 let cred = CredentialFile::from_slice(sa_json.as_bytes()).unwrap();
364 match cred {
365 CredentialFile::ServiceAccount(sa) => {
366 assert_eq!(sa.client_email, "test@example.com");
367 }
368 _ => panic!("Expected ServiceAccount"),
369 }
370
371 let ea_json = r#"{
373 "type": "external_account",
374 "audience": "test_audience",
375 "subject_token_type": "test_type",
376 "token_url": "https://example.com/token",
377 "credential_source": {
378 "file": "/path/to/file",
379 "format": {
380 "type": "text"
381 }
382 }
383 }"#;
384 let cred = CredentialFile::from_slice(ea_json.as_bytes()).unwrap();
385 assert!(matches!(cred, CredentialFile::ExternalAccount(_)));
386
387 let au_json = r#"{
389 "type": "authorized_user",
390 "client_id": "test_id",
391 "client_secret": "test_secret",
392 "refresh_token": "test_token"
393 }"#;
394 let cred = CredentialFile::from_slice(au_json.as_bytes()).unwrap();
395 match cred {
396 CredentialFile::AuthorizedUser(oauth2) => {
397 assert_eq!(oauth2.client_id, "test_id");
398 assert_eq!(oauth2.client_secret, "test_secret");
399 assert_eq!(oauth2.refresh_token, "test_token");
400 }
401 _ => panic!("Expected AuthorizedUser"),
402 }
403 }
404
405 #[test]
406 fn test_credential_is_valid() {
407 let cred = Credential::with_service_account(ServiceAccount {
409 client_email: "test@example.com".to_string(),
410 private_key: "key".to_string(),
411 });
412 assert!(cred.is_valid());
413 assert!(cred.has_service_account());
414 assert!(!cred.has_token());
415
416 let cred = Credential::with_token(Token {
418 access_token: "test".to_string(),
419 expires_at: Some(Timestamp::now() + Duration::from_secs(3600)),
420 });
421 assert!(cred.is_valid());
422 assert!(!cred.has_service_account());
423 assert!(cred.has_token());
424 assert!(cred.has_valid_token());
425
426 let cred = Credential::with_token(Token {
428 access_token: String::new(),
429 expires_at: None,
430 });
431 assert!(!cred.is_valid());
432 assert!(!cred.has_valid_token());
433
434 let mut cred = Credential::with_service_account(ServiceAccount {
436 client_email: "test@example.com".to_string(),
437 private_key: "key".to_string(),
438 });
439 cred.token = Some(Token {
440 access_token: "test".to_string(),
441 expires_at: Some(Timestamp::now() + Duration::from_secs(3600)),
442 });
443 assert!(cred.is_valid());
444 assert!(cred.has_service_account());
445 assert!(cred.has_valid_token());
446
447 let mut cred = Credential::with_service_account(ServiceAccount {
449 client_email: "test@example.com".to_string(),
450 private_key: "key".to_string(),
451 });
452 cred.token = Some(Token {
453 access_token: "test".to_string(),
454 expires_at: Some(Timestamp::now() - Duration::from_secs(3600)),
455 });
456 assert!(cred.is_valid()); assert!(!cred.has_valid_token());
458 }
459}