diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index ddc7224..3679c05 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -3,6 +3,7 @@ import * as DPoP from "dpop" import type { GetCodeCallback } from "./GetCodeCallback.js" import type { TokenProvider } from "./TokenProvider.js" import type { GetIssuerCallback } from "./GetIssuerCallback.js" +import { SessionForgottenError } from "./SessionForgottenError.js" /** The client metadata shape produced by dynamic client registration. */ type ClientRegistration = Awaited> @@ -39,6 +40,15 @@ export class DPoPTokenProvider implements TokenProvider { */ readonly #sessions = new Map>() + /** + * Per-issuer supersession counter. {@link forget} bumps it; an in-flight + * {@link upgrade} that captured an earlier value re-checks it before arming + * (attaching credentials) and fails closed if it advanced — so a session + * forgotten mid-upgrade can never complete a request with its credentials, + * and a concurrent renewal can never resurrect it. Absent entry means 0. + */ + readonly #generations = 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 @@ -58,8 +68,20 @@ export class DPoPTokenProvider implements TokenProvider { async upgrade(request: Request): Promise { const issuer = await this.#getIssuer(request) + + // Capture the issuer's generation BEFORE establishing/reusing the session + // so a forget() that lands during #session (any of its awaits: discovery, + // the popup, the token/refresh grant) is detected below. + const generation = this.#generationOf(issuer.href) const session = await this.#session(issuer) + // Supersession fence — re-check before arming: if the session was + // forgotten while this upgrade was in flight, fail closed rather than + // attach the discarded session's credentials to the request. + if (this.#generationOf(issuer.href) !== generation) { + throw new SessionForgottenError(issuer) + } + const headers = new Headers(request.headers) headers.set("DPoP", await DPoP.generateProof(session.dpopKey, request.url, request.method, undefined, session.accessToken)) @@ -68,6 +90,39 @@ export class DPoPTokenProvider implements TokenProvider { return new Request(request, {headers}) } + /** + * Definitively drops the cached session for the request's issuer — the + * "log out" / "switch account" primitive. + * + * @remarks + * Unlike {@link invalidate} (which is *transient*: it marks the access token + * stale but KEEPS the durable refresh token, so the next {@link upgrade} + * silently re-establishes the same session), `forget` is *definitive*: it + * evicts the whole session — refresh token included — so the session does + * NOT silently come back. The next {@link upgrade} runs a fresh + * authorization-code flow. + * + * It is supersession-safe: any {@link upgrade} for this issuer that is + * already in flight will fail closed with {@link SessionForgottenError} + * instead of completing with the forgotten session's credentials, and an + * in-flight renewal cannot resurrect the evicted session. + */ + async forget(request: Request): Promise { + const issuer = await this.#getIssuer(request) + + // Supersede first, then evict: an in-flight upgrade re-reads the bumped + // generation at its post-#session fence, and an in-flight #begin (which + // set the cache entry before its awaits) never re-installs after this + // delete, so the session cannot be resurrected. + this.#generations.set(issuer.href, this.#generationOf(issuer.href) + 1) + this.#sessions.delete(issuer.href) + } + + /** The current supersession counter for an issuer (0 when never forgotten). */ + #generationOf(issuerHref: string): number { + return this.#generations.get(issuerHref) ?? 0 + } + /** * Marks the cached session stale when the access token attached to the * request was rejected by the resource server (still 401 after an upgrade): diff --git a/src/SessionForgottenError.ts b/src/SessionForgottenError.ts new file mode 100644 index 0000000..4838f66 --- /dev/null +++ b/src/SessionForgottenError.ts @@ -0,0 +1,20 @@ +import { ReactiveAuthenticationError } from "./ReactiveAuthenticationError.js" + +/** + * Thrown by {@link DPoPTokenProvider.upgrade} when the session it was + * establishing/reusing was {@link DPoPTokenProvider.forget | forgotten} while + * the upgrade was in flight. + * + * @remarks + * This is the fail-closed outcome of the supersession fence: once a session is + * forgotten (e.g. the user logged out), a request that was already mid-upgrade + * must NOT complete carrying that session's credentials. The reactive `fetch` + * rejects with this error rather than silently attaching a token the consumer + * asked to discard. + */ +export class SessionForgottenError extends ReactiveAuthenticationError { + constructor(public issuer: URL, cause?: any) { + super(`Session for issuer ${issuer.href} was forgotten during the upgrade`, cause) + this.name = "SessionForgottenError" + } +} diff --git a/src/TokenProvider.ts b/src/TokenProvider.ts index 0da8e29..d1b7b75 100644 --- a/src/TokenProvider.ts +++ b/src/TokenProvider.ts @@ -13,4 +13,18 @@ export interface TokenProvider { * @param request - The rejected upgraded request (carrying the credentials this provider attached). */ invalidate?(request: Request): Promise + + /** + * Optional: definitively drops any cached session for the request's target + * — the "log out" / "switch account" primitive. In contrast to + * {@link invalidate} (transient — keeps the durable credential so the + * session silently re-establishes), a provider implementing `forget` should + * discard the whole session, including any refresh token, so it does NOT + * come back until the next interactive authorization. Implementations should + * make this safe against a concurrent {@link upgrade} completing with the + * discarded credentials. + * + * @param request - A request identifying the session (target/issuer) to forget. + */ + forget?(request: Request): Promise } diff --git a/src/mod.ts b/src/mod.ts index 268eee3..e654743 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -5,6 +5,7 @@ export * from "./ReactiveFetchManager.js" export * from "./ReactiveFetchWorkerManager.js" export * from "./CodeRequestCancelledError.js" export * from "./ReactiveAuthenticationError.js" +export * from "./SessionForgottenError.js" export * from "./ClientCredentialsTokenProvider.js" export * from "./GetCodeCallback.js" export * from "./issuerFrom.js" diff --git a/test/DPoPTokenProvider.forget.test.ts b/test/DPoPTokenProvider.forget.test.ts new file mode 100644 index 0000000..955fc19 --- /dev/null +++ b/test/DPoPTokenProvider.forget.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { DPoPTokenProvider } from "../src/DPoPTokenProvider.js" +import { SessionForgottenError } from "../src/SessionForgottenError.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)), getIssuer = async () => new URL(as.issuer)) { + const provider = new DPoPTokenProvider(callbackUri, getCode, getIssuer) + return {provider, getCode} +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +describe("DPoPTokenProvider.forget (definitive session teardown)", () => { + beforeEach(async () => { + as = await createFakeAuthorizationServer({ + issueRefreshTokens: true, + scopesSupported: ["openid", "webid", "offline_access"], + grantTypesSupported: ["authorization_code", "refresh_token"], + }) + vi.stubGlobal("fetch", as.fetch) + }) + + it("drops the session so the next upgrade runs a fresh authorization flow", async () => { + const {provider, getCode} = makeProvider() + + await provider.upgrade(new Request("https://pod.test/a")) + expect(getCode).toHaveBeenCalledTimes(1) + expect(as.registrations).toHaveLength(1) + + await provider.forget(new Request("https://pod.test/a")) + + await provider.upgrade(new Request("https://pod.test/b")) + expect(getCode).toHaveBeenCalledTimes(2) // a brand-new interactive flow, not a silent reuse + expect(as.registrations).toHaveLength(2) + }) + + it("is definitive where invalidate is transient (drops the refresh token vs keeps it)", async () => { + const {provider, getCode} = makeProvider() + + // Transient: invalidate marks the access token stale but KEEPS the refresh + // token, so the next upgrade renews silently via the refresh grant. + const first = await provider.upgrade(new Request("https://pod.test/a")) + await provider.invalidate(first) + await provider.upgrade(new Request("https://pod.test/b")) + expect(getCode).toHaveBeenCalledTimes(1) // no new popup + expect(as.tokenRequests.at(-1)?.get("grant_type")).toBe("refresh_token") + + // Definitive: forget discards the whole session (refresh token included), + // so the next upgrade must re-authorize interactively. + await provider.forget(new Request("https://pod.test/c")) + await provider.upgrade(new Request("https://pod.test/d")) + expect(getCode).toHaveBeenCalledTimes(2) // a fresh interactive flow + expect(as.tokenRequests.at(-1)?.get("grant_type")).toBe("authorization_code") + }) + + it("fails closed when a session is forgotten mid-upgrade, and does not resurrect it", async () => { + // A getCode we can hold open, to keep the first upgrade in flight past the + // point where it captured the session's generation. + const entered = Promise.withResolvers() + const proceed = Promise.withResolvers() + const getCode = vi.fn(async (url: URL) => { + entered.resolve() + await proceed.promise + return as.authorize(url) + }) + const {provider} = makeProvider(getCode) + + const upgrading = provider.upgrade(new Request("https://pod.test/private")) + await entered.promise // the upgrade is now parked inside the authorization flow + + // The user logs out while the request is still in flight. + await provider.forget(new Request("https://pod.test/private")) + + proceed.resolve() // let the (now superseded) authorization flow complete + + // The in-flight request must NOT complete carrying the forgotten session's + // credentials — it fails closed instead. + await expect(upgrading).rejects.toBeInstanceOf(SessionForgottenError) + + // …and the mid-flight session was not left cached (no resurrection): the + // next upgrade needs a fresh authorization. + const next = await provider.upgrade(new Request("https://pod.test/again")) + expect(next.headers.get("Authorization")).toMatch(/^DPoP /) + expect(getCode).toHaveBeenCalledTimes(2) + }) + + it("is per-issuer: forgetting one issuer does not fail-close an in-flight upgrade for another", async () => { + const entered = Promise.withResolvers() + const proceed = Promise.withResolvers() + const getCode = vi.fn(async (url: URL) => { + entered.resolve() + await proceed.promise + return as.authorize(url) + }) + // /drop resolves to a different issuer than the pod being authenticated. + const getIssuer = async (request: Request) => + new URL(request.url.includes("/drop") ? "https://other.test" : as.issuer) + const {provider} = makeProvider(getCode, getIssuer) + + const upgrading = provider.upgrade(new Request("https://pod.test/keep")) + await entered.promise + + // Forget a DIFFERENT issuer while the pod.test upgrade is in flight. + await provider.forget(new Request("https://pod.test/drop")) + + proceed.resolve() + + // The pod.test upgrade is unaffected and completes normally. + const upgraded = await upgrading + expect(upgraded.headers.get("Authorization")).toMatch(/^DPoP /) + }) + + it("forget with no established session is a safe no-op", async () => { + const {provider, getCode} = makeProvider() + + await expect(provider.forget(new Request("https://pod.test/x"))).resolves.toBeUndefined() + + // A later upgrade still works (and is not spuriously fenced by the bump). + const upgraded = await provider.upgrade(new Request("https://pod.test/x")) + expect(upgraded.headers.get("Authorization")).toMatch(/^DPoP /) + expect(getCode).toHaveBeenCalledTimes(1) + }) +})