feat(auth): session expiration policy (3d idle / 14d absolute) + per-account override + bulk revoke #168
Reference in New Issue
Block a user
Delete Branch "feat/session-expiration-policy"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Fixes the "logged in forever" bug and adds owner-side controls for session policy + bulk session revocation. Defaults are Strict (3-day idle, 14-day absolute) — every user sees a hard 14-day re-auth at minimum, instead of the previous sliding-7-day-rolling-forever pattern. Plus one folded-in dashboard UX commit (focus-and-pulse on same-page Start Session CTAs).
What ships
Backend:
b269a1add160—accounts.session_idle_minutes+session_absolute_minutes(NULL = use system default) + CHECK constraintSettings.SESSION_*_MINUTESdefaults + min/max boundsauth_time+idle_max+abs_max(seconds) at every login entry point/auth/refreshenforces absolute cap (now >= auth_time + abs_max→ 401session_expired_absolute); atomic-revoke-then-check so absolute-expired tokens can't be replayedsession_expired_idle/session_expired_absolute/invalid_refresh_tokenGET/PATCH /accounts/me/security(returns effective + bounds +active_userslist, audited on PATCH)POST /accounts/me/security/revoke-sessions(scope: "all" | "others", audited)Frontend:
Token+OAuthCallbackResponsecarryidle_expires_at+absolute_expires_at(ISO 8601)session_expired_*(→/login?reason=session_expired) vsinvalid_refresh_token(→/login)useAuthSessionExpiryhook + top-of-appSessionExpiryToastdifferentiated by which window is closer (idle → warning amber with Stay signed in action; absolute → info cyan, informational only)/account/securitypage (owner-only): Strict/Standard/Custom presets + always-visible-disabled Custom inputs + inline validation + active-users list (name + email + last-login-ago) + count-aware revoke buttons + confirmation modal/loginwhen?reason=session_expiredis setscope=allbulk-revoke: success toast → 1.5s → clear localStorage →/loginNextStepCard+SetupChecklistnow scroll-and-focus the existingStartSessionInputwith a 900ms ring pulse (instead of Link-navigating to the same page silently)Docs:
docs/plans/2026-05-13-session-expiration-policy.md— full design + design-review (initial 4/10 → 9/10) + GSTACK REVIEW REPORT.ai/DECISIONS.md— architectural decision entryCURRENT-STATE.md— Recently-shipped summaryRollout notes
auth_timeclaim) get one free rotation under the new policy. No support-burden mass-logout on deploy.Test plan
pytest tests/test_session_policy.py tests/test_auth.py tests/test_oauth_callbacks.py— 39 passtsc --noEmit -p tsconfig.app.jsoncleanauthStore.test.ts— 2/2idle_max=3600,abs_max=14400(seconds)Sign in nowlink/login?reason=session_expiredshows the cyan info bannerauth_timeclaim) gets one successful rotation, new JWT has fresh claimsFollow-ups (not blocking; file before merge if you want them tracked)
refresh_tokens.last_used_atcolumn)🤖 Generated with Claude Code
Sixth commit in the session-expiration-policy series. The kill-all- sessions endpoint folded into scope after the §4.11 design pass. - POST /accounts/me/security/revoke-sessions, owner-only. - Body: {"scope": "all" | "others"}. Default "all" includes the caller's own refresh token. "others" preserves the caller's sessions so an owner can sign everyone else out without logging themselves out. - Single SQL UPDATE through users.account_id -> refresh_tokens, with revoked_at IS NULL preserved as the gate so already-revoked rows don't get double-stamped (the idempotency property). - Caller's access token is not touched — it dies on its 5-minute timer. Frontend handles "scope=all" UX by clearing localStorage and redirecting after the response (commit 8). - Affected users' next /auth/refresh hits the existing atomic-revoke zero-rows path -> invalid_refresh_token (plain logout, no banner). - Writes one account.sessions_revoked_bulk audit event with {scope, revoked_count}. Tests added in test_session_policy.py (6 cases): - #17 scope=all kills caller's own session; their refresh -> 401 invalid_refresh_token. - #18 scope=others preserves caller's session; their refresh succeeds, member's refresh -> 401 invalid_refresh_token. - #19 account-scoped: test_admin in a different account is unaffected when test_user's owner runs revoke-all (revoked_count=1, not 2). - #20 engineer-role member -> 403. - #21 emits exactly one audit row with the expected payload. - #22 idempotent: second immediate POST returns revoked_count=0. 22/22 in test_session_policy. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Seventh commit in the session-expiration-policy series. Wires the backend taxonomy from commit 2 through to the frontend so users see the right page (calm banner vs plain logout) when the refresh path fails for different reasons. - types/auth.ts: Token gains idle_expires_at + absolute_expires_at (Optional ISO 8601 strings). The next commit adds the useAuthSessionExpiry hook that reads these. - api/auth.ts: OAuthCallbackResponse mirrors the same two fields. - api/client.ts: refresh-failure handler now branches on the response detail. session_expired_idle and session_expired_absolute both redirect to /login?reason=session_expired (commit 8 adds the banner that reads the query param); any other detail (most commonly invalid_refresh_token) goes to plain /login. The bare redirect is guarded against re-firing when the user is already on /login. The refresh-success path now forwards the two new fields into setTokens so the store stays current as the session ages. - pages/OAuthCallbackPage.tsx: setTokens({...}) spreads idle_expires_at + absolute_expires_at from the OAuth response. No new tests — authStore.test still 2/2, tsc clean. The useAuthSessionExpiry hook and the SessionExpiryToast that consume the new fields land in commit 8 alongside the AccountSecurity page. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Eighth commit in the session-expiration-policy series. Surfaces all the owner controls and user-facing expiry UX that the prior commits plumbed through, designed end-to-end via /plan-design-review (initial 4/10 -> final 9/10; 7 decisions locked in the plan). Backend additions: - accounts/me/security GET response gains active_users: list of {user_id, name, email, last_login_at} for users in this account with at least one un-revoked refresh token. Joined query on refresh_tokens + users, distinct, ordered by last_login desc. Drives the Active Sessions section. Frontend additions: - api/accountSecurity.ts: typed client for GET/PATCH/revoke-sessions. - hooks/useAuthSessionExpiry.ts: reads idle/absolute expiry from the auth store, returns warning ('none'|'soon'|'now') + reason ('idle'|'absolute') so consumers can pick the right UX for the closer window. Re-evaluates every 30s. - components/common/SessionExpiryToast.tsx: top-of-app notice that fires at T-5min. Idle case: warning-amber tone, [Stay signed in] button hits authApi.refresh() and updates the store on success. Absolute case: info-cyan tone, [Sign in now] link to /login (no recoverable action). Dismissable, doesn't re-fire after dismissal. - components/account/RevokeSessionsModal.tsx: confirmation modal for the two bulk-revoke scopes. Title, body, and confirm-label vary by scope; danger-styled confirm button. - pages/account/AccountSecuritySettingsPage.tsx: the main page. Header (Shield icon), intro, Policy card with Strict/Standard/Custom radios + always-visible-disabled Custom inputs (idle/absolute minutes) with inline validation, Save button + emerald success ping, info note about 'applies at next login'. Active sessions card with count-aware copy, list of {name, email, last-login-ago} rows (caller tagged '(you)'), two buttons — 'except me' hidden when count=1, 'sign me out and everyone else' uses danger-tinted styling. - pages/AccountSettingsPage.tsx: 'Session security' row added to the owner-only settings list. - router.tsx: /account/security route, owner-gated via ProtectedRoute. - pages/LoginPage.tsx: cyan info-tone banner above form when ?reason=session_expired is in the URL. - components/layout/AppLayout.tsx: mounts <SessionExpiryToast />. Scope=all bulk-revoke UX (the most jarring moment): on success, toast.success(N sessions), 1.5s delay, then clear localStorage + useAuthStore.logout() + window.location='/login' (no banner — the owner just did this). Backend tests: existing 22/22 still green plus the GET test now asserts active_users is present + non-empty after login. Frontend: tsc clean, authStore test 2/2. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Heads-up on commit
cbb4b25This commit was intended to be a one-file lint fix for
useAuthSessionExpiry.ts(drops asetState-in-effect thatreact-hooks/set-state-in-effectflagged in CI). The commit message describes only that change.In practice the commit also sweeps in 40+
docs/plans/*.md→docs/plans/archive/*.mdrenames (allR100, content-identical moves) that were sitting in the working tree by the time I staged the hook fix. The moves are consistent with the project convention noted inPROJECT_CONTEXT.md(docs/plans/archive/holds pre-March 2026 plans), but they were not part of the lint-fix intent.Leaving as-is rather than force-pushing a clean replacement, per the project's no-destructive-git-without-explicit-ask rule. Reviewer note: if you eyeball the diff and see a sea of
R100renames, that's why — they're identical-content moves, safe to scan past on the way to the actual code change infrontend/src/hooks/useAuthSessionExpiry.ts.Follow-up issues filed: #169 (per-user device list) and #170 (super-admin global ceiling UI).