Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
package com.google.auth.oauth2;

import com.google.api.client.json.GenericJson;
import com.google.api.core.InternalExtensionOnly;
import com.google.auth.http.HttpTransportFactory;
import com.google.common.annotations.VisibleForTesting;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
Expand Down Expand Up @@ -120,6 +121,18 @@ public class AwsCredentials extends ExternalAccountCredentials {

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

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

StsTokenExchangeRequest.Builder stsTokenExchangeRequest =
StsTokenExchangeRequest.newBuilder(retrieveSubjectToken(), getSubjectTokenType())
.setAudience(getAudience());
Expand All @@ -130,7 +143,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 @@ -36,6 +36,7 @@
import com.google.api.client.http.HttpHeaders;
import com.google.api.client.json.GenericJson;
import com.google.api.client.util.Data;
import com.google.api.core.InternalExtensionOnly;
import com.google.auth.RequestMetadataCallback;
import com.google.auth.http.HttpTransportFactory;
import com.google.common.base.MoreObjects;
Expand Down Expand Up @@ -95,7 +96,7 @@

protected transient HttpTransportFactory transportFactory;

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

private final EnvironmentProvider environmentProvider;
private final PropertyProvider propertyProvider;
Expand Down Expand Up @@ -292,16 +293,19 @@
sourceCredentials =
AwsCredentials.newBuilder((AwsCredentials) this)
.setServiceAccountImpersonationUrl(null)
.setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE))
.build();
} else if (this instanceof PluggableAuthCredentials) {
sourceCredentials =
PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this)
.setServiceAccountImpersonationUrl(null)
.setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE))
.build();
} else {
sourceCredentials =
IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this)
.setServiceAccountImpersonationUrl(null)
.setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE))
.build();
}

Expand Down Expand Up @@ -363,7 +367,7 @@
* external source for authentication to Google Cloud Platform, you must validate it before
* providing it to any Google API or library. Providing an unvalidated credential configuration to
* Google APIs can compromise the security of your systems and data. For more information, refer
* to {@see <a

Check failure on line 370 in google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java

View workflow job for this annotation

GitHub Actions / bom-content-test

no tag name after @

Check failure on line 370 in google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java

View workflow job for this annotation

GitHub Actions / BomContentAssertionsTest (Test for assertion logic in BomContentTest)

no tag name after @
* href="https://cloud.google.com/docs/authentication/external/externally-sourced-credentials">documentation</a>}.
*
* @param credentialsStream the stream with the credential definition
Expand All @@ -384,7 +388,7 @@
* external source for authentication to Google Cloud Platform, you must validate it before
* providing it to any Google API or library. Providing an unvalidated credential configuration to
* Google APIs can compromise the security of your systems and data. For more information, refer
* to {@see <a

Check failure on line 391 in google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java

View workflow job for this annotation

GitHub Actions / bom-content-test

no tag name after @

Check failure on line 391 in google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java

View workflow job for this annotation

GitHub Actions / BomContentAssertionsTest (Test for assertion logic in BomContentTest)

no tag name after @
* href="https://cloud.google.com/docs/authentication/external/externally-sourced-credentials">documentation</a>}.
*
* @param credentialsStream the stream with the credential definition
Expand Down Expand Up @@ -526,6 +530,30 @@
return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null;
}

@Nullable ImpersonatedCredentials getImpersonatedCredentials() {
if (this.shouldBuildImpersonatedCredential()) {
this.impersonatedCredentials = this.buildImpersonatedCredentials();
}
return this.impersonatedCredentials;
}

/**
* Refreshes the access token using the specified transport factory for per-cycle transport
* pinning. Internal subclasses ({@link IdentityPoolCredentials}, {@link AwsCredentials}, {@link
* PluggableAuthCredentials}) delegate {@link #refreshAccessToken()} into this method. 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
*/
@InternalExtensionOnly
public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory)
throws IOException {
return refreshAccessToken();
}

/**
* Exchanges the external credential for a Google Cloud access token.
*
Expand All @@ -552,11 +580,9 @@
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);
}

StsRequestHandler.Builder requestHandler =
Expand Down Expand Up @@ -626,6 +652,7 @@
// Properly deserialize the transient transportFactory.
input.defaultReadObject();
transportFactory = newInstance(transportFactoryClassName);
impersonatedCredentials = null;
}

public @Nullable String getServiceAccountImpersonationUrl() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

package com.google.auth.oauth2;

import com.google.api.core.InternalExtensionOnly;
import com.google.auth.http.HttpTransportFactory;
import com.google.auth.mtls.MtlsHttpTransportFactory;
import com.google.auth.mtls.MtlsUtils;
Expand Down Expand Up @@ -72,7 +73,8 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials {
private final @Nullable String actorTokenType;
// Transient: not serialized directly. Reconstructed in readObject() from the credentialSource
// certificate config so deserialized credentials remain usable for mTLS and refresh.
private transient @Nullable X509Provider x509Provider;
private transient volatile @Nullable X509Provider x509Provider;
private transient @Nullable HttpTransportFactory defaultMtlsTransportFactory;
private final ExternalAccountSupplierContext supplierContext;
private final String metricsHeaderValue;

Expand Down Expand Up @@ -114,8 +116,11 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials {
if (builder.transportFactory == null
|| builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY
|| builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory
|| builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) {
this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore);
|| builder.transportFactory.getClass() == MtlsHttpTransportFactory.class
|| (builder.defaultMtlsTransportFactory != null
&& builder.transportFactory == builder.defaultMtlsTransportFactory)) {
this.transportFactory = createMtlsTransportFactory(mtlsKeyStore);
this.defaultMtlsTransportFactory = this.transportFactory;
} else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) {
LOGGER_PROVIDER
.getLogger()
Expand Down Expand Up @@ -182,7 +187,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials {
if (this.actorTokenSupplier != null && !isMtlsConfigured()) {
throw new IllegalArgumentException(
"Actor tokens are only supported for mTLS token exchanges. Please configure a certificate"
+ " source or MtlsHttpTransportFactory.");
+ " configuration in the credential source or provide an mTLS-enabled transport.");
}

if (this.actorTokenSupplier != null) {
Expand Down Expand Up @@ -228,60 +233,114 @@ private boolean isMtlsConfigured() {
&& ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore());
}

private boolean shouldUseMtlsTransportFactory() {
return this.transportFactory == null
|| this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY
|| this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory
|| this.transportFactory.getClass() == MtlsHttpTransportFactory.class
|| (this.defaultMtlsTransportFactory != null
&& this.transportFactory == this.defaultMtlsTransportFactory);
}

@Override
public AccessToken refreshAccessToken() throws IOException {
// Per-cycle cert pinning: snapshot the KeyStore at the start of each refresh cycle.
HttpTransportFactory cycleTransportFactory = this.transportFactory;
if (this.x509Provider != null && this.transportFactory instanceof MtlsHttpTransportFactory) {
KeyStore pinnedKeyStore = this.x509Provider.getKeyStore();
cycleTransportFactory = new MtlsHttpTransportFactory(pinnedKeyStore);
}

// Read subject and actor tokens, atomically if from the same file supplier.
String subjectToken;
String actorToken = null;
if (this.subjectTokenSupplier instanceof FileIdentityPoolSubjectTokenSupplier
&& this.actorTokenSupplier == this.subjectTokenSupplier) {
FileIdentityPoolSubjectTokenSupplier.TokenPair tokens =
((FileIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier)
.readTokens(supplierContext);
subjectToken = tokens.subject;
actorToken = tokens.actor;
} else {
subjectToken = retrieveSubjectToken();
if (this.actorTokenSupplier != null) {
actorToken = this.actorTokenSupplier.getActorToken(supplierContext);
}
KeyStore pinnedKeyStore = null;
if (this.x509Provider != null && shouldUseMtlsTransportFactory()) {
pinnedKeyStore = this.x509Provider.getKeyStore();
cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore);
}
return refreshWithRetry(cycleTransportFactory, pinnedKeyStore, true);
}

StsTokenExchangeRequest.Builder stsTokenExchangeRequest =
StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType())
.setAudience(getAudience());
@InternalExtensionOnly
@Override
public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory)
throws IOException {
// Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to
// ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM)
// and prevent nested retry amplification. Outer callers manage retry coordination.
return refreshWithRetry(cycleTransportFactory, null, false);
}

if (actorToken != null && this.actorTokenType != null) {
stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType));
}
private AccessToken refreshWithRetry(
HttpTransportFactory cycleTransportFactory,
@Nullable KeyStore pinnedKeyStore,
boolean allowRetry)
throws IOException {
try {
ImpersonatedCredentials impersonated = getImpersonatedCredentials();
if (impersonated != null) {
return impersonated.refreshAccessToken(cycleTransportFactory);
}

Collection<String> scopes = getScopes();
if (scopes != null && !scopes.isEmpty()) {
stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes));
}
// Read subject and actor tokens, atomically if from the same file supplier.
String subjectToken;
String actorToken = null;
if (this.subjectTokenSupplier instanceof FileIdentityPoolSubjectTokenSupplier
&& this.actorTokenSupplier == this.subjectTokenSupplier) {
FileIdentityPoolSubjectTokenSupplier.TokenPair tokens =
((FileIdentityPoolSubjectTokenSupplier) this.subjectTokenSupplier)
.readTokens(supplierContext);
subjectToken = tokens.subject;
actorToken = tokens.actor;
} else {
subjectToken = retrieveSubjectToken();
if (this.actorTokenSupplier != null) {
actorToken = this.actorTokenSupplier.getActorToken(supplierContext);
}
}

StsTokenExchangeRequest.Builder stsTokenExchangeRequest =
StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType())
.setAudience(getAudience());

if (actorToken != null && this.actorTokenType != null) {
stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType));
}

Collection<String> scopes = getScopes();
if (scopes != null && !scopes.isEmpty()) {
stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes));
}

try {
return exchangeExternalCredentialForAccessToken(
stsTokenExchangeRequest.build(), cycleTransportFactory);
} catch (OAuthException e) {
if (e.getHttpStatusCode() == 401
} catch (IOException | RuntimeException e) {
if (allowRetry
&& OAuth2Utils.isUnauthorizedException(e)

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.

isUnauthorizedException matches any 401 anywhere in the chain, so a non-certificate 401 from a revoked service account or bad audience triggers a cert reload plus a full STS and IAM retry even when the reloaded keystore is identical to the pinned one. Should we check whether the reloaded certificate changed before retrying?

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 OAuth2Utils.hasCertificateChanged(pinnedKeyStore, freshKeyStore) and checked it before retrying on a 401; if the reloaded certificate chain has not changed, the original exception is rethrown immediately without retrying STS/IAM.

&& this.x509Provider != null
&& this.transportFactory instanceof MtlsHttpTransportFactory) {
&& shouldUseMtlsTransportFactory()) {
KeyStore freshKeyStore;
try {
// On 401, re-read from X509Provider for fresh certs.
freshKeyStore = this.x509Provider.getKeyStore();
} catch (IOException reloadException) {
if (reloadException != e) {
reloadException.addSuppressed(e);
}
throw reloadException;
} catch (Exception reloadException) {
IOException ioException =
new IOException("Failed to reload certificate on retry", reloadException);
if (reloadException != e) {
ioException.addSuppressed(e);
}
throw ioException;
}

if (!OAuth2Utils.hasCertificateChanged(pinnedKeyStore, freshKeyStore)) {
throw e;
}

try {
// On 401, re-read from X509Provider for fresh certs and retry once.
KeyStore freshKeyStore = this.x509Provider.getKeyStore();
HttpTransportFactory retryTransportFactory = new MtlsHttpTransportFactory(freshKeyStore);
return exchangeExternalCredentialForAccessToken(
stsTokenExchangeRequest.build(), retryTransportFactory);
} catch (IOException retryException) {
retryException.addSuppressed(e);
HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore);
return refreshWithRetry(retryTransportFactory, freshKeyStore, false);
} catch (IOException | RuntimeException retryException) {
if (retryException != e) {
retryException.addSuppressed(e);
}
throw retryException;
}
}
Expand Down Expand Up @@ -324,6 +383,11 @@ HttpTransportFactory getTransportFactory() {
return this.x509Provider;
}

@VisibleForTesting
HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) {
return new MtlsHttpTransportFactory(keyStore);
}

/** Clones the IdentityPoolCredentials with the specified scopes. */
@Override
public IdentityPoolCredentials createScoped(Collection<String> newScopes) {
Expand Down Expand Up @@ -352,8 +416,11 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier(
if (builder.transportFactory == null
|| builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY
|| builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory
|| builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) {
this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore);
|| builder.transportFactory.getClass() == MtlsHttpTransportFactory.class
|| (builder.defaultMtlsTransportFactory != null
&& builder.transportFactory == builder.defaultMtlsTransportFactory)) {
this.transportFactory = createMtlsTransportFactory(mtlsKeyStore);
this.defaultMtlsTransportFactory = this.transportFactory;
} else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) {
LOGGER_PROVIDER
.getLogger()
Expand Down Expand Up @@ -395,7 +462,10 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou
new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath);
try {
KeyStore mtlsKeyStore = this.x509Provider.getKeyStore();
this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore);
if (shouldUseMtlsTransportFactory()) {
this.transportFactory = createMtlsTransportFactory(mtlsKeyStore);
this.defaultMtlsTransportFactory = this.transportFactory;
}
} catch (Exception e) {
// Cert loading failure will be handled on refreshAccessToken()
}
Expand Down Expand Up @@ -433,6 +503,7 @@ public static class Builder extends ExternalAccountCredentials.Builder {
private @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier;
private @Nullable String actorTokenType;
private @Nullable X509Provider x509Provider;
private @Nullable HttpTransportFactory defaultMtlsTransportFactory;

Builder() {}

Expand All @@ -441,13 +512,17 @@ public static class Builder extends ExternalAccountCredentials.Builder {
if (this.credentialSource == null) {
this.subjectTokenSupplier = credentials.subjectTokenSupplier;
this.actorTokenSupplier = credentials.actorTokenSupplier;
} else if (credentials.actorTokenSupplier != credentials.subjectTokenSupplier) {
this.actorTokenSupplier = credentials.actorTokenSupplier;
}
// Note: when credentialSource is present, subjectTokenSupplier and actorTokenSupplier
// Note: when credentialSource is present, subjectTokenSupplier and file-based
// actorTokenSupplier
// are intentionally NOT copied here. They will be reconstructed from credentialSource
// during build(), which ensures they share the same FileIdentityPoolSubjectTokenSupplier
// instance for atomic token reads.
this.actorTokenType = credentials.actorTokenType;
this.x509Provider = credentials.x509Provider;
this.defaultMtlsTransportFactory = credentials.defaultMtlsTransportFactory;
}

/**
Expand Down
Loading
Loading