Skip to content
Merged
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
87 changes: 76 additions & 11 deletions src/DPoPTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,21 @@ import type { AuthorizationServerProvider } from "./AuthorizationServerProvider.
import { ClientProvider } from "./ClientProvider.js"
import { supportsOfflineAccess } from "./supportsOfflineAccess.js"

type CacheEntry = { created: number, tokenResult: oauth.TokenEndpointResponse, dpopKey: CryptoKeyPair }
type CacheEntry = {
created: number,
tokenResult: oauth.TokenEndpointResponse,
dpopKey: CryptoKeyPair,
client: oauth.Client,
Comment thread
langsamu marked this conversation as resolved.
authorizationServer: oauth.AuthorizationServer,
}

export class DPoPTokenProvider implements TokenProvider {
readonly #codeProvider: CodeProvider
readonly #callbackUri: string
readonly #cache = new Map<string, CacheEntry> // TODO: Take cache from caller

// TODO: Take cache from caller
// TODO: Once cache is externalized, document that it should not be shared between clients (which would lead to impersonation)
readonly #cache = new Map<string, CacheEntry>
readonly #asProvider: AuthorizationServerProvider
readonly #clientProvider: ClientProvider

Expand All @@ -27,20 +36,43 @@ export class DPoPTokenProvider implements TokenProvider {
}

async upgrade(request: Request): Promise<Request> {
// Form a queue per request URI to never reuse refresh tokens.
// TODO: Revise scope (origin+requestUri) of this lock which might interfere with scenarios that are not bound to origin
const lockName = `DPoPTokenProvider.upgrade[${request.url}]`
Comment thread
jeswr marked this conversation as resolved.
const {dpopKey, tokenResult: {access_token}} = await navigator.locks.request(lockName, async _ =>
Comment thread
langsamu marked this conversation as resolved.
await this.getCachedToken(request))

const headers = new Headers(request.headers)

headers.set("DPoP", await DPoP.generateProof(dpopKey, request.url, request.method, undefined, access_token))
headers.set("Authorization", ["DPoP", access_token].join(" "))

return new Request(request, {headers})
}

private async getCachedToken(request: Request): Promise<CacheEntry> {

@langsamu langsamu Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is the substantial change:

flowchart TD
    start(["Get token"])
    getCached["Get token from cache"]
    found{"Cached token exists?"}
    valid{"Cached token valid?"}
    refresh[["Refresh token"]]
    refreshed{"Refresh succeed?"}
    storeRefreshed["Cache refreshed token"]
    obtain[["Get new token"]]
    storeNew["Cache new token"]

    useRefreshed(["Use refreshed token"])
    useCached(["Use cached token"])
    useNew(["Use new token"])

    start --> getCached
    getCached --> found
    found -- no --> refresh
    found -- yes --> valid
    valid -- no --> refresh
    valid -- yes --> useCached
    refresh --> refreshed
    refreshed -- no --> obtain
    refreshed -- yes --> storeRefreshed
    storeRefreshed --> useRefreshed
    obtain --> storeNew
    storeNew --> useNew

    storeNew ~~~ useCached
    storeNew ~~~ useRefreshed
Loading

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This logic diagram is super helpful, let's reuse it for documentation of the library when relevant.

// TODO: More robust key via callback to support complex caching scenarios
let tokenData = this.#cache.get(request.url)
const cached = this.#cache.get(request.url)

// TODO: Support actively refreshing the token
if (tokenData === undefined || isExpired(tokenData)) {
tokenData = await this.obtainToken(request)
this.#cache.set(request.url, tokenData)
if (cached !== undefined) {
if (!isExpired(cached)) {
return cached
}

const refreshed = await this.refreshToken(cached, request)
if (refreshed !== undefined) {
this.#cache.set(request.url, refreshed)
return refreshed
}
}

const headers = new Headers(request.headers)
const fresh = await this.obtainToken(request)
this.#cache.set(request.url, fresh)

headers.set("DPoP", await DPoP.generateProof(tokenData.dpopKey, request.url, request.method, undefined, tokenData.tokenResult.access_token))
headers.set("Authorization", ["DPoP", tokenData.tokenResult.access_token].join(" "))
return new Request(request, {headers})
return fresh
}

private async obtainToken(request: Request): Promise<CacheEntry> {
const authorizationServer = await this.#asProvider.getAuthorizationServer(request)

Expand Down Expand Up @@ -106,7 +138,40 @@ export class DPoPTokenProvider implements TokenProvider {

const tokenResult = await oauth.processAuthorizationCodeResponse(authorizationServer, clientRegistration, tokenResponse, {expectedNonce: this.nonceVerificationOverride(authorizationServer.issuer, nonce)})

return {created: Date.now(), tokenResult, dpopKey}
return {created: Date.now(), tokenResult, dpopKey, client: clientRegistration, authorizationServer}
}

private async refreshToken(cached: CacheEntry, request: Request): Promise<CacheEntry | undefined> {
if (cached.tokenResult.refresh_token === undefined) {
return undefined
}

const dpop = oauth.DPoP({}, cached.dpopKey)
const options = {DPoP: dpop}

let tokenResult: oauth.TokenEndpointResponse
try {
const tokenResponse = await oauth.refreshTokenGrantRequest(cached.authorizationServer, cached.client, this.getClientAuth(cached.authorizationServer.issuer, cached.client), cached.tokenResult.refresh_token, options)
tokenResult = await oauth.processRefreshTokenResponse(cached.authorizationServer, cached.client, tokenResponse)
} catch (e) {
this.#cache.delete(request.url)

if (e instanceof oauth.ResponseBodyError && e.error === "invalid_grant") {
console.debug("Access token could not be refreshed")

return undefined
}

throw e
Comment thread
langsamu marked this conversation as resolved.
}

// Reuse cached refreshed token if it wasn't rotated (token result didn't have one)
if (tokenResult.refresh_token === undefined) {
// Leave rest of token result intact
tokenResult = {...tokenResult, refresh_token: cached.tokenResult.refresh_token}
}

return {created: Date.now(), tokenResult, dpopKey: cached.dpopKey, client: cached.client, authorizationServer: cached.authorizationServer}
}

private getClientAuth(issuer: string, client: oauth.OmitSymbolProperties<oauth.Client>): oauth.ClientAuth {
Expand Down
Loading