Skip to content

feat(oauth2): implement IAM impersonation mTLS transport pinning and 401 recovery - #14212

Merged
macastelaz merged 21 commits into
googleapis:oauth2-bound-tokensfrom
macastelaz:cert-bound-oauth-iam-pinning
Oct 15, 2026
Merged

macastelaz merged 21 commits into
googleapis:oauth2-bound-tokensfrom
macastelaz:cert-bound-oauth-iam-pinning

Conversation

@macastelaz

@macastelaz macastelaz commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

🥞 Stacked PRs


Summary

Propagates the cycle-pinned mTLS transport factory into ImpersonatedCredentials during Workload Identity Federation with Service Account Impersonation, ensuring the IAM generateAccessToken call uses the exact same pinned client certificate as the STS token exchange.
Also extends 401 recovery in IdentityPoolCredentials to catch unauthorized responses across both STS and IAM calls, reloading fresh certificates and re-executing the full refresh cycle.

Test Coverage

  • 1,030 unit tests passing across oauth2_http (including transport pinning lifecycle, multi-cycle rotation, 401 retry on IAM, and suppressed error handling).
  • Google Java Format: 100% compliant (com.spotify.fmt:fmt-maven-plugin:2.25).

Manual Testing

Sample test "app": https://paste.googleplex.com/5495919190081536
Test results: https://paste.googleplex.com/5757145073713152
See b/542238030 for tracking.

@macastelaz
macastelaz requested review from a team as code owners August 30, 2026 02:28
@macastelaz
macastelaz marked this pull request as draft August 30, 2026 02:28

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces support for actor tokens in IdentityPoolCredentials (specifically for file-based JSON credential sources over mTLS endpoints) and adds certificate pinning with automatic certificate reloading on 401 Unauthorized errors. It also updates other credential classes to support passing a custom HttpTransportFactory during token refresh, masks actor tokens in logs, and adds corresponding unit tests. The review feedback suggests persisting the reloaded transport factory back to this.transportFactory after a successful retry to prevent subsequent refreshes from failing first, and preserving programmatically set actorTokenSupplier instances in the Builder copy constructor.

…401 recovery

- Pin mTLS HttpTransportFactory across multi-step STS and IAM token exchanges so both requests use the exact same certificate snapshot within a single refresh cycle.
- Add 401 Unauthorized recovery with automatic certificate reload from X509Provider and single-retry coordination in IdentityPoolCredentials and ImpersonatedCredentials.
- Preserve custom non-default HttpTransportFactory instances when X509Provider is configured.
- Add comprehensive unit tests across IdentityPoolCredentialsTest, ImpersonatedCredentialsTest, and OAuth2UtilsTest.
@macastelaz
macastelaz force-pushed the cert-bound-oauth-iam-pinning branch from e09d76e to dadeda6 Compare September 17, 2026 03:17
…dentials and address review findings

- Explicitly scope inner sourceCredentials to CLOUD_PLATFORM_SCOPE in ExternalAccountCredentials.buildImpersonatedCredentials and ImpersonatedCredentials.refreshAccessToken so STS issues tokens authorized to call IAM generateAccessToken even when downstream target scopes are configured via createScoped.
- Ensure public no-arg ImpersonatedCredentials.refreshAccessToken delegates without overriding source credential transport settings.
- Preserve custom actorTokenSupplier in IdentityPoolCredentials.Builder copy constructor when credentialSource is present.
- Ensure HTTP response is closed in a finally block in ImpersonatedCredentials.refreshAccessToken.
- Attach initial 401 exception as suppressed when the 401 retry attempt fails in IdentityPoolCredentials.refreshWithRetry.
- Add unit tests in IdentityPoolCredentialsTest and ImpersonatedCredentialsTest covering scoped impersonation, custom actorTokenSupplier preservation, and retry exception chaining.
}
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.

* @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).

}

/**
* 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.

.refreshAccessToken(effectiveTransportFactory);
Credentials authCredentials =
intermediateAccessToken != null
? OAuth2Credentials.create(intermediateAccessToken)

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.

Wrapping intermediateAccessToken in OAuth2Credentials.create creates a bare credential with a null quotaProjectId, which drops the x-goog-user-project header from the IAM generateAccessToken request. Can we use OAuth2Credentials.newBuilder().setAccessToken(intermediateAccessToken).setQuotaProjectId(this.sourceCredentials.getQuotaProjectId()).build() so quota attribution is preserved?

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.

Replaced OAuth2Credentials.create(intermediateAccessToken) with a GoogleCredentials wrapper initialized via GoogleCredentials.newBuilder().setQuotaProjectId(this.sourceCredentials.getQuotaProjectId()).setUniverseDomain(this.sourceCredentials.getUniverseDomain()) so both quotaProjectId (x-goog-user-project) and universeDomain are preserved on the IAM request.

}
AccessToken intermediateAccessToken =
(transportFactory == null)
? ((ExternalAccountCredentials) this.sourceCredentials).refreshAccessToken()

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 the null transport branch, calling refreshAccessToken directly instead of refreshIfExpired bypasses the token cache, lock, and change listeners on sourceCredentials for non-mTLS WIF impersonation. Should line 620 call refreshIfExpired and read getAccessToken, while keeping the uncached refreshAccessToken(effectiveTransportFactory) call on line 621 for pinned mTLS transports?

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! When cycleTransportFactory == null, we now call this.sourceCredentials.refreshIfExpired() and pass this.sourceCredentials directly to new HttpCredentialsAdapter(this.sourceCredentials) (preserving token caching, locking, and change listeners), while keeping the uncached refreshAccessToken(effectiveTransportFactory) call when cycleTransportFactory != null.

"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) {
@Override
HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) {
usedKeyStores.add(keyStore);

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 the five new retry and impersonation tests, createMtlsTransportFactory records usedKeyStores.add(keyStore) and returns () -> mockTransport, which is the exact same HttpTransport instance regardless of which keystore was passed. The tests would still pass even if refreshWithRetry passed the stale transport on retry. Can we return a distinct transport per keystore so the mock verifies that the retry request ran on the transport created from freshKeyStore, and add tests for a persistent 401 failure and a non-401 IAM error?

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 createMtlsTransportFactory(KeyStore) in the impersonation mTLS tests to return a distinct HttpTransport wrapper per KeyStore that records which KeyStore executed each request (requestKeyStores), and added "refreshAccessToken_impersonation_persistent401OnIam_throwsWithSuppressed" and "refreshAccessToken_impersonation_non401OnIam_doesNotRetry".

}

@Test
void builder_actorToken_plainPublicTokenUrl_throws() throws Exception {

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 five actor-token endpoint validation tests added at lines 1821 through 1928 duplicate the existing five tests at lines 2873 through 2981 in the same file. Can we remove the duplicate block?

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 5 duplicate actor-token endpoint validation tests.

errorResponse.put("error", "invalid_token");
errorResponse.put("error_description", "Invalid or expired client certificate.");
return new MockLowLevelHttpResponse()
.setStatusCode(statusCode)

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 early return for non-200 status codes exits before reaching assertEquals(EXPECTED_GRANT_TYPE, grantType), so the failed first attempt never has its request form parameters verified. Can we move the grant_type assertion above the status code check?

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.

Moved the grant_type and subject_token assertions above if (statusCode != 200) so request parameters are verified on simulated error responses as well.


@Test
void isUnauthorizedException_null_returnsFalse() {
org.junit.jupiter.api.Assertions.assertFalse(OAuth2Utils.isUnauthorizedException(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: This file already static-imports assertEquals and assertThrows at lines 35 and 36, but the new tests write out org.junit.jupiter.api.Assertions.assertFalse, org.junit.jupiter.api.Assertions.assertTrue, java.io.IOException, com.google.api.client.http.HttpResponseException, and com.google.api.client.http.HttpHeaders inline. Can we use static imports and top-level imports 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.

Replaced all inline fully qualified names with static and top-level imports in OAuth2UtilsTest.

.getTransport()
.addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "");

java.util.concurrent.atomic.AtomicReference<HttpTransportFactory> capturedSourceTransport =

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 line-wrapping changes to the existing private key string and universe-domain assertion message are unrelated formatting churn, and java.util.concurrent.atomic.AtomicReference is written out inline. Can we revert the unrelated rewrapping to keep the diff clean and add a top-level import for AtomicReference?

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.

Reverted the line-wrapping changes on SA_PRIVATE_KEY_PKCS8 and the universe-domain assertion message, and replaced inline fully qualified names with top-level imports.

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().

* @return the refreshed access token
* @throws IOException if the token refresh fails
*/
@InternalExtensionOnly

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.

Does this overload need to be public? Commit f2d752e compiled cleanly with it package-private, and making it public @InternalExtensionOnly advertises a call target whose default implementation ignores transportFactory and whose IdentityPoolCredentials override skips 401 cert recovery.

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.

Oops - that was over expansive, thanks for catching. Reverted back to package-private

iamTransportFactory.getTransport().setExpireTime(getDefaultExpireTime());
iamTransportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "");

IdentityPoolCredentials sourceCredentials =

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 source credential here defaults to cloud-platform, so the new !currentScopes.contains(CLOUD_PLATFORM_SCOPE) branch at ImpersonatedCredentials.java:613 never runs. Can we pass .setScopes(Collections.singletonList("https://www.googleapis.com/auth/devstorage.read_only")) on the source builder so the test covers that branch?

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.

Configured devstorage.read_only on sourceCredentials so the !currentScopes.contains(CLOUD_PLATFORM_SCOPE) branch is exercised.

.setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
.build();
IdentityPoolCredentials deserialized = serializeAndDeserialize(regularCredentials);
assertTrue(

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: Can this assert ((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore() too? Right now instanceof MtlsHttpTransportFactory passes even when readObject leaves the deserialized subclass with a null 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.

Added assertTrue(((MtlsHttpTransportFactory) deserialized.getTransportFactory()).hasKeyStore()).

@Override
HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) {
usedKeyStores.add(keyStore);
return () -> 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: createMtlsTransportFactory returns the same () -> transport instance for both ksA and ksB, so this test passes even if the 401 retry reuses ksA's transport. Could it return a per-keystore recording transport like the impersonation retry tests do?

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 - wired separate MockExternalAccountCredentialsTransport instances (transportA returning 401 for ksA, transportB returning 200 for ksB) and asserted each receives 1 request.

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.".

return credential.refreshAccessToken();
});
Future<AccessToken> futureA = executor.submit(() -> credential.refreshAccessToken());
Future<AccessToken> futureB = executor.submit(() -> credential.refreshAccessToken());

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: With the new CyclicBarrier, getKeyStoreCount is deterministically 3, so line 2442 can assert assertEquals(3, ...) instead of >= 3 and verify that credential.getTransportFactory() is still mtlsTransport after the retry.

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.

Tightened to assertEquals(3, getKeyStoreCount.get()) and added assertSame(mtlsTransport, credential.getTransportFactory()).

}

@Test
void hasCertificateChanged_nullOrSameReference_returnsFalse() throws Exception {

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: hasCertificateChanged_nullOrSameReference_returnsFalse asserts assertTrue at lines 162-163 and only checks identical references. Could we also compare two distinct KeyStore instances loaded with the same cert so oldCerts.equals(newCerts) is tested directly?

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 hasCertificateChanged_distinctKeyStoreInstances_comparesCertificates() comparing two distinct KeyStore instances loaded with the same certificate (assertFalse) and with a rotated certificate (assertTrue).

return new AccessToken("intermediate-sts-token-" + count, null);
}

@Override

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 createScoped override here is never called because line 1554 already sets cloud-platform scope, and the 401 retry comes from HttpCredentialsAdapter.handleResponse on the null-transport path. Can we drop the unused createScoped override and rename the test to reflect that?

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 unused createScoped override and renamed the test to refreshAccessToken_withoutCycleTransportFactory_externalAccountSourceRetriesOn401FromIam.

@macastelaz
macastelaz requested a review from lsirac September 21, 2026 21:12
try {
ImpersonatedCredentials impersonated = getImpersonatedCredentials();
if (impersonated != null) {
return impersonated.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.

cycleTransportFactory is never null here because this.transportFactory is always initialized in ExternalAccountCredentials. As a result, non-mTLS pools also take the uncached branch in ImpersonatedCredentials, which skips sourceCredentials.refreshIfExpired() on every refresh and clears HttpCredentialsAdapter's 401 retry handler on the IAM request. Can we pass pinnedKeyStore != null ? cycleTransportFactory : null so only pinned mTLS cycles opt out?

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 refreshWithRetry() to pass pinnedKeyStore != null ? cycleTransportFactory : null into impersonated.refreshAccessToken(...) so only pinned mTLS cycles opt out of sourceCredentials.refreshIfExpired() caching and HttpCredentialsAdapter's 401 retry handler.

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

Since this.transportFactory is never null, passing cycleTransportFactory here always forces ImpersonatedCredentials onto the uncached path and disables the 401 retry on IAM. Can this pass null since AWS credentials do not pin a per-cycle mTLS transport?

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 AwsCredentials to pass cycleTransportFactory == this.transportFactory ? null : cycleTransportFactory into impersonated.refreshAccessToken(...) so standard refreshes retain sourceCredentials.refreshIfExpired() caching and IAM 401 retry handling.

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

Same as AwsCredentials, cycleTransportFactory is always non-null here even though no per-cycle mTLS transport is pinned. Can we pass null so ImpersonatedCredentials keeps sourceCredentials.refreshIfExpired() caching and the IAM 401 retry?

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 PluggableAuthCredentials to pass cycleTransportFactory == this.transportFactory ? null : cycleTransportFactory into impersonated.refreshAccessToken(...) so ImpersonatedCredentials retains sourceCredentials.refreshIfExpired() caching and IAM 401 retry handling.


private final @Nullable String tokenInfoUrl;
private final @Nullable String serviceAccountImpersonationUrl;
private transient @Nullable String targetServiceAccountEmail;

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.

Because targetServiceAccountEmail is marked transient and mutated after construction at line 316 on a sourceCredentials copy whose serviceAccountImpersonationUrl is null, deserializing ImpersonatedCredentials or its sourceCredentials resets targetServiceAccountEmail to null. After deserialization, getServiceAccountEmail() returns null and drops GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL in PluggableAuthCredentials. Can we drop transient, make this field final, and set it on the Builder before calling .build()?

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.

Dropped transient, made targetServiceAccountEmail final, set it on ExternalAccountCredentials.Builder before calling .build() in buildImpersonatedCredentials(), and added an assertion in AwsCredentialsTest.serialize() verifying getServiceAccountEmail() is preserved across serialization.

&& ((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 the refreshed access token
* @throws IOException if the token refresh fails
*/
@InternalExtensionOnly

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: Now that refreshAccessToken(HttpTransportFactory) is package-private here and in IdentityPoolCredentials, AwsCredentials, and PluggableAuthCredentials, @InternalExtensionOnly is no longer needed since that annotation is only for public APIs. Can we drop @InternalExtensionOnly and its import from these four classes?

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 @InternalExtensionOnly from refreshAccessToken(HttpTransportFactory) (and its unused imports) across ExternalAccountCredentials, IdentityPoolCredentials, AwsCredentials, and PluggableAuthCredentials.

// 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().

*/
AccessToken refreshAccessToken(@Nullable HttpTransportFactory cycleTransportFactory)
throws IOException {
HttpTransportFactory effectiveTransportFactory =

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.transportFactory is already guaranteed non-null by firstNonNull in the constructor and readObject(), so firstNonNull(cycleTransportFactory, this.transportFactory) can replace this nested ternary.

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.

Replaced the ternary with firstNonNull(cycleTransportFactory, this.transportFactory).

} catch (IOException e) {
throw new IOException("Unable to refresh sourceCredentials", e);
}
Credentials authCredentials =

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: Declaring this local as GoogleCredentials authCredentials avoids adding the com.google.auth.Credentials import at line 49.

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.

Declared authCredentials as GoogleCredentials and removed the com.google.auth.Credentials import.

}
// 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).

@lsirac

lsirac commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Found a few issues during cert rotation and impersonation:

  1. Cert rotation fails when certificate_config.json updates cert_path or when cert.pem rotates mid-cycle
    In IdentityPoolCredentials, refreshAccessToken() snapshots pinnedKeyStore = this.x509Provider.getKeyStore() at the start of the cycle, but CertificateIdentityPoolSubjectTokenSupplier.getSubjectToken() still reads the leaf certificate from disk a second time instead of reading it from pinnedKeyStore. Also, createCertificateSubjectTokenSupplier sets credentialSource.setCredentialLocation(...) only once at construction time, whereas X509Provider.getKeyStore() re-reads certificate_config.json on every refresh.

    • If certificate_config.json updates cert_path and key_path to a new file path, or if cert.pem rotates on disk between x509Provider.getKeyStore() and retrieveSubjectToken(), the TLS handshake presents the new cert while subject_token sends the old cert.
    • Live https://sts.mtls.googleapis.com/v1/token rejects this mismatch with HTTP 400 and OAuthException: Error code invalid_grant: Client cert does not match the cert in mTLS handshake., not 401. Because isUnauthorizedException only checks for 401, refreshWithRetry does not retry.
    • Can we extract the leaf certificate directly from pinnedKeyStore for CertificateIdentityPoolSubjectTokenSupplier so the TLS client cert and subject_token are always pinned to the same snapshot?
  2. 1-hour STS token caching does not work when mTLS is enabled
    In ImpersonatedCredentials.refreshAccessToken(HttpTransportFactory), when cycleTransportFactory != null, the else branch calls externalSource.refreshAccessToken(effectiveTransportFactory) directly instead of refreshIfExpired(), and never stores the returned AccessToken on externalSource. As a result, externalSource.getAccessToken() stays null and every ImpersonatedCredentials refresh re-runs the full STS token exchange against sts.mtls.googleapis.com even when pinnedKeyStore has not changed and the 1-hour STS token is still valid. Can we cache the intermediate STS token alongside the KeyStore it was minted with and reuse it while it is unexpired and the KeyStore is unchanged?

  3. Non-atomic cert_path and key_path rotation fails in the TLS handshake and never reaches the 401 retry
    When cert_path rotates just before key_path, X509Provider.getKeyStore() loads the new certificate with the old private key into KeyStore without error. Against live sts.mtls.googleapis.com, this fails during the TLS handshake with IOException: Error writing request body to server caused by SSLHandshakeException rather than returning an HTTP 401, so hasCertificateChanged is never reached. Can we validate that the private key matches the certificate public key when loading the KeyStore, or retry once when getKeyStore() or the TLS handshake fails due to a key-cert mismatch?

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

Passing cycleTransportFactory here forces ImpersonatedCredentials onto the uncached mTLS path and disables 401 retry on IAM. Can we pass null instead, matching AwsCredentials and PluggableAuthCredentials?

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 ExternalAccountCredentials.exchangeExternalCredentialForAccessToken to pass cycleTransportFactory == this.transportFactory ? null : cycleTransportFactory (which evaluates to null when no per-cycle transport override is active, matching AwsCredentials and PluggableAuthCredentials).

"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken")) {
@Override
HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) {
return () -> mockTransport;

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.

Because createMtlsTransportFactory returns () -> mockTransport without capturing the KeyStore, this test still passes if allowRetry is flipped back to true at line 247. Can we record the KeyStore passed into createMtlsTransportFactory and assert the expected keystore sequence?

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.

createMtlsTransportFactory(KeyStore keyStore) in refreshAccessToken_impersonation_stsReturns401_retriesOnceViaOuterCycle now records capturedKeyStores.add(keyStore), returns 401 when keyStore == ks1 and 200 only when keyStore == ks2, and asserts assertEquals(Arrays.asList(ks1, ks2, ks2), capturedKeyStores).

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.

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.

assertEquals(testCredentials.hashCode(), deserializedCredentials.hashCode());
assertEquals(testCredentials.toString(), deserializedCredentials.toString());
assertSame(Clock.SYSTEM, deserializedCredentials.clock);
assertNotNull(deserializedCredentials.getServiceAccountEmail());

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.

Because serviceAccountImpersonationUrl is set on the fixture, getServiceAccountEmail() reads the email from the URL rather than targetServiceAccountEmail. Can we assert getServiceAccountEmail() on the deserialized inner source copy where the URL was cleared?

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 assertions on deserializedInnerSource ((AwsCredentials) deserializedCredentials.getImpersonatedCredentials().getSourceCredentials()) verifying both assertNull(deserializedInnerSource.getServiceAccountImpersonationUrl()) and assertEquals(testCredentials.getServiceAccountEmail(), deserializedInnerSource.getServiceAccountEmail()).

@macastelaz
macastelaz requested a review from lqiu96 September 28, 2026 20:34
macastelaz added a commit to macastelaz/google-cloud-java that referenced this pull request Sep 28, 2026
The new constructor and getHttpStatusCode() change the computed serialVersionUID, so instances serialized by released versions would fail to load. Same change as in PR googleapis#14212.
@CanIgnoreReturnValue
public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) {
super.setHttpTransportFactory(transportFactory);
this.useMtlsTransportFactory = isDefaultOrMtlsTransportFactory(transportFactory);

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 we just simply this to set this only in the constructor?

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.

Builder(IdentityPoolCredentials) needs to preserve this.useMtlsTransportFactory = credentials.useMtlsTransportFactory across clones (toBuilder(), createScoped(), buildImpersonatedCredentials()) because super(credentials) copies credentials.transportFactory into builder.transportFactory — which on an already-constructed credential is no longer null and may have been resolved via ServiceLoader (per #14212 (comment)) or an overridden createMtlsTransportFactory rather than DefaultHttpTransportFactory. If the constructor re-evaluated isDefaultOrMtlsTransportFactory(builder.transportFactory) on the cloned builder without preserving the flag, useMtlsTransportFactory would flip from true to false on the clone. And because Builder(IdentityPoolCredentials) copies that flag, setHttpTransportFactory updates it in case a caller calls credentials.toBuilder().setHttpTransportFactory(customFactory).build(). Added a comment on the builder copy constructor to clarify this!

Comment on lines +234 to +239
@VisibleForTesting
boolean shouldUseMtlsTransportFactory() {
return this.useMtlsTransportFactory
|| (this.transportFactory instanceof MtlsHttpTransportFactory
&& !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore());
}

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.

qq, this isn't immediately clear to me why we can't just use the value of useMtlsTransportFactory? useMtlsTransportFactory seems to be the reuslt of isDefaultOrMtlsTransportFactory which looks like it already checks for this

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.

At construction time, if a caller supplies a custom MtlsHttpTransportFactory subclass that already has a KeyStore (e.g. new CustomMtlsHttpTransportFactory(ks)), isDefaultOrMtlsTransportFactory returns false so useMtlsTransportFactory is false (preserving the custom subclass during construction and normal refreshes).

However, upon deserialization, ExternalAccountCredentials.readObject() instantiates transportFactoryClassName via its no-arg constructor, leaving this.transportFactory as a keyless MtlsHttpTransportFactory (hasKeyStore() == false) while the deserialized useMtlsTransportFactory field is still false. This second check ensures readObject() (and refreshAccessToken() if the cert load in readObject() fails transiently) still rebuilds a KeyStore-backed MtlsHttpTransportFactory in that case (see #14212 (comment)). Added an inline comment here to explain this!

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.

Hmm, would it be possible to use the above isDefaultOrMtlsTransportFactory to simply the checks?

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 — simplified shouldUseMtlsTransportFactory() to return this.useMtlsTransportFactory || isDefaultOrMtlsTransportFactory(this.transportFactory);.

/* allowRetry= */ 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.

}
}

private static @Nullable String extractAndEncodeLeafCertificate(@Nullable KeyStore keyStore)

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.

note for us in a future PR: looks like parsing through the keystore is a repeated operation in multiple spots (I think I recall seeing this in mtls PR before). Maybe we can extract some common functionality

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.

Agreed! Once both PRs land, we can extract the KeyStore leaf-certificate lookup into a shared utility method in a follow-up PR.

ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl);
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.

Comment on lines +296 to 303
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);
}

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.

Comment on lines +553 to +560
AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) throws IOException {
return refreshAccessToken();
}

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

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.

qq, it's not super clear to me why we need this override. Is this due to the funky behavior with 3p creds and impersonation. IIRC, it seems to call each other in some weird way that ends up being somewhat of a cycle?

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.

One more note: can these methods just be abstract methods that the subclasses override?

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.

Yes, spot on — when ExternalAccountCredentials is configured with service_account_impersonation_url, it builds an internal ImpersonatedCredentials whose sourceCredentials is a cloned copy of the ExternalAccountCredentials (with serviceAccountImpersonationUrl = null). During a refresh:

  1. The outer IdentityPoolCredentials.refreshAccessToken() snapshots pinnedKeyStore and cycleTransportFactory and delegates to impersonatedCredentials.refreshAccessToken(cycleTransportFactory, pinnedKeyStore).
  2. ImpersonatedCredentials then calls back into its sourceCredentials (typed as ExternalAccountCredentials) via externalSource.refreshAccessToken(effectiveTransportFactory, pinnedKeyStore) to exchange the 3P subject token at STS using that same pinned mTLS transport and KeyStore, before calling IAM generateAccessToken.

On making them abstract: because ExternalAccountCredentials is a public abstract class with protected constructors and these overloads are intentionally package-private (per #14212 (comment)), making a package-private method abstract in Java would prevent any class outside com.google.auth.oauth2 from subclassing ExternalAccountCredentials (and making them protected abstract would be a breaking public API change). Expanded the Javadoc here to explain both the impersonation call flow and why the default fallback is provided.

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.

This looks primarily really only used/ needed for IdentityPoolCredentials. Can this just be put into that class?

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 — removed refreshAccessToken(HttpTransportFactory) and refreshAccessToken(HttpTransportFactory, KeyStore) from ExternalAccountCredentials (and AwsCredentials / PluggableAuthCredentials) so they live solely on IdentityPoolCredentials.

@macastelaz
macastelaz requested a review from lqiu96 October 14, 2026 04:00
Comment on lines +123 to +132
return refreshAccessToken(this.transportFactory);
}

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

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.

I'm not entirely sure I follow what is going on here. This looks to set refreshAccessToken with the existing transportFactory? There would not be any "cycle" here. This also seems to be the case for PluggableAuth and Aws, but IdentityPool is different.

Does it need this overload then for refreshAccessToken(HttpTransportFactory 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.

Good point — neither AwsCredentials nor PluggableAuthCredentials uses per-cycle transport pinning, so they don't need the refreshAccessToken(HttpTransportFactory) overload. Removed the overload from ExternalAccountCredentials, AwsCredentials, and PluggableAuthCredentials (keeping it only on IdentityPoolCredentials), while retaining the early getImpersonatedCredentials() delegation inside refreshAccessToken() so the subject token isn't fetched twice when service account impersonation is configured.

Comment on lines +378 to +379
&& this.x509Provider != null
&& shouldUseMtlsTransportFactory()

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.

in this method would these conditions every be false?

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.

Previously they could be false because non-mTLS refreshes, refreshAccessToken(cycleTransportFactory), and the recursive retry call all went through refreshWithRetry. With executeRefresh(...) separated out and recursion removed, refreshWithRetry is now only called when this.x509Provider != null && shouldUseMtlsTransportFactory(), so those checks are no longer needed and have been removed.

Comment on lines +380 to +382
&& (OAuth2Utils.isUnauthorizedException(e)
|| OAuth2Utils.isInvalidGrantException(e)
|| isRetryableTransportException(e))) {

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 we extract this to a helper to help explain this. isMtlsRetryableExceptions?

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 isMtlsRetryableException(Throwable) to encapsulate the retryable exception check.

@macastelaz
macastelaz requested review from a team as code owners October 14, 2026 20:56
@snippet-bot

snippet-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

No region tags are edited in this PR.

This comment is generated by snippet-bot.
If you find problems with this result, please file an issue at:
https://github.com/googleapis/repo-automation-bots/issues.
To update this comment, add snippet-bot:force-run label or use the checkbox below:

  • Refresh this comment

@macastelaz
macastelaz force-pushed the cert-bound-oauth-iam-pinning branch from 133b9cc to fe93d90 Compare October 15, 2026 01:22
@macastelaz
macastelaz requested a review from lqiu96 October 15, 2026 02:56
@macastelaz
macastelaz requested a review from lqiu96 October 15, 2026 15:18
@macastelaz
macastelaz merged commit 80b8228 into googleapis:oauth2-bound-tokens Oct 15, 2026
290 of 294 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants