Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
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
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
1f062f2
fix(oauth2): address review feedback on cached STS token retry and mT…
macastelaz Sep 25, 2026
4e06ff0
test(oauth2): assert pinned KeyStore per refresh cycle in cert rotati…
macastelaz Sep 25, 2026
d5317c2
fix(oauth2): address follow-up review feedback on transport visibilit…
macastelaz Sep 25, 2026
091a91b
fix(auth): address PR #14212 follow-up review comments
macastelaz Sep 28, 2026
4bf0fdd
fix(oauth2): extract refreshWithRetry helpers and clarify mTLS/impers…
macastelaz Oct 14, 2026
fe93d90
chore(oauth2_http): simplify IdentityPoolCredentials retry flow and s…
macastelaz Oct 14, 2026
a9acfb5
Merge branch 'oauth2-bound-tokens' into cert-bound-oauth-iam-pinning
macastelaz Oct 15, 2026
16716d0
fix(auth): return null on KeyStore error and unconditionally clear sc…
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 @@ -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,11 @@ public class AwsCredentials extends ExternalAccountCredentials {

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

StsTokenExchangeRequest.Builder stsTokenExchangeRequest =
StsTokenExchangeRequest.newBuilder(retrieveSubjectToken(), getSubjectTokenType())
.setAudience(getAudience());
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 All @@ -64,6 +68,9 @@
public class CertificateIdentityPoolSubjectTokenSupplier
implements IdentityPoolSubjectTokenSupplier {

// Matches the computed value in released versions so previously serialized instances still load.
private static final long serialVersionUID = -7726329500407809830L;

private final IdentityPoolCredentialSource credentialSource;

private static final Pattern PEM_CERT_PATTERN =
Expand Down Expand Up @@ -96,6 +103,34 @@ private static String loadAndEncodeLeafCertificate(String path) throws IOExcepti
}
}

private static @Nullable String extractAndEncodeLeafCertificate(@Nullable KeyStore keyStore) {
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) {
return null;
}
}

@VisibleForTesting
static X509Certificate parseCertificate(byte[] certData) throws CertificateException {
if (certData == null || certData.length == 0) {
Expand Down Expand Up @@ -135,14 +170,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)

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.

This new non-private overload changes the computed serialVersionUID, so the previously saved cert credentials fail to load with InvalidClassException.

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.

CertificateIdentityPoolSubjectTokenSupplier now declares serialVersionUID = -7726329500407809830L, which is the computed value in the released jars (identical from 1.50.0 through 1.53.0). I verified it end to end: a cert-sourced IdentityPoolCredentials serialized with 1.53.0 now deserializes with this PR's classes, and fails with InvalidClassException without the pin. Added a test that asserts the value.

I checked the other serializable classes this PR touches against 1.53.0. The same issue applied to OAuthException (new constructor and getHttpStatusCode()), so it's now pinned to its released value (-5276727039237496975L) with a test too. PluggableAuthCredentials' computed value also changed, but it isn't serializable in released versions (the handler field), so there's nothing to keep compatible. The rest keep their released values.

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 +270,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 +290,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 @@ -85,6 +85,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 @@ -93,9 +94,9 @@ public abstract class ExternalAccountCredentials extends GoogleCredentials {
// Service.
private final @Nullable String workforcePoolUserProject;

protected transient HttpTransportFactory transportFactory;
protected transient volatile 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 +197,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 +238,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 +289,26 @@ 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);
}
Comment on lines +295 to 302

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can this just be simplified to sourceBuilder = toBuilder()?

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.

ExternalAccountCredentials inherits the concrete public GoogleCredentials.Builder toBuilder() from GoogleCredentials and doesn't override toBuilder() (since ExternalAccountCredentials.Builder is abstract), so toBuilder() here has return type GoogleCredentials.Builder rather than ExternalAccountCredentials.Builder. Re-declaring @Override public abstract Builder toBuilder() on ExternalAccountCredentials would turn an inherited concrete method into an abstract method on a public class (which is a breaking binary/source compatibility change in Clirr), and casting (ExternalAccountCredentials.Builder) toBuilder() would throw ClassCastException at runtime if a custom subclass didn't override toBuilder(), so we kept the explicit instanceof branches here.


String targetPrincipal =
ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl);
// The intermediate STS access token on sourceCredentials is used to authenticate to the IAM
// Credentials generateAccessToken endpoint, which requires the cloud-platform scope (whereas
// this.scopes are passed to ImpersonatedCredentials below for the final impersonated token).
sourceBuilder
.setServiceAccountImpersonationUrl(null)
.setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can you add a note to explain why we add this scope?

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.

Done β€” added a comment explaining that sourceCredentials needs CLOUD_PLATFORM_SCOPE to authenticate to the IAM generateAccessToken endpoint, while this.scopes are passed to ImpersonatedCredentials for the final impersonated token.

sourceBuilder.targetServiceAccountEmail = targetPrincipal;
sourceBuilder.setAccessToken(null);
ExternalAccountCredentials sourceCredentials = sourceBuilder.build();

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.

Should we be clearing the copied access token before building the source credentials?

e.g. after serialization, a forced refresh rebuilds the impersonation credentials but copies the cached service account token which IAM will receive, potentially causing issues (e.g. a token with only a Storage scope won't work).

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.

Good catch.

impersonatedCredentials is now transient and rebuilt lazily, so after deserialization newBuilder(this) copied the outer credential's cached service account token into the source. On the non-pinned path the source then looked fresh, skipped STS, and sent that token to IAM. ensureExternalSourceScoped() didn't catch it because the source is always built with cloud-platform scope. buildImpersonatedCredentials() now clears the copied access token before building the source.

Added two tests:

  • getImpersonatedCredentials_outerHasCachedToken_sourceDoesNotCopyIt
  • refreshAccessToken_withImpersonationAfterDeserialization_sendsStsTokenToIam: refreshes,
    serializes/deserializes, refreshes again, and asserts a new STS exchange happens and IAM
    receives the STS token rather than the service account token.

Both fail without the fix.

return ImpersonatedCredentials.newBuilder()
.setSourceCredentials(sourceCredentials)
.setHttpTransportFactory(transportFactory)
Expand Down Expand Up @@ -522,8 +524,21 @@ 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;
}

/**
Expand Down Expand Up @@ -552,11 +567,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 +651,7 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou
*/
public @Nullable String getServiceAccountEmail() {
if (serviceAccountImpersonationUrl == null || serviceAccountImpersonationUrl.isEmpty()) {
return null;
return targetServiceAccountEmail;
Comment thread
lqiu96 marked this conversation as resolved.
}
return ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl);
}
Expand Down Expand Up @@ -789,6 +803,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 +828,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 +927,7 @@ public Builder setCredentialSource(CredentialSource credentialSource) {
public Builder setServiceAccountImpersonationUrl(
@Nullable String serviceAccountImpersonationUrl) {
this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl;
this.targetServiceAccountEmail = null;
return this;
}

Expand Down
Loading
Loading