Skip to content

feat(auth): ask to sign in again when the session expires - #321

Merged
novoj merged 3 commits into
devfrom
feat/session-expiry-prompt
Nov 2, 2026
Merged

novoj merged 3 commits into
devfrom
feat/session-expiry-prompt

Conversation

@novoj

@novoj novoj commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Summary

When the sign-in session ended while a page was open, the page kept showing the old view and every action failed with its own confusing error. API routes answer 401 AUTH_SESSION_MISSING in that case. With single sign-on the session has a fixed lifetime (OIDC_SESSION_MAX_AGE, 24 h by default, no refresh), so anyone who keeps a tab open hits this daily.

Changes

  • Proactive check. A SessionWatcher in the signed-in app layout:
    • checks the session at its expiresAt;
    • re-checks when the tab becomes visible or the browser comes back online, at most once a minute;
    • re-reads expiresAt on every check, and retries a check that returns a past expiresAt (clock skew) after 30, 60 and 120 s;
    • never treats a network error or 5xx as expiry, and does no periodic polling.
  • Reactive check. One shared window fetch observer inspects a clone of same-origin /api/ 401 responses (not /api/auth/*), asynchronously and bounded to 16 KiB and one second. On code AUTH_SESSION_MISSING it raises the prompt.
    • Callers get their responses unchanged and without delay.
    • REAUTHENTICATION_REQUIRED, 403 and other codes are ignored.
  • The prompt. A modal: "Your sign-in has expired", with "Sign in again" (a full page load to /login?returnTo=<current page>) and "Later". It closes again when a later check finds the session restored.
  • toastApiError stays silent for a 401 AUTH_SESSION_MISSING response, so the modal replaces the per-action error toasts. Every other error still shows.
  • Login return path. /login accepts returnTo, validated as a same-origin relative path: no //, backslash, control characters or scheme. It is honoured:
    • after password sign-in;
    • for an already signed-in visitor;
    • through SSO, where the callback is an absolute URL on the app origin, because better-auth rejects relative callbacks with a fragment or colon;
    • after a provider cancellation: better-auth handles it before reading the OAuth state, so the destination is recovered from sessionStorage for the retry.

Session lifetime, disableSessionRefresh and the /api/v1 contract are unchanged.

Testing

  • 6 new test files in src/tests/auth/: watcher timing, throttling, skew retry, restored sessions; 401 detection and exclusions, undelayed responses, observer cleanup order; return-path validation against encoded redirect attacks; SSO callbacks through better-auth's real validator, including cancellation.
  • Biome, tsc, whole unit suite: 7,440 passed, 0 failed. Auth and SSO tests also on Node 22.
  • Keycloak integration tests: 7 failures, identical on dev without this branch. They are pre-existing, not caused by this change, and will be reported separately.
  • Browser check on the demo stack, after deleting the session row:
    • Clicking an action opens the modal above an open dialog.
    • A visibility change alone opens it, with no app request.
    • "Sign in again" leads to /login?returnTo=… and returns to the same agenda after sign-in.

When the sign-in session ended while a page was open, the page kept showing
the old view and every action failed with its own confusing error, because
API routes answered 401 AUTH_SESSION_MISSING. With single sign-on the session
has a fixed lifetime (OIDC_SESSION_MAX_AGE, 24 h by default, no refresh), so
this happened every day to anyone who kept a tab open.

- A SessionWatcher in the signed-in app layout schedules a session check at
  the session's expiresAt and re-checks when the tab becomes visible or the
  browser comes back online (at most once a minute). Each check re-reads
  expiresAt; a check that returns an already-past expiresAt (clock skew) is
  retried after 30, 60 and 120 s. A network error or 5xx is never treated as
  expiry. There is no periodic polling.
- One shared window fetch observer inspects a clone of same-origin /api/
  responses with status 401 (not /api/auth/*) asynchronously, bounded to
  16 KiB and one second, and raises the prompt on code AUTH_SESSION_MISSING.
  Responses are returned to callers unchanged and undelayed;
  REAUTHENTICATION_REQUIRED, 403 and other codes are ignored.
- The prompt is a modal: "Your sign-in has expired" with "Sign in again"
  (a full page load to /login?returnTo=<current page>) and "Later". It
  closes again if a later check finds the session restored.
- toastApiError stays silent for a 401 AUTH_SESSION_MISSING response, so the
  modal replaces the per-action error toasts; every other error still shows.
- The login page accepts returnTo, validated as a same-origin relative path
  (no //, backslash, control characters or scheme), and honours it after
  password sign-in, for an already signed-in visitor, and through SSO, where
  the callback is an absolute URL on the app origin because better-auth
  rejects relative callbacks with a fragment or colon. When the provider
  cancels before better-auth reads the OAuth state, the destination is
  recovered from sessionStorage for the retry.

Session lifetime, disableSessionRefresh and the /api/v1 contract are
unchanged.
Comment thread src/app/(auth)/login/page.tsx Resolved
@claude

claude Bot commented Nov 2, 2026

Copy link
Copy Markdown

Review: feat(auth): ask to sign in again when the session expires

Solid, well-tested feature: a proactive SessionWatcher that checks session expiry (with clock-skew retries and capped long timers), a reactive fetch-observer that catches 401 AUTH_SESSION_MISSING on /api/* without delaying or consuming the caller's response, a validated returnTo login redirect, and SSO callback handling verified against a real better-auth instance. Encryption, user-scoping, and deploy-surface rules are untouched; this is a client/auth-UX change only. One functional bug in the SSO-cancellation recovery path should be fixed before merge.

Verdict: REQUEST_CHANGES

Findings: Blocker 0 | Critical 1 | Major 0 | Minor 0 | Nit 0

Findings

CRITICAL - src/app/(auth)/login/page.tsx:31-45 always passes a defined returnTo string (defaulting to "/dashboard") to <SsoLogin>. Because SsoLogin.handleClick (src/components/auth/sso-login.tsx:91-93) only falls back to recalledSsoReturnPath() when returnTo is undefined, the sessionStorage recovery for a provider cancellation is dead code in production: the real cancellation redirect (via onAPIError.errorURL in src/lib/auth.ts:141) omits returnTo, so the login page computes "/dashboard" and overwrites whatever was remembered. A user who cancels at the IdP and retries lands on /dashboard instead of their original page, contradicting the PR description. See the inline comment for the full repro and a suggested fix (only forward returnTo when the query string actually had one). The existing tests for this (src/tests/auth/sso-callback.test.tsx, src/tests/auth/login-return.test.tsx) construct <SsoLogin> directly without a returnTo prop, so they don't exercise the real page -> component wiring and miss this.

Missing tests

None beyond the above - coverage for the watcher timing, throttling, skew retry, 401 detection/exclusions, and return-path validation is thorough. An integration-style test that renders the actual /login page (or at least passes the same returnTo value the page would compute) through a provider-cancellation retry would have caught the bug above.

The login page always passed a validated returnTo (falling back to /dashboard) to SsoLogin, so the client's sessionStorage fallback never ran: after a cancellation better-auth redirects to /login?error=... without returnTo and the retry landed on the dashboard. The page now forwards returnTo only when the query has one; the redirect of a signed-in visitor still uses the validated destination.
Comment thread src/lib/auth/session-expiry.ts Resolved
@claude

claude Bot commented Nov 2, 2026

Copy link
Copy Markdown

Review: feat(auth): ask to sign in again when the session expires

Second pass, after the fix in 7f7b56a. Walked every changed file against AGENTS.md (user scoping, encryption, deploy surface - none apply here, this is a client/auth-UX change) and .claude/rules/ui.md and .claude/rules/i18n.md. The previously flagged critical (SSO-cancellation retry losing the remembered destination because the login page always passed a defined returnTo) is fixed and covered by src/tests/regressions/321-sso-cancellation-return-path.test.tsx and the real-better-auth test in sso-callback.test.tsx; confirmed by re-reading both, including the //evil.example and non-/dashboard cases.

The returnTo validation (loginReturnPath) handles the usual open-redirect bypasses (protocol-relative, backslash, control chars, double-encoding, invalid percent-escapes) with explicit adversarial tests. The reactive 401 observer never delays or consumes the caller's response (clones before returning, bounded to 16 KiB / 1 s), and the proactive watcher's long-timer chunking, clock-skew retry, and throttling all check out against their tests. i18n: both new strings have English and filled-in Czech translations, and "Later" reuses an existing key.

Verdict: APPROVE

Findings: Blocker 0 | Critical 0 | Major 0 | Minor 1 | Nit 0

Findings

MINOR (inline, src/lib/auth/session-expiry.ts:69) - observedFetch snapshots recipients from listeners before awaiting the underlying fetch, but gates inspection on the live listeners.size after the await and then notifies only the stale recipients. A listener that subscribes while a request is in flight (zero listeners when it started, one appears before the response resolves) won't be notified even though inspection runs and finds AUTH_SESSION_MISSING. Narrow window in practice (SessionWatcher mounts once, early, and the proactive timer check is a second line of defense), so phrased as a question inline rather than a blocker.

Missing tests

None beyond the above - the suite already exercises the scenario that mattered most (provider cancellation + retry against a real better-auth instance), plus throttling, skew retry, long-timer capping, 401 detection/exclusions/cleanup ordering, and tampered/unavailable sessionStorage.

The fetch observer snapshotted its listeners before awaiting the request and notified only that snapshot, so a watcher mounted during the request missed an AUTH_SESSION_MISSING response. It now notifies the listeners registered when the response is inspected.
@claude

claude Bot commented Nov 2, 2026

Copy link
Copy Markdown

Review: feat(auth): ask to sign in again when the session expires

Third pass, after fix commit 464875f8. This commit only touches src/lib/auth/session-expiry.ts and its test (confirmed via git show --stat); every other file is unchanged since the round-2 approval.

The fix replaces the stale recipients snapshot with a closure that reads listeners live at notification time (for (const listener of [...listeners]) listener.onExpired();), so a watcher that subscribes while a request is in flight is now notified when inspection finds AUTH_SESSION_MISSING - exactly the gap flagged in round 2. Verified by re-reading src/lib/auth/session-expiry.ts:88-96 and the new test "signals a watcher that subscribed while the request was in flight" in src/tests/auth/session-expiry.test.ts, which resolves the fetch only after a second observer subscribes and checks both the early and late listener fire. The existing cleanup-ordering and double-unsubscribe tests still pass with the live-set approach.

No regressions: the round-1 SSO-cancellation fix (7f7b56a3) and its regression test are untouched, toastApiError's AUTH_SESSION_MISSING suppression and the returnTo validation are unchanged.

Verdict: APPROVE

Findings: Blocker 0 | Critical 0 | Major 0 | Minor 0 | Nit 0

Findings: None. The minor concern from the previous round is fixed and covered by a new test.

Missing tests: None beyond what is already there.

@novoj
novoj merged commit 089f602 into dev Nov 2, 2026
8 checks passed
@novoj
novoj deleted the feat/session-expiry-prompt branch November 2, 2026 10:14
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.

1 participant