Files
familysync/.planning/todos/pending/session-timeout-redirect-to-signin.md
T
Lucas Berger ae9fd9d790 docs(todo): redirect to sign-in on session timeout instead of hanging
Captured from Gate 2 live testing: when the OIDC session expires mid-use, the
app hangs the action and shows a generic 'couldn't load events' instead of
recognizing the signed-out state and redirecting to /api/login. Re-auth is
currently only wired to the initial /api/me failure (one-shot).
2026-06-07 17:49:20 -04:00

3.6 KiB

title, date, priority, phase_hint
title date priority phase_hint
Redirect to sign-in on session timeout instead of hanging / "couldn't load events" 2026-06-07 high Phase 03 (auth-entry UX) / Gate 2 polish

Redirect to sign-in on session timeout instead of hanging

Problem (observed in live Gate 2 testing)

When the OIDC session expires mid-use (e.g. user leaves the tab open past the session/refresh lifespan, then tries to delete an event), the app does not recognize the signed-out state. The action (delete) appears to hang — no sync toast, no progress — and shortly after the calendar shows a generic "couldn't load events" error. The user is fooled into thinking the app is working/broken rather than understanding they've been signed out and need to re-authenticate.

Root cause

Re-auth is only wired to the initial /api/me failure:

  • CalendarShell.tsx calls maybeRedirectToLogin() only on meQuery.isError.
  • maybeRedirectToLogin() (apps/pwa/src/lib/loginRedirect.ts) is one-shot (guarded by the familysync.loginRedirectAttempted sessionStorage flag) and clearLoginRedirect() runs on a successful /api/me.
  • Other calls don't drive re-auth:
    • fetchEvents uses redirect: 'follow', so a timed-out session 302s to Authelia (cross-origin) → the XHR rejects → eventsQuery just errors ("couldn't load events"), no redirect.
    • Write mutations (create/edit/delete) call the API directly; on an expired session the request 302s/opaque-fails and the mutation hangs/errors with no re-auth and no clear feedback.

So a session that expires after the first successful load has no path back to login except a manual full refresh.

Desired behavior

Any API response that indicates "not authenticated anymore" (401, or an opaqueredirect/type: 'opaqueredirect' from the OIDC guard's 302 to Authelia) should put the app into a clear signed-out state and trigger a top-level re-auth navigation to /api/login — from ANY query or mutation, not just the initial /api/me. The user should never be left staring at a hung action or a generic data-load error when the real cause is an expired session.

Scope to decide when promoted

  • Centralize auth-expiry detection in the API client (apps/pwa/src/api/client.ts): a shared helper that classifies a response as "session expired" (401 or opaqueredirect) and throws a typed SessionExpiredError.
  • Apply redirect: 'manual' consistently (fetchEvents/mutations) so the guard's 302 is detectable instead of hanging on the cross-origin follow.
  • A single TanStack Query handler (e.g. QueryCache/MutationCache onError, or a small auth-state listener) that, on SessionExpiredError, calls a re-armed maybeRedirectToLogin() — the one-shot loop guard must be reset so a genuine later expiry can redirect again (clear the flag on detected expiry, not only on successful /api/me).
  • Decide UX: immediate redirect to /api/login vs a brief "Your session expired — signing you back in…" toast/interstitial before redirecting (the non-technical Apple member should not see a raw error).
  • Make sure an in-flight write that fails on expiry is not silently lost — after re-auth, either resurface the unsaved action or clearly tell the user it wasn't saved (ties to T-03-18 no-silent-loss).

Notes

  • Relates to the earlier one-shot login redirect work (quick 260606-tv8) and the OIDC state-cookie-churn fix (commit bb61d21 — gate events query on auth).
  • Verify with a real expiry (or shorten OIDC_AUTH_EXPIRES / Authelia refresh_token_lifespan in a test) rather than only reasoning about it.