diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 270c046..fa923f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: with: node-version-file: .nvmrc - run: npm i + - run: npm test - run: npx tsc summary: diff --git a/package.json b/package.json index 4c8ac79..dfdb6f8 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": "npm run build && node --test \"test/*.test.ts\"" }, "license": "MIT", "dependencies": { @@ -38,9 +39,11 @@ "devDependencies": { "@rdfjs/types": "^2", "@types/n3": "^1", + "fake-indexeddb": "^6.2.5", + "oauth2-mock-server": "^9.1.0", "typedoc": "^0.28.18", "typedoc-plugin-mdn-links": "^5.1.1", - "typescript": "^7" + "typescript": "^6.0.3" }, "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..e12deb6 --- /dev/null +++ b/test/DPoPTokenProvider.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict" +import { afterEach, beforeEach, describe, it, mock } from "node:test" +import { DPoPTokenProvider, type DPoPSession, type DPoPTokenProviderOptions } from "../dist/DPoPTokenProvider.js" +import { MemorySessionCache } from "../dist/MemorySessionCache.js" +import { createFakeAuthorizationServer, type FakeAuthorizationServer } from "./fakeAuthorizationServer.ts" + +const callbackUri = "https://app.test/callback.html" + +let as: FakeAuthorizationServer + +function makeProvider(getCode = mock.fn((url: URL) => as.authorize(url)), options: DPoPTokenProviderOptions = {}) { + const provider = new DPoPTokenProvider(callbackUri, getCode, async () => new URL(as.issuer), options) + return {provider, getCode} +} + +afterEach(async () => { + mock.restoreAll() + mock.timers.reset() + await as.close() +}) + +describe("DPoPTokenProvider session cache", () => { + beforeEach(async () => { + as = await createFakeAuthorizationServer() + mock.method(globalThis, "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")) + + assert.match(upgraded.headers.get("Authorization") ?? "", /^DPoP \S+$/) + assert.ok(upgraded.headers.get("DPoP")) + }) + + 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")), + ]) + + assert.equal(getCode.mock.callCount(), 1) + assert.equal(as.registrations.length, 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")) + + assert.equal(getCode.mock.callCount(), 1) + assert.equal(second.headers.get("Authorization"), 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")) + + assert.notEqual(second.headers.get("DPoP"), 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")) + + mock.timers.enable({apis: ["Date"], now: Date.now() + 3601 * 1000}) + const second = await provider.upgrade(new Request("https://pod.test/b")) + + assert.equal(getCode.mock.callCount(), 2) + assert.notEqual(second.headers.get("Authorization"), first.headers.get("Authorization")) + }) + + it("does not cache a failed flow: the next upgrade retries", async () => { + const getCode = mock.fn((url: URL) => as.authorize(url)) + getCode.mock.mockImplementationOnce(async () => { + throw new Error("user closed the popup") + }) + const {provider} = makeProvider(getCode) + + await assert.rejects(provider.upgrade(new Request("https://pod.test/a")), /user closed the popup/) + + const second = await provider.upgrade(new Request("https://pod.test/b")) + + assert.match(second.headers.get("Authorization") ?? "", /^DPoP \S+$/) + assert.equal(getCode.mock.callCount(), 2) + }) +}) + +describe("DPoPTokenProvider session cache configuration", () => { + beforeEach(async () => { + as = await createFakeAuthorizationServer() + mock.method(globalThis, "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")) + + assert.equal(getCode.mock.callCount(), 1) + }) + + it("honours a custom session key, so callers can scope sessions narrower than the issuer", async () => { + const getCode = mock.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")) + + assert.equal(getCode.mock.callCount(), 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")) + + const session = await cache.get(new URL(as.issuer).href) + assert.ok(session) + assert.match(session.accessToken, /\S+/) + }) + + 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")) + + assert.equal(second.getCode.mock.callCount(), 0) + assert.equal(upgraded.headers.get("Authorization"), `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..71e0786 --- /dev/null +++ b/test/SessionCache.test.ts @@ -0,0 +1,121 @@ +import "fake-indexeddb/auto" +import assert from "node:assert/strict" +import { beforeEach, describe, it } from "node:test" +import { DPoPTokenProvider, type DPoPSession } from "../dist/DPoPTokenProvider.js" +import { IndexedDbSessionCache } from "../dist/IndexedDbSessionCache.js" +import { WebStorageSessionCache } from "../dist/WebStorageSessionCache.js" +import { MemorySessionCache } from "../dist/MemorySessionCache.js" +import type { SessionCache } from "../dist/SessionCache.js" +import { createFakeAuthorizationServer } from "./fakeAuthorizationServer.ts" + +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 +} + +const caches = [ + ["MemorySessionCache", () => new MemorySessionCache()], + ["WebStorageSessionCache", () => new WebStorageSessionCache(memoryStorage())], + ["IndexedDbSessionCache", () => new IndexedDbSessionCache(`db-${Math.random()}`)], +] as const + +for (const [name, create] of caches) { + describe(name, () => { + let cache: SessionCache + + beforeEach(() => { + cache = create() + }) + + it("round trips a value", async () => { + await cache.set("k", {accessToken: "at-1"}) + + assert.deepEqual(await cache.get("k"), {accessToken: "at-1"}) + }) + + it("reports a missing key as undefined", async () => { + assert.equal(await cache.get("absent"), undefined) + }) + + it("forgets a deleted key", async () => { + await cache.set("k", {accessToken: "at-1"}) + await cache.delete("k") + + assert.equal(await cache.get("k"), undefined) + }) + }) +} + +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 assert.rejects(cache.set("k", {dpopKey}), /CryptoKey cannot be stored/) + }) + + it("namespaces its keys", async () => { + const storage = memoryStorage() + await new WebStorageSessionCache(storage).set("https://as.test/", "x") + + assert.equal(storage.key(0), "reactive-authentication:https://as.test/") + }) + + it("discards an unparseable entry", async () => { + const storage = memoryStorage() + storage.setItem("reactive-authentication:k", "not json") + + assert.equal(await new WebStorageSessionCache(storage).get("k"), undefined) + }) +}) + +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 + + assert.ok(restored.privateKey instanceof CryptoKey) + assert.equal(restored.privateKey.extractable, false) + assert.ok(await crypto.subtle.sign({name: "ECDSA", hash: "SHA-256"}, restored.privateKey, new Uint8Array([1])) instanceof ArrayBuffer) + }) + + it("carries a DPoP session across a simulated reload, so the user is not prompted again", async t => { + const as = await createFakeAuthorizationServer() + t.mock.method(globalThis, "fetch", as.fetch) + + try { + 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")) + + const getCode = t.mock.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")) + + assert.equal(getCode.mock.callCount(), 0) + assert.equal(second.headers.get("Authorization"), first.headers.get("Authorization")) + assert.notEqual(second.headers.get("DPoP"), first.headers.get("DPoP")) + } finally { + await as.close() + } + }) +}) diff --git a/test/fakeAuthorizationServer.ts b/test/fakeAuthorizationServer.ts new file mode 100644 index 0000000..4fab7d8 --- /dev/null +++ b/test/fakeAuthorizationServer.ts @@ -0,0 +1,76 @@ +import { Events, OAuth2Server } from "oauth2-mock-server" + +export interface FakeAuthorizationServer { + readonly issuer: string + fetch: typeof globalThis.fetch + authorize(authorizationUrl: URL): Promise + readonly registrations: Record[] + close(): Promise +} + +const issuer = "https://as.test" + +export async function createFakeAuthorizationServer(): Promise { + const nativeFetch = globalThis.fetch + const registrations: Record[] = [] + const server = new OAuth2Server() + + server.service.addRoute("POST", "/register", (request, response) => { + const metadata = request.body as Record + registrations.push(metadata) + response.writeHead(201, {"content-type": "application/json"}) + response.end(JSON.stringify({ + client_id: "client", + redirect_uris: metadata.redirect_uris, + response_types: ["code"], + grant_types: ["authorization_code"], + token_endpoint_auth_method: "none", + })) + }) + server.service.on(Events.BeforeResponse, response => { + response.body.token_type = "DPoP" + }) + + await server.issuer.keys.generate("RS256") + await server.start(0, "127.0.0.1") + const upstream = `http://127.0.0.1:${server.address().port}` + // Present HTTPS to oauth4webapi while keeping the test listener certificate-free. + server.issuer.url = issuer + + async function fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const request = new Request(input, init) + const source = new URL(request.url) + if (source.origin !== issuer) { + throw new Error(`Unexpected request to ${source.origin}`) + } + const target = new URL(`${source.pathname}${source.search}`, upstream) + const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer() + const response = await nativeFetch(target, { + method: request.method, + headers: request.headers, + body, + redirect: request.redirect, + signal: request.signal, + }) + + if (source.pathname === "/.well-known/openid-configuration") { + return Response.json({...await response.json(), registration_endpoint: `${issuer}/register`}) + } + return response + } + + return { + issuer, + fetch, + async authorize(authorizationUrl: URL): Promise { + const response = await fetch(new Request(authorizationUrl, {redirect: "manual"})) + const redirect = response.headers.get("location") + if (redirect === null) { + throw new Error("Authorization server did not redirect") + } + return redirect + }, + registrations, + close: () => server.stop(), + } +}