feat: refresh-token support — renew expired sessions without user interaction#12
feat: refresh-token support — renew expired sessions without user interaction#12jeswr wants to merge 4 commits into
Conversation
…eraction Sessions often outlive the access token (commonly 1 h), and until now the only way to keep going was to re-run the whole authorization flow, popup included. This makes DPoPTokenProvider renew transparently: - where the server advertises support, the provider registers the refresh_token grant (dynamic registration metadata) and requests the offline_access scope (OIDC Core §11); servers without support see the exact requests they saw before; - the refresh token is stored alongside the per-issuer session and an expired access token is renewed with the refresh-token grant (RFC 6749 §6) — no popup, no user interaction; - the grant is DPoP-bound with the session's existing key/handle, so the refreshed access token keeps the same cnf.jkt binding (RFC 9449 §4.3), with a single retry on a server-required DPoP nonce; - rotation is handled per RFC 9700 §4.14.2: when the server rotates the refresh token, the newest one always replaces the old; - when the refresh grant fails (refresh-token expiry, revocation, rotation-reuse detection), the provider falls back to a fresh authorization-code flow — silent while the IdP cookie lives. Public API is unchanged. Tokens stay in memory only, as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds transparent refresh-token support to DPoPTokenProvider so expired access tokens can be renewed without re-running the interactive authorization-code flow, while preserving DPoP binding and maintaining byte-identical requests for issuers that don’t advertise refresh-token support.
Changes:
- Extend the per-issuer cached session to include a reusable oauth4webapi DPoP handle and an optional refresh token.
- Implement refresh-token grant renewal (with one DPoP nonce retry) and fallback to a fresh authorization-code flow on refresh failure.
- Add unit tests covering opt-in registration/scope behavior, silent refresh on expiry, refresh-token rotation adoption, and fallback on
invalid_grant.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/DPoPTokenProvider.ts | Implements refresh-token opt-in + refresh grant renewal and stores refresh token/DPoP handle in the session cache. |
| test/DPoPTokenProvider.test.ts | Adds tests for refresh-token opt-in/no-change behavior, silent refresh, rotation, and refresh-failure fallback. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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) | ||
| } |
There was a problem hiding this comment.
Fixed in 8a5e8ae: the fallback now logs only a static message — the raw oauth4webapi error stays out of the console.
| 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()) | ||
| } |
There was a problem hiding this comment.
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).
…he raw refresh error Review follow-ups: the fake AS can now challenge refresh grants with use_dpop_nonce + DPoP-Nonce (RFC 9449 §8) so the one-retry handshake is exercised end to end, and the refresh-failure fallback no longer logs the raw oauth4webapi error (it can carry the token-endpoint request/response, tokens included). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ffline_access OIDC Core §11: the AS MUST ignore offline_access unless the request's prompt includes consent — oidc-provider (Community Solid Server and brokers built on it) enforces this strictly, so the previous retry (prompt removed entirely) silently came back without a refresh token. Found live against a Solid broker; the fake AS gains an opt-in enforceOfflineAccessConsent mode reproducing that behaviour, and a test drives silent-attempt → login_required → prompt=consent retry → refresh token issued → silent renewal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Live-testing update (bb8606e): against a real oidc-provider-based Solid broker the original change came back with scope The interactive attempt (the retry after Proven live: authorization-code + PKCE + DPoP with |
Stacked on #11 (per-issuer session cache) — review only the last commit; GitHub will retarget to
mainwhen #11 merges.Problem
Access tokens commonly live ~1 hour, so real app sessions outlive them ("the session often times out"). Until now the only renewal path was the full authorization flow — popup included.
Change
DPoPTokenProviderrenews expired sessions transparently with refresh tokens:refresh_tokengrant and the authorization request adds theoffline_accessscope (OIDC Core §11). Servers without support see byte-identical requests to before.cnf.jkt(RFC 9449 §4.3); one retry on a server-required DPoP nonce (use_dpop_nonce).prompt=none-first behaviour.Public API unchanged; tokens stay in memory only, as before.
Tests
npm run build(tsc): cleannpm test: 11/11 (5 new: opt-in registration/scope, no-change for non-supporting servers, silent refresh on expiry, rotation adoption, fallback oninvalid_grant)🤖 Generated with Claude Code