From 258a665544059dadcceef55e960f8ad63c79b972 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 29 Jul 2026 14:45:33 -0700 Subject: [PATCH 1/8] feat(client): add native mTLS transport recipe --- README.md | 74 ++++++++ openai-java-client-okhttp/build.gradle.kts | 2 + .../client/okhttp/OpenAIOkHttpClient.kt | 7 +- .../client/okhttp/OpenAIOkHttpClientAsync.kt | 7 +- .../OpenAIOkHttpClientNativeMutualTlsTest.kt | 162 ++++++++++++++++++ .../com/openai/example/MutualTlsExample.java | 89 ++++++++++ 6 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientNativeMutualTlsTest.kt create mode 100644 openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java diff --git a/README.md b/README.md index b6788a580..cc7859815 100644 --- a/README.md +++ b/README.md @@ -1696,6 +1696,80 @@ OpenAIClient client = OpenAIOkHttpClient.builder() .build(); ``` +#### Mutual TLS with native JSSE + +API-key authenticated HTTP requests can use mTLS without a dedicated SDK API. Build an +`SSLContext` using Java's native JSSE APIs and pass it through the existing OkHttp TLS hooks. + +To opt in, follow the +[OpenAI Mutual TLS Beta Program](https://help.openai.com/en/articles/10876024-openai-mutual-tls-beta-program) +guide to upload a CA certificate and activate it for your project or organization before +configuring the client. + +The PKCS#12 key store must contain the client private key and the complete certificate chain: +the leaf certificate first, followed by any intermediate certificates. Keep server trust separate +from that client identity. Initializing `TrustManagerFactory` with a null `KeyStore` retains the +JVM's normal server-trust configuration. + +```java +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyStore; +import java.util.Arrays; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +char[] password = System.getenv("OPENAI_MTLS_KEYSTORE_PASSWORD").toCharArray(); +KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); +KeyManagerFactory keyManagers = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); +try { + try (InputStream input = + Files.newInputStream(Paths.get(System.getenv("OPENAI_MTLS_KEYSTORE")))) { + clientKeyStore.load(input, password); + } + keyManagers.init(clientKeyStore, password); +} finally { + Arrays.fill(password, '\0'); +} + +TrustManagerFactory trustManagers = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); +trustManagers.init((KeyStore) null); +X509TrustManager trustManager = Arrays.stream(trustManagers.getTrustManagers()) + .filter(X509TrustManager.class::isInstance) + .map(X509TrustManager.class::cast) + .findFirst() + .orElseThrow(IllegalStateException::new); + +SSLContext sslContext = SSLContext.getInstance("TLS"); +sslContext.init(keyManagers.getKeyManagers(), new TrustManager[] {trustManager}, null); + +OpenAIClient client = OpenAIOkHttpClient.builder() + .fromEnv() + // Native TLS configuration does not select an mTLS endpoint automatically. + .baseUrl("https://mtls.api.openai.com/v1") + // Avoid presenting the client identity to a redirect target. + .followRedirects(false) + .sslSocketFactory(sslContext.getSocketFactory()) + .trustManager(trustManager) + .build(); +``` + +An explicit EU or custom endpoint can be used instead of the global mTLS endpoint. The SDK does not +inspect or rewrite that URL. To rotate the client identity, build a new `SSLContext`, HTTP transport, +and SDK client, then close the old client. This recipe applies to ordinary HTTP API-key traffic; it +does not add certificate-only authentication or Realtime/WebSocket mTLS support. + +See the complete, compilable +[`MutualTlsExample`](openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java). + ### Custom HTTP client The SDK consists of three artifacts: diff --git a/openai-java-client-okhttp/build.gradle.kts b/openai-java-client-okhttp/build.gradle.kts index af7e6d409..9f10f7968 100644 --- a/openai-java-client-okhttp/build.gradle.kts +++ b/openai-java-client-okhttp/build.gradle.kts @@ -26,4 +26,6 @@ dependencies { testImplementation(kotlin("test")) testImplementation("org.assertj:assertj-core:3.27.7") testImplementation(platform("com.fasterxml.jackson:jackson-bom:2.21.5")) + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("com.squareup.okhttp3:okhttp-tls:4.12.0") } diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt index 31fd61632..e69222208 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt @@ -75,8 +75,11 @@ class OpenAIOkHttpClient private constructor() { this.dispatcherExecutorService = dispatcherExecutorService } - /** Configures whether the underlying transport follows redirects automatically. */ - @JvmSynthetic + /** + * Configures whether the underlying transport follows redirects automatically. + * + * Defaults to true. + */ fun followRedirects(followRedirects: Boolean) = apply { this.followRedirects = followRedirects } diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt index 65ff1c65c..b2977ccc3 100644 --- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt +++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt @@ -75,8 +75,11 @@ class OpenAIOkHttpClientAsync private constructor() { this.dispatcherExecutorService = dispatcherExecutorService } - /** Configures whether the underlying transport follows redirects automatically. */ - @JvmSynthetic + /** + * Configures whether the underlying transport follows redirects automatically. + * + * Defaults to true. + */ fun followRedirects(followRedirects: Boolean) = apply { this.followRedirects = followRedirects } diff --git a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientNativeMutualTlsTest.kt b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientNativeMutualTlsTest.kt new file mode 100644 index 000000000..21d791254 --- /dev/null +++ b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientNativeMutualTlsTest.kt @@ -0,0 +1,162 @@ +package com.openai.client.okhttp + +import com.openai.client.OpenAIClient +import com.openai.errors.OpenAIIoException +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.security.KeyStore +import java.security.cert.X509Certificate +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +internal class OpenAIOkHttpClientNativeMutualTlsTest { + + private val clientRoot = + HeldCertificate.Builder().commonName("client root").certificateAuthority(2).build() + private val clientIntermediate = + HeldCertificate.Builder() + .commonName("client intermediate") + .certificateAuthority(1) + .signedBy(clientRoot) + .build() + private val clientLeaf = + HeldCertificate.Builder().commonName("client leaf").signedBy(clientIntermediate).build() + + @Test + fun nativeMutualTlsPresentsFullPkcs12Chain() { + mutuallyAuthenticatedServer().use { fixture -> + fixture.server.enqueue( + MockResponse() + .setHeader("Content-Type", "application/json") + .setBody("""{"object":"list","data":[]}""") + ) + val client = + nativeMutualTlsClient( + arrayOf(clientLeaf.certificate, clientIntermediate.certificate), + fixture.clientTrust.trustManager, + fixture.baseUrl, + ) + + try { + client.files().list() + } finally { + client.close() + } + + assertThat(fixture.server.takeRequest().path).isEqualTo("/v1/files") + } + } + + @Test + fun nativeMutualTlsFailsClosedWhenIntermediateIsMissing() { + mutuallyAuthenticatedServer().use { fixture -> + fixture.server.enqueue( + MockResponse() + .setHeader("Content-Type", "application/json") + .setBody("""{"object":"list","data":[]}""") + ) + val client = + nativeMutualTlsClient( + arrayOf(clientLeaf.certificate), + fixture.clientTrust.trustManager, + fixture.baseUrl, + ) + + try { + assertThatThrownBy { client.files().list() } + .isInstanceOf(OpenAIIoException::class.java) + .hasCauseInstanceOf(IOException::class.java) + } finally { + client.close() + } + + assertThat(fixture.server.requestCount).isZero() + } + } + + private fun nativeMutualTlsClient( + chain: Array, + serverTrustManager: X509TrustManager, + baseUrl: String, + ): OpenAIClient { + val password = "test password".toCharArray() + val storedKeyStore = + KeyStore.getInstance("PKCS12").apply { + load(null, null) + setKeyEntry("client", clientLeaf.keyPair.private, password, chain) + } + val encodedKeyStore = + ByteArrayOutputStream().use { output -> + storedKeyStore.store(output, password) + output.toByteArray() + } + val loadedKeyStore = + KeyStore.getInstance("PKCS12").apply { + ByteArrayInputStream(encodedKeyStore).use { input -> load(input, password) } + } + val keyManagers = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { + init(loadedKeyStore, password) + } + val sslContext = + SSLContext.getInstance("TLS").apply { + init(keyManagers.keyManagers, arrayOf(serverTrustManager), null) + } + + return OpenAIOkHttpClient.builder() + .apiKey("test") + .baseUrl(baseUrl) + .followRedirects(false) + .sslSocketFactory(sslContext.socketFactory) + .trustManager(serverTrustManager) + .maxRetries(0) + .build() + } + + private fun mutuallyAuthenticatedServer(): MutualTlsServer { + val serverRoot = + HeldCertificate.Builder().commonName("server root").certificateAuthority(1).build() + val serverCertificate = + HeldCertificate.Builder() + .commonName("localhost") + .addSubjectAlternativeName("localhost") + .signedBy(serverRoot) + .build() + val serverIdentity = + HandshakeCertificates.Builder() + .heldCertificate(serverCertificate) + .addTrustedCertificate(clientRoot.certificate) + .build() + val clientTrust = + HandshakeCertificates.Builder().addTrustedCertificate(serverRoot.certificate).build() + val server = + MockWebServer().apply { + useHttps(serverIdentity.sslSocketFactory(), false) + requireClientAuth() + start() + } + return MutualTlsServer(server, clientTrust) + } + + private data class MutualTlsServer( + val server: MockWebServer, + val clientTrust: HandshakeCertificates, + ) : AutoCloseable { + val baseUrl: String + get() = server.url("/v1").toString().removeSuffix("/") + + override fun close() { + server.close() + } + } +} diff --git a/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java new file mode 100644 index 000000000..e6e0f9c93 --- /dev/null +++ b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java @@ -0,0 +1,89 @@ +package com.openai.example; + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyStore; +import java.util.Arrays; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +/** + * Configures mTLS through Java's native JSSE APIs and the SDK's existing OkHttp TLS hooks. + * + *

The PKCS#12 key store must contain the client private key and its certificate chain, ordered + * leaf first followed by any intermediate certificates. The OpenAI API key is still required. + */ +public final class MutualTlsExample { + private static final String MTLS_BASE_URL = "https://mtls.api.openai.com/v1"; + + private MutualTlsExample() {} + + public static void main(String[] args) throws Exception { + Path keyStorePath = Paths.get(requireEnv("OPENAI_MTLS_KEYSTORE")); + char[] password = requireEnv("OPENAI_MTLS_KEYSTORE_PASSWORD").toCharArray(); + + KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); + KeyManagerFactory keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + try { + try (InputStream input = Files.newInputStream(keyStorePath)) { + clientKeyStore.load(input, password); + } + keyManagers.init(clientKeyStore, password); + } finally { + Arrays.fill(password, '\0'); + } + + OpenAIClient client = null; + try { + // Client identity and server trust are separate. A null KeyStore retains the JVM's + // normal server-trust configuration. + TrustManagerFactory trustManagers = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagers.init((KeyStore) null); + X509TrustManager trustManager = findX509TrustManager(trustManagers); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagers.getKeyManagers(), new TrustManager[] {trustManager}, null); + + client = OpenAIOkHttpClient.builder() + .fromEnv() + // Native TLS configuration does not select an mTLS endpoint automatically. + .baseUrl(MTLS_BASE_URL) + // Avoid presenting the client identity to a redirect target. + .followRedirects(false) + .sslSocketFactory(sslContext.getSocketFactory()) + .trustManager(trustManager) + .build(); + + client.files().list(); + } finally { + if (client != null) { + client.close(); + } + } + } + + private static X509TrustManager findX509TrustManager(TrustManagerFactory trustManagerFactory) { + return Arrays.stream(trustManagerFactory.getTrustManagers()) + .filter(X509TrustManager.class::isInstance) + .map(X509TrustManager.class::cast) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "The default TrustManagerFactory did not provide an X509TrustManager")); + } + + private static String requireEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) { + throw new IllegalStateException(name + " must be set"); + } + return value; + } +} From c77f9ee6aff8a9247b07a490827c589b4a81352d Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 31 Jul 2026 13:35:40 -0700 Subject: [PATCH 2/8] docs: clarify mTLS configuration failures --- README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cc7859815..cc1d344a9 100644 --- a/README.md +++ b/README.md @@ -1725,13 +1725,21 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; -char[] password = System.getenv("OPENAI_MTLS_KEYSTORE_PASSWORD").toCharArray(); +String keyStorePath = System.getenv("OPENAI_MTLS_KEYSTORE"); +String keyStorePassword = System.getenv("OPENAI_MTLS_KEYSTORE_PASSWORD"); +if (keyStorePath == null || keyStorePath.isEmpty()) { + throw new IllegalStateException("OPENAI_MTLS_KEYSTORE must be set"); +} +if (keyStorePassword == null || keyStorePassword.isEmpty()) { + throw new IllegalStateException("OPENAI_MTLS_KEYSTORE_PASSWORD must be set"); +} + +char[] password = keyStorePassword.toCharArray(); KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); KeyManagerFactory keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); try { - try (InputStream input = - Files.newInputStream(Paths.get(System.getenv("OPENAI_MTLS_KEYSTORE")))) { + try (InputStream input = Files.newInputStream(Paths.get(keyStorePath))) { clientKeyStore.load(input, password); } keyManagers.init(clientKeyStore, password); @@ -1746,7 +1754,8 @@ X509TrustManager trustManager = Arrays.stream(trustManagers.getTrustManagers()) .filter(X509TrustManager.class::isInstance) .map(X509TrustManager.class::cast) .findFirst() - .orElseThrow(IllegalStateException::new); + .orElseThrow(() -> new IllegalStateException( + "The default TrustManagerFactory did not provide an X509TrustManager")); SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(keyManagers.getKeyManagers(), new TrustManager[] {trustManager}, null); From dbd0a37ccc63bebd9566c47519f247246881c4ac Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 31 Jul 2026 13:39:41 -0700 Subject: [PATCH 3/8] fix(docs): harden native mTLS configuration --- README.md | 38 +++++++++++++------ .../com/openai/example/MutualTlsExample.java | 30 +++++++++++++-- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index cc1d344a9..f00c4789b 100644 --- a/README.md +++ b/README.md @@ -1706,10 +1706,14 @@ To opt in, follow the guide to upload a CA certificate and activate it for your project or organization before configuring the client. -The PKCS#12 key store must contain the client private key and the complete certificate chain: -the leaf certificate first, followed by any intermediate certificates. Keep server trust separate -from that client identity. Initializing `TrustManagerFactory` with a null `KeyStore` retains the -JVM's normal server-trust configuration. +Certificate-chain support is a separate mTLS beta capability that is available by request. Contact +your Account Director or OpenAI Support to enable it. When it is enabled, the PKCS#12 key store must +contain the client private key and the complete certificate chain: the leaf certificate first, +followed by every required intermediate certificate. Without certificate-chain support, the client +leaf certificate must be directly signed by an active CA certificate that you uploaded to OpenAI. + +Keep server trust separate from the client identity. Initializing `TrustManagerFactory` with a null +`KeyStore` retains the JVM's normal server-trust configuration. ```java import com.openai.client.OpenAIClient; @@ -1725,6 +1729,16 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; +String apiKey = System.getProperty("openai.apiKey", System.getenv("OPENAI_API_KEY")); +if (apiKey == null || apiKey.isEmpty()) { + throw new IllegalStateException( + "openai.apiKey or OPENAI_API_KEY must be set for OpenAI mTLS"); +} +String baseUrl = System.getProperty("openai.baseUrl", System.getenv("OPENAI_BASE_URL")); +if (baseUrl == null || baseUrl.isEmpty()) { + baseUrl = "https://mtls.api.openai.com/v1"; +} + String keyStorePath = System.getenv("OPENAI_MTLS_KEYSTORE"); String keyStorePassword = System.getenv("OPENAI_MTLS_KEYSTORE_PASSWORD"); if (keyStorePath == null || keyStorePath.isEmpty()) { @@ -1761,9 +1775,9 @@ SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(keyManagers.getKeyManagers(), new TrustManager[] {trustManager}, null); OpenAIClient client = OpenAIOkHttpClient.builder() - .fromEnv() - // Native TLS configuration does not select an mTLS endpoint automatically. - .baseUrl("https://mtls.api.openai.com/v1") + // Set the OpenAI credential explicitly so an Azure key cannot be selected accidentally. + .apiKey(apiKey) + .baseUrl(baseUrl) // Avoid presenting the client identity to a redirect target. .followRedirects(false) .sslSocketFactory(sslContext.getSocketFactory()) @@ -1771,10 +1785,12 @@ OpenAIClient client = OpenAIOkHttpClient.builder() .build(); ``` -An explicit EU or custom endpoint can be used instead of the global mTLS endpoint. The SDK does not -inspect or rewrite that URL. To rotate the client identity, build a new `SSLContext`, HTTP transport, -and SDK client, then close the old client. This recipe applies to ordinary HTTP API-key traffic; it -does not add certificate-only authentication or Realtime/WebSocket mTLS support. +Set `openai.baseUrl` or `OPENAI_BASE_URL` to `https://mtls-eu.api.openai.com/v1` for EU Data +Residency, or to an appropriate custom mTLS gateway. If neither is set, the recipe uses +`https://mtls.api.openai.com/v1`. The SDK does not inspect or rewrite that URL. To rotate the client +identity, build a new `SSLContext`, HTTP transport, and SDK client, then close the old client. This +recipe applies to ordinary HTTP API-key traffic; it does not add certificate-only authentication or +Realtime/WebSocket mTLS support. See the complete, compilable [`MutualTlsExample`](openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java). diff --git a/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java index e6e0f9c93..39f91128b 100644 --- a/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java +++ b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java @@ -19,13 +19,20 @@ * *

The PKCS#12 key store must contain the client private key and its certificate chain, ordered * leaf first followed by any intermediate certificates. The OpenAI API key is still required. + * OpenAI certificate-chain verification requires separate enablement; without it, the leaf must be + * directly signed by an active CA certificate uploaded to OpenAI. */ public final class MutualTlsExample { - private static final String MTLS_BASE_URL = "https://mtls.api.openai.com/v1"; + private static final String DEFAULT_MTLS_BASE_URL = "https://mtls.api.openai.com/v1"; private MutualTlsExample() {} public static void main(String[] args) throws Exception { + String apiKey = requireConfiguredValue("openai.apiKey", "OPENAI_API_KEY"); + String baseUrl = configuredValue("openai.baseUrl", "OPENAI_BASE_URL"); + if (baseUrl == null || baseUrl.isEmpty()) { + baseUrl = DEFAULT_MTLS_BASE_URL; + } Path keyStorePath = Paths.get(requireEnv("OPENAI_MTLS_KEYSTORE")); char[] password = requireEnv("OPENAI_MTLS_KEYSTORE_PASSWORD").toCharArray(); @@ -53,9 +60,10 @@ public static void main(String[] args) throws Exception { sslContext.init(keyManagers.getKeyManagers(), new TrustManager[] {trustManager}, null); client = OpenAIOkHttpClient.builder() - .fromEnv() - // Native TLS configuration does not select an mTLS endpoint automatically. - .baseUrl(MTLS_BASE_URL) + // Select an OpenAI bearer credential explicitly; do not fall back to Azure. + .apiKey(apiKey) + // An explicit system property or environment variable preserves EU/custom routing. + .baseUrl(baseUrl) // Avoid presenting the client identity to a redirect target. .followRedirects(false) .sslSocketFactory(sslContext.getSocketFactory()) @@ -79,6 +87,20 @@ private static X509TrustManager findX509TrustManager(TrustManagerFactory trustMa "The default TrustManagerFactory did not provide an X509TrustManager")); } + private static String requireConfiguredValue(String propertyName, String environmentVariable) { + String value = configuredValue(propertyName, environmentVariable); + if (value == null || value.isEmpty()) { + throw new IllegalStateException( + propertyName + " or " + environmentVariable + " must be set for OpenAI mTLS"); + } + return value; + } + + private static String configuredValue(String propertyName, String environmentVariable) { + String value = System.getProperty(propertyName); + return value != null ? value : System.getenv(environmentVariable); + } + private static String requireEnv(String name) { String value = System.getenv(name); if (value == null || value.isEmpty()) { From e876c5ccf1fd187e6692a25bb2e2b4e417291594 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 31 Jul 2026 14:06:57 -0700 Subject: [PATCH 4/8] docs: preserve lazy mTLS config precedence --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f00c4789b..e9898003a 100644 --- a/README.md +++ b/README.md @@ -1729,12 +1729,18 @@ import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; -String apiKey = System.getProperty("openai.apiKey", System.getenv("OPENAI_API_KEY")); +String apiKey = System.getProperty("openai.apiKey"); +if (apiKey == null) { + apiKey = System.getenv("OPENAI_API_KEY"); +} if (apiKey == null || apiKey.isEmpty()) { throw new IllegalStateException( "openai.apiKey or OPENAI_API_KEY must be set for OpenAI mTLS"); } -String baseUrl = System.getProperty("openai.baseUrl", System.getenv("OPENAI_BASE_URL")); +String baseUrl = System.getProperty("openai.baseUrl"); +if (baseUrl == null) { + baseUrl = System.getenv("OPENAI_BASE_URL"); +} if (baseUrl == null || baseUrl.isEmpty()) { baseUrl = "https://mtls.api.openai.com/v1"; } From 1b2a8956889e8f21b0a548fab08aae1323c5676a Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 31 Jul 2026 14:25:47 -0700 Subject: [PATCH 5/8] docs: preserve mTLS request scope --- README.md | 11 +++++++++++ .../java/com/openai/example/MutualTlsExample.java | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/README.md b/README.md index e9898003a..15a274243 100644 --- a/README.md +++ b/README.md @@ -1744,6 +1744,14 @@ if (baseUrl == null) { if (baseUrl == null || baseUrl.isEmpty()) { baseUrl = "https://mtls.api.openai.com/v1"; } +String organization = System.getProperty("openai.orgId"); +if (organization == null) { + organization = System.getenv("OPENAI_ORG_ID"); +} +String project = System.getProperty("openai.projectId"); +if (project == null) { + project = System.getenv("OPENAI_PROJECT_ID"); +} String keyStorePath = System.getenv("OPENAI_MTLS_KEYSTORE"); String keyStorePassword = System.getenv("OPENAI_MTLS_KEYSTORE_PASSWORD"); @@ -1783,6 +1791,9 @@ sslContext.init(keyManagers.getKeyManagers(), new TrustManager[] {trustManager}, OpenAIClient client = OpenAIOkHttpClient.builder() // Set the OpenAI credential explicitly so an Azure key cannot be selected accidentally. .apiKey(apiKey) + // Preserve the organization and project scope selected by normal SDK configuration. + .organization(organization) + .project(project) .baseUrl(baseUrl) // Avoid presenting the client identity to a redirect target. .followRedirects(false) diff --git a/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java index 39f91128b..b9c46b323 100644 --- a/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java +++ b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java @@ -33,6 +33,8 @@ public static void main(String[] args) throws Exception { if (baseUrl == null || baseUrl.isEmpty()) { baseUrl = DEFAULT_MTLS_BASE_URL; } + String organization = configuredValue("openai.orgId", "OPENAI_ORG_ID"); + String project = configuredValue("openai.projectId", "OPENAI_PROJECT_ID"); Path keyStorePath = Paths.get(requireEnv("OPENAI_MTLS_KEYSTORE")); char[] password = requireEnv("OPENAI_MTLS_KEYSTORE_PASSWORD").toCharArray(); @@ -62,6 +64,9 @@ public static void main(String[] args) throws Exception { client = OpenAIOkHttpClient.builder() // Select an OpenAI bearer credential explicitly; do not fall back to Azure. .apiKey(apiKey) + // Retain the organization and project scope from normal SDK configuration. + .organization(organization) + .project(project) // An explicit system property or environment variable preserves EU/custom routing. .baseUrl(baseUrl) // Avoid presenting the client identity to a redirect target. From 1fb0ebbdcb13c1a1ce91f317f5c133411db3546b Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 31 Jul 2026 14:33:01 -0700 Subject: [PATCH 6/8] docs: fail closed on empty mTLS endpoint --- README.md | 5 ++++- .../src/main/java/com/openai/example/MutualTlsExample.java | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 15a274243..ef2237269 100644 --- a/README.md +++ b/README.md @@ -1741,8 +1741,11 @@ String baseUrl = System.getProperty("openai.baseUrl"); if (baseUrl == null) { baseUrl = System.getenv("OPENAI_BASE_URL"); } -if (baseUrl == null || baseUrl.isEmpty()) { +if (baseUrl == null) { baseUrl = "https://mtls.api.openai.com/v1"; +} else if (baseUrl.isEmpty()) { + throw new IllegalStateException( + "openai.baseUrl or OPENAI_BASE_URL must not be empty for OpenAI mTLS"); } String organization = System.getProperty("openai.orgId"); if (organization == null) { diff --git a/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java index b9c46b323..2c99daeee 100644 --- a/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java +++ b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java @@ -30,8 +30,10 @@ private MutualTlsExample() {} public static void main(String[] args) throws Exception { String apiKey = requireConfiguredValue("openai.apiKey", "OPENAI_API_KEY"); String baseUrl = configuredValue("openai.baseUrl", "OPENAI_BASE_URL"); - if (baseUrl == null || baseUrl.isEmpty()) { + if (baseUrl == null) { baseUrl = DEFAULT_MTLS_BASE_URL; + } else if (baseUrl.isEmpty()) { + throw new IllegalStateException("openai.baseUrl or OPENAI_BASE_URL must not be empty for OpenAI mTLS"); } String organization = configuredValue("openai.orgId", "OPENAI_ORG_ID"); String project = configuredValue("openai.projectId", "OPENAI_PROJECT_ID"); From 6d3aec0c042d9678eda6e7dfbdd99d8785a05d23 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 31 Jul 2026 14:37:55 -0700 Subject: [PATCH 7/8] docs: require HTTPS for mTLS endpoints --- README.md | 10 ++++++++++ .../java/com/openai/example/MutualTlsExample.java | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/README.md b/README.md index ef2237269..520f68a13 100644 --- a/README.md +++ b/README.md @@ -1719,6 +1719,7 @@ Keep server trust separate from the client identity. Initializing `TrustManagerF import com.openai.client.OpenAIClient; import com.openai.client.okhttp.OpenAIOkHttpClient; import java.io.InputStream; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Paths; import java.security.KeyStore; @@ -1747,6 +1748,15 @@ if (baseUrl == null) { throw new IllegalStateException( "openai.baseUrl or OPENAI_BASE_URL must not be empty for OpenAI mTLS"); } +URI baseUri; +try { + baseUri = URI.create(baseUrl); +} catch (IllegalArgumentException ignored) { + throw new IllegalStateException("OpenAI mTLS requires a valid HTTPS base URL"); +} +if (!"https".equalsIgnoreCase(baseUri.getScheme()) || baseUri.getRawAuthority() == null) { + throw new IllegalStateException("OpenAI mTLS requires a valid HTTPS base URL"); +} String organization = System.getProperty("openai.orgId"); if (organization == null) { organization = System.getenv("OPENAI_ORG_ID"); diff --git a/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java index 2c99daeee..f555c9b13 100644 --- a/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java +++ b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java @@ -3,6 +3,7 @@ import com.openai.client.OpenAIClient; import com.openai.client.okhttp.OpenAIOkHttpClient; import java.io.InputStream; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -35,6 +36,7 @@ public static void main(String[] args) throws Exception { } else if (baseUrl.isEmpty()) { throw new IllegalStateException("openai.baseUrl or OPENAI_BASE_URL must not be empty for OpenAI mTLS"); } + requireHttpsBaseUrl(baseUrl); String organization = configuredValue("openai.orgId", "OPENAI_ORG_ID"); String project = configuredValue("openai.projectId", "OPENAI_PROJECT_ID"); Path keyStorePath = Paths.get(requireEnv("OPENAI_MTLS_KEYSTORE")); @@ -108,6 +110,18 @@ private static String configuredValue(String propertyName, String environmentVar return value != null ? value : System.getenv(environmentVariable); } + private static void requireHttpsBaseUrl(String baseUrl) { + URI baseUri; + try { + baseUri = URI.create(baseUrl); + } catch (IllegalArgumentException ignored) { + throw new IllegalStateException("OpenAI mTLS requires a valid HTTPS base URL"); + } + if (!"https".equalsIgnoreCase(baseUri.getScheme()) || baseUri.getRawAuthority() == null) { + throw new IllegalStateException("OpenAI mTLS requires a valid HTTPS base URL"); + } + } + private static String requireEnv(String name) { String value = System.getenv(name); if (value == null || value.isEmpty()) { From 00dcd3d386cfc5939cf6a99edd2c2925145d457a Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Fri, 31 Jul 2026 14:44:40 -0700 Subject: [PATCH 8/8] test: assert presented mTLS certificate chain --- README.md | 1 + .../client/okhttp/OpenAIOkHttpClientNativeMutualTlsTest.kt | 5 ++++- .../src/main/java/com/openai/example/MutualTlsExample.java | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 520f68a13..4046941a0 100644 --- a/README.md +++ b/README.md @@ -1752,6 +1752,7 @@ URI baseUri; try { baseUri = URI.create(baseUrl); } catch (IllegalArgumentException ignored) { + // URI parse exceptions include the rejected value, which may contain credentials. throw new IllegalStateException("OpenAI mTLS requires a valid HTTPS base URL"); } if (!"https".equalsIgnoreCase(baseUri.getScheme()) || baseUri.getRawAuthority() == null) { diff --git a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientNativeMutualTlsTest.kt b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientNativeMutualTlsTest.kt index 21d791254..10a9a95ea 100644 --- a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientNativeMutualTlsTest.kt +++ b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientNativeMutualTlsTest.kt @@ -53,7 +53,10 @@ internal class OpenAIOkHttpClientNativeMutualTlsTest { client.close() } - assertThat(fixture.server.takeRequest().path).isEqualTo("/v1/files") + val request = fixture.server.takeRequest() + assertThat(request.path).isEqualTo("/v1/files") + assertThat(requireNotNull(request.handshake).peerCertificates) + .containsSubsequence(clientLeaf.certificate, clientIntermediate.certificate) } } diff --git a/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java index f555c9b13..c6dc38aa9 100644 --- a/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java +++ b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java @@ -115,6 +115,7 @@ private static void requireHttpsBaseUrl(String baseUrl) { try { baseUri = URI.create(baseUrl); } catch (IllegalArgumentException ignored) { + // URI parse exceptions include the rejected value, which may contain credentials. throw new IllegalStateException("OpenAI mTLS requires a valid HTTPS base URL"); } if (!"https".equalsIgnoreCase(baseUri.getScheme()) || baseUri.getRawAuthority() == null) {