Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-mtls-dispatcher-fetch.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions packages/b2c-tooling-sdk/src/auth/api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -43,8 +44,8 @@ export class ApiKeyStrategy implements AuthStrategy {
async fetch(url: string, init: FetchInit = {}): Promise<Response> {
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});
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/b2c-tooling-sdk/src/auth/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,9 +20,8 @@ export class BasicAuthStrategy implements AuthStrategy {
async fetch(url: string, init: FetchInit = {}): Promise<Response> {
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<string> {
Expand Down
42 changes: 42 additions & 0 deletions packages/b2c-tooling-sdk/src/auth/dispatch-fetch.ts
Original file line number Diff line number Diff line change
@@ -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<Response>;

/**
* 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<Response> {
const fetchFn = (init.dispatcher ? undiciFetch : fetch) as FetchFn;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe some trace logging could help identify which version we have

return fetchFn(url, init as RequestInit);
}
7 changes: 4 additions & 3 deletions packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down
7 changes: 4 additions & 3 deletions packages/b2c-tooling-sdk/src/auth/oauth-jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions packages/b2c-tooling-sdk/src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
88 changes: 88 additions & 0 deletions packages/b2c-tooling-sdk/test/auth/dispatch-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>) => {
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<string, string>)?.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);
});
});
Loading