From 569a02270e045bfdbd072c99d4ec5b6cd6a6adcc Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 4 Jun 2026 20:25:46 -0400 Subject: [PATCH 1/4] fix(sdk): route mTLS dispatcher requests through undici's own fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TLS/mTLS dispatcher is an undici `Agent` constructed from the `undici` npm package, but every auth strategy handed it to `global.fetch`, which is backed by whatever undici Node bundles internally. That bundled version drifts across Node releases (Node 22 -> undici 6.x, Node 24 -> 7.x) and can be a different major than the npm package. undici's request-handler interface changed across majors, and the cross-version compat shim is removed in undici 8 — so pairing a foreign Agent with `global.fetch` across that boundary can fail and silently drop the client certificate. Add a shared `dispatchFetch` helper that routes dispatcher-bearing requests through undici's own `fetch`, keeping the Agent and fetch on one undici instance regardless of the bundled version, and wire it into all five auth strategies (basic, client-credentials, JWT, implicit, API key). `global.fetch` is preserved for the common no-dispatcher case. Adds a regression test that asserts the routing branch (and fails against the old `global.fetch` code path). Independent implementation of the fix proposed in #469 by Holger Nestmann (@hnestmann); fixes #468. Co-authored-by: Holger Nestmann --- .changeset/fix-mtls-dispatcher-fetch.md | 5 + packages/b2c-tooling-sdk/src/auth/api-key.ts | 5 +- packages/b2c-tooling-sdk/src/auth/basic.ts | 6 +- .../src/auth/dispatch-fetch.ts | 42 ++++++++ .../src/auth/oauth-implicit.ts | 7 +- .../b2c-tooling-sdk/src/auth/oauth-jwt.ts | 7 +- packages/b2c-tooling-sdk/src/auth/oauth.ts | 8 +- .../test/auth/dispatch-fetch.test.ts | 95 +++++++++++++++++++ 8 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-mtls-dispatcher-fetch.md create mode 100644 packages/b2c-tooling-sdk/src/auth/dispatch-fetch.ts create mode 100644 packages/b2c-tooling-sdk/test/auth/dispatch-fetch.test.ts 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..0e804aed2 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/auth/dispatch-fetch.test.ts @@ -0,0 +1,95 @@ +/* + * 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 () => { + const mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + mockAgent.get('https://example.test').intercept({path: '/', method: 'GET'}).reply(200, 'ok'); + + // Route the stub through the mock so no real network is touched, while still + // counting that global.fetch was the entry point for the no-dispatcher case. + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + globalFetchCalls++; + const {fetch: undiciFetch} = await import('undici'); + return undiciFetch(url as string, {...init, dispatcher: mockAgent} as RequestInit) as unknown as Response; + }) 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); + + await mockAgent.close(); + }); +}); From bf2070ea279515ffc3ae3b0435f9cd42c067af64 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 4 Jun 2026 20:34:21 -0400 Subject: [PATCH 2/4] test: fix test-tsconfig type error in dispatch-fetch regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-dispatcher case routed its global.fetch stub through an undici MockAgent and cast the init to RequestInit, which collides between the global RequestInit (undici-types) and undici's own RequestInit under `tsc -p test` (the CI pretest step). Simplify it to a plain canned-Response stub that just asserts global.fetch is the entry point — no cast, no mock. --- .../test/auth/dispatch-fetch.test.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/b2c-tooling-sdk/test/auth/dispatch-fetch.test.ts b/packages/b2c-tooling-sdk/test/auth/dispatch-fetch.test.ts index 0e804aed2..4fc38dc2e 100644 --- a/packages/b2c-tooling-sdk/test/auth/dispatch-fetch.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/dispatch-fetch.test.ts @@ -72,16 +72,11 @@ describe('auth/dispatch-fetch (issue #468)', () => { }); it('uses global.fetch when no dispatcher is present', async () => { - const mockAgent = new MockAgent(); - mockAgent.disableNetConnect(); - mockAgent.get('https://example.test').intercept({path: '/', method: 'GET'}).reply(200, 'ok'); - - // Route the stub through the mock so no real network is touched, while still - // counting that global.fetch was the entry point for the no-dispatcher case. - globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + // 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++; - const {fetch: undiciFetch} = await import('undici'); - return undiciFetch(url as string, {...init, dispatcher: mockAgent} as RequestInit) as unknown as Response; + return new Response('ok', {status: 200}); }) as typeof fetch; const auth = new BasicAuthStrategy('user', 'pass'); @@ -89,7 +84,5 @@ describe('auth/dispatch-fetch (issue #468)', () => { expect(res.status).to.equal(200); expect(globalFetchCalls, 'no-dispatcher requests use global.fetch').to.equal(1); - - await mockAgent.close(); }); }); From e7cbc90b56d3dd4290f95dca7bdc4fcb96dfe515 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 4 Jun 2026 20:42:54 -0400 Subject: [PATCH 3/4] ci: add Node 26 to the Linux test matrix Node 26 bundles undici 8.x, whose request-handler interface dropped the legacy compatibility shim. This leg guards the mTLS dispatcher path against undici version skew between Node's bundled undici and the npm `undici` dependency (the class of failure fixed in this PR). --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d2653197..c5878dec8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,11 @@ jobs: strategy: matrix: - node-version: [22.x, 24.x] + # Node 26 (bundled undici 8.x) is included to guard the mTLS dispatcher + # path against undici version skew: the request handler interface changed + # across undici majors, and Node's bundled undici differs from the npm + # `undici` dependency. See packages/b2c-tooling-sdk/src/auth/dispatch-fetch.ts. + node-version: [22.x, 24.x, 26.x] steps: - name: Checkout code From a9f8b6aecd2526f6e0a32172913f45095ee21b32 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 4 Jun 2026 21:38:48 -0400 Subject: [PATCH 4/4] Revert "ci: add Node 26 to the Linux test matrix" This reverts commit e7cbc90b56d3dd4290f95dca7bdc4fcb96dfe515. --- .github/workflows/ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5878dec8..2d2653197 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,11 +26,7 @@ jobs: strategy: matrix: - # Node 26 (bundled undici 8.x) is included to guard the mTLS dispatcher - # path against undici version skew: the request handler interface changed - # across undici majors, and Node's bundled undici differs from the npm - # `undici` dependency. See packages/b2c-tooling-sdk/src/auth/dispatch-fetch.ts. - node-version: [22.x, 24.x, 26.x] + node-version: [22.x, 24.x] steps: - name: Checkout code