Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -120,6 +120,11 @@ public class AwsCredentials extends ExternalAccountCredentials {

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

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

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

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@

protected transient HttpTransportFactory transportFactory;

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

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: Since readObject resets impersonatedCredentials to null at line 655 and serialVersionUID is explicit, can we mark impersonatedCredentials transient so Java does not serialize the inner credential graph just to discard it?

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.

Marked impersonatedCredentials transient and added a serialization round-trip test in ExternalAccountCredentialsTest.


private final EnvironmentProvider environmentProvider;
private final PropertyProvider propertyProvider;
Expand Down Expand Up @@ -292,16 +292,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))
Comment thread
lsirac marked this conversation as resolved.
Outdated
.build();
} else {
sourceCredentials =
IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this)
.setServiceAccountImpersonationUrl(null)
.setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE))
.build();
}
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.


Expand Down Expand Up @@ -363,7 +366,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 369 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 369 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 +387,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 390 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 390 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 +529,19 @@
return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null;
}

/**
* Refreshes the access token using the specified transport factory. Default implementation

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: The Javadoc says the default implementation delegates to refreshAccessToken(), whereas all three subclasses do the reverse by delegating the no-arg method into this overload. Also, naming the parameter transportFactory in AwsCredentials.java:127 and ImpersonatedCredentials.java:601 shadows the class field, whereas IdentityPoolCredentials names it cycleTransportFactory.

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 ExternalAccountCredentials.refreshAccessToken(HttpTransportFactory) to clarify that internal subclasses delegate refreshAccessToken() into this method while the default implementation delegates back to refreshAccessToken(), and renamed the parameter to cycleTransportFactory across all classes to avoid shadowing this.transportFactory.

* delegates to {@link #refreshAccessToken()}. Subclasses should override this method if they
* support transport pinning per refresh cycle.
*
* @param transportFactory the HTTP transport factory to use for this refresh cycle
* @return the refreshed access token
* @throws IOException if the token refresh fails
*/
public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException {

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.

The default refreshAccessToken(HttpTransportFactory) delegates to refreshAccessToken() and silently drops the transport factory. All internal subclasses override it, but a third-party subclass would lose transport pinning without warning. Should we annotate this method or class with @InternalExtensionOnly per the repository versioning guidelines?

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.

Annotated public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) with @InternalExtensionOnly across ExternalAccountCredentials, IdentityPoolCredentials, AwsCredentials, and PluggableAuthCredentials (and kept ImpersonatedCredentials.refreshAccessToken(HttpTransportFactory) package-private).

return refreshAccessToken();
}

/**
* Exchanges the external credential for a Google Cloud access token.
*
Expand Down Expand Up @@ -556,7 +572,7 @@
this.impersonatedCredentials = this.buildImpersonatedCredentials();
}
if (this.impersonatedCredentials != null) {
return this.impersonatedCredentials.refreshAccessToken();
return this.impersonatedCredentials.refreshAccessToken(cycleTransportFactory);

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.

When serviceAccountImpersonationUrl is set, IdentityPoolCredentials.refreshWithRetry fetches the subject and actor tokens to build stsTokenExchangeRequest, calls exchangeExternalCredentialForAccessToken, and then immediately delegates to impersonatedCredentials.refreshAccessToken here while discarding stsTokenExchangeRequest. The inner credential then fetches the subject and actor tokens a second time in the same refresh cycle, or four times on a 401 retry. Can we short-circuit before fetching tokens when serviceAccountImpersonationUrl is present?

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 ImpersonatedCredentials impersonated = getImpersonatedCredentials();
if (impersonated != null) {
return impersonated.refreshAccessToken(cycleTransportFactory);
}

at the start of: IdentityPoolCredentials.refreshWithRetry, AwsCredentials.refreshAccessToken, and
PluggableAuthCredentials.refreshAccessToken before retrieveSubjectToken() / getActorToken() are called.

Subject and actor tokens (and AWS metadata requests) are now fetched only once per refresh attempt (or twice on a 401 retry). Kept the check inside exchangeExternalCredentialForAccessToken as a defensive fallback if called directly.

}

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

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: impersonatedCredentials is declared transient at line 100, so it is already null after input.defaultReadObject() and this assignment can be dropped.

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 the redundant this.impersonatedCredentials = null; assignment in readObject().

}

public @Nullable String getServiceAccountImpersonationUrl() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ 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 final ExternalAccountSupplierContext supplierContext;
private final String metricsHeaderValue;

Expand Down Expand Up @@ -115,7 +115,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials {
|| builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY
|| builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory
|| builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) {
this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore);
this.transportFactory = createMtlsTransportFactory(mtlsKeyStore);
} else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) {
LOGGER_PROVIDER
.getLogger()
Expand Down Expand Up @@ -182,7 +182,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.");

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: The exception message says "or provide an mTLS-enabled transport", but isMtlsConfigured() at line 230 only accepts x509Provider != null or an MtlsHttpTransportFactory with hasKeyStore(). Should the message say "or provide an MtlsHttpTransportFactory constructed with a KeyStore"?

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 exception message (and matching test assertion) to "or provide an MtlsHttpTransportFactory constructed with a KeyStore.".

}

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

private boolean shouldUseMtlsTransportFactory() {

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.

In readObject() at lines 465 to 472, if x509Provider.getKeyStore() fails transiently during deserialization, the catch comment notes that cert loading will be handled on refreshAccessToken(). However, if this.transportFactory is a deserialized MtlsHttpTransportFactory subclass with hasKeyStore() == false, shouldUseMtlsTransportFactory() returns false because it omits the this.transportFactory instanceof MtlsHttpTransportFactory && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore() check from line 466. Should shouldUseMtlsTransportFactory() include that check too?

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 (this.transportFactory instanceof MtlsHttpTransportFactory && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()) directly into shouldUseMtlsTransportFactory() and simplified readObject() to call if (shouldUseMtlsTransportFactory()).

return this.transportFactory == null
|| this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY
|| this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory
|| this.transportFactory.getClass() == MtlsHttpTransportFactory.class;
}

@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) {
if (this.x509Provider != null && shouldUseMtlsTransportFactory()) {
KeyStore pinnedKeyStore = this.x509Provider.getKeyStore();
cycleTransportFactory = new MtlsHttpTransportFactory(pinnedKeyStore);
cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore);
}
return refreshWithRetry(cycleTransportFactory, true);
}

@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, false);
}

private AccessToken refreshWithRetry(

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.

a few quick notes: This method is quite large and it's a bit hard to understand what is going on here (also admittedly I'm not as familiar with IdentityPoolCredentials or 3p creds in general).

Can we split this into a series of helpers. On a high level I think I see a few things

  1. Resolving the transport
  2. Retrieving the subject and actor tokens
  3. Making the request and setting the scopes

I think we can keep the logic regarding retries and all that inside this method

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 β€” extracted resolveCycleTransportFactory, retrieveSubjectAndActorTokens, and buildAndExchangeStsRequest as private helpers while keeping the pinnedKeyStore snapshot and retry handling inside refreshWithRetry.

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.

Thanks. I still find this a bit hard to follow. I think the cor elogic is in helpers, but the main challenge is with the retry logic.

Is it possible to not use recursion for this? IMO it's easier to follow just seeing the 1 or 2 requests flowing in the code.

I think it may be easier to separate the refresh call from the retry logic.

Perhaps we can create a helper for making the refresh call and invoke it once or twice inside this method depending on whether we are allowed to retry or not.

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 β€” separated the single-attempt refresh execution into executeRefresh(...) and removed the recursion from refreshWithRetry(...). Non-retryable callers now invoke executeRefresh(...) directly, and refreshWithRetry(...) executes Attempt 1 in the try block and Attempt 2 directly in the catch block.

HttpTransportFactory cycleTransportFactory, boolean allowRetry) throws IOException {
// Read subject and actor tokens, atomically if from the same file supplier.
String subjectToken;
String actorToken = null;
Expand Down Expand Up @@ -270,22 +290,52 @@ public AccessToken refreshAccessToken() throws IOException {
try {
return exchangeExternalCredentialForAccessToken(
stsTokenExchangeRequest.build(), cycleTransportFactory);
} catch (OAuthException e) {
if (e.getHttpStatusCode() == 401
} catch (Exception 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 and retry once.
KeyStore freshKeyStore = this.x509Provider.getKeyStore();
HttpTransportFactory retryTransportFactory = new MtlsHttpTransportFactory(freshKeyStore);
return exchangeExternalCredentialForAccessToken(
stsTokenExchangeRequest.build(), retryTransportFactory);
} catch (IOException retryException) {
retryException.addSuppressed(e);
throw retryException;
// 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 (ioException != e) {
ioException.addSuppressed(e);
}
throw ioException;
}

HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore);
try {
return refreshWithRetry(retryTransportFactory, false);
} catch (Exception retryException) {
if (retryException != e) {
retryException.addSuppressed(e);
}
if (retryException instanceof IOException) {
throw (IOException) retryException;
}
if (retryException instanceof RuntimeException) {
throw (RuntimeException) retryException;
}
throw new IOException(retryException);

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: exchangeExternalCredentialForAccessToken only throws IOException or unchecked exceptions, so after rethrowing IOException and RuntimeException, throw new IOException(retryException) on line 329 and throw new IOException(e) on line 338 are unreachable dead code. Catching IOException | RuntimeException lets us simplify both blocks to throw retryException and throw e.

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.

Simplified both catch blocks to catch IOException | RuntimeException and rethrow directly (throw retryException / throw e).

}
}
if (e instanceof IOException) {
throw (IOException) e;
}
if (e instanceof RuntimeException) {
throw (RuntimeException) e;
}
throw e;
throw new IOException(e);
}
}

Expand Down Expand Up @@ -324,6 +374,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 @@ -353,7 +408,7 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier(
|| builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY
|| builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory
|| builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) {
this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore);
this.transportFactory = createMtlsTransportFactory(mtlsKeyStore);
} else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) {
LOGGER_PROVIDER
.getLogger()
Expand Down Expand Up @@ -395,7 +450,9 @@ 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()) {

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.

After deserialization this only rebuilds the factory when shouldUseMtlsTransportFactory() is true, so an MtlsHttpTransportFactory subclass or a ServiceLoader-provided factory comes back keyless without a client certificate, whereas the old readObject always restored the keystore-backed factory. Should this also replace the factory when transportFactory instanceof MtlsHttpTransportFactory && !((MtlsHttpTransportFactory) transportFactory).hasKeyStore()?

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 - updated readObject to also rebuild this.transportFactory via createMtlsTransportFactory(this.x509Provider.getKeyStore()) when this.transportFactory instanceof MtlsHttpTransportFactory && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore().

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.

For ServiceLoader-registered transport factories, transportFactoryClassName captures the SPI class before the mTLS upgrade. After deserialization, shouldUseMtlsTransportFactory() returns false, so mTLS is not restored. Can we persist a flag so shouldUseMtlsTransportFactory() rebuilds the mTLS transport on deserialization?

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.

Persisted private final boolean useMtlsTransportFactory on IdentityPoolCredentials (and Builder.useMtlsTransportFactory, preserved across toBuilder()), and added deserialization_respectsUseMtlsTransportFactoryFlag in IdentityPoolCredentialsTest to verify that shouldUseMtlsTransportFactory() rebuilds the mTLS transport on deserialization.

this.transportFactory = createMtlsTransportFactory(mtlsKeyStore);
}
} catch (Exception e) {
// Cert loading failure will be handled on refreshAccessToken()
}
Expand Down Expand Up @@ -441,8 +498,11 @@ 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

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: // actorTokenSupplier is left orphaned on its own line here. Can we rewrap this comment block cleanly?

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.

Re-wrapped the comment block in Builder(IdentityPoolCredentials).

// 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.
Expand Down
Loading
Loading