From a065575234ae19526c45ae3d445054e40a5ef7a3 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 18:58:49 +0000 Subject: [PATCH 01/18] feat(auth): integrate attested X.509 workload identity into clients --- scripts/test-packed-package.ts | 14 +- src/azure.ts | 12 +- src/bedrock.ts | 8 +- src/client.ts | 246 ++++- .../auth/x509-workload-identity-auth.ts | 328 +++++++ tests/lib/x509-workload-identity.test.ts | 918 ++++++++++++++++++ tests/utils/x509-test-lab.ts | 2 + 7 files changed, 1486 insertions(+), 42 deletions(-) create mode 100644 src/internal/auth/x509-workload-identity-auth.ts create mode 100644 tests/lib/x509-workload-identity.test.ts diff --git a/scripts/test-packed-package.ts b/scripts/test-packed-package.ts index 1b1b154c11..6561ea901e 100644 --- a/scripts/test-packed-package.ts +++ b/scripts/test-packed-package.ts @@ -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();`, ]); } diff --git a/src/azure.ts b/src/azure.ts index 8731d36056..1720c25f06 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -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 { +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']. */ diff --git a/src/bedrock.ts b/src/bedrock.ts index fb02f85f73..576531b54b 100644 --- a/src/bedrock.ts +++ b/src/bedrock.ts @@ -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. @@ -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. * @@ -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.'); } diff --git a/src/client.ts b/src/client.ts index 41c036f411..6e7dcc60c8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -17,8 +17,17 @@ import { resolveDataResidency, type DataResidency } from './internal/data-reside export type { DataResidency } from './internal/data-residency'; import * as Errors from './core/error'; import * as Pagination from './core/pagination'; -import type { WorkloadIdentity } from './auth/types'; +import type { WorkloadIdentity, X509WorkloadIdentity } from './auth/types'; import { WorkloadIdentityAuth } from './auth/workload-identity-auth'; +import { + X509_API_BASE_URL, + X509WorkloadIdentityAuth, + assertX509APIOrigin, + assertX509RequestOptions, + isX509WorkloadIdentity, + snapshotX509RequestOptions, +} from './internal/auth/x509-workload-identity-auth'; +import type { X509Transport } from './internal/auth/x509-transport-registry'; import { OAuthError, SubjectTokenProviderError } from './core/error'; import { type ConversationCursorPageParams, @@ -415,7 +424,10 @@ export interface ClientOptions { * Workload identity configuration for OAuth2 token exchange authentication. * Mutually exclusive with `apiKey`. */ - workloadIdentity?: WorkloadIdentity | undefined; + workloadIdentity?: WorkloadIdentity | X509WorkloadIdentity | undefined; + + /** Approved, frozen Node.js certificate transport required only for X.509 workload identity. */ + x509Transport?: X509Transport | undefined; /** * Configure this client to use a third-party API provider. @@ -443,13 +455,17 @@ export class OpenAI { private fetch: Fetch; #encoder: Opts.RequestEncoder; + #x509Fetch: Fetch | undefined; // Preserve an explicit global selection without storing a second routing URL. #explicitDataResidency = false; - #responseAttempts = new WeakMap(); + #responseAttempts = new WeakMap< + AbortController, + { timeout: number; retriesRemaining: number; continueRequest?: (operation: () => T) => T } + >(); protected idempotencyHeader?: string; protected _options: ClientOptions; private _provider: ProviderRuntime | undefined; - private _workloadIdentityAuth?: WorkloadIdentityAuth; + private _workloadIdentityAuth?: WorkloadIdentityAuth | X509WorkloadIdentityAuth; /** * API Client for interfacing with the OpenAI API. @@ -471,10 +487,11 @@ export class OpenAI { */ constructor(clientOptions: ClientOptions = {}) { const residencyBaseURL = resolveDataResidency(clientOptions); + const usesX509Identity = isX509WorkloadIdentity(clientOptions.workloadIdentity); const provider = clientOptions.provider; if (provider) { const conflictingOptions = ( - ['apiKey', 'adminAPIKey', 'workloadIdentity', 'baseURL', 'dataResidency'] as const + ['apiKey', 'adminAPIKey', 'workloadIdentity', 'x509Transport', 'baseURL', 'dataResidency'] as const ).filter((key) => clientOptions[key] != null); if (conflictingOptions.length) { throw new Errors.OpenAIError( @@ -495,6 +512,7 @@ export class OpenAI { project = provider ? null : (readEnv('OPENAI_PROJECT_ID') ?? null), webhookSecret = readEnv('OPENAI_WEBHOOK_SECRET') ?? null, workloadIdentity, + x509Transport, ...opts } = clientOptions as InternalClientOptions; const providerRuntime = provider ? configureProvider(provider) : undefined; @@ -505,11 +523,40 @@ export class OpenAI { project, webhookSecret, workloadIdentity, + x509Transport, provider, ...opts, - baseURL: providerRuntime?.baseURL ?? residencyBaseURL ?? (baseURL || `https://api.openai.com/v1`), + baseURL: + providerRuntime?.baseURL ?? + residencyBaseURL ?? + (baseURL || (usesX509Identity ? X509_API_BASE_URL : `https://api.openai.com/v1`)), }; + if (x509Transport && !usesX509Identity) { + throw new Errors.OpenAIError('An X.509 transport requires an X.509 workload identity.'); + } + + if (usesX509Identity) { + if (residencyBaseURL !== undefined || inheritedResidencySelection) { + throw new Errors.OpenAIError('X.509 workload identity does not support data residency selection.'); + } + if (clientOptions.fetch !== undefined) { + throw new Errors.OpenAIError( + 'X.509 workload identity does not support a custom fetch implementation.', + ); + } + assertX509APIOrigin(options.baseURL!); + assertX509RequestOptions(options.fetchOptions); + if ( + this.fetchWithAuth !== OpenAI.prototype.fetchWithAuth || + this.fetchWithTimeout !== OpenAI.prototype.fetchWithTimeout + ) { + throw new Errors.OpenAIError( + 'X.509 workload identity does not support overridden fetch dispatch hooks.', + ); + } + } + if (apiKey && workloadIdentity) { throw new Errors.OpenAIError('The `apiKey` and `workloadIdentity` options are mutually exclusive'); } @@ -558,7 +605,14 @@ export class OpenAI { this._provider = providerRuntime; if (workloadIdentity) { - this._workloadIdentityAuth = new WorkloadIdentityAuth(workloadIdentity, this.fetch); + if (isX509WorkloadIdentity(workloadIdentity)) { + const authentication = new X509WorkloadIdentityAuth(workloadIdentity, x509Transport); + this._workloadIdentityAuth = authentication; + this.#x509Fetch = authentication.fetch(); + this.fetch = this.#x509Fetch; + } else { + this._workloadIdentityAuth = new WorkloadIdentityAuth(workloadIdentity, this.fetch); + } } this.apiKey = typeof apiKey === 'string' ? apiKey : null; @@ -582,15 +636,30 @@ export class OpenAI { timeout: this.timeout, logger: this.logger, logLevel: this.logLevel, - fetch: this.fetch, + fetch: this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? undefined : this.fetch, fetchOptions: this.fetchOptions, apiKey: this._options.apiKey, adminAPIKey: this.adminAPIKey, workloadIdentity: this._options.workloadIdentity, + x509Transport: this._options.x509Transport, organization: this.organization, project: this.project, webhookSecret: this.webhookSecret, }; + const currentlyX509 = this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth; + const nextIdentity = hasOwn(options, 'workloadIdentity') + ? options.workloadIdentity + : inheritedOptions.workloadIdentity; + const nextX509 = isX509WorkloadIdentity(nextIdentity); + if (currentlyX509 !== nextX509) { + delete inheritedOptions.fetch; + delete inheritedOptions.baseURL; + if (nextX509) { + inheritedOptions.apiKey = null; + } else { + delete inheritedOptions.x509Transport; + } + } if (residencyBaseURL !== undefined) { delete inheritedOptions.baseURL; } @@ -598,6 +667,7 @@ export class OpenAI { delete inheritedOptions.apiKey; delete inheritedOptions.adminAPIKey; delete inheritedOptions.workloadIdentity; + delete inheritedOptions.x509Transport; delete inheritedOptions.baseURL; if (provider !== inheritedProvider) { delete inheritedOptions.organization; @@ -664,6 +734,13 @@ export class OpenAI { adminAPIKeyAuth: true, }, ): Promise { + if ( + this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth && + schemes.adminAPIKeyAuth && + this.adminAPIKey !== null + ) { + return await this.adminAPIKeyAuth(opts); + } return buildHeaders([ schemes.bearerAuth ? await this.bearerAuth(opts) : null, schemes.adminAPIKeyAuth ? await this.adminAPIKeyAuth(opts) : null, @@ -672,7 +749,28 @@ export class OpenAI { protected async bearerAuth(opts: FinalRequestOptions): Promise { if (this._workloadIdentityAuth) { - return buildHeaders([{ Authorization: `Bearer ${await this._workloadIdentityAuth.getToken()}` }]); + if (this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth) { + if ( + this.fetchWithAuth !== OpenAI.prototype.fetchWithAuth || + this.fetchWithTimeout !== OpenAI.prototype.fetchWithTimeout + ) { + throw new Errors.OpenAIError( + 'X.509 workload identity does not support overridden fetch dispatch hooks.', + ); + } + if (!X509WorkloadIdentityAuth.shouldAuthenticate(opts, this._options.defaultHeaders)) { + return undefined; + } + } + const token = + this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth + ? await this._workloadIdentityAuth.getToken(opts, { + apiURL: this.buildURL(opts.path!, opts.query as Record, opts.defaultBaseURL), + defaultHeaders: this._options.defaultHeaders, + timeout: opts.timeout ?? this.timeout, + }) + : await this._workloadIdentityAuth.getToken(); + return buildHeaders([{ Authorization: `Bearer ${token}` }]); } if (this.apiKey == null) { return undefined; @@ -819,7 +917,12 @@ export class OpenAI { options: PromiseOrValue, remainingRetries: number | null = null, ): APIPromise { - return this.responsePromise(this.makeRequest(options, remainingRetries, undefined)); + const operation = () => this.makeRequest(options, remainingRetries, undefined); + const request = + this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth + ? this._workloadIdentityAuth.runRequest(operation) + : operation(); + return this.responsePromise(request); } private responsePromise( @@ -827,7 +930,10 @@ export class OpenAI { parse: (client: OpenAI, props: APIResponseProps) => Promise = (client, props) => this.parseResponseWithTimeout(client, props), ): APIPromise { - const promise = new APIPromise(this, request, parse); + const promise = new APIPromise(this, request, (client, props) => { + const resume = this.#responseAttempts.get(props.controller)?.continueRequest; + return resume ? resume(() => parse(client, props)) : parse(client, props); + }); // A body timeout can retry after the original raw response has arrived. Wait for // parsing before selecting the response so withResponse() reports the retry. @@ -857,8 +963,18 @@ export class OpenAI { while (true) { const attempt = this.#responseAttempts.get(props.controller); const timeout = attempt?.timeout ?? props.options.timeout ?? this.timeout; - const remaining = Math.max(0, props.startTime + timeout - Date.now()); + const x509Authentication = + this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth + ? this._workloadIdentityAuth + : undefined; + const remaining = + x509Authentication?.remainingTimeout(props.options, timeout) ?? + Math.max(0, props.startTime + timeout - Date.now()); const callerSignal = props.options.signal; + const abortError = () => + x509Authentication && callerSignal + ? this._makeUserAbortError(callerSignal) + : new Errors.APIUserAbortError(); let timer: ReturnType | undefined; let abortListener: (() => void) | undefined; let timedOut = false; @@ -866,7 +982,7 @@ export class OpenAI { try { // Tool runners preserve a completed buffered turn before cancellation stops the next request. if (callerSignal?.aborted && props.options.__metadata?.['helperMethod'] !== 'runTools') { - throw new Errors.APIUserAbortError(); + throw abortError(); } const timeoutPromise = new Promise((_, reject) => { @@ -877,7 +993,7 @@ export class OpenAI { }, remaining); if (callerSignal) { - abortListener = () => reject(new Errors.APIUserAbortError()); + abortListener = () => reject(abortError()); callerSignal.addEventListener('abort', abortListener, { once: true }); } }); @@ -885,7 +1001,7 @@ export class OpenAI { return await Promise.race([defaultParseResponse(client, props), timeoutPromise]); } catch (error) { if (callerSignal?.aborted) { - throw new Errors.APIUserAbortError(); + throw abortError(); } if (!timedOut) { throw error; @@ -940,15 +1056,26 @@ export class OpenAI { const { req, url, timeout } = await this.buildRequest(options, { retryCount: maxRetries - retriesRemaining, }); - const hasStreamingBody = options.__metadata?.['hasStreamingBody'] === true; + const x509Authentication = + this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; + x509Authentication?.bindRequest(options, req, this.adminAPIKey); + let hasStreamingBody = options.__metadata?.['hasStreamingBody'] === true; await this.prepareRequest(req, { url, options }); await this._provider?.prepareRequest?.(req, { url, options }); + x509Authentication?.assertRequest(req); + if ( + x509Authentication && + ((globalThis.ReadableStream && req.body instanceof globalThis.ReadableStream) || + (typeof req.body === 'object' && req.body !== null && Symbol.asyncIterator in req.body)) + ) { + hasStreamingBody = true; + } /** Not an API request ID, just for correlating local log entries. */ const requestLogID = 'log_' + ((Math.random() * (1 << 24)) | 0).toString(16).padStart(6, '0'); const retryLogStr = retryOfRequestLogID === undefined ? '' : `, retryOf: ${retryOfRequestLogID}`; - const startTime = Date.now(); + const startTime = x509Authentication?.requestStartedAt(options) ?? Date.now(); loggerFor(this).debug( `[${requestLogID}] sending request`, @@ -971,7 +1098,11 @@ export class OpenAI { this.fetchWithTimeout === OpenAI.prototype.fetchWithTimeout ? createRequestController(req.signal) : new AbortController(); - const response = await this.fetchWithAuth(url, req, timeout, controller, security).catch(castToError); + const remainingTimeout = x509Authentication?.remainingTimeout(options, timeout) ?? timeout; + const fetchWithAuth = x509Authentication ? OpenAI.prototype.fetchWithAuth : this.fetchWithAuth; + const response = await fetchWithAuth + .call(this, url, req, remainingTimeout, controller, security) + .catch(castToError); const headersTime = Date.now(); if (response instanceof globalThis.Error) { @@ -1054,21 +1185,28 @@ export class OpenAI { response.status === 401 && this._workloadIdentityAuth && security.bearerAuth && - !options.__metadata?.['hasStreamingBody'] && + (!x509Authentication || x509Authentication.usedWorkloadToken(options)) && + (!x509Authentication || retriesRemaining > 0) && + !hasStreamingBody && !options.__metadata?.['workloadIdentityTokenRefreshed'] ) { - await Shims.CancelReadableStream(response.body); + if (x509Authentication) { + void Shims.CancelReadableStream(response.body).catch(() => undefined); + } else { + await Shims.CancelReadableStream(response.body); + } this._workloadIdentityAuth.invalidateToken(); - return this.makeRequest( - { - ...options, - __metadata: { - ...options.__metadata, - workloadIdentityTokenRefreshed: true, - }, + const replayOptions = { + ...options, + __metadata: { + ...options.__metadata, + workloadIdentityTokenRefreshed: true, }, - retriesRemaining, + }; + return this.makeRequest( + replayOptions, + x509Authentication ? retriesRemaining - 1 : retriesRemaining, retryOfRequestLogID ?? requestLogID, ); } @@ -1078,7 +1216,11 @@ export class OpenAI { const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; // We don't need the body of this response. - await Shims.CancelReadableStream(response.body); + if (x509Authentication) { + void Shims.CancelReadableStream(response.body).catch(() => undefined); + } else { + await Shims.CancelReadableStream(response.body); + } loggerFor(this).info(`${responseInfo} - ${retryMessage}`); loggerFor(this).debug( `[${requestLogID}] response error (${retryMessage})`, @@ -1138,7 +1280,12 @@ export class OpenAI { }), ); - this.#responseAttempts.set(controller, { timeout, retriesRemaining }); + const continueRequest = x509Authentication?.continuation(); + this.#responseAttempts.set(controller, { + timeout, + retriesRemaining, + ...(continueRequest ? { continueRequest } : {}), + }); return { response, options, controller, requestLogID, retryOfRequestLogID, startTime }; } @@ -1162,7 +1309,11 @@ export class OpenAI { Page: new (...args: ConstructorParameters) => PageClass, options: PromiseOrValue, ): Pagination.PagePromise { - const request = this.makeRequest(options, null, undefined); + const operation = () => this.makeRequest(options, null, undefined); + const request = + this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth + ? this._workloadIdentityAuth.runRequest(operation) + : operation(); const page = new Pagination.PagePromise(this as any as OpenAI, request, Page); const guarded = this.responsePromise(request, async (client, props) => { const body = await this.parseResponseWithTimeout(client, props); @@ -1186,7 +1337,7 @@ export class OpenAI { adminAPIKeyAuth: true, }, ): Promise { - if (this._workloadIdentityAuth && schemes.bearerAuth) { + if (this._workloadIdentityAuth && !this.#x509Fetch && schemes.bearerAuth) { const headers = init.headers as Headers; const authHeader = headers.get('Authorization'); if (!authHeader || authHeader === `Bearer ${WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER}`) { @@ -1195,7 +1346,8 @@ export class OpenAI { } } - const response = await this.fetchWithTimeout(url, init, timeout, controller); + const fetchWithTimeout = this.#x509Fetch ? OpenAI.prototype.fetchWithTimeout : this.fetchWithTimeout; + const response = await fetchWithTimeout.call(this, url, init, timeout, controller); return response; } @@ -1231,7 +1383,7 @@ export class OpenAI { try { // use undefined this binding; fetch errors if bound to something else in browser/cloudflare - return await this.fetch.call(undefined, url, fetchOptions); + return await (this.#x509Fetch ?? this.fetch).call(undefined, url, fetchOptions); } catch (err) { if (signal && !composed) signal.removeEventListener('abort', abort); throw err; @@ -1302,6 +1454,12 @@ export class OpenAI { const maxRetries = options.maxRetries ?? this.maxRetries; timeoutMillis = this.calculateDefaultRetryTimeoutMillis(retriesRemaining, maxRetries); } + if (this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth) { + const remaining = this._workloadIdentityAuth.remainingTimeout(options, options.timeout ?? this.timeout); + if (timeoutMillis >= remaining) { + throw new Errors.APIConnectionTimeoutError(); + } + } await sleep(timeoutMillis); return this.makeRequest(options, retriesRemaining - 1, requestLogID); @@ -1326,7 +1484,23 @@ export class OpenAI { inputOptions: FinalRequestOptions, { retryCount = 0 }: { retryCount?: number } = {}, ): Promise<{ req: FinalizedRequestInit; url: string; timeout: number }> { + if ( + this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth && + !this._workloadIdentityAuth.inRequest() + ) { + return await this._workloadIdentityAuth.runRequest(() => + OpenAI.prototype.buildRequest.call(this, inputOptions, { retryCount }), + ); + } const options = { ...inputOptions }; + const x509Authentication = + this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; + const x509ClientFetchOptions = x509Authentication + ? snapshotX509RequestOptions(this.fetchOptions) + : undefined; + const x509RequestFetchOptions = x509Authentication + ? snapshotX509RequestOptions(options.fetchOptions) + : undefined; const { method, path, query, defaultBaseURL } = options; const url = this.buildURL(path!, query as Record, defaultBaseURL); @@ -1350,8 +1524,8 @@ export class OpenAI { ...((globalThis as any).ReadableStream && body instanceof (globalThis as any).ReadableStream && { duplex: 'half' }), ...(body && { body }), - ...((this.fetchOptions as any) ?? {}), - ...((options.fetchOptions as any) ?? {}), + ...(((x509Authentication ? x509ClientFetchOptions : this.fetchOptions) as any) ?? {}), + ...(((x509Authentication ? x509RequestFetchOptions : options.fetchOptions) as any) ?? {}), }; return { req, url, timeout: options.timeout }; diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts new file mode 100644 index 0000000000..f790f58d6d --- /dev/null +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -0,0 +1,328 @@ +import { APIConnectionTimeoutError, APIUserAbortError, OpenAIError } from '../../core/error'; +import type { WorkloadIdentity, X509WorkloadIdentity } from '../../auth/types'; +import type { Fetch } from '../builtin-types'; +import { buildHeaders } from '../headers'; +import type { HeadersLike } from '../headers'; +import type { FinalRequestOptions } from '../request-options'; +import type { MergedRequestInit } from '../types'; +import { hasOwn } from '../utils/values'; +import { resolveX509Transport } from './x509-transport-registry'; +import type { RegisteredX509Transport, X509RequestScope, X509Transport } from './x509-transport-registry'; + +/** Sole API authority approved for OpenAI X.509 workload-identity federation. */ +export const X509_API_BASE_URL = 'https://mtls.api.openai.com/v1'; + +const X509_API_ORIGIN = 'https://mtls.api.openai.com'; +const FORBIDDEN_TRANSPORT_OPTIONS = ['dispatcher', 'agent', 'client', 'tls', 'proxy']; +const headerValue = (headers: Headers, name: string): string | null => + Headers.prototype.get.call(headers, name); +const invalidateUncachedToken = (): undefined => undefined; +const userAbortError = (signal: AbortSignal): APIUserAbortError => { + const error = new APIUserAbortError(); + Object.defineProperty(error, 'cause', { value: signal.reason, writable: true, configurable: true }); + return error; +}; + +function assertSafeHeaders(headers: Headers): void { + for (const name of Headers.prototype.keys.call(headers)) { + const canonical = name.toLowerCase().split('_').join('-'); + if ( + canonical === 'api-key' || + canonical === 'x-api-key' || + canonical === 'proxy-authorization' || + canonical === 'host' + ) { + throw new OpenAIError('X.509 workload identity cannot send conflicting authentication credentials.'); + } + } +} + +function exchangeDeadline( + timeout: number | undefined, + callerSignal: AbortSignal | null | undefined, +): { signal: AbortSignal; dispose: () => void } { + const deadline = new AbortController(); + const timer = + timeout === undefined + ? undefined + : setTimeout(() => deadline.abort(new APIConnectionTimeoutError()), timeout); + const timerHandle: unknown = timer; + if ( + typeof timerHandle === 'object' && + timerHandle !== null && + 'unref' in timerHandle && + typeof timerHandle.unref === 'function' + ) { + timerHandle.unref(); + } + const cancel = () => deadline.abort(callerSignal?.reason); + callerSignal?.addEventListener('abort', cancel, { once: true }); + if (callerSignal?.aborted) { + cancel(); + } + return { + signal: deadline.signal, + dispose: () => { + callerSignal?.removeEventListener('abort', cancel); + if (timer) { + clearTimeout(timer); + } + }, + }; +} + +/** Distinguishes true certificate identities from extensible legacy subject-token identities. */ +export function isX509WorkloadIdentity( + identity: WorkloadIdentity | X509WorkloadIdentity | undefined, +): identity is X509WorkloadIdentity { + if (!identity || typeof identity !== 'object') { + return false; + } + + const discriminator = Object.getOwnPropertyDescriptor(identity, 'type'); + return ( + !!discriminator && 'value' in discriminator && discriminator.value === 'x509' && !('provider' in identity) + ); +} + +/** Rejects every destination outside the sole enrolled, global X.509 API authority. */ +export function assertX509APIOrigin(value: string | URL): URL { + let target: URL; + try { + target = new URL(value); + } catch { + throw new OpenAIError('X.509 workload identity requires the approved global mTLS API origin.'); + } + + if (target.origin !== X509_API_ORIGIN || target.username || target.password) { + throw new OpenAIError('X.509 workload identity requires the approved global mTLS API origin.'); + } + return target; +} + +/** Prevents caller options from replacing the immutable transport selected at construction. */ +export function assertX509FetchOptions(options: MergedRequestInit | RequestInit | undefined): void { + if (!options) { + return; + } + + for (const name of FORBIDDEN_TRANSPORT_OPTIONS) { + if (hasOwn(options, name)) { + throw new OpenAIError('X.509 workload identity cannot override its approved transport capability.'); + } + } + const redirect: unknown = Object.getOwnPropertyDescriptor(options, 'redirect')?.value; + if (redirect !== undefined && redirect !== 'manual') { + throw new OpenAIError('X.509 workload identity requests require manual redirects.'); + } +} + +/** Rejects caller body replacement while allowing the SDK's legitimate final RequestInit body. */ +export function assertX509RequestOptions(options: MergedRequestInit | RequestInit | undefined): void { + assertX509FetchOptions(options); + if (options && ['body', 'headers', 'method', 'signal'].some((name) => hasOwn(options, name))) { + throw new OpenAIError( + 'X.509 workload identity cannot override its request body, headers, method, or signal through fetch options.', + ); + } +} + +/** Validates and snapshots the exact caller options that will reach authenticated dispatch. */ +export function snapshotX509RequestOptions(options: MergedRequestInit | undefined): MergedRequestInit { + assertX509RequestOptions(options); + const snapshot = { ...options }; + assertX509RequestOptions(snapshot); + return snapshot; +} + +/** Bridges certificate authentication into existing OpenAI auth and fetch hooks without optional peers. */ +export class X509WorkloadIdentityAuth { + readonly #identityProviderId: string; + readonly #serviceAccountId: string; + readonly #transport: RegisteredX509Transport; + + /** Captures one registered, immutable certificate identity and its enrolled selectors. */ + constructor(identity: X509WorkloadIdentity, transport: X509Transport | undefined) { + this.#transport = resolveX509Transport(transport); + this.#identityProviderId = identity.identityProviderId; + this.#serviceAccountId = identity.serviceAccountId; + } + + /** Preserves explicitly headerless requests without presenting a certificate to the issuer. */ + static shouldAuthenticate(options: FinalRequestOptions, defaultHeaders: HeadersLike | undefined): boolean { + return !buildHeaders([defaultHeaders, options.headers]).nulls.has('authorization'); + } + + /** Establishes an independent scope even when concurrent requests share caller options. */ + runRequest(operation: () => T): T { + return this.#transport.run(operation); + } + + /** Reports whether a public request-building call already belongs to an active logical operation. */ + inRequest(): boolean { + return this.#transport.current() !== undefined; + } + + /** Binds deferred response parsing to the original logical request and its unchanged deadline. */ + continuation(): (operation: () => T) => T { + const scope = this.#scope(); + return (operation) => this.#transport.resume(scope, operation); + } + + /** Returns the original authentication start so response consumption shares its request deadline. */ + requestStartedAt(_options: FinalRequestOptions): number | undefined { + return this.#transport.current()?.wallStartedAt; + } + + /** Distinguishes issued workload credentials from independent admin or headerless requests. */ + usedWorkloadToken(_options: FinalRequestOptions): boolean { + return this.#transport.current()?.token !== undefined; + } + + /** Returns the budget left after certificate authentication without starting another timeout. */ + remainingTimeout(_options: FinalRequestOptions, timeout: number): number { + const scope = this.#transport.current(); + if (scope === undefined) { + return timeout; + } + const remaining = timeout - (performance.now() - scope.monotonicStartedAt); + if (remaining <= 0) { + throw new APIConnectionTimeoutError(); + } + return remaining; + } + + /** Exchanges the exact certificate capability selected for the matching API dispatch. */ + async getToken( + options?: FinalRequestOptions, + context?: { apiURL: string; defaultHeaders: HeadersLike | undefined; timeout: number }, + ): Promise { + if (options?.signal?.aborted) { + throw userAbortError(options.signal); + } + X509WorkloadIdentityAuth.#preflight(options, context); + + const scope = options ? this.#scope() : undefined; + const remaining = context && options ? this.remainingTimeout(options, context.timeout) : context?.timeout; + const { signal: callerSignal } = options ?? {}; + const { signal, dispose } = exchangeDeadline(remaining, callerSignal); + + try { + if (callerSignal?.aborted) { + throw userAbortError(callerSignal); + } + const exchanged = await this.#transport.exchange( + this.#identityProviderId, + this.#serviceAccountId, + signal, + ); + if (scope) { + scope.token = exchanged.accessToken; + } + return exchanged.accessToken; + } catch (error) { + if (callerSignal?.aborted) { + throw userAbortError(callerSignal); + } + throw error; + } finally { + dispose(); + } + } + + static #preflight( + options: FinalRequestOptions | undefined, + context: { apiURL: string; defaultHeaders: HeadersLike | undefined; timeout: number } | undefined, + ): void { + if (options) { + assertX509RequestOptions(options.fetchOptions); + } + if (!context) { + return; + } + assertX509APIOrigin(context.apiURL); + const supplied = buildHeaders([context.defaultHeaders, options?.headers]); + for (const name of supplied.values.keys()) { + const canonical = name.toLowerCase().split('_').join('-'); + if ( + canonical === 'authorization' || + canonical === 'api-key' || + canonical === 'x-api-key' || + canonical === 'proxy-authorization' || + canonical === 'host' + ) { + throw new OpenAIError( + 'X.509 workload identity cannot use caller-supplied authentication credentials.', + ); + } + } + } + + /** Token caching is added separately; every current exchange already produces a fresh credential. */ + readonly invalidateToken = invalidateUncachedToken; + + #scope(): X509RequestScope { + const scope = this.#transport.current(); + if (!scope) { + throw new OpenAIError('X.509 workload identity requires an active certificate request scope.'); + } + return scope; + } + + /** Binds the minted credential to the original headers before protected request hooks run. */ + bindRequest(options: FinalRequestOptions, request: RequestInit, adminAPIKey: string | null): void { + if (!(request.headers instanceof Headers)) { + throw new OpenAIError('X.509 workload identity requires the original workload authorization headers.'); + } + const scope = this.#scope(); + const { token } = scope; + const security = options.__security ?? { bearerAuth: true }; + let approvedAuthorization = token ? `Bearer ${token}` : null; + if (!token && security.adminAPIKeyAuth && adminAPIKey && headerValue(request.headers, 'Authorization')) { + approvedAuthorization = `Bearer ${adminAPIKey}`; + } + scope.headers = request.headers; + scope.authorization = approvedAuthorization; + this.assertRequest(request); + } + + /** Rejects request hooks that replace the selected bearer or its approved header identity. */ + assertRequest(request: RequestInit): void { + const { headers } = request; + if (!(headers instanceof Headers)) { + throw new OpenAIError('X.509 workload identity must preserve its issued workload authorization.'); + } + const scope = this.#transport.current(); + if ( + !scope || + scope.headers !== headers || + scope.authorization === undefined || + headerValue(headers, 'Authorization') !== scope.authorization + ) { + throw new OpenAIError('X.509 workload identity must preserve its issued workload authorization.'); + } + assertSafeHeaders(headers); + } + + /** Returns a guarded final dispatcher while preserving all existing request hook object identities. */ + fetch(): Fetch { + return async (input, init = {}) => { + const target = assertX509APIOrigin( + typeof input === 'string' || input instanceof URL ? input : input.url, + ); + assertX509FetchOptions(init); + this.assertRequest(init); + + const approved = init.headers; + if (!(approved instanceof Headers)) { + throw new OpenAIError('X.509 workload identity must preserve its issued workload authorization.'); + } + const headers = new Headers([...Headers.prototype.entries.call(approved)]); + assertSafeHeaders(headers); + + init.headers = headers; + init.redirect = 'manual'; + return await this.#transport.dispatch(target, init); + }; + } +} diff --git a/tests/lib/x509-workload-identity.test.ts b/tests/lib/x509-workload-identity.test.ts new file mode 100644 index 0000000000..0c2038e11c --- /dev/null +++ b/tests/lib/x509-workload-identity.test.ts @@ -0,0 +1,918 @@ +import { X509Certificate } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import { Agent, ProxyAgent } from 'undici'; +import { vi } from 'vitest'; + +import OpenAI, { APIUserAbortError, AzureOpenAI, BedrockOpenAI } from 'openai'; +import type { ClientOptions } from 'openai'; +import type { WorkloadIdentity } from 'openai/auth'; +import { createX509Transport } from 'openai/auth/x509-transport'; +import type { X509Transport } from 'openai/auth/x509-transport'; +import * as transportCapability from 'openai/internal/auth/x509-transport-capability'; + +import { + closeObservedServers, + createConnectProxy, + createMutualTLSServer, + createX509TestLab, + listenLoopback, +} from '../utils/x509-test-lab'; + +const ACCESS_TOKEN = 'synthetic-x509-workload-access-token'; +const TOKEN_RESPONSE = { + access_token: ACCESS_TOKEN, + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + expires_in: 3600, +}; + +let dispatcher: Agent; +let transport: X509Transport; + +function identity() { + return { + type: 'x509' as const, + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + }; +} + +function options(overrides: Partial = {}): ClientOptions { + return { + apiKey: null, + workloadIdentity: identity(), + x509Transport: transport, + ...overrides, + }; +} + +function mockTransportRequests() { + return vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, target) => { + if (target.origin === 'https://mtls.auth.openai.com') { + return Response.json(TOKEN_RESPONSE); + } + return Response.json({ data: [] }); + }); +} + +beforeEach(() => { + dispatcher = new Agent(); + transport = createX509Transport({ + runtime: 'node', + dispatcher, + certificateIdentity: 'static', + proxy: 'direct', + }); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + await dispatcher.close(); +}); + +describe('OpenAI X.509 workload-identity client integration', () => { + test('defaults certificate-authenticated clients to the approved global mTLS endpoint', () => { + const client = new OpenAI(options()); + + expect(client.baseURL).toBe('https://mtls.api.openai.com/v1'); + }); + + test('preserves an explicitly configured approved global mTLS endpoint', () => { + const client = new OpenAI(options({ baseURL: 'https://mtls.api.openai.com:443/v1' })); + + expect(client.baseURL).toBe('https://mtls.api.openai.com:443/v1'); + }); + + test('rejects an X.509 workload identity without its top-level transport capability', () => { + expect(() => new OpenAI(options({ x509Transport: undefined }))).toThrow(/X\.509.*transport/iu); + }); + + test('rejects a forged X.509 transport capability before token exchange', () => { + expect(() => new OpenAI(options({ x509Transport: {} as X509Transport }))).toThrow(/X\.509.*transport/iu); + }); + + test('rejects an X.509 transport without an X.509 workload identity', () => { + expect(() => new OpenAI({ apiKey: 'synthetic-api-key', x509Transport: transport })).toThrow( + /X\.509.*workload identity/iu, + ); + }); + + test('preserves a separate admin-only bearer without exchanging workload credentials', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options({ adminAPIKey: 'synthetic-admin-secret' })); + + await client.request({ + path: '/organization/projects', + method: 'get', + __security: { adminAPIKeyAuth: true }, + }); + + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[1].origin).toBe('https://mtls.api.openai.com'); + expect(new Headers(send.mock.calls[0]?.[2].headers).get('Authorization')).toBe( + 'Bearer synthetic-admin-secret', + ); + }); + + test('keeps an inherited admin key separate from ordinary workload-authenticated requests', async () => { + vi.stubEnv('OPENAI_ADMIN_KEY', 'synthetic-inherited-admin-secret'); + const send = mockTransportRequests(); + const client = new OpenAI(options()); + + await client.models.list(); + + expect(send).toHaveBeenCalledTimes(2); + expect(new Headers(send.mock.calls[1]?.[2].headers).get('Authorization')).toBe(`Bearer ${ACCESS_TOKEN}`); + }); + + test('preserves an explicitly headerless request without presenting a certificate to the issuer', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + + await client.models.list({ headers: { Authorization: null } }); + + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[1].origin).toBe('https://mtls.api.openai.com'); + expect(new Headers(send.mock.calls[0]?.[2].headers).has('Authorization')).toBe(false); + }); + + test('selects an explicitly requested admin bearer without exchanging a discarded workload credential', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options({ adminAPIKey: 'synthetic-admin-secret' })); + + await client.request({ + path: '/organization/projects', + method: 'get', + __security: { bearerAuth: true, adminAPIKeyAuth: true }, + }); + + expect(send).toHaveBeenCalledTimes(1); + expect(new Headers(send.mock.calls[0]?.[2].headers).get('Authorization')).toBe( + 'Bearer synthetic-admin-secret', + ); + }); + + test('allows an explicit admin bearer to be intentionally omitted without exchanging a workload token', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options({ adminAPIKey: 'synthetic-admin-secret' })); + + await client.request({ + path: '/organization/projects', + method: 'get', + headers: { Authorization: null }, + __security: { adminAPIKeyAuth: true }, + }); + + expect(send).toHaveBeenCalledTimes(1); + expect(new Headers(send.mock.calls[0]?.[2].headers).has('Authorization')).toBe(false); + }); + + test.each([ + ['headerless', { headers: { Authorization: null }, __security: { bearerAuth: true } }], + ['admin', { __security: { bearerAuth: true, adminAPIKeyAuth: true } }], + ])('does not replay a %s 401 as an unrequested workload identity', async (_mode, requestOptions) => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockResolvedValue(Response.json({ error: { message: 'synthetic unauthorized' } }, { status: 401 })); + const client = new OpenAI(options({ adminAPIKey: 'synthetic-admin-secret', maxRetries: 0 })); + + await expect(client.request({ path: '/models', method: 'get', ...requestOptions })).rejects.toThrow( + /unauthorized/iu, + ); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[1].origin).toBe('https://mtls.api.openai.com'); + }); + + test.each([ + 'https://api.openai.com/v1', + 'https://mtls-eu.api.openai.com/v1', + 'https://tenant.openai.azure.com/openai', + 'https://bedrock-mantle.us-east-1.api.aws/openai/v1', + 'https://attacker.invalid/v1', + 'http://mtls.api.openai.com/v1', + 'https://mtls.api.openai.com:8443/v1', + 'https://user:password@mtls.api.openai.com/v1', + ])('rejects an unapproved API authority before token exchange: %s', (baseURL) => { + expect(() => new OpenAI(options({ baseURL }))).toThrow(/approved.*mTLS.*origin/iu); + }); + + test('rejects an unapproved API authority inherited from the environment', () => { + vi.stubEnv('OPENAI_BASE_URL', 'https://tenant.openai.azure.com/openai'); + + expect(() => new OpenAI(options())).toThrow(/approved.*mTLS.*origin/iu); + }); + + test.each(['global', 'us', 'eu', 'ae'] as const)( + 'rejects unsupported X.509 data residency selection %s', + (dataResidency) => { + expect(() => new OpenAI(options({ dataResidency }))).toThrow(/residency/iu); + }, + ); + + test('preserves null and undefined residency as ordinary omission', () => { + expect(new OpenAI(options({ dataResidency: null })).baseURL).toBe('https://mtls.api.openai.com/v1'); + expect(new OpenAI(options({ dataResidency: undefined })).baseURL).toBe('https://mtls.api.openai.com/v1'); + }); + + test('rejects a custom fetch before certificate authentication can be intercepted', () => { + const customFetch = vi.fn(async () => Response.json({ data: [] })); + + expect(() => new OpenAI(options({ fetch: customFetch }))).toThrow(/custom fetch/iu); + expect(customFetch).not.toHaveBeenCalled(); + }); + + test.each(['dispatcher', 'agent', 'client', 'tls', 'proxy'])( + 'rejects a client-level %s transport override', + (name) => { + expect(() => new OpenAI(options({ fetchOptions: { [name]: {} } }))).toThrow(/transport/iu); + }, + ); + + test('rejects client-level request-body replacement before certificate presentation', () => { + const send = mockTransportRequests(); + const fetchOptions: NonNullable = {}; + Object.defineProperty(fetchOptions, 'body', { value: 'synthetic-replaced-body', enumerable: true }); + + expect(() => new OpenAI(options({ fetchOptions }))).toThrow(/request body/iu); + expect(send).not.toHaveBeenCalled(); + }); + + test.each(['body', 'headers', 'method', 'signal'])( + 'rejects a mutable client-level %s override before certificate presentation', + async (name) => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + const fetchOptions: NonNullable = {}; + Object.defineProperty(fetchOptions, name, { value: 'synthetic-attacker-override', enumerable: true }); + client.fetchOptions = fetchOptions; + + await expect(client.models.list()).rejects.toThrow(/request body, headers, method, or signal/iu); + expect(send).not.toHaveBeenCalled(); + }, + ); + + test('clones an X.509 client without mistaking its protected transport wrapper for a custom fetch', () => { + const client = new OpenAI(options()); + const cloned = client.withOptions({ timeout: 2500 }); + + expect(cloned.baseURL).toBe('https://mtls.api.openai.com/v1'); + expect(cloned.timeout).toBe(2500); + }); + + test('switches an X.509 client back to ordinary API-key authentication', () => { + const client = new OpenAI(options()); + const switched = client.withOptions({ workloadIdentity: undefined, apiKey: 'synthetic-api-key' }); + + expect(switched.baseURL).toBe('https://api.openai.com/v1'); + expect(switched.apiKey).toBe('synthetic-api-key'); + }); + + test('switches an X.509 client back to ordinary subject-token workload identity', () => { + const client = new OpenAI(options()); + const switched = client.withOptions({ + workloadIdentity: { + identityProviderId: 'synthetic-legacy-provider', + serviceAccountId: 'synthetic-legacy-account', + provider: { tokenType: 'jwt', getToken: async () => 'synthetic-jwt' }, + }, + apiKey: null, + }); + + expect(switched.baseURL).toBe('https://api.openai.com/v1'); + }); + + test('switches an ordinary API-key client into approved X.509 workload identity', () => { + const ordinary = new OpenAI({ apiKey: 'synthetic-api-key' }); + vi.stubEnv('OPENAI_API_KEY', 'synthetic-environment-api-key'); + const switched = ordinary.withOptions({ workloadIdentity: identity(), x509Transport: transport }); + + expect(switched.baseURL).toBe('https://mtls.api.openai.com/v1'); + expect(switched.apiKey).toBeNull(); + }); + + test('rejects pre-aborted API requests before presenting a certificate to the issuer', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + const controller = new AbortController(); + const reason = new Error('synthetic-caller-canceled'); + controller.abort(reason); + + await expect(client.models.list({ signal: controller.signal })).rejects.toMatchObject({ + constructor: APIUserAbortError, + cause: reason, + }); + expect(send).not.toHaveBeenCalled(); + }); + + test('preserves the SDK cancellation error and reason when the issuer request is aborted', async () => { + const controller = new AbortController(); + const reason = new Error('synthetic-mid-exchange-cancellation'); + const send = vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async () => { + controller.abort(reason); + throw reason; + }); + const client = new OpenAI(options({ maxRetries: 0 })); + + await expect(client.models.list({ signal: controller.signal })).rejects.toMatchObject({ + constructor: APIUserAbortError, + cause: reason, + }); + expect(send).toHaveBeenCalledTimes(1); + }); + + test('never presents a certificate after cancellation during request-option preflight', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options({ maxRetries: 0 })); + const caller = new AbortController(); + const reason = new Error('synthetic-preflight-cancellation'); + let reads = 0; + const request = { path: '/models', method: 'get' as const, signal: caller.signal }; + Object.defineProperty(request, 'fetchOptions', { + enumerable: true, + get: () => { + reads += 1; + if (reads === 2) { + caller.abort(reason); + } + return {}; + }, + }); + + await expect(client.request(request)).rejects.toMatchObject({ + constructor: APIUserAbortError, + cause: reason, + }); + expect(send).not.toHaveBeenCalled(); + }); + + test('preserves the caller cancellation reason while consuming an authenticated response body', async () => { + const caller = new AbortController(); + const reason = new Error('synthetic-body-cancellation'); + let responseBody: ReadableStreamDefaultController | undefined; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : new Response( + new ReadableStream({ + start(controller) { + responseBody = controller; + controller.enqueue(new TextEncoder().encode('{"data":[')); + }, + }), + { headers: { 'content-type': 'application/json' } }, + ), + ); + const client = new OpenAI(options({ maxRetries: 0 })); + setTimeout(() => caller.abort(reason), 10); + + try { + await expect(client.models.list({ signal: caller.signal })).rejects.toMatchObject({ + constructor: APIUserAbortError, + cause: reason, + }); + expect(send).toHaveBeenCalledTimes(2); + } finally { + responseBody?.close(); + } + }); + + test('keeps the original monotonic response-body deadline when the wall clock moves backward', async () => { + const wallNow = Date.now.bind(Date); + let responseBody: ReadableStreamDefaultController | undefined; + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, url) => { + if (url.origin === 'https://mtls.auth.openai.com') { + return Response.json(TOKEN_RESPONSE); + } + vi.spyOn(Date, 'now').mockImplementation(() => wallNow() - 5000); + return new Response( + new ReadableStream({ + start(controller) { + responseBody = controller; + controller.enqueue(new TextEncoder().encode('{"data":[')); + }, + }), + { headers: { 'content-type': 'application/json' } }, + ); + }); + const client = new OpenAI(options({ maxRetries: 0, timeout: 50 })); + const startedAt = performance.now(); + + try { + await expect(client.models.list()).rejects.toThrow(/timed out/iu); + expect(performance.now() - startedAt).toBeLessThan(500); + } finally { + responseBody?.close(); + } + }); + + test('rejects request-level transport replacement before presenting a certificate to the issuer', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + + await expect(client.models.list({ fetchOptions: { dispatcher } })).rejects.toThrow(/transport/iu); + expect(send).not.toHaveBeenCalled(); + }); + + test('rejects request-level body replacement before presenting a certificate to the issuer', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + const fetchOptions: NonNullable = {}; + Object.defineProperty(fetchOptions, 'body', { value: 'synthetic-replaced-body', enumerable: true }); + + await expect( + client.request({ + path: '/models', + method: 'post', + body: { intended: 'synthetic-original-body' }, + fetchOptions, + }), + ).rejects.toThrow(/request body/iu); + expect(send).not.toHaveBeenCalled(); + }); + + test('validates the same request fetch-options snapshot used for the final authenticated dispatch', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + const replacement: NonNullable = {}; + Object.defineProperty(replacement, 'body', { value: 'synthetic-attacker-override', enumerable: true }); + let reads = 0; + const request = { path: '/models', method: 'post' as const, body: { synthetic: true } }; + Object.defineProperty(request, 'fetchOptions', { + enumerable: true, + get: () => { + reads += 1; + return reads === 1 ? replacement : {}; + }, + }); + + await expect(client.request(request)).rejects.toThrow(/request body/iu); + expect(send).not.toHaveBeenCalled(); + }); + + test('does not replay a rejected certificate credential when no retry budget remains', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json({ error: { message: 'synthetic rejected credential' } }, { status: 401 }), + ); + const client = new OpenAI(options({ maxRetries: 0 })); + + await expect(client.models.list()).rejects.toThrow(/rejected credential/iu); + expect(send).toHaveBeenCalledTimes(2); + expect(send.mock.calls.filter(([, url]) => url.origin === 'https://mtls.auth.openai.com')).toHaveLength( + 1, + ); + }); + + test('consumes one retry allowance when replaying a rejected certificate credential', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json({ error: { message: 'synthetic rejected credential' } }, { status: 401 }), + ); + const client = new OpenAI(options({ maxRetries: 1 })); + + await expect(client.models.list()).rejects.toThrow(/rejected credential/iu); + expect(send).toHaveBeenCalledTimes(4); + expect(send.mock.calls.filter(([, url]) => url.origin === 'https://mtls.auth.openai.com')).toHaveLength( + 2, + ); + }); + + test.each([401, 429])( + 'does not allow stalled %i response cleanup to block an approved retry', + async (status) => { + const cancellation = Promise.race([]); + let dispatches = 0; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => { + if (url.origin === 'https://mtls.auth.openai.com') { + return Response.json(TOKEN_RESPONSE); + } + dispatches += 1; + return dispatches === 1 + ? new Response(new ReadableStream({ cancel: () => cancellation }), { + status, + headers: { 'retry-after-ms': '1' }, + }) + : Response.json({ data: [] }); + }); + const client = new OpenAI(options({ maxRetries: 1, timeout: 250 })); + + await expect(client.models.list()).resolves.toMatchObject({ data: [] }); + expect(dispatches).toBe(2); + expect(send).toHaveBeenCalledTimes(4); + }, + ); + + test.each(['Authorization', 'api-key', 'x-api-key', 'Proxy-Authorization', 'Host'])( + 'rejects caller-supplied %s before exchanging a workload credential', + async (header) => { + const send = mockTransportRequests(); + const client = new OpenAI(options({ defaultHeaders: { [header]: 'synthetic-conflicting-secret' } })); + + await expect(client.models.list()).rejects.toThrow(/caller-supplied.*credentials/iu); + expect(send).not.toHaveBeenCalled(); + }, + ); + + test('rejects a public API-origin mutation before presenting a certificate to the issuer', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + client.baseURL = 'https://tenant.openai.azure.com/openai'; + + await expect(client.models.list()).rejects.toThrow(/approved.*mTLS.*origin/iu); + expect(send).not.toHaveBeenCalled(); + }); + + test('keeps final API dispatch on its private attested transport when public fetch is replaced', async () => { + const send = mockTransportRequests(); + const attacker = vi.fn(async () => Response.json({ data: [] })); + const client = new OpenAI(options()); + Object.defineProperty(client, 'fetch', { value: attacker }); + + await expect(client.models.list()).resolves.toMatchObject({ data: [] }); + expect(send).toHaveBeenCalledTimes(2); + expect(attacker).not.toHaveBeenCalled(); + }); + + test('preserves direct public buildRequest with an independently scoped workload credential', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + + const built = await client.buildRequest({ path: '/models', method: 'get' }); + + expect(built.url).toBe('https://mtls.api.openai.com/v1/models'); + expect(new Headers(built.req.headers).get('Authorization')).toBe(`Bearer ${ACCESS_TOKEN}`); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[1].origin).toBe('https://mtls.auth.openai.com'); + }); + + test('runs an existing buildRequest override exactly once for direct request construction', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + const build = vi.spyOn(client, 'buildRequest'); + + await client.buildRequest({ path: '/models', method: 'get' }); + + expect(build).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + }); + + test('isolates concurrent logical requests that reuse the exact same caller options object', async () => { + let exchangeCount = 0; + const dispatched = new Set(); + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation( + async (_transport, target, request) => { + if (target.origin === 'https://mtls.auth.openai.com') { + exchangeCount += 1; + const accessToken = `synthetic-concurrent-token-${exchangeCount}`; + await delay(5); + return Response.json({ ...TOKEN_RESPONSE, access_token: accessToken }); + } + dispatched.add(new Headers(request.headers).get('Authorization')); + return Response.json({ data: [] }); + }, + ); + const client = new OpenAI(options()); + const shared = { path: '/models', method: 'get' as const }; + + await Promise.all([client.request(shared), client.request(shared)]); + + expect(exchangeCount).toBe(2); + expect(dispatched).toEqual( + new Set(['Bearer synthetic-concurrent-token-1', 'Bearer synthetic-concurrent-token-2']), + ); + }); + + test('starts a new isolated operation when caller options are reused sequentially', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options({ timeout: 50 })); + const shared = { path: '/models', method: 'get' as const }; + + await client.request(shared); + await delay(60); + await client.request(shared); + + expect(send).toHaveBeenCalledTimes(4); + }); + + test('rejects protected hooks that replace the issued workload bearer', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + const { headers } = request; + if (headers instanceof Headers) { + headers.set('Authorization', 'Bearer synthetic-attacker-bearer'); + } + }, + }); + + await expect(client.models.list()).rejects.toThrow(/issued workload authorization/iu); + expect(send).toHaveBeenCalledTimes(1); + }); + + test.each([401, 503])( + 'never replays a one-shot request body installed by a protected hook after %i', + async (status) => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json({ error: { message: 'synthetic one-shot request failure' } }, { status }), + ); + const client = new OpenAI(options({ maxRetries: 2 })); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + request.body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('synthetic one-shot payload')); + controller.close(); + }, + }); + }, + }); + + await expect( + client.request({ path: '/models', method: 'post', body: { synthetic: true } }), + ).rejects.toThrow(/one-shot request failure/iu); + expect(send).toHaveBeenCalledTimes(2); + }, + ); + + test('rejects protected hooks that disguise a replaced bearer with a spoofed Headers getter', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + const { headers } = request; + if (headers instanceof Headers) { + headers.set('Authorization', 'Bearer synthetic-attacker-bearer'); + Object.defineProperty(headers, 'get', { value: () => `Bearer ${ACCESS_TOKEN}` }); + } + }, + }); + + await expect(client.models.list()).rejects.toThrow(/issued workload authorization/iu); + expect(send).toHaveBeenCalledTimes(1); + }); + + test('rejects forbidden proxy credentials before they can reach an enabled debug logger', async () => { + const secret = 'synthetic-private-proxy-secret'; + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const send = mockTransportRequests(); + const client = new OpenAI(options({ logger, logLevel: 'debug' })); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + if (request.headers instanceof Headers) { + request.headers.set('Proxy-Authorization', secret); + } + }, + }); + + await expect(client.models.list()).rejects.toThrow(/conflicting authentication/iu); + expect(JSON.stringify(logger.debug.mock.calls)).not.toContain(secret); + expect(send).toHaveBeenCalledTimes(1); + }); + + test.each(['fetchWithAuth', 'fetchWithTimeout'])( + 'rejects a %s replacement installed after client construction before exchanging credentials', + async (hook) => { + const send = mockTransportRequests(); + const attacker = vi.fn(async () => Response.json({ data: [] })); + const client = new OpenAI(options()); + Object.defineProperty(client, hook, { value: attacker }); + + await expect(client.models.list()).rejects.toThrow(/overridden fetch dispatch/iu); + expect(send).not.toHaveBeenCalled(); + expect(attacker).not.toHaveBeenCalled(); + }, + ); + + test('keeps private dispatch attestation when a protected hook replaces mutable authentication state', async () => { + const send = mockTransportRequests(); + const attacker = vi.fn(async () => Response.json({ data: [] })); + const client = new OpenAI(options()); + Object.defineProperty(client, 'prepareRequest', { + value: async () => { + Object.defineProperty(client, '_workloadIdentityAuth', { value: undefined }); + Object.defineProperty(client, 'fetchWithTimeout', { value: attacker }); + }, + }); + + await expect(client.models.list()).resolves.toMatchObject({ data: [] }); + expect(send).toHaveBeenCalledTimes(2); + expect(attacker).not.toHaveBeenCalled(); + }); + + test('shares one timeout across issuer authentication and the final API response', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, target, requestOptions) => { + await delay(100, undefined, { signal: requestOptions.signal ?? undefined }); + return target.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json({ data: [] }); + }); + const client = new OpenAI(options({ timeout: 150, maxRetries: 0 })); + + await expect(client.models.list()).rejects.toThrow(/timed out/iu); + expect(send).toHaveBeenCalledTimes(2); + }); + + test('rejects a retry delay that would exceed the original certificate request deadline', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, target) => + target.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json( + { error: { message: 'synthetic rate limit' } }, + { status: 429, headers: { 'retry-after-ms': '250' } }, + ), + ); + const client = new OpenAI(options({ timeout: 100, maxRetries: 1 })); + + await expect(client.models.list()).rejects.toThrow(/timed out/iu); + expect(send).toHaveBeenCalledTimes(2); + }); + + test('retains its request scope and original deadline while consuming a delayed response body', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, target) => { + if (target.origin === 'https://mtls.auth.openai.com') { + return Response.json(TOKEN_RESPONSE); + } + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"data":')); + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ); + }); + const client = new OpenAI(options({ timeout: 60, maxRetries: 1 })); + + await expect(client.models.list()).rejects.toThrow(/timed out/iu); + expect(send).toHaveBeenCalledTimes(2); + }); + + test('rejects unsupported final-dispatch overrides before issuing a workload bearer', () => { + class UnsafeDispatchClient extends OpenAI { + override async fetchWithTimeout(): Promise { + return Response.json({ baseURL: this.baseURL }); + } + } + + const send = mockTransportRequests(); + expect(() => new UnsafeDispatchClient(options())).toThrow(/overridden fetch dispatch/iu); + expect(send).not.toHaveBeenCalled(); + }); + + test('rejects X.509 transport through the legacy Azure client', () => { + const unsafe = { + apiVersion: '2026-01-01', + apiKey: 'synthetic-azure-key', + endpoint: 'https://tenant.openai.azure.com', + x509Transport: transport, + }; + + expect(() => new AzureOpenAI(unsafe as ConstructorParameters[0])).toThrow(/X\.509/iu); + }); + + test('rejects X.509 transport through the legacy Bedrock client', () => { + const unsafe = { + apiKey: 'synthetic-bedrock-key', + baseURL: 'https://bedrock-mantle.us-east-1.api.aws/openai/v1', + x509Transport: transport, + }; + + expect(() => new BedrockOpenAI(unsafe as ConstructorParameters[0])).toThrow( + /Bedrock.*authentication/iu, + ); + }); + + test('does not classify an extensible legacy workload identity with X.509 metadata as certificate auth', async () => { + interface ApplicationWorkloadIdentity extends WorkloadIdentity { + readonly type: 'x509'; + } + + const subjectToken = vi.fn(async () => 'synthetic-jwt-subject-token'); + const legacy: ApplicationWorkloadIdentity = { + type: 'x509', + identityProviderId: 'synthetic-legacy-identity-provider', + serviceAccountId: 'synthetic-legacy-service-account', + provider: { tokenType: 'jwt', getToken: subjectToken }, + }; + const fetch = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input.toString(); + return url === 'https://auth.openai.com/oauth/token' + ? Response.json({ access_token: 'synthetic-legacy-bearer' }) + : Response.json({ data: [] }); + }); + const client = new OpenAI({ apiKey: null, workloadIdentity: legacy, fetch }); + + await client.models.list(); + + expect(subjectToken).toHaveBeenCalledTimes(1); + expect(client.baseURL).toBe('https://api.openai.com/v1'); + }); + + test('uses the exact same attested TLS identity for the real issuer and API handshakes', async () => { + const lab = createX509TestLab(); + const issuer = createMutualTLSServer( + lab, + (_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(TOKEN_RESPONSE)); + }, + lab.issuerServer, + ); + const api = createMutualTLSServer( + lab, + (request, response) => { + if (request.headers.authorization !== `Bearer ${ACCESS_TOKEN}`) { + response.writeHead(401); + response.end(); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ data: [] })); + }, + lab.apiServer, + ); + let proxy: ReturnType | undefined; + let proxyDispatcher: ProxyAgent | undefined; + + try { + const [issuerURL, apiURL] = await Promise.all([listenLoopback(issuer), listenLoopback(api)]); + proxy = createConnectProxy( + lab, + false, + lab.proxyServer, + new Map([ + ['mtls.auth.openai.com:443', issuerURL], + ['mtls.api.openai.com:443', apiURL], + ]), + ); + const proxyURL = await listenLoopback(proxy, false); + proxyDispatcher = new ProxyAgent({ + uri: proxyURL.href, + requestTls: { + ca: lab.certificateAuthority, + cert: lab.firstClient.certificate, + key: lab.firstClient.privateKey, + }, + }); + const approved = createX509Transport({ + runtime: 'node', + dispatcher: proxyDispatcher, + certificateIdentity: 'static', + proxy: 'http-connect', + }); + const client = new OpenAI(options({ x509Transport: approved })); + + const models = await client.models.list(); + + expect(models.data).toEqual([]); + const fingerprint = new X509Certificate(lab.firstClient.certificate).fingerprint256; + expect(issuer.requests).toEqual([ + expect.objectContaining({ + authority: 'mtls.auth.openai.com', + authorization: undefined, + certificateFingerprint: fingerprint, + path: '/oauth/token', + serverName: 'mtls.auth.openai.com', + }), + ]); + expect(api.requests).toEqual([ + expect.objectContaining({ + authority: 'mtls.api.openai.com', + authorization: `Bearer ${ACCESS_TOKEN}`, + certificateFingerprint: fingerprint, + path: '/v1/models', + serverName: 'mtls.api.openai.com', + }), + ]); + expect(proxy.requests.map((request) => request.path)).toEqual([ + 'mtls.auth.openai.com:443', + 'mtls.api.openai.com:443', + ]); + } finally { + await proxyDispatcher?.close(); + await closeObservedServers(issuer, api, ...(proxy ? [proxy] : [])); + } + }); +}); diff --git a/tests/utils/x509-test-lab.ts b/tests/utils/x509-test-lab.ts index 0d9258f028..d573fa22b1 100644 --- a/tests/utils/x509-test-lab.ts +++ b/tests/utils/x509-test-lab.ts @@ -19,6 +19,7 @@ export interface X509TestLab { proxyCertificateAuthority: Buffer; server: TestCertificate; issuerServer: TestCertificate; + apiServer: TestCertificate; proxyServer: TestCertificate; firstClient: TestCertificate; secondClient: TestCertificate; @@ -161,6 +162,7 @@ export function createX509TestLab(): X509TestLab { proxyCertificateAuthority: proxyAuthority.certificate, server: issueCertificate('localhost', 'server', workloadAuthority), issuerServer: issueCertificate('mtls.auth.openai.com', 'server', workloadAuthority), + apiServer: issueCertificate('mtls.api.openai.com', 'server', workloadAuthority), proxyServer: issueCertificate('localhost', 'server', proxyAuthority), firstClient: issueCertificate('workload-a', 'client', workloadAuthority), secondClient: issueCertificate('workload-b', 'client', workloadAuthority), From 650ee549f536200e260261ca31f4fc06f8f5634b Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 20:43:17 +0000 Subject: [PATCH 02/18] fix(auth): close X.509 retry, cancellation, and WebSocket review gaps --- src/auth/x509-transport.ts | 15 +- src/beta/realtime/internal-base.ts | 2 + src/client.ts | 80 +++++- src/internal/auth/x509-token-exchange.ts | 69 ++++-- src/internal/auth/x509-transport-registry.ts | 37 +++ .../auth/x509-transport-state-browser.ts | 21 ++ src/internal/auth/x509-transport-state.cts | 28 +++ .../auth/x509-workload-identity-auth.ts | 88 ++++++- src/realtime/internal-base.ts | 2 + src/resources/beta/responses/internal-base.ts | 2 + src/resources/responses/internal-base.ts | 2 + tests/auth/x509-transport.test.ts | 3 + .../x509-workload-review-regressions.test.ts | 234 ++++++++++++++++++ 13 files changed, 550 insertions(+), 33 deletions(-) create mode 100644 tests/lib/x509-workload-review-regressions.test.ts diff --git a/src/auth/x509-transport.ts b/src/auth/x509-transport.ts index b69ce1e2ef..4dd40c47d8 100644 --- a/src/auth/x509-transport.ts +++ b/src/auth/x509-transport.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import { setTimeout as delay } from 'node:timers/promises'; import { createX509Transport as createCapability, registerX509Transport, @@ -6,14 +7,25 @@ import { } 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(); 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, @@ -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; } diff --git a/src/beta/realtime/internal-base.ts b/src/beta/realtime/internal-base.ts index 222f197fab..0a5858311e 100644 --- a/src/beta/realtime/internal-base.ts +++ b/src/beta/realtime/internal-base.ts @@ -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 { @@ -177,6 +178,7 @@ export function buildRealtimeURL( client: Pick, connection: string | RealtimeConnectionConfig, ): URL { + assertX509WebSocketSupported(client); const config: RealtimeConnectionConfig = typeof connection === 'string' ? { model: connection } : connection; const baseURL = client.baseURL; diff --git a/src/client.ts b/src/client.ts index 6e7dcc60c8..725f6805db 100644 --- a/src/client.ts +++ b/src/client.ts @@ -28,6 +28,7 @@ import { snapshotX509RequestOptions, } from './internal/auth/x509-workload-identity-auth'; import type { X509Transport } from './internal/auth/x509-transport-registry'; +import { markApprovedX509Client } from '#x509-transport-state'; import { OAuthError, SubjectTokenProviderError } from './core/error'; import { type ConversationCursorPageParams, @@ -610,6 +611,7 @@ export class OpenAI { this._workloadIdentityAuth = authentication; this.#x509Fetch = authentication.fetch(); this.fetch = this.#x509Fetch; + markApprovedX509Client(this); } else { this._workloadIdentityAuth = new WorkloadIdentityAuth(workloadIdentity, this.fetch); } @@ -1040,6 +1042,44 @@ export class OpenAI { } } + /** Keeps terminal X.509 error-body consumption inside the original logical request deadline. */ + private async readX509ResponseError( + response: Response, + options: FinalRequestOptions, + timeout: number, + controller: AbortController, + authentication: X509WorkloadIdentityAuth, + ): Promise { + const deadline = new AbortController(); + const callerSignal = options.signal; + const cancel = () => deadline.abort(callerSignal?.reason); + callerSignal?.addEventListener('abort', cancel, { once: true }); + if (callerSignal?.aborted) { + cancel(); + } + + try { + const remaining = authentication.remainingTimeout(options, timeout); + const expiration = authentication.waitForRetry(remaining, deadline.signal).then(() => { + controller.abort(); + void Shims.CancelReadableStream(response.body).catch(() => undefined); + throw new Errors.APIConnectionTimeoutError(); + }); + return await Promise.race([ + response.text().catch(() => 'X.509 workload identity API response body could not be read.'), + expiration, + ]); + } catch (error) { + if (callerSignal?.aborted) { + throw this._makeUserAbortError(callerSignal); + } + throw error; + } finally { + callerSignal?.removeEventListener('abort', cancel); + deadline.abort(); + } + } + private async makeRequest( optionsInput: PromiseOrValue, retriesRemaining: number | null, @@ -1053,11 +1093,25 @@ export class OpenAI { await this.prepareOptions(options); - const { req, url, timeout } = await this.buildRequest(options, { - retryCount: maxRetries - retriesRemaining, - }); const x509Authentication = this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; + let built: { req: FinalizedRequestInit; url: string; timeout: number }; + try { + built = await this.buildRequest(options, { + retryCount: maxRetries - retriesRemaining, + }); + } catch (error) { + if (x509Authentication && retriesRemaining && X509WorkloadIdentityAuth.isRetryableFailure(error)) { + return await this.retryRequest( + options, + retriesRemaining, + retryOfRequestLogID ?? 'x509-token-exchange', + X509WorkloadIdentityAuth.retryHeaders(error), + ); + } + throw error; + } + const { req, url, timeout } = built; x509Authentication?.bindRequest(options, req, this.adminAPIKey); let hasStreamingBody = options.__metadata?.['hasStreamingBody'] === true; @@ -1093,6 +1147,9 @@ export class OpenAI { } const security = options.__security ?? { bearerAuth: true }; + if (x509Authentication && options.signal && req.signal !== options.signal) { + req.signal = req.signal ? AbortSignal.any([options.signal, req.signal]) : options.signal; + } // Request hooks may replace the caller signal before it reaches fetch. const controller = this.fetchWithTimeout === OpenAI.prototype.fetchWithTimeout @@ -1248,7 +1305,9 @@ export class OpenAI { loggerFor(this).info(`${responseInfo} - ${retryMessage}`); - const errText = await response.text().catch((err: any) => castToError(err).message); + const errText = x509Authentication + ? await this.readX509ResponseError(response, options, timeout, controller, x509Authentication) + : await response.text().catch((err: any) => castToError(err).message); const errJSON = safeJSON(errText) as any; const errMessage = errJSON ? undefined : errText; @@ -1281,6 +1340,7 @@ export class OpenAI { ); const continueRequest = x509Authentication?.continuation(); + x509Authentication?.releaseRequestCredentials(); this.#responseAttempts.set(controller, { timeout, retriesRemaining, @@ -1454,13 +1514,19 @@ export class OpenAI { const maxRetries = options.maxRetries ?? this.maxRetries; timeoutMillis = this.calculateDefaultRetryTimeoutMillis(retriesRemaining, maxRetries); } - if (this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth) { - const remaining = this._workloadIdentityAuth.remainingTimeout(options, options.timeout ?? this.timeout); + const x509Authentication = + this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; + if (x509Authentication) { + const remaining = x509Authentication.remainingTimeout(options, options.timeout ?? this.timeout); if (timeoutMillis >= remaining) { throw new Errors.APIConnectionTimeoutError(); } } - await sleep(timeoutMillis); + if (x509Authentication) { + await x509Authentication.waitForRetry(timeoutMillis, options.signal); + } else { + await sleep(timeoutMillis); + } return this.makeRequest(options, retriesRemaining - 1, requestLogID); } diff --git a/src/internal/auth/x509-token-exchange.ts b/src/internal/auth/x509-token-exchange.ts index b65ef326de..31519c64af 100644 --- a/src/internal/auth/x509-token-exchange.ts +++ b/src/internal/auth/x509-token-exchange.ts @@ -7,6 +7,13 @@ import { } from '../../core/error'; import { hasOwn } from '../utils/values'; import { sendX509Request } from './x509-transport-capability'; +import { isRetryableX509TransportFailure } from './x509-transport-registry'; +import { + isTransientX509ConnectionError, + markRetryableX509IssuerError, + markTransientX509ConnectionError, + rememberX509OAuthError, +} from '#x509-transport-state'; import type { X509ExchangedToken, X509Transport } from './x509-transport-registry'; export type { X509ExchangedToken } from './x509-transport-registry'; @@ -20,6 +27,14 @@ const SAFE_OAUTH_ERRORS = new Set(['invalid_grant', 'invalid_subject_token', 'to const SAFE_RESPONSE_HEADERS = ['retry-after', 'retry-after-ms', 'x-should-retry', 'x-request-id']; const MAX_TOKEN_EXCHANGE_DURATION_MS = 5000; +function transientConnectionError(message: string, failure: unknown): APIConnectionError { + const error = new APIConnectionError({ message }); + if (isRetryableX509TransportFailure(failure)) { + markTransientX509ConnectionError(error); + } + return error; +} + /** Parameters for one certificate-authenticated OAuth exchange. */ export interface X509TokenExchangeOptions { /** Frozen, caller-owned certificate transport shared with the eventual API request. */ @@ -84,12 +99,12 @@ async function readResponseBody(response: Response, signal?: AbortSignal): Promi chunks.push(chunk.value); totalBytes += chunk.value.byteLength; } - } catch { + } catch (error) { cancel(); if (signal?.aborted) { signal.throwIfAborted(); } - throw new APIConnectionError({ message: 'X.509 workload identity token response could not be read.' }); + throw transientConnectionError('X.509 workload identity token response could not be read.', error); } finally { signal?.removeEventListener('abort', cancel); reader.releaseLock(); @@ -170,7 +185,11 @@ function readOAuthErrorCode(value: unknown): string | undefined { return undefined; } -async function oauthError(response: Response, signal?: AbortSignal): Promise { +async function oauthError( + response: Response, + signal?: AbortSignal, + callerSignal?: AbortSignal, +): Promise { let errorCode: { error: string } | undefined; try { @@ -182,14 +201,23 @@ async function oauthError(response: Response, signal?: AbortSignal): Promise { - timeoutController.abort( - new APIConnectionTimeoutError({ message: 'X.509 workload identity token exchange timed out.' }), - ); + const error = new APIConnectionTimeoutError({ + message: 'X.509 workload identity token exchange timed out.', + }); + markTransientX509ConnectionError(error); + timeoutController.abort(error); }, MAX_TOKEN_EXCHANGE_DURATION_MS); timeout.unref(); @@ -236,9 +266,9 @@ export async function exchangeX509Token(options: X509TokenExchangeOptions): Prom redirect: 'manual', signal, }); - } catch { + } catch (error) { signal.throwIfAborted(); - throw new APIConnectionError({ message: 'X.509 workload identity token exchange connection failed.' }); + throw transientConnectionError('X.509 workload identity token exchange connection failed.', error); } signal.throwIfAborted(); @@ -257,22 +287,23 @@ export async function exchangeX509Token(options: X509TokenExchangeOptions): Prom } } if (response.status === 400 || response.status === 401 || response.status === 403) { - throw await oauthError(response, signal); + throw await oauthError(response, signal, options.signal); } cancelResponseBody(response); - throw APIError.generate( + const retryableStatus = + response.status === 408 || response.status === 409 || response.status === 429 || response.status >= 500; + const headers = safeResponseHeaders(response, retryableStatus); + const error = APIError.generate( response.status, undefined, 'X.509 workload identity token exchange failed.', - safeResponseHeaders( - response, - response.status === 408 || - response.status === 409 || - response.status === 429 || - response.status >= 500, - ), + headers, ); + if (retryableStatus && headers.get('x-should-retry') !== 'false') { + markRetryableX509IssuerError(error); + } + throw error; } finally { clearTimeout(timeout); } diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index 8f6f55625b..e6ec67cb04 100644 --- a/src/internal/auth/x509-transport-registry.ts +++ b/src/internal/auth/x509-transport-registry.ts @@ -1,6 +1,40 @@ import { OpenAIError } from '../../core/error'; import { findRegisteredX509Transport } from '#x509-transport-state'; +const transientX509TransportCodes = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'EPIPE', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_BODY_TIMEOUT', + 'UND_ERR_SOCKET', +]); + +/** Retries only known temporary failures when neither error layer reports a permanent code. */ +export function isRetryableX509TransportFailure(failure: unknown): boolean { + const cause = + typeof failure === 'object' && failure !== null + ? Object.getOwnPropertyDescriptor(failure, 'cause')?.value + : undefined; + let retryable = false; + for (const candidate of [failure, cause]) { + const code = + typeof candidate === 'object' && candidate !== null + ? Object.getOwnPropertyDescriptor(candidate, 'code')?.value + : undefined; + if (typeof code === 'string') { + if (!transientX509TransportCodes.has(code)) { + return false; + } + retryable = true; + } + } + return retryable; +} + export const x509TransportBrand: unique symbol = Symbol('X.509 transport capability'); /** Opaque identity of one frozen, caller-owned, explicitly attested X.509 transport. */ @@ -47,6 +81,9 @@ export interface RegisteredX509Transport { /** Re-enters the original request scope when response parsing resumes outside its promise chain. */ resume: (scope: X509RequestScope, operation: () => T) => T; + + /** Waits for retry backoff using an abortable timer that keeps an active request alive. */ + sleep: (duration: number, signal?: AbortSignal | null) => Promise; } /** Resolves a previously registered opaque capability without importing an optional transport peer. */ diff --git a/src/internal/auth/x509-transport-state-browser.ts b/src/internal/auth/x509-transport-state-browser.ts index 9579c79348..255145aec1 100644 --- a/src/internal/auth/x509-transport-state-browser.ts +++ b/src/internal/auth/x509-transport-state-browser.ts @@ -1,6 +1,9 @@ /** Browser-safe capability state keeps CommonJS outside the ordinary SDK ESM graph. */ const registeredX509Transports = new WeakMap(); const transientX509ConnectionErrors = new WeakSet(); +const retryableX509IssuerErrors = new WeakSet(); +const approvedX509Clients = new WeakSet(); +const approvedX509OAuthErrors = new WeakMap(); /** Looks up an opaque capability without exposing the registry itself. */ export const findRegisteredX509Transport = WeakMap.prototype.get.bind(registeredX509Transports); @@ -13,3 +16,21 @@ export const markTransientX509ConnectionError = WeakSet.prototype.add.bind(trans /** Checks the private transient classification without retaining caller-owned errors. */ export const isTransientX509ConnectionError = WeakSet.prototype.has.bind(transientX509ConnectionErrors); + +/** Privately brands retryable issuer failures without exposing classification state. */ +export const markRetryableX509IssuerError = WeakSet.prototype.add.bind(retryableX509IssuerErrors); + +/** Recognizes trusted issuer failures without evaluating caller-controlled properties. */ +export const isRetryableX509IssuerError = WeakSet.prototype.has.bind(retryableX509IssuerErrors); + +/** Brands validated clients without exposing their mutable options or authentication fields. */ +export const markApprovedX509Client = WeakSet.prototype.add.bind(approvedX509Clients); + +/** Recognizes private client ownership without caller-visible markers. */ +export const isApprovedX509Client = WeakSet.prototype.has.bind(approvedX509Clients); + +/** Stores trusted OAuth metadata without exposing it to unrelated callers. */ +export const rememberX509OAuthError = WeakMap.prototype.set.bind(approvedX509OAuthErrors); + +/** Retrieves trusted metadata when public OAuth errors cross module formats. */ +export const findX509OAuthError = WeakMap.prototype.get.bind(approvedX509OAuthErrors); diff --git a/src/internal/auth/x509-transport-state.cts b/src/internal/auth/x509-transport-state.cts index 037b61dab6..832e245f12 100644 --- a/src/internal/auth/x509-transport-state.cts +++ b/src/internal/auth/x509-transport-state.cts @@ -1,8 +1,36 @@ /** One lexical capability registry remains authoritative across mixed module formats. */ const registeredX509Transports = new WeakMap(); +const transientX509ConnectionErrors = new WeakSet(); +const retryableX509IssuerErrors = new WeakSet(); +const approvedX509Clients = new WeakSet(); +const approvedX509OAuthErrors = 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); + +/** Privately brands sanitized connection errors shared across CommonJS and ESM clients. */ +export const markTransientX509ConnectionError = WeakSet.prototype.add.bind(transientX509ConnectionErrors); + +/** Recognizes a transient connection without trusting public error properties. */ +export const isTransientX509ConnectionError = WeakSet.prototype.has.bind(transientX509ConnectionErrors); + +/** Privately brands issuer-generated retryable HTTP failures across module formats. */ +export const markRetryableX509IssuerError = WeakSet.prototype.add.bind(retryableX509IssuerErrors); + +/** Recognizes only retryable HTTP errors produced by the trusted certificate exchange. */ +export const isRetryableX509IssuerError = WeakSet.prototype.has.bind(retryableX509IssuerErrors); + +/** Brands only clients whose transport capability was successfully validated. */ +export const markApprovedX509Client = WeakSet.prototype.add.bind(approvedX509Clients); + +/** Recognizes immutable client ownership across mixed CommonJS and ESM helpers. */ +export const isApprovedX509Client = WeakSet.prototype.has.bind(approvedX509Clients); + +/** Records the sanitized OAuth response without trusting mutable public error properties. */ +export const rememberX509OAuthError = WeakMap.prototype.set.bind(approvedX509OAuthErrors); + +/** Retrieves trusted OAuth metadata for public cross-module error normalization. */ +export const findX509OAuthError = WeakMap.prototype.get.bind(approvedX509OAuthErrors); diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index f790f58d6d..4bd6895882 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -1,4 +1,4 @@ -import { APIConnectionTimeoutError, APIUserAbortError, OpenAIError } from '../../core/error'; +import { APIConnectionTimeoutError, APIUserAbortError, OAuthError, OpenAIError } from '../../core/error'; import type { WorkloadIdentity, X509WorkloadIdentity } from '../../auth/types'; import type { Fetch } from '../builtin-types'; import { buildHeaders } from '../headers'; @@ -7,6 +7,12 @@ import type { FinalRequestOptions } from '../request-options'; import type { MergedRequestInit } from '../types'; import { hasOwn } from '../utils/values'; import { resolveX509Transport } from './x509-transport-registry'; +import { + isApprovedX509Client, + findX509OAuthError, + isRetryableX509IssuerError, + isTransientX509ConnectionError, +} from '#x509-transport-state'; import type { RegisteredX509Transport, X509RequestScope, X509Transport } from './x509-transport-registry'; /** Sole API authority approved for OpenAI X.509 workload-identity federation. */ @@ -79,10 +85,33 @@ export function isX509WorkloadIdentity( return false; } - const discriminator = Object.getOwnPropertyDescriptor(identity, 'type'); - return ( - !!discriminator && 'value' in discriminator && discriminator.value === 'x509' && !('provider' in identity) - ); + if ('provider' in identity) { + return false; + } + + let current: object | null = identity; + while (current !== null && current !== Object.prototype) { + const discriminator = Object.getOwnPropertyDescriptor(current, 'type'); + if (discriminator) { + if (!('value' in discriminator)) { + throw new OpenAIError('X.509 workload identity type must be a plain data property.'); + } + return discriminator.value === 'x509'; + } + current = Object.getPrototypeOf(current) as object | null; + } + return false; +} + +/** Rejects unsupported WebSocket authentication before any connection or credential side effect. */ +export function assertX509WebSocketSupported(client: unknown): void { + if (!client || typeof client !== 'object') { + return; + } + + if (isApprovedX509Client(client)) { + throw new OpenAIError('X.509 workload identity does not support WebSocket connections.'); + } } /** Rejects every destination outside the sole enrolled, global X.509 API authority. */ @@ -165,10 +194,19 @@ export class X509WorkloadIdentityAuth { /** Binds deferred response parsing to the original logical request and its unchanged deadline. */ continuation(): (operation: () => T) => T { - const scope = this.#scope(); + const { wallStartedAt, monotonicStartedAt } = this.#scope(); + const scope: X509RequestScope = { wallStartedAt, monotonicStartedAt }; return (operation) => this.#transport.resume(scope, operation); } + /** Removes dispatched bearer material before settled request promises can retain their scope. */ + releaseRequestCredentials(): void { + const scope = this.#scope(); + delete scope.token; + delete scope.headers; + delete scope.authorization; + } + /** Returns the original authentication start so response consumption shares its request deadline. */ requestStartedAt(_options: FinalRequestOptions): number | undefined { return this.#transport.current()?.wallStartedAt; @@ -192,6 +230,36 @@ export class X509WorkloadIdentityAuth { return remaining; } + /** Cancels active retry timers promptly without changing public caller-abort semantics. */ + async waitForRetry(duration: number, signal?: AbortSignal | null): Promise { + try { + await this.#transport.sleep(duration, signal); + } catch (error) { + if (signal?.aborted) { + throw userAbortError(signal); + } + throw error; + } + } + + /** Trusts only issuer or connection failures privately branded by the approved transport. */ + static isRetryableFailure(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (isTransientX509ConnectionError(error) || isRetryableX509IssuerError(error)) + ); + } + + /** Reads safe retry hints only from a privately branded, sanitized issuer response. */ + static retryHeaders(error: unknown): Headers | undefined { + if (!error || typeof error !== 'object' || !isRetryableX509IssuerError(error)) { + return undefined; + } + const headers: unknown = Object.getOwnPropertyDescriptor(error, 'headers')?.value; + return headers instanceof Headers ? headers : undefined; + } + /** Exchanges the exact certificate capability selected for the matching API dispatch. */ async getToken( options?: FinalRequestOptions, @@ -224,6 +292,14 @@ export class X509WorkloadIdentityAuth { if (callerSignal?.aborted) { throw userAbortError(callerSignal); } + if (error && typeof error === 'object' && !(error instanceof OAuthError)) { + const oauth: + | { status: 400 | 401 | 403; error: { error: string } | undefined; headers: Headers } + | undefined = findX509OAuthError(error); + if (oauth) { + throw new OAuthError(oauth.status, oauth.error, oauth.headers); + } + } throw error; } finally { dispose(); diff --git a/src/realtime/internal-base.ts b/src/realtime/internal-base.ts index 382034784f..ef7dca20e4 100644 --- a/src/realtime/internal-base.ts +++ b/src/realtime/internal-base.ts @@ -8,6 +8,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 { @@ -252,6 +253,7 @@ export function buildRealtimeURL( client: Pick, connection: string | RealtimeConnectionConfig, ): URL { + assertX509WebSocketSupported(client); const config: RealtimeConnectionConfig = typeof connection === 'string' ? { model: connection } : connection; const baseURL = client.baseURL; diff --git a/src/resources/beta/responses/internal-base.ts b/src/resources/beta/responses/internal-base.ts index ec88532a99..1ba1eab748 100644 --- a/src/resources/beta/responses/internal-base.ts +++ b/src/resources/beta/responses/internal-base.ts @@ -4,6 +4,7 @@ import * as ResponsesAPI from './responses'; import { OpenAI } from '../../../client'; import { EventEmitter } from '../../../core/EventEmitter'; import { OpenAIError } from '../../../core/error'; +import { assertX509WebSocketSupported } from '../../../internal/auth/x509-workload-identity-auth'; import type { RawWebSocketData, ReconnectingEvent, UnsentMessage } from '../../../internal/ws'; @@ -100,6 +101,7 @@ export abstract class ResponsesEmitter extends EventEmitter { } export function buildURL(client: OpenAI, parameters: Record): URL { + assertX509WebSocketSupported(client); const { ...query } = parameters; const endpoint = '/responses'; const url = new URL(client.buildURL(endpoint, query, undefined)); diff --git a/src/resources/responses/internal-base.ts b/src/resources/responses/internal-base.ts index d5b3c07097..0ac61b01f7 100644 --- a/src/resources/responses/internal-base.ts +++ b/src/resources/responses/internal-base.ts @@ -4,6 +4,7 @@ import * as ResponsesAPI from './responses'; import { OpenAI } from '../../client'; import { EventEmitter } from '../../core/EventEmitter'; import { OpenAIError } from '../../core/error'; +import { assertX509WebSocketSupported } from '../../internal/auth/x509-workload-identity-auth'; import type { RawWebSocketData, ReconnectingEvent, UnsentMessage } from '../../internal/ws'; @@ -96,6 +97,7 @@ export abstract class ResponsesEmitter extends EventEmitter { } export function buildURL(client: OpenAI, parameters: Record): URL { + assertX509WebSocketSupported(client); const { ...query } = parameters; const endpoint = '/responses'; const url = new URL(client.buildURL(endpoint, query, undefined)); diff --git a/tests/auth/x509-transport.test.ts b/tests/auth/x509-transport.test.ts index c93aa4dea9..1ff7f19985 100644 --- a/tests/auth/x509-transport.test.ts +++ b/tests/auth/x509-transport.test.ts @@ -81,6 +81,7 @@ describe('explicit X.509 transport capability', () => { run: (operation) => operation(), current: vi.fn(), resume: (_scope, operation) => operation(), + sleep: vi.fn(), }), ).toThrow(/genuine.*capability/iu); expect(dispatch).not.toHaveBeenCalled(); @@ -101,6 +102,7 @@ describe('explicit X.509 transport capability', () => { run: (operation) => operation(), current: vi.fn(), resume: (_scope, operation) => operation(), + sleep: vi.fn(), }), ).toThrow(/more than once/iu); expect(dispatch).not.toHaveBeenCalled(); @@ -125,6 +127,7 @@ describe('explicit X.509 transport capability', () => { run: (operation) => operation(), current: vi.fn(), resume: (_scope, operation) => operation(), + sleep: vi.fn(), }), ).toThrow(/genuine.*capability/iu); expect(dispatch).not.toHaveBeenCalled(); diff --git a/tests/lib/x509-workload-review-regressions.test.ts b/tests/lib/x509-workload-review-regressions.test.ts new file mode 100644 index 0000000000..e62ae473cb --- /dev/null +++ b/tests/lib/x509-workload-review-regressions.test.ts @@ -0,0 +1,234 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { Agent } from 'undici'; +import { vi } from 'vitest'; + +import OpenAI, { APIConnectionTimeoutError, APIUserAbortError } from 'openai'; +import type { ClientOptions } from 'openai'; +import { createX509Transport } from 'openai/auth/x509-transport'; +import type { X509Transport } from 'openai/auth/x509-transport'; +import * as transportCapability from 'openai/internal/auth/x509-transport-capability'; +import { isRetryableX509TransportFailure } from 'openai/internal/auth/x509-transport-registry'; +import { OpenAIRealtimeWebSocket as StableNativeRealtime } from 'openai/realtime/websocket'; +import { OpenAIRealtimeWS as StableNodeRealtime } from 'openai/realtime/ws'; +import { OpenAIRealtimeWebSocket as BetaNativeRealtime } from 'openai/beta/realtime/websocket'; +import { OpenAIRealtimeWS as BetaNodeRealtime } from 'openai/beta/realtime/ws'; +import { ResponsesWS as StableResponsesWS } from 'openai/resources/responses/ws'; +import { ResponsesWS as BetaResponsesWS } from 'openai/resources/beta/responses/ws'; + +const TOKEN_RESPONSE = { + access_token: 'synthetic-review-bearer', + token_type: 'Bearer', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + expires_in: 3600, +}; + +let dispatcher: Agent; +let transport: X509Transport; + +function options(overrides: Partial = {}): ClientOptions { + return { + apiKey: null, + maxRetries: 0, + workloadIdentity: { + type: 'x509', + identityProviderId: 'synthetic-review-provider', + serviceAccountId: 'synthetic-review-account', + }, + x509Transport: transport, + ...overrides, + }; +} + +beforeEach(() => { + dispatcher = new Agent(); + transport = createX509Transport({ + runtime: 'node', + dispatcher, + certificateIdentity: 'static', + proxy: 'direct', + }); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await dispatcher.close(); +}); + +describe('X.509 review regressions', () => { + test.each([408, 409, 429, 500, 503])('retries trusted issuer status %i', async (status) => { + let issuerRequests = 0; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => { + if (url.origin === 'https://mtls.auth.openai.com') { + issuerRequests += 1; + return issuerRequests === 1 + ? new Response(null, { status, headers: { 'retry-after-ms': '1' } }) + : Response.json(TOKEN_RESPONSE); + } + return Response.json({ data: [] }); + }); + + await new OpenAI(options({ maxRetries: 1 })).models.list(); + + expect(issuerRequests).toBe(2); + expect(send).toHaveBeenCalledTimes(3); + }); + + test('honors an explicit issuer retry denial', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockResolvedValue(new Response(null, { status: 503, headers: { 'x-should-retry': 'false' } })); + + await expect(new OpenAI(options({ maxRetries: 2 })).models.list()).rejects.toMatchObject({ + status: 503, + }); + expect(send).toHaveBeenCalledTimes(1); + }); + + test('cancels API retry backoff immediately when its caller aborts', async () => { + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : new Response(null, { status: 503, headers: { 'retry-after-ms': '500' } }), + ); + const controller = new AbortController(); + const startedAt = performance.now(); + const pending = new OpenAI(options({ maxRetries: 1, timeout: 1000 })).models.list({ + signal: controller.signal, + }); + await delay(20); + controller.abort(new Error('synthetic-retry-canceled')); + + await expect(pending).rejects.toBeInstanceOf(APIUserAbortError); + expect(performance.now() - startedAt).toBeLessThan(350); + }); + + test('keeps terminal API error-body consumption inside its request deadline', async () => { + const stream = new ReadableStream({ + pull: async () => { + await Promise.race([]); + }, + }); + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : new Response(stream, { status: 400 }), + ); + + await expect(new OpenAI(options({ timeout: 35 })).models.list()).rejects.toBeInstanceOf( + APIConnectionTimeoutError, + ); + }); + + test('preserves caller cancellation when a protected hook replaces the request signal', async () => { + class SignalReplacingClient extends OpenAI { + readonly replacementSignal = new AbortController().signal; + + protected override async prepareRequest(request: RequestInit): Promise { + request.signal = this.replacementSignal; + } + } + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, url, request) => { + if (url.origin === 'https://mtls.auth.openai.com') { + return Response.json(TOKEN_RESPONSE); + } + await delay(500, undefined, { signal: request.signal ?? undefined }); + return Response.json({ data: [] }); + }); + const controller = new AbortController(); + const startedAt = performance.now(); + const pending = new SignalReplacingClient(options()).models.list({ signal: controller.signal }); + await delay(20); + controller.abort(new Error('synthetic-original-caller-canceled')); + + await expect(pending).rejects.toBeInstanceOf(APIUserAbortError); + expect(performance.now() - startedAt).toBeLessThan(350); + }); + + test('recognizes an inherited plain-data X.509 identity discriminator', () => { + const inheritedIdentity = Object.assign(Object.create({ type: 'x509' }) as object, { + identityProviderId: 'synthetic-inherited-provider', + serviceAccountId: 'synthetic-inherited-account', + }); + + const client = new OpenAI( + options({ workloadIdentity: inheritedIdentity as ClientOptions['workloadIdentity'] }), + ); + + expect(client.baseURL).toBe('https://mtls.api.openai.com/v1'); + }); + + test.each(['own', 'inherited'] as const)( + 'rejects an %s accessor discriminator without invoking it', + (location) => { + const getter = vi.fn(() => 'x509'); + const base = { + identityProviderId: 'synthetic-accessor-provider', + serviceAccountId: 'synthetic-accessor-account', + }; + const workloadIdentity = + location === 'own' + ? Object.defineProperty(base, 'type', { get: getter }) + : Object.assign(Object.create(Object.defineProperty({}, 'type', { get: getter })) as object, base); + + expect( + () => + new OpenAI(options({ workloadIdentity: workloadIdentity as ClientOptions['workloadIdentity'] })), + ).toThrow(/plain data property/iu); + expect(getter).not.toHaveBeenCalled(); + }, + ); + + test.each([ + ['permanent outer TLS code', 'ERR_TLS_CERT_ALTNAME_INVALID', 'ECONNRESET'], + ['permanent nested TLS code', 'ECONNRESET', 'ERR_TLS_CERT_ALTNAME_INVALID'], + ['permanent decompression', 'Z_DATA_ERROR', 'ECONNRESET'], + ])('never retries a transport failure with a %s', (_label, outer, nested) => { + const failure = Object.assign(new Error('synthetic mixed transport failure'), { + code: outer, + cause: { code: nested }, + }); + + expect(isRetryableX509TransportFailure(failure)).toBe(false); + }); + + test('accepts a wrapper around a known transient transport code', () => { + const failure = Object.assign(new Error('synthetic temporary transport failure'), { + cause: { code: 'ECONNRESET' }, + }); + + expect(isRetryableX509TransportFailure(failure)).toBe(true); + }); + + const websocketSurfaces = [ + ['stable Responses', (client: OpenAI) => new StableResponsesWS(client)], + ['beta Responses', (client: OpenAI) => new BetaResponsesWS(client)], + ['stable Node Realtime', (client: OpenAI) => new StableNodeRealtime({ model: 'gpt-realtime' }, client)], + ['beta Node Realtime', (client: OpenAI) => new BetaNodeRealtime({ model: 'gpt-realtime' }, client)], + [ + 'stable native Realtime', + (client: OpenAI) => new StableNativeRealtime({ model: 'gpt-realtime' }, client), + ], + ['beta native Realtime', (client: OpenAI) => new BetaNativeRealtime({ model: 'gpt-realtime' }, client)], + ] as const; + + test.each(websocketSurfaces)('rejects %s before opening an unsupported socket', (_name, open) => { + const client = new OpenAI(options()); + + expect(() => open(client)).toThrow(/X\.509.*WebSocket/iu); + }); + + test('keeps WebSockets disabled after the original identity object is mutated', () => { + const configuration = options(); + const client = new OpenAI(configuration); + const identity = configuration.workloadIdentity; + if (!identity) { + throw new Error('Expected a synthetic workload identity.'); + } + Object.defineProperty(identity, 'type', { value: 'legacy' }); + + expect(() => new StableResponsesWS(client)).toThrow(/X\.509.*WebSocket/iu); + expect(() => new StableNodeRealtime({ model: 'gpt-realtime' }, client)).toThrow(/X\.509.*WebSocket/iu); + }); +}); From bc9050a2b258f96dd3d9bd39ed3fa9ba149d8df8 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 20:50:14 +0000 Subject: [PATCH 03/18] fix(auth): preserve caller cancellation with supported TypeScript floors --- src/client.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/client.ts b/src/client.ts index 725f6805db..49b556a383 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1147,13 +1147,13 @@ export class OpenAI { } const security = options.__security ?? { bearerAuth: true }; - if (x509Authentication && options.signal && req.signal !== options.signal) { - req.signal = req.signal ? AbortSignal.any([options.signal, req.signal]) : options.signal; - } // Request hooks may replace the caller signal before it reaches fetch. const controller = this.fetchWithTimeout === OpenAI.prototype.fetchWithTimeout - ? createRequestController(req.signal) + ? createRequestController( + req.signal ?? (x509Authentication ? options.signal : undefined), + x509Authentication ? options.signal : undefined, + ) : new AbortController(); const remainingTimeout = x509Authentication?.remainingTimeout(options, timeout) ?? timeout; const fetchWithAuth = x509Authentication ? OpenAI.prototype.fetchWithAuth : this.fetchWithAuth; @@ -1834,7 +1834,10 @@ OpenAI.Videos = Videos; const composedCallerSignals = new WeakMap(); -function createRequestController(callerSignal: AbortSignal | null | undefined): AbortController { +function createRequestController( + callerSignal: AbortSignal | null | undefined, + originalSignal?: AbortSignal | null, +): AbortController { const controller = new AbortController(); if (!callerSignal) return controller; @@ -1846,7 +1849,11 @@ function createRequestController(callerSignal: AbortSignal | null | undefined): try { // Native composition keeps cancellation active after response headers without // retaining an abort listener on the caller's signal or changing its reason. - const composed = nativeAbortSignal.any([controller.signal, callerSignal]) as AbortSignal; + const signals = [controller.signal, callerSignal]; + if (originalSignal && originalSignal !== callerSignal) { + signals.push(originalSignal); + } + const composed = nativeAbortSignal.any(signals) as AbortSignal; Object.defineProperty(controller, 'signal', { value: composed, configurable: true }); composedCallerSignals.set(controller, callerSignal); } catch { From 524bc42ad87cb81f386034177524f017c93a369a Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 21:04:06 +0000 Subject: [PATCH 04/18] fix(auth): preserve X.509 clone identity and one-shot request bodies --- src/client.ts | 13 +++- src/internal/auth/x509-token-exchange.ts | 2 +- .../auth/x509-workload-identity-auth.ts | 26 ++++++- .../x509-workload-review-regressions.test.ts | 68 +++++++++++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/client.ts b/src/client.ts index 49b556a383..6eb3d24c80 100644 --- a/src/client.ts +++ b/src/client.ts @@ -631,6 +631,8 @@ export class OpenAI { const residencyBaseURL = resolveDataResidency(options); const inheritedProvider = this._options.provider; const provider = options.provider ?? inheritedProvider; + const x509Authentication = + this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; const inheritedOptions: ClientOptions = { ...this._options, baseURL: this.baseURL, @@ -642,13 +644,13 @@ export class OpenAI { fetchOptions: this.fetchOptions, apiKey: this._options.apiKey, adminAPIKey: this.adminAPIKey, - workloadIdentity: this._options.workloadIdentity, + workloadIdentity: x509Authentication?.identitySnapshot() ?? this._options.workloadIdentity, x509Transport: this._options.x509Transport, organization: this.organization, project: this.project, webhookSecret: this.webhookSecret, }; - const currentlyX509 = this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth; + const currentlyX509 = x509Authentication !== undefined; const nextIdentity = hasOwn(options, 'workloadIdentity') ? options.workloadIdentity : inheritedOptions.workloadIdentity; @@ -1101,7 +1103,12 @@ export class OpenAI { retryCount: maxRetries - retriesRemaining, }); } catch (error) { - if (x509Authentication && retriesRemaining && X509WorkloadIdentityAuth.isRetryableFailure(error)) { + if ( + x509Authentication && + retriesRemaining && + !options.__metadata?.['hasStreamingBody'] && + X509WorkloadIdentityAuth.isRetryableFailure(error) + ) { return await this.retryRequest( options, retriesRemaining, diff --git a/src/internal/auth/x509-token-exchange.ts b/src/internal/auth/x509-token-exchange.ts index 31519c64af..7cc785b1d1 100644 --- a/src/internal/auth/x509-token-exchange.ts +++ b/src/internal/auth/x509-token-exchange.ts @@ -287,7 +287,7 @@ export async function exchangeX509Token(options: X509TokenExchangeOptions): Prom } } if (response.status === 400 || response.status === 401 || response.status === 403) { - throw await oauthError(response, signal, options.signal); + throw await oauthError(response, signal, callerSignal); } cancelResponseBody(response); diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index 4bd6895882..19e1ce6318 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -85,8 +85,16 @@ export function isX509WorkloadIdentity( return false; } - if ('provider' in identity) { - return false; + let providerOwner: object | null = identity; + while (providerOwner !== null && providerOwner !== Object.prototype) { + const provider = Object.getOwnPropertyDescriptor(providerOwner, 'provider'); + if (provider) { + if (!('value' in provider) || provider.value !== undefined) { + return false; + } + break; + } + providerOwner = Object.getPrototypeOf(providerOwner) as object | null; } let current: object | null = identity; @@ -168,6 +176,7 @@ export function snapshotX509RequestOptions(options: MergedRequestInit | undefine export class X509WorkloadIdentityAuth { readonly #identityProviderId: string; readonly #serviceAccountId: string; + readonly #configuredRefreshBufferMs: number | undefined; readonly #transport: RegisteredX509Transport; /** Captures one registered, immutable certificate identity and its enrolled selectors. */ @@ -175,6 +184,19 @@ export class X509WorkloadIdentityAuth { this.#transport = resolveX509Transport(transport); this.#identityProviderId = identity.identityProviderId; this.#serviceAccountId = identity.serviceAccountId; + this.#configuredRefreshBufferMs = identity.refreshBufferMs; + } + + /** Reconstructs the immutable selectors captured before caller-owned identity mutation. */ + identitySnapshot(): X509WorkloadIdentity { + return { + type: 'x509', + identityProviderId: this.#identityProviderId, + serviceAccountId: this.#serviceAccountId, + ...(this.#configuredRefreshBufferMs === undefined + ? {} + : { refreshBufferMs: this.#configuredRefreshBufferMs }), + }; } /** Preserves explicitly headerless requests without presenting a certificate to the issuer. */ diff --git a/tests/lib/x509-workload-review-regressions.test.ts b/tests/lib/x509-workload-review-regressions.test.ts index e62ae473cb..3a6ae6a38c 100644 --- a/tests/lib/x509-workload-review-regressions.test.ts +++ b/tests/lib/x509-workload-review-regressions.test.ts @@ -86,6 +86,28 @@ describe('X.509 review regressions', () => { expect(send).toHaveBeenCalledTimes(1); }); + test('never retries issuer authentication after a one-shot request body starts pulling', async () => { + let pulledChunks = 0; + const body = { + async *[Symbol.asyncIterator]() { + pulledChunks += 1; + yield new TextEncoder().encode('synthetic-first-chunk'); + pulledChunks += 1; + yield new TextEncoder().encode('synthetic-second-chunk'); + }, + }; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockResolvedValue(new Response(null, { status: 503, headers: { 'retry-after-ms': '1' } })); + + await expect( + new OpenAI(options({ maxRetries: 1 })).request({ path: '/responses', method: 'post', body }), + ).rejects.toMatchObject({ status: 503 }); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[1].origin).toBe('https://mtls.auth.openai.com'); + expect(pulledChunks).toBe(1); + }); + test('cancels API retry backoff immediately when its caller aborts', async () => { vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, url) => url.origin === 'https://mtls.auth.openai.com' @@ -159,6 +181,52 @@ describe('X.509 review regressions', () => { expect(client.baseURL).toBe('https://mtls.api.openai.com/v1'); }); + test.each(['own', 'inherited'] as const)('treats an %s undefined legacy provider as absent', (location) => { + const identity = { + type: 'x509', + identityProviderId: 'synthetic-undefined-provider', + serviceAccountId: 'synthetic-service-account', + }; + const workloadIdentity = + location === 'own' + ? { ...identity, provider: undefined } + : Object.assign(Object.create({ provider: undefined }) as object, identity); + + const client = new OpenAI( + options({ workloadIdentity: workloadIdentity as ClientOptions['workloadIdentity'] }), + ); + + expect(client.baseURL).toBe('https://mtls.api.openai.com/v1'); + }); + + test('clones only the identity selectors captured before caller configuration mutation', async () => { + const configuration = options(); + const client = new OpenAI(configuration); + const identity = configuration.workloadIdentity; + if (!identity || !('identityProviderId' in identity)) { + throw new Error('Expected a synthetic X.509 identity.'); + } + Object.assign(identity, { + type: 'mutated', + identityProviderId: 'synthetic-attacker-provider', + serviceAccountId: 'synthetic-attacker-account', + }); + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json({ data: [] }), + ); + + await client.withOptions({ timeout: 2000 }).models.list(); + + expect(JSON.parse(String(send.mock.calls[0]?.[2].body))).toMatchObject({ + identity_provider_id: 'synthetic-review-provider', + service_account_id: 'synthetic-review-account', + }); + }); + test.each(['own', 'inherited'] as const)( 'rejects an %s accessor discriminator without invoking it', (location) => { From ccf6fd9463134708853d7e67b3a6a60c06cb35b1 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 21:38:15 +0000 Subject: [PATCH 05/18] fix(auth): expose x509 identity with supported client options --- src/auth/index.ts | 7 ++++++- src/auth/types.ts | 24 ++++++++++++++++++++++++ tests/lib/x509-workload-identity.test.ts | 4 ++-- 3 files changed, 32 insertions(+), 3 deletions(-) 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/tests/lib/x509-workload-identity.test.ts b/tests/lib/x509-workload-identity.test.ts index 0c2038e11c..1ca2e2762b 100644 --- a/tests/lib/x509-workload-identity.test.ts +++ b/tests/lib/x509-workload-identity.test.ts @@ -5,7 +5,7 @@ import { vi } from 'vitest'; import OpenAI, { APIUserAbortError, AzureOpenAI, BedrockOpenAI } from 'openai'; import type { ClientOptions } from 'openai'; -import type { WorkloadIdentity } from 'openai/auth'; +import type { WorkloadIdentity, X509WorkloadIdentity } from 'openai/auth'; import { createX509Transport } from 'openai/auth/x509-transport'; import type { X509Transport } from 'openai/auth/x509-transport'; import * as transportCapability from 'openai/internal/auth/x509-transport-capability'; @@ -29,7 +29,7 @@ const TOKEN_RESPONSE = { let dispatcher: Agent; let transport: X509Transport; -function identity() { +function identity(): X509WorkloadIdentity { return { type: 'x509' as const, identityProviderId: 'synthetic-identity-provider', From eb4e72a392ffc9f86c595d1303735c2a163b1033 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 21:51:21 +0000 Subject: [PATCH 06/18] fix(auth): secure x509 request snapshots and terminal cleanup --- src/client.ts | 54 ++++++-- src/internal/auth/x509-transport-registry.ts | 3 + .../auth/x509-workload-identity-auth.ts | 71 ++++++++-- .../x509-workload-review-regressions.test.ts | 126 +++++++++++++++++- 4 files changed, 233 insertions(+), 21 deletions(-) diff --git a/src/client.ts b/src/client.ts index 6eb3d24c80..8ed5a52937 100644 --- a/src/client.ts +++ b/src/client.ts @@ -461,7 +461,11 @@ export class OpenAI { #explicitDataResidency = false; #responseAttempts = new WeakMap< AbortController, - { timeout: number; retriesRemaining: number; continueRequest?: (operation: () => T) => T } + { + timeout: number; + retriesRemaining: number; + continueRequest?: (operation: () => Promise) => Promise; + } >(); protected idempotencyHeader?: string; protected _options: ClientOptions; @@ -762,7 +766,14 @@ export class OpenAI { 'X.509 workload identity does not support overridden fetch dispatch hooks.', ); } - if (!X509WorkloadIdentityAuth.shouldAuthenticate(opts, this._options.defaultHeaders)) { + const snapshots = this._workloadIdentityAuth.headerSnapshots(); + if ( + !X509WorkloadIdentityAuth.shouldAuthenticate( + opts, + snapshots.defaultHeaders, + snapshots.requestHeaders, + ) + ) { return undefined; } } @@ -770,7 +781,7 @@ export class OpenAI { this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? await this._workloadIdentityAuth.getToken(opts, { apiURL: this.buildURL(opts.path!, opts.query as Record, opts.defaultBaseURL), - defaultHeaders: this._options.defaultHeaders, + ...this._workloadIdentityAuth.headerSnapshots(), timeout: opts.timeout ?? this.timeout, }) : await this._workloadIdentityAuth.getToken(); @@ -971,9 +982,16 @@ export class OpenAI { this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; - const remaining = - x509Authentication?.remainingTimeout(props.options, timeout) ?? - Math.max(0, props.startTime + timeout - Date.now()); + let remaining: number; + try { + remaining = + x509Authentication?.remainingTimeout(props.options, timeout) ?? + Math.max(0, props.startTime + timeout - Date.now()); + } catch (error) { + props.controller.abort(); + void Shims.CancelReadableStream(props.response.body).catch(() => undefined); + throw error; + } const callerSignal = props.options.signal; const abortError = () => x509Authentication && callerSignal @@ -1067,10 +1085,14 @@ export class OpenAI { void Shims.CancelReadableStream(response.body).catch(() => undefined); throw new Errors.APIConnectionTimeoutError(); }); - return await Promise.race([ + const body = await Promise.race([ response.text().catch(() => 'X.509 workload identity API response body could not be read.'), expiration, ]); + if (callerSignal?.aborted) { + throw this._makeUserAbortError(callerSignal); + } + return body; } catch (error) { if (callerSignal?.aborted) { throw this._makeUserAbortError(callerSignal); @@ -1568,6 +1590,10 @@ export class OpenAI { const options = { ...inputOptions }; const x509Authentication = this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; + const x509Headers = x509Authentication?.snapshotHeaders(this._options.defaultHeaders, options.headers); + if (x509Headers) { + options.headers = x509Headers.requestHeaders; + } const x509ClientFetchOptions = x509Authentication ? snapshotX509RequestOptions(this.fetchOptions) : undefined; @@ -1588,7 +1614,13 @@ export class OpenAI { }; } - const reqHeaders = await this.buildHeaders({ options: inputOptions, method, bodyHeaders, retryCount }); + const reqHeaders = await this.buildHeaders({ + options: inputOptions, + method, + bodyHeaders, + retryCount, + x509Headers, + }); const req: FinalizedRequestInit = { method, @@ -1609,11 +1641,13 @@ export class OpenAI { method, bodyHeaders, retryCount, + x509Headers, }: { options: FinalRequestOptions; method: HTTPMethod; bodyHeaders: HeadersLike; retryCount: number; + x509Headers?: { defaultHeaders: NullableHeaders; requestHeaders: NullableHeaders } | undefined; }): Promise { let idempotencyHeaders: HeadersLike = {}; if (this.idempotencyHeader && method !== 'get') { @@ -1637,9 +1671,9 @@ export class OpenAI { this._provider ? undefined : await this.authHeaders(options, options.__security ?? { bearerAuth: true }), - this._options.defaultHeaders, + x509Headers?.defaultHeaders ?? this._options.defaultHeaders, bodyHeaders, - options.headers, + x509Headers?.requestHeaders ?? options.headers, ]); if (!this._provider) { diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index e6ec67cb04..e57eab466c 100644 --- a/src/internal/auth/x509-transport-registry.ts +++ b/src/internal/auth/x509-transport-registry.ts @@ -1,4 +1,5 @@ import { OpenAIError } from '../../core/error'; +import type { NullableHeaders } from '../headers'; import { findRegisteredX509Transport } from '#x509-transport-state'; const transientX509TransportCodes = new Set([ @@ -56,6 +57,8 @@ export interface X509ExchangedToken { export interface X509RequestScope { wallStartedAt: number; monotonicStartedAt: number; + defaultHeaders?: NullableHeaders; + requestHeaders?: NullableHeaders; token?: string; headers?: Headers; authorization?: string | null; diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index 19e1ce6318..803bab5725 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -2,7 +2,7 @@ import { APIConnectionTimeoutError, APIUserAbortError, OAuthError, OpenAIError } import type { WorkloadIdentity, X509WorkloadIdentity } from '../../auth/types'; import type { Fetch } from '../builtin-types'; import { buildHeaders } from '../headers'; -import type { HeadersLike } from '../headers'; +import type { HeadersLike, NullableHeaders } from '../headers'; import type { FinalRequestOptions } from '../request-options'; import type { MergedRequestInit } from '../types'; import { hasOwn } from '../utils/values'; @@ -200,13 +200,43 @@ export class X509WorkloadIdentityAuth { } /** Preserves explicitly headerless requests without presenting a certificate to the issuer. */ - static shouldAuthenticate(options: FinalRequestOptions, defaultHeaders: HeadersLike | undefined): boolean { - return !buildHeaders([defaultHeaders, options.headers]).nulls.has('authorization'); + static shouldAuthenticate( + options: FinalRequestOptions, + defaultHeaders: HeadersLike | undefined, + requestHeaders: HeadersLike = options.headers, + ): boolean { + return !buildHeaders([defaultHeaders, requestHeaders]).nulls.has('authorization'); + } + + /** Snapshots each caller-owned header layer once while preserving nulls and precedence. */ + snapshotHeaders( + defaultHeaders: HeadersLike, + requestHeaders: HeadersLike, + ): { defaultHeaders: NullableHeaders; requestHeaders: NullableHeaders } { + const scope = this.#scope(); + scope.defaultHeaders ??= buildHeaders([defaultHeaders]); + scope.requestHeaders ??= buildHeaders([requestHeaders]); + return this.headerSnapshots(); + } + + /** Returns the already rendered caller headers without touching mutable inputs again. */ + headerSnapshots(): { defaultHeaders: NullableHeaders; requestHeaders: NullableHeaders } { + const { defaultHeaders, requestHeaders } = this.#scope(); + if (!defaultHeaders || !requestHeaders) { + throw new OpenAIError('X.509 workload identity requires snapshotted request headers.'); + } + return { defaultHeaders, requestHeaders }; } /** Establishes an independent scope even when concurrent requests share caller options. */ - runRequest(operation: () => T): T { - return this.#transport.run(operation); + runRequest(operation: () => Promise): Promise { + return this.#transport.run(async () => { + try { + return await operation(); + } finally { + this.releaseRequestCredentials(); + } + }); } /** Reports whether a public request-building call already belongs to an active logical operation. */ @@ -215,16 +245,25 @@ export class X509WorkloadIdentityAuth { } /** Binds deferred response parsing to the original logical request and its unchanged deadline. */ - continuation(): (operation: () => T) => T { + continuation(): (operation: () => Promise) => Promise { const { wallStartedAt, monotonicStartedAt } = this.#scope(); const scope: X509RequestScope = { wallStartedAt, monotonicStartedAt }; - return (operation) => this.#transport.resume(scope, operation); + return (operation) => + this.#transport.resume(scope, async () => { + try { + return await operation(); + } finally { + this.releaseRequestCredentials(); + } + }); } /** Removes dispatched bearer material before settled request promises can retain their scope. */ releaseRequestCredentials(): void { const scope = this.#scope(); delete scope.token; + delete scope.defaultHeaders; + delete scope.requestHeaders; delete scope.headers; delete scope.authorization; } @@ -285,7 +324,12 @@ export class X509WorkloadIdentityAuth { /** Exchanges the exact certificate capability selected for the matching API dispatch. */ async getToken( options?: FinalRequestOptions, - context?: { apiURL: string; defaultHeaders: HeadersLike | undefined; timeout: number }, + context?: { + apiURL: string; + defaultHeaders: HeadersLike | undefined; + requestHeaders: HeadersLike; + timeout: number; + }, ): Promise { if (options?.signal?.aborted) { throw userAbortError(options.signal); @@ -330,7 +374,14 @@ export class X509WorkloadIdentityAuth { static #preflight( options: FinalRequestOptions | undefined, - context: { apiURL: string; defaultHeaders: HeadersLike | undefined; timeout: number } | undefined, + context: + | { + apiURL: string; + defaultHeaders: HeadersLike | undefined; + requestHeaders: HeadersLike; + timeout: number; + } + | undefined, ): void { if (options) { assertX509RequestOptions(options.fetchOptions); @@ -339,7 +390,7 @@ export class X509WorkloadIdentityAuth { return; } assertX509APIOrigin(context.apiURL); - const supplied = buildHeaders([context.defaultHeaders, options?.headers]); + const supplied = buildHeaders([context.defaultHeaders, context.requestHeaders]); for (const name of supplied.values.keys()) { const canonical = name.toLowerCase().split('_').join('-'); if ( diff --git a/tests/lib/x509-workload-review-regressions.test.ts b/tests/lib/x509-workload-review-regressions.test.ts index 3a6ae6a38c..5f278cb28d 100644 --- a/tests/lib/x509-workload-review-regressions.test.ts +++ b/tests/lib/x509-workload-review-regressions.test.ts @@ -7,7 +7,10 @@ import type { ClientOptions } from 'openai'; import { createX509Transport } from 'openai/auth/x509-transport'; import type { X509Transport } from 'openai/auth/x509-transport'; import * as transportCapability from 'openai/internal/auth/x509-transport-capability'; -import { isRetryableX509TransportFailure } from 'openai/internal/auth/x509-transport-registry'; +import { + isRetryableX509TransportFailure, + resolveX509Transport, +} from 'openai/internal/auth/x509-transport-registry'; import { OpenAIRealtimeWebSocket as StableNativeRealtime } from 'openai/realtime/websocket'; import { OpenAIRealtimeWS as StableNodeRealtime } from 'openai/realtime/ws'; import { OpenAIRealtimeWebSocket as BetaNativeRealtime } from 'openai/beta/realtime/websocket'; @@ -143,6 +146,127 @@ describe('X.509 review regressions', () => { ); }); + test('preserves caller cancellation when an error body fails synchronously on abort', async () => { + const caller = new AbortController(); + const reason = new Error('synthetic-immediate-error-body-cancellation'); + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : new Response( + new ReadableStream({ + pull(controller) { + caller.abort(reason); + controller.error(reason); + }, + }), + { status: 403 }, + ), + ); + + await expect(new OpenAI(options()).models.list({ signal: caller.signal })).rejects.toMatchObject({ + constructor: APIUserAbortError, + cause: reason, + }); + }); + + test.each(['default', 'request'] as const)( + 'renders mutable %s header values only once before presenting its certificate', + async (location) => { + const getter = vi.fn(() => (getter.mock.calls.length === 1 ? undefined : 'synthetic-forbidden')); + const headers = Object.defineProperty({}, 'api-key', { enumerable: true, get: getter }); + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json({ data: [] }), + ); + const client = new OpenAI(options(location === 'default' ? { defaultHeaders: headers } : {})); + + await client.models.list(location === 'request' ? { headers } : {}); + + expect(getter).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(2); + expect(new Headers(send.mock.calls[1]?.[2].headers).has('api-key')).toBe(false); + }, + ); + + test.each(['default', 'request'] as const)( + 'rejects forbidden %s header snapshots before presenting its certificate', + async (location) => { + const getter = vi.fn(() => 'synthetic-forbidden'); + const headers = Object.defineProperty({}, 'Proxy-Authorization', { enumerable: true, get: getter }); + const send = vi.spyOn(transportCapability, 'sendX509Request'); + const client = new OpenAI(options(location === 'default' ? { defaultHeaders: headers } : {})); + + await expect(client.models.list(location === 'request' ? { headers } : {})).rejects.toThrow( + /caller-supplied.*credentials/iu, + ); + expect(getter).toHaveBeenCalledTimes(1); + expect(send).not.toHaveBeenCalled(); + }, + ); + + test('preserves caller request and header identities while snapshotting authenticated headers', async () => { + const headers = { 'X-Synthetic-Request': 'synthetic-value' }; + const request = { path: '/models', method: 'get' as const, headers }; + let observedOptions: unknown; + const client = new OpenAI(options()); + Object.defineProperty(client, 'prepareRequest', { + value: async (_request: RequestInit, context: { options: unknown }) => { + observedOptions = context.options; + }, + }); + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json({ data: [] }), + ); + + await client.request(request); + + expect(observedOptions).toBe(request); + expect(request.headers).toBe(headers); + }); + + test('clears authenticated request credentials when a protected request hook fails', async () => { + let observedScope: ReturnType['current']>; + const client = new OpenAI(options()); + Object.defineProperty(client, 'prepareRequest', { + value: async () => { + observedScope = resolveX509Transport(transport).current(); + throw new Error('synthetic-request-hook-failure'); + }, + }); + vi.spyOn(transportCapability, 'sendX509Request').mockResolvedValue(Response.json(TOKEN_RESPONSE)); + + await expect(client.models.list()).rejects.toThrow('synthetic-request-hook-failure'); + expect(observedScope).toBeDefined(); + expect(observedScope).not.toHaveProperty('token'); + expect(observedScope).not.toHaveProperty('headers'); + expect(observedScope).not.toHaveProperty('authorization'); + expect(observedScope).not.toHaveProperty('defaultHeaders'); + expect(observedScope).not.toHaveProperty('requestHeaders'); + }); + + test('cancels an unread successful response when parsing begins after its request deadline', async () => { + const canceled = vi.fn(); + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : new Response(new ReadableStream({ cancel: canceled }), { + headers: { 'content-type': 'application/json' }, + }), + ); + const request = new OpenAI(options({ timeout: 25 })).models.list(); + + await request.asResponse(); + await delay(35); + + await expect(request).rejects.toBeInstanceOf(APIConnectionTimeoutError); + expect(canceled).toHaveBeenCalledTimes(1); + }); + test('preserves caller cancellation when a protected hook replaces the request signal', async () => { class SignalReplacingClient extends OpenAI { readonly replacementSignal = new AbortController().signal; From 04778b572aabc60d105c98983ecad1475265c3a8 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 22:00:09 +0000 Subject: [PATCH 07/18] fix(auth): start x509 deadlines after request options resolve --- src/client.ts | 20 ++++--- .../x509-workload-review-regressions.test.ts | 54 +++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/client.ts b/src/client.ts index 8ed5a52937..97d91be016 100644 --- a/src/client.ts +++ b/src/client.ts @@ -932,11 +932,13 @@ export class OpenAI { options: PromiseOrValue, remainingRetries: number | null = null, ): APIPromise { - const operation = () => this.makeRequest(options, remainingRetries, undefined); + const authentication = this._workloadIdentityAuth; const request = - this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth - ? this._workloadIdentityAuth.runRequest(operation) - : operation(); + authentication instanceof X509WorkloadIdentityAuth + ? Promise.resolve(options).then((resolved) => + authentication.runRequest(() => this.makeRequest(resolved, remainingRetries, undefined)), + ) + : this.makeRequest(options, remainingRetries, undefined); return this.responsePromise(request); } @@ -1398,11 +1400,13 @@ export class OpenAI { Page: new (...args: ConstructorParameters) => PageClass, options: PromiseOrValue, ): Pagination.PagePromise { - const operation = () => this.makeRequest(options, null, undefined); + const authentication = this._workloadIdentityAuth; const request = - this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth - ? this._workloadIdentityAuth.runRequest(operation) - : operation(); + authentication instanceof X509WorkloadIdentityAuth + ? Promise.resolve(options).then((resolved) => + authentication.runRequest(() => this.makeRequest(resolved, null, undefined)), + ) + : this.makeRequest(options, null, undefined); const page = new Pagination.PagePromise(this as any as OpenAI, request, Page); const guarded = this.responsePromise(request, async (client, props) => { const body = await this.parseResponseWithTimeout(client, props); diff --git a/tests/lib/x509-workload-review-regressions.test.ts b/tests/lib/x509-workload-review-regressions.test.ts index 5f278cb28d..e2fbfeedac 100644 --- a/tests/lib/x509-workload-review-regressions.test.ts +++ b/tests/lib/x509-workload-review-regressions.test.ts @@ -6,6 +6,7 @@ import OpenAI, { APIConnectionTimeoutError, APIUserAbortError } from 'openai'; import type { ClientOptions } from 'openai'; import { createX509Transport } from 'openai/auth/x509-transport'; import type { X509Transport } from 'openai/auth/x509-transport'; +import { Page } from 'openai/core/pagination'; import * as transportCapability from 'openai/internal/auth/x509-transport-capability'; import { isRetryableX509TransportFailure, @@ -89,6 +90,59 @@ describe('X.509 review regressions', () => { expect(send).toHaveBeenCalledTimes(1); }); + test.each(['request', 'method', 'list'] as const)( + 'starts the %s X.509 deadline only after deferred request options resolve', + async (surface) => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json({ data: [] }), + ); + const client = new OpenAI(options({ timeout: 20 })); + const deferred = delay(45).then(() => ({})); + + if (surface === 'request') { + await client.request(deferred.then((request) => ({ ...request, path: '/models', method: 'get' }))); + } else if (surface === 'method') { + await client.get('/models', deferred); + } else { + await client.getAPIList('/models', Page, deferred); + } + + expect(send).toHaveBeenCalledTimes(2); + }, + ); + + test('keeps a nested public request in an independent X.509 authentication scope', async () => { + const scopes: ReturnType['current']>[] = []; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json({ data: [] }), + ); + const client = new OpenAI(options()); + Object.defineProperty(client, 'prepareRequest', { + value: async () => { + scopes.push(resolveX509Transport(transport).current()); + if (scopes.length === 1) { + await client.models.list(); + } + }, + }); + + await client.models.list(); + + expect(scopes).toHaveLength(2); + expect(scopes[0]).not.toBe(scopes[1]); + expect(send.mock.calls.filter((call) => call[1].origin === 'https://mtls.api.openai.com')).toHaveLength( + 2, + ); + }); + test('never retries issuer authentication after a one-shot request body starts pulling', async () => { let pulledChunks = 0; const body = { From c96083834155f1139cba9cc38cf4f54e0f5a085e Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 22:09:40 +0000 Subject: [PATCH 08/18] fix(auth): bind x509 preflight to dispatch url and body lifetime --- src/client.ts | 6 +++- src/internal/auth/x509-transport-registry.ts | 1 + .../auth/x509-workload-identity-auth.ts | 16 +++++++++ .../x509-workload-review-regressions.test.ts | 35 +++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 97d91be016..b11a9845d3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -464,6 +464,7 @@ export class OpenAI { { timeout: number; retriesRemaining: number; + hasStreamingBody: boolean; continueRequest?: (operation: () => Promise) => Promise; } >(); @@ -780,7 +781,7 @@ export class OpenAI { const token = this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? await this._workloadIdentityAuth.getToken(opts, { - apiURL: this.buildURL(opts.path!, opts.query as Record, opts.defaultBaseURL), + apiURL: this._workloadIdentityAuth.requestAPIURL(), ...this._workloadIdentityAuth.headerSnapshots(), timeout: opts.timeout ?? this.timeout, }) @@ -1034,6 +1035,7 @@ export class OpenAI { const retriesRemaining = attempt?.retriesRemaining ?? 0; if ( !retriesRemaining || + attempt?.hasStreamingBody || props.options.__metadata?.['hasStreamingBody'] || ((globalThis as any).ReadableStream && props.options.body instanceof (globalThis as any).ReadableStream) || @@ -1375,6 +1377,7 @@ export class OpenAI { this.#responseAttempts.set(controller, { timeout, retriesRemaining, + hasStreamingBody, ...(continueRequest ? { continueRequest } : {}), }); return { response, options, controller, requestLogID, retryOfRequestLogID, startTime }; @@ -1607,6 +1610,7 @@ export class OpenAI { const { method, path, query, defaultBaseURL } = options; const url = this.buildURL(path!, query as Record, defaultBaseURL); + x509Authentication?.snapshotAPIURL(url); if ('timeout' in options) validatePositiveInteger('timeout', options.timeout); options.timeout = options.timeout ?? this.timeout; const { bodyHeaders, body, isStreamingBody } = this.buildBody({ options }); diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index e57eab466c..84154972f6 100644 --- a/src/internal/auth/x509-transport-registry.ts +++ b/src/internal/auth/x509-transport-registry.ts @@ -57,6 +57,7 @@ export interface X509ExchangedToken { export interface X509RequestScope { wallStartedAt: number; monotonicStartedAt: number; + apiURL?: string; defaultHeaders?: NullableHeaders; requestHeaders?: NullableHeaders; token?: string; diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index 803bab5725..81db66a931 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -228,6 +228,21 @@ export class X509WorkloadIdentityAuth { return { defaultHeaders, requestHeaders }; } + /** Validates and retains the exact destination that authenticated dispatch will use. */ + snapshotAPIURL(value: string): void { + assertX509APIOrigin(value); + this.#scope().apiURL = value; + } + + /** Reads the already-approved destination without rerendering caller-owned request options. */ + requestAPIURL(): string { + const { apiURL } = this.#scope(); + if (apiURL === undefined) { + throw new OpenAIError('X.509 workload identity requires a snapshotted API destination.'); + } + return apiURL; + } + /** Establishes an independent scope even when concurrent requests share caller options. */ runRequest(operation: () => Promise): Promise { return this.#transport.run(async () => { @@ -261,6 +276,7 @@ export class X509WorkloadIdentityAuth { /** Removes dispatched bearer material before settled request promises can retain their scope. */ releaseRequestCredentials(): void { const scope = this.#scope(); + delete scope.apiURL; delete scope.token; delete scope.defaultHeaders; delete scope.requestHeaders; diff --git a/tests/lib/x509-workload-review-regressions.test.ts b/tests/lib/x509-workload-review-regressions.test.ts index e2fbfeedac..8aab366125 100644 --- a/tests/lib/x509-workload-review-regressions.test.ts +++ b/tests/lib/x509-workload-review-regressions.test.ts @@ -143,6 +143,41 @@ describe('X.509 review regressions', () => { ); }); + test('validates the exact rendered API destination before presenting its certificate', async () => { + const path = vi.fn(() => + path.mock.calls.length === 1 ? 'https://attacker.invalid/v1/models' : '/models', + ); + const request = Object.defineProperty({ method: 'get' as const, path: '/models' }, 'path', { + enumerable: true, + get: path, + }); + const send = vi.spyOn(transportCapability, 'sendX509Request'); + + await expect(new OpenAI(options()).request(request)).rejects.toThrow(/approved.*mTLS.*origin/iu); + + expect(path).toHaveBeenCalledTimes(1); + expect(send).not.toHaveBeenCalled(); + }); + + test('never replays a one-shot body installed by a hook after response-body timeout', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : new Response(new ReadableStream(), { headers: { 'content-type': 'application/json' } }), + ); + const client = new OpenAI(options({ timeout: 35, maxRetries: 1 })); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + request.body = new ReadableStream(); + }, + }); + + await expect(client.models.list()).rejects.toBeInstanceOf(APIConnectionTimeoutError); + expect(send).toHaveBeenCalledTimes(2); + }); + test('never retries issuer authentication after a one-shot request body starts pulling', async () => { let pulledChunks = 0; const body = { From f6cdacad3f943ba4acb039ef0afd4a8f76b5b37a Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 22:22:40 +0000 Subject: [PATCH 09/18] fix(auth): document and stabilize initial x509 client contract --- docs/authentication.md | 55 ++++++++++++++- src/auth/types.ts | 5 +- src/client.ts | 49 ++++++------- .../auth/x509-workload-identity-auth.ts | 14 ++-- .../x509-workload-review-regressions.test.ts | 69 +++++++++++++++++++ 5 files changed, 155 insertions(+), 37 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 770eede5c2..3ed49097bf 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -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. @@ -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 diff --git a/src/auth/types.ts b/src/auth/types.ts index 8f2ebe3175..2157facd47 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -36,16 +36,13 @@ 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; /** X.509 federation does not send an OAuth client identifier. */ clientId?: never; - /** X.509 refresh configuration is expressed in milliseconds. */ + /** X.509 federation does not accept subject-token refresh configuration. */ refreshBufferSeconds?: never; } diff --git a/src/client.ts b/src/client.ts index b11a9845d3..376c722063 100644 --- a/src/client.ts +++ b/src/client.ts @@ -493,21 +493,7 @@ export class OpenAI { */ constructor(clientOptions: ClientOptions = {}) { const residencyBaseURL = resolveDataResidency(clientOptions); - const usesX509Identity = isX509WorkloadIdentity(clientOptions.workloadIdentity); const provider = clientOptions.provider; - if (provider) { - const conflictingOptions = ( - ['apiKey', 'adminAPIKey', 'workloadIdentity', 'x509Transport', 'baseURL', 'dataResidency'] as const - ).filter((key) => clientOptions[key] != null); - if (conflictingOptions.length) { - throw new Errors.OpenAIError( - `The \`provider\` option cannot be used with ${conflictingOptions - .map((key) => `\`${key}\``) - .join(', ')}. Configure authentication and the base URL through the provider instead.`, - ); - } - } - const { baseURL = provider ? null : readEnv('OPENAI_BASE_URL'), dataResidency: _dataResidency, @@ -521,6 +507,23 @@ export class OpenAI { x509Transport, ...opts } = clientOptions as InternalClientOptions; + if (provider) { + const conflictingOptions = ( + ['apiKey', 'adminAPIKey', 'workloadIdentity', 'x509Transport', 'baseURL', 'dataResidency'] as const + ).filter((key) => (key === 'workloadIdentity' ? workloadIdentity : clientOptions[key]) != null); + if (conflictingOptions.length) { + throw new Errors.OpenAIError( + `The \`provider\` option cannot be used with ${conflictingOptions + .map((key) => `\`${key}\``) + .join(', ')}. Configure authentication and the base URL through the provider instead.`, + ); + } + } + const identity = isX509WorkloadIdentity(workloadIdentity) + ? { x509: workloadIdentity, legacy: undefined } + : { x509: undefined, legacy: workloadIdentity }; + const x509Identity = identity.x509; + const usesX509Identity = x509Identity !== undefined; const providerRuntime = provider ? configureProvider(provider) : undefined; const options: ClientOptions = { apiKey, @@ -610,16 +613,14 @@ export class OpenAI { this._options = options; this._provider = providerRuntime; - if (workloadIdentity) { - if (isX509WorkloadIdentity(workloadIdentity)) { - const authentication = new X509WorkloadIdentityAuth(workloadIdentity, x509Transport); - this._workloadIdentityAuth = authentication; - this.#x509Fetch = authentication.fetch(); - this.fetch = this.#x509Fetch; - markApprovedX509Client(this); - } else { - this._workloadIdentityAuth = new WorkloadIdentityAuth(workloadIdentity, this.fetch); - } + if (x509Identity) { + const authentication = new X509WorkloadIdentityAuth(x509Identity, x509Transport); + this._workloadIdentityAuth = authentication; + this.#x509Fetch = authentication.fetch(); + this.fetch = this.#x509Fetch; + markApprovedX509Client(this); + } else if (identity.legacy) { + this._workloadIdentityAuth = new WorkloadIdentityAuth(identity.legacy, this.fetch); } this.apiKey = typeof apiKey === 'string' ? apiKey : null; diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index 81db66a931..b07789234d 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -176,7 +176,6 @@ export function snapshotX509RequestOptions(options: MergedRequestInit | undefine export class X509WorkloadIdentityAuth { readonly #identityProviderId: string; readonly #serviceAccountId: string; - readonly #configuredRefreshBufferMs: number | undefined; readonly #transport: RegisteredX509Transport; /** Captures one registered, immutable certificate identity and its enrolled selectors. */ @@ -184,7 +183,6 @@ export class X509WorkloadIdentityAuth { this.#transport = resolveX509Transport(transport); this.#identityProviderId = identity.identityProviderId; this.#serviceAccountId = identity.serviceAccountId; - this.#configuredRefreshBufferMs = identity.refreshBufferMs; } /** Reconstructs the immutable selectors captured before caller-owned identity mutation. */ @@ -193,9 +191,6 @@ export class X509WorkloadIdentityAuth { type: 'x509', identityProviderId: this.#identityProviderId, serviceAccountId: this.#serviceAccountId, - ...(this.#configuredRefreshBufferMs === undefined - ? {} - : { refreshBufferMs: this.#configuredRefreshBufferMs }), }; } @@ -443,8 +438,13 @@ export class X509WorkloadIdentityAuth { const { token } = scope; const security = options.__security ?? { bearerAuth: true }; let approvedAuthorization = token ? `Bearer ${token}` : null; - if (!token && security.adminAPIKeyAuth && adminAPIKey && headerValue(request.headers, 'Authorization')) { - approvedAuthorization = `Bearer ${adminAPIKey}`; + if ( + !token && + security.adminAPIKeyAuth && + adminAPIKey !== null && + headerValue(request.headers, 'Authorization') !== null + ) { + approvedAuthorization = new Headers({ Authorization: `Bearer ${adminAPIKey}` }).get('Authorization'); } scope.headers = request.headers; scope.authorization = approvedAuthorization; diff --git a/tests/lib/x509-workload-review-regressions.test.ts b/tests/lib/x509-workload-review-regressions.test.ts index 8aab366125..452cf8e1d8 100644 --- a/tests/lib/x509-workload-review-regressions.test.ts +++ b/tests/lib/x509-workload-review-regressions.test.ts @@ -1,4 +1,5 @@ import { setTimeout as delay } from 'node:timers/promises'; +import { readFileSync } from 'node:fs'; import { Agent } from 'undici'; import { vi } from 'vitest'; @@ -59,6 +60,74 @@ afterEach(async () => { }); describe('X.509 review regressions', () => { + test('documents the public X.509 authentication flow alongside workload-identity guidance', () => { + const authenticationGuide = readFileSync('docs/authentication.md', 'utf-8'); + + expect(authenticationGuide).toContain("import { createX509Transport } from 'openai/auth/x509-transport'"); + expect(authenticationGuide).toContain("certificateIdentity: 'static'"); + expect(authenticationGuide).toContain('https://mtls.api.openai.com/v1'); + expect(authenticationGuide).toContain('WebSocket'); + }); + + test('classifies an accessor-backed workload identity exactly once', async () => { + const configuration = options(); + const enrolled = configuration.workloadIdentity; + const getter = vi.fn(() => + getter.mock.calls.length === 1 + ? enrolled + : { + identityProviderId: 'synthetic-other-provider', + serviceAccountId: 'synthetic-other-account', + provider: { tokenType: 'jwt' as const, getToken: async () => 'synthetic-subject-token' }, + }, + ); + Object.defineProperty(configuration, 'workloadIdentity', { enumerable: true, get: getter }); + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json({ data: [] }), + ); + + await new OpenAI(configuration).models.list(); + + expect(getter).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(2); + }); + + test('preserves an explicitly empty admin credential without presenting its certificate', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockResolvedValue(Response.json({ data: [] })); + const client = new OpenAI(options({ adminAPIKey: '' })); + + await client.request({ + path: '/organization/projects', + method: 'get', + __security: { adminAPIKeyAuth: true }, + }); + + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[1].origin).toBe('https://mtls.api.openai.com'); + expect(new Headers(send.mock.calls[0]?.[2].headers).get('Authorization')).toBe('Bearer'); + }); + + test('never approves caller-substituted admin credentials', async () => { + const send = vi.spyOn(transportCapability, 'sendX509Request'); + const client = new OpenAI(options({ adminAPIKey: '' })); + + await expect( + client.request({ + path: '/organization/projects', + method: 'get', + headers: { Authorization: 'Bearer synthetic-attacker-admin' }, + __security: { adminAPIKeyAuth: true }, + }), + ).rejects.toThrow(/authorization/iu); + expect(send).not.toHaveBeenCalled(); + }); + test.each([408, 409, 429, 500, 503])('retries trusted issuer status %i', async (status) => { let issuerRequests = 0; const send = vi From 67b4165457a3382818977321bd5d12c00b1f7cb6 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 22:35:11 +0000 Subject: [PATCH 10/18] fix(auth): snapshot x509 signals and one-shot hook bodies --- src/client.ts | 7 ++- src/internal/auth/x509-transport-registry.ts | 1 + .../auth/x509-workload-identity-auth.ts | 18 ++++++-- .../x509-workload-review-regressions.test.ts | 46 +++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/client.ts b/src/client.ts index 376c722063..aa4214fb0a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -784,6 +784,7 @@ export class OpenAI { ? await this._workloadIdentityAuth.getToken(opts, { apiURL: this._workloadIdentityAuth.requestAPIURL(), ...this._workloadIdentityAuth.headerSnapshots(), + signal: this._workloadIdentityAuth.requestSignal(), timeout: opts.timeout ?? this.timeout, }) : await this._workloadIdentityAuth.getToken(); @@ -1155,7 +1156,10 @@ export class OpenAI { if ( x509Authentication && ((globalThis.ReadableStream && req.body instanceof globalThis.ReadableStream) || - (typeof req.body === 'object' && req.body !== null && Symbol.asyncIterator in req.body)) + (typeof req.body === 'object' && + req.body !== null && + (Symbol.asyncIterator in req.body || + (Symbol.iterator in req.body && 'next' in req.body && typeof req.body.next === 'function')))) ) { hasStreamingBody = true; } @@ -1598,6 +1602,7 @@ export class OpenAI { const options = { ...inputOptions }; const x509Authentication = this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; + x509Authentication?.snapshotRequestSignal(options.signal); const x509Headers = x509Authentication?.snapshotHeaders(this._options.defaultHeaders, options.headers); if (x509Headers) { options.headers = x509Headers.requestHeaders; diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index 84154972f6..c1d18f9922 100644 --- a/src/internal/auth/x509-transport-registry.ts +++ b/src/internal/auth/x509-transport-registry.ts @@ -57,6 +57,7 @@ export interface X509ExchangedToken { export interface X509RequestScope { wallStartedAt: number; monotonicStartedAt: number; + callerSignal?: AbortSignal | null | undefined; apiURL?: string; defaultHeaders?: NullableHeaders; requestHeaders?: NullableHeaders; diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index b07789234d..8c211a443e 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -238,6 +238,16 @@ export class X509WorkloadIdentityAuth { return apiURL; } + /** Captures the exact caller signal that will be attached to authenticated dispatch. */ + snapshotRequestSignal(signal: AbortSignal | null | undefined): void { + this.#scope().callerSignal = signal; + } + + /** Returns the request signal without invoking caller-owned accessors again. */ + requestSignal(): AbortSignal | null | undefined { + return this.#scope().callerSignal; + } + /** Establishes an independent scope even when concurrent requests share caller options. */ runRequest(operation: () => Promise): Promise { return this.#transport.run(async () => { @@ -271,6 +281,7 @@ export class X509WorkloadIdentityAuth { /** Removes dispatched bearer material before settled request promises can retain their scope. */ releaseRequestCredentials(): void { const scope = this.#scope(); + delete scope.callerSignal; delete scope.apiURL; delete scope.token; delete scope.defaultHeaders; @@ -339,17 +350,18 @@ export class X509WorkloadIdentityAuth { apiURL: string; defaultHeaders: HeadersLike | undefined; requestHeaders: HeadersLike; + signal: AbortSignal | null | undefined; timeout: number; }, ): Promise { - if (options?.signal?.aborted) { - throw userAbortError(options.signal); + const callerSignal = context ? context.signal : options?.signal; + if (callerSignal?.aborted) { + throw userAbortError(callerSignal); } X509WorkloadIdentityAuth.#preflight(options, context); const scope = options ? this.#scope() : undefined; const remaining = context && options ? this.remainingTimeout(options, context.timeout) : context?.timeout; - const { signal: callerSignal } = options ?? {}; const { signal, dispose } = exchangeDeadline(remaining, callerSignal); try { diff --git a/tests/lib/x509-workload-review-regressions.test.ts b/tests/lib/x509-workload-review-regressions.test.ts index 452cf8e1d8..8b0e2cf11d 100644 --- a/tests/lib/x509-workload-review-regressions.test.ts +++ b/tests/lib/x509-workload-review-regressions.test.ts @@ -247,6 +247,52 @@ describe('X.509 review regressions', () => { expect(send).toHaveBeenCalledTimes(2); }); + test('never retries a synchronous one-shot iterator installed by a request hook', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : new Response(null, { status: 503, headers: { 'retry-after-ms': '1' } }), + ); + const client = new OpenAI(options({ maxRetries: 1 })); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + Object.defineProperty(request, 'body', { + value: [new TextEncoder().encode('synthetic-one-shot-chunk')][Symbol.iterator](), + writable: true, + configurable: true, + }); + }, + }); + + await expect(client.models.list()).rejects.toMatchObject({ status: 503 }); + expect(send).toHaveBeenCalledTimes(2); + }); + + test('authenticates with the same accessor-backed signal captured for API dispatch', async () => { + const actual = new AbortController(); + const unrelated = new AbortController(); + const getter = vi.fn(() => (getter.mock.calls.length === 1 ? actual.signal : unrelated.signal)); + const request = Object.defineProperty({ path: '/models', method: 'get' as const }, 'signal', { + enumerable: true, + get: getter, + }); + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, _url, init) => { + await delay(500, undefined, { signal: init.signal ?? undefined }); + return Response.json(TOKEN_RESPONSE); + }); + const pending = new OpenAI(options()).buildRequest(request); + await delay(20); + actual.abort(new Error('synthetic-snapshotted-signal-canceled')); + + await expect(pending).rejects.toBeInstanceOf(APIUserAbortError); + expect(getter).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + }); + test('never retries issuer authentication after a one-shot request body starts pulling', async () => { let pulledChunks = 0; const body = { From e2f32d54d56be76a2d29100c5aff3f9f958f7442 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 00:42:17 +0000 Subject: [PATCH 11/18] fix(auth): bind x509 hooks to immutable request settings --- src/client.ts | 61 +++++--- src/internal/auth/x509-transport-registry.ts | 2 +- .../auth/x509-workload-identity-auth.ts | 42 ++++-- .../x509-workload-review-regressions.test.ts | 132 ++++++++++++++++++ 4 files changed, 206 insertions(+), 31 deletions(-) diff --git a/src/client.ts b/src/client.ts index aa4214fb0a..cc74b2408d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -784,8 +784,7 @@ export class OpenAI { ? await this._workloadIdentityAuth.getToken(opts, { apiURL: this._workloadIdentityAuth.requestAPIURL(), ...this._workloadIdentityAuth.headerSnapshots(), - signal: this._workloadIdentityAuth.requestSignal(), - timeout: opts.timeout ?? this.timeout, + ...this._workloadIdentityAuth.requestSnapshot(), }) : await this._workloadIdentityAuth.getToken(); return buildHeaders([{ Authorization: `Bearer ${token}` }]); @@ -1077,16 +1076,18 @@ export class OpenAI { authentication: X509WorkloadIdentityAuth, ): Promise { const deadline = new AbortController(); - const callerSignal = options.signal; - const cancel = () => deadline.abort(callerSignal?.reason); - callerSignal?.addEventListener('abort', cancel, { once: true }); - if (callerSignal?.aborted) { + const callerSignal = controller.signal; + let timedOut = false; + const cancel = () => deadline.abort(callerSignal.reason); + callerSignal.addEventListener('abort', cancel, { once: true }); + if (callerSignal.aborted) { cancel(); } try { const remaining = authentication.remainingTimeout(options, timeout); const expiration = authentication.waitForRetry(remaining, deadline.signal).then(() => { + timedOut = true; controller.abort(); void Shims.CancelReadableStream(response.body).catch(() => undefined); throw new Errors.APIConnectionTimeoutError(); @@ -1095,17 +1096,17 @@ export class OpenAI { response.text().catch(() => 'X.509 workload identity API response body could not be read.'), expiration, ]); - if (callerSignal?.aborted) { + if (callerSignal.aborted && !timedOut) { throw this._makeUserAbortError(callerSignal); } return body; } catch (error) { - if (callerSignal?.aborted) { + if (callerSignal.aborted && !timedOut) { throw this._makeUserAbortError(callerSignal); } throw error; } finally { - callerSignal?.removeEventListener('abort', cancel); + callerSignal.removeEventListener('abort', cancel); deadline.abort(); } } @@ -1152,7 +1153,7 @@ export class OpenAI { await this.prepareRequest(req, { url, options }); await this._provider?.prepareRequest?.(req, { url, options }); - x509Authentication?.assertRequest(req); + x509Authentication?.adoptRequestHeaders(req); if ( x509Authentication && ((globalThis.ReadableStream && req.body instanceof globalThis.ReadableStream) || @@ -1180,8 +1181,9 @@ export class OpenAI { }), ); - if (options.signal?.aborted || req.signal?.aborted) { - throw this._makeUserAbortError(options.signal?.aborted ? options.signal : req.signal!); + const callerSignal = x509Authentication ? x509Authentication.requestSnapshot().signal : options.signal; + if (callerSignal?.aborted || req.signal?.aborted) { + throw this._makeUserAbortError(callerSignal?.aborted ? callerSignal : req.signal!); } const security = options.__security ?? { bearerAuth: true }; @@ -1189,8 +1191,8 @@ export class OpenAI { const controller = this.fetchWithTimeout === OpenAI.prototype.fetchWithTimeout ? createRequestController( - req.signal ?? (x509Authentication ? options.signal : undefined), - x509Authentication ? options.signal : undefined, + req.signal ?? (x509Authentication ? callerSignal : undefined), + x509Authentication ? callerSignal : undefined, ) : new AbortController(); const remainingTimeout = x509Authentication?.remainingTimeout(options, timeout) ?? timeout; @@ -1202,8 +1204,8 @@ export class OpenAI { if (response instanceof globalThis.Error) { const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; - if (options.signal?.aborted || req.signal?.aborted) { - throw this._makeUserAbortError(options.signal?.aborted ? options.signal : req.signal!); + if (callerSignal?.aborted || req.signal?.aborted) { + throw this._makeUserAbortError(callerSignal?.aborted ? callerSignal : req.signal!); } // detect native connection timeout errors // deno throws "TypeError: error sending request for url (https://example/): client error (Connect): tcp connect error: Operation timed out (os error 60): Operation timed out (os error 60)" @@ -1558,13 +1560,16 @@ export class OpenAI { const x509Authentication = this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; if (x509Authentication) { - const remaining = x509Authentication.remainingTimeout(options, options.timeout ?? this.timeout); + const remaining = x509Authentication.remainingTimeout( + options, + x509Authentication.requestSnapshot().timeout, + ); if (timeoutMillis >= remaining) { throw new Errors.APIConnectionTimeoutError(); } } if (x509Authentication) { - await x509Authentication.waitForRetry(timeoutMillis, options.signal); + await x509Authentication.waitForRetry(timeoutMillis, x509Authentication.requestSnapshot().signal); } else { await sleep(timeoutMillis); } @@ -1602,7 +1607,6 @@ export class OpenAI { const options = { ...inputOptions }; const x509Authentication = this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; - x509Authentication?.snapshotRequestSignal(options.signal); const x509Headers = x509Authentication?.snapshotHeaders(this._options.defaultHeaders, options.headers); if (x509Headers) { options.headers = x509Headers.requestHeaders; @@ -1617,8 +1621,19 @@ export class OpenAI { const url = this.buildURL(path!, query as Record, defaultBaseURL); x509Authentication?.snapshotAPIURL(url); - if ('timeout' in options) validatePositiveInteger('timeout', options.timeout); + const explicitTimeout = 'timeout' in options; + if (explicitTimeout) validatePositiveInteger('timeout', options.timeout); options.timeout = options.timeout ?? this.timeout; + x509Authentication?.snapshotRequest(options.signal, options.timeout); + if (x509Authentication) { + const snapshot = x509Authentication.requestSnapshot(); + options.timeout = snapshot.timeout; + if (snapshot.signal === undefined) { + delete options.signal; + } else { + options.signal = snapshot.signal; + } + } const { bodyHeaders, body, isStreamingBody } = this.buildBody({ options }); if (isStreamingBody) { @@ -1634,6 +1649,7 @@ export class OpenAI { bodyHeaders, retryCount, x509Headers, + x509Timeout: explicitTimeout ? options.timeout : undefined, }); const req: FinalizedRequestInit = { @@ -1656,12 +1672,14 @@ export class OpenAI { bodyHeaders, retryCount, x509Headers, + x509Timeout, }: { options: FinalRequestOptions; method: HTTPMethod; bodyHeaders: HeadersLike; retryCount: number; x509Headers?: { defaultHeaders: NullableHeaders; requestHeaders: NullableHeaders } | undefined; + x509Timeout: number | undefined; }): Promise { let idempotencyHeaders: HeadersLike = {}; if (this.idempotencyHeader && method !== 'get') { @@ -1670,13 +1688,14 @@ export class OpenAI { } const helperMethod = options.__metadata?.['helperMethod']; + const timeout = x509Headers ? x509Timeout : options.timeout; const headers = buildHeaders([ idempotencyHeaders, { Accept: 'application/json', ...(!isRunningInBrowserOrBrowserWorker() ? { 'User-Agent': this.getUserAgent() } : undefined), 'X-Stainless-Retry-Count': String(retryCount), - ...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}), + ...(timeout ? { 'X-Stainless-Timeout': String(Math.trunc(timeout / 1000)) } : {}), ...getPlatformHeaders(), ...(typeof helperMethod === 'string' ? { 'X-Stainless-Helper-Method': helperMethod } : {}), 'OpenAI-Organization': this.organization, diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index c1d18f9922..4b1aab09df 100644 --- a/src/internal/auth/x509-transport-registry.ts +++ b/src/internal/auth/x509-transport-registry.ts @@ -57,7 +57,7 @@ export interface X509ExchangedToken { export interface X509RequestScope { wallStartedAt: number; monotonicStartedAt: number; - callerSignal?: AbortSignal | null | undefined; + request?: { signal: AbortSignal | null | undefined; timeout: number }; apiURL?: string; defaultHeaders?: NullableHeaders; requestHeaders?: NullableHeaders; diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index 8c211a443e..28aa6f6ca5 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -238,14 +238,18 @@ export class X509WorkloadIdentityAuth { return apiURL; } - /** Captures the exact caller signal that will be attached to authenticated dispatch. */ - snapshotRequestSignal(signal: AbortSignal | null | undefined): void { - this.#scope().callerSignal = signal; + /** Captures the exact caller signal and deadline approved for authenticated dispatch. */ + snapshotRequest(signal: AbortSignal | null | undefined, timeout: number): void { + this.#scope().request ??= { signal, timeout }; } - /** Returns the request signal without invoking caller-owned accessors again. */ - requestSignal(): AbortSignal | null | undefined { - return this.#scope().callerSignal; + /** Returns immutable request settings without invoking caller-owned accessors again. */ + requestSnapshot(): { signal: AbortSignal | null | undefined; timeout: number } { + const { request } = this.#scope(); + if (!request) { + throw new OpenAIError('X.509 workload identity requires snapshotted request settings.'); + } + return request; } /** Establishes an independent scope even when concurrent requests share caller options. */ @@ -266,8 +270,12 @@ export class X509WorkloadIdentityAuth { /** Binds deferred response parsing to the original logical request and its unchanged deadline. */ continuation(): (operation: () => Promise) => Promise { - const { wallStartedAt, monotonicStartedAt } = this.#scope(); - const scope: X509RequestScope = { wallStartedAt, monotonicStartedAt }; + const { wallStartedAt, monotonicStartedAt, request } = this.#scope(); + const scope: X509RequestScope = { + wallStartedAt, + monotonicStartedAt, + ...(request ? { request } : {}), + }; return (operation) => this.#transport.resume(scope, async () => { try { @@ -281,7 +289,7 @@ export class X509WorkloadIdentityAuth { /** Removes dispatched bearer material before settled request promises can retain their scope. */ releaseRequestCredentials(): void { const scope = this.#scope(); - delete scope.callerSignal; + delete scope.request; delete scope.apiURL; delete scope.token; delete scope.defaultHeaders; @@ -463,6 +471,22 @@ export class X509WorkloadIdentityAuth { this.assertRequest(request); } + /** Rebinds an equivalent protected-hook container without relaxing final dispatch identity checks. */ + adoptRequestHeaders(request: RequestInit): void { + const scope = this.#scope(); + const original = scope.headers; + if (!(original instanceof Headers) || !(request.headers instanceof Headers)) { + throw new OpenAIError('X.509 workload identity must preserve its issued workload authorization.'); + } + scope.headers = request.headers; + try { + this.assertRequest(request); + } catch (error) { + scope.headers = original; + throw error; + } + } + /** Rejects request hooks that replace the selected bearer or its approved header identity. */ assertRequest(request: RequestInit): void { const { headers } = request; diff --git a/tests/lib/x509-workload-review-regressions.test.ts b/tests/lib/x509-workload-review-regressions.test.ts index 8b0e2cf11d..59bed2803c 100644 --- a/tests/lib/x509-workload-review-regressions.test.ts +++ b/tests/lib/x509-workload-review-regressions.test.ts @@ -293,6 +293,61 @@ describe('X.509 review regressions', () => { expect(send).toHaveBeenCalledTimes(1); }); + test('authenticates within the same accessor-backed timeout captured for API dispatch', async () => { + const getter = vi.fn(() => (getter.mock.calls.length === 1 ? 30 : 1000)); + const request = Object.defineProperty({ path: '/models', method: 'get' as const }, 'timeout', { + enumerable: true, + get: getter, + }); + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, _url, init) => { + await delay(150, undefined, { signal: init.signal ?? undefined }); + return Response.json(TOKEN_RESPONSE); + }); + + await expect(new OpenAI(options()).buildRequest(request)).rejects.toBeInstanceOf( + APIConnectionTimeoutError, + ); + expect(getter).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + }); + + test('preserves its original accessor-backed signal when issuer authentication retries', async () => { + const actual = new AbortController(); + const unrelated = new AbortController(); + const getter = vi.fn(() => (getter.mock.calls.length === 1 ? actual.signal : unrelated.signal)); + const request = Object.defineProperty({ path: '/models', method: 'get' as const }, 'signal', { + enumerable: true, + get: getter, + }); + let issuerCalls = 0; + let dispatchedSignal: RequestInit['signal']; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => { + if (url.origin !== 'https://mtls.auth.openai.com') { + return Response.json({ data: [] }); + } + issuerCalls += 1; + return issuerCalls === 1 + ? new Response(null, { status: 503, headers: { 'retry-after-ms': '1' } }) + : Response.json(TOKEN_RESPONSE); + }); + const client = new OpenAI(options({ maxRetries: 1 })); + Object.defineProperty(client, 'prepareRequest', { + value: async (prepared: RequestInit) => { + dispatchedSignal = prepared.signal; + }, + }); + + await client.request(request); + + expect(dispatchedSignal).toBe(actual.signal); + expect(getter.mock.calls.length).toBeGreaterThan(1); + expect(send).toHaveBeenCalledTimes(3); + }); + test('never retries issuer authentication after a one-shot request body starts pulling', async () => { let pulledChunks = 0; const body = { @@ -373,6 +428,38 @@ describe('X.509 review regressions', () => { }); }); + test('preserves hook-signal cancellation while reading a stalled terminal error body', async () => { + const replacement = new AbortController(); + const reason = new Error('synthetic-hook-error-body-cancellation'); + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url, request) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : new Response( + new ReadableStream({ + start(stream) { + request.signal?.addEventListener('abort', () => stream.error(request.signal?.reason), { + once: true, + }); + }, + }), + { status: 403 }, + ), + ); + const client = new OpenAI(options({ timeout: 1000 })); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + request.signal = replacement.signal; + }, + }); + const pending = client.models.list(); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2)); + replacement.abort(reason); + + await expect(pending).rejects.toMatchObject({ constructor: APIUserAbortError, cause: reason }); + }); + test.each(['default', 'request'] as const)( 'renders mutable %s header values only once before presenting its certificate', async (location) => { @@ -433,6 +520,51 @@ describe('X.509 review regressions', () => { expect(request.headers).toBe(headers); }); + test('accepts an equivalent Headers replacement from a protected request hook', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(TOKEN_RESPONSE) + : Response.json({ data: [] }), + ); + const client = new OpenAI(options()); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + request.headers = new Headers(request.headers); + request.headers.set('X-Synthetic-Hook', 'synthetic-approved-value'); + }, + }); + + await expect(client.models.list()).resolves.toMatchObject({ data: [] }); + expect(send).toHaveBeenCalledTimes(2); + expect(new Headers(send.mock.calls[1]?.[2].headers).get('Authorization')).toBe( + 'Bearer synthetic-review-bearer', + ); + expect(new Headers(send.mock.calls[1]?.[2].headers).get('X-Synthetic-Hook')).toBe( + 'synthetic-approved-value', + ); + }); + + test.each([ + ['Authorization', 'Bearer synthetic-attacker-bearer'], + ['Proxy-Authorization', 'synthetic-attacker-proxy'], + ])('rejects a hook-replaced Headers container carrying forbidden %s', async (name, value) => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockResolvedValue(Response.json(TOKEN_RESPONSE)); + const client = new OpenAI(options()); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + request.headers = new Headers(request.headers); + request.headers.set(name, value); + }, + }); + + await expect(client.models.list()).rejects.toThrow(/authorization|authentication/iu); + expect(send).toHaveBeenCalledTimes(1); + }); + test('clears authenticated request credentials when a protected request hook fails', async () => { let observedScope: ReturnType['current']>; const client = new OpenAI(options()); From d27455e8e6b8d2dfab04f01260059984ce240a1d Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 01:07:29 +0000 Subject: [PATCH 12/18] fix(auth): preserve approved x509 request boundaries --- src/client.ts | 19 ++- src/internal/auth/x509-transport-registry.ts | 6 +- .../auth/x509-workload-identity-auth.ts | 20 ++- tests/lib/x509-workload-identity.test.ts | 3 +- .../x509-workload-request-boundaries.test.ts | 157 ++++++++++++++++++ 5 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 tests/lib/x509-workload-request-boundaries.test.ts diff --git a/src/client.ts b/src/client.ts index cc74b2408d..87fda11d49 100644 --- a/src/client.ts +++ b/src/client.ts @@ -996,7 +996,7 @@ export class OpenAI { void Shims.CancelReadableStream(props.response.body).catch(() => undefined); throw error; } - const callerSignal = props.options.signal; + const callerSignal = x509Authentication ? props.controller.signal : props.options.signal; const abortError = () => x509Authentication && callerSignal ? this._makeUserAbortError(callerSignal) @@ -1019,14 +1019,16 @@ export class OpenAI { }, remaining); if (callerSignal) { - abortListener = () => reject(abortError()); + abortListener = () => { + if (!timedOut) reject(abortError()); + }; callerSignal.addEventListener('abort', abortListener, { once: true }); } }); return await Promise.race([defaultParseResponse(client, props), timeoutPromise]); } catch (error) { - if (callerSignal?.aborted) { + if (callerSignal?.aborted && !timedOut) { throw abortError(); } if (!timedOut) { @@ -1147,7 +1149,10 @@ export class OpenAI { } throw error; } - const { req, url, timeout } = built; + const { req, url } = built; + const timeout = x509Authentication + ? Math.min(built.timeout, x509Authentication.requestSnapshot().timeout) + : built.timeout; x509Authentication?.bindRequest(options, req, this.adminAPIKey); let hasStreamingBody = options.__metadata?.['hasStreamingBody'] === true; @@ -1176,7 +1181,7 @@ export class OpenAI { retryOfRequestLogID, method: options.method, url, - options, + options: x509Authentication ? { body: req.body, ...x509Authentication.requestSnapshot() } : options, headers: req.headers, }), ); @@ -1624,7 +1629,9 @@ export class OpenAI { const explicitTimeout = 'timeout' in options; if (explicitTimeout) validatePositiveInteger('timeout', options.timeout); options.timeout = options.timeout ?? this.timeout; - x509Authentication?.snapshotRequest(options.signal, options.timeout); + if (x509Authentication && x509RequestFetchOptions) { + x509Authentication.snapshotRequest(options.signal, options.timeout, x509RequestFetchOptions); + } if (x509Authentication) { const snapshot = x509Authentication.requestSnapshot(); options.timeout = snapshot.timeout; diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index 4b1aab09df..ab8d53df69 100644 --- a/src/internal/auth/x509-transport-registry.ts +++ b/src/internal/auth/x509-transport-registry.ts @@ -1,10 +1,14 @@ import { OpenAIError } from '../../core/error'; import type { NullableHeaders } from '../headers'; +import type { MergedRequestInit } from '../types'; import { findRegisteredX509Transport } from '#x509-transport-state'; const transientX509TransportCodes = new Set([ 'ECONNRESET', 'ECONNREFUSED', + 'ENETUNREACH', + 'EHOSTUNREACH', + 'ENETDOWN', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN', @@ -57,7 +61,7 @@ export interface X509ExchangedToken { export interface X509RequestScope { wallStartedAt: number; monotonicStartedAt: number; - request?: { signal: AbortSignal | null | undefined; timeout: number }; + request?: { signal: AbortSignal | null | undefined; timeout: number; fetchOptions: MergedRequestInit }; apiURL?: string; defaultHeaders?: NullableHeaders; requestHeaders?: NullableHeaders; diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index 28aa6f6ca5..3903a2f5fe 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -238,13 +238,21 @@ export class X509WorkloadIdentityAuth { return apiURL; } - /** Captures the exact caller signal and deadline approved for authenticated dispatch. */ - snapshotRequest(signal: AbortSignal | null | undefined, timeout: number): void { - this.#scope().request ??= { signal, timeout }; + /** Captures the exact caller settings approved for authenticated dispatch. */ + snapshotRequest( + signal: AbortSignal | null | undefined, + timeout: number, + fetchOptions: MergedRequestInit, + ): void { + this.#scope().request ??= { signal, timeout, fetchOptions }; } /** Returns immutable request settings without invoking caller-owned accessors again. */ - requestSnapshot(): { signal: AbortSignal | null | undefined; timeout: number } { + requestSnapshot(): { + signal: AbortSignal | null | undefined; + timeout: number; + fetchOptions: MergedRequestInit; + } { const { request } = this.#scope(); if (!request) { throw new OpenAIError('X.509 workload identity requires snapshotted request settings.'); @@ -360,6 +368,7 @@ export class X509WorkloadIdentityAuth { requestHeaders: HeadersLike; signal: AbortSignal | null | undefined; timeout: number; + fetchOptions: MergedRequestInit; }, ): Promise { const callerSignal = context ? context.signal : options?.signal; @@ -411,11 +420,12 @@ export class X509WorkloadIdentityAuth { defaultHeaders: HeadersLike | undefined; requestHeaders: HeadersLike; timeout: number; + fetchOptions: MergedRequestInit; } | undefined, ): void { if (options) { - assertX509RequestOptions(options.fetchOptions); + assertX509RequestOptions(context ? context.fetchOptions : options.fetchOptions); } if (!context) { return; diff --git a/tests/lib/x509-workload-identity.test.ts b/tests/lib/x509-workload-identity.test.ts index 1ca2e2762b..f26dd44f37 100644 --- a/tests/lib/x509-workload-identity.test.ts +++ b/tests/lib/x509-workload-identity.test.ts @@ -332,7 +332,7 @@ describe('OpenAI X.509 workload-identity client integration', () => { enumerable: true, get: () => { reads += 1; - if (reads === 2) { + if (reads === 1) { caller.abort(reason); } return {}; @@ -343,6 +343,7 @@ describe('OpenAI X.509 workload-identity client integration', () => { constructor: APIUserAbortError, cause: reason, }); + expect(reads).toBe(1); expect(send).not.toHaveBeenCalled(); }); diff --git a/tests/lib/x509-workload-request-boundaries.test.ts b/tests/lib/x509-workload-request-boundaries.test.ts new file mode 100644 index 0000000000..01c03ce489 --- /dev/null +++ b/tests/lib/x509-workload-request-boundaries.test.ts @@ -0,0 +1,157 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { Agent } from 'undici'; +import { vi } from 'vitest'; + +import OpenAI, { APIConnectionTimeoutError, APIUserAbortError } from 'openai'; +import type { ClientOptions } from 'openai'; +import { createX509Transport } from 'openai/auth/x509-transport'; +import type { X509Transport } from 'openai/auth/x509-transport'; +import * as transportCapability from 'openai/internal/auth/x509-transport-capability'; + +const tokenResponse = { + access_token: 'synthetic-request-boundary-bearer', + token_type: 'Bearer', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + expires_in: 3600, +}; + +let dispatcher: Agent; +let transport: X509Transport; + +function options(overrides: Partial = {}): ClientOptions { + return { + apiKey: null, + maxRetries: 0, + workloadIdentity: { + type: 'x509', + identityProviderId: 'synthetic-boundary-provider', + serviceAccountId: 'synthetic-boundary-account', + }, + x509Transport: transport, + ...overrides, + }; +} + +beforeEach(() => { + dispatcher = new Agent(); + transport = createX509Transport({ + runtime: 'node', + dispatcher, + certificateIdentity: 'static', + proxy: 'direct', + }); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await dispatcher.close(); +}); + +describe('X.509 request ownership boundaries', () => { + test('keeps the captured accessor-backed signal during successful response parsing', async () => { + const captured = new AbortController(); + const reason = new Error('synthetic-success-body-cancellation'); + const getter = vi.fn(() => { + if (getter.mock.calls.length > 1) { + throw new Error('unexpected signal accessor reread'); + } + return captured.signal; + }); + const request = Object.defineProperty({ path: '/models', method: 'get' as const }, 'signal', { + enumerable: true, + get: getter, + }); + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url, init) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(tokenResponse) + : new Response( + new ReadableStream({ + start(stream) { + init.signal?.addEventListener('abort', () => stream.error(init.signal?.reason), { + once: true, + }); + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const pending = new OpenAI(options({ timeout: 1000 })).request(request); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2)); + captured.abort(reason); + + await expect(pending).rejects.toMatchObject({ constructor: APIUserAbortError, cause: reason }); + expect(getter).toHaveBeenCalledTimes(1); + }); + + test('uses the validated accessor-backed fetch options throughout issuer preflight and dispatch', async () => { + const getter = vi.fn(() => { + if (getter.mock.calls.length > 1) { + throw new Error('unexpected fetch-options accessor reread'); + } + return { cache: 'no-store' as const }; + }); + const request = Object.defineProperty({ path: '/models', method: 'get' as const }, 'fetchOptions', { + enumerable: true, + get: getter, + }); + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(tokenResponse) + : Response.json({ data: [] }), + ); + + await expect(new OpenAI(options()).request(request)).resolves.toMatchObject({ data: [] }); + expect(getter).toHaveBeenCalledTimes(1); + expect(send.mock.calls[1]?.[2].cache).toBe('no-store'); + }); + + test('does not let a protected buildRequest override enlarge the authenticated request deadline', async () => { + class ExtendedTimeoutClient extends OpenAI { + override async buildRequest(...args: Parameters) { + return { ...(await super.buildRequest(...args)), timeout: 500 }; + } + } + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url, init) => { + if (url.origin === 'https://mtls.auth.openai.com') { + return Response.json(tokenResponse); + } + await delay(140, undefined, { signal: init.signal ?? undefined }); + return Response.json({ data: [] }); + }); + + await expect(new ExtendedTimeoutClient(options({ timeout: 50 })).models.list()).rejects.toBeInstanceOf( + APIConnectionTimeoutError, + ); + expect(send).toHaveBeenCalledTimes(2); + }); + + test.each(['ENETUNREACH', 'EHOSTUNREACH', 'ENETDOWN'])( + 'retries a temporary issuer routing failure with code %s', + async (code) => { + let issuerAttempts = 0; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => { + if (url.origin !== 'https://mtls.auth.openai.com') { + return Response.json({ data: [] }); + } + issuerAttempts += 1; + if (issuerAttempts === 1) { + throw Object.assign(new Error('synthetic temporary routing failure'), { code }); + } + return Response.json(tokenResponse); + }); + + await expect(new OpenAI(options({ maxRetries: 1 })).models.list()).resolves.toMatchObject({ data: [] }); + expect(issuerAttempts).toBe(2); + expect(send).toHaveBeenCalledTimes(3); + }, + ); +}); From 54b474dbd1a054239e82567a356dd8c206f8f1b3 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 01:52:27 +0000 Subject: [PATCH 13/18] fix(auth): validate x509 request before credential exchange --- src/client.ts | 37 +++++- src/internal/auth/x509-transport-registry.ts | 2 + .../auth/x509-workload-identity-auth.ts | 66 ++++++++++- .../x509-workload-request-boundaries.test.ts | 108 ++++++++++++++++++ 4 files changed, 208 insertions(+), 5 deletions(-) diff --git a/src/client.ts b/src/client.ts index 87fda11d49..27c9b01565 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1128,11 +1128,27 @@ export class OpenAI { const x509Authentication = this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; + x509Authentication?.beginRequestPlanning(); let built: { req: FinalizedRequestInit; url: string; timeout: number }; try { - built = await this.buildRequest(options, { + const candidate = await this.buildRequest(options, { retryCount: maxRetries - retriesRemaining, }); + built = { req: candidate.req, url: candidate.url, timeout: candidate.timeout }; + if (x509Authentication) { + validatePositiveInteger('timeout', built.timeout); + x509Authentication.authorizePlannedRequest(built.url, built.req); + const security = options.__security ?? { bearerAuth: true }; + const authenticationHeaders = await this.authHeaders(options, security); + const suppliedHeaders = x509Authentication.headerSnapshots(); + const supplied = buildHeaders([suppliedHeaders.defaultHeaders, suppliedHeaders.requestHeaders]); + for (const [name, value] of authenticationHeaders?.values ?? []) { + if (!supplied.nulls.has(name) && !built.req.headers.has(name)) { + built.req.headers.set(name, value); + } + } + this.validateHeaders(buildHeaders([supplied, built.req.headers]), security); + } } catch (error) { if ( x509Authentication && @@ -1190,6 +1206,11 @@ export class OpenAI { if (callerSignal?.aborted || req.signal?.aborted) { throw this._makeUserAbortError(callerSignal?.aborted ? callerSignal : req.signal!); } + if (x509Authentication && (req.signal || callerSignal)) { + x509Authentication.setEffectiveSignal( + createRequestController(req.signal ?? callerSignal, callerSignal).signal, + ); + } const security = options.__security ?? { bearerAuth: true }; // Request hooks may replace the caller signal before it reaches fetch. @@ -1574,7 +1595,7 @@ export class OpenAI { } } if (x509Authentication) { - await x509Authentication.waitForRetry(timeoutMillis, x509Authentication.requestSnapshot().signal); + await x509Authentication.waitForRetry(timeoutMillis, x509Authentication.effectiveSignal()); } else { await sleep(timeoutMillis); } @@ -1708,7 +1729,9 @@ export class OpenAI { 'OpenAI-Organization': this.organization, 'OpenAI-Project': this.project, }, - this._provider + this._provider || + (this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth && + this._workloadIdentityAuth.isPlanningRequest()) ? undefined : await this.authHeaders(options, options.__security ?? { bearerAuth: true }), x509Headers?.defaultHeaders ?? this._options.defaultHeaders, @@ -1716,7 +1739,13 @@ export class OpenAI { x509Headers?.requestHeaders ?? options.headers, ]); - if (!this._provider) { + if ( + !this._provider && + !( + this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth && + this._workloadIdentityAuth.isPlanningRequest() + ) + ) { this.validateHeaders(headers, options.__security ?? { bearerAuth: true }); } diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index ab8d53df69..3b82b4c0fb 100644 --- a/src/internal/auth/x509-transport-registry.ts +++ b/src/internal/auth/x509-transport-registry.ts @@ -62,6 +62,8 @@ export interface X509RequestScope { wallStartedAt: number; monotonicStartedAt: number; request?: { signal: AbortSignal | null | undefined; timeout: number; fetchOptions: MergedRequestInit }; + phase?: 'planning' | 'authorizing'; + effectiveSignal?: AbortSignal; apiURL?: string; defaultHeaders?: NullableHeaders; requestHeaders?: NullableHeaders; diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index 3903a2f5fe..a139323f54 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -260,6 +260,67 @@ export class X509WorkloadIdentityAuth { return request; } + /** Arms one logical network deadline only after protected option preparation completes. */ + beginRequestPlanning(): void { + const scope = this.#scope(); + if (!scope.request) { + scope.wallStartedAt = Date.now(); + scope.monotonicStartedAt = performance.now(); + } + scope.phase = 'planning'; + delete scope.effectiveSignal; + } + + /** Keeps certificate authentication outside overridable request construction. */ + isPlanningRequest(): boolean { + return this.#scope().phase === 'planning'; + } + + /** Approves the final overridden destination and transport before minting a bearer. */ + authorizePlannedRequest(url: string, request: RequestInit): void { + const scope = this.#scope(); + const headers = Object.getOwnPropertyDescriptor(request, 'headers'); + const signal = Object.getOwnPropertyDescriptor(request, 'signal'); + const redirect = Object.getOwnPropertyDescriptor(request, 'redirect'); + if ( + scope.phase !== 'planning' || + !headers || + !('value' in headers) || + !(headers.value instanceof Headers) || + (signal && !('value' in signal)) || + (redirect && !('value' in redirect)) + ) { + throw new OpenAIError('X.509 workload identity requires an approved final request.'); + } + this.snapshotAPIURL(url); + assertX509FetchOptions(request); + try { + assertSafeHeaders(headers.value); + } catch { + throw new OpenAIError('X.509 workload identity cannot use caller-supplied authentication credentials.'); + } + if (headerValue(headers.value, 'Authorization') !== null) { + throw new OpenAIError('X.509 workload identity cannot use caller-supplied authorization credentials.'); + } + if ((signal?.value ?? undefined) !== (this.requestSnapshot().signal ?? undefined)) { + throw new OpenAIError('X.509 workload identity must preserve its approved request signal.'); + } + scope.phase = 'authorizing'; + } + + /** Retains caller-only cancellation separately from SDK-created deadline controllers. */ + setEffectiveSignal(signal: AbortSignal | undefined): void { + if (signal) { + this.#scope().effectiveSignal = signal; + } + } + + /** Uses protected-hook cancellation when an authenticated attempt enters retry backoff. */ + effectiveSignal(): AbortSignal | null | undefined { + const scope = this.#scope(); + return scope.effectiveSignal ?? scope.request?.signal; + } + /** Establishes an independent scope even when concurrent requests share caller options. */ runRequest(operation: () => Promise): Promise { return this.#transport.run(async () => { @@ -278,11 +339,12 @@ export class X509WorkloadIdentityAuth { /** Binds deferred response parsing to the original logical request and its unchanged deadline. */ continuation(): (operation: () => Promise) => Promise { - const { wallStartedAt, monotonicStartedAt, request } = this.#scope(); + const { wallStartedAt, monotonicStartedAt, request, effectiveSignal } = this.#scope(); const scope: X509RequestScope = { wallStartedAt, monotonicStartedAt, ...(request ? { request } : {}), + ...(effectiveSignal ? { effectiveSignal } : {}), }; return (operation) => this.#transport.resume(scope, async () => { @@ -298,6 +360,8 @@ export class X509WorkloadIdentityAuth { releaseRequestCredentials(): void { const scope = this.#scope(); delete scope.request; + delete scope.phase; + delete scope.effectiveSignal; delete scope.apiURL; delete scope.token; delete scope.defaultHeaders; diff --git a/tests/lib/x509-workload-request-boundaries.test.ts b/tests/lib/x509-workload-request-boundaries.test.ts index 01c03ce489..884f28ae93 100644 --- a/tests/lib/x509-workload-request-boundaries.test.ts +++ b/tests/lib/x509-workload-request-boundaries.test.ts @@ -48,6 +48,114 @@ afterEach(async () => { }); describe('X.509 request ownership boundaries', () => { + test('validates the final overridden destination before presenting a certificate', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockResolvedValue(Response.json(tokenResponse)); + const client = new OpenAI(options()); + const original = client.buildRequest.bind(client); + Object.defineProperty(client, 'buildRequest', { + value: async (...args: Parameters) => ({ + ...(await original(...args)), + url: 'https://untrusted.invalid/v1/models', + }), + }); + + await expect(client.models.list()).rejects.toThrow(/origin|endpoint|URL/iu); + expect(send).not.toHaveBeenCalled(); + }); + + test('uses one immutable accessor-backed override destination for authentication and dispatch', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(tokenResponse) + : Response.json({ data: [] }), + ); + const client = new OpenAI(options()); + const original = client.buildRequest.bind(client); + const getter = vi.fn(() => + getter.mock.calls.length === 1 + ? 'https://mtls.api.openai.com/v1/models' + : 'https://untrusted.invalid/v1/models', + ); + Object.defineProperty(client, 'buildRequest', { + value: async (...args: Parameters) => + Object.defineProperty(await original(...args), 'url', { get: getter }), + }); + + await expect(client.models.list()).resolves.toMatchObject({ data: [] }); + expect(getter).toHaveBeenCalledTimes(1); + expect(send.mock.calls[1]?.[1].origin).toBe('https://mtls.api.openai.com'); + }); + + test.each(['Authorization', 'Proxy-Authorization', 'Host'])( + 'rejects overridden final %s before certificate exchange', + async (name) => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockResolvedValue(Response.json(tokenResponse)); + const client = new OpenAI(options()); + const original = client.buildRequest.bind(client); + Object.defineProperty(client, 'buildRequest', { + value: async (...args: Parameters) => { + const built = await original(...args); + built.req.headers.set(name, 'synthetic-unapproved-credential'); + return built; + }, + }); + + await expect(client.models.list()).rejects.toThrow(/caller-supplied.*credentials/iu); + expect(send).not.toHaveBeenCalled(); + }, + ); + + test('starts its network deadline only after asynchronous request preparation completes', async () => { + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(tokenResponse) + : Response.json({ data: [] }), + ); + const client = new OpenAI(options({ timeout: 45 })); + Object.defineProperty(client, 'prepareOptions', { + value: async () => await delay(90), + }); + + await expect(client.models.list()).resolves.toMatchObject({ + data: [], + }); + expect(send).toHaveBeenCalledTimes(2); + }); + + test('cancels retry backoff promptly through the effective protected-hook signal', async () => { + const hookController = new AbortController(); + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(tokenResponse) + : new Response(null, { status: 503, headers: { 'retry-after-ms': '500' } }), + ); + const client = new OpenAI(options({ maxRetries: 1, timeout: 2000 })); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + request.signal = hookController.signal; + }, + }); + const pending = client.models.list(); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2)); + const reason = new Error('synthetic-hook-retry-cancellation'); + const canceledAt = performance.now(); + hookController.abort(reason); + + await expect(pending).rejects.toMatchObject({ constructor: APIUserAbortError, cause: reason }); + expect(performance.now() - canceledAt).toBeLessThan(250); + expect(send).toHaveBeenCalledTimes(2); + }); + test('keeps the captured accessor-backed signal during successful response parsing', async () => { const captured = new AbortController(); const reason = new Error('synthetic-success-body-cancellation'); From 501a3a3642bf48bf12a91bef837cf0d3b0f73d84 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 02:16:44 +0000 Subject: [PATCH 14/18] fix(auth): bind x509 attempt deadline cancellation and body ownership --- src/client.ts | 21 ++- src/internal/auth/x509-transport-registry.ts | 1 + .../auth/x509-workload-identity-auth.ts | 40 ++++- .../x509-workload-request-boundaries.test.ts | 141 ++++++++++++++++++ 4 files changed, 195 insertions(+), 8 deletions(-) diff --git a/src/client.ts b/src/client.ts index 27c9b01565..0c2ce04b36 100644 --- a/src/client.ts +++ b/src/client.ts @@ -785,6 +785,7 @@ export class OpenAI { apiURL: this._workloadIdentityAuth.requestAPIURL(), ...this._workloadIdentityAuth.headerSnapshots(), ...this._workloadIdentityAuth.requestSnapshot(), + signal: this._workloadIdentityAuth.effectiveSignal(), }) : await this._workloadIdentityAuth.getToken(); return buildHeaders([{ Authorization: `Bearer ${token}` }]); @@ -1137,7 +1138,7 @@ export class OpenAI { built = { req: candidate.req, url: candidate.url, timeout: candidate.timeout }; if (x509Authentication) { validatePositiveInteger('timeout', built.timeout); - x509Authentication.authorizePlannedRequest(built.url, built.req); + x509Authentication.authorizePlannedRequest(built.url, built.req, built.timeout); const security = options.__security ?? { bearerAuth: true }; const authenticationHeaders = await this.authHeaders(options, security); const suppliedHeaders = x509Authentication.headerSnapshots(); @@ -1150,6 +1151,7 @@ export class OpenAI { this.validateHeaders(buildHeaders([supplied, built.req.headers]), security); } } catch (error) { + x509Authentication?.retireRequestBody(); if ( x509Authentication && retriesRemaining && @@ -1206,9 +1208,11 @@ export class OpenAI { if (callerSignal?.aborted || req.signal?.aborted) { throw this._makeUserAbortError(callerSignal?.aborted ? callerSignal : req.signal!); } - if (x509Authentication && (req.signal || callerSignal)) { + if (x509Authentication) { x509Authentication.setEffectiveSignal( - createRequestController(req.signal ?? callerSignal, callerSignal).signal, + req.signal || callerSignal + ? createRequestController(req.signal ?? callerSignal, callerSignal).signal + : undefined, ); } @@ -1223,6 +1227,7 @@ export class OpenAI { : new AbortController(); const remainingTimeout = x509Authentication?.remainingTimeout(options, timeout) ?? timeout; const fetchWithAuth = x509Authentication ? OpenAI.prototype.fetchWithAuth : this.fetchWithAuth; + x509Authentication?.releaseRequestBody(); const response = await fetchWithAuth .call(this, url, req, remainingTimeout, controller, security) .catch(castToError); @@ -1626,9 +1631,12 @@ export class OpenAI { this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth && !this._workloadIdentityAuth.inRequest() ) { - return await this._workloadIdentityAuth.runRequest(() => - OpenAI.prototype.buildRequest.call(this, inputOptions, { retryCount }), - ); + const authentication = this._workloadIdentityAuth; + return await authentication.runRequest(async () => { + const built = await OpenAI.prototype.buildRequest.call(this, inputOptions, { retryCount }); + authentication.releaseRequestBody(); + return built; + }); } const options = { ...inputOptions }; const x509Authentication = @@ -1669,6 +1677,7 @@ export class OpenAI { ...inputOptions.__metadata, hasStreamingBody: true, }; + x509Authentication?.ownRequestBody(body, options.body); } const reqHeaders = await this.buildHeaders({ diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index 3b82b4c0fb..74fbec37da 100644 --- a/src/internal/auth/x509-transport-registry.ts +++ b/src/internal/auth/x509-transport-registry.ts @@ -64,6 +64,7 @@ export interface X509RequestScope { request?: { signal: AbortSignal | null | undefined; timeout: number; fetchOptions: MergedRequestInit }; phase?: 'planning' | 'authorizing'; effectiveSignal?: AbortSignal; + materializedBody?: ReadableStream; apiURL?: string; defaultHeaders?: NullableHeaders; requestHeaders?: NullableHeaders; diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index a139323f54..1595300b0e 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -4,6 +4,7 @@ import type { Fetch } from '../builtin-types'; import { buildHeaders } from '../headers'; import type { HeadersLike, NullableHeaders } from '../headers'; import type { FinalRequestOptions } from '../request-options'; +import { CancelReadableStream } from '../shims'; import type { MergedRequestInit } from '../types'; import { hasOwn } from '../utils/values'; import { resolveX509Transport } from './x509-transport-registry'; @@ -268,7 +269,6 @@ export class X509WorkloadIdentityAuth { scope.monotonicStartedAt = performance.now(); } scope.phase = 'planning'; - delete scope.effectiveSignal; } /** Keeps certificate authentication outside overridable request construction. */ @@ -277,7 +277,7 @@ export class X509WorkloadIdentityAuth { } /** Approves the final overridden destination and transport before minting a bearer. */ - authorizePlannedRequest(url: string, request: RequestInit): void { + authorizePlannedRequest(url: string, request: RequestInit, timeout: number): void { const scope = this.#scope(); const headers = Object.getOwnPropertyDescriptor(request, 'headers'); const signal = Object.getOwnPropertyDescriptor(request, 'signal'); @@ -305,13 +305,47 @@ export class X509WorkloadIdentityAuth { if ((signal?.value ?? undefined) !== (this.requestSnapshot().signal ?? undefined)) { throw new OpenAIError('X.509 workload identity must preserve its approved request signal.'); } + const approved = this.requestSnapshot(); + scope.request = { ...approved, timeout: Math.min(approved.timeout, timeout) }; scope.phase = 'authorizing'; } + /** Owns only SDK-created iterator adapters until authenticated dispatch takes responsibility. */ + ownRequestBody(body: unknown, source: unknown): void { + if (body instanceof ReadableStream && body !== source) { + this.#scope().materializedBody = body; + } + } + + /** Retires abandoned upload adapters without masking or blocking their authentication failure. */ + retireRequestBody(): void { + const scope = this.#scope(); + const body = scope.materializedBody; + delete scope.materializedBody; + if (body) { + void X509WorkloadIdentityAuth.#cancelRequestBody(body); + } + } + + static async #cancelRequestBody(body: ReadableStream): Promise { + try { + await CancelReadableStream(body); + } catch { + // Upload retirement must never replace the original authentication failure. + } + } + + /** Transfers an approved upload to the actual request transport without cancelling it. */ + releaseRequestBody(): void { + delete this.#scope().materializedBody; + } + /** Retains caller-only cancellation separately from SDK-created deadline controllers. */ setEffectiveSignal(signal: AbortSignal | undefined): void { if (signal) { this.#scope().effectiveSignal = signal; + } else { + delete this.#scope().effectiveSignal; } } @@ -327,6 +361,7 @@ export class X509WorkloadIdentityAuth { try { return await operation(); } finally { + this.retireRequestBody(); this.releaseRequestCredentials(); } }); @@ -362,6 +397,7 @@ export class X509WorkloadIdentityAuth { delete scope.request; delete scope.phase; delete scope.effectiveSignal; + delete scope.materializedBody; delete scope.apiURL; delete scope.token; delete scope.defaultHeaders; diff --git a/tests/lib/x509-workload-request-boundaries.test.ts b/tests/lib/x509-workload-request-boundaries.test.ts index 884f28ae93..47f41597d5 100644 --- a/tests/lib/x509-workload-request-boundaries.test.ts +++ b/tests/lib/x509-workload-request-boundaries.test.ts @@ -48,6 +48,147 @@ afterEach(async () => { }); describe('X.509 request ownership boundaries', () => { + test('applies a lowered final override deadline before certificate authentication', async () => { + let minted = false; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, _url, request) => { + await delay(140, undefined, { signal: request.signal ?? undefined }); + minted = true; + return Response.json(tokenResponse); + }); + const client = new OpenAI(options({ timeout: 500 })); + const original = client.buildRequest.bind(client); + Object.defineProperty(client, 'buildRequest', { + value: async (...args: Parameters) => ({ + ...(await original(...args)), + timeout: 35, + }), + }); + + await expect(client.models.list()).rejects.toBeInstanceOf(APIConnectionTimeoutError); + expect(minted).toBe(false); + expect(send).toHaveBeenCalledTimes(1); + }); + + test('retires an SDK-materialized one-shot iterator when certificate authentication fails', async () => { + let finalized = false; + const body = { + async *[Symbol.asyncIterator]() { + try { + yield new TextEncoder().encode('synthetic-private-upload'); + yield new TextEncoder().encode('synthetic-never-dispatched'); + } finally { + finalized = true; + } + }, + }; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockResolvedValue(new Response(null, { status: 503, headers: { 'retry-after-ms': '1' } })); + + await expect( + new OpenAI(options({ maxRetries: 1 })).request({ path: '/responses', method: 'post', body }), + ).rejects.toMatchObject({ status: 503 }); + await vi.waitFor(() => expect(finalized).toBe(true), { timeout: 200 }); + expect(send).toHaveBeenCalledTimes(1); + }); + + test('does not cancel a caller-owned request stream after certificate authentication fails', async () => { + let canceled = false; + const body = new ReadableStream({ + cancel() { + canceled = true; + }, + }); + vi.spyOn(transportCapability, 'sendX509Request').mockResolvedValue(new Response(null, { status: 503 })); + + await expect( + new OpenAI(options()).request({ path: '/responses', method: 'post', body }), + ).rejects.toMatchObject({ status: 503 }); + expect(canceled).toBe(false); + }); + + test('retires an SDK-owned upload when direct authenticated request construction fails', async () => { + let finalized = false; + const body = { + async *[Symbol.asyncIterator]() { + try { + yield new TextEncoder().encode('synthetic-direct-upload'); + } finally { + finalized = true; + } + }, + }; + vi.spyOn(transportCapability, 'sendX509Request').mockResolvedValue(new Response(null, { status: 503 })); + + await expect( + new OpenAI(options()).buildRequest({ path: '/responses', method: 'post', body }), + ).rejects.toMatchObject({ status: 503 }); + await vi.waitFor(() => expect(finalized).toBe(true), { timeout: 200 }); + }); + + test('transfers a directly built SDK-owned upload to its caller without cancellation', async () => { + let finalized = false; + const body = { + async *[Symbol.asyncIterator]() { + try { + yield new TextEncoder().encode('synthetic-direct-approved-upload'); + } finally { + finalized = true; + } + }, + }; + vi.spyOn(transportCapability, 'sendX509Request').mockResolvedValue(Response.json(tokenResponse)); + + const built = await new OpenAI(options()).buildRequest({ path: '/responses', method: 'post', body }); + expect(finalized).toBe(false); + if (!(built.req.body instanceof ReadableStream)) { + throw new Error('Expected the SDK-owned upload stream.'); + } + await built.req.body.cancel(); + expect(finalized).toBe(true); + }); + + test('preserves protected-hook cancellation into the next certificate exchange', async () => { + const hook = new AbortController(); + const reason = new Error('synthetic-retried-issuer-hook-cancellation'); + let issuerAttempts = 0; + let apiAttempts = 0; + let retriedIssuerMinted = false; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url, request) => { + if (url.origin === 'https://mtls.auth.openai.com') { + issuerAttempts += 1; + if (issuerAttempts > 1) { + await delay(500, undefined, { signal: request.signal ?? undefined }); + retriedIssuerMinted = true; + } + return Response.json(tokenResponse); + } + apiAttempts += 1; + return new Response(null, { status: 401 }); + }); + const client = new OpenAI(options({ timeout: 2000, maxRetries: 1 })); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + request.signal = hook.signal; + }, + }); + + const pending = client.models.list(); + await vi.waitFor(() => expect(issuerAttempts).toBe(2)); + const canceledAt = performance.now(); + hook.abort(reason); + + await expect(pending).rejects.toMatchObject({ constructor: APIUserAbortError, cause: reason }); + expect(performance.now() - canceledAt).toBeLessThan(250); + expect(retriedIssuerMinted).toBe(false); + expect(apiAttempts).toBe(1); + expect(send).toHaveBeenCalledTimes(3); + }); + test('validates the final overridden destination before presenting a certificate', async () => { const send = vi .spyOn(transportCapability, 'sendX509Request') From d880268674cb44bebd5d3ed55aa23cf7288cb9aa Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 02:35:48 +0000 Subject: [PATCH 15/18] fix x509 terminal response cleanup and legacy stream declarations --- src/client.ts | 8 ++++--- src/internal/auth/x509-transport-registry.ts | 1 + .../x509-workload-request-boundaries.test.ts | 24 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/client.ts b/src/client.ts index 0c2ce04b36..d1cb0e11d6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1090,9 +1090,6 @@ export class OpenAI { try { const remaining = authentication.remainingTimeout(options, timeout); const expiration = authentication.waitForRetry(remaining, deadline.signal).then(() => { - timedOut = true; - controller.abort(); - void Shims.CancelReadableStream(response.body).catch(() => undefined); throw new Errors.APIConnectionTimeoutError(); }); const body = await Promise.race([ @@ -1104,6 +1101,11 @@ export class OpenAI { } return body; } catch (error) { + if (error instanceof Errors.APIConnectionTimeoutError) { + timedOut = true; + controller.abort(); + void Shims.CancelReadableStream(response.body).catch(() => undefined); + } if (callerSignal.aborted && !timedOut) { throw this._makeUserAbortError(callerSignal); } diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index 74fbec37da..622ca7bce8 100644 --- a/src/internal/auth/x509-transport-registry.ts +++ b/src/internal/auth/x509-transport-registry.ts @@ -1,5 +1,6 @@ import { OpenAIError } from '../../core/error'; import type { NullableHeaders } from '../headers'; +import type { ReadableStream } from '../shim-types'; import type { MergedRequestInit } from '../types'; import { findRegisteredX509Transport } from '#x509-transport-state'; diff --git a/tests/lib/x509-workload-request-boundaries.test.ts b/tests/lib/x509-workload-request-boundaries.test.ts index 47f41597d5..41d9bac358 100644 --- a/tests/lib/x509-workload-request-boundaries.test.ts +++ b/tests/lib/x509-workload-request-boundaries.test.ts @@ -71,6 +71,30 @@ describe('X.509 request ownership boundaries', () => { expect(send).toHaveBeenCalledTimes(1); }); + test('retires an unread error response when its headers exhaust the request deadline', async () => { + let elapsed = 0; + let apiSignal: AbortSignal | undefined; + const canceled = vi.fn(); + vi.spyOn(performance, 'now').mockImplementation(() => elapsed); + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url, request) => { + if (url.origin === 'https://mtls.auth.openai.com') { + return Response.json(tokenResponse); + } + apiSignal = request.signal ?? undefined; + elapsed = 51; + return new Response(new ReadableStream({ cancel: canceled }), { status: 403 }); + }); + + await expect(new OpenAI(options({ timeout: 50 })).models.list()).rejects.toBeInstanceOf( + APIConnectionTimeoutError, + ); + expect(apiSignal?.aborted).toBe(true); + expect(canceled).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(2); + }); + test('retires an SDK-materialized one-shot iterator when certificate authentication fails', async () => { let finalized = false; const body = { From cde209810d60d74e0629456f85b94ae8b4bef1ae Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 02:38:45 +0000 Subject: [PATCH 16/18] fix x509 caller cancellation precedence at response deadline --- src/client.ts | 2 +- .../x509-workload-request-boundaries.test.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index d1cb0e11d6..53c8a44c02 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1102,7 +1102,7 @@ export class OpenAI { return body; } catch (error) { if (error instanceof Errors.APIConnectionTimeoutError) { - timedOut = true; + timedOut = !callerSignal.aborted; controller.abort(); void Shims.CancelReadableStream(response.body).catch(() => undefined); } diff --git a/tests/lib/x509-workload-request-boundaries.test.ts b/tests/lib/x509-workload-request-boundaries.test.ts index 41d9bac358..2abeae97fd 100644 --- a/tests/lib/x509-workload-request-boundaries.test.ts +++ b/tests/lib/x509-workload-request-boundaries.test.ts @@ -95,6 +95,30 @@ describe('X.509 request ownership boundaries', () => { expect(send).toHaveBeenCalledTimes(2); }); + test('preserves caller cancellation when error headers also exhaust the request deadline', async () => { + let elapsed = 0; + const caller = new AbortController(); + const reason = new Error('synthetic-error-headers-cancellation'); + const canceled = vi.fn(); + vi.spyOn(performance, 'now').mockImplementation(() => elapsed); + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, url) => { + if (url.origin === 'https://mtls.auth.openai.com') { + return Response.json(tokenResponse); + } + elapsed = 51; + caller.abort(reason); + return new Response(new ReadableStream({ cancel: canceled }), { status: 403 }); + }); + + await expect( + new OpenAI(options({ timeout: 50 })).models.list({ signal: caller.signal }), + ).rejects.toMatchObject({ + constructor: APIUserAbortError, + cause: reason, + }); + expect(canceled).toHaveBeenCalledTimes(1); + }); + test('retires an SDK-materialized one-shot iterator when certificate authentication fails', async () => { let finalized = false; const body = { From 414d9ed99aec85a1728d8d65fcc9cfc4de09b572 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 02:51:06 +0000 Subject: [PATCH 17/18] refactor x509 terminal response cancellation guard --- src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 53c8a44c02..cc72d82e0e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1096,7 +1096,7 @@ export class OpenAI { response.text().catch(() => 'X.509 workload identity API response body could not be read.'), expiration, ]); - if (callerSignal.aborted && !timedOut) { + if (callerSignal.aborted) { throw this._makeUserAbortError(callerSignal); } return body; From d7e6c96cc84ce94977245ba831613fd446dcf5da Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 02:57:23 +0000 Subject: [PATCH 18/18] fix x509 delayed cancellation and replaced upload ownership --- src/client.ts | 17 ++--- .../auth/x509-workload-identity-auth.ts | 11 ++- .../x509-workload-request-boundaries.test.ts | 68 +++++++++++++++++++ 3 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/client.ts b/src/client.ts index cc72d82e0e..54caea4e26 100644 --- a/src/client.ts +++ b/src/client.ts @@ -987,21 +987,22 @@ export class OpenAI { this._workloadIdentityAuth instanceof X509WorkloadIdentityAuth ? this._workloadIdentityAuth : undefined; + const callerSignal = x509Authentication ? props.controller.signal : props.options.signal; + const abortError = () => + x509Authentication && callerSignal + ? this._makeUserAbortError(callerSignal) + : new Errors.APIUserAbortError(); let remaining: number; try { remaining = x509Authentication?.remainingTimeout(props.options, timeout) ?? Math.max(0, props.startTime + timeout - Date.now()); } catch (error) { + const cancellation = callerSignal?.aborted ? abortError() : undefined; props.controller.abort(); void Shims.CancelReadableStream(props.response.body).catch(() => undefined); - throw error; + throw cancellation ?? error; } - const callerSignal = x509Authentication ? props.controller.signal : props.options.signal; - const abortError = () => - x509Authentication && callerSignal - ? this._makeUserAbortError(callerSignal) - : new Errors.APIUserAbortError(); let timer: ReturnType | undefined; let abortListener: (() => void) | undefined; let timedOut = false; @@ -1229,7 +1230,7 @@ export class OpenAI { : new AbortController(); const remainingTimeout = x509Authentication?.remainingTimeout(options, timeout) ?? timeout; const fetchWithAuth = x509Authentication ? OpenAI.prototype.fetchWithAuth : this.fetchWithAuth; - x509Authentication?.releaseRequestBody(); + x509Authentication?.releaseRequestBody(req.body); const response = await fetchWithAuth .call(this, url, req, remainingTimeout, controller, security) .catch(castToError); @@ -1636,7 +1637,7 @@ export class OpenAI { const authentication = this._workloadIdentityAuth; return await authentication.runRequest(async () => { const built = await OpenAI.prototype.buildRequest.call(this, inputOptions, { retryCount }); - authentication.releaseRequestBody(); + authentication.releaseRequestBody(built.req.body); return built; }); } diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index 1595300b0e..63955c86a2 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -335,9 +335,14 @@ export class X509WorkloadIdentityAuth { } } - /** Transfers an approved upload to the actual request transport without cancelling it. */ - releaseRequestBody(): void { - delete this.#scope().materializedBody; + /** Transfers the dispatched upload while retiring any SDK-owned body replaced by a hook. */ + releaseRequestBody(body: unknown): void { + const scope = this.#scope(); + if (scope.materializedBody === body) { + delete scope.materializedBody; + } else { + this.retireRequestBody(); + } } /** Retains caller-only cancellation separately from SDK-created deadline controllers. */ diff --git a/tests/lib/x509-workload-request-boundaries.test.ts b/tests/lib/x509-workload-request-boundaries.test.ts index 2abeae97fd..124d39edea 100644 --- a/tests/lib/x509-workload-request-boundaries.test.ts +++ b/tests/lib/x509-workload-request-boundaries.test.ts @@ -119,6 +119,41 @@ describe('X.509 request ownership boundaries', () => { expect(canceled).toHaveBeenCalledTimes(1); }); + test.each(['caller', 'protected hook'] as const)( + 'preserves %s cancellation when successful response parsing starts after its deadline', + async (source) => { + let elapsed = 0; + const caller = new AbortController(); + const reason = new Error(`synthetic-delayed-${source}-cancellation`); + const canceled = vi.fn(); + vi.spyOn(performance, 'now').mockImplementation(() => elapsed); + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(tokenResponse) + : new Response(new ReadableStream({ cancel: canceled }), { + headers: { 'content-type': 'application/json' }, + }), + ); + const client = new OpenAI(options({ timeout: 50 })); + if (source === 'protected hook') { + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + request.signal = caller.signal; + }, + }); + } + const request = + source === 'caller' ? client.models.list({ signal: caller.signal }) : client.models.list(); + + await request.asResponse(); + caller.abort(reason); + elapsed = 51; + + await expect(request).rejects.toMatchObject({ constructor: APIUserAbortError, cause: reason }); + expect(canceled).toHaveBeenCalledTimes(1); + }, + ); + test('retires an SDK-materialized one-shot iterator when certificate authentication fails', async () => { let finalized = false; const body = { @@ -142,6 +177,39 @@ describe('X.509 request ownership boundaries', () => { expect(send).toHaveBeenCalledTimes(1); }); + test('retires an SDK-owned upload when a protected request hook replaces its body', async () => { + let finalized = false; + const body = { + async *[Symbol.asyncIterator]() { + try { + yield new TextEncoder().encode('synthetic-replaced-private-upload'); + yield new TextEncoder().encode('synthetic-never-dispatched'); + } finally { + finalized = true; + } + }, + }; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url, request) => { + if (url.origin === 'https://mtls.auth.openai.com') { + return Response.json(tokenResponse); + } + expect(request.body).toBe('synthetic-protected-hook-replacement'); + return Response.json({ data: [] }); + }); + const client = new OpenAI(options()); + Object.defineProperty(client, 'prepareRequest', { + value: async (request: RequestInit) => { + request.body = 'synthetic-protected-hook-replacement'; + }, + }); + + await client.request({ path: '/responses', method: 'post', body }); + await vi.waitFor(() => expect(finalized).toBe(true), { timeout: 200 }); + expect(send).toHaveBeenCalledTimes(2); + }); + test('does not cancel a caller-owned request stream after certificate authentication fails', async () => { let canceled = false; const body = new ReadableStream({