Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/DPoPTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof oauth.processDynamicClientRegistrationResponse>>
Expand Down Expand Up @@ -39,6 +40,15 @@ export class DPoPTokenProvider implements TokenProvider {
*/
readonly #sessions = new Map<string, Promise<IssuerSession>>()

/**
* 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<string, number>()

/**
* The shared authentication work is provider-owned, so it is deliberately
* not tied to any single request's AbortSignal — aborting one request must
Expand All @@ -58,8 +68,20 @@ export class DPoPTokenProvider implements TokenProvider {

async upgrade(request: Request): Promise<Request> {
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))
Expand All @@ -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<void> {
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):
Expand Down
20 changes: 20 additions & 0 deletions src/SessionForgottenError.ts
Original file line number Diff line number Diff line change
@@ -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"
}
}
14 changes: 14 additions & 0 deletions src/TokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,18 @@ export interface TokenProvider {
* @param request - The rejected upgraded request (carrying the credentials this provider attached).
*/
invalidate?(request: Request): Promise<void>

/**
* 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<void>
}
1 change: 1 addition & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
130 changes: 130 additions & 0 deletions test/DPoPTokenProvider.forget.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>()
const proceed = Promise.withResolvers<void>()
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<void>()
const proceed = Promise.withResolvers<void>()
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)
})
})
Loading