Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
dadeda6
feat(oauth2): implement IAM impersonation mTLS transport pinning and …
macastelaz Sep 17, 2026
5cc87e0
fix(oauth2): enforce CLOUD_PLATFORM_SCOPE on impersonation source cre…
macastelaz Sep 17, 2026
73c5a0a
fix(oauth2): address PR review feedback for IAM mTLS transport pinning
macastelaz Sep 17, 2026
8034525
fix(oauth2): address post-review audit edge cases for mTLS pinning
macastelaz Sep 17, 2026
17aae1f
fix(oauth2): preserve exact class check for MtlsHttpTransportFactory …
macastelaz Sep 17, 2026
f2d752e
test(oauth2): add coverage for custom MtlsHttpTransportFactory subcla…
macastelaz Sep 17, 2026
a5e69ed
fix(oauth2): annotate refreshAccessToken(HttpTransportFactory) with @…
macastelaz Sep 18, 2026
031a594
test(oauth2): add mTLS in-process socket tests and live GCP WIF integ…
macastelaz Aug 29, 2026
fdfea4f
test(oauth2): escape Windows path backslashes in MtlsPipelineLocalTes…
macastelaz Sep 1, 2026
4266196
test(oauth2): ensure executor shutdown and temp file cleanup in mTLS …
macastelaz Sep 16, 2026
7ac9e63
test(oauth2): verify real Cert A -> Cert B rotation on 401 and harden…
macastelaz Sep 18, 2026
13e8be0
fix(oauth2): address review feedback on mTLS transport pinning and 40…
macastelaz Sep 21, 2026
44ca6d7
fix(oauth2): address PR #14212 round 2 review feedback
macastelaz Sep 22, 2026
ffe2b6d
Merge branch 'cert-bound-oauth-iam-pinning' into cert-bound-oauth-int…
macastelaz Sep 22, 2026
08e026b
fix: address PR #14220 review comments on OAuthException, MtlsPipelin…
macastelaz Sep 22, 2026
9313161
fix(oauth2): pin leaf subject token from KeyStore, cache STS token wi…
macastelaz Sep 22, 2026
6e5f7fe
fix(oauth2): support mTLS pinning for standalone ImpersonatedCredenti…
macastelaz Sep 22, 2026
ddd5ea8
fix(oauth2): handle mid-rotation invalid_grant, cached STS 401 retry,…
macastelaz Sep 23, 2026
b47ab2a
fix(auth): harden concurrency and nullability in ImpersonatedCredenti…
macastelaz Sep 23, 2026
fb1a9c8
Merge branch 'cert-bound-oauth-iam-pinning' into cert-bound-oauth-int…
macastelaz Sep 23, 2026
22556ff
fix(oauth2): address PR #14220 round 2 review feedback
macastelaz Sep 23, 2026
849c4e5
style(oauth2): remove unused body variable in MtlsPipelineLocalTest
macastelaz Sep 23, 2026
1f062f2
fix(oauth2): address review feedback on cached STS token retry and mT…
macastelaz Sep 25, 2026
d28d8c8
Merge remote-tracking branch 'fork/cert-bound-oauth-iam-pinning' into…
macastelaz Sep 25, 2026
4e06ff0
test(oauth2): assert pinned KeyStore per refresh cycle in cert rotati…
macastelaz Sep 25, 2026
231e06c
test(oauth2): address review feedback on mTLS pipeline and WIF integr…
macastelaz Sep 25, 2026
ad119fd
Merge branch 'cert-bound-oauth-iam-pinning' into cert-bound-oauth-int…
macastelaz Sep 25, 2026
45aa304
Merge branch 'cert-bound-oauth-iam-pinning' into cert-bound-oauth-int…
macastelaz Sep 25, 2026
d5317c2
fix(oauth2): address follow-up review feedback on transport visibilit…
macastelaz Sep 25, 2026
f96ff1a
test(oauth2): skip mTLS WIF integration tests when GOOGLE_API_CERTIFI…
macastelaz Sep 25, 2026
683b765
test(oauth2): exercise public programmatic mTLS path in WIF integrati…
macastelaz Sep 25, 2026
efa9d9e
fix(oauth2): fall back to response content when OAuth error field has…
macastelaz Sep 25, 2026
091a91b
fix(auth): address PR #14212 follow-up review comments
macastelaz Sep 28, 2026
395e3af
Merge remote-tracking branch 'fork/cert-bound-oauth-iam-pinning' into…
macastelaz Sep 28, 2026
f8c15f6
test(auth): enable mTLS and actor_token WIF integration tests by default
macastelaz Sep 30, 2026
8788dc2
test(auth): refine leaf cert extraction, assertions, and WIF setup sc…
macastelaz Sep 30, 2026
55226e2
chore(auth): merge origin/oauth2-bound-tokens into enable-mtls-wif-in…
macastelaz Oct 15, 2026
1e02910
test(auth): use DEFAULT_MTLS_CERT_CONFIG_PATH directly in WIF mTLS ITs
macastelaz Oct 16, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@

package com.google.auth.oauth2;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpRequest;
Expand All @@ -47,6 +47,8 @@
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.json.webtoken.JsonWebSignature;
import com.google.api.client.json.webtoken.JsonWebToken;
import com.google.api.client.util.GenericData;
import com.google.auth.http.HttpCredentialsAdapter;
import com.google.auth.http.HttpTransportFactory;
Expand All @@ -58,8 +60,15 @@
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.Base64;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
Expand All @@ -71,7 +80,8 @@
* <p>The only requirements for this test suite to run is to set the environment variable
* GOOGLE_APPLICATION_CREDENTIALS to point to the same service account keys used in the setup script
* (workloadidentityfederation-setup). These tests call GCS to get bucket information. The bucket
* name must be provided through the GCS_BUCKET environment variable.
* name must be provided through the GCS_BUCKET environment variable. The mTLS tests use {@code
* testresources/mtls/certificate_config.json}.
*/
final class ITWorkloadIdentityFederationTest {

Expand All @@ -83,13 +93,24 @@ final class ITWorkloadIdentityFederationTest {

private static final String AWS_AUDIENCE = AUDIENCE_PREFIX + "aws-1";
private static final String OIDC_AUDIENCE = AUDIENCE_PREFIX + "oidc-1";
private static final String OIDC_JWT_AUDIENCE = AUDIENCE_PREFIX + "oidc-jwt-1";
private static final String OIDC_JWT_ISSUER =
"https://storage.googleapis.com/example-oidc-issuer";
private static final String DEFAULT_MTLS_CERT_CONFIG_PATH =
"testresources/mtls/certificate_config.json";

private String clientEmail;
private String clientId;
private String privateKeyId;
private PrivateKey privateKey;

@BeforeEach
void setup() throws IOException {
GenericJson keys = getServiceAccountKeyFileAsJson();
clientEmail = (String) keys.get("client_email");
clientId = (String) keys.get("client_id");
privateKeyId = (String) keys.get("private_key_id");
privateKey = OAuth2Utils.privateKeyFromPkcs8((String) keys.get("private_key"));
}

/**
Expand Down Expand Up @@ -290,16 +311,20 @@ void identityPoolCredentials_withProgrammaticAuth() throws IOException {

/**
* IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token): Uses
* the service account to generate Google ID tokens for subject and actor tokens. Writes both
* the service account private key to sign OIDC JWTs for subject and actor tokens with RFC 8705
* certificate thumbprint binding (cnf.x5t#S256) and delegation (may_act) claims. Writes both
* tokens to a temporary JSON file with subject_token and actor_token field names. Configures
* certificate_config_location pointing to the certificate config. Exchanges the tokens over the
* mTLS STS endpoint (https://sts.mtls.googleapis.com/v1/token) and calls GCS.
*/
@Test
void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws Exception {
String certConfigPath = getMtlsCertificateConfigPath();
String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE);
String actorToken = generateGoogleIdToken(OIDC_AUDIENCE);
KeyStore keyStore = new X509Provider(DEFAULT_MTLS_CERT_CONFIG_PATH).getKeyStore();
String certThumbprint = computeCertThumbprint(keyStore);
String subjectToken =
generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ true);
String actorToken =
generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ false);

File tokenFile =
File.createTempFile(
Expand All @@ -317,7 +342,7 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws

GenericJson config = new GenericJson();
config.put("type", "external_account");
config.put("audience", OIDC_AUDIENCE);
config.put("audience", OIDC_JWT_AUDIENCE);
config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt");
config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt");
config.put("token_url", "https://sts.mtls.googleapis.com/v1/token");
Expand All @@ -337,7 +362,7 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws
credentialSource.put("format", format);

GenericJson certificate = new GenericJson();
certificate.put("certificate_config_location", certConfigPath);
certificate.put("certificate_config_location", DEFAULT_MTLS_CERT_CONFIG_PATH);
credentialSource.put("certificate", certificate);

config.put("credential_source", credentialSource);
Expand All @@ -346,7 +371,6 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws
(IdentityPoolCredentials)
ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY);

KeyStore keyStore = new X509Provider(certConfigPath).getKeyStore();
HttpTransport mtlsTransport = new MtlsHttpTransportFactory(keyStore).create();
callGcs(
identityPoolCredentials,
Expand All @@ -359,26 +383,30 @@ void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws

/**
* IdentityPoolCredentials (OIDC provider with programmatic mTLS and actor token): Uses the
* service account to generate Google ID tokens for subject and actor tokens via suppliers.
* service account private key to sign OIDC JWTs for subject and actor tokens via suppliers.
* Configures mTLS transport using MtlsHttpTransportFactory with KeyStore loaded from the mTLS
* certificate config. Exchanges the tokens over mTLS STS endpoint and calls GCS.
*/
@Test
void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exception {
X509Provider x509Provider = new X509Provider(DEFAULT_MTLS_CERT_CONFIG_PATH);
KeyStore keyStore = x509Provider.getKeyStore();
String certThumbprint = computeCertThumbprint(keyStore);
IdentityPoolSubjectTokenSupplier tokenSupplier =
context -> generateGoogleIdToken(OIDC_AUDIENCE);
IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE);
context ->
generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ true);
IdentityPoolActorTokenSupplier actorSupplier =
context ->
generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ false);

X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath());
HttpTransportFactory transportFactory =
new MtlsHttpTransportFactory(x509Provider.getKeyStore());
HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore);

IdentityPoolCredentials credentials =
IdentityPoolCredentials.newBuilder()
.setSubjectTokenSupplier(tokenSupplier)
.setActorTokenSupplier(actorSupplier)
.setActorTokenType(SubjectTokenTypes.JWT.value)
.setAudience(OIDC_AUDIENCE)
.setAudience(OIDC_JWT_AUDIENCE)
.setSubjectTokenType(SubjectTokenTypes.JWT)
.setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
.setServiceAccountImpersonationUrl(
Expand All @@ -401,10 +429,13 @@ void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exceptio
*/
@Test
void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken()
throws IOException {
String certConfigPath = getMtlsCertificateConfigPath();
String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE);
String actorToken = generateGoogleIdToken(OIDC_AUDIENCE);
throws Exception {
KeyStore keyStore = new X509Provider(DEFAULT_MTLS_CERT_CONFIG_PATH).getKeyStore();
String certThumbprint = computeCertThumbprint(keyStore);
String subjectToken =
generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ true);
String actorToken =
generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ false);

File tokenFile =
File.createTempFile(
Expand All @@ -424,7 +455,7 @@ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken

GenericJson config = new GenericJson();
config.put("type", "external_account");
config.put("audience", OIDC_AUDIENCE);
config.put("audience", OIDC_JWT_AUDIENCE);
config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt");
config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt");
config.put("token_url", "https://sts.mtls.googleapis.com/v1/token");
Expand All @@ -439,7 +470,7 @@ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken
credentialSource.put("format", format);

GenericJson certificate = new GenericJson();
certificate.put("certificate_config_location", certConfigPath);
certificate.put("certificate_config_location", DEFAULT_MTLS_CERT_CONFIG_PATH);
credentialSource.put("certificate", certificate);

config.put("credential_source", credentialSource);
Expand All @@ -451,6 +482,7 @@ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken
AccessToken accessToken = identityPoolCredentials.refreshAccessToken();
assertNotNull(accessToken);
assertNotNull(accessToken.getTokenValue());
assertFalse(accessToken.getTokenValue().isEmpty());
} finally {
tokenFile.delete();
}
Expand All @@ -463,20 +495,24 @@ void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken
*/
@Test
void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throws Exception {
X509Provider x509Provider = new X509Provider(DEFAULT_MTLS_CERT_CONFIG_PATH);
KeyStore keyStore = x509Provider.getKeyStore();
String certThumbprint = computeCertThumbprint(keyStore);
IdentityPoolSubjectTokenSupplier tokenSupplier =
context -> generateGoogleIdToken(OIDC_AUDIENCE);
IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE);
context ->
generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ true);
IdentityPoolActorTokenSupplier actorSupplier =
context ->
generateSignedOidcJwt(OIDC_JWT_AUDIENCE, certThumbprint, /* includeMayAct= */ false);

X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath());
HttpTransportFactory transportFactory =
new MtlsHttpTransportFactory(x509Provider.getKeyStore());
HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore);

IdentityPoolCredentials credentials =
IdentityPoolCredentials.newBuilder()
.setSubjectTokenSupplier(tokenSupplier)
.setActorTokenSupplier(actorSupplier)
.setActorTokenType(SubjectTokenTypes.JWT.value)
.setAudience(OIDC_AUDIENCE)
.setAudience(OIDC_JWT_AUDIENCE)
.setSubjectTokenType(SubjectTokenTypes.JWT)
.setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
.setHttpTransportFactory(transportFactory)
Expand All @@ -485,6 +521,7 @@ void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throw
AccessToken accessToken = credentials.refreshAccessToken();
assertNotNull(accessToken);
assertNotNull(accessToken.getTokenValue());
assertFalse(accessToken.getTokenValue().isEmpty());
}

private GenericJson buildIdentityPoolCredentialConfig() throws IOException {
Expand Down Expand Up @@ -588,12 +625,59 @@ private GenericJson buildAwsCredentialConfig() {
return config;
}

private String getMtlsCertificateConfigPath() {
String certConfigPath = System.getenv("GOOGLE_API_CERTIFICATE_CONFIG");
assumeTrue(
certConfigPath != null && !certConfigPath.isEmpty(),
"Skipping mTLS test: GOOGLE_API_CERTIFICATE_CONFIG env variable is not set.");
return certConfigPath;
private String computeCertThumbprint(KeyStore keyStore) throws IOException {
try {
Enumeration<String> aliases = keyStore.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (keyStore.isKeyEntry(alias)) {
Certificate[] chain = keyStore.getCertificateChain(alias);
Certificate cert =
(chain != null && chain.length > 0) ? chain[0] : keyStore.getCertificate(alias);
if (cert instanceof X509Certificate) {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(cert.getEncoded());
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
}
}
} catch (GeneralSecurityException e) {
throw new IOException("Failed to compute X.509 certificate SHA-256 thumbprint.", e);
}
throw new IOException("No X509Certificate found in KeyStore.");
}

private String generateSignedOidcJwt(String audience, String x5tS256, boolean includeMayAct)
throws IOException {
JsonWebSignature.Header header = new JsonWebSignature.Header();
header.setAlgorithm("RS256");
header.setType("JWT");
header.setKeyId(privateKeyId);

long nowSeconds = Instant.now().getEpochSecond();
JsonWebToken.Payload payload = new JsonWebToken.Payload();
payload.setIssuer(OIDC_JWT_ISSUER);
payload.setSubject(clientId);
payload.setAudience(audience);
payload.setIssuedAtTimeSeconds(nowSeconds);
payload.setExpirationTimeSeconds(nowSeconds + 3600);

Map<String, Object> cnf = new HashMap<>();
cnf.put("x5t#S256", x5tS256);
payload.put("cnf", cnf);

if (includeMayAct) {
Map<String, Object> mayAct = new HashMap<>();
mayAct.put("sub", clientId);
mayAct.put("iss", OIDC_JWT_ISSUER);
payload.put("may_act", mayAct);
}

try {
return JsonWebSignature.signUsingRsaSha256(
privateKey, OAuth2Utils.JSON_FACTORY, header, payload);
} catch (GeneralSecurityException e) {
throw new IOException("Error signing OIDC JWT with service account private key.", e);
}
}

private void callGcs(GoogleCredentials credentials) throws IOException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ generate_random_string

pool_id="pool-"${suffix}
oidc_provider_id="oidc-"${suffix}
oidc_jwt_provider_id="oidc-jwt-"${suffix}
aws_provider_id="aws-"${suffix}

# Fill in.
Expand All @@ -115,6 +116,7 @@ service_account_email=""
sub=""; # client_id from service account key file

oidc_aud="//iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${oidc_provider_id}"
oidc_jwt_aud="//iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${oidc_jwt_provider_id}"
aws_aud="//iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${aws_provider_id}"

gcloud config set project ${project_id}
Expand All @@ -132,6 +134,26 @@ gcloud beta iam workload-identity-pools providers create-oidc ${oidc_provider_id
--location="global" \
--attribute-mapping="google.subject=assertion.sub"

# Create the OIDC JWT Provider for mTLS / certificate-bound and actor token tests.
jwk_file=$(mktemp)
curl -sS "https://www.googleapis.com/service_accounts/v1/jwk/${service_account_email}" > "${jwk_file}"
gcloud beta iam workload-identity-pools providers create-oidc ${oidc_jwt_provider_id} \
--workload-identity-pool=${pool_id} \
--issuer-uri="https://storage.googleapis.com/example-oidc-issuer" \
--jwk-json-path="${jwk_file}" \
--location="global" \
--attribute-mapping="google.subject=assertion.sub"
rm -f "${jwk_file}"

# Configure actorProviders on the OIDC JWT Provider to allow actor_token delegation.
oidc_jwt_provider_resource="projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${oidc_jwt_provider_id}"
curl -sS -X PATCH \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "X-Goog-User-Project: ${project_id}" \
-H "Content-Type: application/json" \
-d "{\"actorProviders\": [\"${oidc_jwt_provider_resource}\"]}" \
"https://iam.googleapis.com/v1/${oidc_jwt_provider_resource}?updateMask=actorProviders"

# Create the AWS Provider.
gcloud beta iam workload-identity-pools providers create-aws ${aws_provider_id} \
--workload-identity-pool=${pool_id} \
Expand All @@ -148,6 +170,7 @@ gcloud iam service-accounts add-iam-policy-binding ${service_account_email} \
--member "principalSet://iam.googleapis.com/projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/attribute.aws_role/arn:aws:sts::${aws_account_id}:assumed-role/${aws_role_name}"

echo "OIDC audience:"${oidc_aud}
echo "OIDC JWT audience:"${oidc_jwt_aud}
echo "AWS audience:"${aws_aud}
echo "AWS role name:"${aws_role_name}
echo "AWS role ARN: arn:aws:iam::${aws_account_id}:role/${aws_role_name}"
Loading