Skip to content

Commit dcb29b0

Browse files
macastelazjinseopkim0blakeli0sakthivelmaniigemini-code-assist[bot]
authored
feat(oauth2): support certificate-bound OAuth2 tokens and mTLS rotation for Workload Identity Federation (#14781)
### Summary Merges the `oauth2-bound-tokens` feature branch into `main`, adding end-to-end support for certificate-bound OAuth 2.0 tokens and automatic mTLS certificate rotation in Workload Identity Federation (`IdentityPoolCredentials` and `ImpersonatedCredentials`). #### Included Feature Branch PRs - #13955 — `feat(oauth2): Extract actor tokens for cert-bound OAuth2 STS exchange` - #14212 — `feat(oauth2): implement IAM impersonation mTLS transport pinning and 401 recovery` - #14220 — `test(oauth2): add mTLS in-process socket tests and live GCP WIF integration tests` - #14430 — `feat(oauth2): support non-mTLS token URLs and unbound actor tokens in IdentityPoolCredentials` - #14554 — `test(auth): enable mTLS and actor_token WIF integration tests by default` #### Key Capabilities - **Actor Token Support for Hybrid Certificate-Bound WIF (`IdentityPoolActorTokenSupplier`, `FileIdentityPoolSubjectTokenSupplier`)**: - Supports extracting both a primary X.509 leaf certificate subject token and a secondary file-sourced `actor_token` (text or JSON via `actor_token_field_name` and `actor_token_type`) for STS token exchange. - Caches file-sourced tokens using file metadata (`mtime`, size, file key) and JWT `exp` claims with a 5-minute expiration margin to avoid redundant disk reads. - **Per-Cycle mTLS Transport Pinning & Certificate Rotation (`IdentityPoolCredentials`, `ImpersonatedCredentials`, `MtlsHttpTransportFactory`)**: - Snapshots the `KeyStore` once per refresh cycle so leaf certificate extraction, the STS token exchange, and downstream IAM Service Account impersonation (`generateAccessToken` / `generateIdToken`) share the exact same certificate and private key snapshot. - Automatically recovers from `401 Unauthorized`, OAuth `invalid_grant`, and transient TLS/KeyStore load failures during non-atomic split cert/key writes by reloading the `KeyStore` once and retrying when a certificate change is detected (`OAuth2Utils.hasCertificateChanged`). - Supports standalone `ImpersonatedCredentials` configured with `MtlsProvider` as well as delegated `ImpersonatedCredentials` chains. - **Test Coverage**: - Comprehensive unit tests across `IdentityPoolCredentialsTest`, `ImpersonatedCredentialsTest`, `FileIdentityPoolSubjectTokenSupplierTest`, `MtlsHttpTransportFactoryTest`, `OAuth2UtilsTest`, and `OAuthExceptionTest`. - In-process mTLS socket tests (`MtlsPipelineLocalTest`) and live GCP Workload Identity Federation integration tests (`ITWorkloadIdentityFederationTest`). ### Manual & Live Verification End-to-end live GCP and real-TLS socket fault-injection testing was executed against `oauth2-bound-tokens` (`65d592bd783`): - **Full verification report, logs, and test harness**: https://paste.googleplex.com/6755399436525184 - **Scenarios verified (15 / 15 passed)**: 1. **Live Direct mTLS STS Exchange (`sts.mtls.googleapis.com`)**: File-based JSON `subject_token` + `actor_token` with RFC 8705 `cnf.x5t#S256` certificate binding and atomic single-supplier file reads. 2. **Serialization Round-Trip (`readObject`) + `createScoped()`**: Deserialization of `IdentityPoolCredentials` with mTLS state reconstruction across multiple live STS refresh cycles. 3. **Full 3-Hop Live mTLS Chain (`STS mTLS` -> `IAM Impersonation mTLS` -> `GCS mTLS`)**: Cert-bound workload + actor token exchange at `sts.mtls.googleapis.com`, service account impersonation at `iamcredentials.mtls.googleapis.com`, and bucket access at `storage.mtls.googleapis.com` (`HTTP 200`). 4. **Programmatic Builder (`Suppliers` + `MtlsHttpTransportFactory`) Live 3-Hop mTLS**: End-to-end `STS mTLS` -> `IAM mTLS` -> `GCS mTLS` (`HTTP 200`) and verification of `OAuth2Utils` mTLS helpers (`hasMtlsKeyStore`, `isKeylessMtlsTransportFactory`, `isDefaultOrMtlsTransportFactory`). 5. **Non-mTLS Unbound Actor Token over Public Endpoints**: Unbound `subject_token` + `actor_token` exchange over public `sts.googleapis.com` -> `iamcredentials.googleapis.com` -> `storage.googleapis.com` (`HTTP 200`). 6. **X.509 Certificate Workload Identity Federation**: Leaf certificate extraction and `trust_chain` handshake against `sts.mtls.googleapis.com` via `CertificateIdentityPoolSubjectTokenSupplier`. 7. **`PluggableAuthCredentials` Impersonation Transport Reconciliation**: Rebuilding `PluggableAuthCredentials` with a custom `HttpTransportFactory` and verifying both STS and `ImpersonatedCredentials` use the reconciled transport against live STS, IAM, and GCS (`HTTP 200`). 8. **Review Feedback Edge Cases**: `FileIdentityPoolSubjectTokenSupplier` TEXT format subject/actor token handling and `OAuthException.createFromJsonErrorResponse` parsing across live STS 400 errors, nested `google.rpc.Status` JSON, and empty-body HTTP error fallbacks. 9. **Real-TLS Socket Fault Injection**: Mid-cycle cert rotation pinning between STS and IAM, `401 Unauthorized` IAM recovery (both fresh and cached STS token eviction), `400 invalid_grant` STS cert rotation recovery, and 8-thread concurrent mTLS refresh. --------- Co-authored-by: Jin Seop Kim <jinseop@google.com> Co-authored-by: Blake Li <blakeli@google.com> Co-authored-by: Sakthivel Subramanian <179120858+sakthivelmanii@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 0bae268 commit dcb29b0

34 files changed

Lines changed: 9622 additions & 163 deletions

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

Lines changed: 64 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,46 @@ 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+
/** Returns the {@link KeyStore} used by this factory, or {@code null} if none was configured. */
100+
public @Nullable KeyStore getKeyStore() {
101+
return this.mtlsKeyStore;
102+
}
103+
104+
private static boolean checkHasKeyStore(@Nullable KeyStore keyStore) {
105+
if (keyStore == null) {
106+
return false;
107+
}
108+
try {
109+
Enumeration<String> aliases = keyStore.aliases();
110+
if (aliases == null) {
111+
return false;
112+
}
113+
while (aliases.hasMoreElements()) {
114+
String alias = aliases.nextElement();
115+
if (keyStore.isKeyEntry(alias)) {
116+
Certificate[] chain = keyStore.getCertificateChain(alias);
117+
if (chain != null && chain.length > 0) {
118+
return true;
119+
}
120+
}
121+
}
122+
return false;
123+
} catch (KeyStoreException e) {
124+
return false;
125+
}
64126
}
65127

66128
@Override

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,16 @@ public class AwsCredentials extends ExternalAccountCredentials {
120120

121121
@Override
122122
public AccessToken refreshAccessToken() throws IOException {
123+
// Delegate to ImpersonatedCredentials before calling retrieveSubjectToken().
124+
// ImpersonatedCredentials refreshes its own non-impersonated sourceCredentials copy (and
125+
// caches the intermediate STS token via refreshIfExpired()), so calling retrieveSubjectToken()
126+
// here would redundantly execute the subject token retrieval only for
127+
// exchangeExternalCredentialForAccessToken() to discard the built StsTokenExchangeRequest.
128+
ImpersonatedCredentials impersonated = getImpersonatedCredentials();
129+
if (impersonated != null) {
130+
return impersonated.refreshAccessToken();
131+
}
132+
123133
StsTokenExchangeRequest.Builder stsTokenExchangeRequest =
124134
StsTokenExchangeRequest.newBuilder(retrieveSubjectToken(), getSubjectTokenType())
125135
.setAudience(getAudience());

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

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,20 @@
4242
import java.nio.file.Files;
4343
import java.nio.file.NoSuchFileException;
4444
import java.nio.file.Paths;
45+
import java.security.KeyStore;
46+
import java.security.KeyStoreException;
4547
import java.security.cert.Certificate;
4648
import java.security.cert.CertificateEncodingException;
4749
import java.security.cert.CertificateException;
4850
import java.security.cert.CertificateFactory;
4951
import java.security.cert.X509Certificate;
5052
import java.util.ArrayList;
5153
import java.util.Base64;
54+
import java.util.Enumeration;
5255
import java.util.List;
5356
import java.util.regex.Matcher;
5457
import org.jspecify.annotations.NullMarked;
58+
import org.jspecify.annotations.Nullable;
5559

5660
/**
5761
* Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} by reading an
@@ -63,6 +67,9 @@
6367
public class CertificateIdentityPoolSubjectTokenSupplier
6468
implements IdentityPoolSubjectTokenSupplier {
6569

70+
// Matches the computed value in released versions so previously serialized instances still load.
71+
private static final long serialVersionUID = -7726329500407809830L;
72+
6673
private final IdentityPoolCredentialSource credentialSource;
6774

6875
CertificateIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) {
@@ -92,6 +99,34 @@ private static String loadAndEncodeLeafCertificate(String path) throws IOExcepti
9299
}
93100
}
94101

102+
private static @Nullable String extractAndEncodeLeafCertificate(@Nullable KeyStore keyStore) {
103+
if (keyStore == null) {
104+
return null;
105+
}
106+
try {
107+
Enumeration<String> aliases = keyStore.aliases();
108+
if (aliases == null) {
109+
return null;
110+
}
111+
while (aliases.hasMoreElements()) {
112+
String alias = aliases.nextElement();
113+
if (keyStore.isKeyEntry(alias)) {
114+
Certificate[] chain = keyStore.getCertificateChain(alias);
115+
if (chain != null && chain.length > 0 && chain[0] instanceof X509Certificate) {
116+
return encodeCert((X509Certificate) chain[0]);
117+
}
118+
Certificate cert = keyStore.getCertificate(alias);
119+
if (cert instanceof X509Certificate) {
120+
return encodeCert((X509Certificate) cert);
121+
}
122+
}
123+
}
124+
return null;
125+
} catch (KeyStoreException | CertificateEncodingException e) {
126+
return null;
127+
}
128+
}
129+
95130
@VisibleForTesting
96131
static X509Certificate parseCertificate(byte[] certData) throws CertificateException {
97132
if (certData == null || certData.length == 0) {
@@ -131,14 +166,27 @@ private static String encodeCert(X509Certificate certificate)
131166
*/
132167
@Override
133168
public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException {
134-
String leafCertPath = credentialSource.getCredentialLocation();
169+
return getSubjectToken(context, null);
170+
}
171+
172+
/**
173+
* Retrieves the X509 subject token, extracting the leaf certificate directly from {@code
174+
* pinnedKeyStore} when provided so that the TLS client certificate and {@code subject_token} are
175+
* pinned to the exact same certificate snapshot.
176+
*/
177+
String getSubjectToken(ExternalAccountSupplierContext context, @Nullable KeyStore pinnedKeyStore)
178+
throws IOException {
135179
String trustChainPath = null;
136180
if (credentialSource.getCertificateConfig() != null) {
137181
trustChainPath = credentialSource.getCertificateConfig().getTrustChainPath();
138182
}
139183

140-
// Load and encode the leaf certificate.
141-
String encodedLeafCert = loadAndEncodeLeafCertificate(leafCertPath);
184+
// Extract the leaf certificate from the pinned KeyStore if present; otherwise read from disk.
185+
String encodedLeafCert = extractAndEncodeLeafCertificate(pinnedKeyStore);
186+
if (encodedLeafCert == null) {
187+
String leafCertPath = credentialSource.getCredentialLocation();
188+
encodedLeafCert = loadAndEncodeLeafCertificate(leafCertPath);
189+
}
142190

143191
// Initialize the certificate chain for the subject token. The Security Token Service (STS)
144192
// requires that the leaf certificate (the one used for authenticating this workload) must be
@@ -238,7 +286,7 @@ private void populateCertChainFromTrustChain(
238286
* @throws CertificateException If an error occurs while parsing a certificate.
239287
*/
240288
@VisibleForTesting
241-
static List<X509Certificate> readTrustChain(String trustChainPath)
289+
static List<X509Certificate> readTrustChain(@Nullable String trustChainPath)
242290
throws IOException, CertificateException {
243291
List<X509Certificate> certificateTrustChain = new ArrayList<>();
244292

0 commit comments

Comments
 (0)