From 636d41e97acb6feb5a065cee012c74cd2f3d7da2 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 1/2] 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 | 10 +- src/DPoPTokenProvider.ts | 115 +++++++++++++++++-- test/DPoPTokenProvider.test.ts | 93 +++++++++++++++ test/fakeAuthorizationServer.ts | 193 ++++++++++++++++++++++++++++++++ 4 files changed, 396 insertions(+), 15 deletions(-) create mode 100644 test/DPoPTokenProvider.test.ts create mode 100644 test/fakeAuthorizationServer.ts diff --git a/package.json b/package.json index 0efe8d0..d3b6dfc 100644 --- a/package.json +++ b/package.json @@ -26,17 +26,19 @@ "url": "git+https://github.com/solid-contrib/reactive-authentication.git" }, "scripts": { - "build": "tsc" + "build": "tsc", + "test": "vitest run" }, "license": "MIT", "dependencies": { - "oauth4webapi": "^3", - "dpop": "^2" + "dpop": "^2", + "oauth4webapi": "^3" }, "devDependencies": { "typedoc": "^0.28.18", "typedoc-plugin-mdn-links": "^5.1.1", - "typescript": "^6" + "typescript": "^6", + "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 23e0a5d56e50df36a806519cfe02b79ae81b72a1 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 2/2] 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)