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
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,19 @@
"url": "git+https://github.com/solid-contrib/reactive-authentication.git"
},
"scripts": {
"build": "tsc"
"build": "tsc",
"test": "vitest run"
},
"license": "MIT",
"dependencies": {
"oauth4webapi": "^3",
"dpop": "^2"
"dpop": "^2",
"oauth4webapi": "^3"
},
"devDependencies": {
"typedoc": "^0.28.18",
"typedoc-plugin-mdn-links": "^5.1.1",
"typescript": "^6"
"typescript": "^6",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=24.0.0"
Expand Down
115 changes: 104 additions & 11 deletions src/DPoPTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,44 @@ import type { GetCodeCallback } from "./GetCodeCallback.js"
import type { TokenProvider } from "./TokenProvider.js"
import type { GetIssuerCallback } from "./GetIssuerCallback.js"

/** The client metadata shape produced by dynamic client registration. */
type ClientRegistration = Awaited<ReturnType<typeof oauth.processDynamicClientRegistrationResponse>>

/** Authentication state for one issuer, reused across upgrades. */
interface IssuerSession {
authorizationServer: oauth.AuthorizationServer
clientRegistration: ClientRegistration
dpopKey: CryptoKeyPair
accessToken: string
/** Epoch milliseconds after which the access token is considered expired, or undefined when the server gave no expiry. */
expiresAt: number | undefined
}

/**
* Refresh this much before the server-reported expiry, so clock skew between us
* and the resource server does not produce a window of rejected requests.
*/
const expirySkewMs = 30_000

export class DPoPTokenProvider implements TokenProvider {
readonly #getCode: GetCodeCallback
readonly #callbackUri: string
readonly #getIssuer: GetIssuerCallback

/**
* Single-flight session cache per issuer: concurrent upgrades share one
* authorization-code flow (one popup), and later upgrades reuse the
* established token until it expires instead of re-running the flow.
*/
readonly #sessions = new Map<string, Promise<IssuerSession>>()

/**
* The shared authentication work is provider-owned, so it is deliberately
* not tied to any single request's AbortSignal — aborting one request must
* not cancel the login that other concurrent upgrades are waiting on.
*/
readonly #authSignal = new AbortController().signal

constructor(callbackUri: string, getCodeCallback: GetCodeCallback, getIssuerCallback: GetIssuerCallback) {
this.#getCode = getCodeCallback
this.#callbackUri = callbackUri
Expand All @@ -21,11 +54,62 @@ export class DPoPTokenProvider implements TokenProvider {

async upgrade(request: Request): Promise<Request> {
const issuer = await this.#getIssuer(request)
const session = await this.#session(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})
}

/**
* 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
* upgrade retries.
*/
async #session(issuer: URL): Promise<IssuerSession> {
const pending = this.#sessions.get(issuer.href)
if (pending === undefined) {
return this.#begin(issuer, this.#authenticate(issuer))
}

const session = await pending
if (!hasExpired(session)) {
return session
}

// Renew, unless a concurrent caller already replaced the expired session.
if (this.#sessions.get(issuer.href) === pending) {
this.#sessions.delete(issuer.href)
return this.#begin(issuer, this.#authenticate(issuer))
}

const discoveryResponse = await oauth.discoveryRequest(issuer, {signal: request.signal})
return this.#session(issuer)
}

/** Caches the in-flight work; evicts it on failure so the flow can be retried. */
async #begin(issuer: URL, work: Promise<IssuerSession>): Promise<IssuerSession> {
this.#sessions.set(issuer.href, work)
try {
return await work
} catch (e) {
if (this.#sessions.get(issuer.href) === work) {
this.#sessions.delete(issuer.href)
}
throw e
}
}

/** The full authorization-code flow: discovery → registration → PKCE/DPoP code grant. */
async #authenticate(issuer: URL): Promise<IssuerSession> {
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[]
Expand Down Expand Up @@ -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 {
Expand All @@ -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.Client>): oauth.ClientAuth {
Expand All @@ -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: IssuerSession): boolean {
return session.expiresAt !== undefined && Date.now() >= session.expiresAt
}
Comment on lines +200 to +206

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this needed a strategy. Fixed in #14 (stacked follow-up): TokenProvider gains an optional invalidate(), and ReactiveFetchManager retries exactly once with renewed credentials when an upgraded request is still rejected — covering both the missing-expires_in case and early revocation, without guessing a max age.


function isEssMissingIssInteractionNeeded(e: unknown) {
try {
return ((((e as oauth.OperationProcessingError).cause as any).parameters) as URLSearchParams).get("error") === "interaction_required"
Expand Down
93 changes: 93 additions & 0 deletions test/DPoPTokenProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { DPoPTokenProvider } from "../src/DPoPTokenProvider.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))) {
const provider = new DPoPTokenProvider(callbackUri, getCode, async () => new URL(as.issuer))
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)
})
})
Loading
Loading