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 1/4] 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 2/4] 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 46e4362eac67059852002b974b948d609be7fde8 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:16:18 +0100 Subject: [PATCH 3/4] feat: make session caching pluggable and its key configurable Sessions were held in a private per-issuer Map, which fixed both where they live and how they are keyed. - SessionCache is the storage seam, async so a session can live in IndexedDB or an editor secrets API rather than only in memory. MemorySessionCache is the default. - GetSessionKeyCallback derives the key, defaulting to the issuer. Callers that must not share one session per authorization server can scope narrower. - Single-flight moves to a separate in-memory map of in-flight flows, since a pending Promise cannot be persisted. One popup per key is unchanged. - IssuerSession becomes the exported DPoPSession, so caches can be typed. The fake authorization server now signs ID tokens with jose instead of hand-rolled base64url and subtle.sign. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 1 + src/DPoPTokenProvider.ts | 100 ++++++++++++++++---------------- src/GetSessionKeyCallback.ts | 1 + src/MemorySessionCache.ts | 18 ++++++ src/SessionCache.ts | 13 +++++ src/mod.ts | 3 + test/DPoPTokenProvider.test.ts | 55 +++++++++++++++++- test/fakeAuthorizationServer.ts | 43 ++++++-------- 8 files changed, 156 insertions(+), 78 deletions(-) create mode 100644 src/GetSessionKeyCallback.ts create mode 100644 src/MemorySessionCache.ts create mode 100644 src/SessionCache.ts diff --git a/package.json b/package.json index ef010fb..5b17f7b 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "devDependencies": { "@rdfjs/types": "^2", "@types/n3": "^1", + "jose": "^6.2.8", "typedoc": "^0.28.18", "typedoc-plugin-mdn-links": "^5.1.1", "typescript": "^7", diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index eac3cc5..8e66085 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -3,49 +3,61 @@ import * as DPoP from "dpop" import type { GetCodeCallback } from "./GetCodeCallback.js" import type { TokenProvider } from "./TokenProvider.js" import type { GetIssuerCallback } from "./GetIssuerCallback.js" +import type { GetSessionKeyCallback } from "./GetSessionKeyCallback.js" +import type { SessionCache } from "./SessionCache.js" +import { MemorySessionCache } from "./MemorySessionCache.js" /** The client metadata shape produced by dynamic client registration. */ type ClientRegistration = Awaited> -/** Authentication state for one issuer, reused across upgrades. */ -interface IssuerSession { +/** + * An established authentication, reused across upgrades. + * + * @remarks Structured cloneable, so a {@link SessionCache} can persist it, but + * not JSON serialisable because of the non extractable {@link CryptoKeyPair}. + */ +export interface DPoPSession { 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. */ + /** Epoch milliseconds, or undefined when the server reported 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. - */ +export interface DPoPTokenProviderOptions { + /** Defaults to {@link MemorySessionCache}. */ + sessionCache?: SessionCache + + /** Defaults to the issuer, so one session is shared per authorization server. */ + getSessionKey?: GetSessionKeyCallback +} + +/** Renew this long before the reported expiry, to absorb clock skew. */ const expirySkewMs = 30_000 export class DPoPTokenProvider implements TokenProvider { readonly #getCode: GetCodeCallback readonly #callbackUri: string readonly #getIssuer: GetIssuerCallback + readonly #getSessionKey: GetSessionKeyCallback + readonly #sessions: SessionCache - /** - * 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>() + /** In flight flows, so concurrent upgrades share one popup. */ + readonly #pending = 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. + * Provider owned, so aborting one request does not cancel the login that + * other concurrent upgrades are waiting on. */ readonly #authSignal = new AbortController().signal - constructor(callbackUri: string, getCodeCallback: GetCodeCallback, getIssuerCallback: GetIssuerCallback) { + constructor(callbackUri: string, getCodeCallback: GetCodeCallback, getIssuerCallback: GetIssuerCallback, options: DPoPTokenProviderOptions = {}) { this.#getCode = getCodeCallback this.#callbackUri = callbackUri this.#getIssuer = getIssuerCallback + this.#getSessionKey = options.getSessionKey ?? (async (_, issuer) => issuer.href) + this.#sessions = options.sessionCache ?? new MemorySessionCache() } async matches(request: Request): Promise { @@ -54,7 +66,7 @@ export class DPoPTokenProvider implements TokenProvider { async upgrade(request: Request): Promise { const issuer = await this.#getIssuer(request) - const session = await this.#session(issuer) + const session = await this.#session(request, issuer) const headers = new Headers(request.headers) @@ -64,46 +76,34 @@ export class DPoPTokenProvider implements TokenProvider { 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)) - } + /** Reuses a live session, and otherwise runs the flow once per key. */ + async #session(request: Request, issuer: URL): Promise { + const key = await this.#getSessionKey(request, issuer) - const session = await pending - if (!hasExpired(session)) { - return session + const cached = await this.#sessions.get(key) + if (cached !== undefined && !hasExpired(cached)) { + return cached } - // 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 pending = this.#pending.get(key) + if (pending !== undefined) { + return pending } - 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) + // Not cached on failure, so the next upgrade retries. + const work = this.#authenticate(issuer) + this.#pending.set(key, work) try { - return await work - } catch (e) { - if (this.#sessions.get(issuer.href) === work) { - this.#sessions.delete(issuer.href) - } - throw e + const session = await work + await this.#sessions.set(key, session) + return session + } finally { + this.#pending.delete(key) } } - /** The full authorization-code flow: discovery → registration → PKCE/DPoP code grant. */ - async #authenticate(issuer: URL): Promise { + /** Discovery, registration, then the PKCE and DPoP code grant. */ + async #authenticate(issuer: URL): Promise { const signal = this.#authSignal const discoveryResponse = await oauth.discoveryRequest(issuer, {signal}) @@ -201,7 +201,7 @@ 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 { +function hasExpired(session: DPoPSession): boolean { return session.expiresAt !== undefined && Date.now() >= session.expiresAt } diff --git a/src/GetSessionKeyCallback.ts b/src/GetSessionKeyCallback.ts new file mode 100644 index 0000000..e4709f9 --- /dev/null +++ b/src/GetSessionKeyCallback.ts @@ -0,0 +1 @@ +export type GetSessionKeyCallback = (request: Request, issuer: URL) => Promise diff --git a/src/MemorySessionCache.ts b/src/MemorySessionCache.ts new file mode 100644 index 0000000..301f5f6 --- /dev/null +++ b/src/MemorySessionCache.ts @@ -0,0 +1,18 @@ +import type { SessionCache } from "./SessionCache.js" + +/** Keeps sessions for the lifetime of the provider. */ +export class MemorySessionCache implements SessionCache { + readonly #entries = new Map() + + async get(key: string): Promise { + return this.#entries.get(key) + } + + async set(key: string, value: T): Promise { + this.#entries.set(key, value) + } + + async delete(key: string): Promise { + this.#entries.delete(key) + } +} diff --git a/src/SessionCache.ts b/src/SessionCache.ts new file mode 100644 index 0000000..6ba8641 --- /dev/null +++ b/src/SessionCache.ts @@ -0,0 +1,13 @@ +/** + * Where a token provider keeps established sessions. + * + * @remarks Asynchronous so sessions can live wherever the host offers, such as + * IndexedDB in a browser or the secrets API in an editor extension. + */ +export interface SessionCache { + get(key: string): Promise + + set(key: string, value: T): Promise + + delete(key: string): Promise +} diff --git a/src/mod.ts b/src/mod.ts index 0342cf6..50c37bb 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -9,6 +9,9 @@ export * from "./ClientCredentialsTokenProvider.js" export * from "./GetCodeCallback.js" export * from "./issuerFrom.js" export * from "./TokenProvider.js" +export * from "./SessionCache.js" +export * from "./MemorySessionCache.js" +export * from "./GetSessionKeyCallback.js" export * from "./GetIssuerCallback.js" export * from "./IdpPicker.js" export * from "./WebIdPicker.js" diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts index 470a45b..1362ab3 100644 --- a/test/DPoPTokenProvider.test.ts +++ b/test/DPoPTokenProvider.test.ts @@ -1,13 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { DPoPTokenProvider } from "../src/DPoPTokenProvider.js" +import { DPoPTokenProvider, type DPoPSession, type DPoPTokenProviderOptions } from "../src/DPoPTokenProvider.js" +import { MemorySessionCache } from "../src/MemorySessionCache.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)) +function makeProvider(getCode = vi.fn((url: URL) => as.authorize(url)), options: DPoPTokenProviderOptions = {}) { + const provider = new DPoPTokenProvider(callbackUri, getCode, async () => new URL(as.issuer), options) return {provider, getCode} } @@ -91,3 +92,51 @@ describe("DPoPTokenProvider session cache", () => { expect(getCode).toHaveBeenCalledTimes(2) }) }) + +describe("DPoPTokenProvider session cache configuration", () => { + beforeEach(async () => { + as = await createFakeAuthorizationServer() + vi.stubGlobal("fetch", as.fetch) + }) + + it("defaults to one session per issuer", async () => { + const {provider, getCode} = makeProvider() + + await provider.upgrade(new Request("https://pod.test/a")) + await provider.upgrade(new Request("https://other.test/b")) + + expect(getCode).toHaveBeenCalledTimes(1) + }) + + it("honours a custom session key, so callers can scope sessions narrower than the issuer", async () => { + const getCode = vi.fn((url: URL) => as.authorize(url)) + const {provider} = makeProvider(getCode, {getSessionKey: async request => new URL(request.url).origin}) + + await provider.upgrade(new Request("https://pod.test/a")) + await provider.upgrade(new Request("https://pod.test/b")) + await provider.upgrade(new Request("https://other.test/c")) + + expect(getCode).toHaveBeenCalledTimes(2) + }) + + it("stores sessions in a caller supplied cache", async () => { + const cache = new MemorySessionCache() + const {provider} = makeProvider(undefined, {sessionCache: cache}) + + await provider.upgrade(new Request("https://pod.test/a")) + + expect(await cache.get(new URL(as.issuer).href)).toMatchObject({accessToken: expect.stringMatching(/^at-\d+$/)}) + }) + + it("reuses a session already present in a shared cache, without prompting", async () => { + const cache = new MemorySessionCache() + const first = makeProvider(undefined, {sessionCache: cache}) + await first.provider.upgrade(new Request("https://pod.test/a")) + + const second = makeProvider(undefined, {sessionCache: cache}) + const upgraded = await second.provider.upgrade(new Request("https://pod.test/b")) + + expect(second.getCode).not.toHaveBeenCalled() + expect(upgraded.headers.get("Authorization")).toBe(`DPoP ${(await cache.get(new URL(as.issuer).href))!.accessToken}`) + }) +}) diff --git a/test/fakeAuthorizationServer.ts b/test/fakeAuthorizationServer.ts index 7f651f4..83f4341 100644 --- a/test/fakeAuthorizationServer.ts +++ b/test/fakeAuthorizationServer.ts @@ -1,14 +1,13 @@ +import { exportJWK, SignJWT } from "jose" + /** - * A minimal in-memory OAuth 2.0 / OpenID Connect authorization server for unit - * tests, exposed as a `fetch` implementation to stub `globalThis.fetch` with. + * Just enough authorization server for oauth4webapi's strict client side: + * discovery, JWKS, dynamic client registration, and a token endpoint. * - * 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. + * @remarks Exposed as a `fetch` to stub `globalThis.fetch` with. Signing uses + * jose; the rest is deliberately small so tests can control expiry, refresh + * token rotation, and how many times the user was prompted. */ - export interface FakeAuthorizationServerOptions { /** `expires_in` reported on every token response. Default 3600. */ expiresIn?: number @@ -48,15 +47,6 @@ export interface FakeAuthorizationServer { 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"}}) } @@ -68,7 +58,7 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe 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) + const publicJwk = await exportJWK(keys.publicKey) let counter = 0 /** nonce + client of each outstanding authorization code */ @@ -79,13 +69,16 @@ export async function createFakeAuthorizationServer(options: FakeAuthorizationSe 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))}` + const claims = nonce === null ? {} : {nonce} + + return new SignJWT(claims) + .setProtectedHeader({alg: "ES256", kid: "test"}) + .setIssuer(issuer) + .setSubject("user") + .setAudience(clientId) + .setIssuedAt() + .setExpirationTime("10m") + .sign(keys.privateKey) } function tokenBody(refreshable: boolean, idToken?: string) { From e34467fb3b6721d16c4d586e9c95493aa3aa1693 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:58:21 +0100 Subject: [PATCH 4/4] feat: persist sessions in IndexedDB or web storage MemorySessionCache loses everything on reload, so the popup comes back on every page load. IndexedDbSessionCache is the one to reach for with DPoP. IndexedDB stores by structured clone, which keeps a non extractable CryptoKey intact, so the key survives a browser restart while staying unreadable by script on the origin. Verified by round tripping a key and signing with it afterwards. WebStorageSessionCache takes localStorage or sessionStorage for sessions that really are just JSON, such as a bare refresh token. It cannot hold a DPoP session: JSON.stringify turns a CryptoKey into {} without complaining, which would fail much later inside generateProof, so set() throws instead and names the alternative. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 1 + src/IndexedDbSessionCache.ts | 56 +++++++++++++++++ src/WebStorageSessionCache.ts | 56 +++++++++++++++++ src/mod.ts | 2 + test/SessionCache.test.ts | 115 ++++++++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 src/IndexedDbSessionCache.ts create mode 100644 src/WebStorageSessionCache.ts create mode 100644 test/SessionCache.test.ts diff --git a/package.json b/package.json index 5b17f7b..87be2ca 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "devDependencies": { "@rdfjs/types": "^2", "@types/n3": "^1", + "fake-indexeddb": "^6.2.5", "jose": "^6.2.8", "typedoc": "^0.28.18", "typedoc-plugin-mdn-links": "^5.1.1", diff --git a/src/IndexedDbSessionCache.ts b/src/IndexedDbSessionCache.ts new file mode 100644 index 0000000..ebe6f8b --- /dev/null +++ b/src/IndexedDbSessionCache.ts @@ -0,0 +1,56 @@ +import type { SessionCache } from "./SessionCache.js" + +const defaultDatabaseName = "reactive-authentication" +const storeName = "sessions" + +/** + * Persists sessions in IndexedDB, so they survive a reload or a browser restart. + * + * @remarks Preferred for DPoP. IndexedDB stores by structured clone, which keeps a non extractable {@link CryptoKey} intact, so the key outlives the page while remaining unreadable by script on the origin. {@link WebStorageSessionCache} cannot hold one at all. + */ +export class IndexedDbSessionCache implements SessionCache { + readonly #databaseName: string + #database?: Promise + + constructor(databaseName: string = defaultDatabaseName) { + this.#databaseName = databaseName + } + + async get(key: string): Promise { + return this.#run("readonly", store => store.get(key)) + } + + async set(key: string, value: T): Promise { + await this.#run("readwrite", store => store.put(value, key)) + } + + async delete(key: string): Promise { + await this.#run("readwrite", store => store.delete(key)) + } + + async #run(mode: IDBTransactionMode, work: (store: IDBObjectStore) => IDBRequest): Promise { + const database = await this.#open() + + return settled(work(database.transaction(storeName, mode).objectStore(storeName))) + } + + #open(): Promise { + if (this.#database === undefined) { + const request = indexedDB.open(this.#databaseName) + request.onupgradeneeded = () => request.result.createObjectStore(storeName) + + this.#database = settled(request) + } + + return this.#database + } +} + +function settled(request: IDBRequest): Promise { + const {promise, resolve, reject} = Promise.withResolvers() + + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + + return promise +} diff --git a/src/WebStorageSessionCache.ts b/src/WebStorageSessionCache.ts new file mode 100644 index 0000000..a643946 --- /dev/null +++ b/src/WebStorageSessionCache.ts @@ -0,0 +1,56 @@ +import type { SessionCache } from "./SessionCache.js" + +const defaultPrefix = "reactive-authentication:" + +/** + * Persists sessions as JSON in web storage: `localStorage` to survive a browser restart, or `sessionStorage` to last only as long as the tab. + * + * @remarks Suitable for sessions that are entirely JSON, such as a bare refresh token. A DPoP session is not, because {@link JSON.stringify} discards a {@link CryptoKey} without complaining; {@link set} throws rather than store one, and {@link IndexedDbSessionCache} handles that case. + * + * @remarks Anything kept here is readable by any script running on the origin, so store the least that will do. + */ +export class WebStorageSessionCache implements SessionCache { + readonly #storage: Storage + readonly #prefix: string + + /** + * @param storage - Which store to use, normally `localStorage` or `sessionStorage`. + * @param prefix - Namespace for the keys, to keep them apart from the rest of the origin's data. + */ + constructor(storage: Storage, prefix: string = defaultPrefix) { + this.#storage = storage + this.#prefix = prefix + } + + async get(key: string): Promise { + const stored = this.#storage.getItem(this.#prefix + key) + if (stored === null) { + return undefined + } + + try { + return JSON.parse(stored) as T + } catch { + // Left by an older version, or by something else on the origin. + await this.delete(key) + + return undefined + } + } + + async set(key: string, value: T): Promise { + this.#storage.setItem(this.#prefix + key, JSON.stringify(value, rejectCryptoKey)) + } + + async delete(key: string): Promise { + this.#storage.removeItem(this.#prefix + key) + } +} + +function rejectCryptoKey(_: string, value: unknown): unknown { + if (typeof CryptoKey !== "undefined" && value instanceof CryptoKey) { + throw new TypeError("A CryptoKey cannot be stored in web storage, because JSON.stringify would silently discard it. Use IndexedDbSessionCache instead.") + } + + return value +} diff --git a/src/mod.ts b/src/mod.ts index 50c37bb..beed80c 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -11,6 +11,8 @@ export * from "./issuerFrom.js" export * from "./TokenProvider.js" export * from "./SessionCache.js" export * from "./MemorySessionCache.js" +export * from "./IndexedDbSessionCache.js" +export * from "./WebStorageSessionCache.js" export * from "./GetSessionKeyCallback.js" export * from "./GetIssuerCallback.js" export * from "./IdpPicker.js" diff --git a/test/SessionCache.test.ts b/test/SessionCache.test.ts new file mode 100644 index 0000000..71d8ccd --- /dev/null +++ b/test/SessionCache.test.ts @@ -0,0 +1,115 @@ +import "fake-indexeddb/auto" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { DPoPTokenProvider, type DPoPSession } from "../src/DPoPTokenProvider.js" +import { IndexedDbSessionCache } from "../src/IndexedDbSessionCache.js" +import { WebStorageSessionCache } from "../src/WebStorageSessionCache.js" +import { MemorySessionCache } from "../src/MemorySessionCache.js" +import type { SessionCache } from "../src/SessionCache.js" +import { createFakeAuthorizationServer, type FakeAuthorizationServer } from "./fakeAuthorizationServer.js" + +const callbackUri = "https://app.test/callback.html" + +function memoryStorage(): Storage { + const entries = new Map() + + return { + getItem: key => entries.get(key) ?? null, + setItem: (key, value) => void entries.set(key, String(value)), + removeItem: key => void entries.delete(key), + clear: () => entries.clear(), + key: index => [...entries.keys()][index] ?? null, + get length() { + return entries.size + }, + } as Storage +} + +describe.each([ + ["MemorySessionCache", () => new MemorySessionCache()], + ["WebStorageSessionCache", () => new WebStorageSessionCache(memoryStorage())], + ["IndexedDbSessionCache", () => new IndexedDbSessionCache(`db-${Math.random()}`)], +])("%s", (_, create) => { + let cache: SessionCache + + beforeEach(() => { + cache = create() + }) + + it("round trips a value", async () => { + await cache.set("k", {accessToken: "at-1"}) + + expect(await cache.get("k")).toEqual({accessToken: "at-1"}) + }) + + it("reports a missing key as undefined", async () => { + expect(await cache.get("absent")).toBeUndefined() + }) + + it("forgets a deleted key", async () => { + await cache.set("k", {accessToken: "at-1"}) + await cache.delete("k") + + expect(await cache.get("k")).toBeUndefined() + }) +}) + +describe("WebStorageSessionCache", () => { + it("refuses a CryptoKey rather than silently storing an empty object", async () => { + const cache = new WebStorageSessionCache(memoryStorage()) + const dpopKey = await crypto.subtle.generateKey({name: "ECDSA", namedCurve: "P-256"}, false, ["sign", "verify"]) + + await expect(cache.set("k", {dpopKey})).rejects.toThrow(/CryptoKey cannot be stored/) + }) + + it("namespaces its keys", async () => { + const storage = memoryStorage() + await new WebStorageSessionCache(storage).set("https://as.test/", "x") + + expect(storage.key(0)).toBe("reactive-authentication:https://as.test/") + }) + + it("discards an unparseable entry", async () => { + const storage = memoryStorage() + storage.setItem("reactive-authentication:k", "not json") + + expect(await new WebStorageSessionCache(storage).get("k")).toBeUndefined() + }) +}) + +describe("IndexedDbSessionCache", () => { + it("keeps a non extractable CryptoKeyPair usable across a round trip", async () => { + const cache = new IndexedDbSessionCache<{dpopKey: CryptoKeyPair}>(`db-${Math.random()}`) + const dpopKey = await crypto.subtle.generateKey({name: "ECDSA", namedCurve: "P-256"}, false, ["sign", "verify"]) + + await cache.set("k", {dpopKey}) + const restored = (await cache.get("k"))!.dpopKey + + expect(restored.privateKey).toBeInstanceOf(CryptoKey) + expect(restored.privateKey.extractable).toBe(false) + expect(await crypto.subtle.sign({name: "ECDSA", hash: "SHA-256"}, restored.privateKey, new Uint8Array([1]))).toBeInstanceOf(ArrayBuffer) + }) + + it("carries a DPoP session across a simulated reload, so the user is not prompted again", async () => { + const as: FakeAuthorizationServer = await createFakeAuthorizationServer() + vi.stubGlobal("fetch", as.fetch) + + const databaseName = `db-${Math.random()}` + const before = new DPoPTokenProvider(callbackUri, url => as.authorize(url), async () => new URL(as.issuer), { + sessionCache: new IndexedDbSessionCache(databaseName), + }) + const first = await before.upgrade(new Request("https://pod.test/a")) + + // A new provider over the same database stands in for the page being reloaded. + const getCode = vi.fn((url: URL) => as.authorize(url)) + const after = new DPoPTokenProvider(callbackUri, getCode, async () => new URL(as.issuer), { + sessionCache: new IndexedDbSessionCache(databaseName), + }) + const second = await after.upgrade(new Request("https://pod.test/b")) + + expect(getCode).not.toHaveBeenCalled() + expect(second.headers.get("Authorization")).toBe(first.headers.get("Authorization")) + expect(second.headers.get("DPoP")).not.toBe(first.headers.get("DPoP")) + + vi.unstubAllGlobals() + }) +})