Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 56 additions & 7 deletions src/auth/src/credentials/external_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<String>;
pub trait SubjectTokenProvider: std::fmt::Debug + Send + Sync {
fn subject_token(&self) -> impl Future<Output = Result<String>> + Send;
}
Comment on lines +36 to +37
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the advantage of using a trait with a single function vs. a thing that implements the AsyncFn trait?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL AsyncFn. Gonna check it out how to use that. But to your point, I was just trying to follow the same pattern from the repo with things like CredentialsProvider, but this one in particular has two methods, so makes sense to be a trait.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AsyncFn is a Rust 2024 feature.... I feat it might not work if the caller is using Rust Edition 2021, so make sure it does, otherwise the trait is a better idea.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trying to use AsyncFn and as it's optional on the external_account::Builder , I'm not finding a way to use without dyn and AsyncFn is not dyn compatible. Any tips ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Says here that asyncfn is nightly-only experimental API https://doc.rust-lang.org/std/ops/trait.AsyncFn.html

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Says here that asyncfn is nightly-only experimental API https://doc.rust-lang.org/std/ops/trait.AsyncFn.html

That is not quite right. AsyncFn is stable:

rust-lang/rust#132706

Manually implementing the AsyncFn trait is not stable. That is, you cannot say impl AsyncFn for MyType ... because the types and methods of the trait are unstable. But you can assume that AsyncFn will be around, and that you can call such functions.

As to the dyn-compatible problems: you could solve that with a private trait:

struct Foo<T> {
  function: T
}
#[async_trait]
trait MyAsyncFn {
  async fn call() -> Blah;
}
#[async_trait]
impl MyAsyncFn for Foo<T> where T: AsyncFn<....> {
  async fn call() -> Blah { function().await }
}

But overall it seems that AsyncFn is more trouble than it is worth.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Thank you for the context!.

Comment on lines +35 to +37
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So to override this I need to implement my own client, error detection, parsing, etc.? Bummer.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the main point of this custom subject token provider is indeed for the customer own all the stack around fetching the subject token, specially in complex scenarios like the ones we discussed where the customers needs to use a custom openssl version and other details. I think we are also evaluating how to allow injecting custom headers and/or provide a custom http client cc @sai-sunder-s

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is for people with advanced use case and know what they are doing. It is just complete freedom to do whatever they want. Yes that means they have to handle error detection and parsing as well but that should be ok. For example, they know best how to handle a kerberos error.


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<String>;
}

#[async_trait::async_trait]
impl<T> SubjectTokenProvider for T
where
T: super::SubjectTokenProvider,
{
async fn subject_token(&self) -> Result<String> {
T::subject_token(self).await
}
}
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
Expand Down Expand Up @@ -61,6 +80,7 @@ enum CredentialSourceFile {
Executable {
executable: ExecutableConfig,
},
Programmatic {},
File {},
Aws {},
}
Expand Down Expand Up @@ -111,6 +131,7 @@ impl From<CredentialSourceFile> for CredentialSource {
CredentialSourceFile::Executable { executable } => {
Self::Executable(ExecutableSourcedCredentials::new(executable))
}
CredentialSourceFile::Programmatic {} => Self::Programmatic {},
CredentialSourceFile::File { .. } => {
unimplemented!("file sourced credential not supported yet")
}
Expand Down Expand Up @@ -139,6 +160,7 @@ enum CredentialSource {
Executable(ExecutableSourcedCredentials),
File {},
Aws {},
Programmatic {},
}

impl ExternalAccountConfig {
Expand All @@ -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")
}
Expand All @@ -166,7 +193,7 @@ impl ExternalAccountConfig {
quota_project_id: Option<String>,
) -> Credentials
where
T: SubjectTokenProvider + 'static,
T: dynamic::SubjectTokenProvider + 'static,
{
let token_provider = ExternalAccountTokenProvider {
subject_token_provider,
Expand All @@ -185,7 +212,7 @@ impl ExternalAccountConfig {
#[derive(Debug)]
struct ExternalAccountTokenProvider<T>
where
T: SubjectTokenProvider,
T: dynamic::SubjectTokenProvider,
{
subject_token_provider: T,
config: ExternalAccountConfig,
Expand All @@ -194,7 +221,7 @@ where
#[async_trait::async_trait]
impl<T> TokenProvider for ExternalAccountTokenProvider<T>
where
T: SubjectTokenProvider,
T: dynamic::SubjectTokenProvider,
{
async fn token(&self) -> Result<Token> {
let subject_token = self.subject_token_provider.subject_token().await?;
Expand Down Expand Up @@ -284,6 +311,7 @@ pub struct Builder {
external_account_config: Value,
quota_project_id: Option<String>,
scopes: Option<Vec<String>>,
subject_token_provider: Option<Box<dyn dynamic::SubjectTokenProvider>>,
}

impl Builder {
Expand All @@ -295,6 +323,7 @@ impl Builder {
external_account_config,
quota_project_id: None,
scopes: None,
subject_token_provider: None,
}
}

Expand Down Expand Up @@ -323,6 +352,16 @@ impl Builder {
self
}

/// bring your own custom implementation of
/// SubjectTokenProvider for OIDC/SAML credentials.
pub fn with_subject_token_provider<T: SubjectTokenProvider + 'static>(
mut self,
subject_token_provider: T,
) -> Self {
self.subject_token_provider = Some(Box::new(subject_token_provider));
self
}
Comment on lines +357 to +363
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are taking subject token provider as an input in the builder, we need to make some more changes as well. It becomes tricky now. There is also a credential source in the config they initialized the builder with.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when setting the subject token provider via the Bulder, it overrides the credential source from the config

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm that maybe ok. When implementing this change lets make sure to document that clearly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another way could be to introduce new(subject_token_provider). But that means we add builder methods to provide all other fields like impersonation url, provider ppol, token url etc.


/// Returns a [Credentials] instance with the configured settings.
///
/// # Errors
Expand All @@ -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))
}
Expand Down
1 change: 1 addition & 0 deletions src/auth/src/credentials/external_account_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@
// limitations under the License.

pub mod executable_sourced;
pub mod programmatic_sourced;
pub mod url_sourced;
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<dyn SubjectTokenProvider>,
}

#[async_trait::async_trait]
impl SubjectTokenProvider for ProgrammaticSourcedCredentials {
async fn subject_token(&self) -> Result<String> {
return self.subject_token_provider.subject_token().await;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
103 changes: 90 additions & 13 deletions src/auth/tests/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<T> = anyhow::Result<T>;
type TestResult = anyhow::Result<(), Box<dyn std::error::Error>>;
Expand Down Expand Up @@ -184,6 +188,19 @@ mod test {
assert!(!fmt.contains("test-api-key"), "{fmt:?}");
}

fn get_token_from_cached_header(cached_headers: CacheableResource<HeaderMap>) -> 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!({
Expand Down Expand Up @@ -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<Output = std::result::Result<String, CredentialsError>> + 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(())
}
Expand Down
Loading