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..c26fa4d 100644 --- a/src/ReactiveFetchManager.ts +++ b/src/ReactiveFetchManager.ts @@ -32,8 +32,19 @@ 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). 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)) } 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..fa91a72 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,89 @@ 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 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")) + } + + 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) + }) +})