Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a4813a2
feat(oauth2): Extract actor tokens for cert-bound OAuth2 STS exchange
macastelaz Jul 30, 2026
c739b37
fix(oauth2): share parsed JSON cache between subject and actor tokens
macastelaz Jul 30, 2026
1a32e6f
feat(oauth2): fail loudly if actor token requested against non-file s…
macastelaz Jul 30, 2026
c42bd53
feat(oauth2): relax actor token mTLS URL validation to .mtls. for PSC…
macastelaz Jul 30, 2026
803dd53
test(oauth2): add tests for strict actor token exceptions
macastelaz Jul 30, 2026
cbcce28
chore(oauth2): update copyright year to 2026 for new files
macastelaz Jul 30, 2026
745d26b
Address architectural review feedback from paste 5381957298028544
macastelaz Jul 30, 2026
48c29d2
Fix trailing whitespace and formatting in IdentityPoolCredentialsTest
macastelaz Jul 30, 2026
178d71d
test: use real MtlsHttpTransportFactory instead of mock to fix Java 8…
macastelaz Jul 31, 2026
c22c521
Fix line width formatting in ExternalAccountCredentialsTest
macastelaz Jul 31, 2026
1aa5036
fix(oauth2): Address review findings from paste 5644036370202624
macastelaz Aug 6, 2026
474f0d7
fix(oauth2): preserve shared FileIdentityPoolTokenSupplier cache in B…
macastelaz Aug 6, 2026
62b8bd5
test(oauth2): expand unit test coverage across IdentityPoolCredential…
macastelaz Aug 7, 2026
3288350
Address PR #13955 review comments
macastelaz Aug 21, 2026
8dbaa6f
Review session improvements
macastelaz Aug 21, 2026
48ee795
chore: fix google-java-format compliance
macastelaz Aug 22, 2026
728d654
Address review comments for cert-bound OAuth Part 2
macastelaz Aug 24, 2026
483cfe0
test(oauth2): rename refreshAccessToken_useSameCertForStsAndIam to re…
macastelaz Aug 25, 2026
d9b26e4
fix(oauth2): address review comments on PR #13955
macastelaz Aug 28, 2026
2b8a0e7
fix(oauth2): rely on transport mTLS validation rather than URL string…
macastelaz Aug 28, 2026
e6a4797
fix(oauth2): validate plain public endpoints when actor tokens are co…
macastelaz Aug 29, 2026
3f95ff5
fix(oauth2): implement Serializable in MtlsHttpTransportFactory
macastelaz Sep 1, 2026
f3dd3bf
fix(oauth2): address PR #13955 review feedback on mTLS and token supp…
macastelaz Sep 15, 2026
f853f76
fix(oauth2): clarify MtlsHttpTransportFactory serialization Javadoc a…
macastelaz Sep 15, 2026
8ab1856
Merge remote-tracking branch 'origin/main' into cert-bound-oauth-part2
macastelaz Sep 16, 2026
bd0dd45
fix(oauth2): address review feedback on MtlsHttpTransportFactory Java…
macastelaz Sep 16, 2026
33bad8a
fix(oauth2): honor custom HttpTransportFactory in refreshAccessToken,…
macastelaz Sep 16, 2026
1052410
fix(oauth2): refresh MtlsHttpTransportFactory snapshot on createScope…
macastelaz Sep 17, 2026
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 @@ -34,10 +34,15 @@
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.core.InternalApi;
import com.google.auth.http.HttpTransportFactory;
import java.io.Serializable;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.cert.Certificate;
import java.util.Enumeration;
import java.util.Objects;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

/**
* An HttpTransportFactory that creates {@link NetHttpTransport} instances configured for mTLS
Expand All @@ -49,8 +54,25 @@
*/
@NullMarked
@InternalApi
public class MtlsHttpTransportFactory implements HttpTransportFactory {
private final KeyStore mtlsKeyStore;
public class MtlsHttpTransportFactory implements HttpTransportFactory, Serializable {
private static final long serialVersionUID = 1L;
private final transient @Nullable KeyStore mtlsKeyStore;
private final transient boolean hasKeyStore;

/**
* Default no-arg constructor creating an instance without a KeyStore ({@code hasKeyStore() ==
* false}). This constructor is invoked reflectively by {@code
* ExternalAccountCredentials.readObject()} during deserialization (since {@code transportFactory}
* is transient on {@code ExternalAccountCredentials}), after which {@code
* IdentityPoolCredentials.readObject()} reconstructs {@code X509Provider} from the serialized
* certificate configuration and replaces the transport factory with {@link
* #MtlsHttpTransportFactory(KeyStore)}. Not intended for direct use; callers configuring mTLS
* should use {@link #MtlsHttpTransportFactory(KeyStore)}.
*/
public MtlsHttpTransportFactory() {
this.mtlsKeyStore = null;
this.hasKeyStore = false;
}

/**
* Constructs a factory for mTLS transports.
Expand All @@ -61,6 +83,41 @@
*/
public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) {
this.mtlsKeyStore = Objects.requireNonNull(mtlsKeyStore, "mtlsKeyStore cannot be null");
this.hasKeyStore = checkHasKeyStore(this.mtlsKeyStore);
}

/**
* Returns whether this factory was constructed with a non-null {@link KeyStore} containing client
* certificates for mTLS. A factory created via the no-arg constructor (e.g. during
* deserialization), with an empty KeyStore, or with a KeyStore containing only trusted CA
* certificates (without a private key entry and certificate chain) will return {@code false}.
*/
public boolean hasKeyStore() {

Check warning on line 95 in google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java

View workflow job for this annotation

GitHub Actions / bom-content-test

Check warning on line 95 in google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java

View workflow job for this annotation

GitHub Actions / BomContentAssertionsTest (Test for assertion logic in BomContentTest)

return this.hasKeyStore;
}

private static boolean checkHasKeyStore(@Nullable KeyStore keyStore) {
if (keyStore == null) {
return false;
}
try {
Enumeration<String> aliases = keyStore.aliases();
if (aliases == null) {
return false;
}
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (keyStore.isKeyEntry(alias)) {
Certificate[] chain = keyStore.getCertificateChain(alias);
if (chain != null && chain.length > 0) {
return true;
}
}
}
return false;
} catch (KeyStoreException e) {
return false;
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@
* external source for authentication to Google Cloud Platform, you must validate it before
* providing it to any Google API or library. Providing an unvalidated credential configuration to
* Google APIs can compromise the security of your systems and data. For more information, refer
* to {@see <a

Check failure on line 366 in google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java

View workflow job for this annotation

GitHub Actions / bom-content-test

no tag name after @

Check failure on line 366 in google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java

View workflow job for this annotation

GitHub Actions / BomContentAssertionsTest (Test for assertion logic in BomContentTest)

no tag name after @
* href="https://cloud.google.com/docs/authentication/external/externally-sourced-credentials">documentation</a>}.
*
* @param credentialsStream the stream with the credential definition
Expand All @@ -384,7 +384,7 @@
* external source for authentication to Google Cloud Platform, you must validate it before
* providing it to any Google API or library. Providing an unvalidated credential configuration to
* Google APIs can compromise the security of your systems and data. For more information, refer
* to {@see <a

Check failure on line 387 in google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java

View workflow job for this annotation

GitHub Actions / bom-content-test

no tag name after @

Check failure on line 387 in google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java

View workflow job for this annotation

GitHub Actions / BomContentAssertionsTest (Test for assertion logic in BomContentTest)

no tag name after @
* href="https://cloud.google.com/docs/authentication/external/externally-sourced-credentials">documentation</a>}.
*
* @param credentialsStream the stream with the credential definition
Expand Down Expand Up @@ -432,6 +432,7 @@
Map<String, Object> json, HttpTransportFactory transportFactory) {
String audience = (String) json.get("audience");
String subjectTokenType = (String) json.get("subject_token_type");
String actorTokenType = (String) json.get("actor_token_type");
String tokenUrl = (String) json.get("token_url");

Map<String, Object> credentialSourceMap = (Map<String, Object>) json.get("credential_source");
Expand Down Expand Up @@ -488,6 +489,7 @@
.setHttpTransportFactory(transportFactory)
.setAudience(audience)
.setSubjectTokenType(subjectTokenType)
.setActorTokenType(actorTokenType)
.setTokenUrl(tokenUrl)
.setTokenInfoUrl(tokenInfoUrl)
.setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap))
Expand Down Expand Up @@ -533,6 +535,22 @@
*/
protected AccessToken exchangeExternalCredentialForAccessToken(
StsTokenExchangeRequest stsTokenExchangeRequest) throws IOException {
return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest, this.transportFactory);
}

/**
* Exchanges the external credential for a Google Cloud access token using the specified transport
* factory. This overload allows callers to provide a per-cycle transport factory, for example one
* pinned to a specific mTLS certificate.
*
* @param stsTokenExchangeRequest the Security Token Service token exchange request
* @param cycleTransportFactory the HTTP transport factory to use for this exchange
* @return the access token returned by the Security Token Service
* @throws OAuthException if the call to the Security Token Service fails
*/
protected AccessToken exchangeExternalCredentialForAccessToken(
Comment thread
macastelaz marked this conversation as resolved.
StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory)
throws IOException {
// Handle service account impersonation if necessary.
if (this.shouldBuildImpersonatedCredential()) {
this.impersonatedCredentials = this.buildImpersonatedCredentials();
Expand All @@ -543,7 +561,9 @@

StsRequestHandler.Builder requestHandler =
StsRequestHandler.newBuilder(
tokenUrl, stsTokenExchangeRequest, transportFactory.create().createRequestFactory());
tokenUrl,
stsTokenExchangeRequest,
cycleTransportFactory.create().createRequestFactory());

// If this credential was initialized with a Workforce configuration then the
// workforcePoolUserProject must be passed to the Security Token Service via the internal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,73 +31,194 @@

package com.google.auth.oauth2;

import static com.google.common.base.Preconditions.checkNotNull;

import com.google.api.client.json.GenericJson;
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.util.Data;
import com.google.auth.oauth2.IdentityPoolCredentialSource.CredentialFormatType;
import com.google.common.io.CharStreams;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Paths;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

/**
* Internal provider for retrieving the subject tokens for {@link IdentityPoolCredentials} to
* exchange for GCP access tokens via a local file.
* Internal provider for retrieving the subject and actor tokens for {@link IdentityPoolCredentials}
* to exchange for GCP access tokens via a local file.
*
* <p>Note: Despite the name, this class handles both subject <em>and</em> actor tokens. The class
* name retains "Subject" for serialization backward compatibility; renaming it would break
* deserialization of previously serialized credentials.
*/
@NullMarked
class FileIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSupplier {
class FileIdentityPoolSubjectTokenSupplier
implements IdentityPoolSubjectTokenSupplier, IdentityPoolActorTokenSupplier {

private final long serialVersionUID = 2475549052347431992L;
private static final long serialVersionUID = 7152208690659890358L;

private final IdentityPoolCredentialSource credentialSource;

/**
* Constructor for FileIdentitySubjectTokenProvider
*
* @param credentialSource the credential source to use.
*/
FileIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) {
this.credentialSource = credentialSource;
this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null");
}

@Override
public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException {
String credentialFilePath = this.credentialSource.getCredentialLocation();
return getToken(credentialSource.subjectTokenFieldName);
}

@Override
public String getActorToken(ExternalAccountSupplierContext context) throws IOException {
if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) {
throw new IllegalArgumentException(
"Actor tokens are only supported for JSON-formatted credential files with distinct field"
+ " names.");
}
return getToken(credentialSource.actorTokenFieldName);
}

/**
* Reads the credential file once and returns both the subject and actor tokens atomically.
*
* <p>This method ensures that both tokens are extracted from the same file read, avoiding
* potential race conditions when the file is being updated between reads.
*
* @param context the supplier context
* @return a {@link TokenPair} containing both the subject and actor tokens
* @throws IOException if the file cannot be read or the required fields are missing
*/
TokenPair readTokens(ExternalAccountSupplierContext context) throws IOException {
if (credentialSource.credentialFormatType != CredentialFormatType.JSON) {
throw new IOException(
"readTokens() is only supported for JSON-formatted credential sources.");
}
Comment thread
macastelaz marked this conversation as resolved.

String subjectFieldName = credentialSource.subjectTokenFieldName;
if (subjectFieldName == null) {
throw new IOException("Subject token field name must be specified for JSON credentials.");
}

String credentialFilePath = credentialSource.getCredentialLocation();
if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException(
String.format(
"Invalid credential location. The file at %s does not exist.", credentialFilePath));
}
try {
return parseToken(
Files.newInputStream(new File(credentialFilePath).toPath()), this.credentialSource);
} catch (IOException e) {
throw new IOException(
"Error when attempting to read the subject token from the credential file.", e);

GenericJson parsedJson = readAndParseJsonFile(credentialFilePath);
String subject = extractField(parsedJson, subjectFieldName);

String actor = null;
if (credentialSource.actorTokenFieldName != null) {
actor = extractField(parsedJson, credentialSource.actorTokenFieldName);
}

return new TokenPair(subject, actor);
}

static String parseToken(InputStream inputStream, IdentityPoolCredentialSource credentialSource)
throws IOException {
if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) {
private String getToken(@Nullable String targetFieldName) throws IOException {
if (credentialSource.credentialFormatType == CredentialFormatType.JSON
&& targetFieldName == null) {
throw new IOException("Target field name must be specified for JSON credentials.");
}

String credentialFilePath = credentialSource.getCredentialLocation();
if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException(
String.format(
"Invalid credential location. The file at %s does not exist.", credentialFilePath));
}

if (credentialSource.credentialFormatType == CredentialFormatType.JSON) {
GenericJson parsedJson = readAndParseJsonFile(credentialFilePath);
return extractField(parsedJson, targetFieldName);
}

try (InputStream inputStream = Files.newInputStream(Paths.get(credentialFilePath))) {
BufferedReader reader =
new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
return CharStreams.toString(reader);
} catch (IOException e) {
throw new IOException("Error when attempting to read the token from the credential file.", e);
}
}
Comment thread
macastelaz marked this conversation as resolved.

private static GenericJson readAndParseJsonFile(String credentialFilePath) throws IOException {
try (InputStream inputStream = Files.newInputStream(Paths.get(credentialFilePath))) {
JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
return parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class);
} catch (Exception e) {
throw new IOException("Error when attempting to read the token from the credential file.", e);
}
}

private static String extractField(GenericJson json, String fieldName) throws IOException {
Object value = json.get(fieldName);
if (value == null || Data.isNull(value)) {
throw new IOException("Invalid token field name. No token was found for field: " + fieldName);
}
if (!(value instanceof String)) {
throw new IOException(
"Token field value for "
+ fieldName
+ " must be a String but was: "
+ value.getClass().getName());
}
return (String) value;
}

/** Used primarily for UrlIdentityPoolSubjectTokenSupplier */
static String parseToken(
InputStream inputStream,
IdentityPoolCredentialSource credentialSource,
@Nullable String targetFieldName)
throws IOException {
try (InputStream in = inputStream;
Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) {
return CharStreams.toString(new BufferedReader(reader));
}

if (targetFieldName == null) {
throw new IOException("Target field name must be specified for JSON credentials.");
}

JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
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;
}
}

JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
GenericJson fileContents =
parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class);
/** Holds a pair of subject and actor tokens read atomically from the same file. */
static class TokenPair {
final String subject;
final @Nullable String actor;

if (!fileContents.containsKey(credentialSource.subjectTokenFieldName)) {
throw new IOException("Invalid subject token field name. No subject token was found.");
TokenPair(String subject, @Nullable String actor) {
this.subject = subject;
this.actor = actor;
}
return (String) fileContents.get(credentialSource.subjectTokenFieldName);
}
}
Loading
Loading