From 56be8e6e759e58cb7c5b816d1e22da12a4835667 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Wed, 4 Jun 2025 14:39:01 -0400 Subject: [PATCH 1/4] feat(auth): allow customers to provide a SubjectTokenProvider impl --- src/auth/src/credentials/external_account.rs | 57 +++++++--- .../credentials/external_account_sources.rs | 1 + .../programmatic_sourced.rs | 28 +++++ src/auth/tests/credentials.rs | 104 +++++++++++++++--- 4 files changed, 163 insertions(+), 27 deletions(-) create mode 100644 src/auth/src/credentials/external_account_sources/programmatic_sourced.rs diff --git a/src/auth/src/credentials/external_account.rs b/src/auth/src/credentials/external_account.rs index aaf4f8cfbb..b28773dd68 100644 --- a/src/auth/src/credentials/external_account.rs +++ b/src/auth/src/credentials/external_account.rs @@ -13,6 +13,7 @@ // limitations under the License. use super::dynamic::CredentialsProvider; +use super::external_account_sources::programmatic_sourced::ProgrammaticSourcedCredentials; use super::external_account_sources::url_sourced::UrlSourcedCredentials; use super::internal::sts_exchange::{ClientAuthentication, ExchangeTokenRequest, STSHandler}; use super::{CacheableResource, Credentials}; @@ -29,7 +30,7 @@ use std::sync::Arc; use tokio::time::{Duration, Instant}; #[async_trait::async_trait] -pub(crate) trait SubjectTokenProvider: std::fmt::Debug + Send + Sync { +pub trait SubjectTokenProvider: std::fmt::Debug + Send + Sync { /// Generate subject token that will be used on STS exchange. async fn subject_token(&self) -> Result; } @@ -57,19 +58,7 @@ impl CredentialSource { quota_project_id: Option, ) -> Credentials { match self { - Self::Url(source) => { - let token_provider = ExternalAccountTokenProvider { - subject_token_provider: source, - config, - }; - let cache = TokenCache::new(token_provider); - Credentials { - inner: Arc::new(ExternalAccountCredentials { - token_provider: cache, - quota_project_id, - }), - } - } + Self::Url(source) => make_credentials_from_provider(source, config, quota_project_id), Self::Executable { .. } => { unimplemented!("executable sourced credential not supported yet") } @@ -83,6 +72,23 @@ impl CredentialSource { } } +fn make_credentials_from_provider( + subject_token_provider: T, + config: ExternalAccountConfig, + quota_project_id: Option, +) -> Credentials { + let token_provider = ExternalAccountTokenProvider { + subject_token_provider, + config, + }; + let cache = TokenCache::new(token_provider); + Credentials { + inner: Arc::new(ExternalAccountCredentials { + token_provider: cache, + quota_project_id, + }), + } +} #[derive(Serialize, Deserialize, Debug, Clone)] struct ExternalAccountConfig { audience: String, @@ -202,6 +208,7 @@ pub struct Builder { external_account_config: Value, quota_project_id: Option, scopes: Option>, + subject_token_provider: Option>, } impl Builder { @@ -213,6 +220,7 @@ impl Builder { external_account_config, quota_project_id: None, scopes: None, + subject_token_provider: None, } } @@ -241,6 +249,16 @@ impl Builder { self } + /// bring your own custom implementation of + /// SubjectTokenProvider for OIDC/SAML credentials. + pub fn with_subject_token_provider( + mut self, + subject_token_provider: T, + ) -> Self { + self.subject_token_provider = Some(Box::new(subject_token_provider)); + self + } + /// Returns a [Credentials] instance with the configured settings. /// /// # Errors @@ -262,6 +280,17 @@ impl Builder { config.scopes = Some(scopes); } + if let Some(subject_token_provider) = self.subject_token_provider { + let source = ProgrammaticSourcedCredentials { + subject_token_provider, + }; + return Ok(make_credentials_from_provider( + source, + config, + self.quota_project_id, + )); + } + Ok(external_account_config .credential_source .make_credentials(config, self.quota_project_id)) diff --git a/src/auth/src/credentials/external_account_sources.rs b/src/auth/src/credentials/external_account_sources.rs index 493b37577a..ca16311841 100644 --- a/src/auth/src/credentials/external_account_sources.rs +++ b/src/auth/src/credentials/external_account_sources.rs @@ -12,4 +12,5 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub mod programmatic_sourced; pub mod url_sourced; diff --git a/src/auth/src/credentials/external_account_sources/programmatic_sourced.rs b/src/auth/src/credentials/external_account_sources/programmatic_sourced.rs new file mode 100644 index 0000000000..dca4cb0aed --- /dev/null +++ b/src/auth/src/credentials/external_account_sources/programmatic_sourced.rs @@ -0,0 +1,28 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::Result; +use crate::credentials::external_account::SubjectTokenProvider; + +#[derive(Debug)] +pub(crate) struct ProgrammaticSourcedCredentials { + pub subject_token_provider: Box, +} + +#[async_trait::async_trait] +impl SubjectTokenProvider for ProgrammaticSourcedCredentials { + async fn subject_token(&self) -> Result { + return self.subject_token_provider.subject_token().await; + } +} diff --git a/src/auth/tests/credentials.rs b/src/auth/tests/credentials.rs index e4ac3f3f01..68617f166b 100644 --- a/src/auth/tests/credentials.rs +++ b/src/auth/tests/credentials.rs @@ -15,6 +15,9 @@ #[cfg(test)] mod test { use google_cloud_auth::credentials::EntityTag; + use google_cloud_auth::credentials::external_account::{ + Builder as ExternalAccountBuilder, SubjectTokenProvider, + }; use google_cloud_auth::credentials::mds::Builder as MdsBuilder; use google_cloud_auth::credentials::service_account::Builder as ServiceAccountBuilder; use google_cloud_auth::credentials::testing::test_credentials; @@ -184,6 +187,22 @@ mod test { assert!(!fmt.contains("test-api-key"), "{fmt:?}"); } + fn get_token_from_cached_header(cached_headers: CacheableResource) -> String { + match cached_headers { + CacheableResource::New { data, .. } => { + let token = data + .get(AUTHORIZATION) + .and_then(|token_value| token_value.to_str().ok()) + .map(|s| s.to_string()) + .unwrap(); + token + } + CacheableResource::NotModified => { + unreachable!("Expecting a header to be present"); + } + } + } + #[tokio::test] async fn create_external_account_access_token() -> TestResult { let source_token_response_body = json!({ @@ -255,19 +274,78 @@ mod test { assert!(fmt.contains("ExternalAccountCredentials")); let cached_headers = creds.headers(Extensions::new()).await?; - match cached_headers { - CacheableResource::New { data, .. } => { - let token = data - .get(AUTHORIZATION) - .and_then(|token_value| token_value.to_str().ok()) - .map(|s| s.to_string()) - .unwrap(); - assert!(token.contains("Bearer an_exchanged_token")); - } - CacheableResource::NotModified => { - unreachable!("Expecting a header to be present"); - } - }; + let token = get_token_from_cached_header(cached_headers); + + assert!(token.contains("Bearer an_exchanged_token")); + + Ok(()) + } + + #[derive(Debug)] + struct MyCustomSubjectTokenProvider { + token: String, + } + + #[async_trait::async_trait] + impl SubjectTokenProvider for MyCustomSubjectTokenProvider { + async fn subject_token(&self) -> std::result::Result { + Ok(self.token.clone()) + } + } + + #[tokio::test] + async fn create_external_account_programmatic() -> TestResult { + let token_response_body = json!({ + "access_token":"an_exchanged_token", + "issued_token_type":"urn:ietf:params:oauth:token-type:access_token", + "token_type":"Bearer", + "expires_in":3600, + "scope":"https://www.googleapis.com/auth/cloud-platform" + }); + + let server = Server::run(); + + server.expect( + Expectation::matching(all_of![ + request::method_path("POST", "/token"), + request::body(url_decoded(contains(("subject_token", "an_example_token")))), + request::body(url_decoded(contains(( + "subject_token_type", + "urn:ietf:params:oauth:token-type:jwt" + )))), + request::body(url_decoded(contains(("audience", "some-audience")))), + request::headers(contains(( + "content-type", + "application/x-www-form-urlencoded" + ))), + ]) + .respond_with(json_encoded(token_response_body)), + ); + + let contents = json!({ + "type": "external_account", + "audience": "some-audience", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": server.url("/token").to_string(), + "credential_source": {} + }); + + let creds = ExternalAccountBuilder::new(contents) + .with_subject_token_provider(MyCustomSubjectTokenProvider { + token: "an_example_token".to_string(), + }) + .build() + .unwrap(); + + // Use the debug output to verify the right kind of credentials are created. + let fmt = format!("{:?}", creds); + print!("{:?}", creds); + assert!(fmt.contains("ExternalAccountCredentials")); + + let cached_headers = creds.headers(Extensions::new()).await?; + let token = get_token_from_cached_header(cached_headers); + + assert!(token.contains("Bearer an_exchanged_token")); Ok(()) } From 094c7693f0aaf54f6269d8fb1aeed8b6c5f36d2c Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Wed, 4 Jun 2025 15:09:09 -0400 Subject: [PATCH 2/4] fix: lint issues --- src/auth/tests/credentials.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/auth/tests/credentials.rs b/src/auth/tests/credentials.rs index 68617f166b..aeda31a83d 100644 --- a/src/auth/tests/credentials.rs +++ b/src/auth/tests/credentials.rs @@ -189,14 +189,11 @@ mod test { fn get_token_from_cached_header(cached_headers: CacheableResource) -> String { match cached_headers { - CacheableResource::New { data, .. } => { - let token = data - .get(AUTHORIZATION) - .and_then(|token_value| token_value.to_str().ok()) - .map(|s| s.to_string()) - .unwrap(); - token - } + CacheableResource::New { data, .. } => data + .get(AUTHORIZATION) + .and_then(|token_value| token_value.to_str().ok()) + .map(|s| s.to_string()) + .unwrap(), CacheableResource::NotModified => { unreachable!("Expecting a header to be present"); } From 11573eea3057cae10efb3dd6e82b4fdd81a9518a Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Thu, 5 Jun 2025 16:12:40 -0400 Subject: [PATCH 3/4] fix: remove async_trait from public SubjectTokenProvider trait --- src/auth/src/credentials/external_account.rs | 32 +++++++++++++++---- .../programmatic_sourced.rs | 2 +- .../external_account_sources/url_sourced.rs | 5 ++- src/auth/tests/credentials.rs | 8 +++-- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/auth/src/credentials/external_account.rs b/src/auth/src/credentials/external_account.rs index b28773dd68..c14d50835e 100644 --- a/src/auth/src/credentials/external_account.rs +++ b/src/auth/src/credentials/external_account.rs @@ -26,13 +26,31 @@ use crate::{BuildResult, Result}; use http::{Extensions, HeaderMap}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::future::Future; use std::sync::Arc; use tokio::time::{Duration, Instant}; -#[async_trait::async_trait] pub trait SubjectTokenProvider: std::fmt::Debug + Send + Sync { - /// Generate subject token that will be used on STS exchange. - async fn subject_token(&self) -> Result; + fn subject_token(&self) -> impl Future> + Send; +} + +pub(crate) mod dynamic { + use super::Result; + #[async_trait::async_trait] + pub trait SubjectTokenProvider: std::fmt::Debug + Send + Sync { + /// Generate subject token that will be used on STS exchange. + async fn subject_token(&self) -> Result; + } + + #[async_trait::async_trait] + impl SubjectTokenProvider for T + where + T: super::SubjectTokenProvider, + { + async fn subject_token(&self) -> Result { + T::subject_token(self).await + } + } } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -72,7 +90,7 @@ impl CredentialSource { } } -fn make_credentials_from_provider( +fn make_credentials_from_provider( subject_token_provider: T, config: ExternalAccountConfig, quota_project_id: Option, @@ -103,7 +121,7 @@ struct ExternalAccountConfig { #[derive(Debug)] struct ExternalAccountTokenProvider where - T: SubjectTokenProvider, + T: dynamic::SubjectTokenProvider, { subject_token_provider: T, config: ExternalAccountConfig, @@ -112,7 +130,7 @@ where #[async_trait::async_trait] impl TokenProvider for ExternalAccountTokenProvider where - T: SubjectTokenProvider, + T: dynamic::SubjectTokenProvider, { async fn token(&self) -> Result { let subject_token = self.subject_token_provider.subject_token().await?; @@ -208,7 +226,7 @@ pub struct Builder { external_account_config: Value, quota_project_id: Option, scopes: Option>, - subject_token_provider: Option>, + subject_token_provider: Option>, } impl Builder { diff --git a/src/auth/src/credentials/external_account_sources/programmatic_sourced.rs b/src/auth/src/credentials/external_account_sources/programmatic_sourced.rs index dca4cb0aed..18b852890d 100644 --- a/src/auth/src/credentials/external_account_sources/programmatic_sourced.rs +++ b/src/auth/src/credentials/external_account_sources/programmatic_sourced.rs @@ -13,7 +13,7 @@ // limitations under the License. use crate::Result; -use crate::credentials::external_account::SubjectTokenProvider; +use crate::credentials::external_account::dynamic::SubjectTokenProvider; #[derive(Debug)] pub(crate) struct ProgrammaticSourcedCredentials { diff --git a/src/auth/src/credentials/external_account_sources/url_sourced.rs b/src/auth/src/credentials/external_account_sources/url_sourced.rs index 9af7a428a5..dcd748483b 100644 --- a/src/auth/src/credentials/external_account_sources/url_sourced.rs +++ b/src/auth/src/credentials/external_account_sources/url_sourced.rs @@ -19,9 +19,8 @@ use serde_json::Value; use std::{collections::HashMap, time::Duration}; use crate::{ - Result, - credentials::external_account::{CredentialSourceFormat, SubjectTokenProvider}, - errors, + Result, credentials::external_account::CredentialSourceFormat, + credentials::external_account::dynamic::SubjectTokenProvider, errors, }; #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/src/auth/tests/credentials.rs b/src/auth/tests/credentials.rs index aeda31a83d..be389f1dc4 100644 --- a/src/auth/tests/credentials.rs +++ b/src/auth/tests/credentials.rs @@ -32,6 +32,7 @@ mod test { use httptest::{Expectation, Server, matchers::*, responders::*}; use scoped_env::ScopedEnv; use serde_json::json; + use std::future::{Future, ready}; type Result = anyhow::Result; type TestResult = anyhow::Result<(), Box>; @@ -283,10 +284,11 @@ mod test { token: String, } - #[async_trait::async_trait] impl SubjectTokenProvider for MyCustomSubjectTokenProvider { - async fn subject_token(&self) -> std::result::Result { - Ok(self.token.clone()) + fn subject_token( + &self, + ) -> impl Future> + Send { + ready(Ok(self.token.clone())) } } From 4ee6c3acca22779e20caf48fd5d688285babfa17 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Fri, 13 Jun 2025 15:18:07 -0400 Subject: [PATCH 4/4] fix: panic when missing token provider --- src/auth/src/credentials/external_account.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/auth/src/credentials/external_account.rs b/src/auth/src/credentials/external_account.rs index 0a6e16ce5d..a146831a94 100644 --- a/src/auth/src/credentials/external_account.rs +++ b/src/auth/src/credentials/external_account.rs @@ -80,9 +80,9 @@ enum CredentialSourceFile { Executable { executable: ExecutableConfig, }, + Programmatic {}, File {}, Aws {}, - Programmatic {}, } /// A representation of a [external account config file]. @@ -131,9 +131,7 @@ impl From for CredentialSource { CredentialSourceFile::Executable { executable } => { Self::Executable(ExecutableSourcedCredentials::new(executable)) } - CredentialSourceFile::Programmatic {} => { - unimplemented!("programmatic sourced credential not supported yet") - } + CredentialSourceFile::Programmatic {} => Self::Programmatic {}, CredentialSourceFile::File { .. } => { unimplemented!("file sourced credential not supported yet") } @@ -162,6 +160,7 @@ enum CredentialSource { Executable(ExecutableSourcedCredentials), File {}, Aws {}, + Programmatic {}, } impl ExternalAccountConfig { @@ -174,6 +173,11 @@ impl ExternalAccountConfig { CredentialSource::Executable(source) => { Self::make_credentials_from_source(source, config, quota_project_id) } + CredentialSource::Programmatic {} => { + panic!( + "programmatic sourced credential should set a subject token provider implementation via external_account::Builder::with_subject_token_provider method" + ) + } CredentialSource::File { .. } => { unimplemented!("file sourced credential not supported yet") }