From efde41142da30df2c2446913f5c35a0e316716ad Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 05:16:00 +0000 Subject: [PATCH 01/11] fix(auth): ship secure first-class X.509 workload credentials --- README.md | 35 +-- docs/authentication.md | 42 +-- examples/mtls/README.md | 6 +- examples/mtls/x509-workload-identity.mjs | 43 +-- package.json | 7 + scripts/test-packed-package.ts | 23 +- src/auth/index.ts | 1 + src/auth/types.ts | 12 +- src/auth/x509-transport.ts | 291 ++++++++++++++++- src/client.ts | 38 +-- src/internal/auth/x509-credential-options.ts | 105 +++++++ .../auth/x509-transport-capability.ts | 99 +++++- src/internal/auth/x509-transport-registry.ts | 9 + .../auth/x509-transport-state-browser.ts | 7 + src/internal/auth/x509-transport-state.cts | 7 + .../auth/x509-workload-identity-auth.ts | 39 ++- tests/auth/x509-transport-conformance.test.ts | 215 +++++++++++++ tests/auth/x509-transport.test.ts | 297 +++++++++++++++++- .../x509-workload-credential-config.test.ts | 115 +++++++ tests/lib/x509-workload-example.test.ts | 6 +- .../x509-workload-request-boundaries.test.ts | 190 ++++++++++- .../x509-workload-review-regressions.test.ts | 4 +- 22 files changed, 1460 insertions(+), 131 deletions(-) create mode 100644 src/internal/auth/x509-credential-options.ts create mode 100644 tests/lib/x509-workload-credential-config.test.ts diff --git a/README.md b/README.md index 6f749f6d1..beb7577e4 100644 --- a/README.md +++ b/README.md @@ -202,47 +202,32 @@ const client = new OpenAI({ ### X.509 client certificates -Applications enrolled for X.509 workload identity can authenticate using a caller-owned, static client certificate instead of a subject-token provider or API key. This Node.js-only integration requires the optional `undici` peer and currently supports only the global `https://mtls.api.openai.com/v1` API endpoint. +Applications enrolled for X.509 workload identity can authenticate using a certificate-backed credential instead of a subject-token provider or API key. This Node.js-only integration requires the optional `undici` peer and currently supports only the global `https://mtls.api.openai.com/v1` API endpoint. ```ts import OpenAI from 'openai'; -import { createX509Transport } from 'openai/auth/x509-transport'; -import { Agent } from 'undici'; +import { workloadIdentity } from 'openai/auth/x509-transport'; -const dispatcher = new Agent({ - connect: { - cert: process.env['OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM'], - key: process.env['OPENAI_X509_CLIENT_PRIVATE_KEY_PEM'], - }, +const credential = workloadIdentity.fromX509({ + certificateChain: process.env['OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM']!, + privateKey: process.env['OPENAI_X509_CLIENT_PRIVATE_KEY_PEM']!, + identityProviderId: process.env['OPENAI_X509_IDENTITY_PROVIDER_ID']!, + serviceAccountId: process.env['OPENAI_X509_SERVICE_ACCOUNT_ID']!, }); const client = new OpenAI({ - apiKey: null, - adminAPIKey: null, - baseURL: null, - organization: null, + credential, project: process.env['OPENAI_X509_PROJECT_ID'] ?? null, - workloadIdentity: { - type: 'x509', - identityProviderId: process.env['OPENAI_X509_IDENTITY_PROVIDER_ID']!, - serviceAccountId: process.env['OPENAI_X509_SERVICE_ACCOUNT_ID']!, - }, - x509Transport: createX509Transport({ - runtime: 'node', - dispatcher, - certificateIdentity: 'static', - proxy: 'direct', - }), }); try { console.log((await client.models.list()).data.length); } finally { - await dispatcher.close(); + await credential.close(); } ``` -The SDK caches short-lived credentials in memory, isolates certificate generations, bounds retries and cancellation, and never closes the caller-owned dispatcher. Configure proactive refresh with optional `workloadIdentity.refreshBufferMs`; it defaults to 1,200,000 milliseconds (20 minutes) and is capped at half of the actual token lifetime. For CONNECT proxies, encrypted private keys, live verification, and certificate rotation, see the [X.509 workload-identity example](./examples/mtls/README.md#x509-workload-identity-nodejs). +The SDK owns the credential's verified TLS transport, caches short-lived tokens in memory, isolates certificate generations, and bounds retries and cancellation. Set `refreshBufferSeconds` on the credential to configure proactive refresh; it defaults to 1,200 seconds (20 minutes) and is capped at half of the token's lifetime. For CONNECT proxies, encrypted private keys, live verification, and certificate rotation, see the [X.509 workload-identity example](./examples/mtls/README.md#x509-workload-identity-nodejs). ## Streaming responses diff --git a/docs/authentication.md b/docs/authentication.md index 3ed49097b..1b6126daa 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -100,53 +100,37 @@ console.log(response.output_text); ### X.509 client certificates -Enrolled Node.js applications can authenticate using a caller-owned, static -client certificate instead of a subject-token provider. Install the optional -Undici transport peer with `npm install openai "undici@^7"`, provide the full -PEM certificate chain and private key, and create an explicitly attested -transport: +Enrolled Node.js applications can authenticate using an SDK-owned certificate +credential instead of a subject-token provider. Install the optional Undici peer +with `npm install openai "undici@^7"` and provide the full PEM certificate chain, +matching private key, and enrolled account selectors: ```ts import OpenAI from 'openai'; -import { createX509Transport } from 'openai/auth/x509-transport'; -import { Agent } from 'undici'; +import { workloadIdentity } from 'openai/auth/x509-transport'; -const dispatcher = new Agent({ - connect: { - cert: process.env['OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM'], - key: process.env['OPENAI_X509_CLIENT_PRIVATE_KEY_PEM'], - }, +const credential = workloadIdentity.fromX509({ + certificateChain: process.env['OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM']!, + privateKey: process.env['OPENAI_X509_CLIENT_PRIVATE_KEY_PEM']!, + identityProviderId: process.env['OPENAI_X509_IDENTITY_PROVIDER_ID']!, + serviceAccountId: process.env['OPENAI_X509_SERVICE_ACCOUNT_ID']!, }); try { const client = new OpenAI({ - apiKey: null, - adminAPIKey: null, - baseURL: null, - organization: null, + credential, project: process.env['OPENAI_X509_PROJECT_ID'] ?? null, - workloadIdentity: { - type: 'x509', - identityProviderId: process.env['OPENAI_X509_IDENTITY_PROVIDER_ID']!, - serviceAccountId: process.env['OPENAI_X509_SERVICE_ACCOUNT_ID']!, - }, - x509Transport: createX509Transport({ - runtime: 'node', - dispatcher, - certificateIdentity: 'static', - proxy: 'direct', - }), }); console.log((await client.models.list()).data.length); } finally { - await dispatcher.close(); + await credential.close(); } ``` X.509 authentication supports only `https://mtls.api.openai.com/v1`. Azure, Bedrock, custom gateways, data-residency overrides, browsers, and WebSocket -transports are unsupported. The application owns and closes its dispatcher. +transports are unsupported. Call `credential.close()` when requests have drained. ### Kubernetes diff --git a/examples/mtls/README.md b/examples/mtls/README.md index f0a1982ce..0a7f4aecf 100644 --- a/examples/mtls/README.md +++ b/examples/mtls/README.md @@ -52,7 +52,7 @@ Because the SDK does not own the mTLS transport, applications can use runtime-na ## X.509 workload identity (Node.js) -X.509 workload identity is separate from API-key + HTTP mTLS: an enrolled client certificate authenticates a workload-identity token exchange, and the resulting short-lived bearer authenticates requests through the same caller-owned certificate transport. The resulting access token is an ordinary bearer credential, so protect it like any other secret; reusing the approved certificate transport does not cryptographically bind the token to that certificate. Only `https://mtls.api.openai.com/v1` is approved; EU, Azure, Bedrock, custom gateways, and data-residency overrides are not supported. +X.509 workload identity is separate from API-key + HTTP mTLS: an enrolled client certificate authenticates a workload-identity token exchange, and the resulting short-lived bearer authenticates requests through the same SDK-owned, verified certificate transport. Create the credential with `workloadIdentity.fromX509({ certificateChain, privateKey, identityProviderId, serviceAccountId })` and pass it to `new OpenAI({ credential })`. The resulting access token is an ordinary bearer credential, so protect it like any other secret; mTLS does not cryptographically bind the token to its certificate. Only `https://mtls.api.openai.com/v1` is approved; EU, Azure, Bedrock, custom gateways, and data-residency overrides are not supported. Install the SDK and its optional Node.js transport peer: @@ -77,7 +77,7 @@ node examples/mtls/x509-workload-identity.mjs Set `OPENAI_X509_CLIENT_KEY_PASSPHRASE` when the PEM private key is encrypted. Existing local fixtures can instead provide certificate and key paths through `OPENAI_MTLS_CERT_CHAIN` and `OPENAI_MTLS_KEY`, identity selectors through `OPENAI_IDENTITY_PROVIDER_ID` and `OPENAI_SERVICE_ACCOUNT_ID`, and an optional tenant through `OPENAI_X509_PROJECT_ID`. The example ignores ambient API keys, admin keys, base URLs, organizations, and ordinary API-key projects so only the selected X.509 identity and tenant determine the request. Keep private-key files readable only by their owner, use managed secret injection where available, and never log PEM contents, passphrases, issued bearer tokens, or proxy credentials. -Proxying is always explicit: set `OPENAI_X509_PROXY_MODE=http_connect` or `OPENAI_X509_PROXY_MODE=https_connect` together with a matching `HTTPS_PROXY` URL. The default `direct` mode ignores ambient proxy variables. The workload certificate is configured only for target TLS, never proxy TLS. The example closes its caller-owned dispatcher after the request. +Proxying is always explicit: set `OPENAI_X509_PROXY_MODE=http_connect` or `OPENAI_X509_PROXY_MODE=https_connect` together with a matching `HTTPS_PROXY` URL. The default `direct` mode ignores ambient proxy variables. The SDK requires verified target and proxy TLS, validates the proxy protocol, and configures the workload certificate only for target TLS. The example closes its SDK-owned credential after the request. From a repository checkout, the following command builds the SDK first and then runs the same explicit live-service check: @@ -85,4 +85,4 @@ From a repository checkout, the following command builds the SDK first and then pnpm test:live:x509 ``` -This check fails without owner-provisioned, enrolled credentials. Rotate a certificate by constructing a new Undici dispatcher and `createX509Transport` capability, then creating or cloning the client with that new top-level `x509Transport`; close the previous dispatcher after in-flight requests finish. +This check fails without owner-provisioned, enrolled credentials. Rotate a certificate by constructing a new `workloadIdentity.fromX509` credential and creating or cloning the client with it; close the previous credential after in-flight requests finish. diff --git a/examples/mtls/x509-workload-identity.mjs b/examples/mtls/x509-workload-identity.mjs index c21754980..e7bbc90e3 100644 --- a/examples/mtls/x509-workload-identity.mjs +++ b/examples/mtls/x509-workload-identity.mjs @@ -2,12 +2,10 @@ // Uses one caller-owned static client certificate for both OpenAI's issuer and API. import { readFile } from 'node:fs/promises'; -import { Agent, ProxyAgent } from 'undici'; const cert = await requiredPem('OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM', 'OPENAI_MTLS_CERT_CHAIN'); const key = await requiredPem('OPENAI_X509_CLIENT_PRIVATE_KEY_PEM', 'OPENAI_MTLS_KEY'); const passphrase = process.env['OPENAI_X509_CLIENT_KEY_PASSPHRASE']; -const requestTls = { cert, key, ...(passphrase === undefined ? {} : { passphrase }) }; const proxyMode = process.env['OPENAI_X509_PROXY_MODE'] ?? 'direct'; const proxy = new Map([ ['direct', 'direct'], @@ -23,36 +21,28 @@ if (proxyURL && proxyURL.protocol !== (proxy === 'https-connect' ? 'https:' : 'h } const identityProviderId = requiredEnv('OPENAI_X509_IDENTITY_PROVIDER_ID', 'OPENAI_IDENTITY_PROVIDER_ID'); const serviceAccountId = requiredEnv('OPENAI_X509_SERVICE_ACCOUNT_ID', 'OPENAI_SERVICE_ACCOUNT_ID'); -const [{ default: OpenAI }, { createX509Transport }] = await Promise.all([ +const [{ default: OpenAI }, { workloadIdentity }] = await Promise.all([ import('openai'), import('openai/auth/x509-transport'), ]); -const dispatcher = createDispatcher(proxyURL, requestTls); +const credential = workloadIdentity.fromX509({ + certificateChain: cert, + privateKey: key, + identityProviderId, + serviceAccountId, + ...(passphrase === undefined ? {} : { passphrase }), + ...(proxyURL ? { proxy: { url: proxyURL, mode: proxy } } : {}), +}); try { - const x509Transport = createX509Transport({ - runtime: 'node', - dispatcher, - certificateIdentity: 'static', - proxy, - }); const client = new OpenAI({ - apiKey: null, - adminAPIKey: null, - baseURL: null, - organization: null, + credential, project: process.env['OPENAI_X509_PROJECT_ID'] ?? null, - workloadIdentity: { - type: 'x509', - identityProviderId, - serviceAccountId, - }, - x509Transport, }); const models = await client.models.list(); console.log(`X.509 workload identity succeeded; received ${models.data.length} models.`); } finally { - await dispatcher.close(); + await credential.close(); } function requiredEnv(name, alternative) { @@ -89,14 +79,3 @@ function approvedProxyURL(value) { } return url; } - -function createDispatcher(approvedURL, targetTls) { - if (!approvedURL) { - return new Agent({ connect: targetTls }); - } - try { - return new ProxyAgent({ uri: approvedURL.href, requestTls: targetTls }); - } catch { - throw new Error('Unable to initialize the approved X.509 CONNECT proxy.'); - } -} diff --git a/package.json b/package.json index afd8d6c9f..daf95904d 100644 --- a/package.json +++ b/package.json @@ -87,12 +87,19 @@ "import": "./dist/index.mjs", "require": "./dist/index.js" }, + "./auth": { + "import": "./dist/auth/index.mjs", + "require": "./dist/auth/index.js" + }, "./internal/auth/x509-transport-capability": null, "./internal/auth/x509-transport-capability.js": null, "./internal/auth/x509-transport-capability.mjs": null, "./internal/auth/x509-transport-registry": null, "./internal/auth/x509-transport-registry.js": null, "./internal/auth/x509-transport-registry.mjs": null, + "./internal/auth/x509-credential-options": null, + "./internal/auth/x509-credential-options.js": null, + "./internal/auth/x509-credential-options.mjs": null, "./internal/auth/x509-transport-state": null, "./internal/auth/x509-transport-state.cjs": null, "./internal/auth/x509-transport-state-browser": null, diff --git a/scripts/test-packed-package.ts b/scripts/test-packed-package.ts index 6561ea901..227352cbc 100644 --- a/scripts/test-packed-package.ts +++ b/scripts/test-packed-package.ts @@ -217,6 +217,20 @@ const packedPackagePath = require('node:path'); tarball, ]); + const optionalUndici = path.join(temporaryDirectory, 'node_modules/undici'); + assert(!fs.existsSync(optionalUndici), 'Public authentication helpers must not require optional Undici'); + for (const [inputType, authenticationImport] of [ + ['commonjs', "const auth = require('openai/auth');"], + ['module', "import * as auth from 'openai/auth';"], + ] as const) { + run(process.execPath, [ + `--input-type=${inputType}`, + '--eval', + `${authenticationImport} if (typeof auth.k8sServiceAccountTokenProvider !== 'function') throw new Error('Public authentication exports are unavailable');`, + ]); + } + assert(!fs.existsSync(optionalUndici), 'Importing public authentication helpers must not install Undici'); + const privateX509Modules = [ 'openai/internal/auth/x509-transport-capability', 'openai/internal/auth/x509-transport-capability.js', @@ -224,6 +238,9 @@ const packedPackagePath = require('node:path'); 'openai/internal/auth/x509-transport-registry', 'openai/internal/auth/x509-transport-registry.js', 'openai/internal/auth/x509-transport-registry.mjs', + 'openai/internal/auth/x509-credential-options', + 'openai/internal/auth/x509-credential-options.js', + 'openai/internal/auth/x509-credential-options.mjs', 'openai/internal/auth/x509-transport-state', 'openai/internal/auth/x509-transport-state.cjs', 'openai/internal/auth/x509-transport-state-browser', @@ -359,6 +376,11 @@ const packedPackagePath = require('node:path'); 'const dispatcher = new Agent();', "const proxyDispatcher = new ProxyAgent({ uri: 'http://127.0.0.1:1' });", "const secureProxyDispatcher = new ProxyAgent({ uri: 'https://127.0.0.1:1' });", + ...(undiciVersion === '5.5.1' + ? [ + "for (const proxy of [proxyDispatcher, secureProxyDispatcher]) { const state = Object.getOwnPropertySymbols(proxy).find((symbol) => symbol.description === 'proxy agent options'); assert(state); proxy[state] = new URL(proxy[state].uri); }", + ] + : []), "const direct = () => createX509Transport({ runtime: 'node', dispatcher, certificateIdentity: 'static', proxy: 'direct' });", "const httpConnect = () => createX509Transport({ runtime: 'node', dispatcher: proxyDispatcher, certificateIdentity: 'static', proxy: 'http-connect' });", "const httpsConnect = () => createX509Transport({ runtime: 'node', dispatcher: secureProxyDispatcher, certificateIdentity: 'static', proxy: 'https-connect' });", @@ -489,7 +511,6 @@ const packedPackagePath = require('node:path'); ); assert.equal(installedPackage.peerDependencies?.['undici'], '>=5 <9'); assert.equal(installedPackage.peerDependenciesMeta?.['undici']?.optional, true); - const optionalUndici = path.join(temporaryDirectory, 'node_modules/undici'); assert(!fs.existsSync(optionalUndici), 'Undici must remain optional for ordinary SDK consumers'); run(process.execPath, [ '--conditions=browser', diff --git a/src/auth/index.ts b/src/auth/index.ts index 06981c334..40695428a 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,6 +1,7 @@ export type { WorkloadIdentity, X509WorkloadIdentity, + X509Credential, SubjectTokenProvider, TokenExchangeResponse, } from './types'; diff --git a/src/auth/types.ts b/src/auth/types.ts index 2f915b08e..3bd7842b5 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -36,7 +36,10 @@ 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. */ + /** Seconds before expiration when access-token refresh begins; defaults to 1,200 seconds. */ + refreshBufferSeconds?: number; + + /** @deprecated Use refreshBufferSeconds to match other workload-identity credentials. */ refreshBufferMs?: number; /** X.509 federation proves certificate possession instead of supplying a subject token. */ @@ -44,9 +47,12 @@ export interface X509WorkloadIdentity { /** X.509 federation does not send an OAuth client identifier. */ clientId?: never; +} - /** X.509 federation does not accept subject-token refresh configuration. */ - refreshBufferSeconds?: never; +/** An SDK-owned certificate credential created by the Node-only X.509 authentication helper. */ +export interface X509Credential { + /** Closes the credential's owned certificate transport after requests have finished. */ + close: () => Promise; } /** OAuth token-exchange response returned by the OpenAI workload-identity endpoint. */ diff --git a/src/auth/x509-transport.ts b/src/auth/x509-transport.ts index 4dd40c47d..51eafd79e 100644 --- a/src/auth/x509-transport.ts +++ b/src/auth/x509-transport.ts @@ -1,15 +1,262 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import { createPrivateKey, X509Certificate } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; +import { types } from 'node:util'; +import { Agent, ProxyAgent } from 'undici'; import { createX509Transport as createCapability, registerX509Transport, sendX509Request, } from '../internal/auth/x509-transport-capability'; -import type { X509Transport, X509TransportOptions } from '../internal/auth/x509-transport-capability'; +import type { + X509ProxyMode, + 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'; +import { markTransientX509ConnectionError, rememberX509Credential } from '#x509-transport-state'; +import type { X509Credential, X509WorkloadIdentity } from './types'; + +/** Explicit, separately trusted CONNECT configuration for an SDK-owned X.509 credential. */ +export interface X509CredentialProxyOptions { + /** CONNECT proxy endpoint; its protocol must match the selected mode. */ + url: string | URL; + + /** Whether the connection to the CONNECT proxy itself is encrypted. */ + mode: Exclude; + + /** Optional private trust roots for the HTTPS proxy; never used for workload TLS. */ + ca?: string | string[] | undefined; +} + +/** Private certificate material and enrolled selectors for one SDK-owned workload credential. */ +export interface X509CredentialOptions { + /** Leaf client certificate followed by its required PEM intermediate chain. */ + certificateChain: string; + + /** PEM private key matching the leaf client certificate. */ + privateKey: string; + + /** Existing OpenAI identity-provider resource enrolled for the certificate. */ + identityProviderId: string; + + /** OpenAI service account authorized for the verified certificate identity. */ + serviceAccountId: string; + + /** Optional private certificate authorities trusted for OpenAI's issuer and API. */ + ca?: string | string[] | undefined; + + /** Optional passphrase used to decrypt an encrypted PEM private key. */ + passphrase?: string | undefined; + + /** Optional CONNECT proxy with separately scoped target and proxy TLS settings. */ + proxy?: X509CredentialProxyOptions | undefined; + + /** Seconds before expiration when access-token refresh begins; defaults to 1,200. */ + refreshBufferSeconds?: number | undefined; +} + +const credentialOptionNames = new Set([ + 'certificateChain', + 'privateKey', + 'identityProviderId', + 'serviceAccountId', + 'ca', + 'passphrase', + 'proxy', + 'refreshBufferSeconds', +]); +const proxyOptionNames = new Set(['url', 'mode', 'ca']); + +function safeOptionRecord( + value: unknown, + allowed: ReadonlySet, + label: string, +): Record { + if (!value || typeof value !== 'object' || types.isProxy(value)) { + throw new Error(`X.509 ${label} options must be a non-proxy object.`); + } + const prototype: unknown = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`X.509 ${label} options must have only own plain data properties.`); + } + const snapshot = Object.create(null) as Record; + for (const name of Reflect.ownKeys(value)) { + if (typeof name !== 'string' || !allowed.has(name)) { + throw new Error(`Unsupported X.509 ${label} option: \`${String(name)}\`.`); + } + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor || !('value' in descriptor)) { + throw new Error(`X.509 ${label} option \`${name}\` must be a plain data property.`); + } + snapshot[name] = descriptor.value; + } + return snapshot; +} + +function requiredCredentialValue(options: Record, name: string): string { + const value = options[name]; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`X.509 credential requires a nonempty own \`${name}\` value.`); + } + return value; +} + +function snapshotCertificateAuthorities(value: unknown): string | string[] | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value === 'string') { + if (value.trim().length === 0) { + throw new Error('X.509 certificate authorities must contain nonempty PEM values.'); + } + return value; + } + if (!Array.isArray(value) || types.isProxy(value) || value.length === 0) { + throw new Error('X.509 certificate authorities must be a PEM string or plain PEM string array.'); + } + const authorities: string[] = []; + for (let index = 0; index < value.length; index += 1) { + const entry = Object.getOwnPropertyDescriptor(value, String(index)); + if (!entry || !('value' in entry) || typeof entry.value !== 'string' || entry.value.trim().length === 0) { + throw new Error('X.509 certificate authorities require own plain nonempty PEM strings.'); + } + authorities.push(entry.value); + } + return authorities; +} + +class OwnedX509Credential implements X509Credential { + readonly #dispatcher: Agent | ProxyAgent; + #closing: Promise | undefined; + + constructor(dispatcher: Agent | ProxyAgent) { + this.#dispatcher = dispatcher; + Object.freeze(this); + } + + /** Closes this credential's owned transport once all in-flight requests have drained. */ + close(): Promise { + this.#closing ??= this.#dispatcher.close(); + return this.#closing; + } +} + +interface ValidatedX509CredentialOptions { + certificateChain: string; + privateKey: string; + identityProviderId: string; + serviceAccountId: string; + refreshBufferSeconds: number | undefined; + passphrase: string | undefined; + ca: string | string[] | undefined; + proxy: unknown; +} + +function validatedCredentialOptions(options: X509CredentialOptions): ValidatedX509CredentialOptions { + const configured = safeOptionRecord(options, credentialOptionNames, 'credential'); + const certificateChain = requiredCredentialValue(configured, 'certificateChain'); + const privateKeyPEM = requiredCredentialValue(configured, 'privateKey'); + const identityProviderId = requiredCredentialValue(configured, 'identityProviderId'); + const serviceAccountId = requiredCredentialValue(configured, 'serviceAccountId'); + const { refreshBufferSeconds, passphrase } = configured; + if (passphrase !== undefined && typeof passphrase !== 'string') { + throw new Error('X.509 credential requires a string private-key passphrase.'); + } + if ( + refreshBufferSeconds !== undefined && + (typeof refreshBufferSeconds !== 'number' || + !Number.isSafeInteger(refreshBufferSeconds) || + refreshBufferSeconds < 0 || + !Number.isSafeInteger(refreshBufferSeconds * 1000)) + ) { + throw new Error('X.509 credential requires a nonnegative integer refreshBufferSeconds.'); + } + const ca = snapshotCertificateAuthorities(configured['ca']); + + const leaf = new X509Certificate(certificateChain); + const privateKey = createPrivateKey({ + key: privateKeyPEM, + ...(passphrase === undefined ? {} : { passphrase }), + }); + if (!leaf.checkPrivateKey(privateKey)) { + throw new Error('X.509 credential private key must match its leaf client certificate.'); + } + + return { + certificateChain, + privateKey: privateKeyPEM, + identityProviderId, + serviceAccountId, + refreshBufferSeconds, + passphrase, + ca, + proxy: configured['proxy'], + }; +} + +interface VerifiedX509TLSOptions { + cert: string; + key: string; + rejectUnauthorized: true; + passphrase?: string; + ca?: string | string[]; +} + +function credentialDispatcher( + proxyOptionsInput: unknown, + requestTls: VerifiedX509TLSOptions, +): { dispatcher: Agent | ProxyAgent; proxy: X509ProxyMode } { + if (proxyOptionsInput === undefined) { + return { dispatcher: new Agent({ connect: requestTls }), proxy: 'direct' }; + } + + const proxyOptions = safeOptionRecord(proxyOptionsInput, proxyOptionNames, 'proxy'); + const proxyURL = proxyOptions['url']; + if (typeof proxyURL !== 'string' && !(proxyURL instanceof URL)) { + throw new Error('X.509 CONNECT proxy requires an own URL string or URL value.'); + } + let url: URL; + try { + url = new URL(typeof proxyURL === 'string' ? proxyURL : URL.prototype.toString.call(proxyURL)); + } catch { + throw new Error('X.509 CONNECT proxy requires a valid proxy URL.'); + } + const selected = proxyOptions['mode']; + const proxy: X509ProxyMode = + selected === 'http-connect' || selected === 'https-connect' ? selected : 'direct'; + if ( + (proxy !== 'http-connect' && proxy !== 'https-connect') || + url.protocol !== (proxy === 'https-connect' ? 'https:' : 'http:') || + url.pathname !== '/' || + url.search || + url.hash + ) { + throw new Error('X.509 CONNECT proxy URL protocol must match its selected secure proxy mode.'); + } + const proxyCA = snapshotCertificateAuthorities(proxyOptions['ca']); + if (proxy === 'http-connect' && proxyCA !== undefined) { + throw new Error('A plaintext X.509 CONNECT proxy cannot configure proxy TLS authorities.'); + } + + return { + proxy, + dispatcher: new ProxyAgent({ + uri: url.href, + requestTls, + ...(proxy === 'https-connect' + ? { + proxyTls: { + rejectUnauthorized: true, + ...(proxyCA === undefined ? {} : { ca: proxyCA }), + }, + } + : {}), + }), + }; +} /** Creates one frozen, caller-attested Node.js transport for X.509 workload authentication. */ export function createX509Transport(options: X509TransportOptions): X509Transport { @@ -42,6 +289,46 @@ export function createX509Transport(options: X509TransportOptions): X509Transpor return capability; } +/** Creates a first-class certificate credential with SDK-owned, verified TLS and CONNECT policy. */ +export function fromX509(options: X509CredentialOptions): X509Credential { + const configured = validatedCredentialOptions(options); + + const requestTls = { + cert: configured.certificateChain, + key: configured.privateKey, + rejectUnauthorized: true as const, + ...(configured.passphrase === undefined ? {} : { passphrase: configured.passphrase }), + ...(configured.ca === undefined ? {} : { ca: configured.ca }), + }; + const { dispatcher, proxy } = credentialDispatcher(configured.proxy, requestTls); + + try { + const transport = createX509Transport({ + runtime: 'node', + dispatcher, + certificateIdentity: 'static', + proxy, + }); + const identity: X509WorkloadIdentity = Object.freeze({ + type: 'x509', + identityProviderId: configured.identityProviderId, + serviceAccountId: configured.serviceAccountId, + ...(configured.refreshBufferSeconds === undefined + ? {} + : { refreshBufferSeconds: configured.refreshBufferSeconds }), + }); + const credential = new OwnedX509Credential(dispatcher); + rememberX509Credential(credential, Object.freeze({ identity, transport })); + return credential; + } catch (error) { + void dispatcher.close(); + throw error; + } +} + +/** Namespaced first-class credential factory, isolated from ordinary browser-safe auth imports. */ +export const workloadIdentity = Object.freeze({ fromX509 }); + export type { X509ProxyMode, X509Transport, diff --git a/src/client.ts b/src/client.ts index 292c37d16..cdcee9f32 100644 --- a/src/client.ts +++ b/src/client.ts @@ -17,7 +17,7 @@ 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, X509WorkloadIdentity } from './auth/types'; +import type { WorkloadIdentity, X509Credential, X509WorkloadIdentity } from './auth/types'; import { WorkloadIdentityAuth } from './auth/workload-identity-auth'; import { X509_API_BASE_URL, @@ -28,6 +28,10 @@ import { snapshotX509RequestOptions, } from './internal/auth/x509-workload-identity-auth'; import type { X509Transport } from './internal/auth/x509-transport-registry'; +import { + normalizeX509CredentialOptions, + prepareX509ClientClone, +} from './internal/auth/x509-credential-options'; import { isTransientX509ConnectionError, markApprovedX509Client } from '#x509-transport-state'; import { OAuthError, SubjectTokenProviderError } from './core/error'; import { @@ -430,6 +434,9 @@ export interface ClientOptions { /** Approved, frozen Node.js certificate transport required only for X.509 workload identity. */ x509Transport?: X509Transport | undefined; + /** First-class certificate credential created with `fromX509` from `openai/auth/x509-transport`. */ + credential?: X509Credential | undefined; + /** * Configure this client to use a third-party API provider. * Mutually exclusive with top-level authentication and `baseURL` options. @@ -457,6 +464,7 @@ export class OpenAI { private fetch: Fetch; #encoder: Opts.RequestEncoder; #x509Authentication: X509WorkloadIdentityAuth | undefined; + #x509Credential: X509Credential | undefined; #x509Fetch: Fetch | undefined; // Preserve an explicit global selection without storing a second routing URL. #explicitDataResidency = false; @@ -495,6 +503,8 @@ export class OpenAI { * @param {boolean} [opts.dangerouslyAllowBrowser=false] - By default, client-side use of this library is not allowed, as it risks exposing your secret API credentials to attackers. */ constructor(clientOptions: ClientOptions = {}) { + const { credential, options: normalizedOptions } = normalizeX509CredentialOptions(clientOptions); + clientOptions = normalizedOptions; const residencyBaseURL = resolveDataResidency(clientOptions); const provider = clientOptions.provider; const { @@ -508,6 +518,7 @@ export class OpenAI { webhookSecret = readEnv('OPENAI_WEBHOOK_SECRET') ?? null, workloadIdentity, x509Transport, + credential: _credential, ...opts } = clientOptions as InternalClientOptions; if (provider) { @@ -601,7 +612,7 @@ export class OpenAI { this.fetch = options.fetch ?? Shims.getDefaultFetch(); this.#encoder = Opts.FallbackEncoder; - const customHeadersEnv = provider ? undefined : readEnv('OPENAI_CUSTOM_HEADERS'); + const customHeadersEnv = provider || credential ? undefined : readEnv('OPENAI_CUSTOM_HEADERS'); if (customHeadersEnv) { const parsed: Record = {}; for (const line of customHeadersEnv.split('\n')) { @@ -620,6 +631,7 @@ export class OpenAI { const authentication = new X509WorkloadIdentityAuth(x509Identity, x509Transport, organization, project); this._workloadIdentityAuth = authentication; this.#x509Authentication = authentication; + this.#x509Credential = credential; this.#x509Fetch = authentication.fetch(); this.fetch = this.#x509Fetch; markApprovedX509Client(this); @@ -640,7 +652,7 @@ export class OpenAI { withOptions(options: Partial): this { const residencyBaseURL = resolveDataResidency(options); const inheritedProvider = this._options.provider; - const provider = options.provider ?? inheritedProvider; + const provider = options.provider ?? (options.credential === undefined ? inheritedProvider : undefined); const x509Authentication = this.#x509Authentication; const inheritedOptions: ClientOptions = { ...this._options, @@ -659,26 +671,14 @@ export class OpenAI { project: this.project, webhookSecret: this.webhookSecret, }; - const currentlyX509 = x509Authentication !== undefined; - 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; - } - } + prepareX509ClientClone(inheritedOptions, options, this.#x509Credential, x509Authentication !== undefined); if (residencyBaseURL !== undefined) { delete inheritedOptions.baseURL; } if (provider) { delete inheritedOptions.apiKey; delete inheritedOptions.adminAPIKey; + delete inheritedOptions.credential; delete inheritedOptions.workloadIdentity; delete inheritedOptions.x509Transport; delete inheritedOptions.baseURL; @@ -697,6 +697,7 @@ export class OpenAI { this.#explicitDataResidency && residencyBaseURL === undefined && !hasOwn(options, 'baseURL') && + options.credential === undefined && !provider, }; const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)(clientOptions); @@ -1151,9 +1152,10 @@ export class OpenAI { retriesRemaining = maxRetries; } + const x509Authentication = this.#x509Authentication; + x509Authentication?.beginRequestPreparation(); await this.prepareOptions(options); - const x509Authentication = this.#x509Authentication; x509Authentication?.beginRequestPlanning(); let built: { req: FinalizedRequestInit; url: string; timeout: number }; try { diff --git a/src/internal/auth/x509-credential-options.ts b/src/internal/auth/x509-credential-options.ts new file mode 100644 index 000000000..b6a5de50b --- /dev/null +++ b/src/internal/auth/x509-credential-options.ts @@ -0,0 +1,105 @@ +import { OpenAIError } from '../../core/error'; +import type { ClientOptions } from '../../client'; +import type { X509Credential } from '../../auth/types'; +import { hasOwn } from '../utils/values'; +import { isX509WorkloadIdentity } from './x509-workload-identity-auth'; +import type { RegisteredX509Credential } from './x509-transport-registry'; +import { findX509Credential } from '#x509-transport-state'; + +/** Validates one privately registered credential and suppresses ambient legacy authentication. */ +export function normalizeX509CredentialOptions(options: ClientOptions): { + credential: X509Credential | undefined; + options: ClientOptions; +} { + const { credential } = options; + if (credential === undefined) { + return { credential, options }; + } + + const registered: RegisteredX509Credential | undefined = findX509Credential(credential); + if (!registered) { + throw new OpenAIError('An X.509 credential must be created by the SDK authentication helper.'); + } + const conflicting = (['apiKey', 'adminAPIKey', 'workloadIdentity', 'x509Transport'] as const).filter( + (name) => { + const value = options[name]; + return value !== null && value !== undefined; + }, + ); + if (conflicting.length > 0) { + throw new OpenAIError( + `The \`credential\` option cannot be combined with ${conflicting.map((name) => `\`${name}\``).join(', ')}.`, + ); + } + + return { + credential, + options: { + ...options, + apiKey: null, + adminAPIKey: null, + baseURL: options.baseURL ?? null, + organization: options.organization ?? null, + project: options.project ?? null, + workloadIdentity: registered.identity, + x509Transport: registered.transport, + }, + }; +} + +/** Preserves credential ownership while isolating transitions between API keys, providers, and X.509. */ +export function prepareX509ClientClone( + inherited: ClientOptions, + overrides: Partial, + credential: X509Credential | undefined, + currentlyX509: boolean, +): void { + const nextIdentity = hasOwn(overrides, 'workloadIdentity') + ? overrides.workloadIdentity + : inherited.workloadIdentity; + const overridingApiKey = overrides.apiKey; + const dropping = + credential !== undefined && + ((overridingApiKey !== null && + overridingApiKey !== undefined && + overrides.workloadIdentity === undefined) || + overrides.provider !== undefined); + const inheritedCredential = + credential !== undefined && + !dropping && + !hasOwn(overrides, 'credential') && + !hasOwn(overrides, 'workloadIdentity') && + !hasOwn(overrides, 'x509Transport') + ? credential + : undefined; + const nextCredential = overrides.credential ?? inheritedCredential; + const nextX509 = nextCredential !== undefined || (!dropping && isX509WorkloadIdentity(nextIdentity)); + + if (currentlyX509 !== nextX509) { + delete inherited.fetch; + delete inherited.baseURL; + if (nextX509) { + inherited.apiKey = null; + } else { + delete inherited.x509Transport; + if (dropping) { + delete inherited.workloadIdentity; + } + } + } + + if (nextCredential === undefined) { + return; + } + delete inherited.apiKey; + delete inherited.adminAPIKey; + delete inherited.workloadIdentity; + delete inherited.x509Transport; + inherited.credential = nextCredential; + if (overrides.credential !== undefined) { + delete inherited.organization; + delete inherited.project; + delete inherited.defaultHeaders; + delete inherited.fetchOptions; + } +} diff --git a/src/internal/auth/x509-transport-capability.ts b/src/internal/auth/x509-transport-capability.ts index f168498b4..2b7aee8f9 100644 --- a/src/internal/auth/x509-transport-capability.ts +++ b/src/internal/auth/x509-transport-capability.ts @@ -27,20 +27,27 @@ export interface X509TransportOptions { const allowedOptionNames = new Set(['runtime', 'dispatcher', 'certificateIdentity', 'proxy']); const transportBrand: typeof x509TransportBrand = x509TransportBrand; +let originalAgentFactory: unknown; class NodeX509Transport implements X509Transport { declare readonly [transportBrand]: true; readonly #dispatcher: Agent | ProxyAgent; + readonly #proxy: X509ProxyMode; - constructor(dispatcher: Agent | ProxyAgent) { + constructor(dispatcher: Agent | ProxyAgent, proxy: X509ProxyMode) { this.#dispatcher = dispatcher; + this.#proxy = proxy; Object.freeze(this); } static dispatcher(value: object): Agent | ProxyAgent | undefined { return #dispatcher in value ? value.#dispatcher : undefined; } + + static proxy(value: object): X509ProxyMode | undefined { + return #proxy in value ? value.#proxy : undefined; + } } /** Registers only a genuine frozen capability whose JavaScript private dispatcher cannot be forged. */ @@ -80,6 +87,74 @@ function assertNodeRuntime(): void { } } +function undiciState(dispatcher: Agent | ProxyAgent, name: string): unknown { + const symbol = Object.getOwnPropertySymbols(dispatcher).find((candidate) => candidate.description === name); + return symbol ? Object.getOwnPropertyDescriptor(dispatcher, symbol)?.value : undefined; +} + +function assertVerifiedTLS(value: unknown): void { + if (value === undefined || value === null) { + if (process.env['NODE_TLS_REJECT_UNAUTHORIZED'] === '0') { + throw new Error('X.509 transport requires explicit TLS server certificate verification.'); + } + return; + } + if (typeof value !== 'object' || types.isProxy(value)) { + throw new Error('X.509 transport requires inspectable TLS server-verification settings.'); + } + const verification = Object.getOwnPropertyDescriptor(value, 'rejectUnauthorized'); + if ( + (verification && (!('value' in verification) || verification.value === false)) || + (process.env['NODE_TLS_REJECT_UNAUTHORIZED'] === '0' && verification?.value !== true) + ) { + throw new Error('X.509 transport requires TLS server certificate verification.'); + } +} + +function assertDispatcherTrust(dispatcher: Agent | ProxyAgent, proxy: X509ProxyMode): void { + if (dispatcher instanceof ProxyAgent) { + const configuration = undiciState(dispatcher, 'proxy agent options'); + if (!configuration || typeof configuration !== 'object') { + throw new Error('X.509 transport requires inspectable CONNECT proxy configuration.'); + } + const uri: unknown = + configuration instanceof URL + ? URL.prototype.toString.call(configuration) + : Object.getOwnPropertyDescriptor(configuration, 'uri')?.value; + let protocol: string; + try { + if (typeof uri !== 'string' && !(uri instanceof URL)) { + throw new Error('Invalid proxy URI'); + } + ({ protocol } = new URL(typeof uri === 'string' ? uri : URL.prototype.toString.call(uri))); + } catch { + throw new Error('X.509 transport requires an approved CONNECT proxy endpoint.'); + } + if (protocol !== (proxy === 'https-connect' ? 'https:' : 'http:')) { + throw new Error('X.509 CONNECT proxy protocol must match its configured proxy mode.'); + } + assertVerifiedTLS(undiciState(dispatcher, 'request tls settings')); + if (proxy === 'https-connect') { + assertVerifiedTLS(undiciState(dispatcher, 'proxy tls settings')); + } + return; + } + + const configuration = undiciState(dispatcher, 'options'); + if (!configuration || typeof configuration !== 'object') { + throw new Error('X.509 transport requires inspectable certificate transport configuration.'); + } + if (originalAgentFactory === undefined) { + const baseline = new Agent(); + originalAgentFactory = undiciState(baseline, 'factory'); + void baseline.close(); + } + if (originalAgentFactory === undefined || undiciState(dispatcher, 'factory') !== originalAgentFactory) { + throw new Error('X.509 transport does not support a custom dispatcher factory.'); + } + assertVerifiedTLS(Object.getOwnPropertyDescriptor(configuration, 'connect')?.value); +} + function attestedDispatcher(options: X509TransportOptions): Agent | ProxyAgent { const dispatcher = dataOption(options, 'dispatcher'); if (!dispatcher || typeof dispatcher !== 'object') { @@ -103,6 +178,8 @@ function attestedDispatcher(options: X509TransportOptions): Agent | ProxyAgent { throw new Error('An X.509 CONNECT proxy requires an Undici ProxyAgent.'); } + assertDispatcherTrust(dispatcher, proxy); + return dispatcher; } @@ -147,13 +224,12 @@ function assertConnectProxySupport(): void { /** * Creates a frozen, opaque capability for one caller-owned Undici transport. * - * `certificateIdentity: 'static'` is an application attestation: the SDK does - * not inspect certificates, private dispatcher internals, callbacks, or TLS - * options and cannot cryptographically prove certificate selection. Configure - * one static identity without custom dispatcher factories. For HTTPS CONNECT, - * independently configure `proxyTls` and `requestTls` so workload credentials - * never reach the proxy. Rotation requires creating a fresh dispatcher and - * capability; the application remains responsible for draining the old one. + * Existing caller-owned dispatchers remain supported only when their effective + * TLS settings preserve server verification and their actual CONNECT protocol + * matches the declared mode. Prefer the SDK-owned `fromX509` credential, which + * constructs verified target and proxy TLS settings from explicit configuration. + * Rotation requires creating a fresh dispatcher and capability; the caller + * remains responsible for draining caller-owned dispatchers. * * This Node-only preview entrypoint requires the optional `undici` peer at * version 5.2.0 or later. CONNECT proxy modes require version 5.5.1 or @@ -185,7 +261,7 @@ export function createX509Transport(options: X509TransportOptions): X509Transpor if (dispatcher instanceof ProxyAgent) { assertConnectProxySupport(); } - return new NodeX509Transport(dispatcher); + return new NodeX509Transport(dispatcher, dataOption(options, 'proxy') as X509ProxyMode); } /** Dispatches through the opaque attested transport without accepting replacement dispatchers. */ @@ -202,6 +278,11 @@ export async function sendX509Request( if (!dispatcher) { throw new Error('Invalid X.509 transport capability.'); } + const proxy = NodeX509Transport.proxy(transport); + if (!proxy) { + throw new Error('Invalid X.509 transport capability.'); + } + assertDispatcherTrust(dispatcher, proxy); const normalizedTarget = new URL(target.href); if (normalizedTarget.protocol !== 'https:') { diff --git a/src/internal/auth/x509-transport-registry.ts b/src/internal/auth/x509-transport-registry.ts index d53925fa0..a3e607a30 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 { X509WorkloadIdentity } from '../../auth/types'; import type { ReadableStream } from '../shim-types'; import type { MergedRequestInit } from '../types'; import { findRegisteredX509Transport } from '#x509-transport-state'; @@ -49,6 +50,12 @@ export interface X509Transport { readonly [x509TransportBrand]: true; } +/** Immutable selectors and transport privately registered for an SDK-owned credential. */ +export interface RegisteredX509Credential { + readonly identity: X509WorkloadIdentity; + readonly transport: X509Transport; +} + /** Validated short-lived token exchanged using a registered certificate identity. */ export interface X509ExchangedToken { /** Header-safe, in-memory OpenAI bearer credential. */ @@ -63,6 +70,8 @@ export interface X509RequestScope { wallStartedAt: number; monotonicStartedAt: number; deadlineArmed?: boolean; + preparationStartedAt?: number; + preparationWallStartedAt?: number; request?: { signal: AbortSignal | null | undefined; timeout: number; fetchOptions: MergedRequestInit }; phase?: 'planning' | 'authorizing'; effectiveSignal?: AbortSignal; diff --git a/src/internal/auth/x509-transport-state-browser.ts b/src/internal/auth/x509-transport-state-browser.ts index 255145aec..c50d7c2dc 100644 --- a/src/internal/auth/x509-transport-state-browser.ts +++ b/src/internal/auth/x509-transport-state-browser.ts @@ -4,6 +4,7 @@ const transientX509ConnectionErrors = new WeakSet(); const retryableX509IssuerErrors = new WeakSet(); const approvedX509Clients = new WeakSet(); const approvedX509OAuthErrors = new WeakMap(); +const approvedX509Credentials = new WeakMap(); /** Looks up an opaque capability without exposing the registry itself. */ export const findRegisteredX509Transport = WeakMap.prototype.get.bind(registeredX509Transports); @@ -34,3 +35,9 @@ export const rememberX509OAuthError = WeakMap.prototype.set.bind(approvedX509OAu /** Retrieves trusted metadata when public OAuth errors cross module formats. */ export const findX509OAuthError = WeakMap.prototype.get.bind(approvedX509OAuthErrors); + +/** Privately binds SDK-owned credentials without importing Node or optional transport peers. */ +export const rememberX509Credential = WeakMap.prototype.set.bind(approvedX509Credentials); + +/** Resolves only credentials registered by the optional Node authentication helper. */ +export const findX509Credential = WeakMap.prototype.get.bind(approvedX509Credentials); diff --git a/src/internal/auth/x509-transport-state.cts b/src/internal/auth/x509-transport-state.cts index 832e245f1..5ddd8567c 100644 --- a/src/internal/auth/x509-transport-state.cts +++ b/src/internal/auth/x509-transport-state.cts @@ -4,6 +4,7 @@ const transientX509ConnectionErrors = new WeakSet(); const retryableX509IssuerErrors = new WeakSet(); const approvedX509Clients = new WeakSet(); const approvedX509OAuthErrors = new WeakMap(); +const approvedX509Credentials = new WeakMap(); /** Looks up an opaque capability without exposing the registry itself. */ export const findRegisteredX509Transport = WeakMap.prototype.get.bind(registeredX509Transports); @@ -34,3 +35,9 @@ export const rememberX509OAuthError = WeakMap.prototype.set.bind(approvedX509OAu /** Retrieves trusted OAuth metadata for public cross-module error normalization. */ export const findX509OAuthError = WeakMap.prototype.get.bind(approvedX509OAuthErrors); + +/** Privately binds SDK-owned credentials to their immutable identity and approved transport. */ +export const rememberX509Credential = WeakMap.prototype.set.bind(approvedX509Credentials); + +/** Resolves only first-class credentials created by the optional Node transport helper. */ +export const findX509Credential = WeakMap.prototype.get.bind(approvedX509Credentials); diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index c84daca00..87e95188e 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -235,6 +235,7 @@ export class X509WorkloadIdentityAuth { readonly #identityProviderId: string; readonly #serviceAccountId: string; readonly #configuredRefreshBufferMs: number | undefined; + readonly #configuredRefreshBufferSeconds: number | undefined; readonly #organization: string | null; readonly #project: string | null; readonly #transport: RegisteredX509Transport; @@ -254,15 +255,32 @@ export class X509WorkloadIdentityAuth { this.#identityProviderId = identity.identityProviderId; this.#serviceAccountId = identity.serviceAccountId; this.#configuredRefreshBufferMs = identity.refreshBufferMs; + this.#configuredRefreshBufferSeconds = identity.refreshBufferSeconds; this.#organization = organization; this.#project = project; + if (this.#configuredRefreshBufferMs !== undefined && this.#configuredRefreshBufferSeconds !== undefined) { + throw new OpenAIError( + 'X.509 workload identity cannot combine refreshBufferSeconds and refreshBufferMs.', + ); + } if ( this.#configuredRefreshBufferMs !== undefined && (!Number.isSafeInteger(this.#configuredRefreshBufferMs) || this.#configuredRefreshBufferMs < 0) ) { throw new OpenAIError('X.509 workload identity requires a nonnegative integer refreshBufferMs.'); } - this.#refreshBufferMs = this.#configuredRefreshBufferMs ?? DEFAULT_REFRESH_BUFFER_MS; + if ( + this.#configuredRefreshBufferSeconds !== undefined && + (!Number.isSafeInteger(this.#configuredRefreshBufferSeconds) || + this.#configuredRefreshBufferSeconds < 0 || + !Number.isSafeInteger(this.#configuredRefreshBufferSeconds * 1000)) + ) { + throw new OpenAIError('X.509 workload identity requires a nonnegative integer refreshBufferSeconds.'); + } + this.#refreshBufferMs = + this.#configuredRefreshBufferSeconds === undefined + ? (this.#configuredRefreshBufferMs ?? DEFAULT_REFRESH_BUFFER_MS) + : this.#configuredRefreshBufferSeconds * 1000; } /** Reconstructs the immutable selectors captured before caller-owned identity mutation. */ @@ -274,6 +292,9 @@ export class X509WorkloadIdentityAuth { ...(this.#configuredRefreshBufferMs === undefined ? {} : { refreshBufferMs: this.#configuredRefreshBufferMs }), + ...(this.#configuredRefreshBufferSeconds === undefined + ? {} + : { refreshBufferSeconds: this.#configuredRefreshBufferSeconds }), }; } @@ -365,6 +386,15 @@ export class X509WorkloadIdentityAuth { return request; } + /** Suspends an already-running network budget during retry-local asynchronous preparation. */ + beginRequestPreparation(): void { + const scope = this.#scope(); + if (scope.deadlineArmed && scope.preparationStartedAt === undefined) { + scope.preparationStartedAt = performance.now(); + scope.preparationWallStartedAt = Date.now(); + } + } + /** Begins local request construction without charging protected hook latency to the network. */ beginRequestPlanning(): void { this.#scope().phase = 'planning'; @@ -377,6 +407,11 @@ export class X509WorkloadIdentityAuth { scope.wallStartedAt = Date.now(); scope.monotonicStartedAt = performance.now(); scope.deadlineArmed = true; + } else if (scope.preparationStartedAt !== undefined) { + scope.monotonicStartedAt += performance.now() - scope.preparationStartedAt; + scope.wallStartedAt += Date.now() - (scope.preparationWallStartedAt ?? Date.now()); + delete scope.preparationStartedAt; + delete scope.preparationWallStartedAt; } } @@ -550,6 +585,8 @@ export class X509WorkloadIdentityAuth { delete scope.request; delete scope.phase; delete scope.deadlineArmed; + delete scope.preparationStartedAt; + delete scope.preparationWallStartedAt; delete scope.effectiveSignal; delete scope.materializedBody; delete scope.apiURL; diff --git a/tests/auth/x509-transport-conformance.test.ts b/tests/auth/x509-transport-conformance.test.ts index 3c63a9548..f5576a3f7 100644 --- a/tests/auth/x509-transport-conformance.test.ts +++ b/tests/auth/x509-transport-conformance.test.ts @@ -2,6 +2,8 @@ import { X509Certificate } from 'node:crypto'; import { Agent, ProxyAgent, fetch } from 'undici'; import { expect } from 'vitest'; import OpenAI from 'openai'; +import { fromX509 } from 'openai/auth/x509-transport'; +import { createProvider } from 'openai/internal/provider'; import { closeObservedServers, @@ -113,6 +115,219 @@ beforeAll(() => { }); describe('real-wire X.509 transport conformance', () => { + test('switches an owned certificate client to an independently authenticated provider', async () => { + const credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + }); + + try { + const original = new OpenAI({ credential }); + const provider = createProvider({ + configure: () => ({ name: 'synthetic-provider', baseURL: 'https://provider.example/v1' }), + }); + const clone = original.withOptions({ provider }); + + expect(clone.baseURL).toBe('https://provider.example/v1'); + expect(clone.apiKey).toBeNull(); + expect(original.baseURL).toBe('https://mtls.api.openai.com/v1'); + } finally { + await credential.close(); + } + }); + + test('switches an independently authenticated provider to an owned certificate credential', async () => { + const credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + }); + + try { + const provider = createProvider({ + configure: () => ({ name: 'synthetic-provider', baseURL: 'https://provider.example/v1' }), + }); + const original = new OpenAI({ provider }); + const clone = original.withOptions({ credential }); + + expect(clone.baseURL).toBe('https://mtls.api.openai.com/v1'); + expect(clone.apiKey).toBeNull(); + expect(original.baseURL).toBe('https://provider.example/v1'); + } finally { + await credential.close(); + } + }); + + test('authenticates the public X.509 credential over both pinned certificate-bound endpoints', async () => { + const exchangedBodies: string[] = []; + const issuer = createMutualTLSServer( + lab, + (request, response) => { + let body = ''; + request.setEncoding('utf-8'); + request.on('data', (chunk: string) => { + body += chunk; + }); + request.once('end', () => { + exchangedBodies.push(body); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + access_token: ACCESS_TOKEN, + token_type: 'Bearer', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + expires_in: 3600, + }), + ); + }); + }, + lab.issuerServer, + ); + const api = createMutualTLSServer( + lab, + (_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ data: [] })); + }, + lab.apiServer, + ); + let proxy: ObservedServer | undefined; + let credential: ReturnType | 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); + const trustRoots = [lab.certificateAuthority.toString()]; + credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + ca: trustRoots, + proxy: { url: proxyURL, mode: 'http-connect' }, + }); + trustRoots[0] = lab.proxyCertificateAuthority.toString(); + const client = new OpenAI({ apiKey: null, credential, maxRetries: 0 }); + + await expect(client.models.list()).resolves.toMatchObject({ data: [] }); + + expect(exchangedBodies).toHaveLength(1); + expect(JSON.parse(exchangedBodies[0] ?? '')).toEqual({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token_type: 'urn:openai:params:oauth:token-type:x509', + identity_provider_id: 'synthetic-identity-provider', + service_account_id: 'synthetic-service-account', + }); + const certificateFingerprint = new X509Certificate(lab.firstClient.certificate).fingerprint256; + expect(issuer.requests).toEqual([ + expect.objectContaining({ + authority: 'mtls.auth.openai.com', + authorization: undefined, + certificateFingerprint, + path: '/oauth/token', + serverName: 'mtls.auth.openai.com', + }), + ]); + expect(api.requests).toEqual([ + expect.objectContaining({ + authority: 'mtls.api.openai.com', + authorization: `Bearer ${ACCESS_TOKEN}`, + certificateFingerprint, + path: '/v1/models', + serverName: 'mtls.api.openai.com', + }), + ]); + expect(proxy.requests).toEqual([ + expect.objectContaining({ + authorization: undefined, + certificateFingerprint: undefined, + path: 'mtls.auth.openai.com:443', + }), + expect.objectContaining({ + authorization: undefined, + certificateFingerprint: undefined, + path: 'mtls.api.openai.com:443', + }), + ]); + } finally { + await credential?.close(); + await closeObservedServers(issuer, api, ...(proxy ? [proxy] : [])); + } + }); + + test.each(['issuer', 'API'] as const)( + 'rejects an untrusted $0 certificate before disclosing workload credentials', + async (untrustedBoundary) => { + const issuer = createMutualTLSServer( + lab, + (_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + access_token: ACCESS_TOKEN, + token_type: 'Bearer', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + expires_in: 3600, + }), + ); + }, + untrustedBoundary === 'issuer' ? lab.proxyServer : lab.issuerServer, + ); + const api = createMutualTLSServer( + lab, + (_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ data: [] })); + }, + untrustedBoundary === 'API' ? lab.proxyServer : lab.apiServer, + ); + let proxy: ObservedServer | undefined; + let credential: ReturnType | 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); + credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + ca: lab.certificateAuthority.toString(), + proxy: { url: proxyURL, mode: 'http-connect' }, + }); + const client = new OpenAI({ apiKey: null, credential, maxRetries: 0 }); + + await expect(client.models.list()).rejects.toThrow(); + expect(issuer.requests).toHaveLength(untrustedBoundary === 'issuer' ? 0 : 1); + expect(api.requests).toEqual([]); + } finally { + await credential?.close(); + await closeObservedServers(issuer, api, ...(proxy ? [proxy] : [])); + } + }, + ); + test('observes one client certificate and isolated credentials on the issuer and API TLS handshakes', async () => { const issuer = createTokenServer(); const api = createAPIServer(); diff --git a/tests/auth/x509-transport.test.ts b/tests/auth/x509-transport.test.ts index 1ff7f1998..81ef0cdb8 100644 --- a/tests/auth/x509-transport.test.ts +++ b/tests/auth/x509-transport.test.ts @@ -1,10 +1,11 @@ import { X509Certificate } from 'node:crypto'; import { once } from 'node:events'; import { createServer } from 'node:http'; +import { inspect } from 'node:util'; import { Agent, ProxyAgent, fetch } from 'undici'; import { vi } from 'vitest'; -import { createX509Transport } from 'openai/auth/x509-transport'; +import { createX509Transport, fromX509, workloadIdentity } from 'openai/auth/x509-transport'; import type { X509Transport, X509TransportOptions } from 'openai/auth/x509-transport'; import { registerX509Transport, sendX509Request } from 'openai/internal/auth/x509-transport-capability'; @@ -25,6 +26,181 @@ function directOptions(dispatcher: Agent): X509TransportOptions { }; } +function credentialOptions() { + const lab = createX509TestLab(); + return { + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + ca: lab.certificateAuthority.toString(), + }; +} + +describe('SDK-owned X.509 credential transport', () => { + test('exports its first-class factory through a frozen workload identity namespace', () => { + expect(Object.isFrozen(workloadIdentity)).toBe(true); + expect(workloadIdentity.fromX509).toBe(fromX509); + }); + + test('returns an opaque, frozen credential whose owned transport closes exactly once', async () => { + const credential = workloadIdentity.fromX509(credentialOptions()); + + expect(Object.isFrozen(credential)).toBe(true); + expect(Reflect.ownKeys(credential)).toEqual([]); + const firstClose = credential.close(); + expect(credential.close()).toBe(firstClose); + await firstClose; + }); + + test('rejects a caller attempt to disable TLS server verification', () => { + const options = { ...credentialOptions(), rejectUnauthorized: false }; + + expect(() => fromX509(options)).toThrow(/rejectUnauthorized|unsupported|TLS/iu); + }); + + test('rejects a caller-supplied dispatcher that disables TLS server verification', async () => { + const dispatcher = new Agent({ connect: { rejectUnauthorized: false } }); + + try { + const options = { ...credentialOptions(), dispatcher }; + + expect(() => fromX509(options)).toThrow(/dispatcher|unsupported/iu); + } finally { + await dispatcher.close(); + } + }); + + test.each([ + { url: 'http://127.0.0.1:1', mode: 'https-connect' }, + { url: 'https://127.0.0.1:1', mode: 'http-connect' }, + ] as const)('rejects proxy protocol mismatches for $mode', ({ url, mode }) => { + expect(() => fromX509({ ...credentialOptions(), proxy: { url, mode } })).toThrow( + /proxy|protocol|HTTPS/iu, + ); + }); + + test('never exposes proxy credentials when rejecting a malformed proxy URL', () => { + const secret = 'synthetic-private-proxy-password'; + const options = { + ...credentialOptions(), + proxy: { + url: `https://synthetic-user:${secret}@invalid host`, + mode: 'https-connect' as const, + }, + }; + let failure: unknown; + + try { + fromX509(options); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(Error); + expect(inspect(failure)).not.toContain(secret); + expect(JSON.stringify(failure)).not.toContain(secret); + }); + + test('rejects inherited certificate material without invoking its accessor', () => { + const { privateKey, ...ownOptions } = credentialOptions(); + const getter = vi.fn(() => privateKey); + const options = Object.assign( + Object.create({ + get privateKey() { + return getter(); + }, + }) as { privateKey: string }, + ownOptions, + ); + + expect(() => fromX509(options)).toThrow(/privateKey|own|plain/iu); + expect(getter).not.toHaveBeenCalled(); + }); + + test('rejects executable credential prototype traps without invoking them', () => { + const trap = vi.fn(() => { + throw new Error('attacker-controlled credential prototype trap'); + }); + const prototype = new Proxy({}, { has: trap }); + const options = Object.assign(Object.create(prototype) as object, credentialOptions()); + + expect(() => fromX509(options)).toThrow(/prototype|plain|proxy/iu); + expect(trap).not.toHaveBeenCalled(); + }); + + test.each(['ca', 'passphrase'] as const)( + 'rejects an inherited optional $0 accessor before touching private material', + (name) => { + const { ca, ...ownOptions } = credentialOptions(); + const getter = vi.fn(() => ca); + const options = Object.assign( + Object.create({ + get [name]() { + return getter(); + }, + }) as Record, + ownOptions, + ); + + expect(() => fromX509(options)).toThrow(/own|plain|inherited|credential/iu); + expect(getter).not.toHaveBeenCalled(); + }, + ); + + test('rejects inherited proxy configuration without invoking its accessor', () => { + const getter = vi.fn(() => 'http://127.0.0.1:1'); + const proxy = Object.assign( + Object.create({ + get url() { + return getter(); + }, + }) as { url: string }, + { mode: 'http-connect' as const }, + ); + + expect(() => fromX509({ ...credentialOptions(), proxy })).toThrow(/proxy|own|plain/iu); + expect(getter).not.toHaveBeenCalled(); + }); + + test('rejects executable trust-root array entries without invoking them', () => { + const root = credentialOptions().ca; + const ca = [root]; + const getter = vi.fn(() => root); + Object.defineProperty(ca, 0, { get: getter }); + + expect(() => fromX509({ ...credentialOptions(), ca })).toThrow(/trust|authorit|certificate|plain/iu); + expect(getter).not.toHaveBeenCalled(); + }); + + test('never sends HTTPS CONNECT proxy credentials to a plaintext proxy', async () => { + const requests = vi.fn(); + const proxy = createServer(); + proxy.on('connect', requests); + const listening = once(proxy, 'listening'); + proxy.listen(0, '127.0.0.1'); + await listening; + + try { + const address = proxy.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected a loopback TCP server address'); + } + const url = `http://synthetic-user:synthetic-secret@127.0.0.1:${address.port}`; + + expect(() => fromX509({ ...credentialOptions(), proxy: { url, mode: 'https-connect' } })).toThrow( + /proxy|protocol|HTTPS/iu, + ); + expect(requests).not.toHaveBeenCalled(); + } finally { + proxy.closeAllConnections(); + const closed = once(proxy, 'close'); + proxy.close(); + await closed; + } + }); +}); + describe('explicit X.509 transport capability', () => { test('rejects Undici without per-request dispatcher support before creating a capability', async () => { const dispatcher = new Agent(); @@ -284,6 +460,125 @@ describe('explicit X.509 transport capability', () => { } }); + test('rejects an externally supplied Agent that disables TLS server verification', async () => { + const dispatcher = new Agent({ connect: { rejectUnauthorized: false } }); + + try { + expect(() => createX509Transport(directOptions(dispatcher))).toThrow( + /rejectUnauthorized|server verification|TLS/iu, + ); + } finally { + await dispatcher.close(); + } + }); + + test('rejects an externally supplied Agent with a custom dispatcher factory', async () => { + const factory = vi.fn(() => new Agent()); + const dispatcher = new Agent({ factory }); + + try { + expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/factory|trusted|transport/iu); + expect(factory).not.toHaveBeenCalled(); + } finally { + await dispatcher.close(); + } + }); + + test('rejects globally disabled TLS verification unless the dispatcher explicitly enables it', async () => { + vi.stubEnv('NODE_TLS_REJECT_UNAUTHORIZED', '0'); + const inherited = new Agent(); + const explicit = new Agent({ connect: { rejectUnauthorized: true } }); + let credential: ReturnType | undefined; + + try { + expect(() => createX509Transport(directOptions(inherited))).toThrow( + /rejectUnauthorized|server verification|TLS/iu, + ); + expect(() => createX509Transport(directOptions(explicit))).not.toThrow(); + credential = fromX509({ + ...credentialOptions(), + proxy: { url: 'http://127.0.0.1:1', mode: 'http-connect' }, + }); + } finally { + vi.unstubAllEnvs(); + await credential?.close(); + await Promise.all([inherited.close(), explicit.close()]); + } + }); + + test.each([ + { url: 'http://127.0.0.1:1', mode: 'https-connect' }, + { url: 'https://127.0.0.1:1', mode: 'http-connect' }, + ] as const)('rejects an external ProxyAgent whose protocol contradicts $mode', async ({ url, mode }) => { + const dispatcher = new ProxyAgent({ uri: url }); + + try { + expect(() => + createX509Transport({ + runtime: 'node', + dispatcher, + certificateIdentity: 'static', + proxy: mode, + }), + ).toThrow(/proxy|protocol|HTTPS/iu); + } finally { + await dispatcher.close(); + } + }); + + test.each([ + { + label: 'target', + options: { uri: 'http://127.0.0.1:1', requestTls: { rejectUnauthorized: false } }, + mode: 'http-connect', + }, + { + label: 'proxy', + options: { uri: 'https://127.0.0.1:1', proxyTls: { rejectUnauthorized: false } }, + mode: 'https-connect', + }, + ] as const)( + 'rejects an external ProxyAgent with disabled $label TLS verification', + async ({ options, mode }) => { + const dispatcher = new ProxyAgent(options); + + try { + expect(() => + createX509Transport({ + runtime: 'node', + dispatcher, + certificateIdentity: 'static', + proxy: mode, + }), + ).toThrow(/rejectUnauthorized|server verification|TLS/iu); + } finally { + await dispatcher.close(); + } + }, + ); + + test('revalidates external TLS verification immediately before dispatch', async () => { + const dispatcher = new Agent({ connect: { rejectUnauthorized: true } }); + + try { + const capability = createX509Transport(directOptions(dispatcher)); + const stateKey = Object.getOwnPropertySymbols(dispatcher).find( + (symbol) => symbol.description === 'options', + ); + const state = stateKey ? Object.getOwnPropertyDescriptor(dispatcher, stateKey)?.value : undefined; + if (!state || typeof state !== 'object' || !state.connect || typeof state.connect !== 'object') { + throw new Error('Expected genuine Undici Agent connection settings'); + } + state.connect.rejectUnauthorized = false; + + await expect(sendX509Request(capability, new URL('https://example.invalid'), {})).rejects.toThrow( + /rejectUnauthorized|server verification|TLS/iu, + ); + } finally { + await dispatcher.close(); + } + }); + test('rejects forged capability objects before dispatch', async () => { await expect( sendX509Request({} as X509Transport, new URL('https://example.invalid'), {}), diff --git a/tests/lib/x509-workload-credential-config.test.ts b/tests/lib/x509-workload-credential-config.test.ts new file mode 100644 index 000000000..6c08aa871 --- /dev/null +++ b/tests/lib/x509-workload-credential-config.test.ts @@ -0,0 +1,115 @@ +import { Agent } from 'undici'; +import { vi } from 'vitest'; + +import OpenAI 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'; + +let dispatcher: Agent; +let transport: X509Transport; + +function configuredIdentity(configuration: Record = {}) { + return { + type: 'x509' as const, + identityProviderId: 'synthetic-configuration-provider', + serviceAccountId: 'synthetic-configuration-account', + ...configuration, + }; +} + +function configuredClient(configuration: Record = {}): OpenAI { + return new OpenAI({ + apiKey: null, + maxRetries: 0, + workloadIdentity: configuredIdentity(configuration), + x509Transport: transport, + }); +} + +function mockExpiringCredential(): () => number { + let exchanges = 0; + vi.spyOn(transportCapability, 'sendX509Request').mockImplementation(async (_transport, url) => { + if (url.origin === 'https://mtls.auth.openai.com') { + exchanges += 1; + return Response.json({ + access_token: `synthetic-configured-refresh-${exchanges}`, + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + expires_in: 20, + }); + } + return Response.json({ data: [] }); + }); + return () => exchanges; +} + +beforeEach(() => { + dispatcher = new Agent(); + transport = createX509Transport({ + runtime: 'node', + dispatcher, + certificateIdentity: 'static', + proxy: 'direct', + }); +}); + +afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + await dispatcher.close(); +}); + +describe('X.509 workload credential refresh configuration', () => { + test.each([null, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects invalid refreshBufferSeconds %s before certificate presentation', + (refreshBufferSeconds) => { + const send = vi.spyOn(transportCapability, 'sendX509Request'); + + expect(() => configuredClient({ refreshBufferSeconds })).toThrow(/refreshBufferSeconds/iu); + expect(send).not.toHaveBeenCalled(); + }, + ); + + test('rejects conflicting seconds and legacy milliseconds refresh options before authentication', () => { + const send = vi.spyOn(transportCapability, 'sendX509Request'); + + expect(() => configuredClient({ refreshBufferSeconds: 4, refreshBufferMs: 4000 })).toThrow( + /refreshBufferSeconds.*refreshBufferMs|refreshBufferMs.*refreshBufferSeconds/iu, + ); + expect(send).not.toHaveBeenCalled(); + }); + + test.each([ + ['refreshBufferSeconds', 4], + ['refreshBufferMs', 4000], + ] as const)('honors the %s refresh option using its documented units', async (name, value) => { + vi.useFakeTimers({ toFake: ['Date', 'performance', 'setTimeout', 'clearTimeout'] }); + const exchanges = mockExpiringCredential(); + const client = configuredClient({ [name]: value }); + + await client.models.list(); + await vi.advanceTimersByTimeAsync(15_999); + await client.models.list(); + expect(exchanges()).toBe(1); + + await vi.advanceTimersByTimeAsync(2); + await client.models.list(); + expect(exchanges()).toBe(2); + }); + + test('preserves configured seconds-based refresh and cached credentials across client clones', async () => { + vi.useFakeTimers({ toFake: ['Date', 'performance', 'setTimeout', 'clearTimeout'] }); + const exchanges = mockExpiringCredential(); + const original = configuredClient({ refreshBufferSeconds: 4 }); + + await original.models.list(); + await vi.advanceTimersByTimeAsync(15_999); + await original.withOptions({ timeout: 2500 }).models.list(); + expect(exchanges()).toBe(1); + + await vi.advanceTimersByTimeAsync(2); + await original.withOptions({ timeout: 2500 }).models.list(); + expect(exchanges()).toBe(2); + }); +}); diff --git a/tests/lib/x509-workload-example.test.ts b/tests/lib/x509-workload-example.test.ts index f8b72219e..99ac4da31 100644 --- a/tests/lib/x509-workload-example.test.ts +++ b/tests/lib/x509-workload-example.test.ts @@ -47,10 +47,8 @@ describe('X.509 workload-identity runnable example', () => { ['runnable', example], ['documented', packageDocumentation], ])('keeps the %s X.509 example isolated from ambient credentials and routing', (_name, source) => { - expect(source).toContain('apiKey: null'); - expect(source).toContain('adminAPIKey: null'); - expect(source).toContain('baseURL: null'); - expect(source).toContain('organization: null'); + expect(source).toContain('workloadIdentity.fromX509'); + expect(source).toContain('credential,'); expect(source).toContain("project: process.env['OPENAI_X509_PROJECT_ID'] ?? null"); }); diff --git a/tests/lib/x509-workload-request-boundaries.test.ts b/tests/lib/x509-workload-request-boundaries.test.ts index 57af24244..f788ad8b7 100644 --- a/tests/lib/x509-workload-request-boundaries.test.ts +++ b/tests/lib/x509-workload-request-boundaries.test.ts @@ -4,10 +4,12 @@ import { vi } from 'vitest'; import OpenAI, { APIConnectionTimeoutError, APIUserAbortError } from 'openai'; import type { ClientOptions } from 'openai'; -import { createX509Transport } from 'openai/auth/x509-transport'; +import { createX509Transport, fromX509 } from 'openai/auth/x509-transport'; import type { X509Transport } from 'openai/auth/x509-transport'; import * as transportCapability from 'openai/internal/auth/x509-transport-capability'; +import { createX509TestLab } from '../utils/x509-test-lab'; + const tokenResponse = { access_token: 'synthetic-request-boundary-bearer', token_type: 'Bearer', @@ -32,6 +34,16 @@ function options(overrides: Partial = {}): ClientOptions { }; } +function ownedCredential() { + const lab = createX509TestLab(); + return fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-boundary-provider', + serviceAccountId: 'synthetic-boundary-account', + }); +} + beforeEach(() => { dispatcher = new Agent(); transport = createX509Transport({ @@ -48,6 +60,115 @@ afterEach(async () => { }); describe('X.509 request ownership boundaries', () => { + test('safely replaces an API-key client with an SDK-owned X.509 credential when cloning', async () => { + const credential = ownedCredential(); + + try { + const original = new OpenAI({ apiKey: 'synthetic-existing-api-key' }); + const clone = original.withOptions({ + credential, + organization: 'synthetic-explicit-organization', + project: 'synthetic-explicit-project', + }); + + expect(clone.baseURL).toBe('https://mtls.api.openai.com/v1'); + expect(clone.apiKey).toBeNull(); + expect(clone.organization).toBe('synthetic-explicit-organization'); + expect(clone.project).toBe('synthetic-explicit-project'); + } finally { + await credential.close(); + } + }); + + test('safely replaces an SDK-owned X.509 credential with an API key when cloning', async () => { + const credential = ownedCredential(); + + try { + const original = new OpenAI({ credential }); + const clone = original.withOptions({ apiKey: 'synthetic-replacement-api-key' }); + + expect(clone.baseURL).toBe('https://api.openai.com/v1'); + expect(clone.apiKey).toBe('synthetic-replacement-api-key'); + } finally { + await credential.close(); + } + }); + + test('shares one SDK-owned credential token across equivalent cloned clients', async () => { + const credential = ownedCredential(); + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => + url.origin === 'https://mtls.auth.openai.com' + ? Response.json(tokenResponse) + : Response.json({ data: [] }), + ); + + try { + const original = new OpenAI({ credential }); + await original.models.list(); + vi.stubEnv('OPENAI_CUSTOM_HEADERS', 'OpenAI-Organization: synthetic-ambient-header-organization'); + const clone = original.withOptions({ timeout: 1000 }); + await clone.models.list(); + const converted = clone.withOptions({ apiKey: 'synthetic-cloned-replacement-api-key' }); + + expect(send).toHaveBeenCalledTimes(3); + expect( + send.mock.calls.filter((call) => call[1].origin === 'https://mtls.auth.openai.com'), + ).toHaveLength(1); + expect(converted.baseURL).toBe('https://api.openai.com/v1'); + expect(converted.apiKey).toBe('synthetic-cloned-replacement-api-key'); + } finally { + vi.unstubAllEnvs(); + await credential.close(); + } + }); + + test('isolates an SDK-owned credential clone from stale client and ambient authentication settings', async () => { + const credential = ownedCredential(); + vi.stubEnv('OPENAI_API_KEY', 'synthetic-ambient-api-key'); + vi.stubEnv('OPENAI_ADMIN_KEY', 'synthetic-ambient-admin-key'); + vi.stubEnv('OPENAI_BASE_URL', 'https://synthetic.invalid/v1'); + vi.stubEnv('OPENAI_ORG_ID', 'synthetic-ambient-organization'); + vi.stubEnv('OPENAI_PROJECT_ID', 'synthetic-ambient-project'); + vi.stubEnv('OPENAI_CUSTOM_HEADERS', 'OpenAI-Organization: synthetic-ambient-header-organization'); + let dispatchedHeaders: Headers | undefined; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url, request) => { + if (url.origin === 'https://mtls.auth.openai.com') { + return Response.json(tokenResponse); + } + dispatchedHeaders = new Headers(request.headers); + return Response.json({ data: [] }); + }); + + try { + const original = new OpenAI({ + apiKey: 'synthetic-original-api-key', + adminAPIKey: null, + organization: 'synthetic-original-organization', + project: 'synthetic-original-project', + defaultHeaders: { 'X-Synthetic-Original': 'must-not-cross' }, + }); + const clone = original.withOptions({ credential }); + + await clone.models.list(); + + expect(clone.baseURL).toBe('https://mtls.api.openai.com/v1'); + expect(clone.apiKey).toBeNull(); + expect(clone.adminAPIKey).toBeNull(); + expect(clone.organization).toBeNull(); + expect(clone.project).toBeNull(); + expect(dispatchedHeaders?.get('X-Synthetic-Original')).toBeNull(); + expect(dispatchedHeaders?.get('OpenAI-Organization')).toBeNull(); + expect(send).toHaveBeenCalledTimes(2); + } finally { + vi.unstubAllEnvs(); + await credential.close(); + } + }); + test('applies a lowered final override deadline before certificate authentication', async () => { let minted = false; const send = vi @@ -501,6 +622,73 @@ describe('X.509 request ownership boundaries', () => { expect(send).toHaveBeenCalledTimes(2); }); + test.each(['prepareOptions', 'prepareRequest'] as const)( + 'excludes asynchronous retry-local %s preparation from the network budget', + async (hook) => { + let preparations = 0; + let attempts = 0; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url) => { + if (url.origin === 'https://mtls.auth.openai.com') { + return Response.json(tokenResponse); + } + attempts += 1; + return attempts === 1 + ? new Response(null, { status: 503, headers: { 'retry-after-ms': '1' } }) + : Response.json({ data: [] }); + }); + const client = new OpenAI(options({ maxRetries: 1, timeout: 50 })); + Object.defineProperty(client, hook, { + value: async () => { + preparations += 1; + if (preparations === 2) { + await delay(90); + } + }, + }); + + await expect(client.models.list()).resolves.toMatchObject({ data: [] }); + expect(preparations).toBe(2); + expect(attempts).toBe(2); + expect(send).toHaveBeenCalledTimes(3); + }, + ); + + test.each(['prepareOptions', 'prepareRequest'] as const)( + 'preserves elapsed network time while excluding retry-local %s preparation', + async (hook) => { + let preparations = 0; + let attempts = 0; + const send = vi + .spyOn(transportCapability, 'sendX509Request') + .mockImplementation(async (_transport, url, request) => { + if (url.origin === 'https://mtls.auth.openai.com') { + return Response.json(tokenResponse); + } + attempts += 1; + await delay(35, undefined, { signal: request.signal ?? undefined }); + return attempts === 1 + ? new Response(null, { status: 503, headers: { 'retry-after-ms': '1' } }) + : Response.json({ data: [] }); + }); + const client = new OpenAI(options({ maxRetries: 1, timeout: 55 })); + Object.defineProperty(client, hook, { + value: async () => { + preparations += 1; + if (preparations === 2) { + await delay(90); + } + }, + }); + + await expect(client.models.list()).rejects.toBeInstanceOf(APIConnectionTimeoutError); + expect(preparations).toBe(2); + expect(attempts).toBe(2); + expect(send).toHaveBeenCalledTimes(3); + }, + ); + test.each(['dispatcher', 'redirect', 'authorization', 'failure'] as const)( 'rejects a protected request hook %s before presenting a certificate', async (mutation) => { diff --git a/tests/lib/x509-workload-review-regressions.test.ts b/tests/lib/x509-workload-review-regressions.test.ts index 4119a2489..e715536fe 100644 --- a/tests/lib/x509-workload-review-regressions.test.ts +++ b/tests/lib/x509-workload-review-regressions.test.ts @@ -63,8 +63,8 @@ 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("import { workloadIdentity } from 'openai/auth/x509-transport'"); + expect(authenticationGuide).toContain('workloadIdentity.fromX509'); expect(authenticationGuide).toContain('https://mtls.api.openai.com/v1'); expect(authenticationGuide).toContain('WebSocket'); }); From 5bab25987279cbfa3a5ec73b3e66d83ac41a017d Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 05:32:52 +0000 Subject: [PATCH 02/11] fix(auth): isolate X.509 clone transitions and proxy credentials --- src/auth/x509-transport.ts | 20 + src/client.ts | 8 +- src/internal/auth/x509-credential-options.ts | 24 +- tests/auth/x509-transport-conformance.test.ts | 362 +++++++++++++----- tests/auth/x509-transport.test.ts | 35 ++ 5 files changed, 337 insertions(+), 112 deletions(-) diff --git a/src/auth/x509-transport.ts b/src/auth/x509-transport.ts index 51eafd79e..4a2ebbd99 100644 --- a/src/auth/x509-transport.ts +++ b/src/auth/x509-transport.ts @@ -205,6 +205,24 @@ interface VerifiedX509TLSOptions { ca?: string | string[]; } +function proxyAuthentication(url: URL): string | undefined { + if (url.username === '' && url.password === '') { + return undefined; + } + let username: string; + let password: string; + try { + username = decodeURIComponent(url.username); + password = decodeURIComponent(url.password); + } catch { + throw new Error('X.509 CONNECT proxy credentials contain invalid URL encoding.'); + } + if (username.includes(':')) { + throw new Error('X.509 CONNECT proxy username cannot contain a colon.'); + } + return Buffer.from(`${username}:${password}`, 'utf-8').toString('base64'); +} + function credentialDispatcher( proxyOptionsInput: unknown, requestTls: VerifiedX509TLSOptions, @@ -240,11 +258,13 @@ function credentialDispatcher( if (proxy === 'http-connect' && proxyCA !== undefined) { throw new Error('A plaintext X.509 CONNECT proxy cannot configure proxy TLS authorities.'); } + const auth = proxyAuthentication(url); return { proxy, dispatcher: new ProxyAgent({ uri: url.href, + ...(auth === undefined ? {} : { auth }), requestTls, ...(proxy === 'https-connect' ? { diff --git a/src/client.ts b/src/client.ts index cdcee9f32..ab22d4cc6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -671,7 +671,12 @@ export class OpenAI { project: this.project, webhookSecret: this.webhookSecret, }; - prepareX509ClientClone(inheritedOptions, options, this.#x509Credential, x509Authentication !== undefined); + const credential = prepareX509ClientClone( + inheritedOptions, + options, + this.#x509Credential, + x509Authentication !== undefined, + ); if (residencyBaseURL !== undefined) { delete inheritedOptions.baseURL; } @@ -692,6 +697,7 @@ export class OpenAI { const clientOptions: InternalClientOptions = { ...inheritedOptions, ...options, + credential, provider, [inheritedDataResidencySelection]: this.#explicitDataResidency && diff --git a/src/internal/auth/x509-credential-options.ts b/src/internal/auth/x509-credential-options.ts index b6a5de50b..39bd6281b 100644 --- a/src/internal/auth/x509-credential-options.ts +++ b/src/internal/auth/x509-credential-options.ts @@ -47,32 +47,37 @@ export function normalizeX509CredentialOptions(options: ClientOptions): { }; } -/** Preserves credential ownership while isolating transitions between API keys, providers, and X.509. */ +/** Distinguishes explicitly supplied ordinary credentials from nullish inheritance. */ +function overridesOrdinaryAuthentication({ apiKey, adminAPIKey }: Partial): boolean { + return (apiKey !== null && apiKey !== undefined) || (adminAPIKey !== null && adminAPIKey !== undefined); +} + +/** Returns the effective credential after reconciling one client's authentication transition. */ export function prepareX509ClientClone( inherited: ClientOptions, overrides: Partial, credential: X509Credential | undefined, currentlyX509: boolean, -): void { +): X509Credential | undefined { const nextIdentity = hasOwn(overrides, 'workloadIdentity') ? overrides.workloadIdentity : inherited.workloadIdentity; - const overridingApiKey = overrides.apiKey; const dropping = credential !== undefined && - ((overridingApiKey !== null && - overridingApiKey !== undefined && - overrides.workloadIdentity === undefined) || + ((overridesOrdinaryAuthentication(overrides) && overrides.workloadIdentity === undefined) || overrides.provider !== undefined); + if (credential !== undefined && hasOwn(overrides, 'workloadIdentity')) { + delete inherited.x509Transport; + } const inheritedCredential = credential !== undefined && !dropping && - !hasOwn(overrides, 'credential') && + overrides.credential === undefined && !hasOwn(overrides, 'workloadIdentity') && !hasOwn(overrides, 'x509Transport') ? credential : undefined; - const nextCredential = overrides.credential ?? inheritedCredential; + const nextCredential = overrides.credential === undefined ? inheritedCredential : overrides.credential; const nextX509 = nextCredential !== undefined || (!dropping && isX509WorkloadIdentity(nextIdentity)); if (currentlyX509 !== nextX509) { @@ -89,7 +94,7 @@ export function prepareX509ClientClone( } if (nextCredential === undefined) { - return; + return undefined; } delete inherited.apiKey; delete inherited.adminAPIKey; @@ -102,4 +107,5 @@ export function prepareX509ClientClone( delete inherited.defaultHeaders; delete inherited.fetchOptions; } + return nextCredential; } diff --git a/tests/auth/x509-transport-conformance.test.ts b/tests/auth/x509-transport-conformance.test.ts index f5576a3f7..1b0fd8cc5 100644 --- a/tests/auth/x509-transport-conformance.test.ts +++ b/tests/auth/x509-transport-conformance.test.ts @@ -2,7 +2,7 @@ import { X509Certificate } from 'node:crypto'; import { Agent, ProxyAgent, fetch } from 'undici'; import { expect } from 'vitest'; import OpenAI from 'openai'; -import { fromX509 } from 'openai/auth/x509-transport'; +import { createX509Transport, fromX509 } from 'openai/auth/x509-transport'; import { createProvider } from 'openai/internal/provider'; import { @@ -115,6 +115,135 @@ beforeAll(() => { }); describe('real-wire X.509 transport conformance', () => { + test.each([ + { label: 'API key', options: { apiKey: 'synthetic-ordinary-api-key' } }, + { label: 'admin API key', options: { adminAPIKey: 'synthetic-ordinary-admin-key' } }, + ])('switches an SDK-owned certificate client to an ordinary $label', async ({ options }) => { + const credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + }); + + try { + const original = new OpenAI({ credential }); + const clone = original.withOptions(options); + + expect(clone.baseURL).toBe('https://api.openai.com/v1'); + expect(clone.apiKey).toBe(options.apiKey ?? null); + expect(clone.adminAPIKey).toBe(options.adminAPIKey ?? null); + expect(original.baseURL).toBe('https://mtls.api.openai.com/v1'); + } finally { + await credential.close(); + } + }); + + test.each([ + { label: 'without admin credentials', options: {} }, + { label: 'with separate admin credentials', options: { adminAPIKey: 'synthetic-admin-key' } }, + ])( + 'rejects replacing an owned X.509 identity $label without a replacement transport', + async ({ options }) => { + const credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + }); + + try { + const original = new OpenAI({ credential }); + + expect(() => + original.withOptions({ + ...options, + workloadIdentity: { + type: 'x509', + identityProviderId: 'replacement-identity-provider', + serviceAccountId: 'replacement-service-account', + }, + }), + ).toThrow(/transport/iu); + expect(original.baseURL).toBe('https://mtls.api.openai.com/v1'); + } finally { + await credential.close(); + } + }, + ); + + test('retains owned credential isolation when an explicit undefined credential is inherited', async () => { + const credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + }); + + try { + const inherited = new OpenAI({ credential }).withOptions({ credential: undefined }); + const ordinary = inherited.withOptions({ adminAPIKey: 'synthetic-admin-key' }); + + expect(inherited.baseURL).toBe('https://mtls.api.openai.com/v1'); + expect(ordinary.baseURL).toBe('https://api.openai.com/v1'); + } finally { + await credential.close(); + } + }); + + test('rejects an explicit null credential without downgrading owned transport isolation', async () => { + const credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + }); + + try { + const original = new OpenAI({ credential }); + + expect(() => Reflect.apply(original.withOptions, original, [{ credential: null }])).toThrow( + /credential.*SDK|SDK.*credential/iu, + ); + expect(original.baseURL).toBe('https://mtls.api.openai.com/v1'); + } finally { + await credential.close(); + } + }); + + test('accepts an explicitly replaced X.509 identity and separately owned transport', async () => { + const credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + }); + const replacementDispatcher = createAgent(lab.secondClient); + const replacementTransport = createX509Transport({ + runtime: 'node', + dispatcher: replacementDispatcher, + certificateIdentity: 'static', + proxy: 'direct', + }); + + try { + const original = new OpenAI({ credential }); + const replacement = original.withOptions({ + workloadIdentity: { + type: 'x509', + identityProviderId: 'replacement-identity-provider', + serviceAccountId: 'replacement-service-account', + }, + x509Transport: replacementTransport, + }); + + expect(replacement.baseURL).toBe('https://mtls.api.openai.com/v1'); + expect(original.baseURL).toBe('https://mtls.api.openai.com/v1'); + } finally { + await Promise.all([credential.close(), replacementDispatcher.close()]); + } + }); + test('switches an owned certificate client to an independently authenticated provider', async () => { const credential = fromX509({ certificateChain: lab.firstClient.certificate.toString(), @@ -161,111 +290,140 @@ describe('real-wire X.509 transport conformance', () => { } }); - test('authenticates the public X.509 credential over both pinned certificate-bound endpoints', async () => { - const exchangedBodies: string[] = []; - const issuer = createMutualTLSServer( - lab, - (request, response) => { - let body = ''; - request.setEncoding('utf-8'); - request.on('data', (chunk: string) => { - body += chunk; - }); - request.once('end', () => { - exchangedBodies.push(body); - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end( - JSON.stringify({ - access_token: ACCESS_TOKEN, - token_type: 'Bearer', - issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', - expires_in: 3600, - }), - ); - }); - }, - lab.issuerServer, - ); - const api = createMutualTLSServer( - lab, - (_request, response) => { - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ data: [] })); - }, - lab.apiServer, - ); - let proxy: ObservedServer | undefined; - let credential: ReturnType | undefined; - - try { - const [issuerURL, apiURL] = await Promise.all([listenLoopback(issuer), listenLoopback(api)]); - proxy = createConnectProxy( + test.each([ + { label: 'no proxy credentials', username: '', password: '', authorization: undefined }, + { + label: 'username-only proxy credentials', + username: 'synthetic-user', + password: '', + authorization: `Basic ${Buffer.from('synthetic-user:').toString('base64')}`, + }, + { + label: 'password-only proxy credentials', + username: '', + password: 'synthetic-password', + authorization: `Basic ${Buffer.from(':synthetic-password').toString('base64')}`, + }, + { + label: 'percent-encoded proxy credentials', + username: 'synthetic@example.com', + password: 'synthetic:secret value', + authorization: `Basic ${Buffer.from('synthetic@example.com:synthetic:secret value').toString('base64')}`, + }, + ])( + 'authenticates both pinned X.509 endpoints with $label', + async ({ username, password, authorization }) => { + const exchangedBodies: string[] = []; + const issuer = createMutualTLSServer( lab, - false, - lab.proxyServer, - new Map([ - ['mtls.auth.openai.com:443', issuerURL], - ['mtls.api.openai.com:443', apiURL], - ]), + (request, response) => { + let body = ''; + request.setEncoding('utf-8'); + request.on('data', (chunk: string) => { + body += chunk; + }); + request.once('end', () => { + exchangedBodies.push(body); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + access_token: ACCESS_TOKEN, + token_type: 'Bearer', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + expires_in: 3600, + }), + ); + }); + }, + lab.issuerServer, ); - const proxyURL = await listenLoopback(proxy, false); - const trustRoots = [lab.certificateAuthority.toString()]; - credential = fromX509({ - certificateChain: lab.firstClient.certificate.toString(), - privateKey: lab.firstClient.privateKey.toString(), - identityProviderId: 'synthetic-identity-provider', - serviceAccountId: 'synthetic-service-account', - ca: trustRoots, - proxy: { url: proxyURL, mode: 'http-connect' }, - }); - trustRoots[0] = lab.proxyCertificateAuthority.toString(); - const client = new OpenAI({ apiKey: null, credential, maxRetries: 0 }); + const api = createMutualTLSServer( + lab, + (_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ data: [] })); + }, + lab.apiServer, + ); + let proxy: ObservedServer | undefined; + let credential: ReturnType | undefined; - await expect(client.models.list()).resolves.toMatchObject({ data: [] }); + 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); + proxyURL.username = username; + proxyURL.password = password; + const trustRoots = [lab.certificateAuthority.toString()]; + credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + ca: trustRoots, + proxy: { url: proxyURL, mode: 'http-connect' }, + }); + trustRoots[0] = lab.proxyCertificateAuthority.toString(); + const client = new OpenAI({ apiKey: null, credential, maxRetries: 0 }); - expect(exchangedBodies).toHaveLength(1); - expect(JSON.parse(exchangedBodies[0] ?? '')).toEqual({ - grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', - subject_token_type: 'urn:openai:params:oauth:token-type:x509', - identity_provider_id: 'synthetic-identity-provider', - service_account_id: 'synthetic-service-account', - }); - const certificateFingerprint = new X509Certificate(lab.firstClient.certificate).fingerprint256; - expect(issuer.requests).toEqual([ - expect.objectContaining({ - authority: 'mtls.auth.openai.com', - authorization: undefined, - certificateFingerprint, - path: '/oauth/token', - serverName: 'mtls.auth.openai.com', - }), - ]); - expect(api.requests).toEqual([ - expect.objectContaining({ - authority: 'mtls.api.openai.com', - authorization: `Bearer ${ACCESS_TOKEN}`, - certificateFingerprint, - path: '/v1/models', - serverName: 'mtls.api.openai.com', - }), - ]); - expect(proxy.requests).toEqual([ - expect.objectContaining({ - authorization: undefined, - certificateFingerprint: undefined, - path: 'mtls.auth.openai.com:443', - }), - expect.objectContaining({ - authorization: undefined, - certificateFingerprint: undefined, - path: 'mtls.api.openai.com:443', - }), - ]); - } finally { - await credential?.close(); - await closeObservedServers(issuer, api, ...(proxy ? [proxy] : [])); - } - }); + await expect(client.models.list()).resolves.toMatchObject({ data: [] }); + + expect(exchangedBodies).toHaveLength(1); + expect(JSON.parse(exchangedBodies[0] ?? '')).toEqual({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token_type: 'urn:openai:params:oauth:token-type:x509', + identity_provider_id: 'synthetic-identity-provider', + service_account_id: 'synthetic-service-account', + }); + const certificateFingerprint = new X509Certificate(lab.firstClient.certificate).fingerprint256; + expect(issuer.requests).toEqual([ + expect.objectContaining({ + authority: 'mtls.auth.openai.com', + authorization: undefined, + certificateFingerprint, + path: '/oauth/token', + proxyAuthorization: undefined, + serverName: 'mtls.auth.openai.com', + }), + ]); + expect(api.requests).toEqual([ + expect.objectContaining({ + authority: 'mtls.api.openai.com', + authorization: `Bearer ${ACCESS_TOKEN}`, + certificateFingerprint, + path: '/v1/models', + proxyAuthorization: undefined, + serverName: 'mtls.api.openai.com', + }), + ]); + expect(proxy.requests).toEqual([ + expect.objectContaining({ + authorization: undefined, + certificateFingerprint: undefined, + path: 'mtls.auth.openai.com:443', + proxyAuthorization: authorization, + }), + expect.objectContaining({ + authorization: undefined, + certificateFingerprint: undefined, + path: 'mtls.api.openai.com:443', + proxyAuthorization: authorization, + }), + ]); + } finally { + await credential?.close(); + await closeObservedServers(issuer, api, ...(proxy ? [proxy] : [])); + } + }, + ); test.each(['issuer', 'API'] as const)( 'rejects an untrusted $0 certificate before disclosing workload credentials', diff --git a/tests/auth/x509-transport.test.ts b/tests/auth/x509-transport.test.ts index 81ef0cdb8..1c6bd65bc 100644 --- a/tests/auth/x509-transport.test.ts +++ b/tests/auth/x509-transport.test.ts @@ -102,6 +102,41 @@ describe('SDK-owned X.509 credential transport', () => { expect(JSON.stringify(failure)).not.toContain(secret); }); + test('rejects malformed encoded proxy credentials without exposing their contents', () => { + const secret = 'synthetic-private-proxy-username'; + const options = { + ...credentialOptions(), + proxy: { + url: `http://${secret}%E0%A4%A@127.0.0.1:1`, + mode: 'http-connect' as const, + }, + }; + let failure: unknown; + + try { + fromX509(options); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(Error); + expect(String(failure)).toMatch(/proxy.*credential|authentication/iu); + expect(inspect(failure)).not.toContain(secret); + expect(JSON.stringify(failure)).not.toContain(secret); + }); + + test('rejects ambiguous proxy Basic usernames containing an encoded colon', () => { + const options = { + ...credentialOptions(), + proxy: { + url: 'http://synthetic%3Auser:password@127.0.0.1:1', + mode: 'http-connect' as const, + }, + }; + + expect(() => fromX509(options)).toThrow(/proxy.*username|credential/iu); + }); + test('rejects inherited certificate material without invoking its accessor', () => { const { privateKey, ...ownOptions } = credentialOptions(); const getter = vi.fn(() => privateKey); From cb577612126fb2c2dd170311437b5a435911f7b7 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 17:15:55 +0000 Subject: [PATCH 03/11] fix(auth): isolate query defaults across X.509 credential transitions --- src/internal/auth/x509-credential-options.ts | 1 + tests/auth/x509-transport-conformance.test.ts | 64 +++++++++++++------ .../x509-workload-request-boundaries.test.ts | 4 ++ 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/internal/auth/x509-credential-options.ts b/src/internal/auth/x509-credential-options.ts index 39bd6281b..6a61e271d 100644 --- a/src/internal/auth/x509-credential-options.ts +++ b/src/internal/auth/x509-credential-options.ts @@ -105,6 +105,7 @@ export function prepareX509ClientClone( delete inherited.organization; delete inherited.project; delete inherited.defaultHeaders; + delete inherited.defaultQuery; delete inherited.fetchOptions; } return nextCredential; diff --git a/tests/auth/x509-transport-conformance.test.ts b/tests/auth/x509-transport-conformance.test.ts index 1b0fd8cc5..61a058f4e 100644 --- a/tests/auth/x509-transport-conformance.test.ts +++ b/tests/auth/x509-transport-conformance.test.ts @@ -267,28 +267,44 @@ describe('real-wire X.509 transport conformance', () => { } }); - test('switches an independently authenticated provider to an owned certificate credential', async () => { - const credential = fromX509({ - certificateChain: lab.firstClient.certificate.toString(), - privateKey: lab.firstClient.privateKey.toString(), - identityProviderId: 'synthetic-identity-provider', - serviceAccountId: 'synthetic-service-account', - }); - - try { - const provider = createProvider({ - configure: () => ({ name: 'synthetic-provider', baseURL: 'https://provider.example/v1' }), + test.each([ + { label: 'without replacement query defaults', defaultQuery: undefined, search: '' }, + { label: 'with explicit replacement query defaults', defaultQuery: { page: '1' }, search: '?page=1' }, + ])( + 'switches an independently authenticated provider to an owned credential $label', + async ({ defaultQuery, search }) => { + const credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', }); - const original = new OpenAI({ provider }); - const clone = original.withOptions({ credential }); - expect(clone.baseURL).toBe('https://mtls.api.openai.com/v1'); - expect(clone.apiKey).toBeNull(); - expect(original.baseURL).toBe('https://provider.example/v1'); - } finally { - await credential.close(); - } - }); + try { + const provider = createProvider({ + configure: () => ({ name: 'synthetic-provider', baseURL: 'https://provider.example/v1' }), + }); + const original = new OpenAI({ + provider, + defaultQuery: { api_key: 'synthetic-provider-private-api-key' }, + }); + const clone = original.withOptions({ + credential, + ...(defaultQuery === undefined ? {} : { defaultQuery }), + }); + + expect(clone.baseURL).toBe('https://mtls.api.openai.com/v1'); + expect(clone.apiKey).toBeNull(); + expect(clone.buildURL('/models', null)).toBe(`https://mtls.api.openai.com/v1/models${search}`); + expect(original.baseURL).toBe('https://provider.example/v1'); + expect(original.buildURL('/models', null)).toBe( + 'https://provider.example/v1/models?api_key=synthetic-provider-private-api-key', + ); + } finally { + await credential.close(); + } + }, + ); test.each([ { label: 'no proxy credentials', username: '', password: '', authorization: undefined }, @@ -372,7 +388,13 @@ describe('real-wire X.509 transport conformance', () => { proxy: { url: proxyURL, mode: 'http-connect' }, }); trustRoots[0] = lab.proxyCertificateAuthority.toString(); - const client = new OpenAI({ apiKey: null, credential, maxRetries: 0 }); + const provider = createProvider({ + configure: () => ({ name: 'synthetic-provider', baseURL: 'https://provider.example/v1' }), + }); + const client = new OpenAI({ + provider, + defaultQuery: { api_key: 'synthetic-provider-private-api-key' }, + }).withOptions({ credential, maxRetries: 0 }); await expect(client.models.list()).resolves.toMatchObject({ data: [] }); diff --git a/tests/lib/x509-workload-request-boundaries.test.ts b/tests/lib/x509-workload-request-boundaries.test.ts index f788ad8b7..ed4cc1a11 100644 --- a/tests/lib/x509-workload-request-boundaries.test.ts +++ b/tests/lib/x509-workload-request-boundaries.test.ts @@ -133,12 +133,14 @@ describe('X.509 request ownership boundaries', () => { vi.stubEnv('OPENAI_PROJECT_ID', 'synthetic-ambient-project'); vi.stubEnv('OPENAI_CUSTOM_HEADERS', 'OpenAI-Organization: synthetic-ambient-header-organization'); let dispatchedHeaders: Headers | undefined; + let dispatchedURL: URL | undefined; const send = vi .spyOn(transportCapability, 'sendX509Request') .mockImplementation(async (_transport, url, request) => { if (url.origin === 'https://mtls.auth.openai.com') { return Response.json(tokenResponse); } + dispatchedURL = url; dispatchedHeaders = new Headers(request.headers); return Response.json({ data: [] }); }); @@ -150,6 +152,7 @@ describe('X.509 request ownership boundaries', () => { organization: 'synthetic-original-organization', project: 'synthetic-original-project', defaultHeaders: { 'X-Synthetic-Original': 'must-not-cross' }, + defaultQuery: { api_key: 'synthetic-original-private-api-key' }, }); const clone = original.withOptions({ credential }); @@ -162,6 +165,7 @@ describe('X.509 request ownership boundaries', () => { expect(clone.project).toBeNull(); expect(dispatchedHeaders?.get('X-Synthetic-Original')).toBeNull(); expect(dispatchedHeaders?.get('OpenAI-Organization')).toBeNull(); + expect(dispatchedURL?.href).toBe('https://mtls.api.openai.com/v1/models'); expect(send).toHaveBeenCalledTimes(2); } finally { vi.unstubAllEnvs(); From 67824fcddf904a0f19a5d9f0c29df4a1fd806f94 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 18:10:26 +0000 Subject: [PATCH 04/11] fix(auth): harden X.509 credential and provider boundaries --- .github/workflows/ci.yml | 24 +++ README.md | 10 +- examples/mtls/README.md | 4 +- examples/mtls/base-url.mjs | 22 +++ examples/mtls/bun.mjs | 4 +- examples/mtls/deno.mjs | 20 +- examples/mtls/node.mjs | 24 +-- package.json | 3 + scripts/test-packed-package.ts | 106 ++++------ src/auth/workload-identity-auth.ts | 8 +- src/azure.ts | 5 +- src/bedrock.ts | 5 +- src/client.ts | 21 +- src/internal/auth/x509-api-origin.ts | 27 +++ src/internal/auth/x509-credential-options.ts | 5 + .../auth/x509-transport-capability.ts | 17 +- .../auth/x509-workload-identity-auth.ts | 36 +--- src/internal/utils/log.ts | 78 ++++++-- tests/auth/workload-identity-auth.test.ts | 32 +++ tests/auth/x509-transport-conformance.test.ts | 98 ++++++++- tests/auth/x509-transport.test.ts | 55 +++++- tests/lib/provider.test.ts | 186 ++++++++++++++++-- .../x509-workload-credential-config.test.ts | 16 +- tests/lib/x509-workload-example.test.ts | 30 ++- tests/lib/x509-workload-identity.test.ts | 61 +++++- tests/utils/x509-test-lab.ts | 15 +- 26 files changed, 733 insertions(+), 179 deletions(-) create mode 100644 examples/mtls/base-url.mjs create mode 100644 src/internal/auth/x509-api-origin.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3ed34d19..0c9fcc648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,30 @@ jobs: ./scripts/build node --experimental-strip-types scripts/test-packed-package.ts + - name: Set up exact minimum supported Node.js runtime + if: matrix.node-version == 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22.0.0' + + - name: Verify first-class X.509 authentication on the exact Node.js floor + if: matrix.node-version == 22 + run: | + floor_consumer="${RUNNER_TEMP}/openai-node-minimum-runtime" + npm install --ignore-scripts --no-audit --no-fund --prefix "$floor_consumer" "$GITHUB_WORKSPACE/dist" 'undici@^7' + cd "$floor_consumer" + node --input-type=module --eval ' + import OpenAI from "openai"; + import { Agent, ProxyAgent } from "undici"; + import { createX509Transport, fromX509, workloadIdentity } from "openai/auth/x509-transport"; + if (process.version !== "v22.0.0" || workloadIdentity.fromX509 !== fromX509) throw new Error("Invalid minimum-runtime X.509 exports"); + for (const [proxy, dispatcher] of [["direct", new Agent()], ["http-connect", new ProxyAgent({ uri: "http://127.0.0.1:1" })], ["https-connect", new ProxyAgent({ uri: "https://127.0.0.1:1" })]]) { + const x509Transport = createX509Transport({ runtime: "node", dispatcher, certificateIdentity: "static", proxy }); + new OpenAI({ apiKey: null, workloadIdentity: { type: "x509", identityProviderId: "synthetic-provider", serviceAccountId: "synthetic-account" }, x509Transport }); + await dispatcher.close(); + } + ' + test_matrix: name: ${{ (github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork || github.event.action == 'ready_for_review') && 'test matrix' || 'test matrix (not run)' }} if: ${{ always() && (github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork || github.event.action == 'ready_for_review') }} diff --git a/README.md b/README.md index beb7577e4..9e80f3c36 100644 --- a/README.md +++ b/README.md @@ -215,12 +215,12 @@ const credential = workloadIdentity.fromX509({ serviceAccountId: process.env['OPENAI_X509_SERVICE_ACCOUNT_ID']!, }); -const client = new OpenAI({ - credential, - project: process.env['OPENAI_X509_PROJECT_ID'] ?? null, -}); - try { + const client = new OpenAI({ + credential, + project: process.env['OPENAI_X509_PROJECT_ID'] ?? null, + }); + console.log((await client.models.list()).data.length); } finally { await credential.close(); diff --git a/examples/mtls/README.md b/examples/mtls/README.md index 0a7f4aecf..310c84104 100644 --- a/examples/mtls/README.md +++ b/examples/mtls/README.md @@ -19,7 +19,7 @@ Each example requests `models.list()` and prints the number of returned models. Install Undici alongside the SDK: ```sh -npm install openai undici +npm install openai "undici@^7" node node.mjs ``` @@ -77,7 +77,7 @@ node examples/mtls/x509-workload-identity.mjs Set `OPENAI_X509_CLIENT_KEY_PASSPHRASE` when the PEM private key is encrypted. Existing local fixtures can instead provide certificate and key paths through `OPENAI_MTLS_CERT_CHAIN` and `OPENAI_MTLS_KEY`, identity selectors through `OPENAI_IDENTITY_PROVIDER_ID` and `OPENAI_SERVICE_ACCOUNT_ID`, and an optional tenant through `OPENAI_X509_PROJECT_ID`. The example ignores ambient API keys, admin keys, base URLs, organizations, and ordinary API-key projects so only the selected X.509 identity and tenant determine the request. Keep private-key files readable only by their owner, use managed secret injection where available, and never log PEM contents, passphrases, issued bearer tokens, or proxy credentials. -Proxying is always explicit: set `OPENAI_X509_PROXY_MODE=http_connect` or `OPENAI_X509_PROXY_MODE=https_connect` together with a matching `HTTPS_PROXY` URL. The default `direct` mode ignores ambient proxy variables. The SDK requires verified target and proxy TLS, validates the proxy protocol, and configures the workload certificate only for target TLS. The example closes its SDK-owned credential after the request. +Proxying is always explicit: set `OPENAI_X509_PROXY_MODE=http_connect` or `OPENAI_X509_PROXY_MODE=https_connect` together with a matching `HTTPS_PROXY` URL. Prefer `https_connect` when the proxy URL includes credentials: `http_connect` sends proxy authentication unencrypted to the explicitly selected proxy and must only be used on a trusted network. The default `direct` mode ignores ambient proxy variables. The SDK requires verified target and proxy TLS, validates the proxy protocol, and configures the workload certificate only for target TLS. The example closes its SDK-owned credential after the request. From a repository checkout, the following command builds the SDK first and then runs the same explicit live-service check: diff --git a/examples/mtls/base-url.mjs b/examples/mtls/base-url.mjs new file mode 100644 index 000000000..6678e6bc7 --- /dev/null +++ b/examples/mtls/base-url.mjs @@ -0,0 +1,22 @@ +/** Accepts only the documented OpenAI certificate-bearing API origins. */ +export function mtlsBaseURL(configured) { + let url; + try { + url = new URL(configured ?? 'https://mtls.api.openai.com/v1'); + } catch { + throw new Error('OPENAI_BASE_URL must be a documented OpenAI HTTPS mTLS endpoint.'); + } + + if ( + (url.origin !== 'https://mtls.api.openai.com' && url.origin !== 'https://mtls-eu.api.openai.com') || + (url.pathname !== '/v1' && url.pathname !== '/v1/') || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error('OPENAI_BASE_URL must be a documented OpenAI HTTPS mTLS endpoint.'); + } + + return `${url.origin}/v1`; +} diff --git a/examples/mtls/bun.mjs b/examples/mtls/bun.mjs index 6375c4459..11afad417 100644 --- a/examples/mtls/bun.mjs +++ b/examples/mtls/bun.mjs @@ -4,13 +4,15 @@ // stays in Bun's native fetch so the SDK can use its existing transport hooks. import OpenAI from 'openai'; +import { mtlsBaseURL } from './base-url.mjs'; +const baseURL = mtlsBaseURL(process.env['OPENAI_BASE_URL']); const cert = Bun.file(requiredEnv('OPENAI_MTLS_CERT_PATH')); const key = Bun.file(requiredEnv('OPENAI_MTLS_KEY_PATH')); const client = new OpenAI({ apiKey: requiredEnv('OPENAI_API_KEY'), - baseURL: process.env['OPENAI_BASE_URL'] ?? 'https://mtls.api.openai.com/v1', + baseURL, fetch: (input, init) => fetch(input, { ...init, diff --git a/examples/mtls/deno.mjs b/examples/mtls/deno.mjs index 6c4db5f61..98af3b0a1 100644 --- a/examples/mtls/deno.mjs +++ b/examples/mtls/deno.mjs @@ -4,21 +4,23 @@ // stays in Deno's HTTP client so the SDK can use its existing transport hooks. import OpenAI from 'npm:openai'; +import { mtlsBaseURL } from './base-url.mjs'; +const baseURL = mtlsBaseURL(Deno.env.get('OPENAI_BASE_URL')); const cert = await Deno.readTextFile(requiredEnv('OPENAI_MTLS_CERT_PATH')); const key = await Deno.readTextFile(requiredEnv('OPENAI_MTLS_KEY_PATH')); const httpClient = Deno.createHttpClient(clientCertificateOptions(cert, key)); -const client = new OpenAI({ - apiKey: requiredEnv('OPENAI_API_KEY'), - baseURL: Deno.env.get('OPENAI_BASE_URL') ?? 'https://mtls.api.openai.com/v1', - fetch: (input, init) => fetch(input, { ...init, client: httpClient }), - fetchOptions: { - redirect: 'manual', - }, -}); - try { + const client = new OpenAI({ + apiKey: requiredEnv('OPENAI_API_KEY'), + baseURL, + fetch: (input, init) => fetch(input, { ...init, client: httpClient }), + fetchOptions: { + redirect: 'manual', + }, + }); + const models = await client.models.list(); console.log('mTLS request succeeded; received ' + models.data.length + ' models.'); } finally { diff --git a/examples/mtls/node.mjs b/examples/mtls/node.mjs index 7fa3e6933..d0bd23097 100644 --- a/examples/mtls/node.mjs +++ b/examples/mtls/node.mjs @@ -6,7 +6,9 @@ import { readFile } from 'node:fs/promises'; import OpenAI from 'openai'; import { Agent, fetch as undiciFetch } from 'undici'; +import { mtlsBaseURL } from './base-url.mjs'; +const baseURL = mtlsBaseURL(process.env['OPENAI_BASE_URL']); const cert = await readFile(requiredEnv('OPENAI_MTLS_CERT_PATH')); const key = await readFile(requiredEnv('OPENAI_MTLS_KEY_PATH')); const passphrase = process.env['OPENAI_MTLS_KEY_PASSPHRASE']; @@ -15,21 +17,21 @@ const dispatcher = new Agent({ connect: { cert, key, - ...(passphrase ? { passphrase } : {}), - }, -}); - -const client = new OpenAI({ - apiKey: requiredEnv('OPENAI_API_KEY'), - baseURL: process.env['OPENAI_BASE_URL'] ?? 'https://mtls.api.openai.com/v1', - fetch: undiciFetch, - fetchOptions: { - dispatcher, - redirect: 'manual', + ...(passphrase === undefined ? {} : { passphrase }), }, }); try { + const client = new OpenAI({ + apiKey: requiredEnv('OPENAI_API_KEY'), + baseURL, + fetch: undiciFetch, + fetchOptions: { + dispatcher, + redirect: 'manual', + }, + }); + const models = await client.models.list(); console.log('mTLS request succeeded; received ' + models.data.length + ' models.'); } finally { diff --git a/package.json b/package.json index daf95904d..ec98ecbb6 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,9 @@ "./internal/auth/x509-credential-options": null, "./internal/auth/x509-credential-options.js": null, "./internal/auth/x509-credential-options.mjs": null, + "./internal/auth/x509-api-origin": null, + "./internal/auth/x509-api-origin.js": null, + "./internal/auth/x509-api-origin.mjs": null, "./internal/auth/x509-transport-state": null, "./internal/auth/x509-transport-state.cjs": null, "./internal/auth/x509-transport-state-browser": null, diff --git a/scripts/test-packed-package.ts b/scripts/test-packed-package.ts index 227352cbc..639cef89a 100644 --- a/scripts/test-packed-package.ts +++ b/scripts/test-packed-package.ts @@ -241,6 +241,9 @@ const packedPackagePath = require('node:path'); 'openai/internal/auth/x509-credential-options', 'openai/internal/auth/x509-credential-options.js', 'openai/internal/auth/x509-credential-options.mjs', + 'openai/internal/auth/x509-api-origin', + 'openai/internal/auth/x509-api-origin.js', + 'openai/internal/auth/x509-api-origin.mjs', 'openai/internal/auth/x509-transport-state', 'openai/internal/auth/x509-transport-state.cjs', 'openai/internal/auth/x509-transport-state-browser', @@ -266,66 +269,30 @@ const packedPackagePath = require('node:path'); const supportedTransports = 'assert.doesNotThrow(direct); assert.doesNotThrow(httpConnect); assert.doesNotThrow(httpsConnect);'; - for (const [undiciVersion, forwardsProxyRequests, transportAssertions] of [ - ['5.1.1', false, unsupportedDispatcher], - ['5.2.0', true, unsupportedProxy], - ['5.5.0', true, unsupportedProxy], - ['5.5.1', false, supportedTransports], - ['6.29.0', false, supportedTransports], - ['7.0.0', false, supportedTransports], + for (const [undiciVersion, transportAssertions] of [ + ['5.1.1', unsupportedDispatcher], + ['5.2.0', unsupportedProxy], + ['5.5.0', unsupportedProxy], + ['5.5.1', supportedTransports], + ['6.28.0', supportedTransports], + ['7.0.0', supportedTransports], ] as const) { - const undiciFixture = path.join(temporaryDirectory, `undici-${undiciVersion}`); const consumer = path.join(temporaryDirectory, `legacy-undici-${undiciVersion}`); - fs.mkdirSync(undiciFixture); fs.mkdirSync(consumer); - fs.writeFileSync( - path.join(undiciFixture, 'package.json'), - JSON.stringify({ name: 'undici', version: undiciVersion, main: 'index.js' }), - ); - fs.writeFileSync( - path.join(undiciFixture, 'index.js'), - [ - `const undici = require(${JSON.stringify(path.join(root, 'node_modules/undici'))});`, - 'exports.Agent = undici.Agent;', - forwardsProxyRequests - ? [ - 'exports.ProxyAgent = class ForwardingProxyAgent extends undici.ProxyAgent {', - ' #proxyOrigin;', - ' constructor(options) {', - ' super(options);', - ' this.#proxyOrigin = new URL(options.uri).origin;', - ' }', - ' dispatch(options, handler) {', - ' return super.dispatch({', - ' ...options,', - ' origin: this.#proxyOrigin,', - ' path: options.origin + options.path,', - ' }, handler);', - ' }', - '};', - ].join('\n') - : 'exports.ProxyAgent = undici.ProxyAgent;', - 'exports.Request = undici.Request;', - undiciVersion === '5.1.1' - ? [ - 'exports.fetch = async function fetch(resource) {', - ' const options = Object.create(arguments[1] ?? null);', - " Object.defineProperty(options, 'dispatcher', { value: undici.getGlobalDispatcher() });", - ' return undici.fetch(resource, options);', - '};', - ].join('\n') - : 'exports.fetch = undici.fetch;', - ].join('\n'), - ); - const packedUndici = run( - 'npm', - ['pack', '--silent', '--cache', npmCache, '--pack-destination', temporaryDirectory], - { cwd: undiciFixture }, - ) + const packedUndici = run('npm', [ + 'pack', + '--silent', + '--ignore-scripts', + '--cache', + npmCache, + '--pack-destination', + temporaryDirectory, + `undici@${undiciVersion}`, + ]) .trim() .split(/\r?\n/) .pop(); - assert(packedUndici, `npm pack did not report the Undici ${undiciVersion} fixture`); + assert(packedUndici, `npm pack did not report the genuine Undici ${undiciVersion} release`); fs.writeFileSync( path.join(consumer, 'package.json'), JSON.stringify({ name: `legacy-undici-${undiciVersion}-consumer`, private: true }), @@ -376,11 +343,6 @@ const packedPackagePath = require('node:path'); 'const dispatcher = new Agent();', "const proxyDispatcher = new ProxyAgent({ uri: 'http://127.0.0.1:1' });", "const secureProxyDispatcher = new ProxyAgent({ uri: 'https://127.0.0.1:1' });", - ...(undiciVersion === '5.5.1' - ? [ - "for (const proxy of [proxyDispatcher, secureProxyDispatcher]) { const state = Object.getOwnPropertySymbols(proxy).find((symbol) => symbol.description === 'proxy agent options'); assert(state); proxy[state] = new URL(proxy[state].uri); }", - ] - : []), "const direct = () => createX509Transport({ runtime: 'node', dispatcher, certificateIdentity: 'static', proxy: 'direct' });", "const httpConnect = () => createX509Transport({ runtime: 'node', dispatcher: proxyDispatcher, certificateIdentity: 'static', proxy: 'http-connect' });", "const httpsConnect = () => createX509Transport({ runtime: 'node', dispatcher: secureProxyDispatcher, certificateIdentity: 'static', proxy: 'https-connect' });", @@ -519,29 +481,45 @@ const packedPackagePath = require('node:path'); "import OpenAI from 'openai'; new OpenAI({ apiKey: 'synthetic-browser-api-key', dangerouslyAllowBrowser: true });", ]); fs.symlinkSync(path.join(root, 'node_modules/undici'), optionalUndici, 'dir'); + const certificateFixture = JSON.parse( + run( + process.execPath, + [ + '-r', + path.join(root, 'node_modules/ts-node/register/transpile-only'), + '-e', + [ + `const { createX509TestLab } = require(${JSON.stringify(path.join(root, 'tests/utils/x509-test-lab.ts'))});`, + 'const { firstClient } = createX509TestLab();', + 'process.stdout.write(JSON.stringify({ certificateChain: firstClient.certificate.toString(), privateKey: firstClient.privateKey.toString() }));', + ].join(' '), + ], + { cwd: root }, + ), + ) as { certificateChain: string; privateKey: string }; for (const [inputType, consumer] of [ [ 'commonjs', - "const OpenAI = require('openai'); const { Agent } = require('undici'); const { createX509Transport } = require('openai/auth/x509-transport');", + "const OpenAI = require('openai'); const { Agent } = require('undici'); const { createX509Transport, fromX509, workloadIdentity } = require('openai/auth/x509-transport');", ], [ 'module', - "import OpenAI from 'openai'; import { Agent } from 'undici'; import { createX509Transport } from 'openai/auth/x509-transport';", + "import OpenAI from 'openai'; import { Agent } from 'undici'; import { createX509Transport, fromX509, workloadIdentity } 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');", + "import OpenAI from 'openai'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const { Agent } = require('undici'); const { createX509Transport, fromX509, workloadIdentity } = 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');", + "import { createRequire } from 'node:module'; import { Agent } from 'undici'; import { createX509Transport, fromX509, workloadIdentity } 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'); new OpenAI({ apiKey: null, workloadIdentity: { type: 'x509', identityProviderId: 'synthetic-provider', serviceAccountId: 'synthetic-account' }, x509Transport: transport }); dispatcher.close();`, + `${consumer} if (workloadIdentity.fromX509 !== fromX509) throw new Error('First-class X.509 factory is unavailable'); const credential = fromX509({ ...${JSON.stringify(certificateFixture)}, identityProviderId: 'synthetic-provider', serviceAccountId: 'synthetic-account' }); new OpenAI({ credential }); credential.close(); 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/auth/workload-identity-auth.ts b/src/auth/workload-identity-auth.ts index 09ce31148..45d1f61d5 100644 --- a/src/auth/workload-identity-auth.ts +++ b/src/auth/workload-identity-auth.ts @@ -45,7 +45,13 @@ export class WorkloadIdentityAuth { * @param fetch Optional fetch implementation for calls to the OpenAI token endpoint. */ constructor(config: WorkloadIdentity, fetch?: Fetch) { - this.config = config; + this.config = { + ...config, + provider: { + tokenType: config.provider.tokenType, + getToken: config.provider.getToken.bind(config.provider), + }, + }; this.fetch = fetch ?? Shims.getDefaultFetch(); } diff --git a/src/azure.ts b/src/azure.ts index 1720c25f0..c60271712 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -13,7 +13,7 @@ import { assertNoDataResidency } from './internal/data-residency'; /** API Client for interfacing with the Azure OpenAI API. */ export interface AzureClientOptions extends Omit< ClientOptions, - 'provider' | 'dataResidency' | 'workloadIdentity' | 'x509Transport' + 'provider' | 'dataResidency' | 'credential' | 'workloadIdentity' | 'x509Transport' > { /** AzureOpenAI does not support third-party provider configuration. */ provider?: never; @@ -21,6 +21,9 @@ export interface AzureClientOptions extends Omit< /** OpenAI data residency cannot be combined with Azure routing. */ dataResidency?: never; + /** Azure cannot receive an SDK-owned OpenAI X.509 certificate credential. */ + credential?: never; + /** Azure cannot receive OpenAI X.509 workload-identity certificate transports. */ x509Transport?: never; diff --git a/src/bedrock.ts b/src/bedrock.ts index 576531b54..7f5282847 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' | 'x509Transport' | 'dataResidency' + 'apiKey' | 'adminAPIKey' | 'baseURL' | 'credential' | 'workloadIdentity' | 'x509Transport' | 'dataResidency' > { /** * Bedrock bearer token used for authentication. @@ -46,6 +46,9 @@ export interface BedrockClientOptions extends Omit< /** OpenAI data residency cannot be combined with Bedrock routing. */ dataResidency?: never; + /** Bedrock cannot receive an SDK-owned OpenAI X.509 certificate credential. */ + credential?: never; + /** * BedrockOpenAI only supports Bedrock bearer token authentication. */ diff --git a/src/client.ts b/src/client.ts index ab22d4cc6..3fae40a03 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,10 +19,9 @@ import * as Errors from './core/error'; import * as Pagination from './core/pagination'; import type { WorkloadIdentity, X509Credential, X509WorkloadIdentity } from './auth/types'; import { WorkloadIdentityAuth } from './auth/workload-identity-auth'; +import { X509_API_BASE_URL, assertX509APIOrigin } from './internal/auth/x509-api-origin'; import { - X509_API_BASE_URL, X509WorkloadIdentityAuth, - assertX509APIOrigin, assertX509RequestOptions, isX509WorkloadIdentity, snapshotX509RequestOptions, @@ -262,6 +261,7 @@ import { formatRequestDetails, loggerFor, parseLogLevel, + redactURL, } from './internal/utils/log'; import { isEmptyObj } from './internal/utils/values'; @@ -691,6 +691,9 @@ export class OpenAI { delete inheritedOptions.organization; delete inheritedOptions.project; delete inheritedOptions.defaultHeaders; + delete inheritedOptions.defaultQuery; + delete inheritedOptions.fetchOptions; + delete inheritedOptions.fetch; } } @@ -707,6 +710,18 @@ export class OpenAI { !provider, }; const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)(clientOptions); + if (provider && new URL(client.baseURL).origin !== new URL(this.baseURL).origin) { + Object.assign(client._options, { + defaultHeaders: options.defaultHeaders, + defaultQuery: options.defaultQuery, + fetchOptions: options.fetchOptions, + fetch: options.fetch, + }); + client.fetchOptions = options.fetchOptions; + client.fetch = options.fetch ?? Shims.getDefaultFetch(); + client.organization = options.organization ?? null; + client.project = options.project ?? null; + } if ( this.#x509Authentication && client.#x509Authentication && @@ -1354,7 +1369,7 @@ export class OpenAI { .filter(([name]) => name === 'x-request-id') .map(([name, value]) => ', ' + name + ': ' + JSON.stringify(value)) .join(''); - const responseInfo = `[${requestLogID}${retryLogStr}${specialHeaders}] ${req.method} ${url} ${ + const responseInfo = `[${requestLogID}${retryLogStr}${specialHeaders}] ${req.method} ${redactURL(url)} ${ response.ok ? 'succeeded' : 'failed' } with status ${response.status} in ${headersTime - startTime}ms`; diff --git a/src/internal/auth/x509-api-origin.ts b/src/internal/auth/x509-api-origin.ts new file mode 100644 index 000000000..b705ac432 --- /dev/null +++ b/src/internal/auth/x509-api-origin.ts @@ -0,0 +1,27 @@ +import { OpenAIError } from '../../core/error'; +import { isSensitiveQueryParameter } from '../utils/log'; + +/** Sole API authority approved for OpenAI X.509 workload-identity federation. */ +export const X509_API_BASE_URL = 'https://mtls.api.openai.com/v1'; + +/** Validates the enrolled API authority and rejects credential-bearing query parameters. */ +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 !== 'https://mtls.api.openai.com' || target.username || target.password) { + throw new OpenAIError('X.509 workload identity requires the approved global mTLS API origin.'); + } + for (const name of target.searchParams.keys()) { + if (isSensitiveQueryParameter(name)) { + throw new OpenAIError( + 'X.509 workload identity cannot send conflicting query authentication credentials.', + ); + } + } + return target; +} diff --git a/src/internal/auth/x509-credential-options.ts b/src/internal/auth/x509-credential-options.ts index 6a61e271d..19d04d596 100644 --- a/src/internal/auth/x509-credential-options.ts +++ b/src/internal/auth/x509-credential-options.ts @@ -83,6 +83,11 @@ export function prepareX509ClientClone( if (currentlyX509 !== nextX509) { delete inherited.fetch; delete inherited.baseURL; + delete inherited.organization; + delete inherited.project; + delete inherited.defaultHeaders; + delete inherited.defaultQuery; + delete inherited.fetchOptions; if (nextX509) { inherited.apiKey = null; } else { diff --git a/src/internal/auth/x509-transport-capability.ts b/src/internal/auth/x509-transport-capability.ts index 2b7aee8f9..51867fec3 100644 --- a/src/internal/auth/x509-transport-capability.ts +++ b/src/internal/auth/x509-transport-capability.ts @@ -103,15 +103,30 @@ function assertVerifiedTLS(value: unknown): void { throw new Error('X.509 transport requires inspectable TLS server-verification settings.'); } const verification = Object.getOwnPropertyDescriptor(value, 'rejectUnauthorized'); + const hostnameVerification = Object.getOwnPropertyDescriptor(value, 'checkServerIdentity'); if ( (verification && (!('value' in verification) || verification.value === false)) || + (hostnameVerification && + (!('value' in hostnameVerification) || hostnameVerification.value !== undefined)) || (process.env['NODE_TLS_REJECT_UNAUTHORIZED'] === '0' && verification?.value !== true) ) { - throw new Error('X.509 transport requires TLS server certificate verification.'); + throw new Error('X.509 transport requires TLS server certificate and hostname verification.'); + } +} + +function assertDispatcherIntegrity(dispatcher: Agent | ProxyAgent): void { + const trustedPrototype = dispatcher instanceof ProxyAgent ? ProxyAgent.prototype : Agent.prototype; + if ( + Object.getPrototypeOf(dispatcher) !== trustedPrototype || + Object.getOwnPropertyDescriptor(dispatcher, 'dispatch') || + Object.getOwnPropertySymbols(dispatcher).some((symbol) => symbol.description === 'dispatch') + ) { + throw new Error('X.509 transport requires an unmodified, trusted Undici dispatcher.'); } } function assertDispatcherTrust(dispatcher: Agent | ProxyAgent, proxy: X509ProxyMode): void { + assertDispatcherIntegrity(dispatcher); if (dispatcher instanceof ProxyAgent) { const configuration = undiciState(dispatcher, 'proxy agent options'); if (!configuration || typeof configuration !== 'object') { diff --git a/src/internal/auth/x509-workload-identity-auth.ts b/src/internal/auth/x509-workload-identity-auth.ts index 87e95188e..f1dea6448 100644 --- a/src/internal/auth/x509-workload-identity-auth.ts +++ b/src/internal/auth/x509-workload-identity-auth.ts @@ -6,7 +6,9 @@ import type { HeadersLike, NullableHeaders } from '../headers'; import type { FinalRequestOptions } from '../request-options'; import { CancelReadableStream } from '../shims'; import type { MergedRequestInit } from '../types'; +import { isSensitiveHeader } from '../utils/log'; import { hasOwn } from '../utils/values'; +import { assertX509APIOrigin } from './x509-api-origin'; import { resolveX509Transport } from './x509-transport-registry'; import { isApprovedX509Client, @@ -21,10 +23,6 @@ import type { 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); @@ -39,12 +37,7 @@ const userAbortError = (signal: AbortSignal): APIUserAbortError => { 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' - ) { + if ((canonical !== 'authorization' && isSensitiveHeader(canonical)) || canonical === 'host') { throw new OpenAIError('X.509 workload identity cannot send conflicting authentication credentials.'); } } @@ -180,21 +173,6 @@ export function assertX509WebSocketSupported(client: unknown): void { } } -/** 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) { @@ -857,13 +835,7 @@ export class X509WorkloadIdentityAuth { 'X.509 workload identity cannot override its enrolled organization or project.', ); } - if ( - canonical === 'authorization' || - canonical === 'api-key' || - canonical === 'x-api-key' || - canonical === 'proxy-authorization' || - canonical === 'host' - ) { + if (isSensitiveHeader(canonical) || canonical === 'host') { throw new OpenAIError( 'X.509 workload identity cannot use caller-supplied authentication credentials.', ); diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts index 7136fafdd..2bde04c5b 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -82,6 +82,61 @@ export function loggerFor(client: OpenAI): Logger { return levelLogger; } +const sensitiveQueryNames = new Set([ + 'apikey', + 'accesstoken', + 'refreshtoken', + 'sessiontoken', + 'sessionid', + 'idtoken', + 'authtoken', + 'authorization', + 'token', + 'password', + 'clientsecret', + 'xamzsecuritytoken', + 'xamzsignature', + 'xamzcredential', +]); + +/** Recognizes credential-bearing query names across ordinary and provider authentication. */ +export function isSensitiveQueryParameter(name: string): boolean { + return sensitiveQueryNames.has(name.toLowerCase().replace(/[-_]/gu, '')); +} + +const sensitiveHeaderNames = new Set([ + 'authorization', + 'proxy-authorization', + 'api-key', + 'x-api-key', + 'x-amz-security-token', + 'cookie', + 'set-cookie', + 'x-session-token', + 'x-session-id', + 'x-auth-token', + 'x-id-token', +]); + +/** Recognizes credential-bearing request headers across provider and workload authentication. */ +export function isSensitiveHeader(name: string): boolean { + return sensitiveHeaderNames.has(name.toLowerCase().replace(/_/gu, '-')); +} + +/** Removes credential-valued query parameters before a request URL reaches any logger. */ +export function redactURL(value: string): string { + const url = new URL(value); + url.username = ''; + url.password = ''; + url.hash = ''; + for (const name of url.searchParams.keys()) { + if (isSensitiveQueryParameter(name)) { + url.searchParams.set(name, '***'); + } + } + return url.href; +} + export const formatRequestDetails = (details: { options?: RequestOptions | undefined; headers?: Headers | Record | undefined; @@ -97,21 +152,22 @@ export const formatRequestDetails = (details: { if (details.options) { details.options = { ...details.options }; delete details.options['headers']; // redundant + leaks internals + if (details.options.query) { + details.options.query = Object.fromEntries( + Object.entries(details.options.query).map(([name, value]) => [ + name, + isSensitiveQueryParameter(name) ? '***' : value, + ]), + ); + } + } + if (details.url) { + details.url = redactURL(details.url); } if (details.headers) { details.headers = Object.fromEntries( (details.headers instanceof Headers ? [...details.headers] : Object.entries(details.headers)).map( - ([name, value]) => [ - name, - name.toLowerCase() === 'authorization' || - name.toLowerCase() === 'api-key' || - name.toLowerCase() === 'x-api-key' || - name.toLowerCase() === 'x-amz-security-token' || - name.toLowerCase() === 'cookie' || - name.toLowerCase() === 'set-cookie' - ? '***' - : value, - ], + ([name, value]) => [name, isSensitiveHeader(name) ? '***' : value], ), ); } diff --git a/tests/auth/workload-identity-auth.test.ts b/tests/auth/workload-identity-auth.test.ts index 8e87efb0c..1514b2690 100644 --- a/tests/auth/workload-identity-auth.test.ts +++ b/tests/auth/workload-identity-auth.test.ts @@ -102,6 +102,38 @@ describe('WorkloadIdentityAuth', () => { expect(fetchCallCount).toBe(1); }); + test('binds cached exchanges to an immutable workload identity and provider snapshot', async () => { + const originalProvider = vi.fn(async () => 'synthetic-original-subject-token'); + const replacementProvider = vi.fn(async () => 'synthetic-replacement-subject-token'); + const observedBodies: Record[] = []; + const config: WorkloadIdentity = { + identityProviderId: 'synthetic-original-identity-provider', + serviceAccountId: 'synthetic-original-service-account', + provider: { tokenType: 'jwt', getToken: originalProvider }, + }; + const auth = new WorkloadIdentityAuth(config, async (_url, init) => { + observedBodies.push(JSON.parse(String(init?.body)) as Record); + return tokenExchangeResponse('synthetic-original-access-token', 3600); + }); + + config.identityProviderId = 'synthetic-replacement-identity-provider'; + config.serviceAccountId = 'synthetic-replacement-service-account'; + config.provider.tokenType = 'id'; + config.provider.getToken = replacementProvider; + + await expect(auth.getToken()).resolves.toBe('synthetic-original-access-token'); + expect(originalProvider).toHaveBeenCalledOnce(); + expect(replacementProvider).not.toHaveBeenCalled(); + expect(observedBodies).toEqual([ + expect.objectContaining({ + identity_provider_id: 'synthetic-original-identity-provider', + service_account_id: 'synthetic-original-service-account', + subject_token: 'synthetic-original-subject-token', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }), + ]); + }); + test('refreshes expired tokens', async () => { let providerCallCount = 0; let fetchCallCount = 0; diff --git a/tests/auth/x509-transport-conformance.test.ts b/tests/auth/x509-transport-conformance.test.ts index 61a058f4e..a0064365a 100644 --- a/tests/auth/x509-transport-conformance.test.ts +++ b/tests/auth/x509-transport-conformance.test.ts @@ -127,12 +127,26 @@ describe('real-wire X.509 transport conformance', () => { }); try { - const original = new OpenAI({ credential }); + const original = new OpenAI({ + credential, + defaultHeaders: { 'x-origin-private': 'synthetic-x509-header-secret' }, + defaultQuery: { api_key: 'synthetic-x509-query-secret' }, + }); const clone = original.withOptions(options); expect(clone.baseURL).toBe('https://api.openai.com/v1'); expect(clone.apiKey).toBe(options.apiKey ?? null); expect(clone.adminAPIKey).toBe(options.adminAPIKey ?? null); + expect(clone.buildURL('/models', null)).toBe('https://api.openai.com/v1/models'); + const built = await clone.buildRequest({ + path: '/models', + method: 'get', + __security: { + bearerAuth: options.apiKey !== undefined, + adminAPIKeyAuth: options.adminAPIKey !== undefined, + }, + }); + expect(built.req.headers.has('x-origin-private')).toBe(false); expect(original.baseURL).toBe('https://mtls.api.openai.com/v1'); } finally { await credential.close(); @@ -172,6 +186,80 @@ describe('real-wire X.509 transport conformance', () => { }, ); + test('authenticates the pinned issuer and API through a real SDK-owned HTTPS CONNECT proxy', async () => { + const issuer = createMutualTLSServer( + lab, + (_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + access_token: ACCESS_TOKEN, + token_type: 'Bearer', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + expires_in: 3600, + }), + ); + }, + lab.issuerServer, + ); + const api = createMutualTLSServer( + lab, + (_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ data: [] })); + }, + lab.apiServer, + ); + let proxy: ObservedServer | undefined; + let credential: ReturnType | undefined; + + try { + const [issuerURL, apiURL] = await Promise.all([listenLoopback(issuer), listenLoopback(api)]); + proxy = createConnectProxy( + lab, + true, + lab.proxyServer, + new Map([ + ['mtls.auth.openai.com:443', issuerURL], + ['mtls.api.openai.com:443', apiURL], + ]), + false, + ); + const proxyURL = await listenLoopback(proxy, true, 'localhost'); + proxyURL.username = 'synthetic-proxy-user'; + proxyURL.password = 'synthetic-proxy-secret'; + credential = fromX509({ + certificateChain: lab.firstClient.certificate.toString(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + ca: lab.certificateAuthority.toString(), + proxy: { + url: proxyURL, + mode: 'https-connect', + ca: lab.proxyCertificateAuthority.toString(), + }, + }); + + await expect(new OpenAI({ credential, maxRetries: 0 }).models.list()).resolves.toMatchObject({ + data: [], + }); + + expect(proxy.requests.map(({ path }) => path)).toEqual([ + 'mtls.auth.openai.com:443', + 'mtls.api.openai.com:443', + ]); + expect(proxy.requests.every(({ proxyAuthorization }) => proxyAuthorization?.startsWith('Basic '))).toBe( + true, + ); + expect(issuer.requests[0]?.proxyAuthorization).toBeUndefined(); + expect(api.requests[0]?.proxyAuthorization).toBeUndefined(); + } finally { + await credential?.close(); + await closeObservedServers(issuer, api, ...(proxy ? [proxy] : [])); + } + }); + test('retains owned credential isolation when an explicit undefined credential is inherited', async () => { const credential = fromX509({ certificateChain: lab.firstClient.certificate.toString(), @@ -253,7 +341,10 @@ describe('real-wire X.509 transport conformance', () => { }); try { - const original = new OpenAI({ credential }); + const original = new OpenAI({ + credential, + defaultQuery: { api_key: 'synthetic-x509-origin-private-secret' }, + }); const provider = createProvider({ configure: () => ({ name: 'synthetic-provider', baseURL: 'https://provider.example/v1' }), }); @@ -261,6 +352,7 @@ describe('real-wire X.509 transport conformance', () => { expect(clone.baseURL).toBe('https://provider.example/v1'); expect(clone.apiKey).toBeNull(); + expect(clone.buildURL('/models', null)).toBe('https://provider.example/v1/models'); expect(original.baseURL).toBe('https://mtls.api.openai.com/v1'); } finally { await credential.close(); @@ -507,7 +599,9 @@ describe('real-wire X.509 transport conformance', () => { } }, ); +}); +describe('real-wire legacy workload-identity and caller-owned mTLS compatibility', () => { test('observes one client certificate and isolated credentials on the issuer and API TLS handshakes', async () => { const issuer = createTokenServer(); const api = createAPIServer(); diff --git a/tests/auth/x509-transport.test.ts b/tests/auth/x509-transport.test.ts index 1c6bd65bc..b74f6bf38 100644 --- a/tests/auth/x509-transport.test.ts +++ b/tests/auth/x509-transport.test.ts @@ -251,13 +251,14 @@ describe('explicit X.509 transport capability', () => { }, }), ]); + expect(dispatch).not.toHaveBeenCalled(); + dispatch.mockRestore(); if (observesRequestDispatcher) { expect(() => createX509Transport(directOptions(dispatcher))).not.toThrow(); } else { expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/Undici 5\.2\.0 or later/u); } - expect(dispatch).not.toHaveBeenCalled(); } finally { await dispatcher.close(); } @@ -507,6 +508,58 @@ describe('explicit X.509 transport capability', () => { } }); + test('rejects a dispatcher that disables TLS hostname verification', async () => { + const dispatcher = new Agent({ + connect: { checkServerIdentity: () => new Error('synthetic custom hostname verifier') }, + }); + + try { + expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/hostname|identity|TLS/iu); + } finally { + await dispatcher.close(); + } + }); + + test('rejects a dispatcher subclass that can intercept certificate-bearing requests', async () => { + const dispatcher = new Agent(); + Object.setPrototypeOf(dispatcher, Object.create(Agent.prototype)); + + try { + expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/dispatcher|trusted|subclass/iu); + } finally { + await dispatcher.close(); + } + }); + + test('rejects an own dispatcher override before it can observe authentication', async () => { + const dispatcher = new Agent(); + const originalDispatch = dispatcher.dispatch.bind(dispatcher); + Object.defineProperty(dispatcher, 'dispatch', { value: originalDispatch, configurable: true }); + + try { + expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/dispatcher|dispatch|trusted/iu); + } finally { + await dispatcher.close(); + } + }); + + test('rejects an own Undici symbol-dispatch override before it can observe authentication', async () => { + const dispatcher = new Agent(); + const symbol = Object.getOwnPropertySymbols(Agent.prototype).find( + (candidate) => candidate.description === 'dispatch', + ); + if (!symbol) { + throw new Error('Undici Agent does not expose its symbol-keyed dispatch method.'); + } + Object.defineProperty(dispatcher, symbol, { value: vi.fn(), configurable: true }); + + try { + expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/dispatcher|dispatch|trusted/iu); + } finally { + await dispatcher.close(); + } + }); + test('rejects an externally supplied Agent with a custom dispatcher factory', async () => { const factory = vi.fn(() => new Agent()); const dispatcher = new Agent({ factory }); diff --git a/tests/lib/provider.test.ts b/tests/lib/provider.test.ts index cd734404c..caf71644b 100644 --- a/tests/lib/provider.test.ts +++ b/tests/lib/provider.test.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest'; import OpenAI from 'openai'; import { createProvider } from 'openai/internal/provider'; import type { ProviderRuntime } from 'openai/internal/provider'; +import type { Fetch } from 'openai/internal/builtin-types'; import { formatRequestDetails } from 'openai/internal/utils/log'; const originalEnv = process.env; @@ -229,15 +230,13 @@ describe('provider', () => { test('can replace standard OpenAI routing with a provider in withOptions', async () => { let requestedURL: string | URL | Request | undefined; let requestedHeaders: Headers | undefined; - const client = new OpenAI({ - apiKey: 'openai-api-key', - fetch: async (url, init) => { - requestedURL = url; - requestedHeaders = new Headers(init?.headers); - return new Response('{}', { headers: { 'Content-Type': 'application/json' } }); - }, - }); - const routedClient = client.withOptions({ provider: provider() }); + const requestFetch: Fetch = async (url, init) => { + requestedURL = url; + requestedHeaders = new Headers(init?.headers); + return new Response('{}', { headers: { 'Content-Type': 'application/json' } }); + }; + const client = new OpenAI({ apiKey: 'openai-api-key', fetch: requestFetch }); + const routedClient = client.withOptions({ provider: provider(), fetch: requestFetch }); await routedClient.request({ method: 'get', path: '/models' }); @@ -247,20 +246,104 @@ describe('provider', () => { expect(requestedHeaders?.has('authorization')).toBe(false); }); + test.each([ + ['standard OpenAI', undefined], + ['another provider', provider({ baseURL: 'https://first-provider.example/v1' })], + ] as const)( + 'does not forward %s query credentials to a replacement provider', + async (_name, originalProvider) => { + const requestedURLs: string[] = []; + const requestFetch: Fetch = async (url) => { + requestedURLs.push(String(url)); + return Response.json({}); + }; + const original = new OpenAI({ + ...(originalProvider ? { provider: originalProvider } : { apiKey: 'synthetic-openai-key' }), + defaultQuery: { api_key: 'synthetic-origin-private-secret' }, + fetch: requestFetch, + }); + const replacement = original.withOptions({ provider: provider(), fetch: requestFetch }); + + await replacement.request({ method: 'get', path: '/models' }); + + expect(requestedURLs).toEqual(['https://provider.example/v1/models']); + expect(original.buildURL('/models', null)).toContain('api_key=synthetic-origin-private-secret'); + }, + ); + + test('preserves explicit replacement query defaults and same-provider clone defaults', () => { + const originalProvider = provider({ baseURL: 'https://first-provider.example/v1' }); + const original = new OpenAI({ + provider: originalProvider, + defaultQuery: { api_key: 'synthetic-origin-private-secret' }, + }); + + expect(original.withOptions({ timeout: 1 }).buildURL('/models', null)).toContain( + 'api_key=synthetic-origin-private-secret', + ); + expect( + original + .withOptions({ provider: provider(), defaultQuery: { api_key: 'synthetic-replacement-secret' } }) + .buildURL('/models', null), + ).toBe('https://provider.example/v1/models?api_key=synthetic-replacement-secret'); + }); + + test('does not inherit certificate-bearing transport options across provider owners', () => { + const inheritedTransport = { credentials: 'include' as const }; + const replacementTransport = { cache: 'no-store' as const }; + const original = new OpenAI({ + apiKey: 'synthetic-openai-key', + fetchOptions: inheritedTransport, + }); + + expect(original.withOptions({ provider: provider() }).fetchOptions).toBeUndefined(); + expect( + original.withOptions({ provider: provider(), fetchOptions: replacementTransport }).fetchOptions, + ).toBe(replacementTransport); + }); + + test('does not inherit origin-bound state when the same provider resolves to a new origin', async () => { + let configuredOrigin = 'https://first-provider.example/v1'; + const dynamicProvider = createProvider({ + configure: () => ({ name: 'dynamic-provider', baseURL: configuredOrigin }), + }); + let requestedURL = ''; + let requestedHeaders = new Headers(); + const requestFetch: Fetch = async (url, init) => { + requestedURL = String(url); + requestedHeaders = new Headers(init?.headers); + return Response.json({}); + }; + const original = new OpenAI({ + provider: dynamicProvider, + defaultHeaders: { 'x-origin-private': 'synthetic-first-provider-header' }, + defaultQuery: { api_key: 'synthetic-first-provider-key' }, + fetchOptions: { credentials: 'include' }, + fetch: requestFetch, + }); + configuredOrigin = 'https://second-provider.example/v1'; + const clone = original.withOptions({ timeout: 10_000, fetch: requestFetch }); + + await clone.request({ method: 'get', path: '/models' }); + + expect(requestedURL).toBe('https://second-provider.example/v1/models'); + expect(requestedHeaders.has('x-origin-private')).toBe(false); + expect(clone.fetchOptions).toBeUndefined(); + expect(original.buildURL('/models', null)).toContain('synthetic-first-provider-key'); + }); + test('drops inherited OpenAI headers when switching to a provider in withOptions', async () => { process.env['OPENAI_CUSTOM_HEADERS'] = 'X-OpenAI-Ambient: leaked'; process.env['OPENAI_ORG_ID'] = 'openai-org'; process.env['OPENAI_PROJECT_ID'] = 'openai-project'; let requestedHeaders: Headers | undefined; - const client = new OpenAI({ - apiKey: 'openai-api-key', - fetch: async (_url, init) => { - requestedHeaders = new Headers(init?.headers); - return new Response('{}', { headers: { 'Content-Type': 'application/json' } }); - }, - }); - const routedClient = client.withOptions({ provider: provider() }); + const requestFetch: Fetch = async (_url, init) => { + requestedHeaders = new Headers(init?.headers); + return new Response('{}', { headers: { 'Content-Type': 'application/json' } }); + }; + const client = new OpenAI({ apiKey: 'openai-api-key', fetch: requestFetch }); + const routedClient = client.withOptions({ provider: provider(), fetch: requestFetch }); await routedClient.request({ method: 'get', path: '/models' }); @@ -278,3 +361,72 @@ test('request logging redacts AWS session tokens', () => { expect(details.headers).toEqual({ 'x-amz-security-token': '***' }); }); + +test('request logging redacts proxy authentication and credential-bearing query parameters', () => { + const details = formatRequestDetails({ + headers: new Headers({ 'proxy-authorization': 'Basic synthetic-private-proxy-secret' }), + url: 'https://provider.example/v1/models?api_key=synthetic-private-api-key&view=public', + }); + + expect(details.headers).toEqual({ 'proxy-authorization': '***' }); + expect(details.url).toBe('https://provider.example/v1/models?api_key=***&view=public'); +}); + +test('request logging redacts URL userinfo, fragments, and AWS query credentials', () => { + const details = formatRequestDetails({ + url: + 'https://user:synthetic-password@provider.example/v1/models?' + + 'X-Amz-Security-Token=synthetic-session&X-Amz-Signature=synthetic-signature&session_token=synthetic-token' + + '#synthetic-private-fragment', + }); + + expect(details.url).toBe( + 'https://provider.example/v1/models?X-Amz-Security-Token=***&X-Amz-Signature=***&session_token=***', + ); +}); + +test('request logging redacts credentials in structured request-option queries', () => { + const details = formatRequestDetails({ + url: 'https://provider.example/v1/models?api_key=synthetic-private-api-key&view=public', + options: { + query: { + api_key: 'synthetic-private-api-key', + Authorization: 'Bearer synthetic-secret', + view: 'public', + }, + }, + }); + + expect(details.options?.query).toEqual({ api_key: '***', Authorization: '***', view: 'public' }); +}); + +test('provider origin changes require an explicitly replaced custom fetch transport', async () => { + const inheritedFetch = vi.fn(async () => Response.json({})); + const replacementFetch = vi.fn(async () => Response.json({})); + const original = new OpenAI({ apiKey: 'synthetic-openai-key', fetch: inheritedFetch }); + const replacement = original.withOptions({ provider: provider(), fetch: replacementFetch }); + + await replacement.request({ method: 'get', path: '/models' }); + + expect(inheritedFetch).not.toHaveBeenCalled(); + expect(replacementFetch).toHaveBeenCalledTimes(1); + const defaultFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({})); + try { + await original.withOptions({ provider: provider() }).request({ method: 'get', path: '/models' }); + expect(defaultFetch).toHaveBeenCalledTimes(1); + expect(inheritedFetch).not.toHaveBeenCalled(); + } finally { + defaultFetch.mockRestore(); + } +}); + +test.each(['X-Session-Token', 'X-Session-Id', 'X-Auth-Token', 'X-ID-Token'])( + 'request logging redacts the %s authentication header', + (header) => { + const details = formatRequestDetails({ + headers: new Headers({ [header]: 'synthetic-provider-authentication-secret' }), + }); + + expect(details.headers).toEqual({ [header.toLowerCase()]: '***' }); + }, +); diff --git a/tests/lib/x509-workload-credential-config.test.ts b/tests/lib/x509-workload-credential-config.test.ts index 6c08aa871..1862adbb8 100644 --- a/tests/lib/x509-workload-credential-config.test.ts +++ b/tests/lib/x509-workload-credential-config.test.ts @@ -1,7 +1,8 @@ import { Agent } from 'undici'; -import { vi } from 'vitest'; +import { expectTypeOf, vi } from 'vitest'; import OpenAI from 'openai'; +import type { AzureClientOptions, AzureOpenAI, BedrockClientOptions, BedrockOpenAI } 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'; @@ -61,6 +62,19 @@ afterEach(async () => { }); describe('X.509 workload credential refresh configuration', () => { + test('excludes X.509 credentials from provider constructors and client clones', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf< + NonNullable[0]>['credential'] + >().toEqualTypeOf(); + expectTypeOf< + NonNullable[0]>['credential'] + >().toEqualTypeOf(); + expectTypeOf[0]['credential']>().toEqualTypeOf(); + expectTypeOf[0]['credential']>().toEqualTypeOf(); + }); + test.each([null, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( 'rejects invalid refreshBufferSeconds %s before certificate presentation', (refreshBufferSeconds) => { diff --git a/tests/lib/x509-workload-example.test.ts b/tests/lib/x509-workload-example.test.ts index 99ac4da31..b1023b14f 100644 --- a/tests/lib/x509-workload-example.test.ts +++ b/tests/lib/x509-workload-example.test.ts @@ -11,6 +11,7 @@ const packageDocumentation = readFileSync(path.resolve(process.cwd(), 'README.md const packageScripts = JSON.parse(readFileSync(path.resolve(process.cwd(), 'package.json'), 'utf-8')) as { scripts: Record; }; +const mtlsBaseURLModule = path.resolve(process.cwd(), 'examples/mtls/base-url.mjs'); function runExample(environment: Record) { return spawnSync(process.execPath, ['--input-type=module'], { @@ -27,12 +28,36 @@ function runExample(environment: Record) { } describe('X.509 workload-identity runnable example', () => { + test.each([ + 'http://mtls.api.openai.com/v1', + 'https://attacker.example/v1', + 'https://mtls.api.openai.com.attacker.example/v1', + 'https://mtls.api.openai.com/v1?api_key=synthetic-secret', + ])('rejects unsafe API-key mTLS example endpoint %s', async (configured) => { + const { mtlsBaseURL } = (await import(mtlsBaseURLModule)) as { + mtlsBaseURL: (configured: string) => string; + }; + + expect(() => mtlsBaseURL(configured)).toThrow(/documented.*HTTPS.*mTLS/iu); + }); + + test.each(['https://mtls.api.openai.com/v1', 'https://mtls-eu.api.openai.com/v1'])( + 'preserves documented API-key mTLS endpoint %s', + async (configured) => { + const { mtlsBaseURL } = (await import(mtlsBaseURLModule)) as { + mtlsBaseURL: (configured: string) => string; + }; + + expect(mtlsBaseURL(configured)).toBe(configured); + }, + ); + test('builds the SDK before running its clean-checkout live validation command', () => { expect(packageScripts.scripts['test:live:x509']).toMatch(/^pnpm build && node /u); }); test('documents an Undici version compatible with the complete supported Node 22 range', () => { - expect(exampleDocumentation).toContain('npm install openai "undici@^7"'); + expect(exampleDocumentation.match(/npm install openai "undici@\^7"/gu)).toHaveLength(2); }); test('builds repository self-imports before documenting the direct example command', () => { @@ -41,6 +66,9 @@ describe('X.509 workload-identity runnable example', () => { test('preserves an explicitly empty encrypted-key passphrase', () => { expect(example).toContain('passphrase === undefined ? {} : { passphrase }'); + expect(readFileSync(path.resolve(process.cwd(), 'examples/mtls/node.mjs'), 'utf-8')).toContain( + 'passphrase === undefined ? {} : { passphrase }', + ); }); test.each([ diff --git a/tests/lib/x509-workload-identity.test.ts b/tests/lib/x509-workload-identity.test.ts index bd02ffc97..1d35ac713 100644 --- a/tests/lib/x509-workload-identity.test.ts +++ b/tests/lib/x509-workload-identity.test.ts @@ -283,12 +283,45 @@ describe('OpenAI X.509 workload-identity client integration', () => { }); test('switches an ordinary API-key client into approved X.509 workload identity', () => { - const ordinary = new OpenAI({ apiKey: 'synthetic-api-key' }); + const ordinary = new OpenAI({ + apiKey: 'synthetic-api-key', + defaultHeaders: { 'x-origin-private': 'synthetic-ordinary-header-secret' }, + defaultQuery: { api_key: 'synthetic-ordinary-query-secret' }, + }); 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(); + expect(switched.buildURL('/models', null)).toBe('https://mtls.api.openai.com/v1/models'); + }); + + test.each([ + 'api_key', + 'Authorization', + 'access_token', + 'session_token', + 'session_id', + 'id_token', + 'auth_token', + 'X-Amz-Security-Token', + 'X-Amz-Signature', + ])('rejects caller-supplied %s query credentials before exchanging a workload credential', async (name) => { + const send = mockTransportRequests(); + const client = new OpenAI(options({ defaultQuery: { [name]: 'synthetic-conflicting-query-secret' } })); + + await expect(client.models.list()).rejects.toThrow(/query|credential|authentication/iu); + expect(send).not.toHaveBeenCalled(); + }); + + test('rejects request-level query credentials before contacting the issuer', async () => { + const send = mockTransportRequests(); + const client = new OpenAI(options()); + + await expect( + client.request({ method: 'get', path: '/models', query: { access_token: 'synthetic-request-secret' } }), + ).rejects.toThrow(/query|credential|authentication/iu); + expect(send).not.toHaveBeenCalled(); }); test('rejects pre-aborted API requests before presenting a certificate to the issuer', async () => { @@ -514,16 +547,24 @@ describe('OpenAI X.509 workload-identity client integration', () => { }, ); - 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' } })); + test.each([ + 'Authorization', + 'api-key', + 'x-api-key', + 'Proxy-Authorization', + 'Cookie', + 'X-Session-Token', + 'X-Session-Id', + 'X-Auth-Token', + 'X-ID-Token', + '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(); - }, - ); + 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(); diff --git a/tests/utils/x509-test-lab.ts b/tests/utils/x509-test-lab.ts index d573fa22b..f084e21f6 100644 --- a/tests/utils/x509-test-lab.ts +++ b/tests/utils/x509-test-lab.ts @@ -211,6 +211,7 @@ export function createConnectProxy( encrypted: boolean, serverCertificate: TestCertificate = lab.proxyServer, routes?: ReadonlyMap, + requireClientCertificate = encrypted, ): ObservedServer { const requests: ObservedRequest[] = []; const connections = new Set(); @@ -219,8 +220,8 @@ export function createConnectProxy( ca: lab.proxyCertificateAuthority, cert: serverCertificate.certificate, key: serverCertificate.privateKey, - requestCert: true, - rejectUnauthorized: true, + requestCert: requireClientCertificate, + rejectUnauthorized: requireClientCertificate, }) : createHTTPServer(); @@ -252,9 +253,13 @@ export function createConnectProxy( return { server, requests, connections }; } -export async function listenLoopback(observed: ObservedServer, encrypted = true): Promise { +export async function listenLoopback( + observed: ObservedServer, + encrypted = true, + host = '127.0.0.1', +): Promise { const listening = once(observed.server, 'listening'); - observed.server.listen(0, '127.0.0.1'); + observed.server.listen(0, host); await listening; const address = observed.server.address(); @@ -262,7 +267,7 @@ export async function listenLoopback(observed: ObservedServer, encrypted = true) throw new Error('Expected a loopback TCP server address'); } - return new URL(`${encrypted ? 'https' : 'http'}://127.0.0.1:${address.port}`); + return new URL(`${encrypted ? 'https' : 'http'}://${host}:${address.port}`); } export async function closeObservedServers(...observedServers: ObservedServer[]): Promise { From ce66c648fd853e6f49f2162188708c860e85e260 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 18:18:50 +0000 Subject: [PATCH 05/11] test(auth): verify first-class X.509 across supported runtimes --- .github/workflows/ci.yml | 21 +++++++++++- scripts/test-packed-package.ts | 42 +++++++++++++----------- src/client.ts | 6 +++- tests/lib/x509-workload-identity.test.ts | 18 ++++++++++ 4 files changed, 66 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c9fcc648..c475022f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,19 @@ jobs: ./scripts/build node --experimental-strip-types scripts/test-packed-package.ts + - name: Prepare isolated minimum-runtime package and synthetic certificate + if: matrix.node-version == 22 + run: | + package_tarball="$(npm pack "$GITHUB_WORKSPACE/dist" --ignore-scripts --silent --pack-destination "$RUNNER_TEMP")" + echo "X509_FLOOR_PACKAGE=${RUNNER_TEMP}/${package_tarball}" >> "$GITHUB_ENV" + node -r ts-node/register/transpile-only --eval ' + const { writeFileSync } = require("node:fs"); + const { join } = require("node:path"); + const { createX509TestLab } = require("./tests/utils/x509-test-lab.ts"); + const { firstClient } = createX509TestLab(); + writeFileSync(join(process.env.RUNNER_TEMP, "openai-x509-minimum-fixture.json"), JSON.stringify({ certificateChain: firstClient.certificate.toString(), privateKey: firstClient.privateKey.toString() })); + ' + - name: Set up exact minimum supported Node.js runtime if: matrix.node-version == 22 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -163,13 +176,19 @@ jobs: if: matrix.node-version == 22 run: | floor_consumer="${RUNNER_TEMP}/openai-node-minimum-runtime" - npm install --ignore-scripts --no-audit --no-fund --prefix "$floor_consumer" "$GITHUB_WORKSPACE/dist" 'undici@^7' + npm install --ignore-scripts --no-audit --no-fund --prefix "$floor_consumer" "$X509_FLOOR_PACKAGE" 'undici@^7' cd "$floor_consumer" node --input-type=module --eval ' + import { readFileSync } from "node:fs"; + import { join } from "node:path"; import OpenAI from "openai"; import { Agent, ProxyAgent } from "undici"; import { createX509Transport, fromX509, workloadIdentity } from "openai/auth/x509-transport"; if (process.version !== "v22.0.0" || workloadIdentity.fromX509 !== fromX509) throw new Error("Invalid minimum-runtime X.509 exports"); + const material = JSON.parse(readFileSync(join(process.env.RUNNER_TEMP, "openai-x509-minimum-fixture.json"), "utf8")); + const credential = fromX509({ ...material, identityProviderId: "synthetic-provider", serviceAccountId: "synthetic-account" }); + new OpenAI({ credential }); + await credential.close(); for (const [proxy, dispatcher] of [["direct", new Agent()], ["http-connect", new ProxyAgent({ uri: "http://127.0.0.1:1" })], ["https-connect", new ProxyAgent({ uri: "https://127.0.0.1:1" })]]) { const x509Transport = createX509Transport({ runtime: "node", dispatcher, certificateIdentity: "static", proxy }); new OpenAI({ apiKey: null, workloadIdentity: { type: "x509", identityProviderId: "synthetic-provider", serviceAccountId: "synthetic-account" }, x509Transport }); diff --git a/scripts/test-packed-package.ts b/scripts/test-packed-package.ts index 639cef89a..544e0e262 100644 --- a/scripts/test-packed-package.ts +++ b/scripts/test-packed-package.ts @@ -268,6 +268,22 @@ const packedPackagePath = require('node:path'); 'assert.doesNotThrow(direct); assert.throws(httpConnect, /CONNECT.*Undici 5\\.5\\.1 or later/u); assert.throws(httpsConnect, /CONNECT.*Undici 5\\.5\\.1 or later/u);'; const supportedTransports = 'assert.doesNotThrow(direct); assert.doesNotThrow(httpConnect); assert.doesNotThrow(httpsConnect);'; + const certificateFixture = JSON.parse( + run( + process.execPath, + [ + '-r', + path.join(root, 'node_modules/ts-node/register/transpile-only'), + '-e', + [ + `const { createX509TestLab } = require(${JSON.stringify(path.join(root, 'tests/utils/x509-test-lab.ts'))});`, + 'const { firstClient } = createX509TestLab();', + 'process.stdout.write(JSON.stringify({ certificateChain: firstClient.certificate.toString(), privateKey: firstClient.privateKey.toString() }));', + ].join(' '), + ], + { cwd: root }, + ), + ) as { certificateChain: string; privateKey: string }; for (const [undiciVersion, transportAssertions] of [ ['5.1.1', unsupportedDispatcher], @@ -326,11 +342,11 @@ const packedPackagePath = require('node:path'); for (const [inputType, imports] of [ [ 'commonjs', - "const assert = require('node:assert/strict'); const { Agent, ProxyAgent } = require('undici'); const { createX509Transport } = require('openai/auth/x509-transport');", + "const assert = require('node:assert/strict'); const OpenAI = require('openai'); const { Agent, ProxyAgent } = require('undici'); const { createX509Transport, fromX509, workloadIdentity } = require('openai/auth/x509-transport');", ], [ 'module', - "import assert from 'node:assert/strict'; import { Agent, ProxyAgent } from 'undici'; import { createX509Transport } from 'openai/auth/x509-transport';", + "import assert from 'node:assert/strict'; import OpenAI from 'openai'; import { Agent, ProxyAgent } from 'undici'; import { createX509Transport, fromX509, workloadIdentity } from 'openai/auth/x509-transport';", ], ]) { run( @@ -347,6 +363,11 @@ const packedPackagePath = require('node:path'); "const httpConnect = () => createX509Transport({ runtime: 'node', dispatcher: proxyDispatcher, certificateIdentity: 'static', proxy: 'http-connect' });", "const httpsConnect = () => createX509Transport({ runtime: 'node', dispatcher: secureProxyDispatcher, certificateIdentity: 'static', proxy: 'https-connect' });", transportAssertions, + 'assert.equal(workloadIdentity.fromX509, fromX509);', + `const credentialOptions = { ...${JSON.stringify(certificateFixture)}, identityProviderId: 'synthetic-provider', serviceAccountId: 'synthetic-account' };`, + undiciVersion === '5.1.1' + ? 'assert.throws(() => fromX509(credentialOptions), /Undici 5\\.2\\.0 or later/u);' + : 'const credential = fromX509(credentialOptions); new OpenAI({ credential }); credential.close();', 'dispatcher.close(); proxyDispatcher.close(); secureProxyDispatcher.close();', ].join(' '), ], @@ -481,23 +502,6 @@ const packedPackagePath = require('node:path'); "import OpenAI from 'openai'; new OpenAI({ apiKey: 'synthetic-browser-api-key', dangerouslyAllowBrowser: true });", ]); fs.symlinkSync(path.join(root, 'node_modules/undici'), optionalUndici, 'dir'); - const certificateFixture = JSON.parse( - run( - process.execPath, - [ - '-r', - path.join(root, 'node_modules/ts-node/register/transpile-only'), - '-e', - [ - `const { createX509TestLab } = require(${JSON.stringify(path.join(root, 'tests/utils/x509-test-lab.ts'))});`, - 'const { firstClient } = createX509TestLab();', - 'process.stdout.write(JSON.stringify({ certificateChain: firstClient.certificate.toString(), privateKey: firstClient.privateKey.toString() }));', - ].join(' '), - ], - { cwd: root }, - ), - ) as { certificateChain: string; privateKey: string }; - for (const [inputType, consumer] of [ [ 'commonjs', diff --git a/src/client.ts b/src/client.ts index 3fae40a03..4a75a86ed 100644 --- a/src/client.ts +++ b/src/client.ts @@ -652,7 +652,11 @@ export class OpenAI { withOptions(options: Partial): this { const residencyBaseURL = resolveDataResidency(options); const inheritedProvider = this._options.provider; - const provider = options.provider ?? (options.credential === undefined ? inheritedProvider : undefined); + const provider = + options.provider ?? + (options.credential === undefined && options.workloadIdentity === undefined + ? inheritedProvider + : undefined); const x509Authentication = this.#x509Authentication; const inheritedOptions: ClientOptions = { ...this._options, diff --git a/tests/lib/x509-workload-identity.test.ts b/tests/lib/x509-workload-identity.test.ts index 1d35ac713..952caa5d3 100644 --- a/tests/lib/x509-workload-identity.test.ts +++ b/tests/lib/x509-workload-identity.test.ts @@ -9,6 +9,7 @@ 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'; +import { createProvider } from 'openai/internal/provider'; import { closeObservedServers, @@ -296,6 +297,23 @@ describe('OpenAI X.509 workload-identity client integration', () => { expect(switched.buildURL('/models', null)).toBe('https://mtls.api.openai.com/v1/models'); }); + test('switches a provider into caller-owned X.509 authentication without inheriting provider state', () => { + const provider = createProvider({ + configure: () => ({ name: 'synthetic-provider', baseURL: 'https://provider.example/v1' }), + }); + const ordinary = new OpenAI({ + provider, + defaultHeaders: { 'x-provider-private': 'synthetic-provider-header-secret' }, + defaultQuery: { api_key: 'synthetic-provider-query-secret' }, + }); + + const switched = ordinary.withOptions({ workloadIdentity: identity(), x509Transport: transport }); + + expect(switched.baseURL).toBe('https://mtls.api.openai.com/v1'); + expect(switched.apiKey).toBeNull(); + expect(switched.buildURL('/models', null)).toBe('https://mtls.api.openai.com/v1/models'); + }); + test.each([ 'api_key', 'Authorization', From 5c36700745cd3945114b8e30e69284f64068c98c Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 18:19:52 +0000 Subject: [PATCH 06/11] refactor(auth): simplify provider credential transitions --- src/client.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/client.ts b/src/client.ts index 4a75a86ed..450bdb42d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -652,11 +652,8 @@ export class OpenAI { withOptions(options: Partial): this { const residencyBaseURL = resolveDataResidency(options); const inheritedProvider = this._options.provider; - const provider = - options.provider ?? - (options.credential === undefined && options.workloadIdentity === undefined - ? inheritedProvider - : undefined); + const replacingProvider = options.credential ?? options.workloadIdentity; + const provider = options.provider ?? (replacingProvider ? undefined : inheritedProvider); const x509Authentication = this.#x509Authentication; const inheritedOptions: ClientOptions = { ...this._options, From edad4e51c7b13da9e2ce38880bcc33640e7f0e46 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 18:25:50 +0000 Subject: [PATCH 07/11] fix(auth): reject and redact prefixed query credentials --- src/internal/utils/log.ts | 3 ++- tests/lib/provider.test.ts | 13 +++++++++++++ tests/lib/x509-workload-identity.test.ts | 5 +++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts index 2bde04c5b..112e80478 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -101,7 +101,8 @@ const sensitiveQueryNames = new Set([ /** Recognizes credential-bearing query names across ordinary and provider authentication. */ export function isSensitiveQueryParameter(name: string): boolean { - return sensitiveQueryNames.has(name.toLowerCase().replace(/[-_]/gu, '')); + const normalized = name.toLowerCase().replace(/[-_]/gu, ''); + return sensitiveQueryNames.has(normalized) || sensitiveQueryNames.has(normalized.replace(/^x/u, '')); } const sensitiveHeaderNames = new Set([ diff --git a/tests/lib/provider.test.ts b/tests/lib/provider.test.ts index caf71644b..2e39568a1 100644 --- a/tests/lib/provider.test.ts +++ b/tests/lib/provider.test.ts @@ -400,6 +400,19 @@ test('request logging redacts credentials in structured request-option queries', expect(details.options?.query).toEqual({ api_key: '***', Authorization: '***', view: 'public' }); }); +test.each(['X-API-Key', 'X-Session-Token', 'X-Session-Id', 'X-Auth-Token', 'X-ID-Token'])( + 'request logging redacts the %s authentication query from URLs and structured options', + (name) => { + const details = formatRequestDetails({ + url: `https://provider.example/v1/models?${name}=synthetic-secret&view=public`, + options: { query: { [name]: 'synthetic-secret', view: 'public' } }, + }); + + expect(details.url).toBe(`https://provider.example/v1/models?${name}=***&view=public`); + expect(details.options?.query).toEqual({ [name]: '***', view: 'public' }); + }, +); + test('provider origin changes require an explicitly replaced custom fetch transport', async () => { const inheritedFetch = vi.fn(async () => Response.json({})); const replacementFetch = vi.fn(async () => Response.json({})); diff --git a/tests/lib/x509-workload-identity.test.ts b/tests/lib/x509-workload-identity.test.ts index 952caa5d3..c813a39dd 100644 --- a/tests/lib/x509-workload-identity.test.ts +++ b/tests/lib/x509-workload-identity.test.ts @@ -322,6 +322,11 @@ describe('OpenAI X.509 workload-identity client integration', () => { 'session_id', 'id_token', 'auth_token', + 'X-API-Key', + 'X-Session-Token', + 'X-Session-Id', + 'X-ID-Token', + 'X-Auth-Token', 'X-Amz-Security-Token', 'X-Amz-Signature', ])('rejects caller-supplied %s query credentials before exchanging a workload credential', async (name) => { From e027b17b6b5fab013447d017b65fe4dfe02b1a30 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 18:32:24 +0000 Subject: [PATCH 08/11] fix(auth): unify sensitive header and query classification --- src/internal/utils/log.ts | 2 +- tests/lib/provider.test.ts | 26 ++++++++++++++++-------- tests/lib/x509-workload-identity.test.ts | 6 ++++++ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts index 112e80478..2feb5be9f 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -121,7 +121,7 @@ const sensitiveHeaderNames = new Set([ /** Recognizes credential-bearing request headers across provider and workload authentication. */ export function isSensitiveHeader(name: string): boolean { - return sensitiveHeaderNames.has(name.toLowerCase().replace(/_/gu, '-')); + return sensitiveHeaderNames.has(name.toLowerCase().replace(/_/gu, '-')) || isSensitiveQueryParameter(name); } /** Removes credential-valued query parameters before a request URL reaches any logger. */ diff --git a/tests/lib/provider.test.ts b/tests/lib/provider.test.ts index 2e39568a1..118376283 100644 --- a/tests/lib/provider.test.ts +++ b/tests/lib/provider.test.ts @@ -433,13 +433,21 @@ test('provider origin changes require an explicitly replaced custom fetch transp } }); -test.each(['X-Session-Token', 'X-Session-Id', 'X-Auth-Token', 'X-ID-Token'])( - 'request logging redacts the %s authentication header', - (header) => { - const details = formatRequestDetails({ - headers: new Headers({ [header]: 'synthetic-provider-authentication-secret' }), - }); +test.each([ + 'X-Access-Token', + 'X-Refresh-Token', + 'X-Session-Token', + 'X-Session-Id', + 'X-Auth-Token', + 'X-ID-Token', + 'Session-Token', + 'Session-Id', + 'Auth-Token', + 'ID-Token', +])('request logging redacts the %s authentication header', (header) => { + const details = formatRequestDetails({ + headers: new Headers({ [header]: 'synthetic-provider-authentication-secret' }), + }); - expect(details.headers).toEqual({ [header.toLowerCase()]: '***' }); - }, -); + expect(details.headers).toEqual({ [header.toLowerCase()]: '***' }); +}); diff --git a/tests/lib/x509-workload-identity.test.ts b/tests/lib/x509-workload-identity.test.ts index c813a39dd..a14b23ae8 100644 --- a/tests/lib/x509-workload-identity.test.ts +++ b/tests/lib/x509-workload-identity.test.ts @@ -576,10 +576,16 @@ describe('OpenAI X.509 workload-identity client integration', () => { 'x-api-key', 'Proxy-Authorization', 'Cookie', + 'X-Access-Token', + 'X-Refresh-Token', 'X-Session-Token', 'X-Session-Id', 'X-Auth-Token', 'X-ID-Token', + 'Session-Token', + 'Session-Id', + 'Auth-Token', + 'ID-Token', 'Host', ])('rejects caller-supplied %s before exchanging a workload credential', async (header) => { const send = mockTransportRequests(); From 7cb53dd37f4b69a5442db37565891dd6c6b42880 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 18:38:47 +0000 Subject: [PATCH 09/11] fix(logging): redact credentials embedded in request paths --- src/internal/utils/log.ts | 8 ++++++++ tests/lib/provider.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts index 2feb5be9f..56689bb31 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -153,6 +153,14 @@ export const formatRequestDetails = (details: { if (details.options) { details.options = { ...details.options }; delete details.options['headers']; // redundant + leaks internals + if (details.options.path) { + const path = details.options.path; + const redacted = new URL(redactURL(new URL(path, 'https://redacted.invalid').href)); + details.options.path = + redacted.origin === 'https://redacted.invalid' + ? `${path.startsWith('/') ? '/' : ''}${redacted.pathname.slice(1)}${redacted.search}` + : redacted.href; + } if (details.options.query) { details.options.query = Object.fromEntries( Object.entries(details.options.query).map(([name, value]) => [ diff --git a/tests/lib/provider.test.ts b/tests/lib/provider.test.ts index 118376283..f1c20d7cb 100644 --- a/tests/lib/provider.test.ts +++ b/tests/lib/provider.test.ts @@ -400,6 +400,30 @@ test('request logging redacts credentials in structured request-option queries', expect(details.options?.query).toEqual({ api_key: '***', Authorization: '***', view: 'public' }); }); +test.each(['ordinary', 'provider'] as const)( + '%s client debug logging redacts authentication embedded in public request paths', + async (kind) => { + const debug = vi.fn(); + const client = new OpenAI({ + ...(kind === 'provider' ? { provider: provider() } : { apiKey: 'synthetic-api-key' }), + fetch: async () => Response.json({}), + logLevel: 'debug', + logger: { debug, info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await client.get( + '/models?api_key=synthetic-path-api-secret&x_session_token=synthetic-path-token&view=public#synthetic-path-fragment', + ); + + const logged = JSON.stringify(debug.mock.calls); + expect(debug).toHaveBeenCalled(); + expect(logged).not.toContain('synthetic-path-api-secret'); + expect(logged).not.toContain('synthetic-path-token'); + expect(logged).not.toContain('synthetic-path-fragment'); + expect(logged).toContain('/models?api_key=***&x_session_token=***&view=public'); + }, +); + test.each(['X-API-Key', 'X-Session-Token', 'X-Session-Id', 'X-Auth-Token', 'X-ID-Token'])( 'request logging redacts the %s authentication query from URLs and structured options', (name) => { From ee1c19b26c1a5cc9779b67727789a2ea4e164861 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 15:39:26 -0700 Subject: [PATCH 10/11] fix(auth): preserve trusted X.509 transport compatibility --- src/auth/workload-identity-auth.ts | 10 +- src/auth/x509-transport.ts | 22 +- src/client.ts | 17 +- .../auth/x509-transport-capability.ts | 123 ++--------- tests/auth/workload-identity-auth.test.ts | 64 ++++++ tests/auth/x509-transport.test.ts | 196 ++++++------------ tests/lib/provider.test.ts | 102 +++++++++ 7 files changed, 271 insertions(+), 263 deletions(-) diff --git a/src/auth/workload-identity-auth.ts b/src/auth/workload-identity-auth.ts index 45d1f61d5..e0130d260 100644 --- a/src/auth/workload-identity-auth.ts +++ b/src/auth/workload-identity-auth.ts @@ -45,11 +45,15 @@ export class WorkloadIdentityAuth { * @param fetch Optional fetch implementation for calls to the OpenAI token endpoint. */ constructor(config: WorkloadIdentity, fetch?: Fetch) { + const { identityProviderId, serviceAccountId, clientId, refreshBufferSeconds, provider } = config; this.config = { - ...config, + identityProviderId, + serviceAccountId, + ...(clientId === undefined ? {} : { clientId }), + ...(refreshBufferSeconds === undefined ? {} : { refreshBufferSeconds }), provider: { - tokenType: config.provider.tokenType, - getToken: config.provider.getToken.bind(config.provider), + tokenType: provider.tokenType, + getToken: provider.getToken.bind(provider), }, }; this.fetch = fetch ?? Shims.getDefaultFetch(); diff --git a/src/auth/x509-transport.ts b/src/auth/x509-transport.ts index 4a2ebbd99..c93c51d0e 100644 --- a/src/auth/x509-transport.ts +++ b/src/auth/x509-transport.ts @@ -223,6 +223,17 @@ function proxyAuthentication(url: URL): string | undefined { return Buffer.from(`${username}:${password}`, 'utf-8').toString('base64'); } +function normalizeProxyURL(value: unknown): URL { + if (typeof value !== 'string' && (typeof value !== 'object' || value === null || types.isProxy(value))) { + throw new Error('X.509 CONNECT proxy requires an own URL string or URL value.'); + } + try { + return new URL(typeof value === 'string' ? value : URL.prototype.toString.call(value)); + } catch { + throw new Error('X.509 CONNECT proxy requires a valid proxy URL.'); + } +} + function credentialDispatcher( proxyOptionsInput: unknown, requestTls: VerifiedX509TLSOptions, @@ -232,16 +243,7 @@ function credentialDispatcher( } const proxyOptions = safeOptionRecord(proxyOptionsInput, proxyOptionNames, 'proxy'); - const proxyURL = proxyOptions['url']; - if (typeof proxyURL !== 'string' && !(proxyURL instanceof URL)) { - throw new Error('X.509 CONNECT proxy requires an own URL string or URL value.'); - } - let url: URL; - try { - url = new URL(typeof proxyURL === 'string' ? proxyURL : URL.prototype.toString.call(proxyURL)); - } catch { - throw new Error('X.509 CONNECT proxy requires a valid proxy URL.'); - } + const url = normalizeProxyURL(proxyOptions['url']); const selected = proxyOptions['mode']; const proxy: X509ProxyMode = selected === 'http-connect' || selected === 'https-connect' ? selected : 'direct'; diff --git a/src/client.ts b/src/client.ts index 450bdb42d..21f1f2cc3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -681,6 +681,15 @@ export class OpenAI { if (residencyBaseURL !== undefined) { delete inheritedOptions.baseURL; } + if (provider !== inheritedProvider) { + delete inheritedOptions.baseURL; + delete inheritedOptions.organization; + delete inheritedOptions.project; + delete inheritedOptions.defaultHeaders; + delete inheritedOptions.defaultQuery; + delete inheritedOptions.fetchOptions; + delete inheritedOptions.fetch; + } if (provider) { delete inheritedOptions.apiKey; delete inheritedOptions.adminAPIKey; @@ -688,14 +697,6 @@ export class OpenAI { delete inheritedOptions.workloadIdentity; delete inheritedOptions.x509Transport; delete inheritedOptions.baseURL; - if (provider !== inheritedProvider) { - delete inheritedOptions.organization; - delete inheritedOptions.project; - delete inheritedOptions.defaultHeaders; - delete inheritedOptions.defaultQuery; - delete inheritedOptions.fetchOptions; - delete inheritedOptions.fetch; - } } const clientOptions: InternalClientOptions = { diff --git a/src/internal/auth/x509-transport-capability.ts b/src/internal/auth/x509-transport-capability.ts index 51867fec3..5ef87a160 100644 --- a/src/internal/auth/x509-transport-capability.ts +++ b/src/internal/auth/x509-transport-capability.ts @@ -15,7 +15,13 @@ export interface X509TransportOptions { /** X.509 transport currently supports genuine Node.js runtimes only. */ runtime: 'node'; - /** Caller-owned Undici Agent or ProxyAgent; the SDK never closes or inspects it. */ + /** + * Caller-owned Undici Agent or ProxyAgent; the SDK never closes or inspects it. + * + * The application attests that its dispatcher, factories, TLS verification, + * certificate selection, and CONNECT proxy configuration are trustworthy. + * Use `fromX509` when the SDK should own and enforce transport configuration. + */ dispatcher: Agent | ProxyAgent; /** Attests that the dispatcher uses one static workload-certificate identity. */ @@ -27,27 +33,20 @@ export interface X509TransportOptions { const allowedOptionNames = new Set(['runtime', 'dispatcher', 'certificateIdentity', 'proxy']); const transportBrand: typeof x509TransportBrand = x509TransportBrand; -let originalAgentFactory: unknown; class NodeX509Transport implements X509Transport { declare readonly [transportBrand]: true; readonly #dispatcher: Agent | ProxyAgent; - readonly #proxy: X509ProxyMode; - constructor(dispatcher: Agent | ProxyAgent, proxy: X509ProxyMode) { + constructor(dispatcher: Agent | ProxyAgent) { this.#dispatcher = dispatcher; - this.#proxy = proxy; Object.freeze(this); } static dispatcher(value: object): Agent | ProxyAgent | undefined { return #dispatcher in value ? value.#dispatcher : undefined; } - - static proxy(value: object): X509ProxyMode | undefined { - return #proxy in value ? value.#proxy : undefined; - } } /** Registers only a genuine frozen capability whose JavaScript private dispatcher cannot be forged. */ @@ -87,89 +86,6 @@ function assertNodeRuntime(): void { } } -function undiciState(dispatcher: Agent | ProxyAgent, name: string): unknown { - const symbol = Object.getOwnPropertySymbols(dispatcher).find((candidate) => candidate.description === name); - return symbol ? Object.getOwnPropertyDescriptor(dispatcher, symbol)?.value : undefined; -} - -function assertVerifiedTLS(value: unknown): void { - if (value === undefined || value === null) { - if (process.env['NODE_TLS_REJECT_UNAUTHORIZED'] === '0') { - throw new Error('X.509 transport requires explicit TLS server certificate verification.'); - } - return; - } - if (typeof value !== 'object' || types.isProxy(value)) { - throw new Error('X.509 transport requires inspectable TLS server-verification settings.'); - } - const verification = Object.getOwnPropertyDescriptor(value, 'rejectUnauthorized'); - const hostnameVerification = Object.getOwnPropertyDescriptor(value, 'checkServerIdentity'); - if ( - (verification && (!('value' in verification) || verification.value === false)) || - (hostnameVerification && - (!('value' in hostnameVerification) || hostnameVerification.value !== undefined)) || - (process.env['NODE_TLS_REJECT_UNAUTHORIZED'] === '0' && verification?.value !== true) - ) { - throw new Error('X.509 transport requires TLS server certificate and hostname verification.'); - } -} - -function assertDispatcherIntegrity(dispatcher: Agent | ProxyAgent): void { - const trustedPrototype = dispatcher instanceof ProxyAgent ? ProxyAgent.prototype : Agent.prototype; - if ( - Object.getPrototypeOf(dispatcher) !== trustedPrototype || - Object.getOwnPropertyDescriptor(dispatcher, 'dispatch') || - Object.getOwnPropertySymbols(dispatcher).some((symbol) => symbol.description === 'dispatch') - ) { - throw new Error('X.509 transport requires an unmodified, trusted Undici dispatcher.'); - } -} - -function assertDispatcherTrust(dispatcher: Agent | ProxyAgent, proxy: X509ProxyMode): void { - assertDispatcherIntegrity(dispatcher); - if (dispatcher instanceof ProxyAgent) { - const configuration = undiciState(dispatcher, 'proxy agent options'); - if (!configuration || typeof configuration !== 'object') { - throw new Error('X.509 transport requires inspectable CONNECT proxy configuration.'); - } - const uri: unknown = - configuration instanceof URL - ? URL.prototype.toString.call(configuration) - : Object.getOwnPropertyDescriptor(configuration, 'uri')?.value; - let protocol: string; - try { - if (typeof uri !== 'string' && !(uri instanceof URL)) { - throw new Error('Invalid proxy URI'); - } - ({ protocol } = new URL(typeof uri === 'string' ? uri : URL.prototype.toString.call(uri))); - } catch { - throw new Error('X.509 transport requires an approved CONNECT proxy endpoint.'); - } - if (protocol !== (proxy === 'https-connect' ? 'https:' : 'http:')) { - throw new Error('X.509 CONNECT proxy protocol must match its configured proxy mode.'); - } - assertVerifiedTLS(undiciState(dispatcher, 'request tls settings')); - if (proxy === 'https-connect') { - assertVerifiedTLS(undiciState(dispatcher, 'proxy tls settings')); - } - return; - } - - const configuration = undiciState(dispatcher, 'options'); - if (!configuration || typeof configuration !== 'object') { - throw new Error('X.509 transport requires inspectable certificate transport configuration.'); - } - if (originalAgentFactory === undefined) { - const baseline = new Agent(); - originalAgentFactory = undiciState(baseline, 'factory'); - void baseline.close(); - } - if (originalAgentFactory === undefined || undiciState(dispatcher, 'factory') !== originalAgentFactory) { - throw new Error('X.509 transport does not support a custom dispatcher factory.'); - } - assertVerifiedTLS(Object.getOwnPropertyDescriptor(configuration, 'connect')?.value); -} - function attestedDispatcher(options: X509TransportOptions): Agent | ProxyAgent { const dispatcher = dataOption(options, 'dispatcher'); if (!dispatcher || typeof dispatcher !== 'object') { @@ -193,8 +109,6 @@ function attestedDispatcher(options: X509TransportOptions): Agent | ProxyAgent { throw new Error('An X.509 CONNECT proxy requires an Undici ProxyAgent.'); } - assertDispatcherTrust(dispatcher, proxy); - return dispatcher; } @@ -239,12 +153,14 @@ function assertConnectProxySupport(): void { /** * Creates a frozen, opaque capability for one caller-owned Undici transport. * - * Existing caller-owned dispatchers remain supported only when their effective - * TLS settings preserve server verification and their actual CONNECT protocol - * matches the declared mode. Prefer the SDK-owned `fromX509` credential, which - * constructs verified target and proxy TLS settings from explicit configuration. - * Rotation requires creating a fresh dispatcher and capability; the caller - * remains responsible for draining caller-owned dispatchers. + * `certificateIdentity: 'static'` is an application attestation: the SDK does + * not inspect certificates, private dispatcher internals, callbacks, or TLS + * options and cannot cryptographically prove certificate selection. Configure + * trusted dispatcher factories, verified target and proxy TLS, one static + * certificate identity, and independently scoped CONNECT credentials. + * Prefer the SDK-owned `fromX509` credential when these guarantees should be + * enforced at construction. Rotation requires a fresh caller-owned dispatcher + * and capability; the application remains responsible for draining it. * * This Node-only preview entrypoint requires the optional `undici` peer at * version 5.2.0 or later. CONNECT proxy modes require version 5.5.1 or @@ -276,7 +192,7 @@ export function createX509Transport(options: X509TransportOptions): X509Transpor if (dispatcher instanceof ProxyAgent) { assertConnectProxySupport(); } - return new NodeX509Transport(dispatcher, dataOption(options, 'proxy') as X509ProxyMode); + return new NodeX509Transport(dispatcher); } /** Dispatches through the opaque attested transport without accepting replacement dispatchers. */ @@ -293,11 +209,6 @@ export async function sendX509Request( if (!dispatcher) { throw new Error('Invalid X.509 transport capability.'); } - const proxy = NodeX509Transport.proxy(transport); - if (!proxy) { - throw new Error('Invalid X.509 transport capability.'); - } - assertDispatcherTrust(dispatcher, proxy); const normalizedTarget = new URL(target.href); if (normalizedTarget.protocol !== 'https:') { diff --git a/tests/auth/workload-identity-auth.test.ts b/tests/auth/workload-identity-auth.test.ts index 1514b2690..108c36c9a 100644 --- a/tests/auth/workload-identity-auth.test.ts +++ b/tests/auth/workload-identity-auth.test.ts @@ -134,6 +134,70 @@ describe('WorkloadIdentityAuth', () => { ]); }); + test('snapshots inherited workload selectors and preserves the provider method receiver', async () => { + const selectors = { + identityProviderId: 'synthetic-inherited-identity-provider', + serviceAccountId: 'synthetic-inherited-service-account', + clientId: 'synthetic-inherited-client', + refreshBufferSeconds: 0, + }; + const provider = { + tokenType: 'jwt' as const, + subjectToken: 'synthetic-inherited-subject-token', + async getToken() { + return this.subjectToken; + }, + }; + + class InheritedWorkloadIdentity implements WorkloadIdentity { + readonly #selectors = selectors; + readonly #provider = provider; + + get identityProviderId() { + return this.#selectors.identityProviderId; + } + + get serviceAccountId() { + return this.#selectors.serviceAccountId; + } + + get clientId() { + return this.#selectors.clientId; + } + + get refreshBufferSeconds() { + return this.#selectors.refreshBufferSeconds; + } + + get provider() { + return this.#provider; + } + } + + const observedBodies: Record[] = []; + const auth = new WorkloadIdentityAuth(new InheritedWorkloadIdentity(), async (_url, init) => { + observedBodies.push(JSON.parse(String(init?.body)) as Record); + return tokenExchangeResponse('synthetic-inherited-access-token', 60); + }); + Object.assign(selectors, { + identityProviderId: 'synthetic-replaced-identity-provider', + serviceAccountId: 'synthetic-replaced-service-account', + clientId: 'synthetic-replaced-client', + refreshBufferSeconds: 1200, + }); + + await expect(auth.getToken()).resolves.toBe('synthetic-inherited-access-token'); + await expect(auth.getToken()).resolves.toBe('synthetic-inherited-access-token'); + expect(observedBodies).toEqual([ + expect.objectContaining({ + identity_provider_id: 'synthetic-inherited-identity-provider', + service_account_id: 'synthetic-inherited-service-account', + client_id: 'synthetic-inherited-client', + subject_token: 'synthetic-inherited-subject-token', + }), + ]); + }); + test('refreshes expired tokens', async () => { let providerCallCount = 0; let fetchCallCount = 0; diff --git a/tests/auth/x509-transport.test.ts b/tests/auth/x509-transport.test.ts index b74f6bf38..2b603d8bc 100644 --- a/tests/auth/x509-transport.test.ts +++ b/tests/auth/x509-transport.test.ts @@ -2,7 +2,7 @@ import { X509Certificate } from 'node:crypto'; import { once } from 'node:events'; import { createServer } from 'node:http'; import { inspect } from 'node:util'; -import { Agent, ProxyAgent, fetch } from 'undici'; +import { Agent, Pool, ProxyAgent, fetch } from 'undici'; import { vi } from 'vitest'; import { createX509Transport, fromX509, workloadIdentity } from 'openai/auth/x509-transport'; @@ -80,6 +80,33 @@ describe('SDK-owned X.509 credential transport', () => { ); }); + test('rejects proxied CONNECT URLs without invoking attacker-controlled traps', () => { + const trap = vi.fn(() => { + throw new Error('synthetic attacker-controlled URL prototype trap'); + }); + const url = new Proxy(new URL('http://127.0.0.1:1'), { getPrototypeOf: trap, get: trap }); + + expect(() => fromX509({ ...credentialOptions(), proxy: { url, mode: 'http-connect' } })).toThrow( + /proxy|URL/iu, + ); + expect(trap).not.toHaveBeenCalled(); + }); + + test('normalizes genuine CONNECT URLs without inspecting their prototype chains', async () => { + const trap = vi.fn(() => { + throw new Error('synthetic attacker-controlled URL prototype trap'); + }); + const url = new URL('http://127.0.0.1:1'); + Object.setPrototypeOf(url, new Proxy(URL.prototype, { getPrototypeOf: trap, get: trap })); + const credential = fromX509({ ...credentialOptions(), proxy: { url, mode: 'http-connect' } }); + + try { + expect(trap).not.toHaveBeenCalled(); + } finally { + await credential.close(); + } + }); + test('never exposes proxy credentials when rejecting a malformed proxy URL', () => { const secret = 'synthetic-private-proxy-password'; const options = { @@ -251,14 +278,12 @@ describe('explicit X.509 transport capability', () => { }, }), ]); - expect(dispatch).not.toHaveBeenCalled(); - dispatch.mockRestore(); - if (observesRequestDispatcher) { expect(() => createX509Transport(directOptions(dispatcher))).not.toThrow(); } else { expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/Undici 5\.2\.0 or later/u); } + expect(dispatch).not.toHaveBeenCalled(); } finally { await dispatcher.close(); } @@ -496,109 +521,37 @@ describe('explicit X.509 transport capability', () => { } }); - test('rejects an externally supplied Agent that disables TLS server verification', async () => { - const dispatcher = new Agent({ connect: { rejectUnauthorized: false } }); - - try { - expect(() => createX509Transport(directOptions(dispatcher))).toThrow( - /rejectUnauthorized|server verification|TLS/iu, - ); - } finally { - await dispatcher.close(); - } - }); - - test('rejects a dispatcher that disables TLS hostname verification', async () => { - const dispatcher = new Agent({ - connect: { checkServerIdentity: () => new Error('synthetic custom hostname verifier') }, - }); - - try { - expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/hostname|identity|TLS/iu); - } finally { - await dispatcher.close(); - } - }); - - test('rejects a dispatcher subclass that can intercept certificate-bearing requests', async () => { - const dispatcher = new Agent(); - Object.setPrototypeOf(dispatcher, Object.create(Agent.prototype)); - - try { - expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/dispatcher|trusted|subclass/iu); - } finally { - await dispatcher.close(); - } - }); - - test('rejects an own dispatcher override before it can observe authentication', async () => { - const dispatcher = new Agent(); - const originalDispatch = dispatcher.dispatch.bind(dispatcher); - Object.defineProperty(dispatcher, 'dispatch', { value: originalDispatch, configurable: true }); - - try { - expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/dispatcher|dispatch|trusted/iu); - } finally { - await dispatcher.close(); - } - }); - - test('rejects an own Undici symbol-dispatch override before it can observe authentication', async () => { - const dispatcher = new Agent(); - const symbol = Object.getOwnPropertySymbols(Agent.prototype).find( - (candidate) => candidate.description === 'dispatch', - ); - if (!symbol) { - throw new Error('Undici Agent does not expose its symbol-keyed dispatch method.'); - } - Object.defineProperty(dispatcher, symbol, { value: vi.fn(), configurable: true }); - - try { - expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/dispatcher|dispatch|trusted/iu); - } finally { - await dispatcher.close(); - } - }); - - test('rejects an externally supplied Agent with a custom dispatcher factory', async () => { + test('preserves a caller-attested Agent with an application-owned dispatcher factory', async () => { const factory = vi.fn(() => new Agent()); const dispatcher = new Agent({ factory }); try { - expect(() => createX509Transport(directOptions(dispatcher))).toThrow(/factory|trusted|transport/iu); + expect(() => createX509Transport(directOptions(dispatcher))).not.toThrow(); expect(factory).not.toHaveBeenCalled(); } finally { await dispatcher.close(); } }); - test('rejects globally disabled TLS verification unless the dispatcher explicitly enables it', async () => { - vi.stubEnv('NODE_TLS_REJECT_UNAUTHORIZED', '0'); - const inherited = new Agent(); - const explicit = new Agent({ connect: { rejectUnauthorized: true } }); - let credential: ReturnType | undefined; + test('preserves application-owned dispatcher instrumentation', async () => { + const dispatcher = new Agent(); + const dispatch = vi.spyOn(dispatcher, 'dispatch'); try { - expect(() => createX509Transport(directOptions(inherited))).toThrow( - /rejectUnauthorized|server verification|TLS/iu, - ); - expect(() => createX509Transport(directOptions(explicit))).not.toThrow(); - credential = fromX509({ - ...credentialOptions(), - proxy: { url: 'http://127.0.0.1:1', mode: 'http-connect' }, - }); + expect(() => createX509Transport(directOptions(dispatcher))).not.toThrow(); + expect(dispatch).not.toHaveBeenCalled(); } finally { - vi.unstubAllEnvs(); - await credential?.close(); - await Promise.all([inherited.close(), explicit.close()]); + dispatch.mockRestore(); + await dispatcher.close(); } }); test.each([ - { url: 'http://127.0.0.1:1', mode: 'https-connect' }, - { url: 'https://127.0.0.1:1', mode: 'http-connect' }, - ] as const)('rejects an external ProxyAgent whose protocol contradicts $mode', async ({ url, mode }) => { - const dispatcher = new ProxyAgent({ uri: url }); + { url: 'http://127.0.0.1:1', mode: 'http-connect' }, + { url: 'https://127.0.0.1:1', mode: 'https-connect' }, + ] as const)('preserves caller-attested $mode target factories', async ({ url, mode }) => { + const factory = vi.fn(() => new Agent()); + const dispatcher = new ProxyAgent({ uri: url, factory }); try { expect(() => @@ -608,60 +561,31 @@ describe('explicit X.509 transport capability', () => { certificateIdentity: 'static', proxy: mode, }), - ).toThrow(/proxy|protocol|HTTPS/iu); + ).not.toThrow(); + expect(factory).not.toHaveBeenCalled(); } finally { await dispatcher.close(); } }); test.each([ - { - label: 'target', - options: { uri: 'http://127.0.0.1:1', requestTls: { rejectUnauthorized: false } }, - mode: 'http-connect', - }, - { - label: 'proxy', - options: { uri: 'https://127.0.0.1:1', proxyTls: { rejectUnauthorized: false } }, - mode: 'https-connect', - }, - ] as const)( - 'rejects an external ProxyAgent with disabled $label TLS verification', - async ({ options, mode }) => { - const dispatcher = new ProxyAgent(options); - - try { - expect(() => - createX509Transport({ - runtime: 'node', - dispatcher, - certificateIdentity: 'static', - proxy: mode, - }), - ).toThrow(/rejectUnauthorized|server verification|TLS/iu); - } finally { - await dispatcher.close(); - } - }, - ); - - test('revalidates external TLS verification immediately before dispatch', async () => { - const dispatcher = new Agent({ connect: { rejectUnauthorized: true } }); + { url: 'http://127.0.0.1:1', mode: 'http-connect' }, + { url: 'https://127.0.0.1:1', mode: 'https-connect' }, + ] as const)('preserves caller-attested $mode proxy-client factories', async ({ url, mode }) => { + const proxyClient = new Pool(url); + const clientFactory = vi.fn(() => proxyClient); + const dispatcher = new ProxyAgent({ uri: url, clientFactory }); try { - const capability = createX509Transport(directOptions(dispatcher)); - const stateKey = Object.getOwnPropertySymbols(dispatcher).find( - (symbol) => symbol.description === 'options', - ); - const state = stateKey ? Object.getOwnPropertyDescriptor(dispatcher, stateKey)?.value : undefined; - if (!state || typeof state !== 'object' || !state.connect || typeof state.connect !== 'object') { - throw new Error('Expected genuine Undici Agent connection settings'); - } - state.connect.rejectUnauthorized = false; - - await expect(sendX509Request(capability, new URL('https://example.invalid'), {})).rejects.toThrow( - /rejectUnauthorized|server verification|TLS/iu, - ); + expect(() => + createX509Transport({ + runtime: 'node', + dispatcher, + certificateIdentity: 'static', + proxy: mode, + }), + ).not.toThrow(); + expect(clientFactory).toHaveBeenCalledTimes(1); } finally { await dispatcher.close(); } diff --git a/tests/lib/provider.test.ts b/tests/lib/provider.test.ts index f1c20d7cb..dbeb7721d 100644 --- a/tests/lib/provider.test.ts +++ b/tests/lib/provider.test.ts @@ -151,6 +151,108 @@ describe('provider', () => { expect(requestedHeaders?.get('x-provider-custom')).toBe('preserve-me'); }); + test('clears provider-owned routing before installing workload identity', async () => { + delete process.env['OPENAI_API_KEY']; + delete process.env['OPENAI_ADMIN_KEY']; + delete process.env['OPENAI_BASE_URL']; + delete process.env['OPENAI_ORG_ID']; + delete process.env['OPENAI_PROJECT_ID']; + + const accessToken = 'synthetic-openai-workload-access-token'; + const respond = async (url: string | URL | Request) => + String(url).includes('/oauth/token') + ? Response.json({ access_token: accessToken, token_type: 'Bearer', expires_in: 3600 }) + : Response.json({ data: [] }); + const inheritedFetch = vi.fn(respond); + const replacementFetch = vi.spyOn(globalThis, 'fetch').mockImplementation(respond); + + try { + const original = new OpenAI({ + provider: provider(), + organization: 'synthetic-provider-organization', + project: 'synthetic-provider-project', + defaultHeaders: { 'x-provider-private': 'synthetic-provider-private-header' }, + defaultQuery: { api_key: 'synthetic-provider-private-api-key' }, + fetchOptions: { credentials: 'include' }, + fetch: inheritedFetch, + }); + const replacement = original.withOptions({ + workloadIdentity: { + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + provider: { tokenType: 'jwt', getToken: async () => 'synthetic-subject-token' }, + }, + }); + + await replacement.models.list(); + + expect(replacement.baseURL).toBe('https://api.openai.com/v1'); + expect(replacement.organization).toBeNull(); + expect(replacement.project).toBeNull(); + expect(replacement.fetchOptions).toBeUndefined(); + expect(inheritedFetch).not.toHaveBeenCalled(); + expect(replacementFetch).toHaveBeenCalledTimes(2); + expect(String(replacementFetch.mock.calls[0]?.[0])).toBe('https://auth.openai.com/oauth/token'); + expect(String(replacementFetch.mock.calls[1]?.[0])).toBe('https://api.openai.com/v1/models'); + const requestHeaders = new Headers(replacementFetch.mock.calls[1]?.[1]?.headers); + expect(requestHeaders.get('authorization')).toBe(`Bearer ${accessToken}`); + expect(requestHeaders.has('x-provider-private')).toBe(false); + } finally { + replacementFetch.mockRestore(); + } + }); + + test('preserves explicit workload-identity routing when replacing a provider', async () => { + delete process.env['OPENAI_API_KEY']; + delete process.env['OPENAI_ADMIN_KEY']; + + const inheritedFetch = vi.fn(async () => Response.json({ data: [] })); + const replacementFetch = vi.fn(async (url: string | URL | Request, _init?: RequestInit) => + String(url).includes('/oauth/token') + ? Response.json({ + access_token: 'synthetic-openai-workload-access-token', + token_type: 'Bearer', + expires_in: 3600, + }) + : Response.json({ data: [] }), + ); + const replacementFetchOptions = { cache: 'no-store' as const }; + const original = new OpenAI({ + provider: provider(), + defaultHeaders: { 'x-provider-private': 'synthetic-provider-private-header' }, + defaultQuery: { api_key: 'synthetic-provider-private-api-key' }, + fetchOptions: { credentials: 'include' }, + fetch: inheritedFetch, + }); + const replacement = original.withOptions({ + workloadIdentity: { + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + provider: { tokenType: 'jwt', getToken: async () => 'synthetic-subject-token' }, + }, + baseURL: 'https://openai.example/v1', + organization: 'synthetic-openai-organization', + project: 'synthetic-openai-project', + defaultHeaders: { 'x-workload-custom': 'synthetic-workload-header' }, + defaultQuery: { page: '1' }, + fetchOptions: replacementFetchOptions, + fetch: replacementFetch, + }); + + await replacement.models.list(); + + expect(replacement.baseURL).toBe('https://openai.example/v1'); + expect(replacement.organization).toBe('synthetic-openai-organization'); + expect(replacement.project).toBe('synthetic-openai-project'); + expect(replacement.fetchOptions).toBe(replacementFetchOptions); + expect(inheritedFetch).not.toHaveBeenCalled(); + expect(replacementFetch).toHaveBeenCalledTimes(2); + expect(String(replacementFetch.mock.calls[1]?.[0])).toBe('https://openai.example/v1/models?page=1'); + const requestHeaders = new Headers(replacementFetch.mock.calls[1]?.[1]?.headers); + expect(requestHeaders.get('x-workload-custom')).toBe('synthetic-workload-header'); + expect(requestHeaders.has('x-provider-private')).toBe(false); + }); + test('does not let a request-level default base URL replace the provider base URL', () => { const client = new OpenAI({ provider: provider({ baseURL: 'https://api.openai.com/v1' }), From de37bfc416767810fb8f15c4a269d40fd7f31cb0 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 26 Aug 2026 17:25:49 -0700 Subject: [PATCH 11/11] refactor(auth): move provider cloning out of generated client --- src/client.ts | 22 +------ src/internal/auth/x509-credential-options.ts | 63 ++++++++++++++------ 2 files changed, 47 insertions(+), 38 deletions(-) diff --git a/src/client.ts b/src/client.ts index 21f1f2cc3..a43437538 100644 --- a/src/client.ts +++ b/src/client.ts @@ -651,9 +651,6 @@ export class OpenAI { */ withOptions(options: Partial): this { const residencyBaseURL = resolveDataResidency(options); - const inheritedProvider = this._options.provider; - const replacingProvider = options.credential ?? options.workloadIdentity; - const provider = options.provider ?? (replacingProvider ? undefined : inheritedProvider); const x509Authentication = this.#x509Authentication; const inheritedOptions: ClientOptions = { ...this._options, @@ -672,7 +669,7 @@ export class OpenAI { project: this.project, webhookSecret: this.webhookSecret, }; - const credential = prepareX509ClientClone( + const { credential, provider } = prepareX509ClientClone( inheritedOptions, options, this.#x509Credential, @@ -681,23 +678,6 @@ export class OpenAI { if (residencyBaseURL !== undefined) { delete inheritedOptions.baseURL; } - if (provider !== inheritedProvider) { - delete inheritedOptions.baseURL; - delete inheritedOptions.organization; - delete inheritedOptions.project; - delete inheritedOptions.defaultHeaders; - delete inheritedOptions.defaultQuery; - delete inheritedOptions.fetchOptions; - delete inheritedOptions.fetch; - } - if (provider) { - delete inheritedOptions.apiKey; - delete inheritedOptions.adminAPIKey; - delete inheritedOptions.credential; - delete inheritedOptions.workloadIdentity; - delete inheritedOptions.x509Transport; - delete inheritedOptions.baseURL; - } const clientOptions: InternalClientOptions = { ...inheritedOptions, diff --git a/src/internal/auth/x509-credential-options.ts b/src/internal/auth/x509-credential-options.ts index 19d04d596..a2565366a 100644 --- a/src/internal/auth/x509-credential-options.ts +++ b/src/internal/auth/x509-credential-options.ts @@ -52,13 +52,42 @@ function overridesOrdinaryAuthentication({ apiKey, adminAPIKey }: Partial, +): ClientOptions['provider'] { + const inheritedProvider = inherited.provider; + const replacingProvider = overrides.credential ?? overrides.workloadIdentity; + const provider = overrides.provider ?? (replacingProvider ? undefined : inheritedProvider); + + if (provider !== inheritedProvider) { + delete inherited.baseURL; + delete inherited.organization; + delete inherited.project; + delete inherited.defaultHeaders; + delete inherited.defaultQuery; + delete inherited.fetchOptions; + delete inherited.fetch; + } + if (provider) { + delete inherited.apiKey; + delete inherited.adminAPIKey; + delete inherited.credential; + delete inherited.workloadIdentity; + delete inherited.x509Transport; + delete inherited.baseURL; + } + return provider; +} + +/** Reconciles one client's credential and provider ownership before cloning its options. */ export function prepareX509ClientClone( inherited: ClientOptions, overrides: Partial, credential: X509Credential | undefined, currentlyX509: boolean, -): X509Credential | undefined { +): { credential: X509Credential | undefined; provider: ClientOptions['provider'] } { const nextIdentity = hasOwn(overrides, 'workloadIdentity') ? overrides.workloadIdentity : inherited.workloadIdentity; @@ -98,20 +127,20 @@ export function prepareX509ClientClone( } } - if (nextCredential === undefined) { - return undefined; - } - delete inherited.apiKey; - delete inherited.adminAPIKey; - delete inherited.workloadIdentity; - delete inherited.x509Transport; - inherited.credential = nextCredential; - if (overrides.credential !== undefined) { - delete inherited.organization; - delete inherited.project; - delete inherited.defaultHeaders; - delete inherited.defaultQuery; - delete inherited.fetchOptions; + if (nextCredential !== undefined) { + delete inherited.apiKey; + delete inherited.adminAPIKey; + delete inherited.workloadIdentity; + delete inherited.x509Transport; + inherited.credential = nextCredential; + if (overrides.credential !== undefined) { + delete inherited.organization; + delete inherited.project; + delete inherited.defaultHeaders; + delete inherited.defaultQuery; + delete inherited.fetchOptions; + } } - return nextCredential; + + return { credential: nextCredential, provider: prepareProviderClone(inherited, overrides) }; }