Skip to content

feat: refresh-token support — renew expired sessions without user interaction#12

Open
jeswr wants to merge 4 commits into
feat/dpop-session-cachefrom
feat/refresh-tokens
Open

feat: refresh-token support — renew expired sessions without user interaction#12
jeswr wants to merge 4 commits into
feat/dpop-session-cachefrom
feat/refresh-tokens

Conversation

@jeswr

@jeswr jeswr commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Stacked on #11 (per-issuer session cache) — review only the last commit; GitHub will retarget to main when #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

DPoPTokenProvider renews expired sessions transparently with refresh tokens:

  • Opt-in by discovery: where the server advertises support, dynamic registration asks for the refresh_token grant and the authorization request adds the offline_access scope (OIDC Core §11). Servers without support see byte-identical requests to before.
  • Transparent renewal: the refresh token is stored alongside the per-issuer session; an expired access token is renewed via the refresh-token grant (RFC 6749 §6) with no popup and no user interaction.
  • DPoP binding preserved: the grant reuses the session's DPoP key/handle, so the refreshed access token keeps the same cnf.jkt (RFC 9449 §4.3); one retry on a server-required DPoP nonce (use_dpop_nonce).
  • Rotation-safe (RFC 9700 §4.14.2): a rotated refresh token always replaces the previous one.
  • Graceful fallback: a failed refresh (expiry, revocation, rotation-reuse detection) falls back to a fresh authorization-code flow — still silent while the IdP cookie lives, via the existing prompt=none-first behaviour.

Public API unchanged; tokens stay in memory only, as before.

Tests

  • npm run build (tsc): clean
  • npm test: 11/11 (5 new: opt-in registration/scope, no-change for non-supporting servers, silent refresh on expiry, rotation adoption, fallback on invalid_grant)
  • Verified live against a Solid-OIDC broker (oidc-provider, rotating refresh tokens, 1 h access tokens) with a deployed pod manager at https://app.solid-test.jeswr.org: refresh grant renews the session mid-flight without any visible prompt.

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings June 11, 2026 19:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/DPoPTokenProvider.ts
Comment on lines +103 to +107
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)
}

Copy link
Copy Markdown
Contributor Author

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.

Comment thread src/DPoPTokenProvider.ts
Comment on lines +118 to +127
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())
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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).

jeswr and others added 2 commits June 11, 2026 21:42
…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>
@jeswr

jeswr commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Live-testing update (bb8606e): against a real oidc-provider-based Solid broker the original change came back with scope openid webid and no refresh token — OIDC Core §11 requires the AS to ignore offline_access unless the request's prompt includes consent, and oidc-provider (Community Solid Server and brokers built on it) enforces that strictly.

The interactive attempt (the retry after prompt=none fails with login_required/interaction_required) now sends prompt=consent when the provider is opting into offline_access; servers without offline_access support see exactly the old requests. The fake AS gained an opt-in enforceOfflineAccessConsent mode reproducing the strict behaviour, with a test driving silent attempt → login_required → consent retry → refresh token issued → silent renewal.

Proven live: authorization-code + PKCE + DPoP with prompt=consentrefresh_token issued, DPoP-bound refresh grant accepted, refreshed token performs an authenticated pod read.

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.

2 participants