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
22const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(120);
23
24/// ServiceAccount holds the client email and private key for service account authentication.
25#[derive(Clone, serde::Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub struct ServiceAccount {
28    /// Private key of credential
29    pub private_key: String,
30    /// The client email of credential
31    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/// ImpersonatedServiceAccount holds the source credentials for impersonation.
50#[derive(Clone, serde::Deserialize, Debug)]
51#[serde(rename_all = "snake_case")]
52pub struct ImpersonatedServiceAccount {
53    /// The URL to obtain the access token for the impersonated service account.
54    pub service_account_impersonation_url: String,
55    /// The underlying OAuth2 credentials.
56    pub source_credentials: OAuth2Credentials,
57    /// Optional delegates for the impersonation.
58    #[serde(default)]
59    pub delegates: Vec<String>,
60}
61
62/// OAuth2 user credentials (for authorized users and impersonation sources).
63#[derive(Clone, serde::Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub struct OAuth2Credentials {
66    /// The client ID.
67    pub client_id: String,
68    /// The client secret.
69    pub client_secret: String,
70    /// The refresh token.
71    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/// External account for workload identity federation.
85#[derive(Clone, serde::Deserialize, Debug)]
86#[serde(rename_all = "snake_case")]
87pub struct ExternalAccount {
88    /// The audience for the external account.
89    pub audience: String,
90    /// The subject token type.
91    pub subject_token_type: String,
92    /// The token URL to exchange tokens.
93    pub token_url: String,
94    /// The credential source.
95    pub credential_source: external_account::Source,
96    /// Optional service account impersonation URL.
97    pub service_account_impersonation_url: Option<String>,
98    /// Optional service account impersonation options.
99    pub service_account_impersonation: Option<external_account::ImpersonationOptions>,
100}
101
102/// External account specific types.
103pub mod external_account {
104    use reqsign_core::Result;
105    use serde::Deserialize;
106
107    /// Where to obtain the external account credentials from.
108    #[derive(Clone, Deserialize, Debug)]
109    #[serde(untagged)]
110    pub enum Source {
111        /// AWS provider-specific credential source.
112        #[serde(rename_all = "snake_case")]
113        Aws(AwsSource),
114        /// URL-based credential source.
115        #[serde(rename_all = "snake_case")]
116        Url(UrlSource),
117        /// File-based credential source.
118        #[serde(rename_all = "snake_case")]
119        File(FileSource),
120        /// Executable-based credential source.
121        #[serde(rename_all = "snake_case")]
122        Executable(ExecutableSource),
123    }
124
125    /// Configuration for fetching credentials from a URL.
126    #[derive(Clone, Deserialize, Debug)]
127    #[serde(rename_all = "snake_case")]
128    pub struct UrlSource {
129        /// The URL to fetch credentials from.
130        pub url: String,
131        /// The format of the response.
132        pub format: Format,
133        /// Optional headers to include in the request.
134        pub headers: Option<std::collections::HashMap<String, String>>,
135    }
136
137    /// Configuration for reading credentials from a file.
138    #[derive(Clone, Deserialize, Debug)]
139    #[serde(rename_all = "snake_case")]
140    pub struct FileSource {
141        /// The file path to read credentials from.
142        pub file: String,
143        /// The format of the file.
144        pub format: Format,
145    }
146
147    /// Configuration for AWS provider-specific workload identity federation.
148    #[derive(Clone, Deserialize, Debug)]
149    #[serde(rename_all = "snake_case")]
150    pub struct AwsSource {
151        /// The environment identifier, currently `aws1`.
152        pub environment_id: String,
153        /// Metadata URL used to derive the region when env vars are absent.
154        pub region_url: Option<String>,
155        /// Metadata URL used to retrieve the role name and credentials.
156        pub url: Option<String>,
157        /// Regional GetCallerIdentity verification URL template.
158        pub regional_cred_verification_url: String,
159        /// Optional IMDSv2 token URL.
160        pub imdsv2_session_token_url: Option<String>,
161    }
162
163    /// Configuration for executing a command to load credentials.
164    #[derive(Clone, Deserialize, Debug)]
165    #[serde(rename_all = "snake_case")]
166    pub struct ExecutableSource {
167        /// The executable configuration.
168        pub executable: ExecutableConfig,
169    }
170
171    /// Executable-based credential configuration.
172    #[derive(Clone, Deserialize, Debug)]
173    #[serde(rename_all = "snake_case")]
174    pub struct ExecutableConfig {
175        /// The full command to run.
176        pub command: String,
177        /// Optional timeout in milliseconds.
178        pub timeout_millis: Option<u64>,
179        /// Optional output file used to cache the executable response.
180        pub output_file: Option<String>,
181    }
182
183    /// Format for parsing credentials.
184    #[derive(Clone, Deserialize, Debug)]
185    #[serde(tag = "type", rename_all = "snake_case")]
186    pub enum Format {
187        /// JSON format.
188        Json {
189            /// The JSON path to extract the subject token.
190            subject_token_field_name: String,
191        },
192        /// Plain text format.
193        Text,
194    }
195
196    impl Format {
197        /// Parse a slice of bytes as the expected format.
198        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    /// Service account impersonation options.
221    #[derive(Clone, Deserialize, Debug)]
222    #[serde(rename_all = "snake_case")]
223    pub struct ImpersonationOptions {
224        /// The lifetime in seconds for the impersonated token.
225        pub token_lifetime_seconds: Option<usize>,
226    }
227}
228
229/// Token represents an OAuth2 access token with expiration.
230#[derive(Clone, Default)]
231pub struct Token {
232    /// The access token.
233    pub access_token: String,
234    /// The expiration time of the token.
235    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/// Credential represents Google credentials that may contain a service account, token, and
263/// provider-discovered signer identity.
264///
265/// **IMPORTANT**: This is a specially designed structure that can hold both ServiceAccount
266/// and Token simultaneously. This design is intentional and critical for Google's authentication:
267///
268/// - Service account only: Used for signed URL generation and JWT-based authentication
269/// - Token only: Used for Bearer authentication (e.g., from metadata server, OAuth2)
270/// - Token with signer email: Also supports query signing through IAMCredentials `signBlob`
271/// - Both: The RequestSigner is responsible for exchanging service account for tokens when needed,
272///   and can use cached tokens when available to avoid unnecessary exchanges
273///
274/// The RequestSigner implementation handles the logic of when to use which credential type
275/// and when to perform token exchanges. Discovery providers should return credentials as they
276/// receive them without trying to perform exchanges themselves. Explicit conversion providers may
277/// return a new variant, such as a token-only credential produced from a service account.
278#[derive(Clone, Debug, Default)]
279pub struct Credential {
280    /// Service account information, if available.
281    pub service_account: Option<ServiceAccount>,
282    /// OAuth2 access token, if available.
283    pub token: Option<Token>,
284    /// Service account email authorized to sign with the token, if known by the provider.
285    ///
286    /// This identity is used only for query signing and does not affect Bearer authentication.
287    pub signer_email: Option<String>,
288}
289
290impl Credential {
291    /// Create a credential with only a service account.
292    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    /// Create a credential with a token.
301    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    /// Set the service account email authorized to sign with this credential's token.
310    ///
311    /// This identity is used only for query signing and does not affect Bearer authentication.
312    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    /// Check if the credential has a service account.
318    pub fn has_service_account(&self) -> bool {
319        self.service_account.is_some()
320    }
321
322    /// Check if the credential has a token.
323    pub fn has_token(&self) -> bool {
324        self.token.is_some()
325    }
326
327    /// Check if the credential has a valid token.
328    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/// CredentialFile represents the different types of Google credential files.
384#[derive(Clone, Debug, serde::Deserialize)]
385#[serde(tag = "type", rename_all = "snake_case")]
386pub enum CredentialFile {
387    /// Service account with private key.
388    ServiceAccount(ServiceAccount),
389    /// External account for workload identity federation.
390    ExternalAccount(ExternalAccount),
391    /// Impersonated service account.
392    ImpersonatedServiceAccount(ImpersonatedServiceAccount),
393    /// OAuth2 authorized user credentials.
394    AuthorizedUser(OAuth2Credentials),
395}
396
397impl CredentialFile {
398    /// Parse credential file from bytes.
399    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 with future expiration
456        token.expires_at = Some(Timestamp::now() + Duration::from_secs(3600));
457        assert!(token.is_valid());
458
459        // Token that expires within 2 minutes
460        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        // Expired token
465        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        // Empty access token
470        token.access_token = String::new();
471        assert!(!token.is_valid());
472    }
473
474    #[test]
475    fn test_credential_file_deserialize() {
476        // Test service account
477        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        // Test external account
491        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        // Test authorized user
580        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        // Service account only
600        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        // Incomplete service account
609        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        // Valid token only
617        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        // Invalid token only
631        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        // Both service account and valid token
639        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        // Service account with expired token
652        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()); // Still valid because of service account
661        assert!(!cred.has_valid_token());
662    }
663}