Repository navigation
feat(oauth2): support non-mTLS token URLs and unbound actor tokens in IdentityPoolCredentials #14430
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(oauth2): support non-mTLS token URLs and unbound actor tokens in IdentityPoolCredentials #14430
Changes from all commits
8e5f005
5b01057
82254db
961361b
78790db
3e40f0a
23fc045
928659b
e5280d8
abfb97c
d12c7ec
8a2ac3f
96423b4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,11 +42,11 @@ | |
| import com.google.errorprone.annotations.CanIgnoreReturnValue; | ||
| import java.io.IOException; | ||
| import java.io.ObjectInputStream; | ||
| import java.net.URI; | ||
| import java.security.KeyStore; | ||
| import java.util.ArrayList; | ||
| import java.util.Collection; | ||
| import java.util.Map; | ||
| import java.util.Objects; | ||
| import org.jspecify.annotations.NullMarked; | ||
| import org.jspecify.annotations.Nullable; | ||
|
|
||
|
|
@@ -55,9 +55,9 @@ | |
| * | ||
| * <p>By default, attempts to exchange the external credential for a GCP access token. | ||
| * | ||
| * <p>Note: Actor token extraction is currently restricted to file-based JSON credential sources | ||
| * over mTLS endpoints. When configuring certificate-bound OAuth 2.0 tokens, ensure your transport | ||
| * layer is configured for mTLS in tandem. | ||
| * <p>Note: Actor token extraction from credential configuration files is currently restricted to | ||
| * file-based JSON credential sources. When configuring certificate-bound OAuth 2.0 tokens, ensure | ||
| * your transport layer and endpoints are configured for mTLS in tandem. | ||
| */ | ||
| @NullMarked | ||
| public class IdentityPoolCredentials extends ExternalAccountCredentials { | ||
|
|
@@ -104,10 +104,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { | |
| // Store the x509Provider and useMtlsTransportFactory flag for per-cycle cert pinning and | ||
| // deserialization recovery. | ||
| this.x509Provider = builder.x509Provider; | ||
| this.useMtlsTransportFactory = | ||
| builder.useMtlsTransportFactory != null | ||
| ? builder.useMtlsTransportFactory | ||
| : isDefaultOrMtlsTransportFactory(builder.transportFactory); | ||
| this.useMtlsTransportFactory = builder.useMtlsTransportFactory; | ||
|
|
||
| // Initialize based on the source type | ||
| if (builder.subjectTokenSupplier != null) { | ||
|
|
@@ -171,43 +168,6 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { | |
| throw new IllegalArgumentException( | ||
| "An actorTokenSupplier must be specified when an actorTokenType is configured."); | ||
| } | ||
|
|
||
| if (this.actorTokenSupplier != null && !isMtlsConfigured()) { | ||
| throw new IllegalArgumentException( | ||
| "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" | ||
| + " configuration in the credential source or provide an MtlsHttpTransportFactory" | ||
| + " constructed with a KeyStore."); | ||
| } | ||
|
|
||
| if (this.actorTokenSupplier != null) { | ||
| validateMtlsEndpoint(getTokenUrl(), "tokenUrl"); | ||
| if (getServiceAccountImpersonationUrl() != null) { | ||
| validateMtlsEndpoint(getServiceAccountImpersonationUrl(), "serviceAccountImpersonationUrl"); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static void validateMtlsEndpoint(@Nullable String url, String fieldName) { | ||
| if (url == null) { | ||
| return; | ||
| } | ||
| String host = URI.create(url).getHost(); | ||
| // For Google Default Universe (googleapis.com), actor tokens require mTLS binding so plain | ||
| // public endpoints (lacking '.mtls.' or Private Service Connect '.p.') are rejected early. | ||
| // Non-GDU domains (e.g. custom universes or TPC) may use different hostname conventions. | ||
| 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.')."); | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed the 3 duplicate |
||
|
|
||
| /** | ||
|
|
@@ -242,7 +202,6 @@ boolean isMtlsConfigured() { | |
| */ | ||
| static boolean isDefaultOrMtlsTransportFactory(@Nullable HttpTransportFactory transportFactory) { | ||
| return transportFactory == null | ||
| || transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY | ||
| || transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory | ||
| || transportFactory.getClass() == MtlsHttpTransportFactory.class | ||
| || (transportFactory instanceof MtlsHttpTransportFactory | ||
|
|
@@ -266,7 +225,7 @@ boolean hasMtlsProviderForImpersonation() { | |
| * Returns whether a clone of this credential can skip reading the KeyStore because the transport | ||
| * factory is already initialized (an mTLS factory with a KeyStore, or a custom non-mTLS factory). | ||
| */ | ||
| boolean hasInitializedMtlsTransport() { | ||
| private boolean hasInitializedMtlsTransport() { | ||
| return this.x509Provider != null | ||
| && (!shouldUseMtlsTransportFactory() | ||
| || (this.transportFactory instanceof MtlsHttpTransportFactory | ||
|
|
@@ -463,8 +422,12 @@ private AccessToken buildAndExchangeStsRequest( | |
| StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType()) | ||
| .setAudience(getAudience()); | ||
|
|
||
| if (actorToken != null && this.actorTokenType != null) { | ||
| stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType)); | ||
| if (this.actorTokenSupplier != null) { | ||
| if (actorToken == null || actorToken.trim().isEmpty()) { | ||
| throw new IOException("The provided actor token cannot be null or empty."); | ||
| } | ||
| stsTokenExchangeRequest.setActingParty( | ||
| new ActingParty(actorToken, Objects.requireNonNull(this.actorTokenType))); | ||
| } | ||
|
|
||
| Collection<String> scopes = getScopes(); | ||
|
|
@@ -640,7 +603,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { | |
| private @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier; | ||
| private @Nullable String actorTokenType; | ||
| private @Nullable X509Provider x509Provider; | ||
| private @Nullable Boolean useMtlsTransportFactory; | ||
| private boolean useMtlsTransportFactory = true; | ||
| private boolean isClonedTransportInitialized; | ||
|
|
||
| Builder() {} | ||
|
|
@@ -653,10 +616,11 @@ public static class Builder extends ExternalAccountCredentials.Builder { | |
| } else if (credentials.actorTokenSupplier != credentials.subjectTokenSupplier) { | ||
| this.actorTokenSupplier = credentials.actorTokenSupplier; | ||
| } | ||
| // Note: when credentialSource is present, subjectTokenSupplier and file-based | ||
| // actorTokenSupplier are intentionally NOT copied here. They will be reconstructed from | ||
| // credentialSource during build(), which ensures they share the same | ||
| // FileIdentityPoolSubjectTokenSupplier instance for atomic token reads. | ||
| // Note: when credentialSource is present and actorTokenSupplier was reconstructed from the | ||
| // same FileIdentityPoolSubjectTokenSupplier instance as subjectTokenSupplier, neither is | ||
| // copied here so they can be reconstructed together during build() for atomic token reads. | ||
| // When a distinct actorTokenSupplier was explicitly provided alongside credentialSource, it | ||
| // is preserved above so createScoped() / toBuilder() do not drop it. | ||
| this.actorTokenType = credentials.actorTokenType; | ||
| this.x509Provider = credentials.x509Provider; | ||
| // Preserve the original mTLS flag across clones because super(credentials) copies | ||
|
|
@@ -696,12 +660,11 @@ public Builder setSubjectTokenSupplier(IdentityPoolSubjectTokenSupplier subjectT | |
| } | ||
|
|
||
| /** | ||
| * 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. updated the Javadoc to match RFC 8693 Section 2.1 terminology |
||
| * actor token representing the acting party that acts on behalf of the subject. | ||
| * | ||
| * <p>An actor token supplier must be paired with an {@link #setActorTokenType actor token type} | ||
| * and requires an mTLS-configured transport. | ||
| * <p>An actor token supplier must be paired with an {@link #setActorTokenType actor token | ||
| * type}. | ||
| * | ||
| * @param actorTokenSupplier the supplier to use for retrieving actor tokens | ||
| * @return this {@code Builder} object | ||
|
|
@@ -768,6 +731,12 @@ public Builder setTokenUrl(String tokenUrl) { | |
|
|
||
| @CanIgnoreReturnValue | ||
| public Builder setCredentialSource(IdentityPoolCredentialSource credentialSource) { | ||
| if (this.credentialSource != null && this.credentialSource != credentialSource) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This clears
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| this.x509Provider = null; | ||
| if (this.useMtlsTransportFactory) { | ||
| this.transportFactory = null; | ||
| } | ||
| } | ||
| super.setCredentialSource(credentialSource); | ||
| this.isClonedTransportInitialized = false; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| return this; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -380,6 +380,36 @@ void parseToken_jsonFormat_missingFieldName_throws() { | |
| "Target field name must be specified for JSON credentials.", exception.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void parseToken_jsonFormat_emptyOrWhitespaceField_throws(@TempDir Path tempDir) | ||
| throws IOException { | ||
| Path credentialFile = tempDir.resolve("credential.json"); | ||
| Files.write(credentialFile, "{\"sub_token\": \" \"}".getBytes(StandardCharsets.UTF_8)); | ||
|
|
||
| Map<String, Object> credentialSourceMap = new HashMap<>(); | ||
| credentialSourceMap.put("file", credentialFile.toString()); | ||
| Map<String, String> formatMap = new HashMap<>(); | ||
| formatMap.put("type", "json"); | ||
| formatMap.put("subject_token_field_name", "sub_token"); | ||
| credentialSourceMap.put("format", formatMap); | ||
| IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); | ||
|
|
||
| FileIdentityPoolSubjectTokenSupplier supplier = | ||
| new FileIdentityPoolSubjectTokenSupplier(source); | ||
| IOException getSubjectException = | ||
| assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); | ||
| assertTrue( | ||
| getSubjectException.getMessage().contains("Empty token was found for field: sub_token")); | ||
|
|
||
| ByteArrayInputStream stream = | ||
| new ByteArrayInputStream("{\"sub_token\": \" \"}".getBytes(StandardCharsets.UTF_8)); | ||
| IOException parseException = | ||
| assertThrows( | ||
| IOException.class, | ||
| () -> FileIdentityPoolSubjectTokenSupplier.parseToken(stream, source, "sub_token")); | ||
| assertTrue(parseException.getMessage().contains("Empty token was found for field: sub_token")); | ||
| } | ||
|
|
||
| @Test | ||
| void readTokens_extractsBothFields(@TempDir Path tempDir) throws IOException { | ||
| Path credentialFile = tempDir.resolve("credential.json"); | ||
|
|
@@ -449,6 +479,56 @@ void readTokens_missingSubjectField_throwsIOException(@TempDir Path tempDir) thr | |
| assertTrue(exception.getMessage().contains("No token was found for field: sub_token")); | ||
| } | ||
|
|
||
| @Test | ||
| void readTokens_emptyOrWhitespaceActorField_throwsIOException(@TempDir Path tempDir) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Since
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done — added |
||
| throws IOException { | ||
| Path credentialFile = tempDir.resolve("credential.json"); | ||
| Files.write( | ||
| credentialFile, | ||
| "{\"sub_token\": \"my_sub_token\", \"act_token\": \" \"}" | ||
| .getBytes(StandardCharsets.UTF_8)); | ||
|
|
||
| Map<String, Object> credentialSourceMap = new HashMap<>(); | ||
| credentialSourceMap.put("file", credentialFile.toString()); | ||
| Map<String, String> formatMap = new HashMap<>(); | ||
| formatMap.put("type", "json"); | ||
| formatMap.put("subject_token_field_name", "sub_token"); | ||
| formatMap.put("actor_token_field_name", "act_token"); | ||
| credentialSourceMap.put("format", formatMap); | ||
|
|
||
| IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); | ||
| FileIdentityPoolSubjectTokenSupplier supplier = | ||
| new FileIdentityPoolSubjectTokenSupplier(source); | ||
|
|
||
| IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); | ||
| assertTrue(exception.getMessage().contains("Empty token was found for field: act_token")); | ||
| } | ||
|
|
||
| @Test | ||
| void readTokens_emptyOrWhitespaceSubjectField_throwsIOException(@TempDir Path tempDir) | ||
| throws IOException { | ||
| Path credentialFile = tempDir.resolve("credential.json"); | ||
| Files.write( | ||
| credentialFile, | ||
| "{\"sub_token\": \" \", \"act_token\": \"my_act_token\"}" | ||
| .getBytes(StandardCharsets.UTF_8)); | ||
|
|
||
| Map<String, Object> credentialSourceMap = new HashMap<>(); | ||
| credentialSourceMap.put("file", credentialFile.toString()); | ||
| Map<String, String> formatMap = new HashMap<>(); | ||
| formatMap.put("type", "json"); | ||
| formatMap.put("subject_token_field_name", "sub_token"); | ||
| formatMap.put("actor_token_field_name", "act_token"); | ||
| credentialSourceMap.put("format", formatMap); | ||
|
|
||
| IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); | ||
| FileIdentityPoolSubjectTokenSupplier supplier = | ||
| new FileIdentityPoolSubjectTokenSupplier(source); | ||
|
|
||
| IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); | ||
| assertTrue(exception.getMessage().contains("Empty token was found for field: sub_token")); | ||
| } | ||
|
|
||
| @Test | ||
| void readTokens_noActorFieldConfigured_returnsNullActor(@TempDir Path tempDir) | ||
| throws IOException { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since
parseTokennow delegates toextractFieldand rejects blank JSON token values for URL and single-token file sources too, can we add a test for a blank token field onparseTokenorgetSubjectToken? Right now onlyreadTokensis covered.There was a problem hiding this comment.
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.