diff --git a/package.json b/package.json index 4c8ac79..87be2ca 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": { @@ -38,9 +39,12 @@ "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", - "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..8e66085 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -3,16 +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> + +/** + * 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, or undefined when the server reported no expiry. */ + expiresAt: number | undefined +} + +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 - constructor(callbackUri: string, getCodeCallback: GetCodeCallback, getIssuerCallback: GetIssuerCallback) { + /** In flight flows, so concurrent upgrades share one popup. */ + readonly #pending = new Map>() + + /** + * 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, 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 { @@ -21,11 +66,50 @@ export class DPoPTokenProvider implements TokenProvider { async upgrade(request: Request): Promise { const issuer = await this.#getIssuer(request) + const session = await this.#session(request, 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}) + } + + /** 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 discoveryResponse = await oauth.discoveryRequest(issuer, {signal: request.signal}) + const cached = await this.#sessions.get(key) + if (cached !== undefined && !hasExpired(cached)) { + return cached + } + + const pending = this.#pending.get(key) + if (pending !== undefined) { + return pending + } + + // Not cached on failure, so the next upgrade retries. + const work = this.#authenticate(issuer) + this.#pending.set(key, work) + try { + const session = await work + await this.#sessions.set(key, session) + return session + } finally { + this.#pending.delete(key) + } + } + + /** 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}) 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: DPoPSession): 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/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/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/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/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 0342cf6..beed80c 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -9,6 +9,11 @@ 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 "./IndexedDbSessionCache.js" +export * from "./WebStorageSessionCache.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 new file mode 100644 index 0000000..1362ab3 --- /dev/null +++ b/test/DPoPTokenProvider.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +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)), options: DPoPTokenProviderOptions = {}) { + const provider = new DPoPTokenProvider(callbackUri, getCode, async () => new URL(as.issuer), options) + 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) + }) +}) + +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/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() + }) +}) diff --git a/test/fakeAuthorizationServer.ts b/test/fakeAuthorizationServer.ts new file mode 100644 index 0000000..83f4341 --- /dev/null +++ b/test/fakeAuthorizationServer.ts @@ -0,0 +1,190 @@ +import { exportJWK, SignJWT } from "jose" + +/** + * Just enough authorization server for oauth4webapi's strict client side: + * discovery, JWKS, dynamic client registration, and a token endpoint. + * + * @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 + /** 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 +} + +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 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 + const publicJwk = await exportJWK(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 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) { + 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 ?? (issueRefreshTokens ? ["authorization_code", "refresh_token"] : ["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(issueRefreshTokens, await signIdToken(params.get("client_id") ?? code.clientId ?? "", code.nonce))) + } + + 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)) + } + // 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) + } + + 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, + } +}