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
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ For secure, automated environments like cloud-managed Kubernetes, Azure, and GCP

The `workloadIdentity` parameter is mutually exclusive with `apiKey`.

The required fields are `identityProviderId`, `serviceAccountId`, and `provider`.
For subject-token workload identity, the required fields are `identityProviderId`, `serviceAccountId`, and `provider`. X.509 workload identity instead uses an enrolled client certificate and its separately configured transport.

### Kubernetes (service account tokens)

Expand Down Expand Up @@ -184,7 +184,7 @@ const client = new OpenAI({
});
```

You can also customize the token refresh buffer (default is 1200 seconds (20 minutes) before expiration):
You can also customize the subject-token refresh buffer (default is 1200 seconds (20 minutes) before expiration):

```ts
import OpenAI from 'openai';
Expand All @@ -200,6 +200,50 @@ const client = new OpenAI({
});
```

### X.509 client certificates

Applications enrolled for X.509 workload identity can authenticate using a caller-owned, static client certificate instead of a subject-token provider or API key. This Node.js-only integration requires the optional `undici` peer and currently supports only the global `https://mtls.api.openai.com/v1` API endpoint.

```ts
import OpenAI from 'openai';
import { createX509Transport } from 'openai/auth/x509-transport';
import { Agent } from 'undici';

const dispatcher = new Agent({
connect: {
cert: process.env['OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM'],
key: process.env['OPENAI_X509_CLIENT_PRIVATE_KEY_PEM'],
},
});

const client = new OpenAI({
apiKey: null,
adminAPIKey: null,
baseURL: null,
organization: null,
project: process.env['OPENAI_X509_PROJECT_ID'] ?? null,
workloadIdentity: {
type: 'x509',
identityProviderId: process.env['OPENAI_X509_IDENTITY_PROVIDER_ID']!,
serviceAccountId: process.env['OPENAI_X509_SERVICE_ACCOUNT_ID']!,
},
x509Transport: createX509Transport({
runtime: 'node',
dispatcher,
certificateIdentity: 'static',
proxy: 'direct',
}),
});

try {
console.log((await client.models.list()).data.length);
} finally {
await dispatcher.close();
}
```

The SDK caches short-lived credentials in memory, isolates certificate generations, bounds retries and cancellation, and never closes the caller-owned dispatcher. Configure proactive refresh with optional `workloadIdentity.refreshBufferMs`; it defaults to 1,200,000 milliseconds (20 minutes) and is capped at half of the actual token lifetime. For CONNECT proxies, encrypted private keys, live verification, and certificate rotation, see the [X.509 workload-identity example](./examples/mtls/README.md#x509-workload-identity-nodejs).

## Streaming responses

We provide support for streaming responses using Server-Sent Events (SSE).
Expand Down
37 changes: 37 additions & 0 deletions examples/mtls/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,40 @@ The Bun example passes `tls: { cert, key }` to Bun's native `fetch`.
## Advanced transports

Because the SDK does not own the mTLS transport, applications can use runtime-native features such as proxies, custom trust stores, encrypted keys, hardware-backed keys, or certificate rotation where their chosen HTTP client supports them. Keep the certificate-bearing transport scoped to the OpenAI mTLS endpoint, and close or replace it when rotating credentials.

## X.509 workload identity (Node.js)

X.509 workload identity is separate from API-key + HTTP mTLS: an enrolled client certificate authenticates a workload-identity token exchange, and the resulting short-lived bearer authenticates requests through the same caller-owned certificate transport. The resulting access token is an ordinary bearer credential, so protect it like any other secret; reusing the approved certificate transport does not cryptographically bind the token to that certificate. Only `https://mtls.api.openai.com/v1` is approved; EU, Azure, Bedrock, custom gateways, and data-residency overrides are not supported.

Install the SDK and its optional Node.js transport peer:

```sh
npm install openai "undici@^7"
```

Undici 7 supports the SDK's complete Node.js 22 compatibility range; Undici 8 requires Node.js 22.19 or later.

Provide the complete PEM certificate chain, private key, enrolled identity-provider ID, and service-account ID through environment variables. The chain must contain the leaf certificate followed by any required intermediates:

```sh
export OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM="$(cat /secure/path/client-chain.pem)"
export OPENAI_X509_CLIENT_PRIVATE_KEY_PEM="$(cat /secure/path/client-key.pem)"
export OPENAI_X509_IDENTITY_PROVIDER_ID='your-enrolled-identity-provider-id'
export OPENAI_X509_SERVICE_ACCOUNT_ID='your-enrolled-service-account-id'

# Build package self-imports when running the example from an SDK repository checkout.
pnpm build
node examples/mtls/x509-workload-identity.mjs
```

Set `OPENAI_X509_CLIENT_KEY_PASSPHRASE` when the PEM private key is encrypted. Existing local fixtures can instead provide certificate and key paths through `OPENAI_MTLS_CERT_CHAIN` and `OPENAI_MTLS_KEY`, identity selectors through `OPENAI_IDENTITY_PROVIDER_ID` and `OPENAI_SERVICE_ACCOUNT_ID`, and an optional tenant through `OPENAI_X509_PROJECT_ID`. The example ignores ambient API keys, admin keys, base URLs, organizations, and ordinary API-key projects so only the selected X.509 identity and tenant determine the request. Keep private-key files readable only by their owner, use managed secret injection where available, and never log PEM contents, passphrases, issued bearer tokens, or proxy credentials.

Proxying is always explicit: set `OPENAI_X509_PROXY_MODE=http_connect` or `OPENAI_X509_PROXY_MODE=https_connect` together with a matching `HTTPS_PROXY` URL. The default `direct` mode ignores ambient proxy variables. The workload certificate is configured only for target TLS, never proxy TLS. The example closes its caller-owned dispatcher after the request.

From a repository checkout, the following command builds the SDK first and then runs the same explicit live-service check:

```sh
pnpm test:live:x509
```

This check fails without owner-provisioned, enrolled credentials. Rotate a certificate by constructing a new Undici dispatcher and `createX509Transport` capability, then creating or cloning the client with that new top-level `x509Transport`; close the previous dispatcher after in-flight requests finish.
102 changes: 102 additions & 0 deletions examples/mtls/x509-workload-identity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env node

// Uses one caller-owned static client certificate for both OpenAI's issuer and API.
import { readFile } from 'node:fs/promises';
import { Agent, ProxyAgent } from 'undici';

const cert = await requiredPem('OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM', 'OPENAI_MTLS_CERT_CHAIN');
const key = await requiredPem('OPENAI_X509_CLIENT_PRIVATE_KEY_PEM', 'OPENAI_MTLS_KEY');
const passphrase = process.env['OPENAI_X509_CLIENT_KEY_PASSPHRASE'];
const requestTls = { cert, key, ...(passphrase === undefined ? {} : { passphrase }) };
const proxyMode = process.env['OPENAI_X509_PROXY_MODE'] ?? 'direct';
const proxy = new Map([
['direct', 'direct'],
['http_connect', 'http-connect'],
['https_connect', 'https-connect'],
]).get(proxyMode);
if (!proxy) {
throw new Error('OPENAI_X509_PROXY_MODE must be direct, http_connect, or https_connect.');
}
const proxyURL = proxy === 'direct' ? undefined : approvedProxyURL(requiredEnv('HTTPS_PROXY', 'https_proxy'));
if (proxyURL && proxyURL.protocol !== (proxy === 'https-connect' ? 'https:' : 'http:')) {
throw new Error('OPENAI_X509_PROXY_MODE must match the HTTPS_PROXY protocol.');
}
const identityProviderId = requiredEnv('OPENAI_X509_IDENTITY_PROVIDER_ID', 'OPENAI_IDENTITY_PROVIDER_ID');
const serviceAccountId = requiredEnv('OPENAI_X509_SERVICE_ACCOUNT_ID', 'OPENAI_SERVICE_ACCOUNT_ID');
const [{ default: OpenAI }, { createX509Transport }] = await Promise.all([
import('openai'),
import('openai/auth/x509-transport'),
]);
const dispatcher = createDispatcher(proxyURL, requestTls);
try {
const x509Transport = createX509Transport({
runtime: 'node',
dispatcher,
certificateIdentity: 'static',
proxy,
});
const client = new OpenAI({
apiKey: null,
adminAPIKey: null,
baseURL: null,
organization: null,
project: process.env['OPENAI_X509_PROJECT_ID'] ?? null,
workloadIdentity: {
type: 'x509',
identityProviderId,
serviceAccountId,
},
x509Transport,
});

const models = await client.models.list();
console.log(`X.509 workload identity succeeded; received ${models.data.length} models.`);
} finally {
await dispatcher.close();
}

function requiredEnv(name, alternative) {
const value = process.env[name] ?? (alternative ? process.env[alternative] : undefined);
if (!value) {
throw new Error(
`Missing required X.509 environment variable: ${name}${alternative ? ` or ${alternative}` : ''}`,
);
}
return value;
}

async function requiredPem(valueName, pathName) {
const value = process.env[valueName];
if (value) {
return value;
}
const certificatePath = process.env[pathName];
if (!certificatePath) {
throw new Error(`Missing required X.509 environment variable: ${valueName} or ${pathName}`);
}
return await readFile(certificatePath, 'utf-8');
}

function approvedProxyURL(value) {
let url;
try {
url = new URL(value);
} catch {
throw new Error('HTTPS_PROXY must contain a valid CONNECT proxy URL.');
}
if (url.pathname !== '/' || url.search || url.hash) {
throw new Error('HTTPS_PROXY must not contain a path, query, or fragment.');
}
return url;
}

function createDispatcher(approvedURL, targetTls) {
if (!approvedURL) {
return new Agent({ connect: targetTls });
}
try {
return new ProxyAgent({ uri: approvedURL.href, requestTls: targetTls });
} catch {
throw new Error('Unable to initialize the approved X.509 CONNECT proxy.');
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"test:unit": "vitest run --config vitest.config.mts",
"test:generated": "OPENAI_TEST_SUITE=generated ./scripts/test",
"test:live:bedrock": "jest --config jest.live.config.ts --runInBand tests/live/bedrock.live.test.ts",
"test:live:x509": "pnpm build && node --input-type=module < examples/mtls/x509-workload-identity.mjs",
"bench": "vitest bench --run --config vitest.bench.config.mts",
"bench:json": "vitest bench --run --config vitest.bench.config.mts --outputJson benchmark-results.json",
"build": "./scripts/build",
Expand Down
3 changes: 3 additions & 0 deletions src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export interface X509WorkloadIdentity {
/** OpenAI service account authorized for the verified certificate identity. */
serviceAccountId: string;

/** Optional milliseconds before expiry when the certificate-backed token should refresh. */
refreshBufferMs?: number;

/** X.509 federation proves certificate possession instead of supplying a subject token. */
provider?: never;

Expand Down
Loading
Loading