Skip to content

Combine session cache, refresh tokens, 401 renewal, insecure opt-in and popup reuse - #1

Merged
PreciousOritsedere merged 10 commits into
mainfrom
combined/cache-refresh-loopback
Jul 30, 2026
Merged

Combine session cache, refresh tokens, 401 renewal, insecure opt-in and popup reuse#1
PreciousOritsedere merged 10 commits into
mainfrom
combined/cache-refresh-loopback

Conversation

@PreciousOritsedere

Copy link
Copy Markdown
Owner

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.

Upstream What it gives us
#11 per-issuer session cache One flow per session instead of one per 401
#12 refresh tokens Expired access tokens renew silently, no popup
#14 renew on rejected 401 A stale token is renewed once rather than surfacing as a permissions error
#18 insecure opt-in http://localhost issuers reachable, so local CSS works
#13 popup reuse The prompt=none retry navigates the existing popup, so no blocked second window and no "Open new window" dialog

Based on feat/dpop-session-cache-installable, which is solid-contrib#11 rebased onto current
main plus the prepare script and .npmrc that let the branch install
straight from git.

Conflict resolution

solid-contrib#18 was the only real conflict. It spreads InsecureConfiguration.requestOptions
into 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 applied
the 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.source guard (already on main).

Verification

Note

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.

jeswr and others added 10 commits July 29, 2026 11:16
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>
@PreciousOritsedere
PreciousOritsedere merged commit c9cfb04 into main Jul 30, 2026
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