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
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,49 @@ jobs:
./scripts/build
node --experimental-strip-types scripts/test-packed-package.ts

- name: Prepare isolated minimum-runtime package and synthetic certificate
if: matrix.node-version == 22
run: |
package_tarball="$(npm pack "$GITHUB_WORKSPACE/dist" --ignore-scripts --silent --pack-destination "$RUNNER_TEMP")"
echo "X509_FLOOR_PACKAGE=${RUNNER_TEMP}/${package_tarball}" >> "$GITHUB_ENV"
node -r ts-node/register/transpile-only --eval '
const { writeFileSync } = require("node:fs");
const { join } = require("node:path");
const { createX509TestLab } = require("./tests/utils/x509-test-lab.ts");
const { firstClient } = createX509TestLab();
writeFileSync(join(process.env.RUNNER_TEMP, "openai-x509-minimum-fixture.json"), JSON.stringify({ certificateChain: firstClient.certificate.toString(), privateKey: firstClient.privateKey.toString() }));
'

- name: Set up exact minimum supported Node.js runtime
if: matrix.node-version == 22
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.0.0'

- name: Verify first-class X.509 authentication on the exact Node.js floor
if: matrix.node-version == 22
run: |
floor_consumer="${RUNNER_TEMP}/openai-node-minimum-runtime"
npm install --ignore-scripts --no-audit --no-fund --prefix "$floor_consumer" "$X509_FLOOR_PACKAGE" 'undici@^7'
cd "$floor_consumer"
node --input-type=module --eval '
import { readFileSync } from "node:fs";
import { join } from "node:path";
import OpenAI from "openai";
import { Agent, ProxyAgent } from "undici";
import { createX509Transport, fromX509, workloadIdentity } from "openai/auth/x509-transport";
if (process.version !== "v22.0.0" || workloadIdentity.fromX509 !== fromX509) throw new Error("Invalid minimum-runtime X.509 exports");
const material = JSON.parse(readFileSync(join(process.env.RUNNER_TEMP, "openai-x509-minimum-fixture.json"), "utf8"));
const credential = fromX509({ ...material, identityProviderId: "synthetic-provider", serviceAccountId: "synthetic-account" });
new OpenAI({ credential });
await credential.close();
for (const [proxy, dispatcher] of [["direct", new Agent()], ["http-connect", new ProxyAgent({ uri: "http://127.0.0.1:1" })], ["https-connect", new ProxyAgent({ uri: "https://127.0.0.1:1" })]]) {
const x509Transport = createX509Transport({ runtime: "node", dispatcher, certificateIdentity: "static", proxy });
new OpenAI({ apiKey: null, workloadIdentity: { type: "x509", identityProviderId: "synthetic-provider", serviceAccountId: "synthetic-account" }, x509Transport });
await dispatcher.close();
}
'

test_matrix:
name: ${{ (github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork || github.event.action == 'ready_for_review') && 'test matrix' || 'test matrix (not run)' }}
if: ${{ always() && (github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork || github.event.action == 'ready_for_review') }}
Expand Down
43 changes: 14 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,47 +202,32 @@ 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.
Applications enrolled for X.509 workload identity can authenticate using a certificate-backed credential 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';
import { workloadIdentity } from 'openai/auth/x509-transport';

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',
}),
const credential = workloadIdentity.fromX509({
certificateChain: process.env['OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM']!,
privateKey: process.env['OPENAI_X509_CLIENT_PRIVATE_KEY_PEM']!,
identityProviderId: process.env['OPENAI_X509_IDENTITY_PROVIDER_ID']!,
serviceAccountId: process.env['OPENAI_X509_SERVICE_ACCOUNT_ID']!,
});

try {
const client = new OpenAI({
credential,
project: process.env['OPENAI_X509_PROJECT_ID'] ?? null,
});

console.log((await client.models.list()).data.length);
} finally {
await dispatcher.close();
await credential.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).
The SDK owns the credential's verified TLS transport, caches short-lived tokens in memory, isolates certificate generations, and bounds retries and cancellation. Set `refreshBufferSeconds` on the credential to configure proactive refresh; it defaults to 1,200 seconds (20 minutes) and is capped at half of the token's 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

Expand Down
42 changes: 13 additions & 29 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,53 +100,37 @@ console.log(response.output_text);

### X.509 client certificates

Enrolled Node.js applications can authenticate using a caller-owned, static
client certificate instead of a subject-token provider. Install the optional
Undici transport peer with `npm install openai "undici@^7"`, provide the full
PEM certificate chain and private key, and create an explicitly attested
transport:
Enrolled Node.js applications can authenticate using an SDK-owned certificate
credential instead of a subject-token provider. Install the optional Undici peer
with `npm install openai "undici@^7"` and provide the full PEM certificate chain,
matching private key, and enrolled account selectors:

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

const dispatcher = new Agent({
connect: {
cert: process.env['OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM'],
key: process.env['OPENAI_X509_CLIENT_PRIVATE_KEY_PEM'],
},
const credential = workloadIdentity.fromX509({
certificateChain: process.env['OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM']!,
privateKey: process.env['OPENAI_X509_CLIENT_PRIVATE_KEY_PEM']!,
identityProviderId: process.env['OPENAI_X509_IDENTITY_PROVIDER_ID']!,
serviceAccountId: process.env['OPENAI_X509_SERVICE_ACCOUNT_ID']!,
});

try {
const client = new OpenAI({
apiKey: null,
adminAPIKey: null,
baseURL: null,
organization: null,
credential,
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',
}),
});

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

X.509 authentication supports only `https://mtls.api.openai.com/v1`. Azure,
Bedrock, custom gateways, data-residency overrides, browsers, and WebSocket
transports are unsupported. The application owns and closes its dispatcher.
transports are unsupported. Call `credential.close()` when requests have drained.

### Kubernetes

Expand Down
8 changes: 4 additions & 4 deletions examples/mtls/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Each example requests `models.list()` and prints the number of returned models.
Install Undici alongside the SDK:

```sh
npm install openai undici
npm install openai "undici@^7"
node node.mjs
```

Expand Down Expand Up @@ -52,7 +52,7 @@ Because the SDK does not own the mTLS transport, applications can use runtime-na

## 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.
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 SDK-owned, verified certificate transport. Create the credential with `workloadIdentity.fromX509({ certificateChain, privateKey, identityProviderId, serviceAccountId })` and pass it to `new OpenAI({ credential })`. The resulting access token is an ordinary bearer credential, so protect it like any other secret; mTLS does not cryptographically bind the token to its 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:

Expand All @@ -77,12 +77,12 @@ 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.
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. Prefer `https_connect` when the proxy URL includes credentials: `http_connect` sends proxy authentication unencrypted to the explicitly selected proxy and must only be used on a trusted network. The default `direct` mode ignores ambient proxy variables. The SDK requires verified target and proxy TLS, validates the proxy protocol, and configures the workload certificate only for target TLS. The example closes its SDK-owned credential 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.
This check fails without owner-provisioned, enrolled credentials. Rotate a certificate by constructing a new `workloadIdentity.fromX509` credential and creating or cloning the client with it; close the previous credential after in-flight requests finish.
22 changes: 22 additions & 0 deletions examples/mtls/base-url.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** Accepts only the documented OpenAI certificate-bearing API origins. */
export function mtlsBaseURL(configured) {
let url;
try {
url = new URL(configured ?? 'https://mtls.api.openai.com/v1');
} catch {
throw new Error('OPENAI_BASE_URL must be a documented OpenAI HTTPS mTLS endpoint.');
}

if (
(url.origin !== 'https://mtls.api.openai.com' && url.origin !== 'https://mtls-eu.api.openai.com') ||
(url.pathname !== '/v1' && url.pathname !== '/v1/') ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error('OPENAI_BASE_URL must be a documented OpenAI HTTPS mTLS endpoint.');
}

return `${url.origin}/v1`;
}
4 changes: 3 additions & 1 deletion examples/mtls/bun.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
// stays in Bun's native fetch so the SDK can use its existing transport hooks.

import OpenAI from 'openai';
import { mtlsBaseURL } from './base-url.mjs';

const baseURL = mtlsBaseURL(process.env['OPENAI_BASE_URL']);
const cert = Bun.file(requiredEnv('OPENAI_MTLS_CERT_PATH'));
const key = Bun.file(requiredEnv('OPENAI_MTLS_KEY_PATH'));

const client = new OpenAI({
apiKey: requiredEnv('OPENAI_API_KEY'),
baseURL: process.env['OPENAI_BASE_URL'] ?? 'https://mtls.api.openai.com/v1',
baseURL,
fetch: (input, init) =>
fetch(input, {
...init,
Expand Down
20 changes: 11 additions & 9 deletions examples/mtls/deno.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@
// stays in Deno's HTTP client so the SDK can use its existing transport hooks.

import OpenAI from 'npm:openai';
import { mtlsBaseURL } from './base-url.mjs';

const baseURL = mtlsBaseURL(Deno.env.get('OPENAI_BASE_URL'));
const cert = await Deno.readTextFile(requiredEnv('OPENAI_MTLS_CERT_PATH'));
const key = await Deno.readTextFile(requiredEnv('OPENAI_MTLS_KEY_PATH'));
const httpClient = Deno.createHttpClient(clientCertificateOptions(cert, key));

const client = new OpenAI({
apiKey: requiredEnv('OPENAI_API_KEY'),
baseURL: Deno.env.get('OPENAI_BASE_URL') ?? 'https://mtls.api.openai.com/v1',
fetch: (input, init) => fetch(input, { ...init, client: httpClient }),
fetchOptions: {
redirect: 'manual',
},
});

try {
const client = new OpenAI({
apiKey: requiredEnv('OPENAI_API_KEY'),
baseURL,
fetch: (input, init) => fetch(input, { ...init, client: httpClient }),
fetchOptions: {
redirect: 'manual',
},
});

const models = await client.models.list();
console.log('mTLS request succeeded; received ' + models.data.length + ' models.');
} finally {
Expand Down
24 changes: 13 additions & 11 deletions examples/mtls/node.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import { readFile } from 'node:fs/promises';
import OpenAI from 'openai';
import { Agent, fetch as undiciFetch } from 'undici';
import { mtlsBaseURL } from './base-url.mjs';

const baseURL = mtlsBaseURL(process.env['OPENAI_BASE_URL']);
const cert = await readFile(requiredEnv('OPENAI_MTLS_CERT_PATH'));
const key = await readFile(requiredEnv('OPENAI_MTLS_KEY_PATH'));
const passphrase = process.env['OPENAI_MTLS_KEY_PASSPHRASE'];
Expand All @@ -15,21 +17,21 @@ const dispatcher = new Agent({
connect: {
cert,
key,
...(passphrase ? { passphrase } : {}),
},
});

const client = new OpenAI({
apiKey: requiredEnv('OPENAI_API_KEY'),
baseURL: process.env['OPENAI_BASE_URL'] ?? 'https://mtls.api.openai.com/v1',
fetch: undiciFetch,
fetchOptions: {
dispatcher,
redirect: 'manual',
...(passphrase === undefined ? {} : { passphrase }),
},
});

try {
const client = new OpenAI({
apiKey: requiredEnv('OPENAI_API_KEY'),
baseURL,
fetch: undiciFetch,
fetchOptions: {
dispatcher,
redirect: 'manual',
},
});

const models = await client.models.list();
console.log('mTLS request succeeded; received ' + models.data.length + ' models.');
} finally {
Expand Down
Loading
Loading