From 5485297da224b52e208bfa16b45fc3b005ab488b Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 21:01:26 +0000 Subject: [PATCH 1/6] fix(auth): snapshot X.509 token-exchange caller signal once --- src/internal/auth/x509-token-exchange.ts | 10 +++++----- tests/auth/x509-token-exchange.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/internal/auth/x509-token-exchange.ts b/src/internal/auth/x509-token-exchange.ts index c988cfedcc..eb76f270f7 100644 --- a/src/internal/auth/x509-token-exchange.ts +++ b/src/internal/auth/x509-token-exchange.ts @@ -201,8 +201,8 @@ async function oauthError(response: Response, signal?: AbortSignal): Promise { - options.signal?.throwIfAborted(); - const { identityProviderId, serviceAccountId } = options; + const { identityProviderId, serviceAccountId, signal: callerSignal, transport } = options; + callerSignal?.throwIfAborted(); if ( typeof identityProviderId !== 'string' || identityProviderId.trim().length === 0 || @@ -215,8 +215,8 @@ export async function exchangeX509Token(options: X509TokenExchangeOptions): Prom } const timeoutController = new AbortController(); - const signal = options.signal - ? AbortSignal.any([options.signal, timeoutController.signal]) + const signal = callerSignal + ? AbortSignal.any([callerSignal, timeoutController.signal]) : timeoutController.signal; const timeout = setTimeout(() => { timeoutController.abort( @@ -235,7 +235,7 @@ export async function exchangeX509Token(options: X509TokenExchangeOptions): Prom let response: Response; try { - response = await sendX509Request(options.transport, TOKEN_EXCHANGE_URL, { + response = await sendX509Request(transport, TOKEN_EXCHANGE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, diff --git a/tests/auth/x509-token-exchange.test.ts b/tests/auth/x509-token-exchange.test.ts index 2860e07d6f..eb9605e1f5 100644 --- a/tests/auth/x509-token-exchange.test.ts +++ b/tests/auth/x509-token-exchange.test.ts @@ -400,6 +400,28 @@ describe('isolated X.509 workload-identity token exchange', () => { expect(send).not.toHaveBeenCalled(); }); + test('snapshots an accessor-provided caller signal before validating and composing it', async () => { + const controller = new AbortController(); + const cancellation = new Error('synthetic-snapshotted-signal-cancellation'); + const signal = vi.fn(() => (signal.mock.calls.length === 1 ? controller.signal : undefined)); + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async () => { + controller.abort(cancellation); + return new Response(null, { status: 503 }); + }); + + await expect( + exchangeX509Token({ + transport, + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + get signal() { + return signal(); + }, + }), + ).rejects.toBe(cancellation); + expect(signal).toHaveBeenCalledTimes(1); + }); + test.each([307, 408, 429, 503])( 'preserves cancellation racing issuer response headers with status %i', async (status) => { From 2d3b9e09cf0066db7b6d5f9e4cbc3fcfc2495d5b Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 21:08:02 +0000 Subject: [PATCH 2/6] fix(auth): reject canceled X.509 exchanges before reading selectors --- src/internal/auth/x509-token-exchange.ts | 3 ++- tests/auth/x509-token-exchange.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/internal/auth/x509-token-exchange.ts b/src/internal/auth/x509-token-exchange.ts index eb76f270f7..e474d8b732 100644 --- a/src/internal/auth/x509-token-exchange.ts +++ b/src/internal/auth/x509-token-exchange.ts @@ -201,8 +201,9 @@ async function oauthError(response: Response, signal?: AbortSignal): Promise { - const { identityProviderId, serviceAccountId, signal: callerSignal, transport } = options; + const { signal: callerSignal } = options; callerSignal?.throwIfAborted(); + const { identityProviderId, serviceAccountId, transport } = options; if ( typeof identityProviderId !== 'string' || identityProviderId.trim().length === 0 || diff --git a/tests/auth/x509-token-exchange.test.ts b/tests/auth/x509-token-exchange.test.ts index eb9605e1f5..a58a302427 100644 --- a/tests/auth/x509-token-exchange.test.ts +++ b/tests/auth/x509-token-exchange.test.ts @@ -400,6 +400,29 @@ describe('isolated X.509 workload-identity token exchange', () => { expect(send).not.toHaveBeenCalled(); }); + test('checks a canceled caller signal before evaluating identity or transport accessors', async () => { + const controller = new AbortController(); + const cancellation = new Error('synthetic-preflight-cancellation'); + const identityGetter = vi.fn(() => 'synthetic-identity-provider'); + const transportGetter = vi.fn(() => transport); + controller.abort(cancellation); + + await expect( + exchangeX509Token({ + get identityProviderId() { + return identityGetter(); + }, + serviceAccountId: 'synthetic-service-account', + get transport() { + return transportGetter(); + }, + signal: controller.signal, + }), + ).rejects.toBe(cancellation); + expect(identityGetter).not.toHaveBeenCalled(); + expect(transportGetter).not.toHaveBeenCalled(); + }); + test('snapshots an accessor-provided caller signal before validating and composing it', async () => { const controller = new AbortController(); const cancellation = new Error('synthetic-snapshotted-signal-cancellation'); From 661d9342554dd04f1ed23674324f0f03d4b8b0e6 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 18:50:00 +0000 Subject: [PATCH 3/6] feat(auth): attest cross-module X.509 transport capabilities --- jest.config.ts | 2 + package.json | 8 +++ scripts/test-packed-package.ts | 22 +++++++ src/auth/index.ts | 7 +- src/auth/types.ts | 24 +++++++ src/auth/x509-transport.ts | 32 ++++++++- src/internal/auth/x509-token-exchange.ts | 13 +--- .../auth/x509-transport-capability.ts | 30 ++++++--- src/internal/auth/x509-transport-registry.ts | 63 ++++++++++++++++++ src/internal/auth/x509-transport-state.cts | 8 +++ tests/auth/x509-transport.test.ts | 66 ++++++++++++++++++- 11 files changed, 254 insertions(+), 21 deletions(-) create mode 100644 src/internal/auth/x509-transport-registry.ts create mode 100644 src/internal/auth/x509-transport-state.cts diff --git a/jest.config.ts b/jest.config.ts index f0ab0c693d..e3685deee3 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -10,8 +10,10 @@ const config: JestConfigWithTsJest = { ), transform: { '^.+\\.(t|j)sx?$': ['@swc/jest', { sourceMaps: 'inline' }], + '^.+\\.cts$': ['@swc/jest', { sourceMaps: 'inline' }], }, moduleNameMapper: { + '(^|/)x509-transport-state\\.cjs$': '/src/internal/auth/x509-transport-state.cts', '^openai$': '/src/index.ts', '^openai/(.*)$': '/src/$1', }, diff --git a/package.json b/package.json index 5b4c5a7226..b831127417 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,14 @@ "import": "./dist/index.mjs", "require": "./dist/index.js" }, + "./internal/auth/x509-transport-capability": null, + "./internal/auth/x509-transport-capability.js": null, + "./internal/auth/x509-transport-capability.mjs": null, + "./internal/auth/x509-transport-registry": null, + "./internal/auth/x509-transport-registry.js": null, + "./internal/auth/x509-transport-registry.mjs": null, + "./internal/auth/x509-transport-state": null, + "./internal/auth/x509-transport-state.cjs": null, "./*.mjs": { "default": "./dist/*.mjs" }, diff --git a/scripts/test-packed-package.ts b/scripts/test-packed-package.ts index 0f39dc7650..3fd361b498 100644 --- a/scripts/test-packed-package.ts +++ b/scripts/test-packed-package.ts @@ -217,6 +217,28 @@ const packedPackagePath = require('node:path'); tarball, ]); + const privateX509Modules = [ + 'openai/internal/auth/x509-transport-capability', + 'openai/internal/auth/x509-transport-capability.js', + 'openai/internal/auth/x509-transport-capability.mjs', + 'openai/internal/auth/x509-transport-registry', + 'openai/internal/auth/x509-transport-registry.js', + 'openai/internal/auth/x509-transport-registry.mjs', + 'openai/internal/auth/x509-transport-state', + 'openai/internal/auth/x509-transport-state.cjs', + ]; + const moduleNames = JSON.stringify(privateX509Modules); + run(process.execPath, [ + '--input-type=commonjs', + '--eval', + `for (const name of ${moduleNames}) { try { require(name); throw new Error(name + ' is publicly accessible'); } catch (error) { if (error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; } }`, + ]); + run(process.execPath, [ + '--input-type=module', + '--eval', + `for (const name of ${moduleNames}) { try { await import(name); throw new Error(name + ' is publicly accessible'); } catch (error) { if (error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; } }`, + ]); + const unsupportedDispatcher = 'assert.throws(direct, /Undici 5\\.2\\.0 or later/u); assert.throws(httpConnect, /Undici 5\\.2\\.0 or later/u); assert.throws(httpsConnect, /Undici 5\\.2\\.0 or later/u);'; const unsupportedProxy = diff --git a/src/auth/index.ts b/src/auth/index.ts index f0cc83316c..06981c3348 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,4 +1,9 @@ -export type { WorkloadIdentity, SubjectTokenProvider, TokenExchangeResponse } from './types'; +export type { + WorkloadIdentity, + X509WorkloadIdentity, + SubjectTokenProvider, + TokenExchangeResponse, +} from './types'; export { k8sServiceAccountTokenProvider, diff --git a/src/auth/types.ts b/src/auth/types.ts index 4099702a31..8f2ebe3175 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -25,6 +25,30 @@ 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; + + /** 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; + + /** X.509 federation does not send an OAuth client identifier. */ + clientId?: never; + + /** X.509 refresh configuration is expressed in milliseconds. */ + 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. */ diff --git a/src/auth/x509-transport.ts b/src/auth/x509-transport.ts index f8efd214b4..b69ce1e2ef 100644 --- a/src/auth/x509-transport.ts +++ b/src/auth/x509-transport.ts @@ -1,4 +1,34 @@ -export { createX509Transport } from '../internal/auth/x509-transport-capability'; +import { AsyncLocalStorage } from 'node:async_hooks'; +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 type { X509RequestScope } from '../internal/auth/x509-transport-registry'; + +/** 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(); + registerX509Transport(capability, { + dispatch: async (target, requestOptions) => await sendX509Request(capability, target, requestOptions), + exchange: async (identityProviderId, serviceAccountId, signal) => + await exchangeX509Token({ + transport: capability, + identityProviderId, + serviceAccountId, + ...(signal ? { signal } : {}), + }), + run: (operation) => + scopes.run({ wallStartedAt: Date.now(), monotonicStartedAt: performance.now() }, operation), + current: () => scopes.getStore(), + resume: (scope, operation) => scopes.run(scope, operation), + }); + return capability; +} + export type { X509ProxyMode, X509Transport, diff --git a/src/internal/auth/x509-token-exchange.ts b/src/internal/auth/x509-token-exchange.ts index e474d8b732..b65ef326de 100644 --- a/src/internal/auth/x509-token-exchange.ts +++ b/src/internal/auth/x509-token-exchange.ts @@ -7,7 +7,9 @@ import { } from '../../core/error'; import { hasOwn } from '../utils/values'; import { sendX509Request } from './x509-transport-capability'; -import type { X509Transport } from './x509-transport-capability'; +import type { X509ExchangedToken, X509Transport } from './x509-transport-registry'; + +export type { X509ExchangedToken } from './x509-transport-registry'; const TOKEN_EXCHANGE_URL = new URL('https://mtls.auth.openai.com/oauth/token'); const TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'; @@ -33,15 +35,6 @@ export interface X509TokenExchangeOptions { signal?: AbortSignal | undefined; } -/** Fully validated, short-lived OAuth credential returned by the X.509 issuer. */ -export interface X509ExchangedToken { - /** Header-safe OAuth bearer token; never persisted or included in error messages. */ - accessToken: string; - - /** Positive issuer-granted lifetime in seconds, never exceeding one hour. */ - expiresIn: number; -} - async function cancelReader( reader: ReadableStreamDefaultReader, reason?: unknown, diff --git a/src/internal/auth/x509-transport-capability.ts b/src/internal/auth/x509-transport-capability.ts index 8b76f78184..463452aba6 100644 --- a/src/internal/auth/x509-transport-capability.ts +++ b/src/internal/auth/x509-transport-capability.ts @@ -1,17 +1,14 @@ import { types } from 'node:util'; import { Agent, ProxyAgent, Request, fetch } from 'undici'; +import { OpenAIError } from '../../core/error'; +import type { RegisteredX509Transport, X509Transport, x509TransportBrand } from './x509-transport-registry'; +import { findRegisteredX509Transport, rememberRegisteredX509Transport } from './x509-transport-state.cjs'; -declare const x509TransportBrand: unique symbol; +export type { X509Transport } from './x509-transport-registry'; /** Explicitly supported, application-owned Undici proxy configurations. */ export type X509ProxyMode = 'direct' | 'http-connect' | 'https-connect'; -/** An opaque, immutable X.509 transport identity created by {@link createX509Transport}. */ -export interface X509Transport { - /** Prevents ordinary objects from being accepted as X.509 transport capabilities. */ - readonly [x509TransportBrand]: true; -} - /** Application attestation for one caller-owned static-certificate Undici transport. */ export interface X509TransportOptions { /** X.509 transport currently supports genuine Node.js runtimes only. */ @@ -30,7 +27,7 @@ export interface X509TransportOptions { const allowedOptionNames = new Set(['runtime', 'dispatcher', 'certificateIdentity', 'proxy']); class NodeX509Transport implements X509Transport { - declare readonly [x509TransportBrand]: true; + declare readonly __opaqueX509Transport: typeof x509TransportBrand; readonly #dispatcher: Agent | ProxyAgent; @@ -44,6 +41,23 @@ class NodeX509Transport implements X509Transport { } } +/** Registers only a genuine frozen capability whose JavaScript private dispatcher cannot be forged. */ +export function registerX509Transport(transport: X509Transport, registered: RegisteredX509Transport): void { + if ( + !transport || + typeof transport !== 'object' || + types.isProxy(transport) || + !Object.isFrozen(transport) || + !NodeX509Transport.dispatcher(transport) + ) { + throw new OpenAIError('Only a genuine frozen X.509 transport capability can be registered.'); + } + if (findRegisteredX509Transport(transport)) { + throw new OpenAIError('An approved X.509 transport capability cannot be registered more than once.'); + } + rememberRegisteredX509Transport(transport, Object.freeze(registered)); +} + function dataOption(options: X509TransportOptions, name: keyof X509TransportOptions): unknown { const descriptor = Object.getOwnPropertyDescriptor(options, name); if (!descriptor || !('value' in descriptor)) { diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts new file mode 100644 index 0000000000..3dd291aef3 --- /dev/null +++ b/src/internal/auth/x509-transport-registry.ts @@ -0,0 +1,63 @@ +import { OpenAIError } from '../../core/error'; +import { findRegisteredX509Transport } from './x509-transport-state.cjs'; + +export declare const x509TransportBrand: unique symbol; + +/** Opaque identity of one frozen, caller-owned, explicitly attested X.509 transport. */ +export interface X509Transport { + /** Prevents ordinary objects from satisfying the transport capability contract. */ + readonly __opaqueX509Transport: typeof x509TransportBrand; +} + +/** Validated short-lived token exchanged using a registered certificate identity. */ +export interface X509ExchangedToken { + /** Header-safe, in-memory OpenAI bearer credential. */ + accessToken: string; + + /** Issuer-approved positive token lifetime in seconds. */ + expiresIn: number; +} + +/** One logical certificate-authenticated operation and its immutable starting deadline. */ +export interface X509RequestScope { + wallStartedAt: number; + monotonicStartedAt: number; + token?: string; + headers?: Headers; + authorization?: string | null; +} + +/** Private, peer-independent operations attached to one approved transport generation. */ +export interface RegisteredX509Transport { + /** Dispatches one request through the exact frozen certificate capability. */ + dispatch: (target: URL, options: RequestInit) => Promise; + + /** Exchanges the same certificate identity for an OpenAI service-account bearer. */ + exchange: ( + identityProviderId: string, + serviceAccountId: string, + signal?: AbortSignal, + ) => Promise; + + /** Establishes one isolated Node async context without exposing Node dependencies to the root SDK. */ + run: (operation: () => T) => T; + + /** Resolves only the current logical request's private certificate-authentication scope. */ + current: () => X509RequestScope | undefined; + + /** Re-enters the original request scope when response parsing resumes outside its promise chain. */ + resume: (scope: X509RequestScope, operation: () => T) => T; +} + +/** Resolves a previously registered opaque capability without importing an optional transport peer. */ +export function resolveX509Transport(value: unknown): RegisteredX509Transport { + if (!value || typeof value !== 'object') { + throw new OpenAIError('X.509 workload identity requires an approved X.509 transport capability.'); + } + + const registered: RegisteredX509Transport | undefined = findRegisteredX509Transport(value); + if (!registered) { + throw new OpenAIError('X.509 workload identity requires an approved X.509 transport capability.'); + } + return registered; +} diff --git a/src/internal/auth/x509-transport-state.cts b/src/internal/auth/x509-transport-state.cts new file mode 100644 index 0000000000..037b61dab6 --- /dev/null +++ b/src/internal/auth/x509-transport-state.cts @@ -0,0 +1,8 @@ +/** One lexical capability registry remains authoritative across mixed module formats. */ +const registeredX509Transports = new WeakMap(); + +/** Looks up an opaque capability without exposing the registry itself. */ +export const findRegisteredX509Transport = WeakMap.prototype.get.bind(registeredX509Transports); + +/** Records a capability only after the Node-only factory verifies its genuine private dispatcher. */ +export const rememberRegisteredX509Transport = WeakMap.prototype.set.bind(registeredX509Transports); diff --git a/tests/auth/x509-transport.test.ts b/tests/auth/x509-transport.test.ts index 1e2fffd4ad..cb14d7d175 100644 --- a/tests/auth/x509-transport.test.ts +++ b/tests/auth/x509-transport.test.ts @@ -6,7 +6,7 @@ import { vi } from 'vitest'; import { createX509Transport } from 'openai/auth/x509-transport'; import type { X509Transport, X509TransportOptions } from 'openai/auth/x509-transport'; -import { sendX509Request } from 'openai/internal/auth/x509-transport-capability'; +import { registerX509Transport, sendX509Request } from 'openai/internal/auth/x509-transport-capability'; import { closeObservedServers, @@ -68,6 +68,70 @@ describe('explicit X.509 transport capability', () => { } }); + test('rejects counterfeit capability registration before attacker callbacks can be installed', () => { + const dispatch = vi.fn(async () => Response.json({ data: [] })); + const exchange = vi.fn(async () => ({ accessToken: 'synthetic-forged-token', expiresIn: 3600 })); + + expect(() => + registerX509Transport(Object.freeze({}) as X509Transport, { + dispatch, + exchange, + run: (operation) => operation(), + current: vi.fn(), + resume: (_scope, operation) => operation(), + }), + ).toThrow(/genuine.*capability/iu); + expect(dispatch).not.toHaveBeenCalled(); + expect(exchange).not.toHaveBeenCalled(); + }); + + test('rejects replacement callbacks for an already-approved transport capability', async () => { + const ownedDispatcher = new Agent(); + const dispatch = vi.fn(async () => Response.json({ data: [] })); + const exchange = vi.fn(async () => ({ accessToken: 'synthetic-stolen-token', expiresIn: 3600 })); + + try { + const approved = createX509Transport(directOptions(ownedDispatcher)); + expect(() => + registerX509Transport(approved, { + dispatch, + exchange, + run: (operation) => operation(), + current: vi.fn(), + resume: (_scope, operation) => operation(), + }), + ).toThrow(/more than once/iu); + expect(dispatch).not.toHaveBeenCalled(); + expect(exchange).not.toHaveBeenCalled(); + } finally { + await ownedDispatcher.close(); + } + }); + + test('rejects a counterfeit that copies a genuine transport prototype without its private dispatcher', async () => { + const ownedDispatcher = new Agent(); + const dispatch = vi.fn(async () => Response.json({ data: [] })); + const exchange = vi.fn(async () => ({ accessToken: 'synthetic-forged-token', expiresIn: 3600 })); + + try { + const approved = createX509Transport(directOptions(ownedDispatcher)); + const counterfeit = Object.freeze(Object.create(Object.getPrototypeOf(approved))) as X509Transport; + expect(() => + registerX509Transport(counterfeit, { + dispatch, + exchange, + run: (operation) => operation(), + current: vi.fn(), + resume: (_scope, operation) => operation(), + }), + ).toThrow(/genuine.*capability/iu); + expect(dispatch).not.toHaveBeenCalled(); + expect(exchange).not.toHaveBeenCalled(); + } finally { + await ownedDispatcher.close(); + } + }); + test.each([ ['browser', 'browser'], ['Bun', 'bun'], From 71f47ecd1f59c1a752d0297b01e9060835fde0b9 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 20:34:55 +0000 Subject: [PATCH 4/6] fix(auth): isolate Node X.509 state from browser and source loaders --- jest.config.ts | 2 +- package.json | 11 +++++++++++ scripts/test-packed-package.ts | 9 +++++++++ scripts/utils/make-dist-package-json.cjs | 13 +++++++++++++ src/internal/auth/x509-transport-capability.ts | 2 +- src/internal/auth/x509-transport-registry.ts | 2 +- src/internal/auth/x509-transport-state-browser.ts | 15 +++++++++++++++ 7 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 src/internal/auth/x509-transport-state-browser.ts diff --git a/jest.config.ts b/jest.config.ts index e3685deee3..b3f08b68ac 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,7 +13,7 @@ const config: JestConfigWithTsJest = { '^.+\\.cts$': ['@swc/jest', { sourceMaps: 'inline' }], }, moduleNameMapper: { - '(^|/)x509-transport-state\\.cjs$': '/src/internal/auth/x509-transport-state.cts', + '^#x509-transport-state$': '/src/internal/auth/x509-transport-state.cts', '^openai$': '/src/index.ts', '^openai/(.*)$': '/src/$1', }, diff --git a/package.json b/package.json index b831127417..a45ccfeeb0 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,14 @@ "overrides": { "minimatch": "^9.0.5" }, + "imports": { + "#x509-transport-state": { + "types": "./src/internal/auth/x509-transport-state.cts", + "browser": "./src/internal/auth/x509-transport-state-browser.ts", + "node": "./src/internal/auth/x509-transport-state.cts", + "default": "./src/internal/auth/x509-transport-state-browser.ts" + } + }, "exports": { ".": { "import": "./dist/index.mjs", @@ -86,6 +94,9 @@ "./internal/auth/x509-transport-registry.mjs": null, "./internal/auth/x509-transport-state": null, "./internal/auth/x509-transport-state.cjs": null, + "./internal/auth/x509-transport-state-browser": null, + "./internal/auth/x509-transport-state-browser.js": null, + "./internal/auth/x509-transport-state-browser.mjs": null, "./*.mjs": { "default": "./dist/*.mjs" }, diff --git a/scripts/test-packed-package.ts b/scripts/test-packed-package.ts index 3fd361b498..1b1b154c11 100644 --- a/scripts/test-packed-package.ts +++ b/scripts/test-packed-package.ts @@ -226,6 +226,9 @@ const packedPackagePath = require('node:path'); 'openai/internal/auth/x509-transport-registry.mjs', 'openai/internal/auth/x509-transport-state', 'openai/internal/auth/x509-transport-state.cjs', + 'openai/internal/auth/x509-transport-state-browser', + 'openai/internal/auth/x509-transport-state-browser.js', + 'openai/internal/auth/x509-transport-state-browser.mjs', ]; const moduleNames = JSON.stringify(privateX509Modules); run(process.execPath, [ @@ -488,6 +491,12 @@ const packedPackagePath = require('node:path'); assert.equal(installedPackage.peerDependenciesMeta?.['undici']?.optional, true); const optionalUndici = path.join(temporaryDirectory, 'node_modules/undici'); assert(!fs.existsSync(optionalUndici), 'Undici must remain optional for ordinary SDK consumers'); + run(process.execPath, [ + '--conditions=browser', + '--input-type=module', + '--eval', + "import OpenAI from 'openai'; new OpenAI({ apiKey: 'synthetic-browser-api-key', dangerouslyAllowBrowser: true });", + ]); fs.symlinkSync(path.join(root, 'node_modules/undici'), optionalUndici, 'dir'); for (const [inputType, consumer] of [ diff --git a/scripts/utils/make-dist-package-json.cjs b/scripts/utils/make-dist-package-json.cjs index c88d73d092..301354b278 100644 --- a/scripts/utils/make-dist-package-json.cjs +++ b/scripts/utils/make-dist-package-json.cjs @@ -16,6 +16,19 @@ function processExportMap(m) { } processExportMap(pkgJson.exports); +if (pkgJson.imports?.['#x509-transport-state']) { + const state = pkgJson.imports['#x509-transport-state']; + for (const condition of Object.keys(state)) { + if (condition === 'types') { + continue; + } + state[condition] = state[condition] + .replace(/^\.\/src\//, './') + .replace(/\.cts$/, '.cjs') + .replace(/\.ts$/, '.mjs'); + } +} + for (const key of ['types', 'main', 'module']) { if (typeof pkgJson[key] === 'string') { pkgJson[key] = pkgJson[key].replace(/^(\.\/)?dist\//, './'); diff --git a/src/internal/auth/x509-transport-capability.ts b/src/internal/auth/x509-transport-capability.ts index 463452aba6..de377b75dd 100644 --- a/src/internal/auth/x509-transport-capability.ts +++ b/src/internal/auth/x509-transport-capability.ts @@ -2,7 +2,7 @@ import { types } from 'node:util'; import { Agent, ProxyAgent, Request, fetch } from 'undici'; import { OpenAIError } from '../../core/error'; import type { RegisteredX509Transport, X509Transport, x509TransportBrand } from './x509-transport-registry'; -import { findRegisteredX509Transport, rememberRegisteredX509Transport } from './x509-transport-state.cjs'; +import { findRegisteredX509Transport, rememberRegisteredX509Transport } from '#x509-transport-state'; export type { X509Transport } from './x509-transport-registry'; diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index 3dd291aef3..36288af58e 100644 --- a/src/internal/auth/x509-transport-registry.ts +++ b/src/internal/auth/x509-transport-registry.ts @@ -1,5 +1,5 @@ import { OpenAIError } from '../../core/error'; -import { findRegisteredX509Transport } from './x509-transport-state.cjs'; +import { findRegisteredX509Transport } from '#x509-transport-state'; export declare const x509TransportBrand: unique symbol; diff --git a/src/internal/auth/x509-transport-state-browser.ts b/src/internal/auth/x509-transport-state-browser.ts new file mode 100644 index 0000000000..9579c79348 --- /dev/null +++ b/src/internal/auth/x509-transport-state-browser.ts @@ -0,0 +1,15 @@ +/** Browser-safe capability state keeps CommonJS outside the ordinary SDK ESM graph. */ +const registeredX509Transports = new WeakMap(); +const transientX509ConnectionErrors = new WeakSet(); + +/** Looks up an opaque capability without exposing the registry itself. */ +export const findRegisteredX509Transport = WeakMap.prototype.get.bind(registeredX509Transports); + +/** Records a capability only after its Node-only factory validates the private dispatcher. */ +export const rememberRegisteredX509Transport = WeakMap.prototype.set.bind(registeredX509Transports); + +/** Privately classifies sanitized issuer errors without exposing transport details. */ +export const markTransientX509ConnectionError = WeakSet.prototype.add.bind(transientX509ConnectionErrors); + +/** Checks the private transient classification without retaining caller-owned errors. */ +export const isTransientX509ConnectionError = WeakSet.prototype.has.bind(transientX509ConnectionErrors); From e43f30aa0113c03258ea9a61c0f95daa51ec0648 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 21:26:58 +0000 Subject: [PATCH 5/6] fix(auth): preserve nominal x509 transport capability branding --- src/internal/auth/x509-transport-capability.ts | 6 ++++-- src/internal/auth/x509-transport-registry.ts | 4 ++-- tests/auth/x509-transport.test.ts | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/internal/auth/x509-transport-capability.ts b/src/internal/auth/x509-transport-capability.ts index de377b75dd..f168498b4b 100644 --- a/src/internal/auth/x509-transport-capability.ts +++ b/src/internal/auth/x509-transport-capability.ts @@ -1,7 +1,8 @@ import { types } from 'node:util'; import { Agent, ProxyAgent, Request, fetch } from 'undici'; import { OpenAIError } from '../../core/error'; -import type { RegisteredX509Transport, X509Transport, x509TransportBrand } from './x509-transport-registry'; +import { x509TransportBrand } from './x509-transport-registry'; +import type { RegisteredX509Transport, X509Transport } from './x509-transport-registry'; import { findRegisteredX509Transport, rememberRegisteredX509Transport } from '#x509-transport-state'; export type { X509Transport } from './x509-transport-registry'; @@ -25,9 +26,10 @@ export interface X509TransportOptions { } const allowedOptionNames = new Set(['runtime', 'dispatcher', 'certificateIdentity', 'proxy']); +const transportBrand: typeof x509TransportBrand = x509TransportBrand; class NodeX509Transport implements X509Transport { - declare readonly __opaqueX509Transport: typeof x509TransportBrand; + declare readonly [transportBrand]: true; readonly #dispatcher: Agent | ProxyAgent; diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index 36288af58e..8f6f55625b 100644 --- a/src/internal/auth/x509-transport-registry.ts +++ b/src/internal/auth/x509-transport-registry.ts @@ -1,12 +1,12 @@ import { OpenAIError } from '../../core/error'; import { findRegisteredX509Transport } from '#x509-transport-state'; -export declare const x509TransportBrand: unique symbol; +export const x509TransportBrand: unique symbol = Symbol('X.509 transport capability'); /** Opaque identity of one frozen, caller-owned, explicitly attested X.509 transport. */ export interface X509Transport { /** Prevents ordinary objects from satisfying the transport capability contract. */ - readonly __opaqueX509Transport: typeof x509TransportBrand; + readonly [x509TransportBrand]: true; } /** Validated short-lived token exchanged using a registered certificate identity. */ diff --git a/tests/auth/x509-transport.test.ts b/tests/auth/x509-transport.test.ts index cb14d7d175..c93aa4dea9 100644 --- a/tests/auth/x509-transport.test.ts +++ b/tests/auth/x509-transport.test.ts @@ -59,9 +59,11 @@ describe('explicit X.509 transport capability', () => { try { const first = createX509Transport(directOptions(dispatcher)); const rotated = createX509Transport(directOptions(rotatedDispatcher)); + const exposesNoStringKeys: Extract extends never ? true : false = true; expect(Object.isFrozen(first)).toBe(true); expect(Reflect.ownKeys(first)).toEqual([]); + expect(exposesNoStringKeys).toBe(true); expect(rotated).not.toBe(first); } finally { await Promise.all([dispatcher.close(), rotatedDispatcher.close()]); From 8963a4165e2a34c77a2f5b4c7e2a1c26c770bd40 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 21:37:29 +0000 Subject: [PATCH 6/6] fix(auth): defer x509 identity export until client integration --- src/auth/index.ts | 7 +------ src/auth/types.ts | 24 ------------------------ 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/src/auth/index.ts b/src/auth/index.ts index 06981c3348..f0cc83316c 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,9 +1,4 @@ -export type { - WorkloadIdentity, - X509WorkloadIdentity, - SubjectTokenProvider, - TokenExchangeResponse, -} from './types'; +export type { WorkloadIdentity, SubjectTokenProvider, TokenExchangeResponse } from './types'; export { k8sServiceAccountTokenProvider, diff --git a/src/auth/types.ts b/src/auth/types.ts index 8f2ebe3175..4099702a31 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -25,30 +25,6 @@ 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; - - /** 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; - - /** X.509 federation does not send an OAuth client identifier. */ - clientId?: never; - - /** X.509 refresh configuration is expressed in milliseconds. */ - 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. */