Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,12 @@ private static String extractField(GenericJson json, String fieldName) throws IO
+ " must be a String but was: "
+ value.getClass().getName());
}
return (String) value;
String token = (String) value;
if (token.trim().isEmpty()) {
throw new IOException(
"Invalid token field value. Empty token was found for field: " + fieldName);
}
return token;
}

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

Object value = fileContents.get(targetFieldName);
if (value == null || Data.isNull(value)) {
throw new IOException(
"Invalid token field name. No token was found for field: " + targetFieldName);
}
if (!(value instanceof String)) {
throw new IOException(
"Token field value for "
+ targetFieldName
+ " must be a String but was: "
+ 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.

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.').");
}
}

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().


/**
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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() {}
Expand All @@ -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
Expand Down Expand Up @@ -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

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

* 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
Expand Down Expand Up @@ -768,6 +731,12 @@ public Builder setTokenUrl(String tokenUrl) {

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

this.x509Provider = null;
if (this.useMtlsTransportFactory) {
this.transportFactory = null;
}
}
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.

return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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)

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.

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 {
Expand Down
Loading
Loading