Skip to main content

gcloud_sdk/token_source/
id_token.rs

1use std::fmt;
2
3use async_trait::async_trait;
4use jiff::Timestamp;
5use once_cell::sync::Lazy;
6use secret_vault_value::SecretValue;
7use tracing::*;
8use url::Url;
9
10use crate::error::{ErrorKind, IdTokenUnsupportedCredentials};
11use crate::token_source::credentials::{
12    auth_error_from_response, from_file, from_json, httpc_post, Credentials,
13    ServiceAccountImpersonationSourceCredentials,
14};
15use crate::token_source::metadata::Metadata;
16use crate::token_source::{
17    create_source, find_default_credentials, from_metadata, BoxSource, DefaultCredentials, Source,
18    Token, TokenSourceType,
19};
20use crate::GCP_DEFAULT_SCOPES;
21
22/// The audience an ID token is minted for and checked against: the URL of a Cloud Run
23/// service or a Cloud Run function, a custom audience configured on the service, or the
24/// OAuth client ID of a resource behind Identity-Aware Proxy.
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct IdTokenAudience(String);
27
28impl IdTokenAudience {
29    pub fn new(audience: impl Into<String>) -> Self {
30        Self(audience.into())
31    }
32
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36}
37
38impl fmt::Display for IdTokenAudience {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.write_str(&self.0)
41    }
42}
43
44/// The email of a service account, such as
45/// `invoker@my-project.iam.gserviceaccount.com`.
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub struct ServiceAccountEmail(String);
48
49impl ServiceAccountEmail {
50    pub fn new(email: impl Into<String>) -> Self {
51        Self(email.into())
52    }
53}
54
55impl fmt::Display for ServiceAccountEmail {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        f.write_str(&self.0)
58    }
59}
60
61/// Mints Google-signed ID tokens for one audience, for calling services that check them,
62/// such as Cloud Run, Cloud Run functions and Identity-Aware Proxy.
63///
64/// Each call to [`Source::token`] mints a new token, so wrap the source in a
65/// [`GoogleAuthTokenGenerator`](crate::GoogleAuthTokenGenerator) with
66/// [`from_source`](crate::GoogleAuthTokenGenerator::from_source) to cache it until shortly
67/// before its `exp`. The token is a [`SecretValue`] and is never logged.
68#[derive(Debug)]
69pub struct IdTokenSource {
70    audience: IdTokenAudience,
71    flow: IdTokenFlow,
72}
73
74impl IdTokenSource {
75    /// A source that mints ID tokens with the identity of the credentials
76    /// `token_source_type` finds:
77    /// - a service account key file signs an assertion and exchanges it at the key's
78    ///   token endpoint;
79    /// - the metadata server mints them for its service account;
80    /// - an `impersonated_service_account` credentials file, such as
81    ///   `gcloud auth application-default login --impersonate-service-account` writes,
82    ///   and `external_account` credentials with a `service_account_impersonation_url`
83    ///   impersonate the service account that URL names.
84    ///
85    /// Fails with [`ErrorKind::IdTokenNeedsImpersonation`] for credentials that hold no
86    /// service account: `authorized_user` credentials, `external_account` credentials
87    /// without impersonation, and [`TokenSourceType::ExternalSource`]. Use
88    /// [`impersonating`](Self::impersonating) for those.
89    pub async fn new(
90        audience: IdTokenAudience,
91        token_source_type: TokenSourceType,
92    ) -> crate::error::Result<Self> {
93        let scopes = GCP_DEFAULT_SCOPES.as_slice();
94        let flow = match token_source_type {
95            TokenSourceType::Default => match find_default_credentials(scopes).await? {
96                DefaultCredentials::File(credentials) => IdTokenFlow::try_from(credentials)?,
97                DefaultCredentials::MetadataServer(metadata) => {
98                    IdTokenFlow::MetadataServer(metadata)
99                }
100            },
101            TokenSourceType::Json(json) => {
102                IdTokenFlow::try_from(from_json(json.as_bytes(), scopes)?)?
103            }
104            TokenSourceType::File(path) => IdTokenFlow::try_from(from_file(path, scopes)?)?,
105            TokenSourceType::MetadataServer => {
106                IdTokenFlow::from_metadata_server("default".to_string()).await?
107            }
108            TokenSourceType::MetadataServerWithAccount(account) => {
109                IdTokenFlow::from_metadata_server(account).await?
110            }
111            TokenSourceType::ExternalSource(_) => {
112                return Err(ErrorKind::IdTokenNeedsImpersonation(
113                    IdTokenUnsupportedCredentials::ExternalSource,
114                )
115                .into())
116            }
117        };
118        debug!(%audience, ?flow, "Created an ID token source");
119        Ok(Self { audience, flow })
120    }
121
122    /// A source that mints ID tokens with the identity of `service_account`, through the
123    /// IAM Credentials `generateIdToken` method. The access token that
124    /// `token_source_type` yields authorizes the call, so its principal needs the
125    /// `iam.serviceAccounts.getOpenIdToken` permission on `service_account`, which the
126    /// Service Account OpenID Connect Identity Token Creator role grants.
127    pub async fn impersonating(
128        audience: IdTokenAudience,
129        service_account: ServiceAccountEmail,
130        token_source_type: TokenSourceType,
131    ) -> crate::error::Result<Self> {
132        let source = create_source(token_source_type, GCP_DEFAULT_SCOPES.clone()).await?;
133        let flow = IdTokenFlow::Impersonation(Impersonation::new(
134            source,
135            service_account,
136            &IAM_CREDENTIALS_URL,
137        ));
138        debug!(%audience, ?flow, "Created an ID token source");
139        Ok(Self { audience, flow })
140    }
141}
142
143#[async_trait]
144impl Source for IdTokenSource {
145    async fn token(&self) -> crate::error::Result<Token> {
146        let id_token = match &self.flow {
147            IdTokenFlow::ServiceAccountKey(service_account) => {
148                service_account.id_token(&self.audience).await?
149            }
150            IdTokenFlow::MetadataServer(metadata) => {
151                metadata.id_token_with_email(&self.audience).await?
152            }
153            IdTokenFlow::Impersonation(impersonation) => {
154                impersonation.id_token(&self.audience).await?
155            }
156        };
157        let expiry = id_token_expiry(&id_token)?;
158        Ok(Token::new("Bearer".to_string(), id_token, expiry))
159    }
160}
161
162#[derive(serde::Deserialize)]
163struct IdTokenExpiry {
164    #[serde(with = "jiff::fmt::serde::timestamp::second::required")]
165    exp: Timestamp,
166}
167
168/// The `exp` claim of an ID token, read without checking its signature: the token
169/// comes straight from Google over TLS, and only its holder's refresh depends on it.
170fn id_token_expiry(id_token: &SecretValue) -> crate::error::Result<Timestamp> {
171    let claims: IdTokenExpiry =
172        jsonwebtoken::dangerous::insecure_decode_claims(id_token.as_sensitive_bytes())?;
173    Ok(claims.exp)
174}
175
176impl From<IdTokenSource> for BoxSource {
177    fn from(v: IdTokenSource) -> Self {
178        Box::new(v)
179    }
180}
181
182/// How an [`IdTokenSource`] obtains its tokens.
183enum IdTokenFlow {
184    ServiceAccountKey(crate::token_source::credentials::ServiceAccount),
185    MetadataServer(Metadata),
186    Impersonation(Impersonation),
187}
188
189impl fmt::Debug for IdTokenFlow {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        match self {
192            Self::ServiceAccountKey(_) => f.write_str("ServiceAccountKey"),
193            Self::MetadataServer(_) => f.write_str("MetadataServer"),
194            Self::Impersonation(impersonation) => f
195                .debug_tuple("Impersonation")
196                .field(&impersonation.service_account)
197                .finish(),
198        }
199    }
200}
201
202impl IdTokenFlow {
203    async fn from_metadata_server(account: String) -> crate::error::Result<Self> {
204        match from_metadata(GCP_DEFAULT_SCOPES.as_slice(), account).await? {
205            Some(metadata) => Ok(Self::MetadataServer(metadata)),
206            None => Err(ErrorKind::TokenSource.into()),
207        }
208    }
209}
210
211impl TryFrom<Credentials> for IdTokenFlow {
212    type Error = crate::error::Error;
213
214    fn try_from(credentials: Credentials) -> Result<Self, Self::Error> {
215        match credentials {
216            Credentials::ServiceAccount(service_account) => {
217                Ok(Self::ServiceAccountKey(service_account))
218            }
219            Credentials::User(_) => Err(ErrorKind::IdTokenNeedsImpersonation(
220                IdTokenUnsupportedCredentials::AuthorizedUser,
221            )
222            .into()),
223            // The source credentials call generateIdToken directly, as they would call
224            // generateAccessToken, rather than through an access token of the
225            // impersonated account, which would need permission on itself.
226            Credentials::ServiceAccountImpersonation(impersonated) => {
227                let service_account = ServiceAccountEmail::from_impersonation_url(
228                    &impersonated.service_account_impersonation_url,
229                )?;
230                let source: BoxSource = match impersonated.source_credentials {
231                    ServiceAccountImpersonationSourceCredentials::ServiceAccount(
232                        mut source_account,
233                    ) => {
234                        source_account.scopes = GCP_DEFAULT_SCOPES.clone();
235                        Credentials::ServiceAccount(source_account).into()
236                    }
237                    ServiceAccountImpersonationSourceCredentials::User(user) => {
238                        Credentials::User(user).into()
239                    }
240                };
241                Ok(Self::Impersonation(Impersonation::new(
242                    source,
243                    service_account,
244                    &IAM_CREDENTIALS_URL,
245                )))
246            }
247            // The federated token from the security token service calls generateIdToken,
248            // the way it calls generateAccessToken for access tokens.
249            Credentials::ExternalAccount(mut external_account) => {
250                let Some(url) = external_account.service_account_impersonation_url.take() else {
251                    return Err(ErrorKind::IdTokenNeedsImpersonation(
252                        IdTokenUnsupportedCredentials::ExternalAccount,
253                    )
254                    .into());
255                };
256                let service_account = ServiceAccountEmail::from_impersonation_url(&url)?;
257                external_account.scopes = GCP_DEFAULT_SCOPES.clone();
258                Ok(Self::Impersonation(Impersonation::new(
259                    Credentials::ExternalAccount(external_account).into(),
260                    service_account,
261                    &IAM_CREDENTIALS_URL,
262                )))
263            }
264        }
265    }
266}
267
268static IAM_CREDENTIALS_URL: Lazy<Url> = Lazy::new(|| {
269    Url::parse("https://iamcredentials.googleapis.com").expect("the IAM Credentials URL is valid")
270});
271
272/// IAM Credentials `generateIdToken` calls for one service account.
273struct Impersonation {
274    /// Yields the access token that authorizes each call.
275    source: BoxSource,
276    service_account: ServiceAccountEmail,
277    /// The service account's `generateIdToken` endpoint.
278    url: Url,
279}
280
281// https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateIdToken
282#[derive(serde::Serialize)]
283#[serde(rename_all = "camelCase")]
284struct GenerateIdTokenRequest<'a> {
285    audience: &'a str,
286    include_email: bool,
287}
288
289#[derive(serde::Deserialize)]
290struct GenerateIdTokenResponse {
291    token: SecretValue,
292}
293
294impl Impersonation {
295    fn new(source: BoxSource, service_account: ServiceAccountEmail, iam_credentials: &Url) -> Self {
296        let mut url = iam_credentials.clone();
297        // Pushing the email as one path segment percent-encodes any `/`, `?` or `#` in it,
298        // so the email cannot change which endpoint is called.
299        url.path_segments_mut()
300            .expect("an http(s) URL has path segments")
301            .pop_if_empty()
302            .extend([
303                "v1",
304                "projects",
305                "-",
306                "serviceAccounts",
307                &format!("{}:generateIdToken", service_account.0),
308            ]);
309        Self {
310            source,
311            service_account,
312            url,
313        }
314    }
315
316    async fn id_token(&self, audience: &IdTokenAudience) -> crate::error::Result<SecretValue> {
317        let access_token = self.source.token().await?;
318        let response = httpc_post(self.url.as_str())
319            .header(
320                reqwest::header::AUTHORIZATION,
321                access_token.authorization()?,
322            )
323            .json(&GenerateIdTokenRequest {
324                audience: audience.as_str(),
325                include_email: true,
326            })
327            .send()
328            .await?;
329        if response.status().is_success() {
330            Ok(response.json::<GenerateIdTokenResponse>().await?.token)
331        } else {
332            Err(auth_error_from_response(response).await)
333        }
334    }
335}
336
337impl ServiceAccountEmail {
338    /// The service account a `service_account_impersonation_url` names: the last path
339    /// segment, up to the `:generateAccessToken` method.
340    fn from_impersonation_url(url: &str) -> crate::error::Result<Self> {
341        let resource = url.rsplit('/').next().unwrap_or_default();
342        let email = resource.split(':').next().unwrap_or_default();
343        if email.is_empty() {
344            Err(ErrorKind::InvalidImpersonationUrl(url.to_string()).into())
345        } else {
346            Ok(Self::new(email))
347        }
348    }
349}
350
351// The tests mint JWTs, which needs a crypto provider.
352#[cfg(all(test, any(feature = "jwt-aws-lc-rs", feature = "jwt-rust-crypto")))]
353mod tests {
354    use super::*;
355    use crate::test_support::{signed_jwt, StubResponse, StubServer, TEST_RSA_PRIVATE_KEY};
356    use jiff::SignedDuration;
357
358    const AUDIENCE: &str = "https://orders-abc123-ew.a.run.app";
359    const KEY_ID: &str = "test-key-id";
360    const CLIENT_EMAIL: &str = "caller@my-project.iam.gserviceaccount.com";
361    const TARGET_SERVICE_ACCOUNT: &str = "invoker@my-project.iam.gserviceaccount.com";
362
363    fn id_token_expiring_at(expiry: Timestamp) -> String {
364        signed_jwt(
365            "google-key",
366            &serde_json::json!({
367                "iss": "https://accounts.google.com",
368                "aud": AUDIENCE,
369                "sub": "1234567890",
370                "iat": expiry.as_second() - 3600,
371                "exp": expiry.as_second(),
372            }),
373        )
374    }
375
376    fn service_account_key_json(token_uri: &str) -> String {
377        serde_json::json!({
378            "type": "service_account",
379            "client_email": CLIENT_EMAIL,
380            "private_key_id": KEY_ID,
381            "private_key": TEST_RSA_PRIVATE_KEY,
382            "token_uri": token_uri,
383        })
384        .to_string()
385    }
386
387    fn whole_second(timestamp: Timestamp) -> Timestamp {
388        Timestamp::from_second(timestamp.as_second()).unwrap()
389    }
390
391    #[tokio::test]
392    async fn service_account_key_exchanges_a_target_audience_assertion() {
393        let expiry = whole_second(Timestamp::now() + SignedDuration::from_hours(1));
394        let id_token = id_token_expiring_at(expiry);
395        let token_endpoint = StubServer::start(vec![StubResponse::json(
396            "200 OK",
397            serde_json::json!({ "id_token": id_token }).to_string(),
398        )])
399        .await;
400        let token_uri = format!("{}/token", token_endpoint.url);
401
402        let source = IdTokenSource::new(
403            IdTokenAudience::new(AUDIENCE),
404            TokenSourceType::Json(service_account_key_json(&token_uri)),
405        )
406        .await
407        .unwrap();
408        let token = source.token().await.unwrap();
409
410        assert_eq!(token.token_type, "Bearer");
411        assert_eq!(token.token.as_sensitive_str(), id_token);
412        assert_eq!(token.expiry, expiry);
413
414        let received = token_endpoint.received();
415        assert_eq!(received.len(), 1);
416        assert_eq!(received[0].request_line, "POST /token HTTP/1.1");
417        let form: std::collections::HashMap<String, String> =
418            url::form_urlencoded::parse(received[0].body.as_bytes())
419                .into_owned()
420                .collect();
421        assert_eq!(
422            form["grant_type"],
423            "urn:ietf:params:oauth:grant-type:jwt-bearer"
424        );
425        let assertion = jsonwebtoken::dangerous::insecure_decode::<serde_json::Value>(
426            form["assertion"].as_bytes(),
427        )
428        .unwrap();
429        assert_eq!(assertion.header.kid.as_deref(), Some(KEY_ID));
430        assert_eq!(assertion.claims["iss"], CLIENT_EMAIL);
431        assert_eq!(assertion.claims["aud"], token_uri.as_str());
432        assert_eq!(assertion.claims["target_audience"], AUDIENCE);
433    }
434
435    struct FixedAccessToken;
436
437    #[async_trait]
438    impl Source for FixedAccessToken {
439        async fn token(&self) -> crate::error::Result<Token> {
440            Ok(Token::new(
441                "Bearer".to_string(),
442                SecretValue::from("caller-access-token"),
443                Timestamp::now() + SignedDuration::from_hours(1),
444            ))
445        }
446    }
447
448    #[tokio::test]
449    async fn impersonation_calls_generate_id_token_with_the_access_token() {
450        let expiry = whole_second(Timestamp::now() + SignedDuration::from_hours(1));
451        let id_token = id_token_expiring_at(expiry);
452        let iam = StubServer::start(vec![StubResponse::json(
453            "200 OK",
454            serde_json::json!({ "token": id_token }).to_string(),
455        )])
456        .await;
457
458        let source = IdTokenSource {
459            audience: IdTokenAudience::new(AUDIENCE),
460            flow: IdTokenFlow::Impersonation(Impersonation::new(
461                Box::new(FixedAccessToken),
462                ServiceAccountEmail::new(TARGET_SERVICE_ACCOUNT),
463                &Url::parse(&iam.url).unwrap(),
464            )),
465        };
466        let token = source.token().await.unwrap();
467
468        assert_eq!(token.token.as_sensitive_str(), id_token);
469        assert_eq!(token.expiry, expiry);
470        let received = iam.received();
471        assert_eq!(received.len(), 1);
472        assert_eq!(
473            received[0].request_line,
474            format!(
475                "POST /v1/projects/-/serviceAccounts/{TARGET_SERVICE_ACCOUNT}:generateIdToken HTTP/1.1"
476            )
477        );
478        assert_eq!(
479            received[0].header("authorization"),
480            Some("Bearer caller-access-token")
481        );
482        let body: serde_json::Value = serde_json::from_str(&received[0].body).unwrap();
483        assert_eq!(
484            body,
485            serde_json::json!({ "audience": AUDIENCE, "includeEmail": true })
486        );
487    }
488
489    #[tokio::test]
490    async fn impersonated_credentials_file_impersonates_the_service_account_it_names() {
491        let credentials = serde_json::json!({
492            "type": "impersonated_service_account",
493            "service_account_impersonation_url": format!(
494                "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{TARGET_SERVICE_ACCOUNT}:generateAccessToken"
495            ),
496            "source_credentials": {
497                "type": "authorized_user",
498                "client_id": "client-id",
499                "client_secret": "client-secret",
500                "refresh_token": "refresh-token",
501            },
502        });
503
504        let source = IdTokenSource::new(
505            IdTokenAudience::new(AUDIENCE),
506            TokenSourceType::Json(credentials.to_string()),
507        )
508        .await
509        .unwrap();
510
511        match source.flow {
512            IdTokenFlow::Impersonation(impersonation) => {
513                assert_eq!(
514                    impersonation.service_account,
515                    ServiceAccountEmail::new(TARGET_SERVICE_ACCOUNT)
516                );
517                assert_eq!(
518                    impersonation.url.as_str(),
519                    format!("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{TARGET_SERVICE_ACCOUNT}:generateIdToken")
520                );
521            }
522            other => panic!("expected impersonation, got {other:?}"),
523        }
524    }
525
526    #[tokio::test]
527    async fn authorized_user_credentials_need_impersonation() {
528        let credentials = serde_json::json!({
529            "type": "authorized_user",
530            "client_id": "client-id",
531            "client_secret": "client-secret",
532            "refresh_token": "refresh-token",
533        });
534
535        let err = IdTokenSource::new(
536            IdTokenAudience::new(AUDIENCE),
537            TokenSourceType::Json(credentials.to_string()),
538        )
539        .await
540        .unwrap_err();
541
542        assert!(matches!(
543            err.kind(),
544            ErrorKind::IdTokenNeedsImpersonation(IdTokenUnsupportedCredentials::AuthorizedUser)
545        ));
546    }
547}