Skip to content
Open
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
24 changes: 24 additions & 0 deletions src/DPoPTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,30 @@ export class DPoPTokenProvider implements TokenProvider {
return new Request(request, {headers})
}

/**
* Marks the cached session stale when the access token attached to the
* request was rejected by the resource server (still 401 after an upgrade):
* revoked, invalidated early, or expired without a server-reported
* lifetime. The next {@link upgrade} then renews the session — refresh
* grant first, new authorization-code flow as fallback — instead of
* replaying the rejected token.
*/
async invalidate(request: Request): Promise<void> {
const issuer = await this.#getIssuer(request)
const pending = this.#sessions.get(issuer.href)
if (pending === undefined) {
return
}

const session = await pending.catch(() => undefined)

// Only when the rejected token is still the cached one — a concurrent
// renewal may already have replaced it.
if (session !== undefined && request.headers.get("Authorization") === `DPoP ${session.accessToken}`) {
session.expiresAt = 0
}
}

/**
* 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
Expand Down
15 changes: 13 additions & 2 deletions src/ReactiveFetchManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,19 @@ export class ReactiveFetchManager extends EventTarget {
return response
}

const upgraded = await provider.upgrade(request)
return this.#globalFetch.call(undefined, upgraded)
const upgraded = await provider.upgrade(request.clone())
const upgradedResponse = await this.#globalFetch.call(undefined, upgraded)
if (upgradedResponse.status !== 401 || provider.invalidate === undefined) {
return upgradedResponse
}

// The credentials we attached were rejected. Mark them stale and retry
// once with renewed ones; if those are rejected too, give up and let the
// caller see the 401 (bounded — never a loop). Cancel the discarded
// response's body so the connection can be reused (undici keep-alive).
await upgradedResponse.body?.cancel().catch(() => undefined)
await provider.invalidate(upgraded)
return this.#globalFetch.call(undefined, await provider.upgrade(request))
}

async #findProvider(request: Request): Promise<TokenProvider | undefined> {
Expand Down
11 changes: 11 additions & 0 deletions src/TokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,15 @@ export interface TokenProvider {
matches(request: Request): Promise<boolean>

upgrade(request: Request): Promise<Request>

/**
* Optional: called when a request this provider upgraded was still rejected
* with 401 — the attached credentials were revoked, invalidated early, or
* expired without a server-reported lifetime. The provider should mark any
* cached credentials for the request stale so the next {@link upgrade}
* renews them instead of replaying the rejected ones.
*
* @param request - The rejected upgraded request (carrying the credentials this provider attached).
*/
invalidate?(request: Request): Promise<void>
}
87 changes: 87 additions & 0 deletions test/DPoPTokenProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { DPoPTokenProvider } from "../src/DPoPTokenProvider.js"
import { ReactiveFetchManager } from "../src/ReactiveFetchManager.js"
import { createFakeAuthorizationServer, type FakeAuthorizationServer } from "./fakeAuthorizationServer.js"

const callbackUri = "https://app.test/callback.html"
Expand Down Expand Up @@ -222,3 +223,89 @@ describe("DPoPTokenProvider refresh tokens", () => {
expect(second.headers.get("Authorization")).toMatch(/^DPoP at-\d+$/)
})
})

describe("renewal after a rejected upgrade (401-once retry)", () => {
/** Tokens the fake resource server no longer accepts. */
let revokedTokens: Set<string>
/** Bearer parts of the Authorization headers the resource server saw, oldest first. */
let presentedTokens: string[]

/** Routes pod.test to a fake resource server (401 unless a non-revoked token is presented), everything else to the fake AS. */
function combinedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const request = new Request(input, init)
if (new URL(request.url).origin !== "https://pod.test") {
return as.fetch(input, init)
}

const authorization = request.headers.get("Authorization")
if (authorization === null || !authorization.startsWith("DPoP ")) {
return Promise.resolve(new Response(null, {status: 401}))
}
const token = authorization.slice("DPoP ".length)

presentedTokens.push(token)
return Promise.resolve(revokedTokens.has(token) ? new Response(null, {status: 401}) : new Response("ok"))
}

beforeEach(async () => {
as = await createFakeAuthorizationServer({
issueRefreshTokens: true,
scopesSupported: ["openid", "webid", "offline_access"],
})
revokedTokens = new Set()
presentedTokens = []
vi.stubGlobal("fetch", combinedFetch)
})

it("renews the session and retries once when the upgraded request is still rejected", async () => {
const {provider, getCode} = makeProvider()
const manager = new ReactiveFetchManager([provider])

const first = await manager.fetch("https://pod.test/private")
expect(first.status).toBe(200)

// Revoke the established token server-side (no expiry has passed).
revokedTokens.add(presentedTokens.at(-1)!)

const second = await manager.fetch("https://pod.test/private")

expect(second.status).toBe(200)
expect(getCode).toHaveBeenCalledTimes(1) // renewed via the refresh grant, no new popup
expect(as.tokenRequests.at(-1)?.get("grant_type")).toBe("refresh_token")
})

it("gives up after one renewal: a still-rejected retry surfaces the 401 unchanged", async () => {
const {provider} = makeProvider()
const manager = new ReactiveFetchManager([provider])

await manager.fetch("https://pod.test/private")

// Reject everything from now on, whatever token is presented.
const reject = {has: () => true} as unknown as Set<string>
revokedTokens = reject

const tokenPresentationsBefore = presentedTokens.length
const response = await manager.fetch("https://pod.test/private")

expect(response.status).toBe(401)
// Bounded: the cached token once, the renewed token once — then give up.
expect(presentedTokens.length - tokenPresentationsBefore).toBe(2)
})

it("ignores invalidation for a token that is no longer the cached one", async () => {
const {provider} = makeProvider()

const first = await provider.upgrade(new Request("https://pod.test/a"))
await provider.invalidate(first)
const second = await provider.upgrade(new Request("https://pod.test/b"))
expect(second.headers.get("Authorization")).not.toBe(first.headers.get("Authorization"))

// Replaying the stale rejection must not invalidate the renewed session.
const tokenRequestsBefore = as.tokenRequests.length
await provider.invalidate(first)
const third = await provider.upgrade(new Request("https://pod.test/c"))

expect(third.headers.get("Authorization")).toBe(second.headers.get("Authorization"))
expect(as.tokenRequests.length).toBe(tokenRequestsBefore)
})
})
Loading