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
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"url": "git+https://github.com/solid-contrib/reactive-authentication.git"
},
"scripts": {
"build": "tsc"
"build": "tsc",
"test": "vitest run"
},
"license": "MIT",
"dependencies": {
Expand All @@ -38,9 +39,12 @@
"devDependencies": {
"@rdfjs/types": "^2",
"@types/n3": "^1",
"fake-indexeddb": "^6.2.5",
"jose": "^6.2.8",
"typedoc": "^0.28.18",
"typedoc-plugin-mdn-links": "^5.1.1",
"typescript": "^7"
"typescript": "^7",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=24.0.0"
Expand Down
117 changes: 105 additions & 12 deletions src/DPoPTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,61 @@ import * as DPoP from "dpop"
import type { GetCodeCallback } from "./GetCodeCallback.js"
import type { TokenProvider } from "./TokenProvider.js"
import type { GetIssuerCallback } from "./GetIssuerCallback.js"
import type { GetSessionKeyCallback } from "./GetSessionKeyCallback.js"
import type { SessionCache } from "./SessionCache.js"
import { MemorySessionCache } from "./MemorySessionCache.js"

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

/**
* An established authentication, reused across upgrades.
*
* @remarks Structured cloneable, so a {@link SessionCache} can persist it, but
* not JSON serialisable because of the non extractable {@link CryptoKeyPair}.
*/
export interface DPoPSession {
authorizationServer: oauth.AuthorizationServer
clientRegistration: ClientRegistration
dpopKey: CryptoKeyPair
accessToken: string
/** Epoch milliseconds, or undefined when the server reported no expiry. */
expiresAt: number | undefined
}

export interface DPoPTokenProviderOptions {
/** Defaults to {@link MemorySessionCache}. */
sessionCache?: SessionCache<DPoPSession>

/** Defaults to the issuer, so one session is shared per authorization server. */
getSessionKey?: GetSessionKeyCallback
}

/** Renew this long before the reported expiry, to absorb clock skew. */
const expirySkewMs = 30_000

export class DPoPTokenProvider implements TokenProvider {
readonly #getCode: GetCodeCallback
readonly #callbackUri: string
readonly #getIssuer: GetIssuerCallback
readonly #getSessionKey: GetSessionKeyCallback
readonly #sessions: SessionCache<DPoPSession>

constructor(callbackUri: string, getCodeCallback: GetCodeCallback, getIssuerCallback: GetIssuerCallback) {
/** In flight flows, so concurrent upgrades share one popup. */
readonly #pending = new Map<string, Promise<DPoPSession>>()

/**
* Provider owned, so aborting one request does not cancel the login that
* other concurrent upgrades are waiting on.
*/
readonly #authSignal = new AbortController().signal

constructor(callbackUri: string, getCodeCallback: GetCodeCallback, getIssuerCallback: GetIssuerCallback, options: DPoPTokenProviderOptions = {}) {
this.#getCode = getCodeCallback
this.#callbackUri = callbackUri
this.#getIssuer = getIssuerCallback
this.#getSessionKey = options.getSessionKey ?? (async (_, issuer) => issuer.href)
this.#sessions = options.sessionCache ?? new MemorySessionCache()
}

async matches(request: Request): Promise<boolean> {
Expand All @@ -21,11 +66,50 @@ export class DPoPTokenProvider implements TokenProvider {

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

/** Reuses a live session, and otherwise runs the flow once per key. */
async #session(request: Request, issuer: URL): Promise<DPoPSession> {
const key = await this.#getSessionKey(request, issuer)

const discoveryResponse = await oauth.discoveryRequest(issuer, {signal: request.signal})
const cached = await this.#sessions.get(key)
if (cached !== undefined && !hasExpired(cached)) {
return cached
}

const pending = this.#pending.get(key)
if (pending !== undefined) {
return pending
}

// Not cached on failure, so the next upgrade retries.
const work = this.#authenticate(issuer)
this.#pending.set(key, work)
try {
const session = await work
await this.#sessions.set(key, session)
return session
} finally {
this.#pending.delete(key)
}
}

/** Discovery, registration, then the PKCE and DPoP code grant. */
async #authenticate(issuer: URL): Promise<DPoPSession> {
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: DPoPSession): boolean {
return session.expiresAt !== undefined && Date.now() >= session.expiresAt
}
Comment thread
jeswr marked this conversation as resolved.

function isEssMissingIssInteractionNeeded(e: unknown) {
try {
return ((((e as oauth.OperationProcessingError).cause as any).parameters) as URLSearchParams).get("error") === "interaction_required"
Expand Down
1 change: 1 addition & 0 deletions src/GetSessionKeyCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type GetSessionKeyCallback = (request: Request, issuer: URL) => Promise<string>
56 changes: 56 additions & 0 deletions src/IndexedDbSessionCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { SessionCache } from "./SessionCache.js"

const defaultDatabaseName = "reactive-authentication"
const storeName = "sessions"

/**
* Persists sessions in IndexedDB, so they survive a reload or a browser restart.
*
* @remarks Preferred for DPoP. IndexedDB stores by structured clone, which keeps a non extractable {@link CryptoKey} intact, so the key outlives the page while remaining unreadable by script on the origin. {@link WebStorageSessionCache} cannot hold one at all.
*/
export class IndexedDbSessionCache<T> implements SessionCache<T> {
readonly #databaseName: string
#database?: Promise<IDBDatabase>

constructor(databaseName: string = defaultDatabaseName) {
this.#databaseName = databaseName
}

async get(key: string): Promise<T | undefined> {
return this.#run("readonly", store => store.get(key))
}

async set(key: string, value: T): Promise<void> {
await this.#run("readwrite", store => store.put(value, key))
}

async delete(key: string): Promise<void> {
await this.#run("readwrite", store => store.delete(key))
}

async #run<R>(mode: IDBTransactionMode, work: (store: IDBObjectStore) => IDBRequest<R>): Promise<R> {
const database = await this.#open()

return settled(work(database.transaction(storeName, mode).objectStore(storeName)))
}

#open(): Promise<IDBDatabase> {
if (this.#database === undefined) {
const request = indexedDB.open(this.#databaseName)
request.onupgradeneeded = () => request.result.createObjectStore(storeName)

this.#database = settled(request)
}

return this.#database
}
}

function settled<R>(request: IDBRequest<R>): Promise<R> {
const {promise, resolve, reject} = Promise.withResolvers<R>()

request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)

return promise
}
18 changes: 18 additions & 0 deletions src/MemorySessionCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { SessionCache } from "./SessionCache.js"

/** Keeps sessions for the lifetime of the provider. */
export class MemorySessionCache<T> implements SessionCache<T> {
readonly #entries = new Map<string, T>()

async get(key: string): Promise<T | undefined> {
return this.#entries.get(key)
}

async set(key: string, value: T): Promise<void> {
this.#entries.set(key, value)
}

async delete(key: string): Promise<void> {
this.#entries.delete(key)
}
}
13 changes: 13 additions & 0 deletions src/SessionCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Where a token provider keeps established sessions.
*
* @remarks Asynchronous so sessions can live wherever the host offers, such as
* IndexedDB in a browser or the secrets API in an editor extension.
*/
export interface SessionCache<T> {
get(key: string): Promise<T | undefined>

set(key: string, value: T): Promise<void>

delete(key: string): Promise<void>
}
56 changes: 56 additions & 0 deletions src/WebStorageSessionCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { SessionCache } from "./SessionCache.js"

const defaultPrefix = "reactive-authentication:"

/**
* Persists sessions as JSON in web storage: `localStorage` to survive a browser restart, or `sessionStorage` to last only as long as the tab.
*
* @remarks Suitable for sessions that are entirely JSON, such as a bare refresh token. A DPoP session is not, because {@link JSON.stringify} discards a {@link CryptoKey} without complaining; {@link set} throws rather than store one, and {@link IndexedDbSessionCache} handles that case.
*
* @remarks Anything kept here is readable by any script running on the origin, so store the least that will do.
*/
export class WebStorageSessionCache<T> implements SessionCache<T> {
readonly #storage: Storage
readonly #prefix: string

/**
* @param storage - Which store to use, normally `localStorage` or `sessionStorage`.
* @param prefix - Namespace for the keys, to keep them apart from the rest of the origin's data.
*/
constructor(storage: Storage, prefix: string = defaultPrefix) {
this.#storage = storage
this.#prefix = prefix
}

async get(key: string): Promise<T | undefined> {
const stored = this.#storage.getItem(this.#prefix + key)
if (stored === null) {
return undefined
}

try {
return JSON.parse(stored) as T
} catch {
// Left by an older version, or by something else on the origin.
await this.delete(key)

return undefined
}
}

async set(key: string, value: T): Promise<void> {
this.#storage.setItem(this.#prefix + key, JSON.stringify(value, rejectCryptoKey))
}

async delete(key: string): Promise<void> {
this.#storage.removeItem(this.#prefix + key)
}
}

function rejectCryptoKey(_: string, value: unknown): unknown {
if (typeof CryptoKey !== "undefined" && value instanceof CryptoKey) {
throw new TypeError("A CryptoKey cannot be stored in web storage, because JSON.stringify would silently discard it. Use IndexedDbSessionCache instead.")
}

return value
}
5 changes: 5 additions & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export * from "./ClientCredentialsTokenProvider.js"
export * from "./GetCodeCallback.js"
export * from "./issuerFrom.js"
export * from "./TokenProvider.js"
export * from "./SessionCache.js"
export * from "./MemorySessionCache.js"
export * from "./IndexedDbSessionCache.js"
export * from "./WebStorageSessionCache.js"
export * from "./GetSessionKeyCallback.js"
export * from "./GetIssuerCallback.js"
export * from "./IdpPicker.js"
export * from "./WebIdPicker.js"
Expand Down
Loading
Loading