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 both service account and token.
263///
264/// **IMPORTANT**: This is a specially designed structure that can hold both ServiceAccount
265/// and Token simultaneously. This design is intentional and critical for Google's authentication:
266///
267/// - Service account only: Used for signed URL generation and JWT-based authentication
268/// - Token only: Used for Bearer authentication (e.g., from metadata server, OAuth2)  
269/// - Both: The RequestSigner is responsible for exchanging service account for tokens when needed,
270///   and can use cached tokens when available to avoid unnecessary exchanges
271///
272/// The RequestSigner implementation handles the logic of when to use which credential type
273/// and when to perform token exchanges. Providers should return credentials as they receive them
274/// without trying to perform exchanges themselves.
275#[derive(Clone, Debug, Default)]
276pub struct Credential {
277    /// Service account information, if available.
278    pub service_account: Option<ServiceAccount>,
279    /// OAuth2 access token, if available.
280    pub token: Option<Token>,
281}
282
283impl Credential {
284    /// Create a credential with only a service account.
285    pub fn with_service_account(service_account: ServiceAccount) -> Self {
286        Self {
287            service_account: Some(service_account),
288            token: None,
289        }
290    }
291
292    /// Create a credential with only a token.
293    pub fn with_token(token: Token) -> Self {
294        Self {
295            service_account: None,
296            token: Some(token),
297        }
298    }
299
300    /// Check if the credential has a service account.
301    pub fn has_service_account(&self) -> bool {
302        self.service_account.is_some()
303    }
304
305    /// Check if the credential has a token.
306    pub fn has_token(&self) -> bool {
307        self.token.is_some()
308    }
309
310    /// Check if the credential has a valid token.
311    pub fn has_valid_token(&self) -> bool {
312        self.token.as_ref().is_some_and(|t| t.is_valid())
313    }
314}
315
316impl KeyTrait for Credential {
317    fn is_valid(&self) -> bool {
318        self.service_account
319            .as_ref()
320            .is_some_and(ServiceAccount::is_valid)
321            || self.has_valid_token()
322    }
323
324    fn is_valid_at(&self, timestamp: Timestamp) -> bool {
325        self.service_account
326            .as_ref()
327            .is_some_and(ServiceAccount::is_valid)
328            || self
329                .token
330                .as_ref()
331                .is_some_and(|token| token.is_valid_at(timestamp))
332    }
333}
334
335/// CredentialFile represents the different types of Google credential files.
336#[derive(Clone, Debug, serde::Deserialize)]
337#[serde(tag = "type", rename_all = "snake_case")]
338pub enum CredentialFile {
339    /// Service account with private key.
340    ServiceAccount(ServiceAccount),
341    /// External account for workload identity federation.
342    ExternalAccount(ExternalAccount),
343    /// Impersonated service account.
344    ImpersonatedServiceAccount(ImpersonatedServiceAccount),
345    /// OAuth2 authorized user credentials.
346    AuthorizedUser(OAuth2Credentials),
347}
348
349impl CredentialFile {
350    /// Parse credential file from bytes.
351    pub fn from_slice(v: &[u8]) -> Result<Self> {
352        serde_json::from_slice(v).map_err(|e| {
353            reqsign_core::Error::unexpected("failed to parse credential file").with_source(e)
354        })
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_external_account_format_parse_text() {
364        let format = external_account::Format::Text;
365        let data = b"test-token";
366        let result = format.parse(data).unwrap();
367        assert_eq!("test-token", result);
368    }
369
370    #[test]
371    fn test_external_account_format_parse_json() {
372        let format = external_account::Format::Json {
373            subject_token_field_name: "access_token".to_string(),
374        };
375        let data = br#"{"access_token": "test-token", "expires_in": 3600}"#;
376        let result = format.parse(data).unwrap();
377        assert_eq!("test-token", result);
378    }
379
380    #[test]
381    fn test_external_account_format_parse_json_missing_field() {
382        let format = external_account::Format::Json {
383            subject_token_field_name: "access_token".to_string(),
384        };
385        let data = br#"{"wrong_field": "test-token"}"#;
386        let result = format.parse(data);
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn test_token_is_valid() {
392        let mut token = Token {
393            access_token: "test".to_string(),
394            expires_at: None,
395        };
396        assert!(token.is_valid());
397
398        // Token with future expiration
399        token.expires_at = Some(Timestamp::now() + Duration::from_secs(3600));
400        assert!(token.is_valid());
401
402        // Token that expires within 2 minutes
403        token.expires_at = Some(Timestamp::now() + Duration::from_secs(30));
404        assert!(!token.is_valid());
405        assert!(token.is_valid_at(Timestamp::now() + Duration::from_secs(10)));
406
407        // Expired token
408        token.expires_at = Some(Timestamp::now() - Duration::from_secs(3600));
409        assert!(!token.is_valid());
410        assert!(!token.is_valid_at(Timestamp::now()));
411
412        // Empty access token
413        token.access_token = String::new();
414        assert!(!token.is_valid());
415    }
416
417    #[test]
418    fn test_credential_file_deserialize() {
419        // Test service account
420        let sa_json = r#"{
421            "type": "service_account",
422            "private_key": "test_key",
423            "client_email": "test@example.com"
424        }"#;
425        let cred = CredentialFile::from_slice(sa_json.as_bytes()).unwrap();
426        match cred {
427            CredentialFile::ServiceAccount(sa) => {
428                assert_eq!(sa.client_email, "test@example.com");
429            }
430            _ => panic!("Expected ServiceAccount"),
431        }
432
433        // Test external account
434        let ea_json = r#"{
435            "type": "external_account",
436            "audience": "test_audience",
437            "subject_token_type": "test_type",
438            "token_url": "https://example.com/token",
439            "credential_source": {
440                "file": "/path/to/file",
441                "format": {
442                    "type": "text"
443                }
444            }
445        }"#;
446        let cred = CredentialFile::from_slice(ea_json.as_bytes()).unwrap();
447        assert!(matches!(cred, CredentialFile::ExternalAccount(_)));
448
449        let aws_ea_json = r#"{
450            "type": "external_account",
451            "audience": "test_audience",
452            "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
453            "token_url": "https://example.com/token",
454            "credential_source": {
455                "environment_id": "aws1",
456                "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
457                "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials",
458                "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
459                "imdsv2_session_token_url": "http://169.254.169.254/latest/api/token"
460            }
461        }"#;
462        let cred = CredentialFile::from_slice(aws_ea_json.as_bytes()).unwrap();
463        match cred {
464            CredentialFile::ExternalAccount(external_account) => match external_account
465                .credential_source
466            {
467                external_account::Source::Aws(source) => {
468                    assert_eq!(source.environment_id, "aws1");
469                    assert_eq!(
470                        source.region_url.as_deref(),
471                        Some("http://169.254.169.254/latest/meta-data/placement/availability-zone")
472                    );
473                    assert_eq!(
474                        source.url.as_deref(),
475                        Some("http://169.254.169.254/latest/meta-data/iam/security-credentials")
476                    );
477                    assert_eq!(
478                        source.regional_cred_verification_url,
479                        "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
480                    );
481                    assert_eq!(
482                        source.imdsv2_session_token_url.as_deref(),
483                        Some("http://169.254.169.254/latest/api/token")
484                    );
485                }
486                _ => panic!("Expected Aws source"),
487            },
488            _ => panic!("Expected ExternalAccount"),
489        }
490
491        let exec_ea_json = r#"{
492            "type": "external_account",
493            "audience": "test_audience",
494            "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
495            "token_url": "https://example.com/token",
496            "credential_source": {
497                "executable": {
498                    "command": "/usr/bin/fetch-token --flag",
499                    "timeout_millis": 5000,
500                    "output_file": "/tmp/token-cache.json"
501                }
502            }
503        }"#;
504        let cred = CredentialFile::from_slice(exec_ea_json.as_bytes()).unwrap();
505        match cred {
506            CredentialFile::ExternalAccount(external_account) => {
507                match external_account.credential_source {
508                    external_account::Source::Executable(source) => {
509                        assert_eq!(source.executable.command, "/usr/bin/fetch-token --flag");
510                        assert_eq!(source.executable.timeout_millis, Some(5000));
511                        assert_eq!(
512                            source.executable.output_file.as_deref(),
513                            Some("/tmp/token-cache.json")
514                        );
515                    }
516                    _ => panic!("Expected Executable source"),
517                }
518            }
519            _ => panic!("Expected ExternalAccount"),
520        }
521
522        // Test authorized user
523        let au_json = r#"{
524            "type": "authorized_user",
525            "client_id": "test_id",
526            "client_secret": "test_secret",
527            "refresh_token": "test_token"
528        }"#;
529        let cred = CredentialFile::from_slice(au_json.as_bytes()).unwrap();
530        match cred {
531            CredentialFile::AuthorizedUser(oauth2) => {
532                assert_eq!(oauth2.client_id, "test_id");
533                assert_eq!(oauth2.client_secret, "test_secret");
534                assert_eq!(oauth2.refresh_token, "test_token");
535            }
536            _ => panic!("Expected AuthorizedUser"),
537        }
538    }
539
540    #[test]
541    fn test_credential_is_valid() {
542        // Service account only
543        let cred = Credential::with_service_account(ServiceAccount {
544            client_email: "test@example.com".to_string(),
545            private_key: "key".to_string(),
546        });
547        assert!(cred.is_valid());
548        assert!(cred.has_service_account());
549        assert!(!cred.has_token());
550
551        // Incomplete service account
552        let cred = Credential::with_service_account(ServiceAccount {
553            client_email: String::new(),
554            private_key: "key".to_string(),
555        });
556        assert!(!cred.is_valid());
557        assert!(!cred.is_valid_at(Timestamp::now()));
558
559        // Valid token only
560        let cred = Credential::with_token(Token {
561            access_token: "test".to_string(),
562            expires_at: Some(Timestamp::now() + Duration::from_secs(3600)),
563        });
564        assert!(cred.is_valid());
565        assert!(!cred.has_service_account());
566        assert!(cred.has_token());
567        assert!(cred.has_valid_token());
568
569        // Invalid token only
570        let cred = Credential::with_token(Token {
571            access_token: String::new(),
572            expires_at: None,
573        });
574        assert!(!cred.is_valid());
575        assert!(!cred.has_valid_token());
576
577        // Both service account and valid token
578        let mut cred = Credential::with_service_account(ServiceAccount {
579            client_email: "test@example.com".to_string(),
580            private_key: "key".to_string(),
581        });
582        cred.token = Some(Token {
583            access_token: "test".to_string(),
584            expires_at: Some(Timestamp::now() + Duration::from_secs(3600)),
585        });
586        assert!(cred.is_valid());
587        assert!(cred.has_service_account());
588        assert!(cred.has_valid_token());
589
590        // Service account with expired token
591        let mut cred = Credential::with_service_account(ServiceAccount {
592            client_email: "test@example.com".to_string(),
593            private_key: "key".to_string(),
594        });
595        cred.token = Some(Token {
596            access_token: "test".to_string(),
597            expires_at: Some(Timestamp::now() - Duration::from_secs(3600)),
598        });
599        assert!(cred.is_valid()); // Still valid because of service account
600        assert!(!cred.has_valid_token());
601    }
602}