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        /// URL-based credential source.
104        #[serde(rename_all = "snake_case")]
105        Url(UrlSource),
106        /// File-based credential source.
107        #[serde(rename_all = "snake_case")]
108        File(FileSource),
109    }
110
111    /// Configuration for fetching credentials from a URL.
112    #[derive(Clone, Deserialize, Debug)]
113    #[serde(rename_all = "snake_case")]
114    pub struct UrlSource {
115        /// The URL to fetch credentials from.
116        pub url: String,
117        /// The format of the response.
118        pub format: Format,
119        /// Optional headers to include in the request.
120        pub headers: Option<std::collections::HashMap<String, String>>,
121    }
122
123    /// Configuration for reading credentials from a file.
124    #[derive(Clone, Deserialize, Debug)]
125    #[serde(rename_all = "snake_case")]
126    pub struct FileSource {
127        /// The file path to read credentials from.
128        pub file: String,
129        /// The format of the file.
130        pub format: Format,
131    }
132
133    /// Format for parsing credentials.
134    #[derive(Clone, Deserialize, Debug)]
135    #[serde(tag = "type", rename_all = "snake_case")]
136    pub enum Format {
137        /// JSON format.
138        Json {
139            /// The JSON path to extract the subject token.
140            subject_token_field_name: String,
141        },
142        /// Plain text format.
143        Text,
144    }
145
146    impl Format {
147        /// Parse a slice of bytes as the expected format.
148        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    /// Service account impersonation options.
171    #[derive(Clone, Deserialize, Debug)]
172    #[serde(rename_all = "snake_case")]
173    pub struct ImpersonationOptions {
174        /// The lifetime in seconds for the impersonated token.
175        pub token_lifetime_seconds: Option<usize>,
176    }
177}
178
179/// Token represents an OAuth2 access token with expiration.
180#[derive(Clone, Default)]
181pub struct Token {
182    /// The access token.
183    pub access_token: String,
184    /// The expiration time of the token.
185    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                // Consider token invalid if it expires within 2 minutes
206                let buffer = Duration::from_secs(120);
207                Timestamp::now() < expires_at - buffer
208            }
209            None => true, // No expiration means always valid
210        }
211    }
212}
213
214/// Credential represents Google credentials that may contain both service account and token.
215///
216/// **IMPORTANT**: This is a specially designed structure that can hold both ServiceAccount
217/// and Token simultaneously. This design is intentional and critical for Google's authentication:
218///
219/// - Service account only: Used for signed URL generation and JWT-based authentication
220/// - Token only: Used for Bearer authentication (e.g., from metadata server, OAuth2)  
221/// - Both: The RequestSigner is responsible for exchanging service account for tokens when needed,
222///   and can use cached tokens when available to avoid unnecessary exchanges
223///
224/// The RequestSigner implementation handles the logic of when to use which credential type
225/// and when to perform token exchanges. Providers should return credentials as they receive them
226/// without trying to perform exchanges themselves.
227#[derive(Clone, Debug, Default)]
228pub struct Credential {
229    /// Service account information, if available.
230    pub service_account: Option<ServiceAccount>,
231    /// OAuth2 access token, if available.
232    pub token: Option<Token>,
233}
234
235impl Credential {
236    /// Create a credential with only a service account.
237    pub fn with_service_account(service_account: ServiceAccount) -> Self {
238        Self {
239            service_account: Some(service_account),
240            token: None,
241        }
242    }
243
244    /// Create a credential with only a token.
245    pub fn with_token(token: Token) -> Self {
246        Self {
247            service_account: None,
248            token: Some(token),
249        }
250    }
251
252    /// Check if the credential has a service account.
253    pub fn has_service_account(&self) -> bool {
254        self.service_account.is_some()
255    }
256
257    /// Check if the credential has a token.
258    pub fn has_token(&self) -> bool {
259        self.token.is_some()
260    }
261
262    /// Check if the credential has a valid token.
263    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        // A credential is valid if it has a service account or a valid token
271        self.service_account.is_some() || self.has_valid_token()
272    }
273}
274
275/// CredentialFile represents the different types of Google credential files.
276#[derive(Clone, Debug, serde::Deserialize)]
277#[serde(tag = "type", rename_all = "snake_case")]
278pub enum CredentialFile {
279    /// Service account with private key.
280    ServiceAccount(ServiceAccount),
281    /// External account for workload identity federation.
282    ExternalAccount(ExternalAccount),
283    /// Impersonated service account.
284    ImpersonatedServiceAccount(ImpersonatedServiceAccount),
285    /// OAuth2 authorized user credentials.
286    AuthorizedUser(OAuth2Credentials),
287}
288
289impl CredentialFile {
290    /// Parse credential file from bytes.
291    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 with future expiration
339        token.expires_at = Some(Timestamp::now() + Duration::from_secs(3600));
340        assert!(token.is_valid());
341
342        // Token that expires within 2 minutes
343        token.expires_at = Some(Timestamp::now() + Duration::from_secs(30));
344        assert!(!token.is_valid());
345
346        // Expired token
347        token.expires_at = Some(Timestamp::now() - Duration::from_secs(3600));
348        assert!(!token.is_valid());
349
350        // Empty access token
351        token.access_token = String::new();
352        assert!(!token.is_valid());
353    }
354
355    #[test]
356    fn test_credential_file_deserialize() {
357        // Test service account
358        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        // Test external account
372        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        // Test authorized user
388        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        // Service account only
408        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        // Valid token only
417        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        // Invalid token only
427        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        // Both service account and valid token
435        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        // Service account with expired token
448        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()); // Still valid because of service account
457        assert!(!cred.has_valid_token());
458    }
459}