Skip to content

feat: pin the providers' own OIDC requests to a caller-supplied fetch (fixes the global-fetch-patch login deadlock)#21

Draft
jeswr wants to merge 1 commit into
mainfrom
feat/oidc-custom-fetch
Draft

feat: pin the providers' own OIDC requests to a caller-supplied fetch (fixes the global-fetch-patch login deadlock)#21
jeswr wants to merge 1 commit into
mainfrom
feat/oidc-custom-fetch

Conversation

@jeswr

@jeswr jeswr commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

DRAFT for maintainer discussion — a lesson learned (the hard way, several times) across @jeswr's Solid app suite, contributed back here. Happy to reshape the API to your taste.

The problem: an OIDC re-entrancy deadlock that hangs login

The token providers perform their own network requests — discovery, dynamic client registration, and the token grant — through oauth4webapi, which defaults to globalThis.fetch.

When an application patches the global fetch with an authenticating wrapper (this library's own ReactiveFetchManager.registerGlobally(), or an app-side wrapper that single-flights concurrent requests onto one shared authentication attempt — a very common pattern to avoid N parallel 401s spawning N login popups), the provider's OIDC requests re-enter that wrapper mid-upgrade. A single-flighting wrapper then awaits the very authentication attempt those requests are serving — a circular await that hangs login before the authorization popup/redirect ever opens. No error, no popup, nothing: the discovery request is parked on the login promise that issued it.

Deterministic repro (against this repo's main)

import { DPoPTokenProvider } from "./dist/DPoPTokenProvider.js"

// an app-installed single-flighting wrapper: mid-login, requests through it
// await the in-flight authentication attempt — i.e. they never resolve
globalThis.fetch = () => new Promise(() => {})

const provider = new DPoPTokenProvider("https://app.example/cb", async u => `${u}?code=x`, async () => new URL("https://op.example"))
await provider.upgrade(new Request("https://pod.example/x")) // hangs forever at oauth.discoveryRequest

The fix: let the caller pin the providers' OIDC hops to a pristine fetch

This PR adds an optional trailing options argument to DPoPTokenProvider, BearerTokenProvider and ClientCredentialsTokenProvider:

const pristineFetch = globalThis.fetch.bind(globalThis) // capture BEFORE patching

const provider = new DPoPTokenProvider(callbackUri, getCode, getIssuer, {fetch: pristineFetch})
const manager = new ReactiveFetchManager([provider])
manager.registerGlobally()

TokenProviderOptions.fetch is threaded into oauth4webapi via its customFetch request option (a small shared customFetchOption adapter bridges oauth4webapi's CustomFetchOptions to the Fetch API signature under exactOptionalPropertyTypes). Defaults are unchanged: with no options, the providers keep resolving globalThis.fetch at call time, exactly as today.

Tests

test/DPoPTokenProvider.customFetch.test.ts (runs via the added npm test script — build, then node --test against dist/, so it exercises the shipped artifact):

  1. The regression test: a full DPoP auth-code login (discovery → registration → PKCE → token grant, through real oauth4webapi against an in-process mock OP) resolves while globalThis.fetch is patched with a never-resolving wrapper — and asserts the patched global was called zero times.
  2. Same for the client-credentials flow.
  3. Backwards compatibility: without options, the provider still rides globalThis.fetch.

Without the fix, test 1 hangs (verified against main).

Notes for discussion


🤖 PSS agent — @jeswr's agent for prod-solid-server / the Solid app+Pod-Manager suite

Token providers perform their own network requests (discovery, dynamic
client registration, the token grant) through oauth4webapi, which
defaults to globalThis.fetch. When an application patches the global
fetch with an authenticating wrapper — ReactiveFetchManager's
registerGlobally(), or its own wrapper that single-flights concurrent
requests onto one shared authentication attempt — those OIDC requests
re-enter the wrapper mid-upgrade. A single-flighting wrapper then
awaits the very authentication attempt its request is serving: a
circular await that hangs login before the authorization
popup/redirect ever opens.

Add TokenProviderOptions.fetch (an optional trailing options argument
on DPoPTokenProvider, BearerTokenProvider and
ClientCredentialsTokenProvider) threading a caller-supplied pristine
fetch into oauth4webapi via its customFetch option, so the providers'
OIDC hops never ride the patched global. Defaults unchanged
(globalThis.fetch resolved at call time).

Includes a mock-OP regression test proving login resolves under a
deadlocking patched global fetch when a pristine fetch is pinned, plus
a backwards-compatibility test, and a README section documenting the
capture-before-patch pattern.

Model: claude-fable-5
Co-Authored-By: Claude Fable <noreply@anthropic.com>
Signed-off-by: Jesse Wright <63333554+jeswr@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant