Skip to content

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

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

macastelaz wants to merge 5 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.

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