Skip to content

Commit f1778ca

Browse files
authored
impl(auth): id token adc flow should allow including email (#3763)
Allow to specify if customers want to include email in the token claims when using ADC flow. This will enable email claim on sources that have to be set up for that, like MDS and Impersonated flows. Towards #3449
1 parent ce06405 commit f1778ca

File tree

5 files changed

+149
-17
lines changed

5 files changed

+149
-17
lines changed

src/auth/integration-tests/src/lib.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -572,20 +572,34 @@ pub mod unstable {
572572
Ok(())
573573
}
574574

575-
pub async fn id_token_adc() -> anyhow::Result<()> {
575+
pub async fn id_token_adc(with_impersonation: bool) -> anyhow::Result<()> {
576576
let (project, adc_json) = get_project_and_service_account().await?;
577+
let mut source_sa_json: serde_json::Value = serde_json::from_slice(&adc_json)?;
578+
579+
let mut expected_email = format!("test-sa-creds@{project}.iam.gserviceaccount.com");
580+
let target_audience = "https://example.com";
581+
582+
if with_impersonation {
583+
let target_principal_email =
584+
format!("impersonation-target@{project}.iam.gserviceaccount.com");
585+
source_sa_json = serde_json::json!({
586+
"type": "impersonated_service_account",
587+
"service_account_impersonation_url": format!("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{target_principal_email}:generateAccessToken"),
588+
"source_credentials": source_sa_json,
589+
});
590+
expected_email = target_principal_email;
591+
}
577592

578593
// Write the ADC to a temporary file
579594
let file = tempfile::NamedTempFile::new().unwrap();
580595
let path = file.into_temp_path();
581-
std::fs::write(&path, adc_json).expect("Unable to write to temporary file.");
582-
583-
let expected_email = format!("test-sa-creds@{project}.iam.gserviceaccount.com");
584-
let target_audience = "https://example.com";
596+
std::fs::write(&path, source_sa_json.to_string())
597+
.expect("Unable to write to temporary file.");
585598

586599
// Create credentials for the principal under test.
587600
let _e = ScopedEnv::set("GOOGLE_APPLICATION_CREDENTIALS", path.to_str().unwrap());
588601
let id_token_creds = IDTokenCredentialBuilder::new(target_audience)
602+
.with_include_email()
589603
.build()
590604
.expect("failed to create id token credentials");
591605

src/auth/integration-tests/tests/driver.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,18 @@ mod driver {
9595
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
9696
#[serial_test::serial]
9797
async fn run_id_token_adc() -> anyhow::Result<()> {
98-
auth_integration_tests::unstable::id_token_adc().await
98+
let with_impersonation = false;
99+
auth_integration_tests::unstable::id_token_adc(with_impersonation).await
100+
}
101+
102+
#[cfg(all(test, google_cloud_unstable_id_token))]
103+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
104+
#[serial_test::serial]
105+
// verify that include_email via ADC flow is passed down to the impersonated
106+
// builder and email claim is included in the token.
107+
async fn run_id_token_adc_impersonated() -> anyhow::Result<()> {
108+
let with_impersonation = true;
109+
auth_integration_tests::unstable::id_token_adc(with_impersonation).await
99110
}
100111

101112
#[cfg(all(test, google_cloud_unstable_id_token))]

src/auth/src/credentials/idtoken.rs

Lines changed: 116 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ pub(crate) mod dynamic {
180180
/// [AIP-4110]: https://google.aip.dev/auth/4110
181181
pub struct Builder {
182182
target_audience: String,
183+
include_email: bool,
183184
}
184185

185186
impl Builder {
@@ -193,9 +194,20 @@ impl Builder {
193194
pub fn new<S: Into<String>>(target_audience: S) -> Self {
194195
Self {
195196
target_audience: target_audience.into(),
197+
include_email: false,
196198
}
197199
}
198200

201+
/// Sets whether the ID token should include the `email` claim of the user in the token.
202+
///
203+
/// For some credentials sources like Metadata Server and Impersonated Credentials, the default is
204+
/// to not include the `email` claim. For other sources, they always include it.
205+
/// This option is only relevant for credentials sources that do not include the `email` claim by default.
206+
pub fn with_include_email(mut self) -> Self {
207+
self.include_email = true;
208+
self
209+
}
210+
199211
/// Returns a [IDTokenCredentials] instance with the configured settings.
200212
///
201213
/// # Errors
@@ -214,30 +226,62 @@ impl Builder {
214226
AdcContents::FallbackToMds => None,
215227
};
216228

217-
build_id_token_credentials(self.target_audience, json_data)
229+
build_id_token_credentials(self.target_audience, self.include_email, json_data)
218230
}
219231
}
232+
enum IDTokenBuilder {
233+
Mds(mds::Builder),
234+
ServiceAccount(service_account::Builder),
235+
Impersonated(impersonated::Builder),
236+
}
220237

221238
fn build_id_token_credentials(
222239
audience: String,
240+
include_email: bool,
223241
json: Option<Value>,
224242
) -> BuildResult<IDTokenCredentials> {
243+
let builder = build_id_token_credentials_internal(audience, include_email, json)?;
244+
match builder {
245+
IDTokenBuilder::Mds(builder) => builder.build(),
246+
IDTokenBuilder::ServiceAccount(builder) => builder.build(),
247+
IDTokenBuilder::Impersonated(builder) => builder.build(),
248+
}
249+
}
250+
251+
fn build_id_token_credentials_internal(
252+
audience: String,
253+
include_email: bool,
254+
json: Option<Value>,
255+
) -> BuildResult<IDTokenBuilder> {
225256
match json {
226257
None => {
227258
// TODO(#3587): pass context that is being built from ADC flow.
228-
mds::Builder::new(audience)
229-
.with_format(mds::Format::Full)
230-
.build()
259+
let format = if include_email {
260+
mds::Format::Full
261+
} else {
262+
mds::Format::Standard
263+
};
264+
Ok(IDTokenBuilder::Mds(
265+
mds::Builder::new(audience).with_format(format),
266+
))
231267
}
232268
Some(json) => {
233269
let cred_type = extract_credential_type(&json)?;
234270
match cred_type {
235271
"authorized_user" => Err(BuilderError::not_supported(format!(
236272
"{cred_type}, use idtoken::user_account::Builder directly."
237273
))),
238-
"service_account" => service_account::Builder::new(audience, json).build(),
274+
"service_account" => Ok(IDTokenBuilder::ServiceAccount(
275+
service_account::Builder::new(audience, json),
276+
)),
239277
"impersonated_service_account" => {
240-
impersonated::Builder::new(audience, json).build()
278+
let builder = impersonated::Builder::new(audience, json);
279+
let builder = if include_email {
280+
builder.with_include_email()
281+
} else {
282+
builder
283+
};
284+
Ok(IDTokenBuilder::Impersonated(builder))
241285
}
242286
"external_account" => {
243287
// never gonna be supported for id tokens
@@ -289,7 +333,9 @@ fn instant_from_epoch_seconds(secs: u64, now: SystemTime) -> Option<Instant> {
289333
pub(crate) mod tests {
290334
use super::*;
291335
use jsonwebtoken::{Algorithm, EncodingKey, Header};
336+
use mds::Format;
292337
use rsa::pkcs1::EncodeRsaPrivateKey;
338+
use serde_json::json;
293339
use serial_test::parallel;
294340
use std::collections::HashMap;
295341
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -380,7 +426,7 @@ pub(crate) mod tests {
380426
"refresh_token": "test_refresh_token",
381427
});
382428

383-
let result = build_id_token_credentials(audience, Some(json));
429+
let result = build_id_token_credentials(audience, false, Some(json));
384430
assert!(result.is_err());
385431
let err = result.unwrap_err();
386432
assert!(err.is_not_supported());
@@ -408,7 +454,7 @@ pub(crate) mod tests {
408454
}
409455
});
410456

411-
let result = build_id_token_credentials(audience, Some(json));
457+
let result = build_id_token_credentials(audience, false, Some(json));
412458
assert!(result.is_err());
413459
let err = result.unwrap_err();
414460
assert!(err.is_not_supported());
@@ -424,11 +470,72 @@ pub(crate) mod tests {
424470
"type": "unknown_credential_type",
425471
});
426472

427-
let result = build_id_token_credentials(audience, Some(json));
473+
let result = build_id_token_credentials(audience, false, Some(json));
428474
assert!(result.is_err());
429475
let err = result.unwrap_err();
430476
assert!(err.is_unknown_type());
431477
assert!(err.to_string().contains("unknown_credential_type"));
432478
Ok(())
433479
}
480+
481+
#[tokio::test]
482+
#[parallel]
483+
async fn test_build_id_token_include_email_mds() -> TestResult {
484+
let audience = "test_audience".to_string();
485+
486+
// Test with include_email = true and no source credentials (MDS Fallback)
487+
let creds = build_id_token_credentials_internal(audience.clone(), true, None)?;
488+
assert!(matches!(creds, IDTokenBuilder::Mds(_)));
489+
if let IDTokenBuilder::Mds(builder) = creds {
490+
assert!(matches!(builder.format, Some(Format::Full)));
491+
}
492+
493+
// Test with include_email = false and no source credentials (MDS Fallback)
494+
let creds = build_id_token_credentials_internal(audience.clone(), false, None)?;
495+
assert!(matches!(creds, IDTokenBuilder::Mds(_)));
496+
if let IDTokenBuilder::Mds(builder) = creds {
497+
assert!(matches!(builder.format, Some(Format::Standard)));
498+
}
499+
500+
Ok(())
501+
}
502+
503+
#[tokio::test]
504+
#[parallel]
505+
async fn test_build_id_token_include_email_impersonated() -> TestResult {
506+
let audience = "test_audience".to_string();
507+
let json = json!({
508+
"type": "impersonated_service_account",
509+
"source_credentials": {
510+
"type": "service_account",
511+
"project_id": "test-project",
512+
"private_key_id": "test-key-id",
513+
"private_key": "-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----",
514+
"client_email": "[email protected]",
515+
"client_id": "test-client-id",
516+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
517+
"token_uri": "https://oauth2.googleapis.com/token",
518+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
519+
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/source%40test-project.iam.gserviceaccount.com"
520+
},
521+
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateIdToken"
522+
});
523+
524+
// Test with include_email = true and impersonated source credentials
525+
let creds =
526+
build_id_token_credentials_internal(audience.clone(), true, Some(json.clone()))?;
527+
assert!(matches!(creds, IDTokenBuilder::Impersonated(_)));
528+
if let IDTokenBuilder::Impersonated(builder) = creds {
529+
assert_eq!(builder.include_email, Some(true));
530+
}
531+
532+
// Test with include_email = false and impersonated source credentials
533+
let creds = build_id_token_credentials_internal(audience.clone(), false, Some(json))?;
534+
assert!(matches!(creds, IDTokenBuilder::Impersonated(_)));
535+
if let IDTokenBuilder::Impersonated(builder) = creds {
536+
assert_eq!(builder.include_email, None);
537+
}
538+
539+
Ok(())
540+
}
434541
}

src/auth/src/credentials/idtoken/impersonated.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ use std::sync::Arc;
9292
pub struct Builder {
9393
source: BuilderSource,
9494
delegates: Option<Vec<String>>,
95-
include_email: Option<bool>,
95+
pub(crate) include_email: Option<bool>,
9696
target_audience: String,
9797
service_account_impersonation_url: Option<String>,
9898
}

src/auth/src/credentials/idtoken/mds.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ impl Format {
131131
/// metadata service.
132132
pub struct Builder {
133133
endpoint: Option<String>,
134-
format: Option<Format>,
134+
pub(crate) format: Option<Format>,
135135
licenses: Option<String>,
136136
target_audience: String,
137137
}

0 commit comments

Comments
 (0)