diff --git a/README.md b/README.md index b6788a580..4046941a0 100644 --- a/README.md +++ b/README.md @@ -1696,6 +1696,136 @@ 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. + +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; +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; +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; + +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"); +if (baseUrl == null) { + baseUrl = System.getenv("OPENAI_BASE_URL"); +} +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"); +} +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) { + 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"); +} +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"); +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(keyStorePath))) { + 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(() -> new IllegalStateException( + "The default TrustManagerFactory did not provide an X509TrustManager")); + +SSLContext sslContext = SSLContext.getInstance("TLS"); +sslContext.init(keyManagers.getKeyManagers(), new TrustManager[] {trustManager}, null); + +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) + .sslSocketFactory(sslContext.getSocketFactory()) + .trustManager(trustManager) + .build(); +``` + +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). + ### 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..10a9a95ea --- /dev/null +++ b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientNativeMutualTlsTest.kt @@ -0,0 +1,165 @@ +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() + } + + val request = fixture.server.takeRequest() + assertThat(request.path).isEqualTo("/v1/files") + assertThat(requireNotNull(request.handshake).peerCertificates) + .containsSubsequence(clientLeaf.certificate, clientIntermediate.certificate) + } + } + + @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..c6dc38aa9 --- /dev/null +++ b/openai-java-example/src/main/java/com/openai/example/MutualTlsExample.java @@ -0,0 +1,133 @@ +package com.openai.example; + +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; +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. + * 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 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 = DEFAULT_MTLS_BASE_URL; + } 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")); + 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() + // 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. + .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 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 void requireHttpsBaseUrl(String baseUrl) { + 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) { + 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()) { + throw new IllegalStateException(name + " must be set"); + } + return value; + } +}