Skip to content

Commit 1fed6d4

Browse files
authored
feat(oauth2): Extract actor tokens for cert-bound OAuth2 STS exchange (#13955)
## 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 - Verified end-to-end execution against live Google STS endpoints, token exchange with actor tokens over mTLS, scoping, serialization round-trip with `readObject()` restoration, and multi-cycle refreshes. - Sample Test App: https://paste.googleplex.com/6209850244726784 - Sanitized Verification Output: https://paste.googleplex.com/6325994750345216 ## 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
1 parent 62cd972 commit 1fed6d4

16 files changed

Lines changed: 3376 additions & 49 deletions

‎google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsHttpTransportFactory.java‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,15 @@
3434
import com.google.api.client.http.javanet.NetHttpTransport;
3535
import com.google.api.core.InternalApi;
3636
import com.google.auth.http.HttpTransportFactory;
37+
import java.io.Serializable;
3738
import java.security.GeneralSecurityException;
3839
import java.security.KeyStore;
40+
import java.security.KeyStoreException;
41+
import java.security.cert.Certificate;
42+
import java.util.Enumeration;
3943
import java.util.Objects;
4044
import org.jspecify.annotations.NullMarked;
45+
import org.jspecify.annotations.Nullable;
4146

4247
/**
4348
* An HttpTransportFactory that creates {@link NetHttpTransport} instances configured for mTLS
@@ -49,8 +54,25 @@
4954
*/
5055
@NullMarked
5156
@InternalApi
52-
public class MtlsHttpTransportFactory implements HttpTransportFactory {
53-
private final KeyStore mtlsKeyStore;
57+
public class MtlsHttpTransportFactory implements HttpTransportFactory, Serializable {
58+
private static final long serialVersionUID = 1L;
59+
private final transient @Nullable KeyStore mtlsKeyStore;
60+
private final transient boolean hasKeyStore;
61+
62+
/**
63+
* Default no-arg constructor creating an instance without a KeyStore ({@code hasKeyStore() ==
64+
* false}). This constructor is invoked reflectively by {@code
65+
* ExternalAccountCredentials.readObject()} during deserialization (since {@code transportFactory}
66+
* is transient on {@code ExternalAccountCredentials}), after which {@code
67+
* IdentityPoolCredentials.readObject()} reconstructs {@code X509Provider} from the serialized
68+
* certificate configuration and replaces the transport factory with {@link
69+
* #MtlsHttpTransportFactory(KeyStore)}. Not intended for direct use; callers configuring mTLS
70+
* should use {@link #MtlsHttpTransportFactory(KeyStore)}.
71+
*/
72+
public MtlsHttpTransportFactory() {
73+
this.mtlsKeyStore = null;
74+
this.hasKeyStore = false;
75+
}
5476

5577
/**
5678
* Constructs a factory for mTLS transports.
@@ -61,6 +83,41 @@ public class MtlsHttpTransportFactory implements HttpTransportFactory {
6183
*/
6284
public MtlsHttpTransportFactory(KeyStore mtlsKeyStore) {
6385
this.mtlsKeyStore = Objects.requireNonNull(mtlsKeyStore, "mtlsKeyStore cannot be null");
86+
this.hasKeyStore = checkHasKeyStore(this.mtlsKeyStore);
87+
}
88+
89+
/**
90+
* Returns whether this factory was constructed with a non-null {@link KeyStore} containing client
91+
* certificates for mTLS. A factory created via the no-arg constructor (e.g. during
92+
* deserialization), with an empty KeyStore, or with a KeyStore containing only trusted CA
93+
* certificates (without a private key entry and certificate chain) will return {@code false}.
94+
*/
95+
public boolean hasKeyStore() {
96+
return this.hasKeyStore;
97+
}
98+
99+
private static boolean checkHasKeyStore(@Nullable KeyStore keyStore) {
100+
if (keyStore == null) {
101+
return false;
102+
}
103+
try {
104+
Enumeration<String> aliases = keyStore.aliases();
105+
if (aliases == null) {
106+
return false;
107+
}
108+
while (aliases.hasMoreElements()) {
109+
String alias = aliases.nextElement();
110+
if (keyStore.isKeyEntry(alias)) {
111+
Certificate[] chain = keyStore.getCertificateChain(alias);
112+
if (chain != null && chain.length > 0) {
113+
return true;
114+
}
115+
}
116+
}
117+
return false;
118+
} catch (KeyStoreException e) {
119+
return false;
120+
}
64121
}
65122

66123
@Override

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ static ExternalAccountCredentials fromJson(
432432
Map<String, Object> json, HttpTransportFactory transportFactory) {
433433
String audience = (String) json.get("audience");
434434
String subjectTokenType = (String) json.get("subject_token_type");
435+
String actorTokenType = (String) json.get("actor_token_type");
435436
String tokenUrl = (String) json.get("token_url");
436437

437438
Map<String, Object> credentialSourceMap = (Map<String, Object>) json.get("credential_source");
@@ -488,6 +489,7 @@ static ExternalAccountCredentials fromJson(
488489
.setHttpTransportFactory(transportFactory)
489490
.setAudience(audience)
490491
.setSubjectTokenType(subjectTokenType)
492+
.setActorTokenType(actorTokenType)
491493
.setTokenUrl(tokenUrl)
492494
.setTokenInfoUrl(tokenInfoUrl)
493495
.setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap))
@@ -533,6 +535,22 @@ private boolean shouldBuildImpersonatedCredential() {
533535
*/
534536
protected AccessToken exchangeExternalCredentialForAccessToken(
535537
StsTokenExchangeRequest stsTokenExchangeRequest) throws IOException {
538+
return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest, this.transportFactory);
539+
}
540+
541+
/**
542+
* Exchanges the external credential for a Google Cloud access token using the specified transport
543+
* factory. This overload allows callers to provide a per-cycle transport factory, for example one
544+
* pinned to a specific mTLS certificate.
545+
*
546+
* @param stsTokenExchangeRequest the Security Token Service token exchange request
547+
* @param cycleTransportFactory the HTTP transport factory to use for this exchange
548+
* @return the access token returned by the Security Token Service
549+
* @throws OAuthException if the call to the Security Token Service fails
550+
*/
551+
protected AccessToken exchangeExternalCredentialForAccessToken(
552+
StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory)
553+
throws IOException {
536554
// Handle service account impersonation if necessary.
537555
if (this.shouldBuildImpersonatedCredential()) {
538556
this.impersonatedCredentials = this.buildImpersonatedCredentials();
@@ -543,7 +561,9 @@ protected AccessToken exchangeExternalCredentialForAccessToken(
543561

544562
StsRequestHandler.Builder requestHandler =
545563
StsRequestHandler.newBuilder(
546-
tokenUrl, stsTokenExchangeRequest, transportFactory.create().createRequestFactory());
564+
tokenUrl,
565+
stsTokenExchangeRequest,
566+
cycleTransportFactory.create().createRequestFactory());
547567

548568
// If this credential was initialized with a Workforce configuration then the
549569
// workforcePoolUserProject must be passed to the Security Token Service via the internal

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

Lines changed: 148 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -31,73 +31,194 @@
3131

3232
package com.google.auth.oauth2;
3333

34+
import static com.google.common.base.Preconditions.checkNotNull;
35+
3436
import com.google.api.client.json.GenericJson;
3537
import com.google.api.client.json.JsonObjectParser;
38+
import com.google.api.client.util.Data;
3639
import com.google.auth.oauth2.IdentityPoolCredentialSource.CredentialFormatType;
3740
import com.google.common.io.CharStreams;
3841
import java.io.BufferedReader;
39-
import java.io.File;
4042
import java.io.IOException;
4143
import java.io.InputStream;
4244
import java.io.InputStreamReader;
45+
import java.io.Reader;
4346
import java.nio.charset.StandardCharsets;
4447
import java.nio.file.Files;
4548
import java.nio.file.LinkOption;
4649
import java.nio.file.Paths;
4750
import org.jspecify.annotations.NullMarked;
51+
import org.jspecify.annotations.Nullable;
4852

4953
/**
50-
* Internal provider for retrieving the subject tokens for {@link IdentityPoolCredentials} to
51-
* exchange for GCP access tokens via a local file.
54+
* Internal provider for retrieving the subject and actor tokens for {@link IdentityPoolCredentials}
55+
* to exchange for GCP access tokens via a local file.
56+
*
57+
* <p>Note: Despite the name, this class handles both subject <em>and</em> actor tokens. The class
58+
* name retains "Subject" for serialization backward compatibility; renaming it would break
59+
* deserialization of previously serialized credentials.
5260
*/
5361
@NullMarked
54-
class FileIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSupplier {
62+
class FileIdentityPoolSubjectTokenSupplier
63+
implements IdentityPoolSubjectTokenSupplier, IdentityPoolActorTokenSupplier {
5564

56-
private final long serialVersionUID = 2475549052347431992L;
65+
private static final long serialVersionUID = 7152208690659890358L;
5766

5867
private final IdentityPoolCredentialSource credentialSource;
5968

60-
/**
61-
* Constructor for FileIdentitySubjectTokenProvider
62-
*
63-
* @param credentialSource the credential source to use.
64-
*/
6569
FileIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) {
66-
this.credentialSource = credentialSource;
70+
this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null");
6771
}
6872

6973
@Override
7074
public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException {
71-
String credentialFilePath = this.credentialSource.getCredentialLocation();
75+
return getToken(credentialSource.subjectTokenFieldName);
76+
}
77+
78+
@Override
79+
public String getActorToken(ExternalAccountSupplierContext context) throws IOException {
80+
if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) {
81+
throw new IllegalArgumentException(
82+
"Actor tokens are only supported for JSON-formatted credential files with distinct field"
83+
+ " names.");
84+
}
85+
return getToken(credentialSource.actorTokenFieldName);
86+
}
87+
88+
/**
89+
* Reads the credential file once and returns both the subject and actor tokens atomically.
90+
*
91+
* <p>This method ensures that both tokens are extracted from the same file read, avoiding
92+
* potential race conditions when the file is being updated between reads.
93+
*
94+
* @param context the supplier context
95+
* @return a {@link TokenPair} containing both the subject and actor tokens
96+
* @throws IOException if the file cannot be read or the required fields are missing
97+
*/
98+
TokenPair readTokens(ExternalAccountSupplierContext context) throws IOException {
99+
if (credentialSource.credentialFormatType != CredentialFormatType.JSON) {
100+
throw new IOException(
101+
"readTokens() is only supported for JSON-formatted credential sources.");
102+
}
103+
104+
String subjectFieldName = credentialSource.subjectTokenFieldName;
105+
if (subjectFieldName == null) {
106+
throw new IOException("Subject token field name must be specified for JSON credentials.");
107+
}
108+
109+
String credentialFilePath = credentialSource.getCredentialLocation();
72110
if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) {
73111
throw new IOException(
74112
String.format(
75113
"Invalid credential location. The file at %s does not exist.", credentialFilePath));
76114
}
77-
try {
78-
return parseToken(
79-
Files.newInputStream(new File(credentialFilePath).toPath()), this.credentialSource);
80-
} catch (IOException e) {
81-
throw new IOException(
82-
"Error when attempting to read the subject token from the credential file.", e);
115+
116+
GenericJson parsedJson = readAndParseJsonFile(credentialFilePath);
117+
String subject = extractField(parsedJson, subjectFieldName);
118+
119+
String actor = null;
120+
if (credentialSource.actorTokenFieldName != null) {
121+
actor = extractField(parsedJson, credentialSource.actorTokenFieldName);
83122
}
123+
124+
return new TokenPair(subject, actor);
84125
}
85126

86-
static String parseToken(InputStream inputStream, IdentityPoolCredentialSource credentialSource)
87-
throws IOException {
88-
if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) {
127+
private String getToken(@Nullable String targetFieldName) throws IOException {
128+
if (credentialSource.credentialFormatType == CredentialFormatType.JSON
129+
&& targetFieldName == null) {
130+
throw new IOException("Target field name must be specified for JSON credentials.");
131+
}
132+
133+
String credentialFilePath = credentialSource.getCredentialLocation();
134+
if (!Files.exists(Paths.get(credentialFilePath), LinkOption.NOFOLLOW_LINKS)) {
135+
throw new IOException(
136+
String.format(
137+
"Invalid credential location. The file at %s does not exist.", credentialFilePath));
138+
}
139+
140+
if (credentialSource.credentialFormatType == CredentialFormatType.JSON) {
141+
GenericJson parsedJson = readAndParseJsonFile(credentialFilePath);
142+
return extractField(parsedJson, targetFieldName);
143+
}
144+
145+
try (InputStream inputStream = Files.newInputStream(Paths.get(credentialFilePath))) {
89146
BufferedReader reader =
90147
new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
91148
return CharStreams.toString(reader);
149+
} catch (IOException e) {
150+
throw new IOException("Error when attempting to read the token from the credential file.", e);
151+
}
152+
}
153+
154+
private static GenericJson readAndParseJsonFile(String credentialFilePath) throws IOException {
155+
try (InputStream inputStream = Files.newInputStream(Paths.get(credentialFilePath))) {
156+
JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
157+
return parser.parseAndClose(inputStream, StandardCharsets.UTF_8, GenericJson.class);
158+
} catch (Exception e) {
159+
throw new IOException("Error when attempting to read the token from the credential file.", e);
92160
}
161+
}
162+
163+
private static String extractField(GenericJson json, String fieldName) throws IOException {
164+
Object value = json.get(fieldName);
165+
if (value == null || Data.isNull(value)) {
166+
throw new IOException("Invalid token field name. No token was found for field: " + fieldName);
167+
}
168+
if (!(value instanceof String)) {
169+
throw new IOException(
170+
"Token field value for "
171+
+ fieldName
172+
+ " must be a String but was: "
173+
+ value.getClass().getName());
174+
}
175+
return (String) value;
176+
}
177+
178+
/** Used primarily for UrlIdentityPoolSubjectTokenSupplier */
179+
static String parseToken(
180+
InputStream inputStream,
181+
IdentityPoolCredentialSource credentialSource,
182+
@Nullable String targetFieldName)
183+
throws IOException {
184+
try (InputStream in = inputStream;
185+
Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
186+
if (credentialSource.credentialFormatType == CredentialFormatType.TEXT) {
187+
return CharStreams.toString(new BufferedReader(reader));
188+
}
189+
190+
if (targetFieldName == null) {
191+
throw new IOException("Target field name must be specified for JSON credentials.");
192+
}
193+
194+
JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
195+
GenericJson fileContents =
196+
parser.parseAndClose(in, StandardCharsets.UTF_8, GenericJson.class);
197+
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;
211+
}
212+
}
93213

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

98-
if (!fileContents.containsKey(credentialSource.subjectTokenFieldName)) {
99-
throw new IOException("Invalid subject token field name. No subject token was found.");
219+
TokenPair(String subject, @Nullable String actor) {
220+
this.subject = subject;
221+
this.actor = actor;
100222
}
101-
return (String) fileContents.get(credentialSource.subjectTokenFieldName);
102223
}
103224
}

0 commit comments

Comments
 (0)