From fa38fd9fdae67beecb7cd57555f4c553c31cbc5e Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:57:16 +0100 Subject: [PATCH 01/10] feat: cache the DPoP session per issuer so repeat 401s reuse the token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously every 401 re-ran the entire flow — discovery, dynamic client registration, a fresh DPoP key, and a new authorization popup — so each authenticated request could prompt the user again. DPoPTokenProvider now keeps a single-flight per-issuer session cache: - concurrent 401 upgrades share one authorization-code flow (one popup); - later upgrades reuse the established access token, signing a fresh DPoP proof per request; - the token's reported `expires_in` is tracked (with 30 s skew) and an expired session re-runs the flow — silently while the IdP cookie lives, thanks to the existing `prompt=none`-first behaviour; - a failed flow is not cached, so the next request can retry; - shared flow work is no longer tied to a single request's AbortSignal (aborting one request must not cancel the login that other concurrent upgrades are waiting on). The public API is unchanged. Also adds a minimal vitest setup (the repo had no test runner) with a compact in-memory authorization server covering the cache behaviour. Co-Authored-By: Claude Fable 5 --- package.json | 6 +- src/DPoPTokenProvider.ts | 115 +++++++++++++++++-- test/DPoPTokenProvider.test.ts | 93 +++++++++++++++ test/fakeAuthorizationServer.ts | 193 ++++++++++++++++++++++++++++++++ 4 files changed, 394 insertions(+), 13 deletions(-) create mode 100644 test/DPoPTokenProvider.test.ts create mode 100644 test/fakeAuthorizationServer.ts diff --git a/package.json b/package.json index 4c8ac79..ef010fb 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "url": "git+https://github.com/solid-contrib/reactive-authentication.git" }, "scripts": { - "build": "tsc" + "build": "tsc", + "test": "vitest run" }, "license": "MIT", "dependencies": { @@ -40,7 +41,8 @@ "@types/n3": "^1", "typedoc": "^0.28.18", "typedoc-plugin-mdn-links": "^5.1.1", - "typescript": "^7" + "typescript": "^7", + "vitest": "^4.1.8" }, "engines": { "node": ">=24.0.0" diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index 4760ae2..eac3cc5 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -4,11 +4,44 @@ import type { GetCodeCallback } from "./GetCodeCallback.js" import type { TokenProvider } from "./TokenProvider.js" import type { GetIssuerCallback } from "./GetIssuerCallback.js" +/** The client metadata shape produced by dynamic client registration. */ +type ClientRegistration = Awaited> + +/** Authentication state for one issuer, reused across upgrades. */ +interface IssuerSession { + authorizationServer: oauth.AuthorizationServer + clientRegistration: ClientRegistration + dpopKey: CryptoKeyPair + accessToken: string + /** Epoch milliseconds after which the access token is considered expired, or undefined when the server gave no expiry. */ + expiresAt: number | undefined +} + +/** + * Refresh this much before the server-reported expiry, so clock skew between us + * and the resource server does not produce a window of rejected requests. + */ +const expirySkewMs = 30_000 + export class DPoPTokenProvider implements TokenProvider { readonly #getCode: GetCodeCallback readonly #callbackUri: string readonly #getIssuer: GetIssuerCallback + /** + * Single-flight session cache per issuer: concurrent upgrades share one + * authorization-code flow (one popup), and later upgrades reuse the + * established token until it expires instead of re-running the flow. + */ + readonly #sessions = new Map>() + + /** + * The shared authentication work is provider-owned, so it is deliberately + * not tied to any single request's AbortSignal — aborting one request must + * not cancel the login that other concurrent upgrades are waiting on. + */ + readonly #authSignal = new AbortController().signal + constructor(callbackUri: string, getCodeCallback: GetCodeCallback, getIssuerCallback: GetIssuerCallback) { this.#getCode = getCodeCallback this.#callbackUri = callbackUri @@ -21,11 +54,62 @@ export class DPoPTokenProvider implements TokenProvider { async upgrade(request: Request): Promise { const issuer = await this.#getIssuer(request) + const session = await this.#session(issuer) + + const headers = new Headers(request.headers) + + headers.set("DPoP", await DPoP.generateProof(session.dpopKey, request.url, request.method, undefined, session.accessToken)) + headers.set("Authorization", ["DPoP", session.accessToken].join(" ")) + + return new Request(request, {headers}) + } + + /** + * Returns the cached session for the issuer, renewing it when expired and + * establishing it when absent. A failed flow is not cached, so the next + * upgrade retries. + */ + async #session(issuer: URL): Promise { + const pending = this.#sessions.get(issuer.href) + if (pending === undefined) { + return this.#begin(issuer, this.#authenticate(issuer)) + } + + const session = await pending + if (!hasExpired(session)) { + return session + } + + // Renew, unless a concurrent caller already replaced the expired session. + if (this.#sessions.get(issuer.href) === pending) { + this.#sessions.delete(issuer.href) + return this.#begin(issuer, this.#authenticate(issuer)) + } - const discoveryResponse = await oauth.discoveryRequest(issuer, {signal: request.signal}) + return this.#session(issuer) + } + + /** Caches the in-flight work; evicts it on failure so the flow can be retried. */ + async #begin(issuer: URL, work: Promise): Promise { + this.#sessions.set(issuer.href, work) + try { + return await work + } catch (e) { + if (this.#sessions.get(issuer.href) === work) { + this.#sessions.delete(issuer.href) + } + throw e + } + } + + /** The full authorization-code flow: discovery → registration → PKCE/DPoP code grant. */ + async #authenticate(issuer: URL): Promise { + const signal = this.#authSignal + + const discoveryResponse = await oauth.discoveryRequest(issuer, {signal}) const authorizationServer = await oauth.processDiscoveryResponse(issuer, discoveryResponse) - const registrationResponse = await oauth.dynamicClientRegistrationRequest(authorizationServer, {redirect_uris: [this.#callbackUri]}, {signal: request.signal}) + const registrationResponse = await oauth.dynamicClientRegistrationRequest(authorizationServer, {redirect_uris: [this.#callbackUri]}, {signal}) const clientRegistration = await oauth.processDynamicClientRegistrationResponse(registrationResponse) const [registeredRedirectUri] = clientRegistration.redirect_uris as string[] const [registeredResponseType] = clientRegistration.response_types as string[] @@ -56,7 +140,7 @@ export class DPoPTokenProvider implements TokenProvider { } } - const authorizationCodeResponse = await this.#getCode(authorizationUrl, request.signal) + const authorizationCodeResponse = await this.#getCode(authorizationUrl, signal) let authorizationCodeParams try { @@ -72,23 +156,24 @@ export class DPoPTokenProvider implements TokenProvider { console.debug("Authorization server requires user interaction, retrying without prompt") authorizationUrl.searchParams.delete("prompt") - const authorizationCodeResponse = await this.#getCode(authorizationUrl, request.signal) + const authorizationCodeResponse = await this.#getCode(authorizationUrl, signal) authorizationCodeParams = oauth.validateAuthResponse(authorizationServer, clientRegistration, new URL(authorizationCodeResponse), state) } else { throw e } } - const tokenResponse = await oauth.authorizationCodeGrantRequest(authorizationServer, clientRegistration, this.getClientAuth(authorizationServer.issuer, clientRegistration), authorizationCodeParams, this.#callbackUri, authorizationServer.code_challenge_methods_supported !== undefined ? codeVerifier : oauth.nopkce, {DPoP: dpop, signal: request.signal}) + const tokenResponse = await oauth.authorizationCodeGrantRequest(authorizationServer, clientRegistration, this.getClientAuth(authorizationServer.issuer, clientRegistration), authorizationCodeParams, this.#callbackUri, authorizationServer.code_challenge_methods_supported !== undefined ? codeVerifier : oauth.nopkce, {DPoP: dpop, signal}) const tokenResult = await oauth.processAuthorizationCodeResponse(authorizationServer, clientRegistration, tokenResponse, {expectedNonce: this.nonceVerificationOverride(authorizationServer.issuer, nonce)}) - const headers = new Headers(request.headers) - - headers.set("DPoP", await DPoP.generateProof(dpopKey, request.url, request.method, undefined, tokenResult.access_token)) - headers.set("Authorization", ["DPoP", tokenResult.access_token].join(" ")) - - return new Request(request, {headers}) + return { + authorizationServer, + clientRegistration, + dpopKey, + accessToken: tokenResult.access_token, + expiresAt: expiresAt(tokenResult), + } } private getClientAuth(issuer: string, client: oauth.OmitSymbolProperties): oauth.ClientAuth { @@ -112,6 +197,14 @@ export class DPoPTokenProvider implements TokenProvider { } } +function expiresAt(token: oauth.TokenEndpointResponse): number | undefined { + return token.expires_in === undefined ? undefined : Date.now() + token.expires_in * 1000 - expirySkewMs +} + +function hasExpired(session: IssuerSession): boolean { + return session.expiresAt !== undefined && Date.now() >= session.expiresAt +} + function isEssMissingIssInteractionNeeded(e: unknown) { try { return ((((e as oauth.OperationProcessingError).cause as any).parameters) as URLSearchParams).get("error") === "interaction_required" diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts new file mode 100644 index 0000000..470a45b --- /dev/null +++ b/test/DPoPTokenProvider.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { DPoPTokenProvider } from "../src/DPoPTokenProvider.js" +import { createFakeAuthorizationServer, type FakeAuthorizationServer } from "./fakeAuthorizationServer.js" + +const callbackUri = "https://app.test/callback.html" + +let as: FakeAuthorizationServer + +function makeProvider(getCode = vi.fn((url: URL) => as.authorize(url))) { + const provider = new DPoPTokenProvider(callbackUri, getCode, async () => new URL(as.issuer)) + return {provider, getCode} +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +describe("DPoPTokenProvider session cache", () => { + beforeEach(async () => { + as = await createFakeAuthorizationServer() + vi.stubGlobal("fetch", as.fetch) + }) + + it("attaches a DPoP-bound access token to the upgraded request", async () => { + const {provider} = makeProvider() + + const upgraded = await provider.upgrade(new Request("https://pod.test/private")) + + expect(upgraded.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/) + expect(upgraded.headers.get("DPoP")).toBeTruthy() + }) + + it("runs the authorization flow once for concurrent upgrades (single-flight)", async () => { + const {provider, getCode} = makeProvider() + + await Promise.all([ + provider.upgrade(new Request("https://pod.test/a")), + provider.upgrade(new Request("https://pod.test/b")), + provider.upgrade(new Request("https://pod.test/c")), + ]) + + expect(getCode).toHaveBeenCalledTimes(1) + expect(as.registrations).toHaveLength(1) + }) + + it("reuses the established session for later upgrades instead of re-prompting", async () => { + const {provider, getCode} = makeProvider() + + const first = await provider.upgrade(new Request("https://pod.test/a")) + const second = await provider.upgrade(new Request("https://pod.test/b")) + + expect(getCode).toHaveBeenCalledTimes(1) + expect(second.headers.get("Authorization")).toBe(first.headers.get("Authorization")) + }) + + it("signs a fresh DPoP proof per request while reusing the access token", async () => { + const {provider} = makeProvider() + + const first = await provider.upgrade(new Request("https://pod.test/a")) + const second = await provider.upgrade(new Request("https://pod.test/b")) + + expect(second.headers.get("DPoP")).not.toBe(first.headers.get("DPoP")) + }) + + it("re-authenticates once the access token has expired", async () => { + const {provider, getCode} = makeProvider() + + const first = await provider.upgrade(new Request("https://pod.test/a")) + + // Step past the reported expiry (minus the skew allowance). + vi.useFakeTimers() + vi.setSystemTime(Date.now() + 3601 * 1000) + + const second = await provider.upgrade(new Request("https://pod.test/b")) + + expect(getCode).toHaveBeenCalledTimes(2) + expect(second.headers.get("Authorization")).not.toBe(first.headers.get("Authorization")) + }) + + it("does not cache a failed flow: the next upgrade retries", async () => { + const getCode = vi.fn((url: URL) => as.authorize(url)) + getCode.mockRejectedValueOnce(new Error("user closed the popup")) + const {provider} = makeProvider(getCode) + + await expect(provider.upgrade(new Request("https://pod.test/a"))).rejects.toThrow("user closed the popup") + + const second = await provider.upgrade(new Request("https://pod.test/b")) + + expect(second.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/) + expect(getCode).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/fakeAuthorizationServer.ts b/test/fakeAuthorizationServer.ts new file mode 100644 index 0000000..e098aa2 --- /dev/null +++ b/test/fakeAuthorizationServer.ts @@ -0,0 +1,193 @@ +/** + * A minimal in-memory OAuth 2.0 / OpenID Connect authorization server for unit + * tests, exposed as a `fetch` implementation to stub `globalThis.fetch` with. + * + * It implements just enough for oauth4webapi's strict client side: discovery, + * JWKS, dynamic client registration, and a token endpoint handling the + * `authorization_code` and `refresh_token` grants — including ES256-signed ID + * tokens (oauth4webapi requires a valid ID token whenever a nonce is expected) + * and refresh-token rotation. + */ + +export interface FakeAuthorizationServerOptions { + /** `expires_in` reported on every token response. Default 3600. */ + expiresIn?: number + /** Whether token responses include a refresh token. Default false. */ + issueRefreshTokens?: boolean + /** Whether the refresh-token grant rotates the refresh token. Default true. */ + rotateRefreshTokens?: boolean + /** `scopes_supported` advertised by discovery. Default ["openid", "webid"]. */ + scopesSupported?: string[] + /** `grant_types_supported` advertised by discovery. Default ["authorization_code"]. */ + grantTypesSupported?: string[] +} + +export interface AuthorizationRequestRecord { + scope: string | null + prompt: string | null + clientId: string | null +} + +export interface FakeAuthorizationServer { + readonly issuer: string + /** Stub `globalThis.fetch` with this. */ + fetch: typeof globalThis.fetch + /** + * The "user agent": simulates visiting the authorization endpoint and + * returns the redirect-back URL carrying `code` and `state`. Use as the + * provider's `getCode` callback. + */ + authorize(authorizationUrl: URL): Promise + /** Every authorization request seen, oldest first. */ + readonly authorizationRequests: AuthorizationRequestRecord[] + /** Client registration metadata bodies received, oldest first. */ + readonly registrations: Record[] + /** Form bodies received by the token endpoint, oldest first. */ + readonly tokenRequests: URLSearchParams[] + /** Refresh tokens that are currently redeemable. */ + readonly activeRefreshTokens: Set +} + +const encoder = new TextEncoder() + +function base64url(data: Uint8Array | string): string { + const bytes = typeof data === "string" ? encoder.encode(data) : data + let binary = "" + for (const b of bytes) binary += String.fromCharCode(b) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), {status, headers: {"content-type": "application/json"}}) +} + +export async function createFakeAuthorizationServer(options: FakeAuthorizationServerOptions = {}): Promise { + const issuer = "https://as.test" + const expiresIn = options.expiresIn ?? 3600 + const rotate = options.rotateRefreshTokens ?? true + + const keys = await crypto.subtle.generateKey({name: "ECDSA", namedCurve: "P-256"}, true, ["sign", "verify"]) as CryptoKeyPair + const publicJwk = await crypto.subtle.exportKey("jwk", keys.publicKey) + + let counter = 0 + /** nonce + client of each outstanding authorization code */ + const codes = new Map() + const activeRefreshTokens = new Set() + const authorizationRequests: AuthorizationRequestRecord[] = [] + const registrations: Record[] = [] + const tokenRequests: URLSearchParams[] = [] + + async function signIdToken(clientId: string, nonce: string | null): Promise { + const header = base64url(JSON.stringify({alg: "ES256", kid: "test"})) + const now = Math.floor(Date.now() / 1000) + const claims: Record = {iss: issuer, sub: "user", aud: clientId, iat: now, exp: now + 600} + if (nonce !== null) claims.nonce = nonce + const payload = base64url(JSON.stringify(claims)) + const signature = await crypto.subtle.sign({name: "ECDSA", hash: "SHA-256"}, keys.privateKey, encoder.encode(`${header}.${payload}`)) + return `${header}.${payload}.${base64url(new Uint8Array(signature))}` + } + + function tokenBody(refreshable: boolean, idToken?: string) { + const body: Record = { + access_token: `at-${++counter}`, + token_type: "DPoP", + expires_in: expiresIn, + scope: "openid webid", + } + if (idToken !== undefined) body.id_token = idToken + if (refreshable) { + const refreshToken = `rt-${counter}` + activeRefreshTokens.add(refreshToken) + body.refresh_token = refreshToken + } + return body + } + + async function handle(request: Request): Promise { + const url = new URL(request.url) + + if (url.href === `${issuer}/.well-known/openid-configuration`) { + return json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + jwks_uri: `${issuer}/jwks`, + code_challenge_methods_supported: ["S256"], + id_token_signing_alg_values_supported: ["ES256"], + scopes_supported: options.scopesSupported ?? ["openid", "webid"], + grant_types_supported: options.grantTypesSupported ?? ["authorization_code"], + }) + } + + if (url.pathname === "/jwks") { + return json({keys: [{...publicJwk, alg: "ES256", use: "sig", kid: "test"}]}) + } + + if (url.pathname === "/register") { + const metadata = await request.json() as Record + registrations.push(metadata) + return json({ + client_id: `client-${++counter}`, + redirect_uris: metadata.redirect_uris, + response_types: ["code"], + grant_types: metadata.grant_types ?? ["authorization_code"], + token_endpoint_auth_method: "none", + }, 201) + } + + if (url.pathname === "/token") { + const params = new URLSearchParams(await request.text()) + tokenRequests.push(params) + + if (params.get("grant_type") === "authorization_code") { + const code = codes.get(params.get("code") ?? "") + if (code === undefined) { + return json({error: "invalid_grant"}, 400) + } + codes.delete(params.get("code")!) + return json(tokenBody(options.issueRefreshTokens ?? false, await signIdToken(params.get("client_id") ?? code.clientId ?? "", code.nonce))) + } + + if (params.get("grant_type") === "refresh_token") { + const presented = params.get("refresh_token") ?? "" + if (!activeRefreshTokens.has(presented)) { + return json({error: "invalid_grant"}, 400) + } + if (rotate) { + activeRefreshTokens.delete(presented) + } + return json(tokenBody(true)) + } + + return json({error: "unsupported_grant_type"}, 400) + } + + return new Response("not found", {status: 404}) + } + + return { + issuer, + fetch: (input, init) => handle(new Request(input, init)), + async authorize(authorizationUrl: URL): Promise { + authorizationRequests.push({ + scope: authorizationUrl.searchParams.get("scope"), + prompt: authorizationUrl.searchParams.get("prompt"), + clientId: authorizationUrl.searchParams.get("client_id"), + }) + const code = `code-${++counter}` + codes.set(code, { + nonce: authorizationUrl.searchParams.get("nonce"), + clientId: authorizationUrl.searchParams.get("client_id"), + }) + const redirect = new URL(authorizationUrl.searchParams.get("redirect_uri")!) + redirect.searchParams.set("code", code) + redirect.searchParams.set("state", authorizationUrl.searchParams.get("state")!) + return redirect.href + }, + authorizationRequests, + registrations, + tokenRequests, + activeRefreshTokens, + } +} From c57f39c69436ecec325218515b832e103b94f968 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:42:38 +0100 Subject: [PATCH 02/10] test: make the fake AS honest about refresh tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: discovery now advertises the refresh_token grant exactly when the server issues refresh tokens; the refresh-token grant is rejected (unsupported_grant_type) when refresh tokens are disabled; and a non-rotating server keeps the presented token active without issuing a replacement (RFC 6749 §6) instead of silently rotating. Co-Authored-By: Claude Fable 5 --- test/fakeAuthorizationServer.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/fakeAuthorizationServer.ts b/test/fakeAuthorizationServer.ts index e098aa2..7f651f4 100644 --- a/test/fakeAuthorizationServer.ts +++ b/test/fakeAuthorizationServer.ts @@ -64,6 +64,7 @@ function json(body: unknown, status = 200): Response { export async function createFakeAuthorizationServer(options: FakeAuthorizationServerOptions = {}): Promise { const issuer = "https://as.test" const expiresIn = options.expiresIn ?? 3600 + const issueRefreshTokens = options.issueRefreshTokens ?? false const rotate = options.rotateRefreshTokens ?? true const keys = await crypto.subtle.generateKey({name: "ECDSA", namedCurve: "P-256"}, true, ["sign", "verify"]) as CryptoKeyPair @@ -116,7 +117,7 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe code_challenge_methods_supported: ["S256"], id_token_signing_alg_values_supported: ["ES256"], scopes_supported: options.scopesSupported ?? ["openid", "webid"], - grant_types_supported: options.grantTypesSupported ?? ["authorization_code"], + grant_types_supported: options.grantTypesSupported ?? (issueRefreshTokens ? ["authorization_code", "refresh_token"] : ["authorization_code"]), }) } @@ -146,18 +147,21 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe return json({error: "invalid_grant"}, 400) } codes.delete(params.get("code")!) - return json(tokenBody(options.issueRefreshTokens ?? false, await signIdToken(params.get("client_id") ?? code.clientId ?? "", code.nonce))) + return json(tokenBody(issueRefreshTokens, await signIdToken(params.get("client_id") ?? code.clientId ?? "", code.nonce))) } - if (params.get("grant_type") === "refresh_token") { + if (params.get("grant_type") === "refresh_token" && issueRefreshTokens) { const presented = params.get("refresh_token") ?? "" if (!activeRefreshTokens.has(presented)) { return json({error: "invalid_grant"}, 400) } if (rotate) { + // Rotation (RFC 9700 §4.14.2): retire the presented token and issue a replacement. activeRefreshTokens.delete(presented) + return json(tokenBody(true)) } - return json(tokenBody(true)) + // No rotation: the presented token stays active and the response carries no new one (RFC 6749 §6). + return json(tokenBody(false)) } return json({error: "unsupported_grant_type"}, 400) From 0e6149e8a64489cbee5434f3d39891e1b8fbf31f Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:19:27 +0100 Subject: [PATCH 03/10] chore: add prepare script so the branch installs directly from git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing this repo as a git dependency needs dist/ built at install time — dist/ is gitignored and there is no published tarball for this branch. `prepare` runs after npm installs the git checkout's deps, so consumers get a built package from a plain `npm i solid-contrib/reactive-authentication#feat/dpop-session-cache-installable`. The .npmrc is needed because typedoc@0.28 peer-caps typescript at 6.x while the project is on ^7; that ERESOLVE would abort the install before prepare could run. --- .npmrc | 4 ++++ package.json | 1 + 2 files changed, 5 insertions(+) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..35dafd7 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +; typedoc@0.28 peer-caps typescript at 6.x while this project is on ^7. +; Without this, `npm install` of this repo as a git dependency fails ERESOLVE +; before `prepare` ever gets to run. +legacy-peer-deps=true diff --git a/package.json b/package.json index ef010fb..43b4e34 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "scripts": { "build": "tsc", + "prepare": "tsc", "test": "vitest run" }, "license": "MIT", From 1ae7330fe249f42b86057946dffc8abe097df8a3 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:59:41 +0100 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20refresh-token=20support=20?= =?UTF-8?q?=E2=80=94=20renew=20expired=20sessions=20without=20user=20inter?= =?UTF-8?q?action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions often outlive the access token (commonly 1 h), and until now the only way to keep going was to re-run the whole authorization flow, popup included. This makes DPoPTokenProvider renew transparently: - where the server advertises support, the provider registers the refresh_token grant (dynamic registration metadata) and requests the offline_access scope (OIDC Core §11); servers without support see the exact requests they saw before; - the refresh token is stored alongside the per-issuer session and an expired access token is renewed with the refresh-token grant (RFC 6749 §6) — no popup, no user interaction; - the grant is DPoP-bound with the session's existing key/handle, so the refreshed access token keeps the same cnf.jkt binding (RFC 9449 §4.3), with a single retry on a server-required DPoP nonce; - rotation is handled per RFC 9700 §4.14.2: when the server rotates the refresh token, the newest one always replaces the old; - when the refresh grant fails (refresh-token expiry, revocation, rotation-reuse detection), the provider falls back to a fresh authorization-code flow — silent while the IdP cookie lives. Public API is unchanged. Tokens stay in memory only, as before. Co-Authored-By: Claude Fable 5 --- src/DPoPTokenProvider.ts | 64 ++++++++++++++++++++++++-- test/DPoPTokenProvider.test.ts | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index eac3cc5..ad6ba6b 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -12,7 +12,11 @@ interface IssuerSession { authorizationServer: oauth.AuthorizationServer clientRegistration: ClientRegistration dpopKey: CryptoKeyPair + /** The oauth4webapi DPoP handle for token-endpoint requests. Reused so refreshed tokens stay bound to the same key (RFC 9449 §4.3) and server-provided nonces are remembered. */ + dpopHandle: oauth.DPoPHandle accessToken: string + /** The refresh token (RFC 6749 §6), when the server issued one. Updated in place when the server rotates it. */ + refreshToken: string | undefined /** Epoch milliseconds after which the access token is considered expired, or undefined when the server gave no expiry. */ expiresAt: number | undefined } @@ -83,12 +87,53 @@ export class DPoPTokenProvider implements TokenProvider { // Renew, unless a concurrent caller already replaced the expired session. if (this.#sessions.get(issuer.href) === pending) { this.#sessions.delete(issuer.href) - return this.#begin(issuer, this.#authenticate(issuer)) + return this.#begin(issuer, this.#renew(issuer, session)) } return this.#session(issuer) } + /** Prefers a transparent refresh-token grant; falls back to a new authorization-code flow when there is no refresh token or the grant fails (expired, revoked, rotation reuse, …). */ + async #renew(issuer: URL, expired: IssuerSession): Promise { + if (expired.refreshToken === undefined) { + return this.#authenticate(issuer) + } + + try { + return await this.#refresh(expired, expired.refreshToken) + } catch (e) { + console.debug("Refresh token grant failed, falling back to a new authorization", e) + return this.#authenticate(issuer) + } + } + + /** The refresh-token grant (RFC 6749 §6), DPoP-bound to the session's key, adopting the rotated refresh token when the server issues one (RFC 9700 §4.14.2). */ + async #refresh(session: IssuerSession, refreshToken: string): Promise { + const {authorizationServer, clientRegistration, dpopHandle} = session + const clientAuth = this.getClientAuth(authorizationServer.issuer, clientRegistration) + + const grant = () => oauth.refreshTokenGrantRequest(authorizationServer, clientRegistration, clientAuth, refreshToken, {DPoP: dpopHandle, signal: this.#authSignal}) + + let tokenResult + try { + tokenResult = await oauth.processRefreshTokenResponse(authorizationServer, clientRegistration, await grant()) + } catch (e) { + if (!oauth.isDPoPNonceError(e)) { + throw e + } + + // The handle has captured the server's DPoP nonce from the error response; retry once. + tokenResult = await oauth.processRefreshTokenResponse(authorizationServer, clientRegistration, await grant()) + } + + return { + ...session, + accessToken: tokenResult.access_token, + refreshToken: tokenResult.refresh_token ?? refreshToken, + expiresAt: expiresAt(tokenResult), + } + } + /** Caches the in-flight work; evicts it on failure so the flow can be retried. */ async #begin(issuer: URL, work: Promise): Promise { this.#sessions.set(issuer.href, work) @@ -109,7 +154,18 @@ export class DPoPTokenProvider implements TokenProvider { const discoveryResponse = await oauth.discoveryRequest(issuer, {signal}) const authorizationServer = await oauth.processDiscoveryResponse(issuer, discoveryResponse) - const registrationResponse = await oauth.dynamicClientRegistrationRequest(authorizationServer, {redirect_uris: [this.#callbackUri]}, {signal}) + // Opt in to refresh tokens where the server supports them: register for the + // refresh_token grant and ask for the offline_access scope (OIDC Core §11). + // Servers that support neither see the exact requests they saw before. + const useRefreshTokens = authorizationServer.grant_types_supported?.includes("refresh_token") ?? false + const useOfflineAccess = authorizationServer.scopes_supported?.includes("offline_access") ?? false + + const registrationMetadata: Parameters[1] = { + redirect_uris: [this.#callbackUri], + ...useRefreshTokens ? {grant_types: ["authorization_code", "refresh_token"]} : {}, + } + + const registrationResponse = await oauth.dynamicClientRegistrationRequest(authorizationServer, registrationMetadata, {signal}) const clientRegistration = await oauth.processDynamicClientRegistrationResponse(registrationResponse) const [registeredRedirectUri] = clientRegistration.redirect_uris as string[] const [registeredResponseType] = clientRegistration.response_types as string[] @@ -125,7 +181,7 @@ export class DPoPTokenProvider implements TokenProvider { authorizationUrl.searchParams.set("client_id", clientRegistration.client_id) authorizationUrl.searchParams.set("redirect_uri", registeredRedirectUri!) authorizationUrl.searchParams.set("response_type", registeredResponseType!) - authorizationUrl.searchParams.set("scope", "openid webid") + authorizationUrl.searchParams.set("scope", useOfflineAccess ? "openid webid offline_access" : "openid webid") authorizationUrl.searchParams.set("prompt", "none") authorizationUrl.searchParams.set("state", state) authorizationUrl.searchParams.set("nonce", nonce) @@ -171,7 +227,9 @@ export class DPoPTokenProvider implements TokenProvider { authorizationServer, clientRegistration, dpopKey, + dpopHandle: dpop, accessToken: tokenResult.access_token, + refreshToken: tokenResult.refresh_token, expiresAt: expiresAt(tokenResult), } } diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts index 470a45b..cb090c0 100644 --- a/test/DPoPTokenProvider.test.ts +++ b/test/DPoPTokenProvider.test.ts @@ -91,3 +91,87 @@ describe("DPoPTokenProvider session cache", () => { expect(getCode).toHaveBeenCalledTimes(2) }) }) + +describe("DPoPTokenProvider refresh tokens", () => { + beforeEach(async () => { + as = await createFakeAuthorizationServer({ + issueRefreshTokens: true, + scopesSupported: ["openid", "webid", "offline_access"], + grantTypesSupported: ["authorization_code", "refresh_token"], + }) + vi.stubGlobal("fetch", as.fetch) + }) + + it("opts in where supported: registers the refresh_token grant and requests offline_access", async () => { + const {provider} = makeProvider() + + await provider.upgrade(new Request("https://pod.test/a")) + + expect(as.registrations[0]?.grant_types).toEqual(["authorization_code", "refresh_token"]) + expect(as.authorizationRequests[0]?.scope).toBe("openid webid offline_access") + }) + + it("does not change the requests for servers without refresh support", async () => { + as = await createFakeAuthorizationServer() + vi.stubGlobal("fetch", as.fetch) + const {provider} = makeProvider() + + await provider.upgrade(new Request("https://pod.test/a")) + + expect(as.registrations[0]?.grant_types).toBeUndefined() + expect(as.authorizationRequests[0]?.scope).toBe("openid webid") + }) + + it("refreshes an expired access token without user interaction", async () => { + const {provider, getCode} = makeProvider() + + const first = await provider.upgrade(new Request("https://pod.test/a")) + + vi.useFakeTimers() + vi.setSystemTime(Date.now() + 3601 * 1000) + + const second = await provider.upgrade(new Request("https://pod.test/b")) + + expect(getCode).toHaveBeenCalledTimes(1) // no new popup + expect(second.headers.get("Authorization")).not.toBe(first.headers.get("Authorization")) + expect(as.tokenRequests.at(-1)?.get("grant_type")).toBe("refresh_token") + }) + + it("adopts the rotated refresh token (a second expiry refreshes with the new one)", async () => { + const {provider, getCode} = makeProvider() + + await provider.upgrade(new Request("https://pod.test/a")) + + vi.useFakeTimers() + vi.setSystemTime(Date.now() + 3601 * 1000) + await provider.upgrade(new Request("https://pod.test/b")) + + vi.setSystemTime(Date.now() + 3601 * 1000) + const third = await provider.upgrade(new Request("https://pod.test/c")) + + expect(getCode).toHaveBeenCalledTimes(1) + expect(third.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/) + + const refreshRequests = as.tokenRequests.filter(r => r.get("grant_type") === "refresh_token") + expect(refreshRequests).toHaveLength(2) + // The second refresh presented a different (rotated) token than the first. + expect(refreshRequests[1]?.get("refresh_token")).not.toBe(refreshRequests[0]?.get("refresh_token")) + }) + + it("falls back to a new authorization-code flow when the refresh grant fails", async () => { + const {provider, getCode} = makeProvider() + + await provider.upgrade(new Request("https://pod.test/a")) + + // Revoke server-side: the next refresh attempt gets invalid_grant. + as.activeRefreshTokens.clear() + + vi.useFakeTimers() + vi.setSystemTime(Date.now() + 3601 * 1000) + + const second = await provider.upgrade(new Request("https://pod.test/b")) + + expect(getCode).toHaveBeenCalledTimes(2) // re-authorized + expect(second.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/) + }) +}) From 3ff3667406b9f3773b07e9517f51fc988291921e Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:44:25 +0100 Subject: [PATCH 05/10] test: cover the DPoP-nonce retry on the refresh grant; stop logging the raw refresh error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: the fake AS can now challenge refresh grants with use_dpop_nonce + DPoP-Nonce (RFC 9449 §8) so the one-retry handshake is exercised end to end, and the refresh-failure fallback no longer logs the raw oauth4webapi error (it can carry the token-endpoint request/response, tokens included). Co-Authored-By: Claude Fable 5 --- src/DPoPTokenProvider.ts | 6 ++++-- test/DPoPTokenProvider.test.ts | 23 +++++++++++++++++++++++ test/fakeAuthorizationServer.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index ad6ba6b..5a70ea9 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -101,8 +101,10 @@ export class DPoPTokenProvider implements TokenProvider { try { return await this.#refresh(expired, expired.refreshToken) - } catch (e) { - console.debug("Refresh token grant failed, falling back to a new authorization", e) + } catch { + // Deliberately not logging the error: oauth4webapi errors can carry the + // token-endpoint request/response (tokens included). + console.debug("Refresh token grant failed, falling back to a new authorization") return this.#authenticate(issuer) } } diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts index cb090c0..2a1384f 100644 --- a/test/DPoPTokenProvider.test.ts +++ b/test/DPoPTokenProvider.test.ts @@ -158,6 +158,29 @@ describe("DPoPTokenProvider refresh tokens", () => { expect(refreshRequests[1]?.get("refresh_token")).not.toBe(refreshRequests[0]?.get("refresh_token")) }) + it("retries the refresh grant once when the server demands a DPoP nonce", async () => { + as = await createFakeAuthorizationServer({ + issueRefreshTokens: true, + scopesSupported: ["openid", "webid", "offline_access"], + refreshRequiresDPoPNonce: true, + }) + vi.stubGlobal("fetch", as.fetch) + const {provider, getCode} = makeProvider() + + await provider.upgrade(new Request("https://pod.test/a")) + + vi.useFakeTimers() + vi.setSystemTime(Date.now() + 3601 * 1000) + + const second = await provider.upgrade(new Request("https://pod.test/b")) + + expect(getCode).toHaveBeenCalledTimes(1) // refreshed silently despite the nonce challenge + expect(second.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/) + + const refreshRequests = as.tokenRequests.filter(r => r.get("grant_type") === "refresh_token") + expect(refreshRequests).toHaveLength(2) // challenged once, then accepted with the nonce + }) + it("falls back to a new authorization-code flow when the refresh grant fails", async () => { const {provider, getCode} = makeProvider() diff --git a/test/fakeAuthorizationServer.ts b/test/fakeAuthorizationServer.ts index 7f651f4..f7dd9ba 100644 --- a/test/fakeAuthorizationServer.ts +++ b/test/fakeAuthorizationServer.ts @@ -16,6 +16,8 @@ export interface FakeAuthorizationServerOptions { issueRefreshTokens?: boolean /** Whether the refresh-token grant rotates the refresh token. Default true. */ rotateRefreshTokens?: boolean + /** Whether the refresh-token grant demands a server-provided DPoP nonce (RFC 9449 §8), challenging proofs without one via `use_dpop_nonce`. Default false. */ + refreshRequiresDPoPNonce?: boolean /** `scopes_supported` advertised by discovery. Default ["openid", "webid"]. */ scopesSupported?: string[] /** `grant_types_supported` advertised by discovery. Default ["authorization_code"]. */ @@ -61,6 +63,23 @@ function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), {status, headers: {"content-type": "application/json"}}) } +/** The server-provided nonce demanded when `refreshRequiresDPoPNonce` is on. */ +const dpopNonce = "fake-as-dpop-nonce" + +/** The `nonce` claim of the request's DPoP proof, if any (signature deliberately not verified — this is a test double). */ +function dpopProofNonce(request: Request): string | undefined { + const payload = request.headers.get("DPoP")?.split(".")[1] + if (payload === undefined) { + return undefined + } + + try { + return JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/"))).nonce + } catch { + return undefined + } +} + export async function createFakeAuthorizationServer(options: FakeAuthorizationServerOptions = {}): Promise { const issuer = "https://as.test" const expiresIn = options.expiresIn ?? 3600 @@ -151,6 +170,15 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe } if (params.get("grant_type") === "refresh_token" && issueRefreshTokens) { + // Nonce challenge first (RFC 9449 §8): the presented refresh token must + // survive the challenge so the client's retry can redeem it. + if (options.refreshRequiresDPoPNonce && dpopProofNonce(request) !== dpopNonce) { + return new Response(JSON.stringify({error: "use_dpop_nonce", error_description: "Authorization server requires nonce in DPoP proof"}), { + status: 400, + headers: {"content-type": "application/json", "DPoP-Nonce": dpopNonce}, + }) + } + const presented = params.get("refresh_token") ?? "" if (!activeRefreshTokens.has(presented)) { return json({error: "invalid_grant"}, 400) From f2e05e47ed5df895d5c2aaad26170cdfd0df2ab6 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:00:52 +0100 Subject: [PATCH 06/10] fix: send prompt=consent on the interactive attempt when requesting offline_access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OIDC Core §11: the AS MUST ignore offline_access unless the request's prompt includes consent — oidc-provider (Community Solid Server and brokers built on it) enforces this strictly, so the previous retry (prompt removed entirely) silently came back without a refresh token. Found live against a Solid broker; the fake AS gains an opt-in enforceOfflineAccessConsent mode reproducing that behaviour, and a test drives silent-attempt → login_required → prompt=consent retry → refresh token issued → silent renewal. Co-Authored-By: Claude Fable 5 --- src/DPoPTokenProvider.ts | 14 +++++++--- test/DPoPTokenProvider.test.ts | 24 ++++++++++++++++++ test/fakeAuthorizationServer.ts | 45 +++++++++++++++++++++++++-------- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index 5a70ea9..252b328 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -211,9 +211,17 @@ export class DPoPTokenProvider implements TokenProvider { // Workaround ESS not returning `iss` in error response isEssMissingIssInteractionNeeded(e) ) { - console.debug("Authorization server requires user interaction, retrying without prompt") - - authorizationUrl.searchParams.delete("prompt") + console.debug("Authorization server requires user interaction, retrying interactively") + + // The interactive attempt must carry `prompt=consent` for the server + // to honour `offline_access`: OIDC Core §11 says the AS MUST ignore + // the scope otherwise, and oidc-provider (Community Solid Server and + // brokers built on it) enforces that strictly. + if (useOfflineAccess) { + authorizationUrl.searchParams.set("prompt", "consent") + } else { + authorizationUrl.searchParams.delete("prompt") + } const authorizationCodeResponse = await this.#getCode(authorizationUrl, signal) authorizationCodeParams = oauth.validateAuthResponse(authorizationServer, clientRegistration, new URL(authorizationCodeResponse), state) } else { diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts index 2a1384f..ca64ac5 100644 --- a/test/DPoPTokenProvider.test.ts +++ b/test/DPoPTokenProvider.test.ts @@ -158,6 +158,30 @@ describe("DPoPTokenProvider refresh tokens", () => { expect(refreshRequests[1]?.get("refresh_token")).not.toBe(refreshRequests[0]?.get("refresh_token")) }) + it("sends prompt=consent on the interactive attempt so strict servers honour offline_access (OIDC Core §11)", async () => { + as = await createFakeAuthorizationServer({ + issueRefreshTokens: true, + scopesSupported: ["openid", "webid", "offline_access"], + enforceOfflineAccessConsent: true, + }) + vi.stubGlobal("fetch", as.fetch) + const {provider, getCode} = makeProvider() + + const first = await provider.upgrade(new Request("https://pod.test/a")) + + expect(first.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/) + expect(getCode).toHaveBeenCalledTimes(2) // silent attempt → login_required → interactive retry + expect(as.authorizationRequests.at(-1)?.prompt).toBe("consent") + + // The strict server issued a refresh token, so expiry renews silently. + vi.useFakeTimers() + vi.setSystemTime(Date.now() + 3601 * 1000) + await provider.upgrade(new Request("https://pod.test/b")) + + expect(getCode).toHaveBeenCalledTimes(2) // no further interaction + expect(as.tokenRequests.at(-1)?.get("grant_type")).toBe("refresh_token") + }) + it("retries the refresh grant once when the server demands a DPoP nonce", async () => { as = await createFakeAuthorizationServer({ issueRefreshTokens: true, diff --git a/test/fakeAuthorizationServer.ts b/test/fakeAuthorizationServer.ts index f7dd9ba..64350d6 100644 --- a/test/fakeAuthorizationServer.ts +++ b/test/fakeAuthorizationServer.ts @@ -18,6 +18,14 @@ export interface FakeAuthorizationServerOptions { rotateRefreshTokens?: boolean /** Whether the refresh-token grant demands a server-provided DPoP nonce (RFC 9449 §8), challenging proofs without one via `use_dpop_nonce`. Default false. */ refreshRequiresDPoPNonce?: boolean + /** + * Emulate a server that enforces OIDC Core §11 the way oidc-provider does + * (Community Solid Server and brokers built on it): `prompt=none` is + * answered with `error=login_required` (no session), and `offline_access` + * is silently dropped from any request whose prompt does not include + * `consent`. Default false (lenient: silent authorization succeeds). + */ + enforceOfflineAccessConsent?: boolean /** `scopes_supported` advertised by discovery. Default ["openid", "webid"]. */ scopesSupported?: string[] /** `grant_types_supported` advertised by discovery. Default ["authorization_code"]. */ @@ -90,8 +98,8 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe const publicJwk = await crypto.subtle.exportKey("jwk", keys.publicKey) let counter = 0 - /** nonce + client of each outstanding authorization code */ - const codes = new Map() + /** nonce + client + effective scope of each outstanding authorization code */ + const codes = new Map() const activeRefreshTokens = new Set() const authorizationRequests: AuthorizationRequestRecord[] = [] const registrations: Record[] = [] @@ -107,12 +115,12 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe return `${header}.${payload}.${base64url(new Uint8Array(signature))}` } - function tokenBody(refreshable: boolean, idToken?: string) { + function tokenBody(refreshable: boolean, scope: string, idToken?: string) { const body: Record = { access_token: `at-${++counter}`, token_type: "DPoP", expires_in: expiresIn, - scope: "openid webid", + scope, } if (idToken !== undefined) body.id_token = idToken if (refreshable) { @@ -166,7 +174,8 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe return json({error: "invalid_grant"}, 400) } codes.delete(params.get("code")!) - return json(tokenBody(issueRefreshTokens, await signIdToken(params.get("client_id") ?? code.clientId ?? "", code.nonce))) + const refreshable = issueRefreshTokens && code.scope.split(" ").includes("offline_access") + return json(tokenBody(refreshable, code.scope, await signIdToken(params.get("client_id") ?? code.clientId ?? "", code.nonce))) } if (params.get("grant_type") === "refresh_token" && issueRefreshTokens) { @@ -186,10 +195,10 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe if (rotate) { // Rotation (RFC 9700 §4.14.2): retire the presented token and issue a replacement. activeRefreshTokens.delete(presented) - return json(tokenBody(true)) + return json(tokenBody(true, "openid webid offline_access")) } // No rotation: the presented token stays active and the response carries no new one (RFC 6749 §6). - return json(tokenBody(false)) + return json(tokenBody(false, "openid webid offline_access")) } return json({error: "unsupported_grant_type"}, 400) @@ -202,17 +211,33 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe issuer, fetch: (input, init) => handle(new Request(input, init)), async authorize(authorizationUrl: URL): Promise { + const prompt = authorizationUrl.searchParams.get("prompt") + const scope = authorizationUrl.searchParams.get("scope") ?? "openid" authorizationRequests.push({ - scope: authorizationUrl.searchParams.get("scope"), - prompt: authorizationUrl.searchParams.get("prompt"), + scope, + prompt, clientId: authorizationUrl.searchParams.get("client_id"), }) + const redirect = new URL(authorizationUrl.searchParams.get("redirect_uri")!) + + if (options.enforceOfflineAccessConsent && prompt === "none") { + // No session: a silent request cannot succeed. + redirect.searchParams.set("error", "login_required") + redirect.searchParams.set("state", authorizationUrl.searchParams.get("state")!) + return redirect.href + } + + // OIDC Core §11: offline_access MUST be ignored unless the prompt includes consent. + const effectiveScope = options.enforceOfflineAccessConsent && !(prompt?.split(" ").includes("consent") ?? false) + ? scope.split(" ").filter(s => s !== "offline_access").join(" ") + : scope + const code = `code-${++counter}` codes.set(code, { nonce: authorizationUrl.searchParams.get("nonce"), clientId: authorizationUrl.searchParams.get("client_id"), + scope: effectiveScope, }) - const redirect = new URL(authorizationUrl.searchParams.get("redirect_uri")!) redirect.searchParams.set("code", code) redirect.searchParams.set("state", authorizationUrl.searchParams.get("state")!) return redirect.href From 4e0d4d5f9028249923cbcb150d25b8af6fecb7e9 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:46:42 +0100 Subject: [PATCH 07/10] feat: renew the session once when an upgraded request is still rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cached access token can stop working before its reported expiry — or have no reported expiry at all (expires_in is optional): revocation, server-side invalidation, key rollover. Previously the manager replayed the same rejected token on every 401, locking the user out until reload. ReactiveFetchManager now asks the provider to invalidate its credentials when the upgraded request still comes back 401, and retries exactly once with renewed ones (refresh grant first, popup flow as fallback). A still-rejected retry surfaces the 401 unchanged — bounded, never a loop. TokenProvider.invalidate is optional, so existing providers are unaffected (public API stays backwards-compatible). Co-Authored-By: Claude Fable 5 --- src/DPoPTokenProvider.ts | 24 ++++++++++ src/ReactiveFetchManager.ts | 13 ++++- src/TokenProvider.ts | 11 +++++ test/DPoPTokenProvider.test.ts | 86 ++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index 252b328..ddc7224 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -68,6 +68,30 @@ export class DPoPTokenProvider implements TokenProvider { return new Request(request, {headers}) } + /** + * Marks the cached session stale when the access token attached to the + * request was rejected by the resource server (still 401 after an upgrade): + * revoked, invalidated early, or expired without a server-reported + * lifetime. The next {@link upgrade} then renews the session — refresh + * grant first, new authorization-code flow as fallback — instead of + * replaying the rejected token. + */ + async invalidate(request: Request): Promise { + const issuer = await this.#getIssuer(request) + const pending = this.#sessions.get(issuer.href) + if (pending === undefined) { + return + } + + const session = await pending.catch(() => undefined) + + // Only when the rejected token is still the cached one — a concurrent + // renewal may already have replaced it. + if (session !== undefined && request.headers.get("Authorization") === `DPoP ${session.accessToken}`) { + session.expiresAt = 0 + } + } + /** * Returns the cached session for the issuer, renewing it when expired and * establishing it when absent. A failed flow is not cached, so the next diff --git a/src/ReactiveFetchManager.ts b/src/ReactiveFetchManager.ts index 6fffbfb..5a73b23 100644 --- a/src/ReactiveFetchManager.ts +++ b/src/ReactiveFetchManager.ts @@ -32,8 +32,17 @@ export class ReactiveFetchManager extends EventTarget { return response } - const upgraded = await provider.upgrade(request) - return this.#globalFetch.call(undefined, upgraded) + const upgraded = await provider.upgrade(request.clone()) + const upgradedResponse = await this.#globalFetch.call(undefined, upgraded) + if (upgradedResponse.status !== 401 || provider.invalidate === undefined) { + return upgradedResponse + } + + // The credentials we attached were rejected. Mark them stale and retry + // once with renewed ones; if those are rejected too, give up and let the + // caller see the 401 (bounded — never a loop). + await provider.invalidate(upgraded) + return this.#globalFetch.call(undefined, await provider.upgrade(request)) } async #findProvider(request: Request): Promise { diff --git a/src/TokenProvider.ts b/src/TokenProvider.ts index fc646b3..0da8e29 100644 --- a/src/TokenProvider.ts +++ b/src/TokenProvider.ts @@ -2,4 +2,15 @@ export interface TokenProvider { matches(request: Request): Promise upgrade(request: Request): Promise + + /** + * Optional: called when a request this provider upgraded was still rejected + * with 401 — the attached credentials were revoked, invalidated early, or + * expired without a server-reported lifetime. The provider should mark any + * cached credentials for the request stale so the next {@link upgrade} + * renews them instead of replaying the rejected ones. + * + * @param request - The rejected upgraded request (carrying the credentials this provider attached). + */ + invalidate?(request: Request): Promise } diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts index ca64ac5..7cc4665 100644 --- a/test/DPoPTokenProvider.test.ts +++ b/test/DPoPTokenProvider.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { DPoPTokenProvider } from "../src/DPoPTokenProvider.js" +import { ReactiveFetchManager } from "../src/ReactiveFetchManager.js" import { createFakeAuthorizationServer, type FakeAuthorizationServer } from "./fakeAuthorizationServer.js" const callbackUri = "https://app.test/callback.html" @@ -222,3 +223,88 @@ describe("DPoPTokenProvider refresh tokens", () => { expect(second.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/) }) }) + +describe("renewal after a rejected upgrade (401-once retry)", () => { + /** Tokens the fake resource server no longer accepts. */ + let revokedTokens: Set + /** Bearer parts of the Authorization headers the resource server saw, oldest first. */ + let presentedTokens: string[] + + /** Routes pod.test to a fake resource server (401 unless a non-revoked token is presented), everything else to the fake AS. */ + function combinedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const request = new Request(input, init) + if (new URL(request.url).origin !== "https://pod.test") { + return as.fetch(input, init) + } + + const token = request.headers.get("Authorization")?.replace("DPoP ", "") + if (token === undefined) { + return Promise.resolve(new Response(null, {status: 401})) + } + + presentedTokens.push(token) + return Promise.resolve(revokedTokens.has(token) ? new Response(null, {status: 401}) : new Response("ok")) + } + + beforeEach(async () => { + as = await createFakeAuthorizationServer({ + issueRefreshTokens: true, + scopesSupported: ["openid", "webid", "offline_access"], + }) + revokedTokens = new Set() + presentedTokens = [] + vi.stubGlobal("fetch", combinedFetch) + }) + + it("renews the session and retries once when the upgraded request is still rejected", async () => { + const {provider, getCode} = makeProvider() + const manager = new ReactiveFetchManager([provider]) + + const first = await manager.fetch("https://pod.test/private") + expect(first.status).toBe(200) + + // Revoke the established token server-side (no expiry has passed). + revokedTokens.add(presentedTokens.at(-1)!) + + const second = await manager.fetch("https://pod.test/private") + + expect(second.status).toBe(200) + expect(getCode).toHaveBeenCalledTimes(1) // renewed via the refresh grant, no new popup + expect(as.tokenRequests.at(-1)?.get("grant_type")).toBe("refresh_token") + }) + + it("gives up after one renewal: a still-rejected retry surfaces the 401 unchanged", async () => { + const {provider} = makeProvider() + const manager = new ReactiveFetchManager([provider]) + + await manager.fetch("https://pod.test/private") + + // Reject everything from now on, whatever token is presented. + const reject = {has: () => true} as unknown as Set + revokedTokens = reject + + const tokenPresentationsBefore = presentedTokens.length + const response = await manager.fetch("https://pod.test/private") + + expect(response.status).toBe(401) + // Bounded: the cached token once, the renewed token once — then give up. + expect(presentedTokens.length - tokenPresentationsBefore).toBe(2) + }) + + it("ignores invalidation for a token that is no longer the cached one", async () => { + const {provider} = makeProvider() + + const first = await provider.upgrade(new Request("https://pod.test/a")) + await provider.invalidate(first) + const second = await provider.upgrade(new Request("https://pod.test/b")) + expect(second.headers.get("Authorization")).not.toBe(first.headers.get("Authorization")) + + // Replaying the stale rejection must not invalidate the renewed session. + const tokenRequestsBefore = as.tokenRequests.length + await provider.invalidate(first) + const third = await provider.upgrade(new Request("https://pod.test/c")) + + expect(third.headers.get("Authorization")).toBe(second.headers.get("Authorization")) + expect(as.tokenRequests.length).toBe(tokenRequestsBefore) + }) +}) From 68699d735f0a873b5e5c5e5f02665fb776efa8c5 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:24:45 +0100 Subject: [PATCH 08/10] review: cancel the discarded 401 body; fake RS requires the DPoP scheme Co-Authored-By: Claude Fable 5 --- src/ReactiveFetchManager.ts | 4 +++- test/DPoPTokenProvider.test.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ReactiveFetchManager.ts b/src/ReactiveFetchManager.ts index 5a73b23..c26fa4d 100644 --- a/src/ReactiveFetchManager.ts +++ b/src/ReactiveFetchManager.ts @@ -40,7 +40,9 @@ export class ReactiveFetchManager extends EventTarget { // The credentials we attached were rejected. Mark them stale and retry // once with renewed ones; if those are rejected too, give up and let the - // caller see the 401 (bounded — never a loop). + // caller see the 401 (bounded — never a loop). Cancel the discarded + // response's body so the connection can be reused (undici keep-alive). + await upgradedResponse.body?.cancel().catch(() => undefined) await provider.invalidate(upgraded) return this.#globalFetch.call(undefined, await provider.upgrade(request)) } diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts index 7cc4665..fa91a72 100644 --- a/test/DPoPTokenProvider.test.ts +++ b/test/DPoPTokenProvider.test.ts @@ -237,10 +237,11 @@ describe("renewal after a rejected upgrade (401-once retry)", () => { return as.fetch(input, init) } - const token = request.headers.get("Authorization")?.replace("DPoP ", "") - if (token === undefined) { + const authorization = request.headers.get("Authorization") + if (authorization === null || !authorization.startsWith("DPoP ")) { return Promise.resolve(new Response(null, {status: 401})) } + const token = authorization.slice("DPoP ".length) presentedTokens.push(token) return Promise.resolve(revokedTokens.has(token) ? new Response(null, {status: 401}) : new Response("ok")) From 7efec2ea8bd942bac615f608b60f6c172fb52b7b Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:46:05 +0100 Subject: [PATCH 09/10] fix: allow opting in to insecure OAuth requests oauth4webapi enforces HTTPS on every request, so the auth-code + PKCE + DPoP flow could not talk to a local Community Solid Server over plain HTTP. Rather than guessing which issuers are loopback, expose a global switch consumers toggle themselves. InsecureConfiguration.allow() is deprecated on purpose so the security implication shows up at the call site. Also replaces the unconditional allow in BearerTokenProvider, and covers ClientCredentialsTokenProvider, which had no way to reach a local issuer at all. --- index.html | 5 ++++- src/BearerTokenProvider.ts | 10 ++++----- src/ClientCredentialsTokenProvider.ts | 7 +++++-- src/DPoPTokenProvider.ts | 9 +++++---- src/InsecureConfiguration.ts | 29 +++++++++++++++++++++++++++ src/mod.ts | 1 + 6 files changed, 48 insertions(+), 13 deletions(-) create mode 100644 src/InsecureConfiguration.ts diff --git a/index.html b/index.html index c835634..972841a 100644 --- a/index.html +++ b/index.html @@ -15,9 +15,12 @@ }