Combine session cache, refresh tokens, 401 renewal, insecure opt-in and popup reuse - #1
Merged
Merged
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>
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>
Installing this repo as a git dependency needs dist/ built at install time — dist/ is gitignored and there is no published tarball for this branch. `prepare` runs after npm installs the git checkout's deps, so consumers get a built package from a plain `npm i solid-contrib/reactive-authentication#feat/dpop-session-cache-installable`. The .npmrc is needed because typedoc@0.28 peer-caps typescript at 6.x while the project is on ^7; that ERESOLVE would abort the install before prepare could run.
…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>
…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>
A cached access token can stop working before its reported expiry — or have no reported expiry at all (expires_in is optional): revocation, server-side invalidation, key rollover. Previously the manager replayed the same rejected token on every 401, locking the user out until reload. ReactiveFetchManager now asks the provider to invalidate its credentials when the upgraded request still comes back 401, and retries exactly once with renewed ones (refresh grant first, popup flow as fallback). A still-rejected retry surfaces the 401 unchanged — bounded, never a loop. TokenProvider.invalidate is optional, so existing providers are unaffected (public API stays backwards-compatible). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
oauth4webapi enforces HTTPS on every request, so the auth-code + PKCE + DPoP flow could not talk to a local Community Solid Server over plain HTTP. Rather than guessing which issuers are loopback, expose a global switch consumers toggle themselves. InsecureConfiguration.allow() is deprecated on purpose so the security implication shows up at the call site. Also replaces the unconditional allow in BearerTokenProvider, and covers ClientCredentialsTokenProvider, which had no way to reach a local issuer at all.
The DPoP provider always tries `prompt=none` first, so a fresh login is: silent attempt → `login_required` → interactive retry. The popup element closed the window on EVERY callback message, so the retry had to open a NEW window — outside the original click's (already consumed) user activation. Popup blockers stop that, stranding the user in the "User interaction needed … Open new window" dialog on every first login (and on slower connections the first open can be blocked too). Now the popup is kept open when the callback carries one of the OIDC "user must interact" errors (login_required / interaction_required / consent_required) — exactly the errors token providers retry right away. The retry's `open()` then merely NAVIGATES the existing named window, which browsers always allow. Users see one popup that goes from the silent bounce straight to the login page, with no interstitial dialog. A code response or a terminal error (e.g. access_denied when the user denies consent) still closes the window as before, as do cancel/abort. Verified live with a Playwright-driven login (Chrome popup blocker enabled) against a deployed app + Solid-OIDC broker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Combines four open upstream PRs onto one installable branch, so the Solid File
Manager can develop against a local Community Solid Server and stop re-running
the authorization flow.
http://localhostissuers reachable, so local CSS worksprompt=noneretry navigates the existing popup, so no blocked second window and no "Open new window" dialogBased on
feat/dpop-session-cache-installable, which is solid-contrib#11 rebased onto currentmainplus thepreparescript and.npmrcthat let the branch installstraight from git.
Conflict resolution
solid-contrib#18 was the only real conflict. It spreads
InsecureConfiguration.requestOptionsinto each oauth4webapi call, but solid-contrib#11/solid-contrib#12/solid-contrib#14 had restructured those calls — a
different
signal, and an extra one. Kept the restructured versions and appliedthe opt-in to all four call sites, including the refresh-token grant that solid-contrib#18
predates. Without that one, token renewal against a local CSS would still fail
HTTPS enforcement.
Skipped as already present: solid-contrib#12's copy of the cache commit (in the rebased base)
and solid-contrib#13's
message.sourceguard (already onmain).Verification
npm run build(tsc): cleannpm test: 16/16 passing — feat: per-issuer session cache — stop re-running the full auth flow (popup included) on every 401 solid-contrib/reactive-authentication#11's 6, feat: refresh-token support — renew expired sessions without user interaction solid-contrib/reactive-authentication#12's 5, and feat: renew the session once when an upgraded request is still rejected (401) solid-contrib/reactive-authentication#14's, green togetherNote
This is a working branch pinned to unmerged drafts. As each PR lands upstream,
the corresponding commits should be dropped and the branch rebased, and once all
five land this fork can be abandoned for a published release.