Skip to main content

reqsign_google/
credential.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use reqsign_core::{Result, SigningCredential as KeyTrait, time::Timestamp, utils::Redact};
19use std::fmt::{self, Debug};
20use std::time::Duration;
21
22/// ServiceAccount holds the client email and private key for service account authentication.
23#[derive(Clone, serde::Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub struct ServiceAccount {
26    /// Private key of credential
27    pub private_key: String,
28    /// The client email of credential
29    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/// ImpersonatedServiceAccount holds the source credentials for impersonation.
42#[derive(Clone, serde::Deserialize, Debug)]
43#[serde(rename_all = "snake_case")]
44pub struct ImpersonatedServiceAccount {
45    /// The URL to obtain the access token for the impersonated service account.
46    pub service_account_impersonation_url: String,
47    /// The underlying OAuth2 credentials.
48    pub source_credentials: OAuth2Credentials,
49    /// Optional delegates for the impersonation.
50    #[serde(default)]
51    pub delegates: Vec<String>,
52}
53
54/// OAuth2 user credentials (for authorized users and impersonation sources).
55#[derive(Clone, serde::Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub struct OAuth2Credentials {
58    /// The client ID.
59    pub client_id: String,
60    /// The client secret.
61    pub client_secret: String,
62    /// The refresh token.
63    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/// External account for workload identity federation.
77#[derive(Clone, serde::Deserialize, Debug)]
78#[serde(rename_all = "snake_case")]
79pub struct ExternalAccount {
80    /// The audience for the external account.
81    pub audience: String,
82    /// The subject token type.
83    pub subject_token_type: String,
84    /// The token URL to exchange tokens.
85    pub token_url: String,
86    /// The credential source.
87    pub credential_source: external_account::Source,
88    /// Optional service account impersonation URL.
89    pub service_account_impersonation_url: Option<String>,
90    /// Optional service account impersonation options.
91    pub service_account_impersonation: Option<external_account::ImpersonationOptions>,
92}
93
94/// External account specific types.
95pub mod external_account {
96    use reqsign_core::Result;
97    use serde::Deserialize;
98
99    /// Where to obtain the external account credentials from.
100    #[derive(Clone, Deserialize, Debug)]
101    #[serde(untagged)]
102    pub enum Source {
103        /// AWS provider-specific credential source.
104        #[serde(rename_all = "snake_case")]
105        Aws(AwsSource),
106        /// URL-based credential source.
107        #[serde(rename_all = "snake_case")]
108        Url(UrlSource),
109        /// File-based credential source.
110        #[serde(rename_all = "snake_case")]
111        File(FileSource),
112        /// Executable-based credential source.
113        #[serde(rename_all = "snake_case")]
114        Executable(ExecutableSource),
115    }
116
117    /// Configuration for fetching credentials from a URL.
118    #[derive(Clone, Deserialize, Debug)]
119    #[serde(rename_all = "snake_case")]
120    pub struct UrlSource {
121        /// The URL to fetch credentials from.
122        pub url: String,
123        /// The format of the response.
124        pub format: Format,
125        /// Optional headers to include in the request.
126        pub headers: Option<std::collections::HashMap<String, String>>,
127    }
128
129    /// Configuration for reading credentials from a file.
130    #[derive(Clone, Deserialize, Debug)]
131    #[serde(rename_all = "snake_case")]
132    pub struct FileSource {
133        /// The file path to read credentials from.
134        pub file: String,
135        /// The format of the file.
136        pub format: Format,
137    }
138
139    /// Configuration for AWS provider-specific workload identity federation.
140    #[derive(Clone, Deserialize, Debug)]
141    #[serde(rename_all = "snake_case")]
142    pub struct AwsSource {
143        /// The environment identifier, currently `aws1`.
144        pub environment_id: String,
145        /// Metadata URL used to derive the region when env vars are absent.
146        pub region_url: Option<String>,
147        /// Metadata URL used to retrieve the role name and credentials.
148        pub url: Option<String>,
149        /// Regional GetCallerIdentity verification URL template.
150        pub regional_cred_verification_url: String,
151        /// Optional IMDSv2 token URL.
152        pub imdsv2_session_token_url: Option<String>,
153    }
154
155    /// Configuration for executing a command to load credentials.
156    #[derive(Clone, Deserialize, Debug)]
157    #[serde(rename_all = "snake_case")]
158    pub struct ExecutableSource {
159        /// The executable configuration.
160        pub executable: ExecutableConfig,
161    }
162
163    /// Executable-based credential configuration.
164    #[derive(Clone, Deserialize, Debug)]
165    #[serde(rename_all = "snake_case")]
166    pub struct ExecutableConfig {
167        /// The full command to run.
168        pub command: String,
169        /// Optional timeout in milliseconds.
170        pub timeout_millis: Option<u64>,
171        /// Optional output file used to cache the executable response.
172        pub output_file: Option<String>,
173    }
174
175    /// Format for parsing credentials.
176    #[derive(Clone, Deserialize, Debug)]
177    #[serde(tag = "type", rename_all = "snake_case")]
178    pub enum Format {
179        /// JSON format.
180        Json {
181            /// The JSON path to extract the subject token.
182            subject_token_field_name: String,
183        },
184        /// Plain text format.
185        Text,
186    }
187
188    impl Format {
189        /// Parse a slice of bytes as the expected format.
190        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    /// Service account impersonation options.
213    #[derive(Clone, Deserialize, Debug)]
214    #[serde(rename_all = "snake_case")]
215    pub struct ImpersonationOptions {
216        /// The lifetime in seconds for the impersonated token.
217        pub token_lifetime_seconds: Option<usize>,
218    }
219}
220
221/// Token represents an OAuth2 access token with expiration.
222#[derive(Clone, Default)]
223pub struct Token {
224    /// The access token.
225    pub access_token: String,
226    /// The expiration time of the token.
227    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                // Consider token invalid if it expires within 2 minutes
248                let buffer = Duration::from_secs(120);
249                Timestamp::now() < expires_at - buffer
250            }
251            None => true, // No expiration means always valid
252        }
253    }
254}
255
256/// Credential represents Google credentials that may contain both service account and token.
257///
258/// **IMPORTANT**: This is a specially designed structure that can hold both ServiceAccount
259/// and Token simultaneously. This design is intentional and critical for Google's authentication:
260///
261/// - Service account only: Used for signed URL generation and JWT-based authentication
262/// - Token only: Used for Bearer authentication (e.g., from metadata server, OAuth2)  
263/// - Both: The RequestSigner is responsible for exchanging service account for tokens when needed,
264///   and can use cached tokens when available to avoid unnecessary exchanges
265///
266/// The RequestSigner implementation handles the logic of when to use which credential type
267/// and when to perform token exchanges. Providers should return credentials as they receive them
268/// without trying to perform exchanges themselves.
269#[derive(Clone, Debug, Default)]
270pub struct Credential {
271    /// Service account information, if available.
272    pub service_account: Option<ServiceAccount>,
273    /// OAuth2 access token, if available.
274    pub token: Option<Token>,
275}
276
277impl Credential {
278    /// Create a credential with only a service account.
279    pub fn with_service_account(service_account: ServiceAccount) -> Self {
280        Self {
281            service_account: Some(service_account),
282            token: None,
283        }
284    }
285
286    /// Create a credential with only a token.
287    pub fn with_token(token: Token) -> Self {
288        Self {
289            service_account: None,
290            token: Some(token),
291        }
292    }
293
294    /// Check if the credential has a service account.
295    pub fn has_service_account(&self) -> bool {
296        self.service_account.is_some()
297    }
298
299    /// Check if the credential has a token.
300    pub fn has_token(&self) -> bool {
301        self.token.is_some()
302    }
303
304    /// Check if the credential has a valid token.
305    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        // A credential is valid if it has a service account or a valid token
313        self.service_account.is_some() || self.has_valid_token()
314    }
315}
316
317/// CredentialFile represents the different types of Google credential files.
318#[derive(Clone, Debug, serde::Deserialize)]
319#[serde(tag = "type", rename_all = "snake_case")]
320pub enum CredentialFile {
321    /// Service account with private key.
322    ServiceAccount(ServiceAccount),
323    /// External account for workload identity federation.
324    ExternalAccount(ExternalAccount),
325    /// Impersonated service account.
326    ImpersonatedServiceAccount(ImpersonatedServiceAccount),
327    /// OAuth2 authorized user credentials.
328    AuthorizedUser(OAuth2Credentials),
329}
330
331impl CredentialFile {
332    /// Parse credential file from bytes.
333    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 with future expiration
381        token.expires_at = Some(Timestamp::now() + Duration::from_secs(3600));
382        assert!(token.is_valid());
383
384        // Token that expires within 2 minutes
385        token.expires_at = Some(Timestamp::now() + Duration::from_secs(30));
386        assert!(!token.is_valid());
387
388        // Expired token
389        token.expires_at = Some(Timestamp::now() - Duration::from_secs(3600));
390        assert!(!token.is_valid());
391
392        // Empty access token
393        token.access_token = String::new();
394        assert!(!token.is_valid());
395    }
396
397    #[test]
398    fn test_credential_file_deserialize() {
399        // Test service account
400        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        // Test external account
414        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        // Test authorized user
503        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        // Service account only
523        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        // Valid token only
532        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        // Invalid token only
542        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        // Both service account and valid token
550        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        // Service account with expired token
563        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()); // Still valid because of service account
572        assert!(!cred.has_valid_token());
573    }
574}