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 6 commits into
Conversation
Previously every 401 re-ran the entire flow — discovery, dynamic client registration, a fresh DPoP key, and a new authorization popup — so each authenticated request could prompt the user again. DPoPTokenProvider now keeps a single-flight per-issuer session cache: - concurrent 401 upgrades share one authorization-code flow (one popup); - later upgrades reuse the established access token, signing a fresh DPoP proof per request; - the token's reported `expires_in` is tracked (with 30 s skew) and an expired session re-runs the flow — silently while the IdP cookie lives, thanks to the existing `prompt=none`-first behaviour; - a failed flow is not cached, so the next request can retry; - shared flow work is no longer tied to a single request's AbortSignal (aborting one request must not cancel the login that other concurrent upgrades are waiting on). The public API is unchanged. Also adds a minimal vitest setup (the repo had no test runner) with a compact in-memory authorization server covering the cache behaviour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…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.
Review follow-ups: discovery now advertises the refresh_token grant exactly when the server issues refresh tokens; the refresh-token grant is rejected (unsupported_grant_type) when refresh tokens are disabled; and a non-rotating server keeps the presented token active without issuing a replacement (RFC 6749 §6) instead of silently rotating. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…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 |
23e0a5d to
c57f39c
Compare
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