Skip to content

Commit d06feac

Browse files
feat(oauth2): support non-mTLS token URLs and unbound actor tokens in IdentityPoolCredentials (#14430)
## 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`. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent ed2bd40 commit d06feac

4 files changed

Lines changed: 547 additions & 206 deletions

File tree

‎google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java‎

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,12 @@ private static String extractField(GenericJson json, String fieldName) throws IO
172172
+ " must be a String but was: "
173173
+ value.getClass().getName());
174174
}
175-
return (String) value;
175+
String token = (String) value;
176+
if (token.trim().isEmpty()) {
177+
throw new IOException(
178+
"Invalid token field value. Empty token was found for field: " + fieldName);
179+
}
180+
return token;
176181
}
177182

178183
/** Used primarily for UrlIdentityPoolSubjectTokenSupplier */
@@ -195,19 +200,7 @@ static String parseToken(
195200
GenericJson fileContents =
196201
parser.parseAndClose(in, StandardCharsets.UTF_8, GenericJson.class);
197202

198-
Object value = fileContents.get(targetFieldName);
199-
if (value == null || Data.isNull(value)) {
200-
throw new IOException(
201-
"Invalid token field name. No token was found for field: " + targetFieldName);
202-
}
203-
if (!(value instanceof String)) {
204-
throw new IOException(
205-
"Token field value for "
206-
+ targetFieldName
207-
+ " must be a String but was: "
208-
+ value.getClass().getName());
209-
}
210-
return (String) value;
203+
return extractField(fileContents, targetFieldName);
211204
}
212205
}
213206

‎google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java‎

Lines changed: 28 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@
4242
import com.google.errorprone.annotations.CanIgnoreReturnValue;
4343
import java.io.IOException;
4444
import java.io.ObjectInputStream;
45-
import java.net.URI;
4645
import java.security.KeyStore;
4746
import java.util.ArrayList;
4847
import java.util.Collection;
4948
import java.util.Map;
49+
import java.util.Objects;
5050
import org.jspecify.annotations.NullMarked;
5151
import org.jspecify.annotations.Nullable;
5252

@@ -55,9 +55,9 @@
5555
*
5656
* <p>By default, attempts to exchange the external credential for a GCP access token.
5757
*
58-
* <p>Note: Actor token extraction is currently restricted to file-based JSON credential sources
59-
* over mTLS endpoints. When configuring certificate-bound OAuth 2.0 tokens, ensure your transport
60-
* layer is configured for mTLS in tandem.
58+
* <p>Note: Actor token extraction from credential configuration files is currently restricted to
59+
* file-based JSON credential sources. When configuring certificate-bound OAuth 2.0 tokens, ensure
60+
* your transport layer and endpoints are configured for mTLS in tandem.
6161
*/
6262
@NullMarked
6363
public class IdentityPoolCredentials extends ExternalAccountCredentials {
@@ -104,10 +104,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials {
104104
// Store the x509Provider and useMtlsTransportFactory flag for per-cycle cert pinning and
105105
// deserialization recovery.
106106
this.x509Provider = builder.x509Provider;
107-
this.useMtlsTransportFactory =
108-
builder.useMtlsTransportFactory != null
109-
? builder.useMtlsTransportFactory
110-
: isDefaultOrMtlsTransportFactory(builder.transportFactory);
107+
this.useMtlsTransportFactory = builder.useMtlsTransportFactory;
111108

112109
// Initialize based on the source type
113110
if (builder.subjectTokenSupplier != null) {
@@ -171,43 +168,6 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials {
171168
throw new IllegalArgumentException(
172169
"An actorTokenSupplier must be specified when an actorTokenType is configured.");
173170
}
174-
175-
if (this.actorTokenSupplier != null && !isMtlsConfigured()) {
176-
throw new IllegalArgumentException(
177-
"Actor tokens are only supported for mTLS token exchanges. Please configure a certificate"
178-
+ " configuration in the credential source or provide an MtlsHttpTransportFactory"
179-
+ " constructed with a KeyStore.");
180-
}
181-
182-
if (this.actorTokenSupplier != null) {
183-
validateMtlsEndpoint(getTokenUrl(), "tokenUrl");
184-
if (getServiceAccountImpersonationUrl() != null) {
185-
validateMtlsEndpoint(getServiceAccountImpersonationUrl(), "serviceAccountImpersonationUrl");
186-
}
187-
}
188-
}
189-
190-
private static void validateMtlsEndpoint(@Nullable String url, String fieldName) {
191-
if (url == null) {
192-
return;
193-
}
194-
String host = URI.create(url).getHost();
195-
// For Google Default Universe (googleapis.com), actor tokens require mTLS binding so plain
196-
// public endpoints (lacking '.mtls.' or Private Service Connect '.p.') are rejected early.
197-
// Non-GDU domains (e.g. custom universes or TPC) may use different hostname conventions.
198-
if (host != null
199-
&& host.endsWith("googleapis.com")
200-
&& !host.contains(".mtls.")
201-
&& !host.contains(".p.")) {
202-
throw new IllegalArgumentException(
203-
"The "
204-
+ fieldName
205-
+ " endpoint ("
206-
+ url
207-
+ ") cannot be used with actor tokens because it is a plain public Google API"
208-
+ " endpoint. Please use an mTLS endpoint (e.g. containing '.mtls.') or Private"
209-
+ " Service Connect (containing '.p.').");
210-
}
211171
}
212172

213173
/**
@@ -242,7 +202,6 @@ boolean isMtlsConfigured() {
242202
*/
243203
static boolean isDefaultOrMtlsTransportFactory(@Nullable HttpTransportFactory transportFactory) {
244204
return transportFactory == null
245-
|| transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY
246205
|| transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory
247206
|| transportFactory.getClass() == MtlsHttpTransportFactory.class
248207
|| (transportFactory instanceof MtlsHttpTransportFactory
@@ -266,7 +225,7 @@ boolean hasMtlsProviderForImpersonation() {
266225
* Returns whether a clone of this credential can skip reading the KeyStore because the transport
267226
* factory is already initialized (an mTLS factory with a KeyStore, or a custom non-mTLS factory).
268227
*/
269-
boolean hasInitializedMtlsTransport() {
228+
private boolean hasInitializedMtlsTransport() {
270229
return this.x509Provider != null
271230
&& (!shouldUseMtlsTransportFactory()
272231
|| (this.transportFactory instanceof MtlsHttpTransportFactory
@@ -463,8 +422,12 @@ private AccessToken buildAndExchangeStsRequest(
463422
StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType())
464423
.setAudience(getAudience());
465424

466-
if (actorToken != null && this.actorTokenType != null) {
467-
stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType));
425+
if (this.actorTokenSupplier != null) {
426+
if (actorToken == null || actorToken.trim().isEmpty()) {
427+
throw new IOException("The provided actor token cannot be null or empty.");
428+
}
429+
stsTokenExchangeRequest.setActingParty(
430+
new ActingParty(actorToken, Objects.requireNonNull(this.actorTokenType)));
468431
}
469432

470433
Collection<String> scopes = getScopes();
@@ -640,7 +603,7 @@ public static class Builder extends ExternalAccountCredentials.Builder {
640603
private @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier;
641604
private @Nullable String actorTokenType;
642605
private @Nullable X509Provider x509Provider;
643-
private @Nullable Boolean useMtlsTransportFactory;
606+
private boolean useMtlsTransportFactory = true;
644607
private boolean isClonedTransportInitialized;
645608

646609
Builder() {}
@@ -653,10 +616,11 @@ public static class Builder extends ExternalAccountCredentials.Builder {
653616
} else if (credentials.actorTokenSupplier != credentials.subjectTokenSupplier) {
654617
this.actorTokenSupplier = credentials.actorTokenSupplier;
655618
}
656-
// Note: when credentialSource is present, subjectTokenSupplier and file-based
657-
// actorTokenSupplier are intentionally NOT copied here. They will be reconstructed from
658-
// credentialSource during build(), which ensures they share the same
659-
// FileIdentityPoolSubjectTokenSupplier instance for atomic token reads.
619+
// Note: when credentialSource is present and actorTokenSupplier was reconstructed from the
620+
// same FileIdentityPoolSubjectTokenSupplier instance as subjectTokenSupplier, neither is
621+
// copied here so they can be reconstructed together during build() for atomic token reads.
622+
// When a distinct actorTokenSupplier was explicitly provided alongside credentialSource, it
623+
// is preserved above so createScoped() / toBuilder() do not drop it.
660624
this.actorTokenType = credentials.actorTokenType;
661625
this.x509Provider = credentials.x509Provider;
662626
// Preserve the original mTLS flag across clones because super(credentials) copies
@@ -696,12 +660,11 @@ public Builder setSubjectTokenSupplier(IdentityPoolSubjectTokenSupplier subjectT
696660
}
697661

698662
/**
699-
* Sets the actor token supplier used for certificate-bound OAuth 2.0 token exchanges. The
700-
* supplier provides an actor token representing the entity on whose behalf the subject is
701-
* acting.
663+
* Sets the actor token supplier used for OAuth 2.0 token exchanges. The supplier provides an
664+
* actor token representing the acting party that acts on behalf of the subject.
702665
*
703-
* <p>An actor token supplier must be paired with an {@link #setActorTokenType actor token type}
704-
* and requires an mTLS-configured transport.
666+
* <p>An actor token supplier must be paired with an {@link #setActorTokenType actor token
667+
* type}.
705668
*
706669
* @param actorTokenSupplier the supplier to use for retrieving actor tokens
707670
* @return this {@code Builder} object
@@ -768,6 +731,12 @@ public Builder setTokenUrl(String tokenUrl) {
768731

769732
@CanIgnoreReturnValue
770733
public Builder setCredentialSource(IdentityPoolCredentialSource credentialSource) {
734+
if (this.credentialSource != null && this.credentialSource != credentialSource) {
735+
this.x509Provider = null;
736+
if (this.useMtlsTransportFactory) {
737+
this.transportFactory = null;
738+
}
739+
}
771740
super.setCredentialSource(credentialSource);
772741
this.isClonedTransportInitialized = false;
773742
return this;

‎google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,36 @@ void parseToken_jsonFormat_missingFieldName_throws() {
380380
"Target field name must be specified for JSON credentials.", exception.getMessage());
381381
}
382382

383+
@Test
384+
void parseToken_jsonFormat_emptyOrWhitespaceField_throws(@TempDir Path tempDir)
385+
throws IOException {
386+
Path credentialFile = tempDir.resolve("credential.json");
387+
Files.write(credentialFile, "{\"sub_token\": \" \"}".getBytes(StandardCharsets.UTF_8));
388+
389+
Map<String, Object> credentialSourceMap = new HashMap<>();
390+
credentialSourceMap.put("file", credentialFile.toString());
391+
Map<String, String> formatMap = new HashMap<>();
392+
formatMap.put("type", "json");
393+
formatMap.put("subject_token_field_name", "sub_token");
394+
credentialSourceMap.put("format", formatMap);
395+
IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap);
396+
397+
FileIdentityPoolSubjectTokenSupplier supplier =
398+
new FileIdentityPoolSubjectTokenSupplier(source);
399+
IOException getSubjectException =
400+
assertThrows(IOException.class, () -> supplier.getSubjectToken(null));
401+
assertTrue(
402+
getSubjectException.getMessage().contains("Empty token was found for field: sub_token"));
403+
404+
ByteArrayInputStream stream =
405+
new ByteArrayInputStream("{\"sub_token\": \" \"}".getBytes(StandardCharsets.UTF_8));
406+
IOException parseException =
407+
assertThrows(
408+
IOException.class,
409+
() -> FileIdentityPoolSubjectTokenSupplier.parseToken(stream, source, "sub_token"));
410+
assertTrue(parseException.getMessage().contains("Empty token was found for field: sub_token"));
411+
}
412+
383413
@Test
384414
void readTokens_extractsBothFields(@TempDir Path tempDir) throws IOException {
385415
Path credentialFile = tempDir.resolve("credential.json");
@@ -449,6 +479,56 @@ void readTokens_missingSubjectField_throwsIOException(@TempDir Path tempDir) thr
449479
assertTrue(exception.getMessage().contains("No token was found for field: sub_token"));
450480
}
451481

482+
@Test
483+
void readTokens_emptyOrWhitespaceActorField_throwsIOException(@TempDir Path tempDir)
484+
throws IOException {
485+
Path credentialFile = tempDir.resolve("credential.json");
486+
Files.write(
487+
credentialFile,
488+
"{\"sub_token\": \"my_sub_token\", \"act_token\": \" \"}"
489+
.getBytes(StandardCharsets.UTF_8));
490+
491+
Map<String, Object> credentialSourceMap = new HashMap<>();
492+
credentialSourceMap.put("file", credentialFile.toString());
493+
Map<String, String> formatMap = new HashMap<>();
494+
formatMap.put("type", "json");
495+
formatMap.put("subject_token_field_name", "sub_token");
496+
formatMap.put("actor_token_field_name", "act_token");
497+
credentialSourceMap.put("format", formatMap);
498+
499+
IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap);
500+
FileIdentityPoolSubjectTokenSupplier supplier =
501+
new FileIdentityPoolSubjectTokenSupplier(source);
502+
503+
IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null));
504+
assertTrue(exception.getMessage().contains("Empty token was found for field: act_token"));
505+
}
506+
507+
@Test
508+
void readTokens_emptyOrWhitespaceSubjectField_throwsIOException(@TempDir Path tempDir)
509+
throws IOException {
510+
Path credentialFile = tempDir.resolve("credential.json");
511+
Files.write(
512+
credentialFile,
513+
"{\"sub_token\": \" \", \"act_token\": \"my_act_token\"}"
514+
.getBytes(StandardCharsets.UTF_8));
515+
516+
Map<String, Object> credentialSourceMap = new HashMap<>();
517+
credentialSourceMap.put("file", credentialFile.toString());
518+
Map<String, String> formatMap = new HashMap<>();
519+
formatMap.put("type", "json");
520+
formatMap.put("subject_token_field_name", "sub_token");
521+
formatMap.put("actor_token_field_name", "act_token");
522+
credentialSourceMap.put("format", formatMap);
523+
524+
IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap);
525+
FileIdentityPoolSubjectTokenSupplier supplier =
526+
new FileIdentityPoolSubjectTokenSupplier(source);
527+
528+
IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null));
529+
assertTrue(exception.getMessage().contains("Empty token was found for field: sub_token"));
530+
}
531+
452532
@Test
453533
void readTokens_noActorFieldConfigured_returnsNullActor(@TempDir Path tempDir)
454534
throws IOException {

0 commit comments

Comments
 (0)