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/5] 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 5132179c52169f2a3fb41dbea240e5d2d6f2c7c9 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 2/5] =?UTF-8?q?feat:=20refresh-token=20support=20=E2=80=94?= =?UTF-8?q?=20renew=20expired=20sessions=20without=20user=20interaction?= 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 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 3/5] 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 8a5e8aed38ac3fcf784e34e71daf6b8fe08ebd92 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 4/5] 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 bb8606e167a232726663358439b3fa45eb5df11d 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 5/5] 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