Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a065575
feat(auth): integrate attested X.509 workload identity into clients
jbeckwith-oai Aug 25, 2026
650ee54
fix(auth): close X.509 retry, cancellation, and WebSocket review gaps
jbeckwith-oai Aug 25, 2026
bc9050a
fix(auth): preserve caller cancellation with supported TypeScript floors
jbeckwith-oai Aug 25, 2026
524bc42
fix(auth): preserve X.509 clone identity and one-shot request bodies
jbeckwith-oai Aug 25, 2026
ccf6fd9
fix(auth): expose x509 identity with supported client options
jbeckwith-oai Aug 25, 2026
eb4e72a
fix(auth): secure x509 request snapshots and terminal cleanup
jbeckwith-oai Aug 25, 2026
04778b5
fix(auth): start x509 deadlines after request options resolve
jbeckwith-oai Aug 25, 2026
c960838
fix(auth): bind x509 preflight to dispatch url and body lifetime
jbeckwith-oai Aug 25, 2026
f6cdaca
fix(auth): document and stabilize initial x509 client contract
jbeckwith-oai Aug 25, 2026
67b4165
fix(auth): snapshot x509 signals and one-shot hook bodies
jbeckwith-oai Aug 25, 2026
e2f32d5
fix(auth): bind x509 hooks to immutable request settings
jbeckwith-oai Aug 26, 2026
d27455e
fix(auth): preserve approved x509 request boundaries
jbeckwith-oai Aug 26, 2026
54b474d
fix(auth): validate x509 request before credential exchange
jbeckwith-oai Aug 26, 2026
501a3a3
fix(auth): bind x509 attempt deadline cancellation and body ownership
jbeckwith-oai Aug 26, 2026
d880268
fix x509 terminal response cleanup and legacy stream declarations
jbeckwith-oai Aug 26, 2026
cde2098
fix x509 caller cancellation precedence at response deadline
jbeckwith-oai Aug 26, 2026
414d9ed
refactor x509 terminal response cancellation guard
jbeckwith-oai Aug 26, 2026
d7e6c96
fix x509 delayed cancellation and replaced upload ownership
jbeckwith-oai Aug 26, 2026
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
55 changes: 53 additions & 2 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ The matching options are `apiKey`, `adminAPIKey`, `organization`, `project`, and

## Workload identity

Workload identity exchanges a short-lived cloud identity token for an OpenAI
access token. Configure the external identity provider and OpenAI service
Workload identity exchanges either a short-lived cloud identity token or an
enrolled X.509 client certificate for an OpenAI access token. For the
subject-token flow, configure the external identity provider and OpenAI service
account first, then provide:

- `identityProviderId`: Your OpenAI identity-provider resource ID.
Expand Down Expand Up @@ -97,6 +98,56 @@ const response = await client.responses.create({
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:

```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'],
},
});

try {
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',
}),
});

console.log((await client.models.list()).data.length);
} finally {
await dispatcher.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.

### Kubernetes

`k8sServiceAccountTokenProvider()` reads the mounted Kubernetes service account
Expand Down
14 changes: 11 additions & 3 deletions scripts/test-packed-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,17 +502,25 @@ const packedPackagePath = require('node:path');
for (const [inputType, consumer] of [
[
'commonjs',
"const { Agent } = require('undici'); const { createX509Transport } = require('openai/auth/x509-transport');",
"const OpenAI = require('openai'); const { Agent } = require('undici'); const { createX509Transport } = require('openai/auth/x509-transport');",
],
[
'module',
"import { Agent } from 'undici'; import { createX509Transport } from 'openai/auth/x509-transport';",
"import OpenAI from 'openai'; import { Agent } from 'undici'; import { createX509Transport } from 'openai/auth/x509-transport';",
],
[
'module',
"import OpenAI from 'openai'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const { Agent } = require('undici'); const { createX509Transport } = require('openai/auth/x509-transport');",
],
[
'module',
"import { createRequire } from 'node:module'; import { Agent } from 'undici'; import { createX509Transport } from 'openai/auth/x509-transport'; const OpenAI = createRequire(import.meta.url)('openai');",
],
]) {
run(process.execPath, [
`--input-type=${inputType}`,
'-e',
`${consumer} const dispatcher = new Agent(); const transport = createX509Transport({ runtime: 'node', dispatcher, certificateIdentity: 'static', proxy: 'direct' }); if (!Object.isFrozen(transport)) throw new Error('X.509 transport capability is not frozen'); dispatcher.close();`,
`${consumer} const dispatcher = new Agent(); const transport = createX509Transport({ runtime: 'node', dispatcher, certificateIdentity: 'static', proxy: 'direct' }); if (!Object.isFrozen(transport)) throw new Error('X.509 transport capability is not frozen'); new OpenAI({ apiKey: null, workloadIdentity: { type: 'x509', identityProviderId: 'synthetic-provider', serviceAccountId: 'synthetic-account' }, x509Transport: transport }); dispatcher.close();`,
]);
}

Expand Down
7 changes: 6 additions & 1 deletion src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export type { WorkloadIdentity, SubjectTokenProvider, TokenExchangeResponse } from './types';
export type {
WorkloadIdentity,
X509WorkloadIdentity,
SubjectTokenProvider,
TokenExchangeResponse,
} from './types';

export {
k8sServiceAccountTokenProvider,
Expand Down
21 changes: 21 additions & 0 deletions src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ export interface WorkloadIdentity {
refreshBufferSeconds?: number;
}

/** Configures OpenAI-only federation from an explicitly attested X.509 client certificate. */
export interface X509WorkloadIdentity {
/** Selects certificate-authenticated OAuth federation without an external subject token. */
type: 'x509';

/** Identity-provider resource enrolled for the caller-owned client certificate. */
identityProviderId: string;

/** OpenAI service account authorized for the verified certificate identity. */
serviceAccountId: string;

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

/** X.509 federation does not send an OAuth client identifier. */
clientId?: never;

/** X.509 federation does not accept subject-token refresh configuration. */
refreshBufferSeconds?: never;
}

/** OAuth token-exchange response returned by the OpenAI workload-identity endpoint. */
export interface TokenExchangeResponse {
/** OpenAI access token to use as the request's bearer credential. */
Expand Down
15 changes: 14 additions & 1 deletion src/auth/x509-transport.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout as delay } from 'node:timers/promises';
import {
createX509Transport as createCapability,
registerX509Transport,
sendX509Request,
} from '../internal/auth/x509-transport-capability';
import type { X509Transport, X509TransportOptions } from '../internal/auth/x509-transport-capability';
import { exchangeX509Token } from '../internal/auth/x509-token-exchange';
import { isRetryableX509TransportFailure } from '../internal/auth/x509-transport-registry';
import type { X509RequestScope } from '../internal/auth/x509-transport-registry';
import { markTransientX509ConnectionError } from '#x509-transport-state';

/** Creates one frozen, caller-attested Node.js transport for X.509 workload authentication. */
export function createX509Transport(options: X509TransportOptions): X509Transport {
const capability = createCapability(options);
const scopes = new AsyncLocalStorage<X509RequestScope>();
registerX509Transport(capability, {
dispatch: async (target, requestOptions) => await sendX509Request(capability, target, requestOptions),
dispatch: async (target, requestOptions) => {
try {
return await sendX509Request(capability, target, requestOptions);
} catch (error) {
if (error instanceof Error && isRetryableX509TransportFailure(error)) {
markTransientX509ConnectionError(error);
}
throw error;
}
},
exchange: async (identityProviderId, serviceAccountId, signal) =>
await exchangeX509Token({
transport: capability,
Expand All @@ -25,6 +37,7 @@ export function createX509Transport(options: X509TransportOptions): X509Transpor
scopes.run({ wallStartedAt: Date.now(), monotonicStartedAt: performance.now() }, operation),
current: () => scopes.getStore(),
resume: (scope, operation) => scopes.run(scope, operation),
sleep: async (duration, signal) => await delay(duration, undefined, { signal: signal ?? undefined }),
});
return capability;
}
Expand Down
12 changes: 11 additions & 1 deletion src/azure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@ import { isObj, readEnv } from './internal/utils';
import { path } from './internal/utils/path';
import { OpenAI } from './client';
import type { ClientOptions } from './client';
import type { WorkloadIdentity } from './auth/types';
import { assertNoDataResidency } from './internal/data-residency';

/** API Client for interfacing with the Azure OpenAI API. */
export interface AzureClientOptions extends Omit<ClientOptions, 'provider' | 'dataResidency'> {
export interface AzureClientOptions extends Omit<
ClientOptions,
'provider' | 'dataResidency' | 'workloadIdentity' | 'x509Transport'
> {
/** AzureOpenAI does not support third-party provider configuration. */
provider?: never;

/** OpenAI data residency cannot be combined with Azure routing. */
dataResidency?: never;

/** Azure cannot receive OpenAI X.509 workload-identity certificate transports. */
x509Transport?: never;

/** Existing subject-token workload-identity configuration remains unchanged. */
workloadIdentity?: WorkloadIdentity | undefined;

/**
* Defaults to process.env['OPENAI_API_VERSION'].
*/
Expand Down
8 changes: 6 additions & 2 deletions src/bedrock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type * as ResponsesAPI from './resources/responses/responses';
/** Configures Amazon Bedrock's OpenAI-compatible endpoint and bearer-token authentication. */
export interface BedrockClientOptions extends Omit<
ClientOptions,
'apiKey' | 'adminAPIKey' | 'baseURL' | 'workloadIdentity' | 'dataResidency'
'apiKey' | 'adminAPIKey' | 'baseURL' | 'workloadIdentity' | 'x509Transport' | 'dataResidency'
> {
/**
* Bedrock bearer token used for authentication.
Expand Down Expand Up @@ -51,6 +51,9 @@ export interface BedrockClientOptions extends Omit<
*/
workloadIdentity?: never;

/** Bedrock cannot receive OpenAI X.509 workload-identity certificate transports. */
x509Transport?: never;

/**
* AWS region used to derive the default Bedrock Mantle endpoint.
*
Expand Down Expand Up @@ -142,11 +145,12 @@ export class BedrockOpenAI extends OpenAI {
bedrockTokenProvider,
adminAPIKey,
workloadIdentity,
x509Transport,
dataResidency,
...opts
}: BedrockClientOptions = {}) {
assertNoDataResidency(dataResidency, 'BedrockOpenAI');
if (adminAPIKey || workloadIdentity) {
if (adminAPIKey || workloadIdentity || x509Transport) {
throw new Errors.OpenAIError('BedrockOpenAI only supports Bedrock bearer token authentication.');
}

Expand Down
2 changes: 2 additions & 0 deletions src/beta/realtime/internal-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EventEmitter } from '../../lib/EventEmitter';
import { OpenAIError } from '../../error';
import type OpenAI from '../../index';
import { AzureOpenAI } from '../../index';
import { assertX509WebSocketSupported } from '../../internal/auth/x509-workload-identity-auth';

/** Parses frame data without exposing malformed payloads through JSON syntax errors. */
export function parseRealtimeEvent(data: string): RealtimeServerEvent {
Expand Down Expand Up @@ -177,6 +178,7 @@ export function buildRealtimeURL(
client: Pick<OpenAI, 'apiKey' | 'baseURL'>,
connection: string | RealtimeConnectionConfig,
): URL {
assertX509WebSocketSupported(client);
const config: RealtimeConnectionConfig =
typeof connection === 'string' ? { model: connection } : connection;
const baseURL = client.baseURL;
Expand Down
Loading
Loading