From e28ce094f83608409751b3ffb5db9238d340cf70 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 1/2] 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 5a70ea9..0357cf7 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 2a1384f..b7b67d4 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" @@ -198,3 +199,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 04feb23f68b9618b5703e64b78d35037d7303865 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 2/2] 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"))