diff --git a/.changeset/fix-mtls-dispatcher-fetch.md b/.changeset/fix-mtls-dispatcher-fetch.md new file mode 100644 index 000000000..b99e0d89a --- /dev/null +++ b/.changeset/fix-mtls-dispatcher-fetch.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-tooling-sdk': patch +--- + +Make mTLS / self-signed client certificates robust against Node's bundled undici version. The TLS dispatcher is an undici `Agent` from the `undici` npm package, but it was handed to `global.fetch`, which is backed by whatever undici Node bundles internally — a version that drifts across Node releases and can be a different major than the npm package. Because undici's request-handler interface changed across majors (and the cross-version compatibility shim is removed in undici 8), pairing a foreign Agent with `global.fetch` can fail and silently drop the client certificate. Requests that carry a dispatcher now use undici's own `fetch` so the Agent and fetch always share one undici instance, regardless of Node version. Applies to all auth strategies (basic, client-credentials, JWT, implicit, API key), so staging deploys with `--certificate`/`--selfsigned` keep working as Node updates its bundled undici. diff --git a/packages/b2c-tooling-sdk/src/auth/api-key.ts b/packages/b2c-tooling-sdk/src/auth/api-key.ts index 6269947d8..52bc12c5b 100644 --- a/packages/b2c-tooling-sdk/src/auth/api-key.ts +++ b/packages/b2c-tooling-sdk/src/auth/api-key.ts @@ -4,6 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import type {AuthStrategy, FetchInit} from './types.js'; +import {dispatchFetch} from './dispatch-fetch.js'; import {getLogger} from '../logging/logger.js'; /** @@ -43,8 +44,8 @@ export class ApiKeyStrategy implements AuthStrategy { async fetch(url: string, init: FetchInit = {}): Promise { const headers = new Headers(init.headers); headers.set(this.headerName, this.headerValue); - // Pass through dispatcher for TLS/mTLS support - return fetch(url, {...init, headers} as RequestInit); + // Pass through dispatcher for TLS/mTLS support (see dispatchFetch) + return dispatchFetch(url, {...init, headers}); } /** diff --git a/packages/b2c-tooling-sdk/src/auth/basic.ts b/packages/b2c-tooling-sdk/src/auth/basic.ts index 344af74bd..72636a0c6 100644 --- a/packages/b2c-tooling-sdk/src/auth/basic.ts +++ b/packages/b2c-tooling-sdk/src/auth/basic.ts @@ -4,6 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import type {AuthStrategy, FetchInit} from './types.js'; +import {dispatchFetch} from './dispatch-fetch.js'; import {getLogger} from '../logging/logger.js'; export class BasicAuthStrategy implements AuthStrategy { @@ -19,9 +20,8 @@ export class BasicAuthStrategy implements AuthStrategy { async fetch(url: string, init: FetchInit = {}): Promise { const headers = new Headers(init.headers); headers.set('Authorization', `Basic ${this.encoded}`); - // Pass through dispatcher for TLS/mTLS support - // Node.js fetch accepts dispatcher as an undocumented option - return fetch(url, {...init, headers} as RequestInit); + // Pass through dispatcher for TLS/mTLS support (see dispatchFetch) + return dispatchFetch(url, {...init, headers}); } async getAuthorizationHeader(): Promise { diff --git a/packages/b2c-tooling-sdk/src/auth/dispatch-fetch.ts b/packages/b2c-tooling-sdk/src/auth/dispatch-fetch.ts new file mode 100644 index 000000000..6fe399f28 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/auth/dispatch-fetch.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {fetch as undiciFetch} from 'undici'; +import type {FetchInit} from './types.js'; + +// undiciFetch returns undici's Response type; cast to the global Response at the +// call site. The two are structurally compatible for our usage. +type FetchFn = (url: string, init?: RequestInit) => Promise; + +/** + * Performs a fetch, transparently routing requests that carry an undici + * `dispatcher` (mTLS / self-signed TLS Agent) through undici's own `fetch`. + * + * Why this exists: the `dispatcher` is an undici `Agent` constructed from the + * `undici` npm package (see clients/tls-dispatcher.ts). `global.fetch` is backed + * by whatever undici Node bundles internally, which drifts across Node + * minor/patch releases and can be a different major than the npm package + * (e.g. Node 22 bundles undici 6.x, Node 24 bundles 7.x, the npm dep is pinned to + * 7.x). The undici `Dispatcher` request-handler interface changed across undici + * 6/7/8: undici 7 still ships a compatibility shim that lets a foreign Agent + * accept the bundled fetch's handler, but that shim was removed in undici 8 + * (handler validation now throws `invalid onRequestStart method`). So handing a + * foreign Agent to `global.fetch` across that version boundary can fail and + * silently drop the client certificate. Routing dispatcher-bearing requests + * through undici's own `fetch` keeps the Agent and the fetch on a single + * undici instance, eliminating this class of failure regardless of which undici + * Node bundles. + * + * When no dispatcher is present we use `global.fetch` to preserve the built-in + * behaviour (and existing test coverage) for the common case. + * + * @param url - Request URL + * @param init - Fetch init; may include an undici `dispatcher` + * @returns The fetch response + */ +export function dispatchFetch(url: string, init: FetchInit = {}): Promise { + const fetchFn = (init.dispatcher ? undiciFetch : fetch) as FetchFn; + return fetchFn(url, init as RequestInit); +} diff --git a/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts b/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts index 2456e62de..7fd5232a7 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts @@ -7,6 +7,7 @@ import {createServer, type Server, type IncomingMessage, type ServerResponse} fr import type {Socket} from 'node:net'; import {URL} from 'node:url'; import type {AuthStrategy, AccessTokenResponse, DecodedJWT, FetchInit} from './types.js'; +import {dispatchFetch} from './dispatch-fetch.js'; import {getLogger} from '../logging/logger.js'; import {decodeJWT} from './oauth.js'; import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; @@ -147,8 +148,8 @@ export class ImplicitOAuthStrategy implements AuthStrategy { headers.set('x-dw-client-id', this.config.clientId); const startTime = Date.now(); - // Pass through dispatcher for TLS/mTLS support - let res = await fetch(url, {...init, headers} as RequestInit); + // Pass through dispatcher for TLS/mTLS support (see dispatchFetch) + let res = await dispatchFetch(url, {...init, headers}); const duration = Date.now() - startTime; logger.debug({method, url, status: res.status, duration}, '[Auth] Response'); @@ -167,7 +168,7 @@ export class ImplicitOAuthStrategy implements AuthStrategy { headers.set('Authorization', `Bearer ${newToken}`); const retryStart = Date.now(); - res = await fetch(url, {...init, headers} as RequestInit); + res = await dispatchFetch(url, {...init, headers}); const retryDuration = Date.now() - retryStart; logger.debug({method, url, status: res.status, duration: retryDuration}, '[Auth] Retry response'); diff --git a/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts b/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts index 6c3a2e19a..f827c2742 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts @@ -14,6 +14,7 @@ import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import type {AuthStrategy, FetchInit, AccessTokenResponse} from './types.js'; +import {dispatchFetch} from './dispatch-fetch.js'; import {getLogger} from '../logging/logger.js'; import { getOAuthCacheKey, @@ -201,8 +202,8 @@ export class JwtOAuthStrategy implements AuthStrategy { headers.set('Authorization', `Bearer ${token}`); headers.set('x-dw-client-id', this.config.clientId); - // Pass through dispatcher for TLS/mTLS support - let res = await fetch(url, {...init, headers} as RequestInit); + // Pass through dispatcher for TLS/mTLS support (see dispatchFetch) + let res = await dispatchFetch(url, {...init, headers}); if (res.status !== 401) { this._hasHadSuccess = true; @@ -215,7 +216,7 @@ export class JwtOAuthStrategy implements AuthStrategy { this.invalidateToken(); const newToken = await this.getAccessToken(); headers.set('Authorization', `Bearer ${newToken}`); - res = await fetch(url, {...init, headers} as RequestInit); + res = await dispatchFetch(url, {...init, headers}); } return res; diff --git a/packages/b2c-tooling-sdk/src/auth/oauth.ts b/packages/b2c-tooling-sdk/src/auth/oauth.ts index 5f3510e3f..eb20bc942 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth.ts @@ -4,6 +4,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import type {AuthStrategy, AccessTokenResponse, DecodedJWT, FetchInit} from './types.js'; +import {dispatchFetch} from './dispatch-fetch.js'; import {getLogger} from '../logging/logger.js'; import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; import {globalAuthMiddlewareRegistry, applyAuthRequestMiddleware, applyAuthResponseMiddleware} from './middleware.js'; @@ -120,9 +121,8 @@ export class OAuthStrategy implements AuthStrategy { headers.set('Authorization', `Bearer ${token}`); headers.set('x-dw-client-id', this.config.clientId); - // Pass through dispatcher for TLS/mTLS support - // Node.js fetch accepts dispatcher as an undocumented option - let res = await fetch(url, {...init, headers} as RequestInit); + // Pass through dispatcher for TLS/mTLS support (see dispatchFetch) + let res = await dispatchFetch(url, {...init, headers}); if (res.status !== 401) { this._hasHadSuccess = true; @@ -135,7 +135,7 @@ export class OAuthStrategy implements AuthStrategy { this.invalidateToken(); const newToken = await this.getAccessToken(); headers.set('Authorization', `Bearer ${newToken}`); - res = await fetch(url, {...init, headers} as RequestInit); + res = await dispatchFetch(url, {...init, headers}); } return res; diff --git a/packages/b2c-tooling-sdk/test/auth/dispatch-fetch.test.ts b/packages/b2c-tooling-sdk/test/auth/dispatch-fetch.test.ts new file mode 100644 index 000000000..4fc38dc2e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/auth/dispatch-fetch.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {MockAgent} from 'undici'; +import {BasicAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +/** + * Regression tests for issue #468: when a request carries an undici `dispatcher` + * (mTLS / self-signed TLS Agent), it must be routed through undici's OWN `fetch`, + * not `global.fetch`, so the Agent and the fetch share one undici instance. + * + * The discriminating assertion is *which* fetch is used: + * - dispatcher present -> undici's fetch (NOT global.fetch) + * - dispatcher absent -> global.fetch + * + * We detect a global.fetch call by replacing globalThis.fetch with a counting + * stub. The pre-fix code (`fetch(url, ...)` with the dispatcher) would call that + * stub even when a dispatcher is present; the fixed code must not. A real undici + * MockAgent stands in for the dispatcher so the undici path resolves without + * touching the network. Asserting on the global.fetch count (rather than only on + * a successful response) is what makes this fail for the buggy code: at the + * current undici version global.fetch happens to honor a per-call dispatcher, so + * a response-only assertion would pass for the old code too. + */ +describe('auth/dispatch-fetch (issue #468)', () => { + const realGlobalFetch = globalThis.fetch; + let globalFetchCalls = 0; + + beforeEach(() => { + globalFetchCalls = 0; + globalThis.fetch = (async (...args: Parameters) => { + globalFetchCalls++; + return realGlobalFetch(...args); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = realGlobalFetch; + }); + + it('routes dispatcher-bearing requests through undici fetch, not global.fetch', async () => { + const mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + let interceptedAuth: string | undefined; + mockAgent + .get('https://example.test') + .intercept({path: '/file.txt', method: 'PUT'}) + .reply((opts) => { + interceptedAuth = (opts.headers as Record)?.authorization; + return {statusCode: 201, data: 'created'}; + }); + + const auth = new BasicAuthStrategy('user', 'pass'); + const res = await auth.fetch('https://example.test/file.txt', { + method: 'PUT', + body: 'hello', + dispatcher: mockAgent, + }); + + expect(res.status, 'request resolved via the undici dispatcher path').to.equal(201); + // Core regression assertion: the dispatcher path must NOT use global.fetch. + expect(globalFetchCalls, 'global.fetch must not be used when a dispatcher is present').to.equal(0); + // Auth header is still injected on the dispatcher path. + expect(interceptedAuth).to.equal(`Basic ${Buffer.from('user:pass').toString('base64')}`); + + mockAgent.assertNoPendingInterceptors(); + await mockAgent.close(); + }); + + it('uses global.fetch when no dispatcher is present', async () => { + // The beforeEach stub already counts global.fetch calls; replace it with one + // that short-circuits to a canned Response so no network is touched. + globalThis.fetch = (async () => { + globalFetchCalls++; + return new Response('ok', {status: 200}); + }) as typeof fetch; + + const auth = new BasicAuthStrategy('user', 'pass'); + const res = await auth.fetch('https://example.test/'); + + expect(res.status).to.equal(200); + expect(globalFetchCalls, 'no-dispatcher requests use global.fetch').to.equal(1); + }); +});