diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3ed34d19..c475022f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,49 @@ jobs: ./scripts/build node --experimental-strip-types scripts/test-packed-package.ts + - name: Prepare isolated minimum-runtime package and synthetic certificate + if: matrix.node-version == 22 + run: | + package_tarball="$(npm pack "$GITHUB_WORKSPACE/dist" --ignore-scripts --silent --pack-destination "$RUNNER_TEMP")" + echo "X509_FLOOR_PACKAGE=${RUNNER_TEMP}/${package_tarball}" >> "$GITHUB_ENV" + node -r ts-node/register/transpile-only --eval ' + const { writeFileSync } = require("node:fs"); + const { join } = require("node:path"); + const { createX509TestLab } = require("./tests/utils/x509-test-lab.ts"); + const { firstClient } = createX509TestLab(); + writeFileSync(join(process.env.RUNNER_TEMP, "openai-x509-minimum-fixture.json"), JSON.stringify({ certificateChain: firstClient.certificate.toString(), privateKey: firstClient.privateKey.toString() })); + ' + + - name: Set up exact minimum supported Node.js runtime + if: matrix.node-version == 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22.0.0' + + - name: Verify first-class X.509 authentication on the exact Node.js floor + if: matrix.node-version == 22 + run: | + floor_consumer="${RUNNER_TEMP}/openai-node-minimum-runtime" + npm install --ignore-scripts --no-audit --no-fund --prefix "$floor_consumer" "$X509_FLOOR_PACKAGE" 'undici@^7' + cd "$floor_consumer" + node --input-type=module --eval ' + import { readFileSync } from "node:fs"; + import { join } from "node:path"; + import OpenAI from "openai"; + import { Agent, ProxyAgent } from "undici"; + import { createX509Transport, fromX509, workloadIdentity } from "openai/auth/x509-transport"; + if (process.version !== "v22.0.0" || workloadIdentity.fromX509 !== fromX509) throw new Error("Invalid minimum-runtime X.509 exports"); + const material = JSON.parse(readFileSync(join(process.env.RUNNER_TEMP, "openai-x509-minimum-fixture.json"), "utf8")); + const credential = fromX509({ ...material, identityProviderId: "synthetic-provider", serviceAccountId: "synthetic-account" }); + new OpenAI({ credential }); + await credential.close(); + for (const [proxy, dispatcher] of [["direct", new Agent()], ["http-connect", new ProxyAgent({ uri: "http://127.0.0.1:1" })], ["https-connect", new ProxyAgent({ uri: "https://127.0.0.1:1" })]]) { + const x509Transport = createX509Transport({ runtime: "node", dispatcher, certificateIdentity: "static", proxy }); + new OpenAI({ apiKey: null, workloadIdentity: { type: "x509", identityProviderId: "synthetic-provider", serviceAccountId: "synthetic-account" }, x509Transport }); + await dispatcher.close(); + } + ' + test_matrix: name: ${{ (github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork || github.event.action == 'ready_for_review') && 'test matrix' || 'test matrix (not run)' }} if: ${{ always() && (github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.fork || github.event.action == 'ready_for_review') }} diff --git a/README.md b/README.md index 6f749f6d1..9e80f3c36 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 client = new OpenAI({ - apiKey: null, - adminAPIKey: null, - baseURL: null, - organization: null, - project: process.env['OPENAI_X509_PROJECT_ID'] ?? null, - workloadIdentity: { - type: 'x509', - identityProviderId: process.env['OPENAI_X509_IDENTITY_PROVIDER_ID']!, - serviceAccountId: process.env['OPENAI_X509_SERVICE_ACCOUNT_ID']!, - }, - x509Transport: createX509Transport({ - runtime: 'node', - dispatcher, - certificateIdentity: 'static', - proxy: 'direct', - }), +const credential = workloadIdentity.fromX509({ + certificateChain: process.env['OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM']!, + privateKey: process.env['OPENAI_X509_CLIENT_PRIVATE_KEY_PEM']!, + identityProviderId: process.env['OPENAI_X509_IDENTITY_PROVIDER_ID']!, + serviceAccountId: process.env['OPENAI_X509_SERVICE_ACCOUNT_ID']!, }); try { + const client = new OpenAI({ + credential, + project: process.env['OPENAI_X509_PROJECT_ID'] ?? null, + }); + console.log((await client.models.list()).data.length); } finally { - await dispatcher.close(); + await credential.close(); } ``` -The SDK caches short-lived credentials in memory, isolates certificate generations, bounds retries and cancellation, and never closes the caller-owned dispatcher. Configure proactive refresh with optional `workloadIdentity.refreshBufferMs`; it defaults to 1,200,000 milliseconds (20 minutes) and is capped at half of the actual token lifetime. For CONNECT proxies, encrypted private keys, live verification, and certificate rotation, see the [X.509 workload-identity example](./examples/mtls/README.md#x509-workload-identity-nodejs). +The SDK owns the credential's verified TLS transport, caches short-lived tokens in memory, isolates certificate generations, and bounds retries and cancellation. Set `refreshBufferSeconds` on the credential to configure proactive refresh; it defaults to 1,200 seconds (20 minutes) and is capped at half of the token's lifetime. For CONNECT proxies, encrypted private keys, live verification, and certificate rotation, see the [X.509 workload-identity example](./examples/mtls/README.md#x509-workload-identity-nodejs). ## Streaming responses 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..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 ``` @@ -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. 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: @@ -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/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/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 7b20900e6..326e3cc9a 100644 --- a/package.json +++ b/package.json @@ -87,12 +87,22 @@ "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-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 6561ea901..544e0e262 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,12 @@ 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-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', @@ -248,67 +268,47 @@ 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, 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 }), @@ -342,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( @@ -363,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(' '), ], @@ -489,7 +494,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', @@ -498,29 +502,28 @@ 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'); - 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/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/workload-identity-auth.ts b/src/auth/workload-identity-auth.ts index 59b5b4cbd..d8974a30c 100644 --- a/src/auth/workload-identity-auth.ts +++ b/src/auth/workload-identity-auth.ts @@ -133,7 +133,17 @@ export class WorkloadIdentityAuth { * @param fetch Optional fetch implementation for calls to the OpenAI token endpoint. */ constructor(config: WorkloadIdentity, fetch?: Fetch) { - this.config = config; + const { identityProviderId, serviceAccountId, clientId, refreshBufferSeconds, provider } = config; + this.config = { + identityProviderId, + serviceAccountId, + ...(clientId === undefined ? {} : { clientId }), + ...(refreshBufferSeconds === undefined ? {} : { refreshBufferSeconds }), + 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 4dd40c47d..c93c51d0e 100644 --- a/src/auth/x509-transport.ts +++ b/src/auth/x509-transport.ts @@ -1,15 +1,284 @@ 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 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 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, +): { dispatcher: Agent | ProxyAgent; proxy: X509ProxyMode } { + if (proxyOptionsInput === undefined) { + return { dispatcher: new Agent({ connect: requestTls }), proxy: 'direct' }; + } + + const proxyOptions = safeOptionRecord(proxyOptionsInput, proxyOptionNames, 'proxy'); + const url = normalizeProxyURL(proxyOptions['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.'); + } + const auth = proxyAuthentication(url); + + return { + proxy, + dispatcher: new ProxyAgent({ + uri: url.href, + ...(auth === undefined ? {} : { auth }), + 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 +311,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/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 292c37d16..a43437538 100644 --- a/src/client.ts +++ b/src/client.ts @@ -17,17 +17,20 @@ 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, assertX509APIOrigin } from './internal/auth/x509-api-origin'; import { - X509_API_BASE_URL, X509WorkloadIdentityAuth, - assertX509APIOrigin, assertX509RequestOptions, isX509WorkloadIdentity, snapshotX509RequestOptions, } from './internal/auth/x509-workload-identity-auth'; import type { X509Transport } from './internal/auth/x509-transport-registry'; +import { + normalizeX509CredentialOptions, + prepareX509ClientClone, +} from './internal/auth/x509-credential-options'; import { isTransientX509ConnectionError, markApprovedX509Client } from '#x509-transport-state'; import { OAuthError, SubjectTokenProviderError } from './core/error'; import { @@ -258,6 +261,7 @@ import { formatRequestDetails, loggerFor, parseLogLevel, + redactURL, } from './internal/utils/log'; import { isEmptyObj } from './internal/utils/values'; @@ -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); @@ -639,8 +651,6 @@ export class OpenAI { */ withOptions(options: Partial): this { const residencyBaseURL = resolveDataResidency(options); - const inheritedProvider = this._options.provider; - const provider = options.provider ?? inheritedProvider; const x509Authentication = this.#x509Authentication; const inheritedOptions: ClientOptions = { ...this._options, @@ -659,47 +669,41 @@ 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; - } - } + const { credential, provider } = prepareX509ClientClone( + inheritedOptions, + options, + this.#x509Credential, + x509Authentication !== undefined, + ); if (residencyBaseURL !== undefined) { delete inheritedOptions.baseURL; } - if (provider) { - delete inheritedOptions.apiKey; - delete inheritedOptions.adminAPIKey; - delete inheritedOptions.workloadIdentity; - delete inheritedOptions.x509Transport; - delete inheritedOptions.baseURL; - if (provider !== inheritedProvider) { - delete inheritedOptions.organization; - delete inheritedOptions.project; - delete inheritedOptions.defaultHeaders; - } - } const clientOptions: InternalClientOptions = { ...inheritedOptions, ...options, + credential, provider, [inheritedDataResidencySelection]: 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); + 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 && @@ -1151,9 +1155,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 { @@ -1346,7 +1351,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 new file mode 100644 index 000000000..a2565366a --- /dev/null +++ b/src/internal/auth/x509-credential-options.ts @@ -0,0 +1,146 @@ +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, + }, + }; +} + +/** Distinguishes explicitly supplied ordinary credentials from nullish inheritance. */ +function overridesOrdinaryAuthentication({ apiKey, adminAPIKey }: Partial): boolean { + return (apiKey !== null && apiKey !== undefined) || (adminAPIKey !== null && adminAPIKey !== undefined); +} + +/** Keeps origin-bound configuration with its owning provider during a client clone. */ +function prepareProviderClone( + inherited: ClientOptions, + overrides: 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, +): { credential: X509Credential | undefined; provider: ClientOptions['provider'] } { + const nextIdentity = hasOwn(overrides, 'workloadIdentity') + ? overrides.workloadIdentity + : inherited.workloadIdentity; + const dropping = + credential !== undefined && + ((overridesOrdinaryAuthentication(overrides) && overrides.workloadIdentity === undefined) || + overrides.provider !== undefined); + if (credential !== undefined && hasOwn(overrides, 'workloadIdentity')) { + delete inherited.x509Transport; + } + const inheritedCredential = + credential !== undefined && + !dropping && + overrides.credential === undefined && + !hasOwn(overrides, 'workloadIdentity') && + !hasOwn(overrides, 'x509Transport') + ? credential + : undefined; + const nextCredential = overrides.credential === undefined ? inheritedCredential : overrides.credential; + const nextX509 = nextCredential !== undefined || (!dropping && isX509WorkloadIdentity(nextIdentity)); + + 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 { + delete inherited.x509Transport; + if (dropping) { + delete inherited.workloadIdentity; + } + } + } + + 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 { credential: nextCredential, provider: prepareProviderClone(inherited, overrides) }; +} diff --git a/src/internal/auth/x509-transport-capability.ts b/src/internal/auth/x509-transport-capability.ts index f168498b4..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. */ @@ -150,10 +156,11 @@ function assertConnectProxySupport(): void { * `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. + * 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 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..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) { @@ -235,6 +213,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 +233,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 +270,9 @@ export class X509WorkloadIdentityAuth { ...(this.#configuredRefreshBufferMs === undefined ? {} : { refreshBufferMs: this.#configuredRefreshBufferMs }), + ...(this.#configuredRefreshBufferSeconds === undefined + ? {} + : { refreshBufferSeconds: this.#configuredRefreshBufferSeconds }), }; } @@ -365,6 +364,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 +385,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 +563,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; @@ -820,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..56689bb31 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -82,6 +82,62 @@ 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 { + const normalized = name.toLowerCase().replace(/[-_]/gu, ''); + return sensitiveQueryNames.has(normalized) || sensitiveQueryNames.has(normalized.replace(/^x/u, '')); +} + +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, '-')) || isSensitiveQueryParameter(name); +} + +/** 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 +153,30 @@ 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]) => [ + 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..108c36c9a 100644 --- a/tests/auth/workload-identity-auth.test.ts +++ b/tests/auth/workload-identity-auth.test.ts @@ -102,6 +102,102 @@ 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('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-conformance.test.ts b/tests/auth/x509-transport-conformance.test.ts index 3c63a9548..a0064365a 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 { createX509Transport, fromX509 } from 'openai/auth/x509-transport'; +import { createProvider } from 'openai/internal/provider'; import { closeObservedServers, @@ -113,6 +115,493 @@ 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, + 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(); + } + }); + + 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('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(), + 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(), + privateKey: lab.firstClient.privateKey.toString(), + identityProviderId: 'synthetic-identity-provider', + serviceAccountId: 'synthetic-service-account', + }); + + try { + 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' }), + }); + const clone = original.withOptions({ provider }); + + 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(); + } + }); + + 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', + }); + + 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 }, + { + 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, + (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); + 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 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: [] }); + + 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', + 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] : [])); + } + }, + ); +}); + +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 1ff7f1998..2b603d8bc 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 { Agent, ProxyAgent, fetch } from 'undici'; +import { inspect } from 'node:util'; +import { Agent, Pool, 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,243 @@ 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('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 = { + ...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 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); + 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(); @@ -40,7 +278,6 @@ describe('explicit X.509 transport capability', () => { }, }), ]); - if (observesRequestDispatcher) { expect(() => createX509Transport(directOptions(dispatcher))).not.toThrow(); } else { @@ -284,6 +521,76 @@ describe('explicit X.509 transport capability', () => { } }); + 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))).not.toThrow(); + expect(factory).not.toHaveBeenCalled(); + } finally { + await dispatcher.close(); + } + }); + + test('preserves application-owned dispatcher instrumentation', async () => { + const dispatcher = new Agent(); + const dispatch = vi.spyOn(dispatcher, 'dispatch'); + + try { + expect(() => createX509Transport(directOptions(dispatcher))).not.toThrow(); + expect(dispatch).not.toHaveBeenCalled(); + } finally { + dispatch.mockRestore(); + await dispatcher.close(); + } + }); + + test.each([ + { 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(() => + createX509Transport({ + runtime: 'node', + dispatcher, + certificateIdentity: 'static', + proxy: mode, + }), + ).not.toThrow(); + expect(factory).not.toHaveBeenCalled(); + } finally { + await dispatcher.close(); + } + }); + + test.each([ + { 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 { + expect(() => + createX509Transport({ + runtime: 'node', + dispatcher, + certificateIdentity: 'static', + proxy: mode, + }), + ).not.toThrow(); + expect(clientFactory).toHaveBeenCalledTimes(1); + } 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/provider.test.ts b/tests/lib/provider.test.ts index cd734404c..dbeb7721d 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; @@ -150,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' }), @@ -229,15 +332,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 +348,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 +463,117 @@ 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.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) => { + 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({})); + 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-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()]: '***' }); +}); 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..1862adbb8 --- /dev/null +++ b/tests/lib/x509-workload-credential-config.test.ts @@ -0,0 +1,129 @@ +import { Agent } from 'undici'; +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'; + +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('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) => { + 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..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,16 +66,17 @@ 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([ ['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-identity.test.ts b/tests/lib/x509-workload-identity.test.ts index bd02ffc97..a14b23ae8 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, @@ -283,12 +284,67 @@ 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('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', + 'access_token', + 'session_token', + '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) => { + 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 +570,30 @@ 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-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(); + 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/lib/x509-workload-request-boundaries.test.ts b/tests/lib/x509-workload-request-boundaries.test.ts index 57af24244..ed4cc1a11 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,119 @@ 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; + 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: [] }); + }); + + 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' }, + defaultQuery: { api_key: 'synthetic-original-private-api-key' }, + }); + 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(dispatchedURL?.href).toBe('https://mtls.api.openai.com/v1/models'); + 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 +626,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'); }); 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 {