Skip to content

feat(oauth2): support non-mTLS token URLs and unbound actor tokens in IdentityPoolCredentials - #14430

Open
macastelaz wants to merge 11 commits into
googleapis:oauth2-bound-tokensfrom
macastelaz:feat/oauth2-non-mtls-actor-tokens
Open

macastelaz wants to merge 11 commits into
googleapis:oauth2-bound-tokensfrom
macastelaz:feat/oauth2-non-mtls-actor-tokens

Conversation

@macastelaz

Copy link
Copy Markdown
Contributor

Summary

Allows actor_token and actor_token_type to be used in IdentityPoolCredentials with standard (non-mTLS) STS and IAM impersonation endpoints and without requiring client certificate (certificate_config) configuration.

Context & Rationale

In #13955, client-side guardrails (isMtlsConfigured() and validateMtlsEndpoint()) were enforced in the IdentityPoolCredentials constructor because Google STS initially required actor tokens to be paired with certificate-bound tokens over mTLS. Per the original design discussion, this was intentionally designed as a one-way door that could be loosened in a non-breaking manner once backend support for non-mTLS actor token exchanges was ready.

Changes

  1. Removed Constructor mTLS Restrictions:
    • Removed isMtlsConfigured() check in IdentityPoolCredentials(Builder) so actor tokens can be configured with standard HttpTransportFactory instances and without a certificate block.
    • Removed validateMtlsEndpoint() checks on tokenUrl and serviceAccountImpersonationUrl, allowing standard public endpoints (e.g., https://sts.googleapis.com/v1/token) to be used with actor tokens.
    • Preserved strict pairing validation between actorTokenSupplier and actorTokenType, as well as JSON format checks for file-based actor token extraction.
  2. 401 Handling & Cert Rotation:
    • When x509Provider == null (non-mTLS credentials), refreshAccessToken() uses standard transport without snapshotting a KeyStore, and 401 Unauthorized errors propagate immediately without retry.
    • When x509Provider != null && transportFactory instanceof MtlsHttpTransportFactory (mTLS credentials), per-cycle certificate pinning and single-retry cert reload on 401 remain unchanged.
  3. Javadoc & Test Coverage:
    • Updated class-level and builder Javadocs on IdentityPoolCredentials.
    • Updated negative constructor tests to positive verification tests (*_succeeds).
    • Added refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRetry and fromStream_fileCredentialSource_withoutCertificateConfig_andActorToken_withNonMtlsUrl_refreshesSuccessfully to verify end-to-end non-mTLS actor token exchanges.

Verification

  • All 1,035 unit tests passing in oauth2_http module (including all 88 tests in IdentityPoolCredentialsTest).
  • 100% compliant with fmt-maven-plugin:2.25:check.

@macastelaz
macastelaz requested review from a team as code owners September 18, 2026 02:22

@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 removes the restriction requiring mTLS endpoints and transport configuration for actor token exchanges in IdentityPoolCredentials. Validation checks enforcing mTLS are removed, and unit tests are updated to verify that configuring actor tokens without mTLS now succeeds. Feedback is provided regarding a cross-platform issue in a new test where unescaped backslashes in a file path can cause JSON parsing failures on Windows.

macastelaz and others added 2 commits September 17, 2026 21:25
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
@macastelaz
macastelaz requested review from lqiu96 and lsirac September 18, 2026 03:16
return this.x509Provider != null
|| (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.

Now that isMtlsConfigured() is removed here, MtlsHttpTransportFactory.hasKeyStore() and checkHasKeyStore(KeyStore) have no production callers left in the repo, but new MtlsHttpTransportFactory(keyStore) still scans keyStore.aliases() and certificate chains on every refresh and 401 retry. Should we remove hasKeyStore() and the duplicate mtlsHttpTransportFactory_hasKeyStore_* tests in IdentityPoolCredentialsTest?

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 3 duplicate mtlsHttpTransportFactory_hasKeyStore_* tests from IdentityPoolCredentialsTest since they are already covered in MtlsHttpTransportFactoryTest. Note, however, that I kept MtlsHttpTransportFactory.hasKeyStore() itself because PR #14212 actively calls ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore() in IdentityPoolCredentials.shouldUseMtlsTransportFactory()
and readObject().

+ " source or MtlsHttpTransportFactory.",
e.getMessage());
assertNotNull(credentials);
assertSame(actorSupplier, credentials.getIdentityPoolActorTokenSupplier());

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 Builder(IdentityPoolCredentials) at line 391, this.actorTokenSupplier is only copied when this.credentialSource == null, while this.actorTokenType is copied unconditionally. For a credential built like this test with both credentialSource and .setActorTokenSupplier(actorSupplier), calling credentials.createScoped(...) or credentials.toBuilder().build() drops actorTokenSupplier and throws IllegalArgumentException. Should Builder(IdentityPoolCredentials) preserve credentials.actorTokenSupplier whenever credentials.actorTokenSupplier != credentials.subjectTokenSupplier?

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.

Great catch! Updated Builder(IdentityPoolCredentials) to preserve credentials.actorTokenSupplier when credentials.actorTokenSupplier != credentials.subjectTokenSupplier (even when credentialSource != null), and added a credentials.createScoped(...) assertion to builder_actorTokenWithNonMtlsTransportFactory_succeeds to verify both actorTokenSupplier and actorTokenType are preserved.

.build();
assertNotNull(cred);
assertEquals("urn:ietf:params:oauth:token-type:jwt", cred.getActorTokenType());
assertEquals(MockExternalAccountCredentialsTransport.STS_MTLS_URL, cred.getTokenUrl());

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.

Can we assert cred.getServiceAccountImpersonationUrl() here? The test configures the impersonation URL and verifies tokenUrl and actorTokenType, but it omits checking the impersonation URL on the built instance.

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 assertEquals for cred.getServiceAccountImpersonationUrl().


// Verify Java serialization/deserialization round-trip preserves actor token config
IdentityPoolCredentials deserialized = serializeAndDeserialize(idp);
assertEquals("urn:ietf:params:oauth:token-type:jwt", deserialized.getActorTokenType());

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.

Can we add assertNull(deserialized.getX509Provider()) and assertFalse(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory) to this deserialization check? We should confirm readObject() did not trigger unintended mTLS reconstruction on non-mTLS credentials.

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 assertNull(deserialized.getX509Provider()) and assertFalse(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory) after serializeAndDeserialize(idp).

void builder_actorTokenWithNoArgMtlsFactory_throws() throws Exception {
// A no-arg MtlsHttpTransportFactory (e.g. from deserialization) has no KeyStore,
// so isMtlsConfigured() should return false and building should fail.
void builder_actorTokenWithNoArgMtlsFactory_succeeds() 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.

Can we assert assertSame(factory, credentials.getTransportFactory()) in this test and builder_actorTokenWithEmptyMtlsFactory_succeeds to verify the configured factory was preserved? We can also drop throws Exception from the method signature.

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 assertSame(noArgFactory, credentials.getTransportFactory()) (and dropped throws Exception) in builder_actorTokenWithNoArgMtlsFactory_succeeds, and added assertSame(emptyFactory, credentials.getTransportFactory()) in builder_actorTokenWithEmptyMtlsFactory_succeeds.

void builder_actorTokenWithNonMtlsTransportFactory_succeeds() {
IdentityPoolCredentialSource credentialSource = createFileCredentialSource();
IdentityPoolActorTokenSupplier actorSupplier =
new IdentityPoolActorTokenSupplier() {

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 we use a lambda like context -> "token" or testActorSupplier here instead of the anonymous inner 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.

Replaced the anonymous inner class with IdentityPoolActorTokenSupplier actorSupplier = context -> "token";.

.setHttpTransportFactory(transportFactory);

TestableIdentityPoolCredentials testable =
new TestableIdentityPoolCredentials(builder, true, false);

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 we just use new TestableIdentityPoolCredentials(builder, true) here? The two-argument constructor defaults failOnAllExchanges to false already. We can also pass OAuth2Utils.HTTP_TRANSPORT_FACTORY directly instead of instantiating MockExternalAccountCredentialsTransportFactory since TestableIdentityPoolCredentials mocks the exchange.

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 refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRetry to pass OAuth2Utils.HTTP_TRANSPORT_FACTORY directly and use the two-argument new TestableIdentityPoolCredentials(builder, true) constructor.

@lsirac

lsirac commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Thanks for the updates I think there are a few issues to think about:

  1. When service account impersonation is configured with an actor token, refreshing the token fetches both the subject and actor tokens twice on the first call and still re-fetches and discards them on every refresh while the intermediate STS token is cached.
  2. This may be pre-existing, but if actor_token is empty or whitespace in a JSON credential file, or if the actor token supplier returns null, we either send an empty actor_token parameter to STS or silently drop the actor token and exchange only the subject token instead of failing.
  3. This also may be pre-existing, but when a custom transport factory is configured alongside a certificate config, deserializing the credentials overwrites the custom transport factory with the mTLS transport.

…sonation caching, and custom transport deserialization
@macastelaz

Copy link
Copy Markdown
Contributor Author

Thanks for the updates I think there are a few issues to think about:

  1. When service account impersonation is configured with an actor token, refreshing the token fetches both the subject and actor tokens twice on the first call and still re-fetches and discards them on every refresh while the intermediate STS token is cached.
  2. This may be pre-existing, but if actor_token is empty or whitespace in a JSON credential file, or if the actor token supplier returns null, we either send an empty actor_token parameter to STS or silently drop the actor token and exchange only the subject token instead of failing.
  3. This also may be pre-existing, but when a custom transport factory is configured alongside a certificate config, deserializing the credentials overwrites the custom transport factory with the mTLS transport.

Thanks for the thorough review! Addressed all three issues:

  1. Impersonation + actor token double-fetch & caching: IdentityPoolCredentials.refreshAccessToken() now delegates early to getImpersonatedCredentials() (lazily initialized via initializeImpersonatedCredentials(), preserving the actorTokenSupplier on the cloned sourceCredentials) before reading subjectToken and actorToken. Both tokens are now fetched only once on initial refresh and are not re-fetched while the intermediate STS token in sourceCredentials remains
    valid.
  2. Empty / whitespace / null actor token validation: FileIdentityPoolSubjectTokenSupplier now validates token.trim().isEmpty() for both JSON fields (extractField) and text files (parseToken), and IdentityPoolCredentials.refreshAccessToken() now verifies that actorToken is non-null and non-empty whenever actorTokenSupplier != null, throwing an IOException instead of sending an empty actor_token parameter or silently dropping the acting party.
  3. Custom HttpTransportFactory preservation on deserialization: Added a serialized useMtlsTransportFactory flag (checked via shouldUseMtlsTransportFactory()) so readObject only creates an MtlsHttpTransportFactory when the credential was using the default or MtlsHttpTransportFactory transport, preserving custom HttpTransportFactory instances across serialization/deserialization.

}

@Test
void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRetry() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This builder never sets an X509Provider, so the 401 check in IdentityPoolCredentials short-circuits on this.x509Provider != null before shouldUseMtlsTransportFactory runs, and OAuth2Utils.HTTP_TRANSPORT_FACTORY is treated as mTLS-eligible by isDefaultOrMtlsTransportFactory. Can we configure a TestX509Provider and a custom non-mTLS transport factory here so shouldUseMtlsTransportFactory is what stops the 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.

Done — the test now sets a TestX509Provider and a custom non-mTLS MockExternalAccountCredentialsTransportFactory, so shouldUseMtlsTransportFactory() is what stops the 401 retry.

}
@VisibleForTesting
boolean shouldUseMtlsTransportFactory() {
return this.useMtlsTransportFactory

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 streams serialized before useMtlsTransportFactory existed, transportFactoryClassName was captured as DefaultHttpTransportFactory before initializeMtlsTransport swapped in MtlsHttpTransportFactory, so useMtlsTransportFactory deserializes as false and readObject skips mTLS reconstruction. Should this return this.useMtlsTransportFactory || isDefaultOrMtlsTransportFactory(this.transportFactory) instead?

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 — shouldUseMtlsTransportFactory() now returns this.useMtlsTransportFactory || isDefaultOrMtlsTransportFactory(this.transportFactory).

if (this.serviceAccountImpersonationUrl == null) {
return null;
}
ImpersonatedCredentials local = this.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.

Should impersonatedCredentials be declared volatile? Without volatile, the first check in this double-checked locking block can let a concurrent refreshAccessToken caller observe a non-null reference before its constructor writes finish.

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 — declared impersonatedCredentials as volatile.

@CanIgnoreReturnValue
public Builder setCredentialSource(IdentityPoolCredentialSource credentialSource) {
super.setCredentialSource(credentialSource);
this.isClonedTransportInitialized = false;

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: toBuilder copies x509Provider and getX509Provider prefers that field, so calling setCredentialSource with a new source on a cloned builder still reloads the keystore from the old source's x509Provider. Should setCredentialSource also clear this.x509Provider when the credential source changes?

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 — setCredentialSource now clears x509Provider when the builder already has a different credential source, so a new source on a cloned builder builds its own provider. Added toBuilder_setCredentialSource_withNewSource_clearsCopiedX509Provider to cover it.

if (actorToken == null || actorToken.trim().isEmpty()) {
throw new IOException("The provided actor token cannot be null or empty.");
}
if (this.actorTokenType != 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: The constructor already checks that actorTokenType is not null or empty whenever actorTokenSupplier is set, so this inner if is dead code and would silently drop the acting party if that invariant ever broke. Can we pass Objects.requireNonNull(this.actorTokenType) to ActingParty instead?

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 the inner if and now pass Objects.requireNonNull(this.actorTokenType) to ActingParty.

Collections.singletonList("https://www.googleapis.com/auth/cloud-platform"));

assertEquals(2, getKeyStoreCount.get());
assertEquals(1, getKeyStoreCount.get());

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 this test asserts getKeyStoreCount stays at 1 and the transport factory instance is reused across createScoped, can we rename the method to createScoped_withCertificateConfig_preservesMtlsTransportSnapshot?

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 — renamed to createScoped_withCertificateConfig_preservesMtlsTransportSnapshot.

+ " endpoint. Please use an mTLS endpoint (e.g. containing '.mtls.') or Private"
+ " Service Connect (containing '.p.').");
}
@VisibleForTesting

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: No test calls shouldUseMtlsTransportFactory, hasInitializedMtlsTransport, or createMtlsTransportFactory. Can we drop @VisibleForTesting and make these helpers private?

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 — dropped @VisibleForTesting and made shouldUseMtlsTransportFactory, hasInitializedMtlsTransport, and createMtlsTransportFactory private.

}

@Test
void readTokens_emptyOrWhitespaceActorField_throwsIOException(@TempDir Path tempDir)

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 extractField now rejects whitespace-only values for subject tokens too, can we add a sibling test for an empty or whitespace subject token field?

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

@macastelaz
macastelaz requested a review from lsirac September 24, 2026 16:55

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

When impersonation is configured and hasInitializedMtlsTransport() is already true, the block at lines 198 to 204 reloads the keystore from disk and allocates cycleTransportFactory, then immediately discards it when impersonated.refreshAccessToken() returns here. Should we move the per-cycle cycleTransportFactory snapshot below this if (impersonated != null) block and only run the !hasInitializedMtlsTransport() lazy initialization before getImpersonatedCredentials()?

@macastelaz macastelaz Sep 28, 2026 •

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, done. The lazy initialization still runs before getImpersonatedCredentials(),
so the impersonated credential is built with the mTLS factory. The per-cycle snapshot now
runs only on the direct STS path, after the impersonation return. On the first cycle, the
snapshot reuses the KeyStore that the lazy initialization just loaded, so each refresh
reads it at most once.

Added two tests:

  • refreshAccessToken_withImpersonation_doesNotReloadKeyStoreAfterLazyInit: the first
    refresh loads the KeyStore once and getImpersonatedCredentials() sees an
    MtlsHttpTransportFactory; the second refresh doesn't load it again.
  • refreshAccessToken_withoutImpersonation_readsKeyStoreOncePerCycle: the first cycle loads
    the KeyStore once and the exchange reuses that factory; each later cycle takes one new
    snapshot.

new MockExternalAccountCredentialsTransportFactory();

Map<String, Object> certMap = new HashMap<>();
certMap.put("use_default_certificate_config", true);

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 x509Provider is transient, readObject() constructs a new X509Provider with a null path when use_default_certificate_config is true, which throws IOException on getKeyStore() and hits the catch block before this.transportFactory is touched. That makes this test pass even if the shouldUseMtlsTransportFactory() check in readObject() is removed. Can we set use_default_certificate_config to false and point certificate_config_location to "testresources/mtls/certificate_config.json" so getKeyStore() succeeds during 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.

The test now sets use_default_certificate_config to false and points
certificate_config_location at testresources/mtls/certificate_config.json, so
getKeyStore() succeeds during deserialization. I confirmed the test now fails if the
shouldUseMtlsTransportFactory() check in readObject() is bypassed.

|| transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory
|| transportFactory.getClass() == MtlsHttpTransportFactory.class
|| (transportFactory instanceof MtlsHttpTransportFactory
&& !((MtlsHttpTransportFactory) 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.

Nit: OAuth2Utils.HTTP_TRANSPORT_FACTORY is already an instance of OAuth2Utils.DefaultHttpTransportFactory, and transportFactory.getClass() == MtlsHttpTransportFactory.class short-circuits the hasKeyStore() check since MtlsHttpTransportFactory has no subclasses. Can we simplify this to check transportFactory == null || transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory || transportFactory instanceof MtlsHttpTransportFactory?

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 to the null / DefaultHttpTransportFactory / MtlsHttpTransportFactory instanceof checks.

tokenJson.put("subject_token", "testSubjectToken");
OAuth2Utils.writeInputStreamToFile(
new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)),
tokenFile.toString());

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 this test only builds the credentials and never reads from disk, can we drop @TempDir and pass "credential.json" directly like serialize_deserialize_withCustomTransportFactoryAndCertConfig_preservesCustomFactory does at line 3300?

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. I dropped @tempdir and the token-file write, and the test now passes "credential.json" directly.

- Skip the per-cycle KeyStore snapshot on the impersonation path; reuse the lazily initialized KeyStore on the first direct cycle.
- Simplify isDefaultOrMtlsTransportFactory.
- Make the custom-factory serialization test load a real cert config.
- Drop the unused TempDir from the toBuilder X509Provider test.
@macastelaz
macastelaz requested a review from lsirac September 28, 2026 15:38
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.
return ks;
}
};
ImpersonatedCredentials impersonated = Mockito.mock(ImpersonatedCredentials.class);

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.

Mocking ImpersonatedCredentials without Mockito.withSettings().withoutAnnotations() fails on the JDK 8 CI job with ArrayStoreException: sun.reflect.annotation.EnumConstantNotPresentExceptionProxy because ImpersonatedCredentials is annotated with @NullMarked. Pass Mockito.withSettings().withoutAnnotations() here like IamUtilsTest does.

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 to pass Mockito.withSettings().withoutAnnotations().

static boolean isDefaultOrMtlsTransportFactory(@Nullable HttpTransportFactory transportFactory) {
return transportFactory == null
|| transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory
|| transportFactory instanceof MtlsHttpTransportFactory;

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.

Using transportFactory instanceof MtlsHttpTransportFactory here causes shouldUseMtlsTransportFactory() to return true for caller supplied MtlsHttpTransportFactory subclasses, which overwrites custom subclasses with a plain MtlsHttpTransportFactory and conflicts with CustomMtlsHttpTransportFactory in #14212. Check transportFactory.getClass() == MtlsHttpTransportFactory.class || (transportFactory instanceof MtlsHttpTransportFactory && !((MtlsHttpTransportFactory) transportFactory).hasKeyStore()) instead so custom subclasses are 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.

Updated isDefaultOrMtlsTransportFactory to check transportFactory.getClass() == MtlsHttpTransportFactory.class || (transportFactory instanceof MtlsHttpTransportFactory && !((MtlsHttpTransportFactory) transportFactory).hasKeyStore()), matching #14212.

KeyStore freshKeyStore = this.x509Provider.getKeyStore();
HttpTransportFactory retryTransportFactory = new MtlsHttpTransportFactory(freshKeyStore);
HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore);
if (!hasInitializedMtlsTransport()) {

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.

Is if (!hasInitializedMtlsTransport()) ever true here? The lazy init check at the top of refreshAccessToken() already initializes this.transportFactory under the same this.x509Provider != null && shouldUseMtlsTransportFactory() condition before the STS call runs.

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! Since this.transportFactory is already initialized at the start of refreshAccessToken() whenever !hasInitializedMtlsTransport() is true, this check in the catch block was unreachable. Removed

+ " Service Connect (containing '.p.').");
}
private boolean shouldUseMtlsTransportFactory() {
return this.useMtlsTransportFactory || isDefaultOrMtlsTransportFactory(this.transportFactory);

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.

Can we add a test for the || isDefaultOrMtlsTransportFactory(this.transportFactory) deserialization fallback? Right now serializeAndDeserialize only runs on instances built on this branch where useMtlsTransportFactory is already true, so setting useMtlsTransportFactory to false via reflection before round tripping would cover older serialized streams.

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 CustomMtlsHttpTransportFactory and customMtlsHttpTransportFactorySubclass_preservedInConstructorAndRefresh_rebuiltOnDeserialization (matching #14212).

Passing .setHttpTransportFactory(new CustomMtlsHttpTransportFactory(ks)) sets useMtlsTransportFactory = false, and upon deserialization ServiceCredentials.readObject() instantiates a keyless CustomMtlsHttpTransportFactory() via newInstance(), exercising the || isDefaultOrMtlsTransportFactory(this.transportFactory) fallback branch in shouldUseMtlsTransportFactory().

@CanIgnoreReturnValue
public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) {
super.setHttpTransportFactory(transportFactory);
this.useMtlsTransportFactory = isDefaultOrMtlsTransportFactory(transportFactory);

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.

Can we add a test for credential.toBuilder().setHttpTransportFactory(customFactory).build() on a credential with a certificate config? Nothing currently tests that Builder.setHttpTransportFactory updates useMtlsTransportFactory so the custom transport factory is not overwritten by lazy mTLS init.

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 toBuilder_setHttpTransportFactory_withCustomFactoryAndCertConfig_preservesCustomFactory to verify that overriding the transport factory via toBuilder().setHttpTransportFactory(customFactory) preserves customFactory both on build() and across refreshAccessToken().


assertNotNull(rebuilt.getX509Provider());
assertNotSame(trackingProvider, rebuilt.getX509Provider());
assertEquals(1, getKeyStoreCount.get());

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.

Can we also assert assertNotSame(credential.getTransportFactory(), rebuilt.getTransportFactory()) here to verify that setCredentialSource resets isClonedTransportInitialized?

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 assertNotSame(credential.getTransportFactory(), rebuilt.getTransportFactory()).

.setAudience("audience")
.setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt")
.setTokenUrl("https://sts.googleapis.com/v1/token")
.setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY)

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.

Use MockExternalAccountCredentialsTransportFactory and STS_URL in both builders in this test instead of OAuth2Utils.HTTP_TRANSPORT_FACTORY with https://sts.googleapis.com/v1/token so a validation regression does not make a live network call.

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 refreshAccessToken_withNullOrWhitespaceActorToken_throwsIOException to use MockExternalAccountCredentialsTransportFactory and STS_URL.

* Sets the actor token supplier used for certificate-bound OAuth 2.0 token exchanges. The
* supplier provides an actor token representing the entity on whose behalf the subject is
* acting.
* Sets the actor token supplier used for OAuth 2.0 token exchanges. The supplier provides an

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 RFC 8693 Section 2.1, the actor token represents the acting party that acts on behalf of the subject rather than the entity on whose behalf the subject is acting. Should we update this Javadoc wording to match?

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 to match RFC 8693 Section 2.1 terminology

@macastelaz
macastelaz requested a review from lsirac September 30, 2026 03:55

@CanIgnoreReturnValue
public Builder setCredentialSource(IdentityPoolCredentialSource credentialSource) {
if (this.credentialSource != null && this.credentialSource != credentialSource) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This clears x509Provider, but this.transportFactory still holds the old credential's keyed MtlsHttpTransportFactory, so switching to a credential source without a certificate config leaves the rebuilt credential pinned to the old client certificate. Should we also reset this.transportFactory to null here when useMtlsTransportFactory is true, and add a test for setting a new source without a certificate config?

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 - setCredentialSource now also resets this.transportFactory = null when this.useMtlsTransportFactory is true, and toBuilder_setCredentialSource_withNewSource_clearsCopiedX509Provider now verifies that switching to a credential source without a certificate config resets both x509Provider and MtlsHttpTransportFactory.

+ value.getClass().getName());
}
return (String) value;
return extractField(fileContents, targetFieldName);

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 parseToken now delegates to extractField and rejects blank JSON token values for URL and single-token file sources too, can we add a test for a blank token field on parseToken or getSubjectToken? Right now only readTokens is covered.

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 parseToken_jsonFormat_emptyOrWhitespaceField_throws covering both supplier.getSubjectToken(null) and FileIdentityPoolSubjectTokenSupplier.parseToken(...) when the JSON token field is whitespace.

validateMtlsEndpoint(getServiceAccountImpersonationUrl(), "serviceAccountImpersonationUrl");
}
}
static boolean isDefaultOrMtlsTransportFactory(@Nullable HttpTransportFactory transportFactory) {

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: Nothing outside IdentityPoolCredentials calls isDefaultOrMtlsTransportFactory. Can we make it private static?

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 — changed isDefaultOrMtlsTransportFactory to private static.

private @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier;
private @Nullable String actorTokenType;
private @Nullable X509Provider x509Provider;
private @Nullable Boolean useMtlsTransportFactory;

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 we declare this as private boolean useMtlsTransportFactory = true; instead of a nullable Boolean? It is only null on a fresh builder when setHttpTransportFactory was never called, where the constructor fallback always evaluates to true anyway.

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 — changed Builder.useMtlsTransportFactory to private boolean useMtlsTransportFactory = true; and simplified the constructor assignment to this.useMtlsTransportFactory = builder.useMtlsTransportFactory;.

Mockito.mock(ImpersonatedCredentials.class, Mockito.withSettings().withoutAnnotations());
Mockito.when(impersonated.refreshAccessToken())
.thenReturn(new AccessToken("impersonatedAccessToken", null));
List<HttpTransportFactory> factoriesSeenByImpersonation = new java.util.ArrayList<>();

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 we use new ArrayList<>() here since java.util.ArrayList is already imported at line 71?

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 to new ArrayList<>()

@macastelaz
macastelaz requested a review from lsirac September 30, 2026 18:25

This branch has not been deployed

No deployments
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.

2 participants