Skip to content

Commit c6985fe

Browse files
committed
feat(mtls): support custom HttpTransportFactory with mTLS and improve exception wrapping
- MtlsUtils: - Trust and preserve developer's custom HttpTransportFactory when mTLS is enabled instead of throwing IOException. - X509Provider: - Move workload certificate configuration resolution inside the try-catch block to properly wrap certificate source errors into IOException. - RegionalAccessBoundary & Manager: - Move subdomain substitution (.mtls.) logic to RegionalAccessBoundaryManager, gating it on MtlsUtils.canBeEnabled. - Simplify RegionalAccessBoundary.refresh signature. - Tests: - Add unit tests verifying custom HttpTransportFactory retention under AUTO and ALWAYS policies. - Add tests validating proper wrapping of JSON parsing failures in X509Provider.
1 parent e9a97bf commit c6985fe

7 files changed

Lines changed: 208 additions & 46 deletions

File tree

google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -308,11 +308,9 @@ public static HttpTransportFactory prepareTransportFactoryIfMtlsEnabled(
308308
}
309309

310310
if (baseTransportFactory != OAuth2Utils.HTTP_TRANSPORT_FACTORY) {
311-
// A user configured non-mTLS HttpTransportFactory was explicitly injected.
312-
// Reject it to avoid bypassing mTLS enforcement or overriding the user's factory.
313-
throw new IOException(
314-
"mTLS is enabled on the system, but a user configured non-mTLS HttpTransportFactory was provided: "
315-
+ baseTransportFactory.getClass().getName());
311+
// A user configured HttpTransportFactory was explicitly injected.
312+
// Trust the developer's custom factory and return it as-is.
313+
return baseTransportFactory;
316314
}
317315

318316
MtlsEndpointUsagePolicy mtlsPolicy = getMtlsEndpointUsagePolicy(envProvider);

google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,17 +113,22 @@ public X509Provider() {
113113
*/
114114
@Override
115115
public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOException {
116-
// Attempt to load from resolved Config File
117-
WorkloadCertificateConfiguration workloadCertConfig =
118-
MtlsUtils.getWorkloadCertificateConfiguration(
119-
envProvider, propProvider, certConfigPathOverride);
116+
try {
117+
// Attempt to load from resolved Config File
118+
WorkloadCertificateConfiguration workloadCertConfig =
119+
MtlsUtils.getWorkloadCertificateConfiguration(
120+
envProvider, propProvider, certConfigPathOverride);
120121

121-
try (InputStream certStream = new FileInputStream(new File(workloadCertConfig.getCertPath()));
122-
InputStream privateKeyStream =
123-
new FileInputStream(new File(workloadCertConfig.getPrivateKeyPath()));
124-
SequenceInputStream certAndPrivateKeyStream =
125-
new SequenceInputStream(certStream, privateKeyStream)) {
126-
return SecurityUtils.createMtlsKeyStore(certAndPrivateKeyStream);
122+
try (InputStream certStream =
123+
new FileInputStream(new File(workloadCertConfig.getCertPath()));
124+
InputStream privateKeyStream =
125+
new FileInputStream(new File(workloadCertConfig.getPrivateKeyPath()));
126+
SequenceInputStream certAndPrivateKeyStream =
127+
new SequenceInputStream(certStream, privateKeyStream)) {
128+
return SecurityUtils.createMtlsKeyStore(certAndPrivateKeyStream);
129+
}
130+
} catch (CertificateSourceUnavailableException e) {
131+
throw e;
127132
} catch (Exception e) {
128133
throw new IOException("X509Provider: Unexpected error loading from config file:", e);
129134
}

google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundary.java

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
import com.google.api.client.util.ExponentialBackOff;
4646
import com.google.api.client.util.Key;
4747
import com.google.auth.http.HttpTransportFactory;
48-
import com.google.auth.mtls.MtlsUtils;
4948
import com.google.common.base.MoreObjects;
5049
import com.google.common.base.Preconditions;
5150
import java.io.IOException;
@@ -182,21 +181,14 @@ static RegionalAccessBoundary refresh(
182181
String url,
183182
AccessToken accessToken,
184183
Clock clock,
185-
int maxRetryElapsedTimeMillis,
186-
EnvironmentProvider envProvider)
184+
int maxRetryElapsedTimeMillis)
187185
throws IOException {
188186
Preconditions.checkNotNull(accessToken, "The provided access token is null.");
189187
if (accessToken.getExpirationTimeMillis() != null
190188
&& accessToken.getExpirationTimeMillis() < clock.currentTimeMillis()) {
191189
throw new IllegalArgumentException("The provided access token is expired.");
192190
}
193191

194-
MtlsUtils.MtlsEndpointUsagePolicy policy = MtlsUtils.getMtlsEndpointUsagePolicy(envProvider);
195-
if (transportFactory instanceof com.google.auth.mtls.MtlsHttpTransportFactory
196-
|| policy == MtlsUtils.MtlsEndpointUsagePolicy.ALWAYS) {
197-
url = url.replace("iamcredentials.googleapis.com", "iamcredentials.mtls.googleapis.com");
198-
}
199-
200192
HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory();
201193
HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(url));
202194
// Disable automatic logging by google-http-java-client to prevent leakage of sensitive tokens.

google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/RegionalAccessBoundaryManager.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,17 +201,17 @@ void triggerAsyncRefresh(
201201
skipRAB.set(true);
202202
return;
203203
}
204+
if (com.google.auth.mtls.MtlsUtils.canBeEnabled(envProvider, propProvider, null)) {
205+
url =
206+
url.replace(
207+
"iamcredentials.googleapis.com", "iamcredentials.mtls.googleapis.com");
208+
}
204209
HttpTransportFactory upgradedTransportFactory =
205210
com.google.auth.mtls.MtlsUtils.prepareTransportFactoryIfMtlsEnabled(
206211
transportFactory, envProvider, propProvider, null);
207212
RegionalAccessBoundary newRAB =
208213
RegionalAccessBoundary.refresh(
209-
upgradedTransportFactory,
210-
url,
211-
accessToken,
212-
clock,
213-
maxRetryElapsedTimeMillis,
214-
envProvider);
214+
upgradedTransportFactory, url, accessToken, clock, maxRetryElapsedTimeMillis);
215215
cachedRAB.set(newRAB);
216216
resetCooldown();
217217
} catch (Exception e) {

google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@
3333

3434
import static org.junit.jupiter.api.Assertions.*;
3535

36+
import com.google.auth.http.HttpTransportFactory;
3637
import com.google.auth.oauth2.EnvironmentProvider;
38+
import com.google.auth.oauth2.OAuth2Utils;
3739
import com.google.auth.oauth2.PropertyProvider;
3840
import java.io.File;
3941
import java.io.IOException;
@@ -413,6 +415,141 @@ public String getEnv(String name) {
413415
assertFalse(MtlsUtils.canBeEnabled(envProvider, propProvider, null));
414416
}
415417

418+
@Test
419+
void canBeEnabled_autoPolicy_noConfig_returnsFalse() throws IOException {
420+
EnvironmentProvider envProvider = name -> null;
421+
PropertyProvider propProvider =
422+
new PropertyProvider() {
423+
@Override
424+
public String getProperty(String name, String def) {
425+
if ("user.home".equals(name)) return tempDir.toString();
426+
return def;
427+
}
428+
};
429+
430+
assertFalse(MtlsUtils.canBeEnabled(envProvider, propProvider, null));
431+
}
432+
433+
@Test
434+
void canBeEnabled_alwaysPolicy_returnsTrue() throws IOException {
435+
EnvironmentProvider envProvider =
436+
name -> "GOOGLE_API_USE_MTLS_ENDPOINT".equals(name) ? "always" : null;
437+
PropertyProvider propProvider = (name, def) -> def;
438+
439+
assertTrue(MtlsUtils.canBeEnabled(envProvider, propProvider, null));
440+
}
441+
442+
@Test
443+
void getWorkloadCertificateConfiguration_malformedJson_throwsException() throws IOException {
444+
Path configFile = tempDir.resolve("malformed.json");
445+
Files.write(configFile, "{invalid-json}".getBytes());
446+
447+
EnvironmentProvider envProvider = name -> null;
448+
PropertyProvider propProvider = (name, def) -> def;
449+
450+
assertThrows(
451+
Exception.class,
452+
() ->
453+
MtlsUtils.getWorkloadCertificateConfiguration(
454+
envProvider, propProvider, configFile.toString()));
455+
}
456+
457+
@Test
458+
void prepareTransportFactoryIfMtlsEnabled_nullInput_returnsNull() throws IOException {
459+
assertNull(
460+
MtlsUtils.prepareTransportFactoryIfMtlsEnabled(
461+
null, name -> null, (name, def) -> def, null));
462+
}
463+
464+
@Test
465+
void prepareTransportFactoryIfMtlsEnabled_mtlsFactory_returnsAsIs()
466+
throws java.security.GeneralSecurityException, IOException {
467+
java.security.KeyStore dummyKeyStore =
468+
java.security.KeyStore.getInstance(java.security.KeyStore.getDefaultType());
469+
dummyKeyStore.load(null, null);
470+
MtlsHttpTransportFactory mtlsFactory = new MtlsHttpTransportFactory(dummyKeyStore);
471+
472+
HttpTransportFactory result =
473+
MtlsUtils.prepareTransportFactoryIfMtlsEnabled(
474+
mtlsFactory, name -> null, (name, def) -> def, null);
475+
476+
assertSame(mtlsFactory, result);
477+
}
478+
479+
@Test
480+
void prepareTransportFactoryIfMtlsEnabled_customFactory_mtlsAlways_returnsAsIs()
481+
throws IOException {
482+
HttpTransportFactory customFactory = () -> null;
483+
EnvironmentProvider envProvider =
484+
name -> "GOOGLE_API_USE_MTLS_ENDPOINT".equals(name) ? "always" : null;
485+
PropertyProvider propProvider = (name, def) -> def;
486+
487+
HttpTransportFactory result =
488+
MtlsUtils.prepareTransportFactoryIfMtlsEnabled(
489+
customFactory, envProvider, propProvider, null);
490+
491+
assertSame(customFactory, result);
492+
}
493+
494+
@Test
495+
void prepareTransportFactoryIfMtlsEnabled_customFactory_mtlsAuto_withConfig_returnsAsIs()
496+
throws IOException {
497+
HttpTransportFactory customFactory = () -> null;
498+
EnvironmentProvider envProvider =
499+
name ->
500+
"GOOGLE_API_CERTIFICATE_CONFIG".equals(name)
501+
? "testresources/mtls/certificate_config.json"
502+
: null;
503+
PropertyProvider propProvider = (name, def) -> def;
504+
505+
HttpTransportFactory result =
506+
MtlsUtils.prepareTransportFactoryIfMtlsEnabled(
507+
customFactory, envProvider, propProvider, null);
508+
509+
assertSame(customFactory, result);
510+
}
511+
512+
@Test
513+
void prepareTransportFactoryIfMtlsEnabled_defaultFactory_mtlsAlways_upgradesToMtlsFactory()
514+
throws IOException {
515+
EnvironmentProvider envProvider =
516+
new EnvironmentProvider() {
517+
@Override
518+
public String getEnv(String name) {
519+
if ("GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name)) {
520+
return "true";
521+
}
522+
if ("GOOGLE_API_USE_MTLS_ENDPOINT".equals(name)) {
523+
return "always";
524+
}
525+
if ("GOOGLE_API_CERTIFICATE_CONFIG".equals(name)) {
526+
return "testresources/mtls/certificate_config.json";
527+
}
528+
return null;
529+
}
530+
};
531+
PropertyProvider propProvider = (name, def) -> def;
532+
533+
HttpTransportFactory result =
534+
MtlsUtils.prepareTransportFactoryIfMtlsEnabled(
535+
OAuth2Utils.HTTP_TRANSPORT_FACTORY, envProvider, propProvider, null);
536+
537+
assertTrue(result instanceof MtlsHttpTransportFactory);
538+
}
539+
540+
@Test
541+
void prepareTransportFactoryIfMtlsEnabled_defaultFactory_mtlsAuto_noConfig_returnsAsIs()
542+
throws IOException {
543+
EnvironmentProvider envProvider = name -> null;
544+
PropertyProvider propProvider = (name, def) -> def;
545+
546+
HttpTransportFactory result =
547+
MtlsUtils.prepareTransportFactoryIfMtlsEnabled(
548+
OAuth2Utils.HTTP_TRANSPORT_FACTORY, envProvider, propProvider, null);
549+
550+
assertSame(OAuth2Utils.HTTP_TRANSPORT_FACTORY, result);
551+
}
552+
416553
private String createJsonConfigString(Path certPath, Path keyPath) {
417554
return "{\"cert_configs\":{\"workload\":{\"cert_path\":\""
418555
+ certPath.toString().replace("\\", "\\\\")

google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,12 @@
5050
import java.security.cert.CertificateException;
5151
import java.security.cert.CertificateFactory;
5252
import org.junit.jupiter.api.Test;
53+
import org.junit.jupiter.api.io.TempDir;
5354

5455
class X509ProviderTest {
5556

57+
@TempDir Path tempDir;
58+
5659
private static final String TEST_CERT_PATH = "testresources/mtls/test_cert.pem";
5760
private static final String TEST_CONFIG_PATH = "testresources/mtls/certificate_config.json";
5861

@@ -70,15 +73,16 @@ void x509Provider_fileDoesntExist_throws() {
7073

7174
@Test
7275
void x509Provider_emptyFile_throws() throws IOException {
73-
Path emptyConfig = Files.createTempFile("emptyConfig", ".txt");
74-
emptyConfig.toFile().deleteOnExit();
76+
Path emptyConfig = tempDir.resolve("emptyConfig.txt");
77+
Files.createFile(emptyConfig);
7578

7679
X509Provider testProvider = new X509Provider(emptyConfig.toString());
7780
String expectedErrorMessage = "no JSON input found";
7881

79-
IllegalArgumentException exception =
80-
assertThrows(IllegalArgumentException.class, testProvider::getKeyStore);
81-
assertTrue(exception.getMessage().contains(expectedErrorMessage));
82+
IOException exception = assertThrows(IOException.class, testProvider::getKeyStore);
83+
assertNotNull(exception.getCause());
84+
assertTrue(exception.getCause() instanceof IllegalArgumentException);
85+
assertTrue(exception.getCause().getMessage().contains(expectedErrorMessage));
8286
}
8387

8488
@Test
@@ -142,8 +146,8 @@ void x509Provider_succeeds_withWellKnownPath()
142146
@Test
143147
void x509Provider_succeeds_withWindowsPath()
144148
throws IOException, KeyStoreException, CertificateException {
145-
Path windowsTempDir = Files.createTempDirectory("windowsTempDir");
146-
windowsTempDir.toFile().deleteOnExit();
149+
Path windowsTempDir = tempDir.resolve("windowsTempDir");
150+
Files.createDirectory(windowsTempDir);
147151
Path gcloudDir = windowsTempDir.resolve("gcloud");
148152
Files.createDirectory(gcloudDir);
149153
Path configPath = gcloudDir.resolve("certificate_config.json");
@@ -172,9 +176,8 @@ void x509Provider_succeeds_withWindowsPath()
172176

173177
@Test
174178
void x509Provider_certFileDoesntExist_throws() throws IOException {
175-
Path tempConfig = Files.createTempFile("config", ".json");
176-
tempConfig.toFile().deleteOnExit();
177-
Path nonExistentCert = tempConfig.getParent().resolve("non_existent_cert.pem");
179+
Path tempConfig = tempDir.resolve("config_no_cert.json");
180+
Path nonExistentCert = tempDir.resolve("non_existent_cert.pem");
178181

179182
Files.write(
180183
tempConfig,
@@ -190,10 +193,8 @@ void x509Provider_certFileDoesntExist_throws() throws IOException {
190193

191194
@Test
192195
void x509Provider_malformedCert_throws() throws IOException {
193-
Path tempConfig = Files.createTempFile("config", ".json");
194-
tempConfig.toFile().deleteOnExit();
195-
Path malformedCert = Files.createTempFile("badcert", ".pem");
196-
malformedCert.toFile().deleteOnExit();
196+
Path tempConfig = tempDir.resolve("config_malformed_cert.json");
197+
Path malformedCert = tempDir.resolve("badcert.pem");
197198

198199
Files.write(malformedCert, "This is not a valid certificate".getBytes());
199200

@@ -209,6 +210,36 @@ void x509Provider_malformedCert_throws() throws IOException {
209210
assertThrows(Exception.class, testProvider::getKeyStore);
210211
}
211212

213+
@Test
214+
void x509Provider_missingCertConfigs_throws() throws IOException {
215+
Path tempConfig = tempDir.resolve("config_missing_configs.json");
216+
217+
Files.write(tempConfig, "{}".getBytes());
218+
219+
X509Provider testProvider = new X509Provider(tempConfig.toString());
220+
221+
IOException exception = assertThrows(IOException.class, testProvider::getKeyStore);
222+
assertNotNull(exception.getCause());
223+
assertTrue(exception.getCause() instanceof IllegalArgumentException);
224+
assertTrue(
225+
exception.getCause().getMessage().contains("The cert_configs object must be provided"));
226+
}
227+
228+
@Test
229+
void x509Provider_missingCertPath_throws() throws IOException {
230+
Path tempConfig = tempDir.resolve("config_missing_path.json");
231+
232+
Files.write(
233+
tempConfig, "{\"cert_configs\":{\"workload\":{\"key_path\":\"key.pem\"}}}".getBytes());
234+
235+
X509Provider testProvider = new X509Provider(tempConfig.toString());
236+
237+
IOException exception = assertThrows(IOException.class, testProvider::getKeyStore);
238+
assertNotNull(exception.getCause());
239+
assertTrue(exception.getCause() instanceof IllegalArgumentException);
240+
assertTrue(exception.getCause().getMessage().contains("The cert_path field must be provided"));
241+
}
242+
212243
// Failure Path: mTLS disabled (allowance = false) throws CertificateSourceUnavailableException
213244
@Test
214245
void x509Provider_allowanceDisabled_throws() throws Exception {

google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/RegionalAccessBoundaryTest.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,7 @@ public void testRefreshClosesResponse() throws Exception {
150150
HttpTransportFactory transportFactory = () -> transport;
151151

152152
RegionalAccessBoundary rab =
153-
RegionalAccessBoundary.refresh(
154-
transportFactory, url, token, testClock, 1000, SystemEnvironmentProvider.getInstance());
153+
RegionalAccessBoundary.refresh(transportFactory, url, token, testClock, 1000);
155154

156155
assertEquals("encoded", rab.getEncodedLocations());
157156
assertTrue(mockResponse.isDisconnected(), "Response should have been disconnected");

0 commit comments

Comments
 (0)