-
Notifications
You must be signed in to change notification settings - Fork 1
feat: refresh-token support — renew expired sessions without user interaction #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
jeswr
wants to merge
4
commits into
feat/dpop-session-cache
Choose a base branch
from
feat/refresh-tokens
base: feat/dpop-session-cache
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
5132179
feat: refresh-token support — renew expired sessions without user int…
jeswr 70a32bd
Merge branch 'feat/dpop-session-cache' into feat/refresh-tokens
jeswr 8a5e8ae
test: cover the DPoP-nonce retry on the refresh grant; stop logging t…
jeswr bb8606e
fix: send prompt=consent on the interactive attempt when requesting o…
jeswr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,11 @@ 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 | ||
| } | ||
|
|
@@ -83,12 +87,53 @@ export class DPoPTokenProvider implements TokenProvider { | |
| // 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.#authenticate(issuer)) | ||
| 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 (e) { | ||
| console.debug("Refresh token grant failed, falling back to a new authorization", e) | ||
| 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}) | ||
|
|
||
| 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 on lines
+120
to
+129
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in 8a5e8ae: the fake AS can now challenge refresh grants with use_dpop_nonce + a DPoP-Nonce header, and a new test drives the challenge-then-retry handshake end to end (exactly two refresh requests, no popup). |
||
|
|
||
| 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) | ||
|
|
@@ -109,7 +154,18 @@ export class DPoPTokenProvider implements TokenProvider { | |
| 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}) | ||
| // 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[] | ||
|
|
@@ -125,7 +181,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) | ||
|
|
@@ -171,7 +227,9 @@ export class DPoPTokenProvider implements TokenProvider { | |
| authorizationServer, | ||
| clientRegistration, | ||
| dpopKey, | ||
| dpopHandle: dpop, | ||
| accessToken: tokenResult.access_token, | ||
| refreshToken: tokenResult.refresh_token, | ||
| expiresAt: expiresAt(tokenResult), | ||
| } | ||
| } | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 8a5e8ae: the fallback now logs only a static message — the raw oauth4webapi error stays out of the console.