@W-22849587 fix(sdk): harden mTLS client-cert fetch against bundled-undici drift#474
Open
clavery wants to merge 4 commits into
Open
@W-22849587 fix(sdk): harden mTLS client-cert fetch against bundled-undici drift#474clavery wants to merge 4 commits into
clavery wants to merge 4 commits into
Conversation
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 <hnestmann@users.noreply.github.com>
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.
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).
This reverts commit e7cbc90.
hnestmann
reviewed
Jun 5, 2026
| * @returns The fetch response | ||
| */ | ||
| export function dispatchFetch(url: string, init: FetchInit = {}): Promise<Response> { | ||
| const fetchFn = (init.dispatcher ? undiciFetch : fetch) as FetchFn; |
Collaborator
There was a problem hiding this comment.
maybe some trace logging could help identify which version we have
hnestmann
approved these changes
Jun 5, 2026
charithaT07
approved these changes
Jun 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Client certificates (mTLS) for WebDAV/staging deploys are presented by passing an undici
Agent(built from theundicinpm package) as thedispatcheroption tofetch. Every auth strategy passed that Agent toglobal.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.This PR routes any request that carries a
dispatcherthrough undici's ownfetch(a small shareddispatchFetchhelper), so the Agent and the fetch always share one undici instance, independent of which undici Node bundles. Wired into all five auth strategies (basic, client-credentials, JWT, implicit, API key).Credit: independent implementation of the fix proposed in #469 by @hnestmann. Fixes #468. Tracked as W-22849587.
Root cause (empirically verified)
The undici request-handler interface is produced by
global.fetch(= Node's bundled undici) but validated by theAgent(= the npm undici). undici 8 removed the legacy-handler compatibility shim, so itsassertRequestHandlernow requiresonRequestStartand rejects the older handler shape withUND_ERR_INVALID_ARG: invalid onRequestStart method— the exact error from #468.Reproduced against a real local mutual-TLS server (
requestCert: true) with a real client.p12, across Node 22 (bundles undici 6.x), Node 24 (7.x), and Node 26 (8.x):undiciAgentglobal.fetchglobal.fetchUND_ERR_INVALID_ARG: invalid onRequestStart methodfetch(this fix)Key takeaways:
7.19.2pin it does not reproduce on Node 22/24/26 — undici 7's shim bridges the gap, which is why the original reporter (on a newer undici) hit it and a naive local repro does not.undicito 8.x while users run Node whose bundled undici is older (Node 22/24) — that bump is inevitable. The fix presents the certificate in every cell of the matrix because the npm-undici Agent is always driven by the npm-undicifetch— one instance, one handler interface, regardless of the bundled version.Why this approach (and not the alternatives)
This is the documented, ecosystem-standard "bring-your-own-dispatcher" pattern (Node docs: a custom dispatcher "must be compatible with undici's Dispatcher class" and you "install undici"; undici README;
@octokit/requestuses the identicalimport { fetch as undiciFetch, Agent } from 'undici'+dispatcherwrapper). There is no node-native, undici-free path for client certs overfetch:node:undiciis not importable; anode:https.Agentasdispatcherthrowsagent.dispatch is not a function;cert/keyonRequestInitare silently ignored;NODE_EXTRA_CA_CERTSonly extends trusted root CAs (server verification), not client identity.Rejected:
setGlobalDispatcher(process-global mutation — library-unsafe; we keep zero usages insrc), version-detectingprocess.versions.undici(fragile, breaks on every minor/patch drift), loosening the undici pin (you cannot pin bundled undici), and rewriting ontoundici.request(no correctness gain; breaks theResponse/middleware contract).Changes
src/auth/dispatch-fetch.ts— new shared helper: dispatcher present → undici'sfetch; otherwise →global.fetch.basic.ts,oauth.ts,api-key.ts,oauth-jwt.ts,oauth-implicit.ts. (The original PR patched onlyoauth.ts; the default WebDAV deploy path usesBasicAuthStrategy.)test/auth/dispatch-fetch.test.ts— regression test asserting the routing branch (verified to fail against the oldglobal.fetchcode path, version-independent).@salesforce/b2c-tooling-sdk: patch).Testing
typecheck:agent✅,lint:agent✅,format:check✅ (including thetsc -p testpretest step CI runs).undici@8.3.0fixture, confirming the fix presents the cert in every case and the old path fails exactly as Problems with staging deployments #468 describes ✅Node 26 / test-harness follow-up (not in this PR)
Adding Node 26 to the CI matrix surfaced that the test harness itself (mocha 10 / c8 / yargs) cannot start on Node 26 —
yargs <18ships an extensionlesstype: modulefile that Node 26 classifies as ESM (require is not defined in ES module scope), crashing mocha/c8 before any test runs. That is unrelated to this fix and is its own substantial change (mocha 11, a scopedc8>yargsoverride, anapplicationinsightsESM-interop fix). It is tracked separately in W-22850150 and will add Node 26 as a required CI leg there. This PR therefore keeps the required matrix at 22.x/24.x; the undici-8 risk it guards is fully exercised on those legs.