Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
e8300d7
chore(oauth2): merge origin/oauth2-bound-tokens into cert-bound-oauth…
macastelaz Oct 15, 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 @@ -36,7 +36,7 @@
import com.google.api.client.http.HttpResponseException;
import com.google.api.client.json.GenericJson;
import com.google.api.client.json.JsonParser;
import java.io.IOException;
import java.util.Map;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

Expand Down Expand Up @@ -99,20 +99,67 @@ int getHttpStatusCode() {
return httpStatusCode;
}

static OAuthException createFromHttpResponseException(HttpResponseException e)
throws IOException {
JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser((e).getContent());
GenericJson errorResponse = parser.parseAndClose(GenericJson.class);

String errorCode = (String) errorResponse.get("error");
String errorDescription = null;
String errorUri = null;
if (errorResponse.containsKey("error_description")) {
errorDescription = (String) errorResponse.get("error_description");
static OAuthException createFromHttpResponseException(HttpResponseException e) {
String content = e.getContent();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: This method catches Exception internally and returns an OAuthException on every path. We can safely remove the throws IOException declaration and the unused import.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed throws IOException from createFromHttpResponseException and dropped the unused java.io.IOException import.

if (content == null || content.trim().isEmpty()) {
OAuthException oauthException =
new OAuthException(
"http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode());
oauthException.initCause(e);
return oauthException;
}
if (errorResponse.containsKey("error_uri")) {
errorUri = (String) errorResponse.get("error_uri");
try {
JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(content);
GenericJson errorResponse = parser.parseAndClose(GenericJson.class);
if (errorResponse == null) {
OAuthException oauthException =
new OAuthException(
"http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode());
oauthException.initCause(e);
return oauthException;
}

String errorCode = null;
String errorDescription = null;
Object rawError = errorResponse.get("error");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

parser.parseAndClose(GenericJson.class) returns null if the response content is the JSON literal "null", which will cause a NullPointerException right below when calling errorResponse.get("error"). We should add an explicit if (errorResponse == null) check here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added an explicit if (errorResponse == null) check and added createFromHttpResponseException_jsonLiteralNull() in OAuthExceptionTest.

if (rawError instanceof String) {
errorCode = (String) rawError;
} else if (rawError instanceof Map) {
Map<?, ?> errorMap = (Map<?, ?>) rawError;
if (errorMap.get("status") instanceof String) {
errorCode = (String) errorMap.get("status");
}
if (errorMap.get("message") instanceof String) {
errorDescription = (String) errorMap.get("message");
}
}
if (errorCode == null) {
errorCode = "http_error_" + e.getStatusCode();
}
String errorUri = null;
if (errorResponse.get("error_description") instanceof String) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If a server returns a valid JSON object without "error_description" or a nested "error.message" map (such as {"message": "Certificate expired"} or {}), errorDescription stays null and drops both content and e.getStatusMessage(). Can we fall back when errorDescription == null so the error details are not lost?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated createFromHttpResponseException to extract top-level "message" strings and fall back to e.getStatusMessage() when {} or content when rawError == null && errorDescription == null, with unit tests in OAuthExceptionTest.

errorDescription = (String) errorResponse.get("error_description");
} else if (errorDescription == null && errorResponse.get("message") instanceof String) {
errorDescription = (String) errorResponse.get("message");
}
if (errorDescription == null && !(rawError instanceof String)) {
errorDescription =
errorResponse.isEmpty() && e.getStatusMessage() != null
? e.getStatusMessage()
: content;
}
if (errorResponse.get("error_uri") instanceof String) {
errorUri = (String) errorResponse.get("error_uri");
}
OAuthException oauthException =
new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode());
oauthException.initCause(e);
return oauthException;
} catch (Exception parseException) {
OAuthException oauthException =
new OAuthException("http_error_" + e.getStatusCode(), content, null, e.getStatusCode());
oauthException.initCause(e);
return oauthException;
}
return new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@

package com.google.auth.oauth2;

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;
import com.google.api.client.http.HttpRequestFactory;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.UrlEncodedContent;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.GenericJson;
Expand All @@ -46,12 +49,16 @@
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.GenericData;
import com.google.auth.http.HttpCredentialsAdapter;
import com.google.auth.http.HttpTransportFactory;
import com.google.auth.mtls.MtlsHttpTransportFactory;
import com.google.auth.mtls.X509Provider;
import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
Expand Down Expand Up @@ -281,6 +288,205 @@ void identityPoolCredentials_withProgrammaticAuth() throws IOException {
callGcs(identityPoolCredentials);
}

/**
* 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
* 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);

File tokenFile =
File.createTempFile(
"ITWorkloadIdentityFederation_cert_actor", /* suffix= */ null, /* directory= */ null);
tokenFile.deleteOnExit();
try {
GenericJson tokenJson = new GenericJson();
tokenJson.setFactory(OAuth2Utils.JSON_FACTORY);
tokenJson.put("subject_token", subjectToken);
tokenJson.put("actor_token", actorToken);

OAuth2Utils.writeInputStreamToFile(
new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)),
tokenFile.getAbsolutePath());

GenericJson config = new GenericJson();
config.put("type", "external_account");
config.put("audience", OIDC_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");
config.put(
"service_account_impersonation_url",
String.format(
"https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
clientEmail));

GenericJson credentialSource = new GenericJson();
credentialSource.put("file", tokenFile.getAbsolutePath());

GenericJson format = new GenericJson();
format.put("type", "json");
format.put("subject_token_field_name", "subject_token");
format.put("actor_token_field_name", "actor_token");
credentialSource.put("format", format);

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

config.put("credential_source", credentialSource);

IdentityPoolCredentials identityPoolCredentials =
(IdentityPoolCredentials)
ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY);

KeyStore keyStore = new X509Provider(certConfigPath).getKeyStore();
HttpTransport mtlsTransport = new MtlsHttpTransportFactory(keyStore).create();
callGcs(
identityPoolCredentials,
mtlsTransport,
"https://storage.mtls.googleapis.com/storage/v1/b/");
} finally {
tokenFile.delete();
}
}

/**
* 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.
* 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 {
IdentityPoolSubjectTokenSupplier tokenSupplier =
context -> generateGoogleIdToken(OIDC_AUDIENCE);
IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE);

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

IdentityPoolCredentials credentials =
IdentityPoolCredentials.newBuilder()
.setSubjectTokenSupplier(tokenSupplier)
.setActorTokenSupplier(actorSupplier)
.setActorTokenType(SubjectTokenTypes.JWT.value)
.setAudience(OIDC_AUDIENCE)
.setSubjectTokenType(SubjectTokenTypes.JWT)
.setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
.setServiceAccountImpersonationUrl(
String.format(
"https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
clientEmail))
.setHttpTransportFactory(transportFactory)
.build();

callGcs(
credentials,
transportFactory.create(),
"https://storage.mtls.googleapis.com/storage/v1/b/");
}

/**
* IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token, direct
* STS): Exchanges tokens directly over the mTLS STS endpoint
* (https://sts.mtls.googleapis.com/v1/token) without service account impersonation.
*/
@Test
void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken()
throws IOException {
String certConfigPath = getMtlsCertificateConfigPath();
String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE);
String actorToken = generateGoogleIdToken(OIDC_AUDIENCE);

File tokenFile =
File.createTempFile(
"ITWorkloadIdentityFederation_direct_cert_actor",
/* suffix= */ null,
/* directory= */ null);
tokenFile.deleteOnExit();
try {
GenericJson tokenJson = new GenericJson();
tokenJson.setFactory(OAuth2Utils.JSON_FACTORY);
tokenJson.put("subject_token", subjectToken);
tokenJson.put("actor_token", actorToken);

OAuth2Utils.writeInputStreamToFile(
new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)),
tokenFile.getAbsolutePath());

GenericJson config = new GenericJson();
config.put("type", "external_account");
config.put("audience", OIDC_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");

GenericJson credentialSource = new GenericJson();
credentialSource.put("file", tokenFile.getAbsolutePath());

GenericJson format = new GenericJson();
format.put("type", "json");
format.put("subject_token_field_name", "subject_token");
format.put("actor_token_field_name", "actor_token");
credentialSource.put("format", format);

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

config.put("credential_source", credentialSource);

IdentityPoolCredentials identityPoolCredentials =
(IdentityPoolCredentials)
ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY);

AccessToken accessToken = identityPoolCredentials.refreshAccessToken();
assertNotNull(accessToken);
assertNotNull(accessToken.getTokenValue());
} finally {
tokenFile.delete();
}
}

/**
* IdentityPoolCredentials (OIDC provider with programmatic mTLS and actor token, direct STS):
* Uses suppliers for subject and actor tokens, configuring MtlsHttpTransportFactory. Exchanges
* directly over mTLS STS endpoint without service account impersonation.
*/
@Test
void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throws Exception {
IdentityPoolSubjectTokenSupplier tokenSupplier =
context -> generateGoogleIdToken(OIDC_AUDIENCE);
IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE);

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

IdentityPoolCredentials credentials =
IdentityPoolCredentials.newBuilder()
.setSubjectTokenSupplier(tokenSupplier)
.setActorTokenSupplier(actorSupplier)
.setActorTokenType(SubjectTokenTypes.JWT.value)
.setAudience(OIDC_AUDIENCE)
.setSubjectTokenType(SubjectTokenTypes.JWT)
.setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
.setHttpTransportFactory(transportFactory)
.build();

AccessToken accessToken = credentials.refreshAccessToken();
assertNotNull(accessToken);
assertNotNull(accessToken.getTokenValue());
}

private GenericJson buildIdentityPoolCredentialConfig() throws IOException {
String idToken = generateGoogleIdToken(OIDC_AUDIENCE);

Expand Down Expand Up @@ -382,24 +588,41 @@ 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 void callGcs(GoogleCredentials credentials) throws IOException {
callGcs(credentials, new NetHttpTransport(), "https://storage.googleapis.com/storage/v1/b/");
}

private void callGcs(
GoogleCredentials credentials, HttpTransport transport, String storageBaseUrl)
throws IOException {
String bucketName = System.getenv("GCS_BUCKET");
if (bucketName == null) {
fail("GCS bucket name not set through GCS_BUCKET env variable.");
}

String url = "https://storage.googleapis.com/storage/v1/b/" + bucketName;
String url = storageBaseUrl + bucketName;

HttpCredentialsAdapter credentialsAdapter = new HttpCredentialsAdapter(credentials);
HttpRequestFactory requestFactory =
new NetHttpTransport().createRequestFactory(credentialsAdapter);
HttpRequestFactory requestFactory = transport.createRequestFactory(credentialsAdapter);
HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));

JsonObjectParser parser = new JsonObjectParser(GsonFactory.getDefaultInstance());
request.setParser(parser);

HttpResponse response = request.execute();
assertTrue(response.isSuccessStatusCode());
try {
assertTrue(response.isSuccessStatusCode());
} finally {
response.disconnect();
}
}

/**
Expand Down
Loading
Loading