Skip to content

feat(oauth2): Extract actor tokens for cert-bound OAuth2 STS exchange - #13955

Merged
macastelaz merged 28 commits into
googleapis:oauth2-bound-tokensfrom
macastelaz:cert-bound-oauth-part2
Sep 17, 2026
Merged

macastelaz merged 28 commits into
googleapis:oauth2-bound-tokensfrom
macastelaz:cert-bound-oauth-part2

Conversation

@macastelaz

@macastelaz macastelaz commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for actor tokens in IdentityPoolCredentials during certificate-bound OAuth2 STS token exchange, and implements per-cycle mTLS certificate snapshotting and single-retry rotation.

Architectural Changes & Review Feedback Resolutions

1. Actor Token Extraction & Atomic Reads

  • Added package-private IdentityPoolActorTokenSupplier interface.
  • Enhanced FileIdentityPoolSubjectTokenSupplier with readTokens() to read and parse JSON credential files in a single atomic I/O operation, returning a TokenPair for subject and actor tokens.
  • Removed unreliable file timestamp caching; the file is read at most once per refresh cycle.
  • Added early validation in IdentityPoolCredentialSource: actorTokenFieldName must be non-empty, distinct from subjectTokenFieldName, and is supported only on JSON file sources.

2. Per-Cycle Certificate Pinning & 401 Rotation Retry

  • refreshAccessToken() snapshots the KeyStore from X509Provider at the start of each refresh cycle, creating a cycle-isolated MtlsHttpTransportFactory passed to the STS exchange.
  • On 401 Unauthorized from STS, refreshAccessToken() re-reads a fresh KeyStore from x509Provider and retries the exchange once (preserving original error context via addSuppressed if cert reload fails).
  • Relaxed mTLS endpoint validation to check for .mtls. to support custom universes and PSC endpoints.

3. Serialization & Binary Backward Compatibility

  • SUID Compatibility: Preserved FileIdentityPoolSubjectTokenSupplier class name and declared static serialVersionUID = 7152208690659890358L;, matching the synthetic SUID generated before this PR to prevent InvalidClassException on existing serialized credentials.
  • Binary ABI Preservation: Kept public NetHttpTransport create() return type on MtlsHttpTransportFactory to preserve binary compatibility for compiled callers via covariant return typing.
  • Post-Deserialization Usability: Implemented custom readObject() in IdentityPoolCredentials to reconstruct transient x509Provider and MtlsHttpTransportFactory from the serialized credentialSource, ensuring refreshAccessToken() and createScoped() remain fully functional after deserialization.
  • KeyStore Validation: MtlsHttpTransportFactory.hasKeyStore() checks mtlsKeyStore != null && mtlsKeyStore.size() > 0, ensuring uninitialized or empty KeyStores are not treated as mTLS-configured.

4. API Visibility

  • Made setActorTokenSupplier, setActorTokenType, and setX509Provider package-private on IdentityPoolCredentials.Builder for internal/testing use.

Test Coverage

  • 1009 unit tests passing (oauth2_http module).
  • Production Path Tests: End-to-end tests parsing JSON configs via ExternalAccountCredentials.fromStream() and executing refreshAccessToken().
  • Serialization Tests: Verified backward compatibility against pre-PR serialized byte fixtures and verified token refresh on deserialized instances.
  • Cert Pinning & Retry Tests: Verified single-snapshot cert consistency and 401 cert rotation retries.
  • Google Java Format: Verified 100% compliance with com.spotify.fmt:fmt-maven-plugin:2.25.

Manual Testing

Next Steps

The next phase (which will follow in a separate PR to keep reviews
scoped) will introduce dynamic mTLS transport rotation natively in
GAX and a 401 connection-draining interceptor required for
downstream service retry logic.

Based on design: https://docs.google.com/document/d/1NIKeJX86ETNAjoQA-lZIE_G_8mHspisBaL8gwNCO3dA/edit?resourcekey=0-z7teBZZk0WFIJEHL2ODZxw&tab=t.0

See b/542238030 for tracking

Implementation of Phase 1-3 of the Cert-Bound Oauth2 Design Document:
1. Extend IdentityPoolCredentialSource to parse actorTokenFieldName.
2. Relax mutual exclusivity to allow BOTH file and certificate configurations.
3. Parse actor_token_type in ExternalAccountCredentials.
4. Refactor FileIdentityPoolTokenSupplier and track file timestamp via volatile CachedFile for the parsed JSON payload.
5. Inject actor_token and actor_token_type into StsTokenExchangeRequest using ActingParty.
6. Enforce that actor token extraction requires an mTLS STS configuration.

@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 by adding actor token types and field names, introducing the IdentityPoolActorTokenSupplier interface, and refactoring the file-based supplier to FileIdentityPoolTokenSupplier with caching. Feedback suggests optimizing disk I/O by sharing a single FileIdentityPoolTokenSupplier instance for both subject and actor tokens, passing the target field name dynamically, and relaxing the mTLS URL validation check to generically look for .mtls. to support custom universes and Private Service Connect endpoints.

@macastelaz
macastelaz changed the base branch from main to oauth2-bound-tokens July 30, 2026 02:48
@macastelaz
macastelaz marked this pull request as ready for review July 30, 2026 17:13
@macastelaz
macastelaz requested review from a team as code owners July 30, 2026 17:13
- Mark CachedFile and X509Provider transient to ensure clean serialization.
- Add static modifier to FileIdentityPoolTokenSupplier serialVersionUID.
- Make IdentityPoolActorTokenSupplier public with @NullMarked annotation.
- Preserve actorTokenSupplier in IdentityPoolCredentials Builder copy constructor.
- Mask actor_token in Slf4jLoggingHelpers sensitive keys.
- Add no-arg constructor to MtlsHttpTransportFactory for serialization support.
- Handle Data.isNull in FileIdentityPoolTokenSupplier JSON parsing.
- Add comprehensive test coverage for supplier caching, builder, serialization, and log masking.
…uilder copy constructor

- Guard actorTokenSupplier assignment with if (this.credentialSource == null) in Builder copy constructor.
- Add getIdentityPoolActorTokenSupplier getter for test assertions.
- Add createScoped tests for both file-sourced and supplier-sourced credentials with actor tokens.
…s and FileIdentityPoolTokenSupplier

- Add builder_actorTokenTypeWithoutSupplier_throws testing missing supplier validation.
- Add builder_fileWithCertificateConfig_initializesMtlsTransport testing mTLS initialization for composite file + cert sources.
- Add toBuilder_preservesConfiguration testing builder reconstruction.
- Add parseToken_textFormat_succeeds and parseToken_jsonFormat_missingFieldName_throws testing static token parsing methods.
@nbayati
nbayati self-requested a review August 7, 2026 18:42
@lsirac

lsirac commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Before we launch lets make sure to add integration tests for each supported transport and certificate rotation scenario to make sure that bound tokens work e2e.

Summary of changes:
- Comment 1: Fix Javadoc referencing package-private API
- Comment 2: Replace instanceof with isMtlsConfigured() check
- Comment 3: Per-cycle cert pinning with KeyStore snapshot, 401 retry
- Comment 4: Atomic subject+actor token read via readTokens()
- Comment 5: Make setActorTokenSupplier/Type package-private
- Comment 6: Add Javadocs to builder setter methods
- Comment 7: Fix Builder copy constructor (always copy actorTokenType)
- Comment 8: Annotate no-arg MtlsHttpTransportFactory with @internalapi
- Comment 9: Revert class rename to FileIdentityPoolSubjectTokenSupplier
- Comment 10: Remove CachedFile/volatile caching mechanism
- Comment 11: Add actorTokenFieldName validation
- Comment 12: Integration tests noted for follow-up PR

Added overload exchangeExternalCredentialForAccessToken(request, factory)
for per-cycle transport factory threading.

Added 12 new unit tests covering readTokens(), validation, and mTLS.
All 982 existing + new tests pass.
- Fix copyright year (2024 -> 2026) in FileIdentityPoolSubjectTokenSupplier
- Add Javadoc explaining class name retained for serialization compatibility
- Add hasKeyStore() to MtlsHttpTransportFactory for watertight mTLS validation
- Update isMtlsConfigured() to verify KeyStore is non-null via hasKeyStore()
- Update no-arg constructor Javadoc to explain serialization requirement
- Add comment to Builder copy constructor explaining supplier reconstruction
- Add 3 unit tests for hasKeyStore() and no-arg factory validation
@macastelaz

Copy link
Copy Markdown
Contributor Author

Note that as a result of this latest iteration, I've identified and captured some additional future work to explore in subsequent PRs which I've captured here: https://paste.googleplex.com/5973231872901120

@macastelaz macastelaz closed this Aug 21, 2026
@macastelaz macastelaz reopened this Aug 21, 2026
@InternalApi
public class MtlsHttpTransportFactory implements HttpTransportFactory {
private final KeyStore mtlsKeyStore;
public class MtlsHttpTransportFactory implements HttpTransportFactory, java.io.Serializable {

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.

nit: Can we import the what is needed (e.g. Serializable) to avoid needing the fully qualified names?

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 here and elsewhere where we were using fully qualified names unnecessarily

/** Functional interface for supplying an actor token for IdentityPool credentials. */
@NullMarked
@FunctionalInterface
interface IdentityPoolActorTokenSupplier extends java.io.Serializable {

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.

Some thoughts about the hierarchy (not a blocker on this PR as the interface is package-private and the methods added are internal), but wanted to get your thoughts:

From reading the docs, I believe this is a deliberate design choice as not all general ExternalAccount types may have the concept of an actor_token. I have a slight worry that we may be tying the interface a little too closely to the class itself. (SubjectToken interface and now a new ActorToken interface). Expanding this in the future may result in something like PluggableActorTokenSupplier and AwsActorTokenSupplier interfaces or new interfaces for a new token type.

What do you think about having something like ExternalAccountTokenSupplier (generalized for subject and actor tokens now and can work for any new token in the future). I see the two token methods below take in ExternalAccountSupplierContext param so it may be a bit harder to generalize for more types.

public interface ExternalAccountTokenSupplier {
    String getSubjectToken(ExternalAccountSupplierContext context);
    String getActorToken(ExternalAccountSupplierContext context);
}

IdentityPoolSubjectTokenSupplier would extend from ExternalAccountTokenSupplier and throw an exception or return null for getActorToken.

IdentityPoolSubjectTokenSupplier interface would be marked as obsolete and we would use the more general ExternalAccountTokenSupplier. Widening the class type should be fine.

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.

Thanks for flagging this! I definitely agree we will want to avoid a future where we would have something like "PluggableActorTokenSupplier" and "AwsActorTokenSupplier" interfaces.

With that said, I think we may hit a problem if trying to combine them into a single ExternalAccountTokenSupplier which is that I believe java limits @FunctionalInterface to having only a single abstract method and then whenever a lambda expression is provided it is used for that single abstract method.

I think if/when we expand the actor token support beyond IdentityPoolCredentials we can evaluate other options (which we won't be limited to given the package-private nature of this current solution as you called out), such as having a single ExternalAccountActorTokenSupplier (keeping it separate from the subject token but generalizing beyond cred type).

Comment on lines +191 to +205
URI uri = URI.create(url);
String host = uri.getHost();
if (host != null
&& host.endsWith("googleapis.com")
&& !host.contains(".mtls.")
&& !host.contains(".p.")) {
throw new IllegalArgumentException(
"The "
+ fieldName
+ " endpoint ("
+ url
+ ") cannot be used with actor tokens because it is a plain public Google API"
+ " endpoint. Please use an mTLS endpoint (e.g. containing '.mtls.') or Private"
+ " Service Connect (containing '.p.').");
}

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 general question: Tracing through the tokenUrl flow, I see that we have a default TOKEN_URL set to be https://sts.{UNIVERSE_DOMAIN}/v1/token which then gets replaced with the proper universe domain. Can you double check that this wouldn't result in accidental IllegalArgumentException errors from 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.

Double checked this shouldn't lead to any accidental IllegalArgumentException errors. The constructor of the base ExternalAccountCredentials checks if the tokenUrl is null and if so uses the default token URL that replaces the universe domain ("https://sts.{UNIVERSE_DOMAIN}/v1/token"). That means getTokenUrl never contains the unreplaced universe domain. The only time we'd get an IllegalArgumentException is if the user doesn't specify the mtls token url while specifying an actor token requiring the mtls endpoint - but this is WAI. There is a possible world in the future, from what I've heard, where we may be able to do a token exchange on a non-mtls endpoint so leaving that door open for now seems to make the most sense to me (though we still plan to validate the user config has the mtls token url if they include the actor token).

Comment on lines +69 to +73
@Nullable private final IdentityPoolActorTokenSupplier actorTokenSupplier;
@Nullable private final String actorTokenType;
// Transient: not serialized directly. Reconstructed in readObject() from the credentialSource
// certificate config so deserialized credentials remain usable for mTLS and refresh.
@Nullable private transient X509Provider x509Provider;

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.

nit: annotation is type-use so it should be on the type

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.

Shoot - sorry about that. Fixed!

# Conflicts:
#	google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java
@macastelaz
macastelaz requested review from a team as code owners September 16, 2026 02:12
@snippet-bot

snippet-bot Bot commented Sep 16, 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 changed the base branch from oauth2-bound-tokens to main September 16, 2026 03:44
@macastelaz
macastelaz changed the base branch from main to oauth2-bound-tokens September 16, 2026 03:44
Comment on lines +112 to +116
if (builder.transportFactory == null
|| builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY
|| builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory) {
this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore);
}

@lqiu96 lqiu96 Sep 16, 2026

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.

IIUC, since this upgrades the transportFactory to the mtls variant when there is the default transportFactory, what are the consequences when users set a custom HttpTransportFactory?

If it may not work, should we add a debug level statement? Or would it be better to throw an exception for an invalid state

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 an else block to log at the debug level to inform users that they need to ensure that mtls is supported in their custom transport factory if mtls is required by the token endpoint.

HttpTransportFactory cycleTransportFactory = this.transportFactory;
if (this.x509Provider != null) {
KeyStore pinnedKeyStore = this.x509Provider.getKeyStore();
cycleTransportFactory = new MtlsHttpTransportFactory(pinnedKeyStore);

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, do we need the same default transport check here as well to avoid overriding any custom 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.

Thanks for flagging this gap - this definitely needed the same check to avoid overriding any custom transport and added a test to ensure the custom transport gets preserved across refreshes

Comment on lines +193 to +196
if (host != null
&& host.endsWith("googleapis.com")
&& !host.contains(".mtls.")
&& !host.contains(".p.")) {

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.

nit: Can you add a small comment to explain the conditions as it was a bit hard for me to figure out immediately.

IIUC, this checks that GDU cannot work unless it's configured with PSC or MTLS right? We do allow for non-GDU, but don't explicitly check for PSC/ mTLS in those cases.

I'm sure there probably are some weird edge cases that we may not be validating here and I'm assuming that we don't intend to cover every possible case.

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 a comment clarifying the intent for GDU but not enforcing non-GDU universes.

Comment on lines +206 to +207
} catch (IllegalArgumentException e) {
throw 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.

qq, why throw this exception again?

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 original reason for this (keeping the desired IAE that resulted from reasons not related to URL validity) is actually no longer relevant (it's handled in the constructor of ExternalAccountCredentials which already validates the tokenUrl and serviceAccountImpersonationUrl anyways) so I was able to remove the try/catch and simplify this a bit! Thanks for flagging!

Comment on lines +309 to +317
if (actorTokenFieldName != null) {
if (actorTokenFieldName.trim().isEmpty()) {
throw new IllegalArgumentException("The actor_token_field_name must not be empty.");
}
if (actorTokenFieldName.equals(subjectTokenFieldName)) {
throw new IllegalArgumentException(
"The actor_token_field_name must differ from the subject_token_field_name.");
}
}

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.

just a general question for my curiosity (nothing wrong with validation): How often/ realistic do you think something like this would end up being the case. IIUC, these values will be generated initially by gcloud but I believe a user can simply modify the file themselves if they want. Is this something we've seen customers do in the past?

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.

This check was added from an earlier review suggestion by @lsirac (comment #13955 (comment)) to fail fast on obvious misconfigurations.

You're right that when users generate the credential config directly via gcloud iam workload-identity-pools create-cred-config, gcloud outputs well-formed field names automatically. However, in practice we do see customers authoring or templating these JSON config files outside of gcloud—for example, via Terraform/IaC templates, Kubernetes ConfigMaps/Helm charts, or by copy-pasting and hand-editing an existing config file when setting up custom token suppliers.

If someone accidentally copy-pastes the same JSON key for both subject_token_field_name and actor_token_field_name (or leaves it blank in a template), failing immediately at config load time with a clear IllegalArgumentException is much easier to diagnose than sending duplicate tokens to STS and getting a generic 400 invalid_grant error during runtime token exchange.

@macastelaz
macastelaz merged commit 1fed6d4 into googleapis:oauth2-bound-tokens Sep 17, 2026
263 of 266 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.

4 participants