Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions openai-java-client-okhttp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<X509Certificate>,
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<TrustManager>(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()
}
}
}
Loading
Loading