Skip to content

ENG-30: Refresh token - #42

Open
langsamu wants to merge 4 commits into
mainfrom
refresh_token
Open

langsamu wants to merge 4 commits into
mainfrom
refresh_token

Conversation

@langsamu

@langsamu langsamu commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Introduces functionality to refresh expired, cached access tokens when the authorization server supports it.

d74f833 is the substantial change, other commits just prepare the stage.

@langsamu
langsamu changed the base branch from main to request_offline-access September 10, 2026 16:29
@langsamu langsamu changed the title refresh token ENG-30: Refresh token Sep 10, 2026
@langsamu
langsamu added this pull request to stack #43 September 10, 2026 16:30
Base automatically changed from request_offline-access to main September 11, 2026 08:27

@langsamu langsamu left a comment

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.

Explanation

Comment thread src/DPoPTokenProvider.ts
created: number,
tokenResult: oauth.TokenEndpointResponse,
dpopKey: CryptoKeyPair,
client: oauth.Client,

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.

Refresh tokens are bound to clients, so we must cache the client registration data with the rest.

Because refresh tokens are typically long-lasting credentials used to
request additional access tokens, the refresh token is bound to the
client to which it was issued.

-- RFC 6749, §6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

👍

This cache implementation should always be isolated to one client. In browsers it should be origin bound. Otherwise, we are enabling clients to impersonate one another - by using their credentials.

Please add documentation to this effect at the points where this cache is configured.

Comment thread src/DPoPTokenProvider.ts
return new Request(request, {headers})
}

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

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?"}
    expired{"Cached token expired?"}
    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 --> expired
    expired -- no --> useCached
    expired -- yes --> refresh
    refresh --> refreshed
    refreshed -- yes --> storeRefreshed
    storeRefreshed --> useRefreshed
    refreshed -- no --> obtain
    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.

@langsamu
langsamu marked this pull request as ready for review September 16, 2026 10:59
Comment thread src/DPoPTokenProvider.ts
Comment on lines +134 to +138
private async refreshToken(request: Request): Promise<CacheEntry | undefined> {
const cached = this.#cache.get(request.url)
if (cached === undefined) {
return undefined
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not just call refreshToken directly on the CacheEntry

Suggested change
private async refreshToken(request: Request): Promise<CacheEntry | undefined> {
const cached = this.#cache.get(request.url)
if (cached === undefined) {
return undefined
}
private async refreshToken(cached: CacheEntry): Promise<CacheEntry | undefined> {

Comment thread src/DPoPTokenProvider.ts
return undefined
}

const authorizationServer = await this.#asProvider.getAuthorizationServer(request)

@jeswr jeswr Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It is a bad idea to be determining the authorisation server from the request here.

The refresh_token is bound to a specific authorisation server - so we must always refresh against that server.

If it is not already a property of the tokenResult, you should make the authorisation server a property of the CacheEntry and get it from that.

Comment thread src/DPoPTokenProvider.ts
Comment on lines +54 to +58
const refreshed = await this.refreshToken(request)
if (refreshed !== undefined) {
this.#cache.set(request.url, refreshed)
return refreshed
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If a separate request is initiated during this await it will trigger another refresh using the same refresh token.

This will create a race condition and one of those requests will fail as a refresh token is to only be used against an authorisation server once.

Comment thread src/DPoPTokenProvider.ts

const authorizationServer = await this.#asProvider.getAuthorizationServer(request)
const dpop = oauth.DPoP({}, cached.dpopKey)
const options = {DPoP: dpop, signal: request.signal}

@jeswr jeswr Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The signal of a refresh should not be attached to this specific request. It is possible that this request gets cancelled after the refresh token has been presented to the AS, but before the response has been transmitted to the client.

In this case the client has no valid refresh token; and a full re-authentication flow will need to be completed to upgrade subsequent requests.

The only case where a refresh should be aborted is when the client plans on making no further requests. When we move to having a persistent cache over an in-memory cache - this means the client will never be used again; which is an extreme edge case.

This is also related to https://github.com/solid-contrib/reactive-authentication/pull/42/changes#r4025845055.

Comment thread src/DPoPTokenProvider.ts
Comment on lines +159 to +160

throw e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If the refresh token is invalid for any reason; we should make sure it is cleared from the cache.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants