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
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
189 changes: 175 additions & 14 deletions src/DPoPTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,48 @@ 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
/** The oauth4webapi DPoP handle for token-endpoint requests. Reused so refreshed tokens stay bound to the same key (RFC 9449 §4.3) and server-provided nonces are remembered. */
dpopHandle: oauth.DPoPHandle
accessToken: string
/** The refresh token (RFC 6749 §6), when the server issued one. Updated in place when the server rotates it. */
refreshToken: string | undefined
/** 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 +58,116 @@ 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.#renew(issuer, session))
}

return this.#session(issuer)
}

/** Prefers a transparent refresh-token grant; falls back to a new authorization-code flow when there is no refresh token or the grant fails (expired, revoked, rotation reuse, …). */
async #renew(issuer: URL, expired: IssuerSession): Promise<IssuerSession> {
if (expired.refreshToken === undefined) {
return this.#authenticate(issuer)
}

try {
return await this.#refresh(expired, expired.refreshToken)
} catch {
// Deliberately not logging the error: oauth4webapi errors can carry the
// token-endpoint request/response (tokens included).
console.debug("Refresh token grant failed, falling back to a new authorization")
return this.#authenticate(issuer)
}
}

/** The refresh-token grant (RFC 6749 §6), DPoP-bound to the session's key, adopting the rotated refresh token when the server issues one (RFC 9700 §4.14.2). */
async #refresh(session: IssuerSession, refreshToken: string): Promise<IssuerSession> {
const {authorizationServer, clientRegistration, dpopHandle} = session
const clientAuth = this.getClientAuth(authorizationServer.issuer, clientRegistration)

const grant = () => oauth.refreshTokenGrantRequest(authorizationServer, clientRegistration, clientAuth, refreshToken, {DPoP: dpopHandle, signal: this.#authSignal})

const discoveryResponse = await oauth.discoveryRequest(issuer, {signal: request.signal})
let tokenResult
try {
tokenResult = await oauth.processRefreshTokenResponse(authorizationServer, clientRegistration, await grant())
} catch (e) {
if (!oauth.isDPoPNonceError(e)) {
throw e
}

// The handle has captured the server's DPoP nonce from the error response; retry once.
tokenResult = await oauth.processRefreshTokenResponse(authorizationServer, clientRegistration, await grant())
}
Comment thread
jeswr marked this conversation as resolved.

return {
...session,
accessToken: tokenResult.access_token,
refreshToken: tokenResult.refresh_token ?? refreshToken,
expiresAt: expiresAt(tokenResult),
}
}

/** 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})
// Opt in to refresh tokens where the server supports them: register for the
// refresh_token grant and ask for the offline_access scope (OIDC Core §11).
// Servers that support neither see the exact requests they saw before.
const useRefreshTokens = authorizationServer.grant_types_supported?.includes("refresh_token") ?? false
const useOfflineAccess = authorizationServer.scopes_supported?.includes("offline_access") ?? false

const registrationMetadata: Parameters<typeof oauth.dynamicClientRegistrationRequest>[1] = {
redirect_uris: [this.#callbackUri],
...useRefreshTokens ? {grant_types: ["authorization_code", "refresh_token"]} : {},
}

const registrationResponse = await oauth.dynamicClientRegistrationRequest(authorizationServer, registrationMetadata, {signal})
const clientRegistration = await oauth.processDynamicClientRegistrationResponse(registrationResponse)
const [registeredRedirectUri] = clientRegistration.redirect_uris as string[]
const [registeredResponseType] = clientRegistration.response_types as string[]
Expand All @@ -41,7 +183,7 @@ export class DPoPTokenProvider implements TokenProvider {
authorizationUrl.searchParams.set("client_id", clientRegistration.client_id)
authorizationUrl.searchParams.set("redirect_uri", registeredRedirectUri!)
authorizationUrl.searchParams.set("response_type", registeredResponseType!)
authorizationUrl.searchParams.set("scope", "openid webid")
authorizationUrl.searchParams.set("scope", useOfflineAccess ? "openid webid offline_access" : "openid webid")
authorizationUrl.searchParams.set("prompt", "none")
authorizationUrl.searchParams.set("state", state)
authorizationUrl.searchParams.set("nonce", nonce)
Expand All @@ -56,7 +198,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 @@ -69,26 +211,37 @@ export class DPoPTokenProvider implements TokenProvider {
// Workaround ESS not returning `iss` in error response
isEssMissingIssInteractionNeeded(e)
) {
console.debug("Authorization server requires user interaction, retrying without prompt")
console.debug("Authorization server requires user interaction, retrying interactively")

authorizationUrl.searchParams.delete("prompt")
const authorizationCodeResponse = await this.#getCode(authorizationUrl, request.signal)
// The interactive attempt must carry `prompt=consent` for the server
// to honour `offline_access`: OIDC Core §11 says the AS MUST ignore
// the scope otherwise, and oidc-provider (Community Solid Server and
// brokers built on it) enforces that strictly.
if (useOfflineAccess) {
authorizationUrl.searchParams.set("prompt", "consent")
} else {
authorizationUrl.searchParams.delete("prompt")
}
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,
dpopHandle: dpop,
accessToken: tokenResult.access_token,
refreshToken: tokenResult.refresh_token,
expiresAt: expiresAt(tokenResult),
}
}

private getClientAuth(issuer: string, client: oauth.OmitSymbolProperties<oauth.Client>): oauth.ClientAuth {
Expand All @@ -112,6 +265,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
}

function isEssMissingIssInteractionNeeded(e: unknown) {
try {
return ((((e as oauth.OperationProcessingError).cause as any).parameters) as URLSearchParams).get("error") === "interaction_required"
Expand Down
Loading
Loading