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
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,48 @@ OpenAIClient client = OpenAIOkHttpClient.builder()
.build();
```

#### X.509 client certificate authentication

Applications with a client certificate can exchange that certificate directly for short-lived
OpenAI access tokens without providing a JWT or implementing `SubjectTokenProvider`:

```java
import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.client.okhttp.X509Transport;
import com.openai.client.okhttp.X509WorkloadIdentity;
import java.time.Duration;
import javax.net.ssl.X509ExtendedKeyManager;
import javax.net.ssl.X509TrustManager;

X509ExtendedKeyManager keyManager = /* load your PKCS#12 key manager */;
X509TrustManager trustManager = /* load your trusted server roots */;

X509Transport transport = X509Transport.builder()
.keyManager(keyManager)
.certificateAlias("client-certificate")
.trustManager(trustManager)
.build();

X509WorkloadIdentity identity = X509WorkloadIdentity.builder()
.identityProviderId("your-identity-provider-id")
.serviceAccountId("your-service-account-id")
.transport(transport)
.refreshBuffer(Duration.ofMinutes(10)) // Optional; defaults to 20 minutes.
.build();

OpenAIClient client = OpenAIOkHttpClient.builder()
.x509WorkloadIdentity(identity)
.build();
```

`OpenAIOkHttpClientAsync.builder()` supports the same `x509WorkloadIdentity` option. Tokens are
obtained lazily, cached, and refreshed before expiration. Both the token exchange and API requests
use the configured fixed certificate alias, isolated direct mutual-TLS connections, and native
hostname verification; redirects and custom transport settings are not supported. Close the SDK
client to release both connection pools. For a complete compilable PKCS#12 setup, see
[`X509WorkloadIdentityExample`](openai-java-example/src/main/java/com/openai/example/X509WorkloadIdentityExample.java).

#### Kubernetes service account token provider

```java
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ class OpenAIOkHttpClient private constructor() {
private var sslSocketFactory: SSLSocketFactory? = null
private var trustManager: X509TrustManager? = null
private var hostnameVerifier: HostnameVerifier? = null
private var x509WorkloadIdentity: X509WorkloadIdentity? = null
private var x509BaseUrl: String? = null

/**
* The executor service to use for running HTTP requests.
Expand Down Expand Up @@ -252,7 +254,10 @@ class OpenAIOkHttpClient private constructor() {
*
* Defaults to the production environment: `https://api.openai.com/v1`.
*/
fun baseUrl(baseUrl: String?) = apply { clientOptions.baseUrl(baseUrl) }
fun baseUrl(baseUrl: String?) = apply {
clientOptions.baseUrl(baseUrl)
x509BaseUrl = baseUrl
}

/** Alias for calling [Builder.baseUrl] with `baseUrl.orElse(null)`. */
fun baseUrl(baseUrl: Optional<String>) = baseUrl(baseUrl.getOrNull())
Expand Down Expand Up @@ -352,6 +357,11 @@ class OpenAIOkHttpClient private constructor() {
fun workloadIdentity(workloadIdentity: Optional<WorkloadIdentity>) =
workloadIdentity(workloadIdentity.getOrNull())

/** Authenticates requests using a fixed X.509 client certificate instead of an API key. */
fun x509WorkloadIdentity(x509WorkloadIdentity: X509WorkloadIdentity) = apply {
this.x509WorkloadIdentity = x509WorkloadIdentity
}

fun azureServiceVersion(azureServiceVersion: AzureOpenAIServiceVersion) = apply {
clientOptions.azureServiceVersion(azureServiceVersion)
}
Expand Down Expand Up @@ -463,7 +473,12 @@ class OpenAIOkHttpClient private constructor() {
*
* @see ClientOptions.Builder.fromEnv
*/
fun fromEnv() = apply { clientOptions.fromEnv() }
fun fromEnv() = apply {
clientOptions.fromEnv()
(System.getProperty("openai.baseUrl") ?: System.getenv("OPENAI_BASE_URL"))?.let {
x509BaseUrl = it
}
}

/**
* Returns an immutable instance of [OpenAIClient].
Expand All @@ -472,22 +487,37 @@ class OpenAIOkHttpClient private constructor() {
*/
fun build(): OpenAIClient =
OpenAIClientImpl(
clientOptions
.httpClient(
OkHttpClient.builder()
.timeout(clientOptions.timeout())
.followRedirects(followRedirects)
.proxy(proxy)
.proxyAuthenticator(proxyAuthenticator)
.maxIdleConnections(maxIdleConnections)
.keepAliveDuration(keepAliveDuration)
.dispatcherExecutorService(dispatcherExecutorService)
.sslSocketFactory(sslSocketFactory)
.trustManager(trustManager)
.hostnameVerifier(hostnameVerifier)
.build()
)
.build()
x509WorkloadIdentity?.let { identity ->
require(
proxy == null &&
proxyAuthenticator == null &&
maxIdleConnections == null &&
keepAliveDuration == null &&
dispatcherExecutorService == null &&
sslSocketFactory == null &&
trustManager == null &&
hostnameVerifier == null
) {
"X.509 workload identity cannot be combined with custom transport settings"
}
x509ClientOptions(clientOptions, identity, x509BaseUrl)
}
?: clientOptions
.httpClient(
OkHttpClient.builder()
.timeout(clientOptions.timeout())
.followRedirects(followRedirects)
.proxy(proxy)
.proxyAuthenticator(proxyAuthenticator)
.maxIdleConnections(maxIdleConnections)
.keepAliveDuration(keepAliveDuration)
.dispatcherExecutorService(dispatcherExecutorService)
.sslSocketFactory(sslSocketFactory)
.trustManager(trustManager)
.hostnameVerifier(hostnameVerifier)
.build()
)
.build()
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ class OpenAIOkHttpClientAsync private constructor() {
private var sslSocketFactory: SSLSocketFactory? = null
private var trustManager: X509TrustManager? = null
private var hostnameVerifier: HostnameVerifier? = null
private var x509WorkloadIdentity: X509WorkloadIdentity? = null
private var x509BaseUrl: String? = null

/**
* The executor service to use for running HTTP requests.
Expand Down Expand Up @@ -252,7 +254,10 @@ class OpenAIOkHttpClientAsync private constructor() {
*
* Defaults to the production environment: `https://api.openai.com/v1`.
*/
fun baseUrl(baseUrl: String?) = apply { clientOptions.baseUrl(baseUrl) }
fun baseUrl(baseUrl: String?) = apply {
clientOptions.baseUrl(baseUrl)
x509BaseUrl = baseUrl
}

/** Alias for calling [Builder.baseUrl] with `baseUrl.orElse(null)`. */
fun baseUrl(baseUrl: Optional<String>) = baseUrl(baseUrl.getOrNull())
Expand Down Expand Up @@ -352,6 +357,11 @@ class OpenAIOkHttpClientAsync private constructor() {
fun workloadIdentity(workloadIdentity: Optional<WorkloadIdentity>) =
workloadIdentity(workloadIdentity.getOrNull())

/** Authenticates requests using a fixed X.509 client certificate instead of an API key. */
fun x509WorkloadIdentity(x509WorkloadIdentity: X509WorkloadIdentity) = apply {
this.x509WorkloadIdentity = x509WorkloadIdentity
}

fun azureServiceVersion(azureServiceVersion: AzureOpenAIServiceVersion) = apply {
clientOptions.azureServiceVersion(azureServiceVersion)
}
Expand Down Expand Up @@ -463,7 +473,12 @@ class OpenAIOkHttpClientAsync private constructor() {
*
* @see ClientOptions.Builder.fromEnv
*/
fun fromEnv() = apply { clientOptions.fromEnv() }
fun fromEnv() = apply {
clientOptions.fromEnv()
(System.getProperty("openai.baseUrl") ?: System.getenv("OPENAI_BASE_URL"))?.let {
x509BaseUrl = it
}
}

/**
* Returns an immutable instance of [OpenAIClientAsync].
Expand All @@ -472,22 +487,37 @@ class OpenAIOkHttpClientAsync private constructor() {
*/
fun build(): OpenAIClientAsync =
OpenAIClientAsyncImpl(
clientOptions
.httpClient(
OkHttpClient.builder()
.timeout(clientOptions.timeout())
.followRedirects(followRedirects)
.proxy(proxy)
.proxyAuthenticator(proxyAuthenticator)
.maxIdleConnections(maxIdleConnections)
.keepAliveDuration(keepAliveDuration)
.dispatcherExecutorService(dispatcherExecutorService)
.sslSocketFactory(sslSocketFactory)
.trustManager(trustManager)
.hostnameVerifier(hostnameVerifier)
.build()
)
.build()
x509WorkloadIdentity?.let { identity ->
require(
proxy == null &&
proxyAuthenticator == null &&
maxIdleConnections == null &&
keepAliveDuration == null &&
dispatcherExecutorService == null &&
sslSocketFactory == null &&
trustManager == null &&
hostnameVerifier == null
) {
"X.509 workload identity cannot be combined with custom transport settings"
}
x509ClientOptions(clientOptions, identity, x509BaseUrl)
}
?: clientOptions
.httpClient(
OkHttpClient.builder()
.timeout(clientOptions.timeout())
.followRedirects(followRedirects)
.proxy(proxy)
.proxyAuthenticator(proxyAuthenticator)
.maxIdleConnections(maxIdleConnections)
.keepAliveDuration(keepAliveDuration)
.dispatcherExecutorService(dispatcherExecutorService)
.sslSocketFactory(sslSocketFactory)
.trustManager(trustManager)
.hostnameVerifier(hostnameVerifier)
.build()
)
.build()
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.openai.client.okhttp

import com.openai.core.Timeout
import com.openai.core.checkRequired
import java.net.Proxy
import java.time.Duration

/** Configuration for workload identity authenticated with a fixed X.509 client certificate. */
class X509WorkloadIdentity
private constructor(
/** Identity provider resource ID. */
@get:JvmName("identityProviderId") val identityProviderId: String,
/** Service account ID associated with the certificate identity. */
@get:JvmName("serviceAccountId") val serviceAccountId: String,
/** Caller-owned, fixed-alias mutual TLS configuration. */
@get:JvmName("transport") val transport: X509Transport,
/** How early the SDK refreshes an access token. Defaults to 20 minutes. */
@get:JvmName("refreshBuffer") val refreshBuffer: Duration,
private val exchangeTestProxy: Proxy? = null,
private val apiTestProxy: Proxy? = null,
) {

companion object {
@JvmStatic fun builder() = Builder()
}

/** A Java-compatible builder for certificate-based workload identity. */
class Builder internal constructor() {

private var identityProviderId: String? = null
private var serviceAccountId: String? = null
private var transport: X509Transport? = null
private var refreshBuffer: Duration = Duration.ofMinutes(20)

/** Sets the identity provider resource ID. */
fun identityProviderId(identityProviderId: String) = apply {
this.identityProviderId = identityProviderId
}

/** Sets the service account ID associated with the certificate identity. */
fun serviceAccountId(serviceAccountId: String) = apply {
this.serviceAccountId = serviceAccountId
}

/** Sets the existing fixed-alias X.509 mutual TLS transport capability. */
fun transport(transport: X509Transport) = apply { this.transport = transport }

/** Sets how early a cached token should be refreshed. Defaults to 20 minutes. */
fun refreshBuffer(refreshBuffer: Duration) = apply {
require(!refreshBuffer.isNegative) { "refreshBuffer must not be negative" }
this.refreshBuffer = refreshBuffer
}

/** Returns immutable certificate-based workload identity configuration. */
fun build(): X509WorkloadIdentity =
X509WorkloadIdentity(
checkRequired("identityProviderId", identityProviderId).also {
require(it.isNotBlank()) { "identityProviderId must not be blank" }
},
checkRequired("serviceAccountId", serviceAccountId).also {
require(it.isNotBlank()) { "serviceAccountId must not be blank" }
},
checkRequired("transport", transport),
refreshBuffer,
)
}

@JvmSynthetic
internal fun bind(timeout: Timeout): BoundX509Transport =
if (exchangeTestProxy == null || apiTestProxy == null) transport.bind(timeout)
else transport.bindForTest(timeout, exchangeTestProxy, apiTestProxy)

/** Test-only loopback CONNECT seam; production bindings always connect directly. */
@JvmSynthetic
internal fun withTestProxies(exchangeProxy: Proxy, apiProxy: Proxy): X509WorkloadIdentity =
X509WorkloadIdentity(
identityProviderId,
serviceAccountId,
transport,
refreshBuffer,
exchangeProxy,
apiProxy,
)
}
Loading
Loading