Skip to content

Commit ed2bd40

Browse files
authored
test(oauth2): add mTLS in-process socket tests and live GCP WIF integration tests (#14220)
## Description This PR adds comprehensive integration tests for Certificate-Bound OAuth 2.0 (mTLS), Actor Tokens, and Service Account Impersonation transport pinning across both hermetic in-process socket tests and live GCP Workload Identity Federation suites. ### Summary of Changes: 1. **Hermetic In-Process Socket Suite (`MtlsPipelineLocalTest.java`)**: - Uses JDK `HttpsServer` on `localhost` requiring client authentication (`setNeedClientAuth(true)`). - `testMtlsPipeline_verifiesPeerCertAndPayload`: Verifies server extracts client peer certificate from `SSLSession` and parses token exchange body (`grant_type`, `subject_token`, `actor_token`, `actor_token_type`). - `testMtlsPipeline_withImpersonation_usesSameCertForStsAndIam`: Verifies both STS and IAM calls execute on the exact same pinned mTLS transport and verifies the intermediate `Authorization: Bearer <token>` header. - `testMtlsPipeline_withImpersonation_401OnIam_retriesWithFreshCert`: Verifies full-cycle recovery when IAM returns 401 (reloads fresh KeyStore from disk, re-exchanges at STS with new cert, and calls IAM with new token + cert). - `testMtlsPipeline_concurrentRefreshes`: Multi-threaded refresh verifying thread-isolated transport snapshots. - `testMtlsPipeline_atomicTokenRead`: Verifies single-pass disk I/O for subject + actor tokens. 2. **Live GCP Suite Extensions (`ITWorkloadIdentityFederationTest.java`)**: - `identityPoolCredentials_withCertificateBoundWorkloadAndActorToken`: E2E verification of JSON config with certificate config and actor tokens. - `identityPoolCredentials_withProgrammaticMtlsAndActorToken`: E2E verification of programmatic builder configuration. 3. **`OAuthException.java`**: - Safely handles null HTTP error response content without NPE. ### Verification: - **Unit & Socket Tests**: 1,040 / 1,040 tests passing (including hermetic socket tests). - **Google Java Format**: 100% compliant (`mvn com.spotify.fmt:fmt-maven-plugin:2.25:check`). Source-Link: b/542238030
1 parent 80b8228 commit ed2bd40

6 files changed

Lines changed: 1913 additions & 45 deletions

File tree

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

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
import com.google.api.client.http.HttpResponseException;
3737
import com.google.api.client.json.GenericJson;
3838
import com.google.api.client.json.JsonParser;
39-
import java.io.IOException;
39+
import java.util.Map;
4040
import org.jspecify.annotations.NullMarked;
4141
import org.jspecify.annotations.Nullable;
4242

@@ -99,20 +99,67 @@ int getHttpStatusCode() {
9999
return httpStatusCode;
100100
}
101101

102-
static OAuthException createFromHttpResponseException(HttpResponseException e)
103-
throws IOException {
104-
JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser((e).getContent());
105-
GenericJson errorResponse = parser.parseAndClose(GenericJson.class);
106-
107-
String errorCode = (String) errorResponse.get("error");
108-
String errorDescription = null;
109-
String errorUri = null;
110-
if (errorResponse.containsKey("error_description")) {
111-
errorDescription = (String) errorResponse.get("error_description");
102+
static OAuthException createFromHttpResponseException(HttpResponseException e) {
103+
String content = e.getContent();
104+
if (content == null || content.trim().isEmpty()) {
105+
OAuthException oauthException =
106+
new OAuthException(
107+
"http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode());
108+
oauthException.initCause(e);
109+
return oauthException;
112110
}
113-
if (errorResponse.containsKey("error_uri")) {
114-
errorUri = (String) errorResponse.get("error_uri");
111+
try {
112+
JsonParser parser = OAuth2Utils.JSON_FACTORY.createJsonParser(content);
113+
GenericJson errorResponse = parser.parseAndClose(GenericJson.class);
114+
if (errorResponse == null) {
115+
OAuthException oauthException =
116+
new OAuthException(
117+
"http_error_" + e.getStatusCode(), e.getStatusMessage(), null, e.getStatusCode());
118+
oauthException.initCause(e);
119+
return oauthException;
120+
}
121+
122+
String errorCode = null;
123+
String errorDescription = null;
124+
Object rawError = errorResponse.get("error");
125+
if (rawError instanceof String) {
126+
errorCode = (String) rawError;
127+
} else if (rawError instanceof Map) {
128+
Map<?, ?> errorMap = (Map<?, ?>) rawError;
129+
if (errorMap.get("status") instanceof String) {
130+
errorCode = (String) errorMap.get("status");
131+
}
132+
if (errorMap.get("message") instanceof String) {
133+
errorDescription = (String) errorMap.get("message");
134+
}
135+
}
136+
if (errorCode == null) {
137+
errorCode = "http_error_" + e.getStatusCode();
138+
}
139+
String errorUri = null;
140+
if (errorResponse.get("error_description") instanceof String) {
141+
errorDescription = (String) errorResponse.get("error_description");
142+
} else if (errorDescription == null && errorResponse.get("message") instanceof String) {
143+
errorDescription = (String) errorResponse.get("message");
144+
}
145+
if (errorDescription == null && !(rawError instanceof String)) {
146+
errorDescription =
147+
errorResponse.isEmpty() && e.getStatusMessage() != null
148+
? e.getStatusMessage()
149+
: content;
150+
}
151+
if (errorResponse.get("error_uri") instanceof String) {
152+
errorUri = (String) errorResponse.get("error_uri");
153+
}
154+
OAuthException oauthException =
155+
new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode());
156+
oauthException.initCause(e);
157+
return oauthException;
158+
} catch (Exception parseException) {
159+
OAuthException oauthException =
160+
new OAuthException("http_error_" + e.getStatusCode(), content, null, e.getStatusCode());
161+
oauthException.initCause(e);
162+
return oauthException;
115163
}
116-
return new OAuthException(errorCode, errorDescription, errorUri, e.getStatusCode());
117164
}
118165
}

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

Lines changed: 227 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,16 @@
3131

3232
package com.google.auth.oauth2;
3333

34+
import static org.junit.jupiter.api.Assertions.assertNotNull;
3435
import static org.junit.jupiter.api.Assertions.assertTrue;
3536
import static org.junit.jupiter.api.Assertions.fail;
37+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
3638

3739
import com.google.api.client.http.GenericUrl;
3840
import com.google.api.client.http.HttpRequest;
3941
import com.google.api.client.http.HttpRequestFactory;
4042
import com.google.api.client.http.HttpResponse;
43+
import com.google.api.client.http.HttpTransport;
4144
import com.google.api.client.http.UrlEncodedContent;
4245
import com.google.api.client.http.javanet.NetHttpTransport;
4346
import com.google.api.client.json.GenericJson;
@@ -46,12 +49,16 @@
4649
import com.google.api.client.json.gson.GsonFactory;
4750
import com.google.api.client.util.GenericData;
4851
import com.google.auth.http.HttpCredentialsAdapter;
52+
import com.google.auth.http.HttpTransportFactory;
53+
import com.google.auth.mtls.MtlsHttpTransportFactory;
54+
import com.google.auth.mtls.X509Provider;
4955
import com.google.auth.oauth2.ExternalAccountCredentials.SubjectTokenTypes;
5056
import java.io.ByteArrayInputStream;
5157
import java.io.File;
5258
import java.io.FileInputStream;
5359
import java.io.IOException;
5460
import java.nio.charset.StandardCharsets;
61+
import java.security.KeyStore;
5562
import java.time.Instant;
5663
import java.util.HashMap;
5764
import java.util.Map;
@@ -281,6 +288,205 @@ void identityPoolCredentials_withProgrammaticAuth() throws IOException {
281288
callGcs(identityPoolCredentials);
282289
}
283290

291+
/**
292+
* IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token): Uses
293+
* the service account to generate Google ID tokens for subject and actor tokens. Writes both
294+
* tokens to a temporary JSON file with subject_token and actor_token field names. Configures
295+
* certificate_config_location pointing to the certificate config. Exchanges the tokens over the
296+
* mTLS STS endpoint (https://sts.mtls.googleapis.com/v1/token) and calls GCS.
297+
*/
298+
@Test
299+
void identityPoolCredentials_withCertificateBoundWorkloadAndActorToken() throws Exception {
300+
String certConfigPath = getMtlsCertificateConfigPath();
301+
String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE);
302+
String actorToken = generateGoogleIdToken(OIDC_AUDIENCE);
303+
304+
File tokenFile =
305+
File.createTempFile(
306+
"ITWorkloadIdentityFederation_cert_actor", /* suffix= */ null, /* directory= */ null);
307+
tokenFile.deleteOnExit();
308+
try {
309+
GenericJson tokenJson = new GenericJson();
310+
tokenJson.setFactory(OAuth2Utils.JSON_FACTORY);
311+
tokenJson.put("subject_token", subjectToken);
312+
tokenJson.put("actor_token", actorToken);
313+
314+
OAuth2Utils.writeInputStreamToFile(
315+
new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)),
316+
tokenFile.getAbsolutePath());
317+
318+
GenericJson config = new GenericJson();
319+
config.put("type", "external_account");
320+
config.put("audience", OIDC_AUDIENCE);
321+
config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt");
322+
config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt");
323+
config.put("token_url", "https://sts.mtls.googleapis.com/v1/token");
324+
config.put(
325+
"service_account_impersonation_url",
326+
String.format(
327+
"https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
328+
clientEmail));
329+
330+
GenericJson credentialSource = new GenericJson();
331+
credentialSource.put("file", tokenFile.getAbsolutePath());
332+
333+
GenericJson format = new GenericJson();
334+
format.put("type", "json");
335+
format.put("subject_token_field_name", "subject_token");
336+
format.put("actor_token_field_name", "actor_token");
337+
credentialSource.put("format", format);
338+
339+
GenericJson certificate = new GenericJson();
340+
certificate.put("certificate_config_location", certConfigPath);
341+
credentialSource.put("certificate", certificate);
342+
343+
config.put("credential_source", credentialSource);
344+
345+
IdentityPoolCredentials identityPoolCredentials =
346+
(IdentityPoolCredentials)
347+
ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
348+
349+
KeyStore keyStore = new X509Provider(certConfigPath).getKeyStore();
350+
HttpTransport mtlsTransport = new MtlsHttpTransportFactory(keyStore).create();
351+
callGcs(
352+
identityPoolCredentials,
353+
mtlsTransport,
354+
"https://storage.mtls.googleapis.com/storage/v1/b/");
355+
} finally {
356+
tokenFile.delete();
357+
}
358+
}
359+
360+
/**
361+
* IdentityPoolCredentials (OIDC provider with programmatic mTLS and actor token): Uses the
362+
* service account to generate Google ID tokens for subject and actor tokens via suppliers.
363+
* Configures mTLS transport using MtlsHttpTransportFactory with KeyStore loaded from the mTLS
364+
* certificate config. Exchanges the tokens over mTLS STS endpoint and calls GCS.
365+
*/
366+
@Test
367+
void identityPoolCredentials_withProgrammaticMtlsAndActorToken() throws Exception {
368+
IdentityPoolSubjectTokenSupplier tokenSupplier =
369+
context -> generateGoogleIdToken(OIDC_AUDIENCE);
370+
IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE);
371+
372+
X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath());
373+
HttpTransportFactory transportFactory =
374+
new MtlsHttpTransportFactory(x509Provider.getKeyStore());
375+
376+
IdentityPoolCredentials credentials =
377+
IdentityPoolCredentials.newBuilder()
378+
.setSubjectTokenSupplier(tokenSupplier)
379+
.setActorTokenSupplier(actorSupplier)
380+
.setActorTokenType(SubjectTokenTypes.JWT.value)
381+
.setAudience(OIDC_AUDIENCE)
382+
.setSubjectTokenType(SubjectTokenTypes.JWT)
383+
.setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
384+
.setServiceAccountImpersonationUrl(
385+
String.format(
386+
"https://iamcredentials.mtls.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
387+
clientEmail))
388+
.setHttpTransportFactory(transportFactory)
389+
.build();
390+
391+
callGcs(
392+
credentials,
393+
transportFactory.create(),
394+
"https://storage.mtls.googleapis.com/storage/v1/b/");
395+
}
396+
397+
/**
398+
* IdentityPoolCredentials (OIDC provider with certificate-bound workload and actor token, direct
399+
* STS): Exchanges tokens directly over the mTLS STS endpoint
400+
* (https://sts.mtls.googleapis.com/v1/token) without service account impersonation.
401+
*/
402+
@Test
403+
void identityPoolCredentials_directSts_withCertificateBoundWorkloadAndActorToken()
404+
throws IOException {
405+
String certConfigPath = getMtlsCertificateConfigPath();
406+
String subjectToken = generateGoogleIdToken(OIDC_AUDIENCE);
407+
String actorToken = generateGoogleIdToken(OIDC_AUDIENCE);
408+
409+
File tokenFile =
410+
File.createTempFile(
411+
"ITWorkloadIdentityFederation_direct_cert_actor",
412+
/* suffix= */ null,
413+
/* directory= */ null);
414+
tokenFile.deleteOnExit();
415+
try {
416+
GenericJson tokenJson = new GenericJson();
417+
tokenJson.setFactory(OAuth2Utils.JSON_FACTORY);
418+
tokenJson.put("subject_token", subjectToken);
419+
tokenJson.put("actor_token", actorToken);
420+
421+
OAuth2Utils.writeInputStreamToFile(
422+
new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)),
423+
tokenFile.getAbsolutePath());
424+
425+
GenericJson config = new GenericJson();
426+
config.put("type", "external_account");
427+
config.put("audience", OIDC_AUDIENCE);
428+
config.put("subject_token_type", "urn:ietf:params:oauth:token-type:jwt");
429+
config.put("actor_token_type", "urn:ietf:params:oauth:token-type:jwt");
430+
config.put("token_url", "https://sts.mtls.googleapis.com/v1/token");
431+
432+
GenericJson credentialSource = new GenericJson();
433+
credentialSource.put("file", tokenFile.getAbsolutePath());
434+
435+
GenericJson format = new GenericJson();
436+
format.put("type", "json");
437+
format.put("subject_token_field_name", "subject_token");
438+
format.put("actor_token_field_name", "actor_token");
439+
credentialSource.put("format", format);
440+
441+
GenericJson certificate = new GenericJson();
442+
certificate.put("certificate_config_location", certConfigPath);
443+
credentialSource.put("certificate", certificate);
444+
445+
config.put("credential_source", credentialSource);
446+
447+
IdentityPoolCredentials identityPoolCredentials =
448+
(IdentityPoolCredentials)
449+
ExternalAccountCredentials.fromJson(config, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
450+
451+
AccessToken accessToken = identityPoolCredentials.refreshAccessToken();
452+
assertNotNull(accessToken);
453+
assertNotNull(accessToken.getTokenValue());
454+
} finally {
455+
tokenFile.delete();
456+
}
457+
}
458+
459+
/**
460+
* IdentityPoolCredentials (OIDC provider with programmatic mTLS and actor token, direct STS):
461+
* Uses suppliers for subject and actor tokens, configuring MtlsHttpTransportFactory. Exchanges
462+
* directly over mTLS STS endpoint without service account impersonation.
463+
*/
464+
@Test
465+
void identityPoolCredentials_directSts_withProgrammaticMtlsAndActorToken() throws Exception {
466+
IdentityPoolSubjectTokenSupplier tokenSupplier =
467+
context -> generateGoogleIdToken(OIDC_AUDIENCE);
468+
IdentityPoolActorTokenSupplier actorSupplier = context -> generateGoogleIdToken(OIDC_AUDIENCE);
469+
470+
X509Provider x509Provider = new X509Provider(getMtlsCertificateConfigPath());
471+
HttpTransportFactory transportFactory =
472+
new MtlsHttpTransportFactory(x509Provider.getKeyStore());
473+
474+
IdentityPoolCredentials credentials =
475+
IdentityPoolCredentials.newBuilder()
476+
.setSubjectTokenSupplier(tokenSupplier)
477+
.setActorTokenSupplier(actorSupplier)
478+
.setActorTokenType(SubjectTokenTypes.JWT.value)
479+
.setAudience(OIDC_AUDIENCE)
480+
.setSubjectTokenType(SubjectTokenTypes.JWT)
481+
.setTokenUrl("https://sts.mtls.googleapis.com/v1/token")
482+
.setHttpTransportFactory(transportFactory)
483+
.build();
484+
485+
AccessToken accessToken = credentials.refreshAccessToken();
486+
assertNotNull(accessToken);
487+
assertNotNull(accessToken.getTokenValue());
488+
}
489+
284490
private GenericJson buildIdentityPoolCredentialConfig() throws IOException {
285491
String idToken = generateGoogleIdToken(OIDC_AUDIENCE);
286492

@@ -382,24 +588,41 @@ private GenericJson buildAwsCredentialConfig() {
382588
return config;
383589
}
384590

591+
private String getMtlsCertificateConfigPath() {
592+
String certConfigPath = System.getenv("GOOGLE_API_CERTIFICATE_CONFIG");
593+
assumeTrue(
594+
certConfigPath != null && !certConfigPath.isEmpty(),
595+
"Skipping mTLS test: GOOGLE_API_CERTIFICATE_CONFIG env variable is not set.");
596+
return certConfigPath;
597+
}
598+
385599
private void callGcs(GoogleCredentials credentials) throws IOException {
600+
callGcs(credentials, new NetHttpTransport(), "https://storage.googleapis.com/storage/v1/b/");
601+
}
602+
603+
private void callGcs(
604+
GoogleCredentials credentials, HttpTransport transport, String storageBaseUrl)
605+
throws IOException {
386606
String bucketName = System.getenv("GCS_BUCKET");
387607
if (bucketName == null) {
388608
fail("GCS bucket name not set through GCS_BUCKET env variable.");
389609
}
390610

391-
String url = "https://storage.googleapis.com/storage/v1/b/" + bucketName;
611+
String url = storageBaseUrl + bucketName;
392612

393613
HttpCredentialsAdapter credentialsAdapter = new HttpCredentialsAdapter(credentials);
394-
HttpRequestFactory requestFactory =
395-
new NetHttpTransport().createRequestFactory(credentialsAdapter);
614+
HttpRequestFactory requestFactory = transport.createRequestFactory(credentialsAdapter);
396615
HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));
397616

398617
JsonObjectParser parser = new JsonObjectParser(GsonFactory.getDefaultInstance());
399618
request.setParser(parser);
400619

401620
HttpResponse response = request.execute();
402-
assertTrue(response.isSuccessStatusCode());
621+
try {
622+
assertTrue(response.isSuccessStatusCode());
623+
} finally {
624+
response.disconnect();
625+
}
403626
}
404627

405628
/**

0 commit comments

Comments
 (0)