Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
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 @@ -96,6 +96,11 @@ public boolean hasKeyStore() {
return this.hasKeyStore;
}

/** Returns the {@link KeyStore} used by this factory, or {@code null} if none was configured. */
public @Nullable KeyStore getKeyStore() {
return this.mtlsKeyStore;
}

private static boolean checkHasKeyStore(@Nullable KeyStore keyStore) {
if (keyStore == null) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ public class AwsCredentials extends ExternalAccountCredentials {

@Override
public AccessToken refreshAccessToken() throws IOException {
return refreshAccessToken(this.transportFactory);
}

@Override
AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException {
ImpersonatedCredentials impersonated = getImpersonatedCredentials();
if (impersonated != null) {
return impersonated.refreshAccessToken(null);
}

StsTokenExchangeRequest.Builder stsTokenExchangeRequest =
StsTokenExchangeRequest.newBuilder(retrieveSubjectToken(), getSubjectTokenType())
.setAudience(getAudience());
Expand All @@ -130,7 +140,8 @@ public AccessToken refreshAccessToken() throws IOException {
stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes));
}

return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build());
return exchangeExternalCredentialForAccessToken(
stsTokenExchangeRequest.build(), cycleTransportFactory);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,21 @@
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Paths;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Enumeration;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

/**
* Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} by reading an
Expand Down Expand Up @@ -96,6 +100,35 @@ private static String loadAndEncodeLeafCertificate(String path) throws IOExcepti
}
}

private static @Nullable String extractAndEncodeLeafCertificate(@Nullable KeyStore keyStore)
throws IOException {
if (keyStore == null) {
return null;
}
try {
Enumeration<String> aliases = keyStore.aliases();
if (aliases == null) {
return null;
}
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (keyStore.isKeyEntry(alias)) {
Certificate[] chain = keyStore.getCertificateChain(alias);
if (chain != null && chain.length > 0 && chain[0] instanceof X509Certificate) {
return encodeCert((X509Certificate) chain[0]);
}
Certificate cert = keyStore.getCertificate(alias);
if (cert instanceof X509Certificate) {
return encodeCert((X509Certificate) cert);
}
}
}
return null;
} catch (KeyStoreException | CertificateEncodingException e) {
throw new IOException("Failed to extract leaf certificate from pinned KeyStore", e);
}
}

@VisibleForTesting
static X509Certificate parseCertificate(byte[] certData) throws CertificateException {
if (certData == null || certData.length == 0) {
Expand Down Expand Up @@ -135,14 +168,27 @@ private static String encodeCert(X509Certificate certificate)
*/
@Override
public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException {
String leafCertPath = credentialSource.getCredentialLocation();
return getSubjectToken(context, null);
}

/**
* Retrieves the X509 subject token, extracting the leaf certificate directly from {@code
* pinnedKeyStore} when provided so that the TLS client certificate and {@code subject_token} are
* pinned to the exact same certificate snapshot.
*/
String getSubjectToken(ExternalAccountSupplierContext context, @Nullable KeyStore pinnedKeyStore)
throws IOException {
String trustChainPath = null;
if (credentialSource.getCertificateConfig() != null) {
trustChainPath = credentialSource.getCertificateConfig().getTrustChainPath();
}

// Load and encode the leaf certificate.
String encodedLeafCert = loadAndEncodeLeafCertificate(leafCertPath);
// Extract the leaf certificate from the pinned KeyStore if present; otherwise read from disk.
String encodedLeafCert = extractAndEncodeLeafCertificate(pinnedKeyStore);
if (encodedLeafCert == null) {
String leafCertPath = credentialSource.getCredentialLocation();
encodedLeafCert = loadAndEncodeLeafCertificate(leafCertPath);
}

// Initialize the certificate chain for the subject token. The Security Token Service (STS)
// requires that the leaf certificate (the one used for authenticating this workload) must be
Expand Down Expand Up @@ -222,7 +268,8 @@ private void populateCertChainFromTrustChain(
// elsewhere in the chain.
if (encodedCurrentCert.equals(encodedLeafCert)) {
throw new IllegalArgumentException(
"The leaf certificate should only appear at the beginning of the trust chain file, or be omitted entirely.");
"The leaf certificate should only appear at the beginning of the trust chain file, or"
+ " be omitted entirely.");
}

// Add the current certificate to the chain.
Expand All @@ -241,7 +288,7 @@ private void populateCertChainFromTrustChain(
* @throws CertificateException If an error occurs while parsing a certificate.
*/
@VisibleForTesting
static List<X509Certificate> readTrustChain(String trustChainPath)
static List<X509Certificate> readTrustChain(@Nullable String trustChainPath)
throws IOException, CertificateException {
List<X509Certificate> certificateTrustChain = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import java.io.ObjectInputStream;
import java.math.BigDecimal;
import java.net.URI;
import java.security.KeyStore;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
Expand Down Expand Up @@ -85,6 +86,7 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials {

private final @Nullable String tokenInfoUrl;
private final @Nullable String serviceAccountImpersonationUrl;
private final @Nullable String targetServiceAccountEmail;
private final @Nullable String clientId;
private final @Nullable String clientSecret;

Expand All @@ -95,7 +97,7 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials {

protected transient HttpTransportFactory transportFactory;

protected @Nullable ImpersonatedCredentials impersonatedCredentials;
protected transient volatile @Nullable ImpersonatedCredentials impersonatedCredentials;

private final EnvironmentProvider environmentProvider;
private final PropertyProvider propertyProvider;
Expand Down Expand Up @@ -196,6 +198,7 @@ protected ExternalAccountCredentials(
this.credentialSource = checkNotNull(credentialSource);
this.tokenInfoUrl = tokenInfoUrl;
this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl;
this.targetServiceAccountEmail = null;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.scopes =
Expand Down Expand Up @@ -236,6 +239,7 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder)
this.credentialSource = builder.credentialSource;
this.tokenInfoUrl = builder.tokenInfoUrl;
this.serviceAccountImpersonationUrl = builder.serviceAccountImpersonationUrl;
this.targetServiceAccountEmail = builder.targetServiceAccountEmail;
this.clientId = builder.clientId;
this.clientSecret = builder.clientSecret;

Expand Down Expand Up @@ -286,27 +290,22 @@ protected ExternalAccountCredentials(ExternalAccountCredentials.Builder builder)
if (serviceAccountImpersonationUrl == null) {
return null;
}
String targetPrincipal =
ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl);
// Create a copy of this instance without service account impersonation.
ExternalAccountCredentials sourceCredentials;
ExternalAccountCredentials.Builder sourceBuilder;
if (this instanceof AwsCredentials) {
sourceCredentials =
AwsCredentials.newBuilder((AwsCredentials) this)
.setServiceAccountImpersonationUrl(null)
.build();
sourceBuilder = AwsCredentials.newBuilder((AwsCredentials) this);
} else if (this instanceof PluggableAuthCredentials) {
sourceCredentials =
PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this)
.setServiceAccountImpersonationUrl(null)
.build();
sourceBuilder = PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this);
} else {
sourceCredentials =
IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this)
.setServiceAccountImpersonationUrl(null)
.build();
sourceBuilder = IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this);
}

String targetPrincipal =
ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl);
sourceBuilder
.setServiceAccountImpersonationUrl(null)
.setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE));
sourceBuilder.targetServiceAccountEmail = targetPrincipal;
ExternalAccountCredentials sourceCredentials = sourceBuilder.build();
return ImpersonatedCredentials.newBuilder()
.setSourceCredentials(sourceCredentials)
.setHttpTransportFactory(transportFactory)
Expand Down Expand Up @@ -522,8 +521,42 @@ private static boolean isAwsCredential(Map<String, Object> credentialSource) {
&& ((String) credentialSource.get("environment_id")).startsWith("aws");
}

private boolean shouldBuildImpersonatedCredential() {
return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null;
@Nullable ImpersonatedCredentials getImpersonatedCredentials() {
if (this.serviceAccountImpersonationUrl == null) {
return null;
}
ImpersonatedCredentials local = this.impersonatedCredentials;
if (local == null) {
synchronized (this) {
local = this.impersonatedCredentials;
if (local == null) {
local = this.buildImpersonatedCredentials();
this.impersonatedCredentials = local;
}
}
}
return local;
}

/**
* Refreshes the access token using the specified transport factory for per-cycle transport

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: IdentityPoolCredentials.refreshAccessToken() delegates directly into refreshWithRetry(..., true) rather than calling refreshAccessToken(HttpTransportFactory). We should update this Javadoc to reflect the subclass behavior accurately.

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 the Javadoc on refreshAccessToken(HttpTransportFactory).

* pinning. {@link AwsCredentials} and {@link PluggableAuthCredentials} delegate {@link
* #refreshAccessToken()} into this method, while {@link IdentityPoolCredentials} coordinates
* per-cycle transport pinning and retries directly. This default implementation delegates back to
* {@link #refreshAccessToken()} for any custom subclasses that do not override this method.
*
* @param cycleTransportFactory the HTTP transport factory to use for this refresh cycle
* @return the refreshed access token
* @throws IOException if the token refresh fails
*/
AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException {
return refreshAccessToken();
}

AccessToken refreshAccessToken(
HttpTransportFactory cycleTransportFactory, @Nullable KeyStore pinnedKeyStore)
throws IOException {
return refreshAccessToken(cycleTransportFactory);
}

/**
Expand Down Expand Up @@ -552,11 +585,10 @@ protected AccessToken exchangeExternalCredentialForAccessToken(
StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory)
throws IOException {
// Handle service account impersonation if necessary.
if (this.shouldBuildImpersonatedCredential()) {
this.impersonatedCredentials = this.buildImpersonatedCredentials();
}
if (this.impersonatedCredentials != null) {
return this.impersonatedCredentials.refreshAccessToken();
ImpersonatedCredentials impersonated = getImpersonatedCredentials();
if (impersonated != null) {
return impersonated.refreshAccessToken(
cycleTransportFactory == this.transportFactory ? null : cycleTransportFactory);
}

StsRequestHandler.Builder requestHandler =
Expand Down Expand Up @@ -637,7 +669,7 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou
*/
public @Nullable String getServiceAccountEmail() {
if (serviceAccountImpersonationUrl == null || serviceAccountImpersonationUrl.isEmpty()) {
return null;
return targetServiceAccountEmail;
}
return ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl);
}
Expand Down Expand Up @@ -789,6 +821,7 @@ public abstract static class Builder extends GoogleCredentials.Builder {
protected @Nullable HttpTransportFactory transportFactory;

protected @Nullable String serviceAccountImpersonationUrl;
private @Nullable String targetServiceAccountEmail;
protected @Nullable String clientId;
protected @Nullable String clientSecret;
protected @Nullable Collection<String> scopes;
Expand All @@ -813,6 +846,7 @@ protected Builder(ExternalAccountCredentials credentials) {
this.tokenUrl = credentials.tokenUrl;
this.tokenInfoUrl = credentials.tokenInfoUrl;
this.serviceAccountImpersonationUrl = credentials.serviceAccountImpersonationUrl;
this.targetServiceAccountEmail = credentials.targetServiceAccountEmail;
this.credentialSource = credentials.credentialSource;
this.clientId = credentials.clientId;
this.clientSecret = credentials.clientSecret;
Expand Down Expand Up @@ -911,6 +945,7 @@ public Builder setCredentialSource(CredentialSource credentialSource) {
public Builder setServiceAccountImpersonationUrl(
@Nullable String serviceAccountImpersonationUrl) {
this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl;
this.targetServiceAccountEmail = null;
return this;
}

Expand Down
Loading
Loading