diff --git a/src/auth/src/credentials/external_account.rs b/src/auth/src/credentials/external_account.rs index 18b5797239..a146831a94 100644 --- a/src/auth/src/credentials/external_account.rs +++ b/src/auth/src/credentials/external_account.rs @@ -14,6 +14,7 @@ use super::dynamic::CredentialsProvider; use super::external_account_sources::executable_sourced::ExecutableSourcedCredentials; +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}; @@ -27,13 +28,31 @@ use http::{Extensions, HeaderMap}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; +use std::future::Future; use std::sync::Arc; use tokio::time::{Duration, Instant}; -#[async_trait::async_trait] -pub(crate) trait SubjectTokenProvider: std::fmt::Debug + Send + Sync { - /// Generate subject token that will be used on STS exchange. - async fn subject_token(&self) -> Result; +pub trait SubjectTokenProvider: std::fmt::Debug + Send + Sync { + 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)] @@ -61,6 +80,7 @@ enum CredentialSourceFile { Executable { executable: ExecutableConfig, }, + Programmatic {}, File {}, Aws {}, } @@ -111,6 +131,7 @@ impl From for CredentialSource { CredentialSourceFile::Executable { executable } => { Self::Executable(ExecutableSourcedCredentials::new(executable)) } + CredentialSourceFile::Programmatic {} => Self::Programmatic {}, CredentialSourceFile::File { .. } => { unimplemented!("file sourced credential not supported yet") } @@ -139,6 +160,7 @@ enum CredentialSource { Executable(ExecutableSourcedCredentials), File {}, Aws {}, + Programmatic {}, } impl ExternalAccountConfig { @@ -151,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") } @@ -166,7 +193,7 @@ impl ExternalAccountConfig { quota_project_id: Option, ) -> Credentials where - T: SubjectTokenProvider + 'static, + T: dynamic::SubjectTokenProvider + 'static, { let token_provider = ExternalAccountTokenProvider { subject_token_provider, @@ -185,7 +212,7 @@ impl ExternalAccountConfig { #[derive(Debug)] struct ExternalAccountTokenProvider where - T: SubjectTokenProvider, + T: dynamic::SubjectTokenProvider, { subject_token_provider: T, config: ExternalAccountConfig, @@ -194,7 +221,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?; @@ -284,6 +311,7 @@ pub struct Builder { external_account_config: Value, quota_project_id: Option, scopes: Option>, + subject_token_provider: Option>, } impl Builder { @@ -295,6 +323,7 @@ impl Builder { external_account_config, quota_project_id: None, scopes: None, + subject_token_provider: None, } } @@ -323,6 +352,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 @@ -344,6 +383,16 @@ impl Builder { } let config: ExternalAccountConfig = file.into(); + if let Some(subject_token_provider) = self.subject_token_provider { + let source = ProgrammaticSourcedCredentials { + subject_token_provider, + }; + return Ok(ExternalAccountConfig::make_credentials_from_source( + source, + config, + self.quota_project_id, + )); + } Ok(config.make_credentials(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 77cde8fbf1..bde397d3b1 100644 --- a/src/auth/src/credentials/external_account_sources.rs +++ b/src/auth/src/credentials/external_account_sources.rs @@ -13,4 +13,5 @@ // limitations under the License. pub mod executable_sourced; +pub mod programmatic_sourced; pub mod url_sourced; diff --git a/src/auth/src/credentials/external_account_sources/executable_sourced.rs b/src/auth/src/credentials/external_account_sources/executable_sourced.rs index 96ecb31bdc..ddac8e7fce 100644 --- a/src/auth/src/credentials/external_account_sources/executable_sourced.rs +++ b/src/auth/src/credentials/external_account_sources/executable_sourced.rs @@ -15,7 +15,7 @@ use crate::{ Result, constants::{ACCESS_TOKEN_TYPE, JWT_TOKEN_TYPE, SAML2_TOKEN_TYPE}, - credentials::external_account::{ExecutableConfig, SubjectTokenProvider}, + credentials::external_account::{ExecutableConfig, dynamic::SubjectTokenProvider}, }; use gax::error::CredentialsError; use serde::{Deserialize, Serialize}; 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..18b852890d --- /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::dynamic::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/src/credentials/external_account_sources/url_sourced.rs b/src/auth/src/credentials/external_account_sources/url_sourced.rs index 60b66507a3..eb9444806e 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 e4ac3f3f01..be389f1dc4 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; @@ -29,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>; @@ -184,6 +188,19 @@ 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, .. } => 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"); + } + } + } + #[tokio::test] async fn create_external_account_access_token() -> TestResult { let source_token_response_body = json!({ @@ -255,19 +272,79 @@ 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, + } + + impl SubjectTokenProvider for MyCustomSubjectTokenProvider { + fn subject_token( + &self, + ) -> impl Future> + Send { + ready(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(()) }