From b9257ccd07fdc54f4ea0953eb92a41a406ba2aca Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Thu, 10 Sep 2026 13:47:02 +0100 Subject: [PATCH 1/9] Style --- src/DPoPTokenProvider.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index 4e1b84c..cd3a39e 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -6,7 +6,11 @@ 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, +} export class DPoPTokenProvider implements TokenProvider { readonly #codeProvider: CodeProvider From 332064f9902213ae02d0d695cb6d89f5c1b119dd Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Wed, 9 Sep 2026 12:19:27 +0100 Subject: [PATCH 2/9] Extract --- src/DPoPTokenProvider.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index cd3a39e..d040985 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -31,20 +31,29 @@ export class DPoPTokenProvider implements TokenProvider { } async upgrade(request: Request): Promise { + const {dpopKey, tokenResult: {access_token}} = 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 { // TODO: More robust key via callback to support complex caching scenarios let tokenData = 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) } - const headers = new Headers(request.headers) - - 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 tokenData; } + private async obtainToken(request: Request): Promise { const authorizationServer = await this.#asProvider.getAuthorizationServer(request) From bff1eb8a6af1d22a294b3c35bc8fb44ea295b1a1 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Wed, 9 Sep 2026 12:26:47 +0100 Subject: [PATCH 3/9] Rearrange --- src/DPoPTokenProvider.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index d040985..8197c58 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -43,15 +43,17 @@ export class DPoPTokenProvider implements TokenProvider { private async getCachedToken(request: Request): Promise { // 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 && !isExpired(cached)) { + return cached } - return tokenData; + const fresh = await this.obtainToken(request) + this.#cache.set(request.url, fresh) + + return fresh } private async obtainToken(request: Request): Promise { From d74f833095dea0f97bd3224f030ed4efc73cdc81 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Thu, 10 Sep 2026 17:27:47 +0100 Subject: [PATCH 4/9] Refresh --- src/DPoPTokenProvider.ts | 41 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index 8197c58..cc67506 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -10,6 +10,7 @@ type CacheEntry = { created: number, tokenResult: oauth.TokenEndpointResponse, dpopKey: CryptoKeyPair, + client: oauth.Client, } export class DPoPTokenProvider implements TokenProvider { @@ -50,6 +51,12 @@ export class DPoPTokenProvider implements TokenProvider { return cached } + const refreshed = await this.refreshToken(request) + if (refreshed !== undefined) { + this.#cache.set(request.url, refreshed) + return refreshed + } + const fresh = await this.obtainToken(request) this.#cache.set(request.url, fresh) @@ -121,7 +128,39 @@ 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} + } + + private async refreshToken(request: Request): Promise { + const cached = this.#cache.get(request.url) + if (cached === undefined) { + return undefined + } + + if (cached.tokenResult.refresh_token === undefined) { + return undefined + } + + const authorizationServer = await this.#asProvider.getAuthorizationServer(request) + const dpop = oauth.DPoP({}, cached.dpopKey) + const options = {DPoP: dpop, signal: request.signal} + + const tokenResponse = await oauth.refreshTokenGrantRequest(authorizationServer, cached.client, this.getClientAuth(authorizationServer.issuer, cached.client), cached.tokenResult.refresh_token, options) + + let tokenResult: oauth.TokenEndpointResponse + try { + tokenResult = await oauth.processRefreshTokenResponse(authorizationServer, cached.client, tokenResponse) + } catch (e) { + if (e instanceof oauth.ResponseBodyError && e.error === "invalid_grant") { + console.debug("Access token could not be refreshed") + + return undefined + } + + throw e + } + + return {created: Date.now(), tokenResult, dpopKey: cached.dpopKey, client: cached.client} } private getClientAuth(issuer: string, client: oauth.OmitSymbolProperties): oauth.ClientAuth { From cac016d3aa01db3e29f8159f07797837f176e967 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Wed, 16 Sep 2026 17:05:34 +0100 Subject: [PATCH 5/9] Remind documenting separating cache by client --- src/DPoPTokenProvider.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index cc67506..83fd8a6 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -16,7 +16,10 @@ type CacheEntry = { export class DPoPTokenProvider implements TokenProvider { readonly #codeProvider: CodeProvider readonly #callbackUri: string - readonly #cache = new Map // 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 readonly #asProvider: AuthorizationServerProvider readonly #clientProvider: ClientProvider From 5c15c172beac68354add1802308a8f4eb5b24776 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Wed, 16 Sep 2026 17:19:28 +0100 Subject: [PATCH 6/9] Cache authorization server alongside tokens --- src/DPoPTokenProvider.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index 83fd8a6..ce6dec7 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -11,6 +11,7 @@ type CacheEntry = { tokenResult: oauth.TokenEndpointResponse, dpopKey: CryptoKeyPair, client: oauth.Client, + authorizationServer: oauth.AuthorizationServer, } export class DPoPTokenProvider implements TokenProvider { @@ -131,7 +132,7 @@ 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, client: clientRegistration} + return {created: Date.now(), tokenResult, dpopKey, client: clientRegistration, authorizationServer} } private async refreshToken(request: Request): Promise { @@ -144,15 +145,14 @@ export class DPoPTokenProvider implements TokenProvider { return undefined } - const authorizationServer = await this.#asProvider.getAuthorizationServer(request) const dpop = oauth.DPoP({}, cached.dpopKey) const options = {DPoP: dpop, signal: request.signal} - const tokenResponse = await oauth.refreshTokenGrantRequest(authorizationServer, cached.client, this.getClientAuth(authorizationServer.issuer, cached.client), cached.tokenResult.refresh_token, options) + const tokenResponse = await oauth.refreshTokenGrantRequest(cached.authorizationServer, cached.client, this.getClientAuth(cached.authorizationServer.issuer, cached.client), cached.tokenResult.refresh_token, options) let tokenResult: oauth.TokenEndpointResponse try { - tokenResult = await oauth.processRefreshTokenResponse(authorizationServer, cached.client, tokenResponse) + tokenResult = await oauth.processRefreshTokenResponse(cached.authorizationServer, cached.client, tokenResponse) } catch (e) { if (e instanceof oauth.ResponseBodyError && e.error === "invalid_grant") { console.debug("Access token could not be refreshed") @@ -163,7 +163,7 @@ export class DPoPTokenProvider implements TokenProvider { throw e } - return {created: Date.now(), tokenResult, dpopKey: cached.dpopKey, client: cached.client} + return {created: Date.now(), tokenResult, dpopKey: cached.dpopKey, client: cached.client, authorizationServer: cached.authorizationServer} } private getClientAuth(issuer: string, client: oauth.OmitSymbolProperties): oauth.ClientAuth { From eb5a65337229dd71dbca22cc56907c17b840a602 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Fri, 18 Sep 2026 10:25:57 +0100 Subject: [PATCH 7/9] Use cache entry for refreshing --- src/DPoPTokenProvider.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index ce6dec7..0ce8e78 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -51,14 +51,16 @@ export class DPoPTokenProvider implements TokenProvider { const cached = this.#cache.get(request.url) // TODO: Support actively refreshing the token - if (cached !== undefined && !isExpired(cached)) { - return cached - } + if (cached !== undefined) { + if (!isExpired(cached)) { + return cached + } - const refreshed = await this.refreshToken(request) - if (refreshed !== undefined) { - this.#cache.set(request.url, refreshed) - return refreshed + const refreshed = await this.refreshToken(cached, request.signal) + if (refreshed !== undefined) { + this.#cache.set(request.url, refreshed) + return refreshed + } } const fresh = await this.obtainToken(request) @@ -135,18 +137,13 @@ export class DPoPTokenProvider implements TokenProvider { return {created: Date.now(), tokenResult, dpopKey, client: clientRegistration, authorizationServer} } - private async refreshToken(request: Request): Promise { - const cached = this.#cache.get(request.url) - if (cached === undefined) { - return undefined - } - + private async refreshToken(cached: CacheEntry, signal: AbortSignal): Promise { if (cached.tokenResult.refresh_token === undefined) { return undefined } const dpop = oauth.DPoP({}, cached.dpopKey) - const options = {DPoP: dpop, signal: request.signal} + const options = {DPoP: dpop, signal} const tokenResponse = await oauth.refreshTokenGrantRequest(cached.authorizationServer, cached.client, this.getClientAuth(cached.authorizationServer.issuer, cached.client), cached.tokenResult.refresh_token, options) From 169c015abb1a3d632bfab1efa3f03628a521e262 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Fri, 18 Sep 2026 10:48:15 +0100 Subject: [PATCH 8/9] Invalidate cache on token refresh failure --- src/DPoPTokenProvider.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index b7ced6b..9234548 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -56,7 +56,7 @@ export class DPoPTokenProvider implements TokenProvider { return cached } - const refreshed = await this.refreshToken(cached, request.signal) + const refreshed = await this.refreshToken(cached, request) if (refreshed !== undefined) { this.#cache.set(request.url, refreshed) return refreshed @@ -137,13 +137,13 @@ export class DPoPTokenProvider implements TokenProvider { return {created: Date.now(), tokenResult, dpopKey, client: clientRegistration, authorizationServer} } - private async refreshToken(cached: CacheEntry, signal: AbortSignal): Promise { + private async refreshToken(cached: CacheEntry, request: Request): Promise { if (cached.tokenResult.refresh_token === undefined) { return undefined } const dpop = oauth.DPoP({}, cached.dpopKey) - const options = {DPoP: dpop, signal} + const options = {DPoP: dpop, signal: request.signal} const tokenResponse = await oauth.refreshTokenGrantRequest(cached.authorizationServer, cached.client, this.getClientAuth(cached.authorizationServer.issuer, cached.client), cached.tokenResult.refresh_token, options) @@ -151,6 +151,8 @@ export class DPoPTokenProvider implements TokenProvider { try { 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") From b5cde2e638eaabc19ff047c480548d657d29c71c Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Fri, 18 Sep 2026 15:54:10 +0100 Subject: [PATCH 9/9] Don't reuse refresh tokens --- src/DPoPTokenProvider.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index 9234548..4de5899 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -36,7 +36,10 @@ export class DPoPTokenProvider implements TokenProvider { } async upgrade(request: Request): Promise { - const {dpopKey, tokenResult: {access_token}} = await this.getCachedToken(request) + // Form a queue per request URI to never reuse refresh tokens. + const lockName = `DPoPTokenProvider.upgrade[${request.url}]` + const {dpopKey, tokenResult: {access_token}} = await navigator.locks.request(lockName, async _ => + await this.getCachedToken(request)) const headers = new Headers(request.headers)