First commit in the session-expiration-policy series (see docs/plans/2026-05-13-session-expiration-policy.md). No behavior change yet — this lays the schema + settings groundwork only. - Settings: SESSION_IDLE_MINUTES_DEFAULT=4320 (3d), SESSION_ABSOLUTE_MINUTES_DEFAULT=20160 (14d), plus MIN/MAX bounds so account overrides have envelopes (15min..30d idle, 1h..90d absolute). - accounts table: nullable session_idle_minutes and session_absolute_minutes columns (NULL = use system default), plus a CHECK constraint that rejects idle > absolute when both are set. Partial-override validation lives at the app layer because the DB cannot read Settings. Subsequent commits will: distinguish idle vs invalid-token expiry on the wire, embed auth_time/idle_max/abs_max in refresh JWTs, enforce the absolute cap in /auth/refresh, add the owner-only policy + bulk-revoke endpoints, and surface everything in an AccountSecurity settings page with a session-expiry toast. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
35 KiB
Session Expiration Policy — Design & Implementation Plan
Date: 2026-05-13 Owner: Michael Chihlas Status: Draft — pending review Related issue: none yet (file after plan approval)
1. Problem
Today, once a user logs in to ResolutionFlow, they effectively stay logged in forever:
- Access token: 5 minutes — fine.
- Refresh token: 7 days, with JTI rotation. Every
/auth/refreshmints a fresh 7-day window and revokes the old JTI. - Frontend stores both in
localStorage; Axios interceptor silently refreshes on every 401.
Net effect: a sliding 7-day session with no absolute cap. As long as a user opens the app at least once a week, the refresh token rolls forward indefinitely. There is no enforced re-authentication, no idle-timeout cap, no maximum session lifetime — and no per-account control for MSP owners whose customers may demand stricter security.
This was acceptable for pilot but is not acceptable for self-serve launch:
- MSP buyers' SOC2 / cyber-insurance auditors routinely require enforced session timeouts.
- A stolen device with an unlocked browser hands an attacker indefinite access.
- Owners of paying accounts expect to be able to set policy for their members.
2. Goals
- System-level absolute cap — no session can exceed N days regardless of activity.
- Idle cap — sessions inactive for N days must require re-login.
- Per-account owner override — account owners can tighten or (within sysadmin-imposed ceilings) loosen the policy for their account.
- Graceful UX — users get warned before forced re-login; rotation continues to be silent within the active window.
- Backward-compatible rollout — existing refresh tokens are grandfathered for one rotation, not invalidated at deploy.
3. Non-goals
- Multi-device session management (revoke individual devices). Tracked separately; out of scope here.
- "Remember this device" / trusted device list. Out of scope.
- Per-user (vs per-account) overrides. Out of scope.
- Re-auth on sensitive action (step-up auth). Out of scope.
- Annual review of session policy (analytics dashboards). Out of scope.
4. Design
4.1 Two windows, both enforced
| Window | Default | Meaning |
|---|---|---|
| Idle | 3 days | Maximum time between /auth/refresh calls. Rotation extends this window. |
| Absolute | 14 days | Hard cap from original login (auth_time). Rotation does not extend this. |
The shorter of the two governs: a token is valid only if now < min(idle_exp, auth_time + absolute_max).
4.2 JWT payload changes
Refresh-token JWT today (backend/app/core/security.py:36):
{ "sub": "<user_id>", "type": "refresh", "jti": "<uuid>", "exp": <idle_exp> }
New refresh-token JWT:
{
"sub": "<user_id>",
"type": "refresh",
"jti": "<uuid>",
"exp": <idle_exp>, // unchanged semantics, now = idle window
"auth_time": <login_unix_ts>, // original login (Unix seconds); NOT reset on rotation
"idle_max": <idle_seconds>, // captured at login (account policy snapshot, seconds)
"abs_max": <abs_seconds> // captured at login (account policy snapshot, seconds)
}
Unit convention (single source of truth):
| Surface | Unit | Why |
|---|---|---|
Settings.SESSION_*_MINUTES, accounts.session_*_minutes, PATCH /accounts/me/security request/response, frontend form inputs |
minutes | Human-readable, matches the column names, what owners actually edit |
idle_max, abs_max inside the refresh JWT, auth_time |
seconds (Unix) | Lets auth_time + abs_max be direct Unix math against int(time.time()) with no conversion at check time |
idle_expires_at, absolute_expires_at on API responses, useAuthSessionExpiry hook |
ISO 8601 UTC strings | Matches the rest of the API surface (DateTime(timezone=True) everywhere) |
resolve_session_policy(account) (see §4.4) returns minutes; the _mint_session_tokens helper multiplies by 60 once when stamping the JWT. That's the only place the conversion happens.
Why snapshot idle_max/abs_max into the JWT instead of looking up the account policy on every refresh? Two reasons:
- Refresh path stays DB-cheap (one query, not two).
- If an owner tightens the policy after a user has logged in, the user's existing session continues under the policy in effect at login — fairer UX, matches what Okta and Microsoft do. New logins pick up the tightened policy.
Counter-consideration: if an owner loosens policy, existing sessions stay tight until next login. Acceptable; users won't notice. The owner-tightens case (security event) is the one that matters, and a kill-all-sessions admin button covers that scenario (out of scope here — log an issue).
4.3 Per-account policy storage
New columns on accounts:
| Column | Type | Nullable | Meaning |
|---|---|---|---|
session_idle_minutes |
Integer |
yes | NULL = use system default |
session_absolute_minutes |
Integer |
yes | NULL = use system default |
Minutes (not days) so admins can configure shorter windows for high-security tenants if needed. Stored as Integer to match existing pattern; conversion to timedelta happens at use site.
System-imposed bounds (in Settings, environment-overridable):
| Setting | Default | Floor | Ceiling |
|---|---|---|---|
SESSION_IDLE_MINUTES_DEFAULT |
4320 (3d) | n/a | n/a |
SESSION_ABSOLUTE_MINUTES_DEFAULT |
20160 (14d) | n/a | n/a |
SESSION_IDLE_MINUTES_MIN |
15 | hard floor | account override cannot go below |
SESSION_IDLE_MINUTES_MAX |
43200 (30d) | account override cannot go above | |
SESSION_ABSOLUTE_MINUTES_MIN |
60 (1h) | hard floor | |
SESSION_ABSOLUTE_MINUTES_MAX |
129600 (90d) | account override cannot go above |
Plus invariant: an account's effective idle window must not exceed its effective absolute window. Enforcement is layered:
- App-level (PATCH endpoint, authoritative): before writing the row, resolve both effective values (
override ?? system_default) and reject when effective idle > effective absolute. This is the only place that knows the current system defaults, so it's the only place that can catch a partial-override hole likesession_idle_minutes=43200, session_absolute_minutes=NULLwhen the system absolute default is 20160. - DB CHECK constraint (defense in depth, narrower):
session_idle_minutes IS NULL OR session_absolute_minutes IS NULL OR session_idle_minutes <= session_absolute_minutes. This only catches the both-set case; the partial-override case is intentionally outside the DB's reach because the DB can't seeSettings. Document this in a comment on the constraint.
Alternative considered: require both columns to be NULL or both set (XOR-with-NULL). Rejected because it forces an owner who only wants to override idle to also re-declare the absolute window, which leaks the system default into account data and makes the system default harder to evolve later.
4.4 Resolution function
# backend/app/core/security.py
def resolve_session_policy(account: Account) -> tuple[int, int]:
"""Return (idle_minutes, absolute_minutes) for an account, applying defaults."""
idle = account.session_idle_minutes or settings.SESSION_IDLE_MINUTES_DEFAULT
abs_ = account.session_absolute_minutes or settings.SESSION_ABSOLUTE_MINUTES_DEFAULT
return idle, abs_
Called once at each of the four token-issuing entry points listed in §4.6 (/auth/login, /auth/login/json, /auth/google/callback, /auth/microsoft/callback) and snapshotted into the JWT via _mint_session_tokens. Not called on /auth/refresh — that path carries forward the existing snapshot.
4.5 Refresh endpoint changes
POST /auth/refresh (backend/app/api/endpoints/auth.py:377) currently:
- Decodes refresh JWT (via
get_refresh_token_payloaddep). - Atomically revokes old JTI (
UPDATE … SET revoked_at=now() WHERE token_hash=? AND revoked_at IS NULL RETURNING …). - Mints new refresh + access tokens with same
sub.
New algorithm (precise):
- Decode refresh JWT (idle expiry already surfaced as
session_expired_idlebydecode_refresh_token_strict; see §4.10). - NEW: load
useranduser.accountbysubfrom the decoded payload. Needed before any legacy-token handling because the grandfather path needs to read the account's current policy. If the user is missing or inactive, return 401 withdetail="invalid_refresh_token"(existing behavior, unchanged). - NEW (grandfather path): if
auth_timeis missing from the payload (legacy token issued before this PR), treat it asnow()and snapshot the loaded account's current policy viaresolve_session_policy(account)intoidle_max/abs_max. One free rotation under the new policy. - NEW: compute
absolute_deadline = auth_time + abs_max(both in Unix seconds). Compare withnow >= absolute_deadline, not>— a token whose deadline equalsnow()is expired, not valid. - Atomically revoke the JTI regardless of outcome (single UPDATE, same statement as today). This consumes the token whether or not the absolute check passes — so an absolute-expired token cannot be replayed forever; a second attempt finds the row already
revoked_at IS NOT NULLand falls through to the existing "invalid or revoked refresh token" 401. - If the atomic UPDATE matched zero rows (already revoked): 401 with
detail="invalid_refresh_token". - If
now >= absolute_deadline: 401 withdetail="session_expired_absolute". (The row is already revoked from step 5.) - Otherwise mint new tokens, carrying forward
auth_time,idle_max,abs_maxunchanged from the old token (or freshly snapshotted if grandfathered in step 3).
Helper contract: _refresh_session_tokens(payload, user, account, db) -> Token. Takes the validated decoded payload plus the already-loaded user/account so it doesn't re-query. Returns the same Token shape as _mint_session_tokens (with the two new ISO expiry fields). Distinct from _mint_session_tokens because the refresh path carries claims forward instead of resolving policy.
Idle expiry is handled earlier in the chain: get_refresh_token_payload calls decode_token, which returns None for any JWT past exp — that's the existing 401 path. See §4.10 for distinguishing idle expiry from generic invalid-token errors in the response.
4.6 Login endpoints
Token-issuing endpoints that need the snapshot logic (verified against the codebase):
| Endpoint | File:line | Response model |
|---|---|---|
POST /auth/login (form-encoded, OAuth2PasswordRequestForm) |
backend/app/api/endpoints/auth.py:303 |
Token |
POST /auth/login/json (JSON body — what the frontend actually calls) |
backend/app/api/endpoints/auth.py:342 |
Token |
POST /auth/google/callback |
backend/app/api/endpoints/oauth.py:174 |
OAuthCallbackResponse |
POST /auth/microsoft/callback |
backend/app/api/endpoints/oauth.py:204 |
OAuthCallbackResponse |
POST /auth/refresh |
backend/app/api/endpoints/auth.py:377 |
Token |
POST /auth/register (auth.py:92) returns UserResponse and does not auto-login — the frontend follows up with a separate call to /auth/login/json. No token-minting changes needed in /register itself; the subsequent /login/json call will pick up the new claims naturally.
Each of the four token-issuing endpoints (login, login/json, both OAuth callbacks) calls create_refresh_token with the extra claims. Wrap in a helper _mint_session_tokens(user, account, db) -> Token (or OAuthCallbackResponse — see §4.10 on shared response fields) to avoid drift across four sites. /auth/refresh uses a variant that carries forward existing claims instead of re-snapshotting policy.
4.7 Account security endpoint
New endpoint module: backend/app/api/endpoints/account_security.py
GET /accounts/me/security → returns {idle_minutes, absolute_minutes, effective_idle_minutes, effective_absolute_minutes, system_min/max bounds}
PATCH /accounts/me/security → owner only; validates bounds + invariant; writes account row
require_account_owner from app/api/deps.py:189 enforces ownership. Returns the effective values (after defaults applied) so the frontend doesn't have to know about NULL semantics.
4.8 Frontend changes
Response-field naming (single scheme, used everywhere):
Both Token (/auth/login, /auth/login/json, /auth/refresh) and OAuthCallbackResponse (/auth/google/callback, /auth/microsoft/callback) gain two new fields:
| Field | Type | Source |
|---|---|---|
idle_expires_at |
ISO 8601 UTC string | derived from refresh JWT exp |
absolute_expires_at |
ISO 8601 UTC string | derived from refresh JWT auth_time + abs_max |
ISO strings (not Unix ints) for consistency with the rest of the API surface, which uses DateTime(timezone=True) everywhere. Frontend parses with new Date(...).
New hook: frontend/src/hooks/useAuthSessionExpiry.ts
- Reads
idleExpiresAtandabsoluteExpiresAtfromauthStore. - Returns
{ idleExpiresAt, absoluteExpiresAt, warning, reason }wherewarning ∈ {"none", "soon", "now"}andreason ∈ {"idle", "absolute"}indicating which window is closer. - "soon" fires at T-5min on whichever window comes first.
- Pairs with a top-of-app
<SessionExpiryToast />mounted inAppLayout.tsx.
Modified: frontend/src/api/client.ts interceptor
- On 401 with
detail="session_expired_absolute"ordetail="session_expired_idle": skip the refresh attempt, flush tokens, redirect to/login?reason=session_expired. (Both surfaces go through the same banner — users don't need to distinguish the two.) - On 401 with
detail="invalid_refresh_token"or any other detail: current behavior (drop to/loginwithout the reason banner). - Existing access-token-expired flow (transparent
/auth/refresh) unchanged.
Modified: frontend/src/store/authStore.ts
setTokens(token: Token)(authStore.ts:140) is the single token-persistence path used by bothlogin()and the OAuth flow. Extend theTokentype withidle_expires_at+absolute_expires_at;setTokenswrites them to store + localStorage alongside the access/refresh tokens. No new action.- The Axios refresh interceptor (
api/client.ts:139) destructuresaccess_token, refresh_tokentoday — extend to read the two new fields and callsetTokensso refreshed sessions update their expiry metadata. - Legacy-state migration: on store rehydrate, if tokens exist but
idle_expires_at/absolute_expires_atare missing from localStorage, leave themnulland let the next/auth/refreshpopulate them via response fields. The hook treatsnullas "unknown — don't warn yet." No forced logout for pre-deploy localStorage.
Modified: frontend/src/pages/OAuthCallbackPage.tsx
- The
setTokens({...})call atOAuthCallbackPage.tsx:102currently passes{access_token, refresh_token, token_type}from theOAuthCallbackResponse. Addidle_expires_atandabsolute_expires_atto the spread so OAuth-issued sessions get the same expiry metadata as password logins.
New page: frontend/src/pages/account/AccountSecuritySettingsPage.tsx
- Lives under existing
/accountrouting withrequireRoleOwnerstyle guard. - Two preset tiers — Strict (3d/14d) and Standard (7d/30d) — plus a Custom tier with two numeric inputs (idle/absolute in days).
- Hint copy showing the system min/max from the GET response.
- Save → PATCH → toast.
- Below the form, an info line: "Policy changes apply to new logins. Existing sessions continue under the policy in effect at their login time. To force-logout existing sessions, use the actions below."
- A separate "Active sessions" section with two actions (see §4.11):
- Sign out everyone except me (secondary button) — revokes other users' sessions in this account, leaves the caller signed in.
- Sign out everyone, including me (destructive-style button) — revokes all sessions for the account; the caller is immediately redirected to
/login. Confirmation modal required.
Modified: AccountSettingsPage.tsx
- Add a "Session Security" link card to the existing grid (owner-only visibility).
New login page banner: when ?reason=session_expired is present, show a calm info banner: "Your session ended for security. Please sign in again." (No alarm UI, just clarity. Same banner for both idle and absolute expiry; the user doesn't need to learn the distinction.)
4.9 Migration
alembic revision -m "add session policy columns to accounts" (manual, per Lesson 77).
ALTER TABLE accounts
ADD COLUMN session_idle_minutes INTEGER,
ADD COLUMN session_absolute_minutes INTEGER,
ADD CONSTRAINT session_idle_le_absolute_when_both_set
CHECK (session_idle_minutes IS NULL
OR session_absolute_minutes IS NULL
OR session_idle_minutes <= session_absolute_minutes);
COMMENT ON CONSTRAINT session_idle_le_absolute_when_both_set ON accounts IS
'Defense in depth: catches idle > absolute when both are overridden. '
'The partial-override case (one NULL, one set) is validated at the app layer '
'against current system defaults, since the DB cannot see Settings.';
No backfill: NULL is the intended state for "use system default."
Confirm: accounts is in the global-tables list per PROJECT_CONTEXT.md, so the migration does not add RLS predicates. Verified — accounts is explicitly named there.
4.10 Error-detail taxonomy
/auth/refresh returns 401 with one of these detail values, so the frontend can distinguish UX paths:
detail |
When | Frontend action |
|---|---|---|
session_expired_idle |
refresh JWT past exp (idle window elapsed) |
flush tokens, redirect /login?reason=session_expired |
session_expired_absolute |
refresh JWT alive, but now >= auth_time + abs_max |
flush tokens, redirect /login?reason=session_expired |
invalid_refresh_token |
JTI not in DB, already revoked, signature bad, type mismatch | flush tokens, redirect /login (no banner) |
Implementation note: decode_token currently swallows JWTError and returns None, so idle expiry is indistinguishable from a signature failure at the dep level. Fix by switching get_refresh_token_payload (or adding a sibling) to call jwt.decode directly and catch ExpiredSignatureError separately from generic JWTError. Idle-expired tokens raise the former; map that to session_expired_idle. All other JWT errors map to invalid_refresh_token.
4.11 Bulk session revocation (kill-all-sessions)
Endpoint: POST /accounts/me/security/revoke-sessions, owner-only via require_account_owner.
Request body:
{ "scope": "all" | "others" }
Default "all" if body omitted. "others" excludes the calling user's own refresh tokens (so the owner stays signed in); "all" includes them.
Response:
{ "revoked_count": <int> }
Behavior:
- Single SQL UPDATE:
refresh_tokens.revoked_at = now()for rows whereuser_id IN (SELECT id FROM users WHERE account_id = :caller_account_id)ANDrevoked_at IS NULL. Ifscope="others", also ANDuser_id != caller.id. - All affected users' next
/auth/refreshmatches zero rows in the atomic revoke (§4.5 step 5) → 401invalid_refresh_token→ redirect to/login(no banner — the user was signed out by an admin, not by expiry; the plain/loginredirect is honest UX). - Caller's access token is not revoked (we don't track access JTIs by design); it dies naturally on its 5-minute timer. For
scope="all", the frontend handles UX by clearing localStorage and redirecting to/loginafter the response — so the stale access token simply isn't used. Accept the 5-minute window where the caller's access token could in theory still hit endpoints; this matches the existing logout flow and is consistent with the threat model (the action is "kick everyone out," not "instantly invalidate every credential").
Audit: writes one account.sessions_revoked_bulk event with {actor_user_id, account_id, scope, revoked_count}.
Out of scope: distinguishing session_revoked_by_admin from invalid_refresh_token on the wire for affected users. Doing so requires tracking the revocation reason per refresh_tokens row (new column). Not worth the complexity right now — the affected user just sees they're logged out, same as if they'd been logged out for any other reason. Revisit if pilots ask for it.
Why not also per-user-device revoke? Refresh tokens today don't carry device/user-agent metadata; the unit of granularity is "all of user X's active sessions" (which is most of what people want anyway — e.g., I lost my laptop). The endpoint is account-scoped because that's the owner-control story we're shipping. Per-user device list is a follow-up if/when needed (§9).
5. Backward compatibility
5.1 Existing refresh tokens (no auth_time claim)
On first /auth/refresh after deploy:
- Backend detects missing
auth_time, treats current time asauth_time, snapshots current account policy. - User effectively gets one free 14-day absolute window starting at first post-deploy refresh.
Trade-off vs forcing universal re-login on deploy:
- ✅ Zero deploy-day support burden (no pilots flood Slack with "I got logged out").
- ❌ Users with active sessions see no enforcement for up to 14 days.
Given the user base is small (pilot phase) and the bigger goal is new signups have a secure default, the friendly path wins.
5.2 If we ever need to invalidate everyone
SECRET_KEY rotation kills all existing tokens. Documented in DEV-ENV.md but not part of this PR.
6. Test plan
Backend (backend/tests/test_session_policy.py — new file, unless noted):
- Default policy applied — login without account override → JWT has
idle_max=259200,abs_max=1209600(seconds; 3d/14d). Account/settings columns are minutes (4320/20160); the helper multiplies by 60 when stamping. - Account override honored — owner PATCHes
session_idle_minutes=60,session_absolute_minutes=240→ next login JWT hasidle_max=3600,abs_max=14400(seconds). - Override bounds enforced — PATCH idle below
SESSION_IDLE_MINUTES_MIN→ 422; PATCH absolute aboveSESSION_ABSOLUTE_MINUTES_MAX→ 422. - Invariant enforced (both-set) — PATCH idle=300, absolute=120 → 422.
- Invariant enforced (partial override) — system default absolute=20160; PATCH idle=43200 with absolute=NULL → 422 (effective idle > effective absolute, app-layer check).
- DB constraint catches both-set inversion — direct SQL
UPDATE accounts SET session_idle_minutes=300, session_absolute_minutes=120rolls back withCheckViolation. - Non-owner cannot PATCH — engineer/viewer get 403.
- Refresh respects absolute cap (boundary) — set
auth_time = now - abs_maxexactly → refresh 401 withsession_expired_absolute(deadline check is>=, not>). - Absolute-expired token is consumed — attempt #1 returns
session_expired_absolute; attempt #2 with the same token returnsinvalid_refresh_token(row was revoked atomically in #1, cannot be replayed). - Refresh extends idle but not absolute — rotate twice within
abs_max; both succeed;auth_timeunchanged across rotations. - Idle expiry (boundary) — set refresh
exp = now→ 401 withsession_expired_idle(not genericinvalid_refresh_token). - Grandfather path — legacy refresh token without
auth_time/idle_max/abs_max→ one successful rotation; new JWT has all three claims,auth_time≈now(). - Tightening after login doesn't affect existing sessions — login under policy A, owner tightens to policy B, refresh succeeds under A's snapshot.
/auth/login/jsoncarries new claims and response fields — JWT decode showsauth_time/idle_max/abs_max; response body hasidle_expires_at+absolute_expires_atas ISO strings.- OAuth callback responses include expiry fields —
/auth/google/callbackand/auth/microsoft/callbackOAuthCallbackResponsebodies have bothidle_expires_atandabsolute_expires_at. Mock the Google/Microsoft token-exchange step; assert on the final response shape. - Policy update writes audit row — PATCH
/accounts/me/securityemits oneaccount.session_policy_updateaudit event withactor_user_id,account_id, and a payload of{old: {...}, new: {...}, effective_old: {...}, effective_new: {...}}. Verify via the existing audit-log query incore/audit.py. - Bulk revoke scope=all — seed three active refresh tokens for two users in the account (caller + one other). POST
/accounts/me/security/revoke-sessionswith{"scope": "all"}→revoked_count=3; caller's own refresh token is now revoked too. Their next/auth/refresh→ 401invalid_refresh_token. - Bulk revoke scope=others — same seed. POST with
{"scope": "others"}→revoked_count=2(caller's token survives). Caller's/auth/refreshstill succeeds; the other user's/auth/refresh→ 401invalid_refresh_token. - Bulk revoke is account-scoped — seed tokens for users in account A and account B. Owner of A POSTs revoke →
revoked_countreflects only A's tokens; B's tokens remain active. - Bulk revoke is owner-only — engineer/viewer POST → 403; super_admin POST against
/meworks only if they own an account (the endpoint is/me, not/{account_id}). - Bulk revoke writes audit row —
account.sessions_revoked_bulkwith{actor_user_id, account_id, scope, revoked_count}. - Bulk revoke is idempotent — second immediate POST returns
revoked_count=0(no already-revoked rows are double-stamped).
Frontend (frontend/src/__tests__/ or colocated *.test.tsx):
useAuthSessionExpiryreturns"soon"within 5min of whichever ofidleExpiresAt/absoluteExpiresAtcomes first;reasonfield indicates which.- Axios interceptor on 401 with
session_expired_absoluteredirects to/login?reason=session_expiredinstead of attempting refresh. - Axios interceptor on 401 with
session_expired_idledoes the same. - Axios interceptor on 401 with
invalid_refresh_tokenredirects to/loginwithout the reason banner. authStorerehydrate handles legacy localStorage shape (noidleExpiresAt/absoluteExpiresAt) without throwing or forced logout; hook treatsnullas "no warning."
Manual:
- Log in as
owner@, set Custom (idle=60 min, absolute=240 min) under Account → Session Security, log out, log in asengineer@(same account), decode the refresh JWT in localStorage, confirmidle_max=3600andabs_max=14400(seconds — the configured minutes × 60). - Confirm the existing
useSessionTimer(troubleshooting-flow timer) is unaffected by the new hook. - Pre-deploy localStorage path: install build, log in to capture token, deploy session-policy build, refresh page — confirm no forced logout and that the next
/auth/refreshpopulates the new fields.
7. Rollout
- Land migration + backend changes behind no flag (the absolute cap is the whole point — flagging it defeats the purpose).
- Default policy is Strict (3d/14d) for new accounts. Existing pilot accounts get NULL → defaults; user can manually loosen any pilot account via the new endpoint or direct SQL if friction emerges.
- After deploy, watch Sentry for spikes in
session_expired_absolute401s (expected: tiny — only legacy tokens approaching 14-day mark hit this) and unexpected refresh failures. - Announce in pilot Slack: "We added session expiration. You'll be asked to log in again every 2 weeks max. Account owners can adjust under Account → Session Security."
8. Files touched
Backend
backend/app/core/config.py— newSESSION_*settings (defaults + min/max bounds).backend/app/core/security.py—create_refresh_tokensignature change (acceptsauth_time/idle_max/abs_max),resolve_session_policy(account)helper,decode_refresh_token_strict()that distinguishesExpiredSignatureErrorfrom genericJWTError.backend/app/api/deps.py— updateget_refresh_token_payloadto surface idle-expiry assession_expired_idleinstead of collapsing into a generic 401.backend/app/api/endpoints/auth.py— refresh-endpoint logic (atomic-revoke-then-check-absolute),_mint_session_tokens(user, account, db) -> Tokenhelper, login + login/json call sites.backend/app/api/endpoints/oauth.py— both callbacks call_mint_session_tokens;OAuthCallbackResponsegains the two new fields.backend/app/schemas/token.py—Token(token.py:5) addsidle_expires_at+absolute_expires_at(ISO strings).backend/app/schemas/oauth.py—OAuthCallbackResponseadds the same two fields.backend/app/api/endpoints/account_security.py— NEW (~130 lines: GET/PATCH for policy + POST/revoke-sessions, audit logging for both mutations).backend/app/api/router.py— register new router.backend/app/models/account.py— two new columns + DB CHECK constraint.backend/app/schemas/account_security.py— NEW (request/response: policy GET/PATCH with effective + bounds;RevokeSessionsRequest+RevokeSessionsResponse).backend/app/core/audit.py— addaccount.session_policy_updateevent type (or use the existing generic emitter if it accepts free-form types — verify during impl).backend/alembic/versions/<hash>_session_policy_columns.py— NEW (manual; per Lesson 77, never--rev-id).backend/tests/test_session_policy.py— NEW.
Frontend
frontend/src/api/client.ts— interceptor branches on bothsession_expired_idleandsession_expired_absolute(same redirect target/login?reason=session_expired); also propagates new expiry fields from successful/auth/refreshresponses intosetTokens.frontend/src/api/auth.ts—Tokentype adds the two new ISO fields.frontend/src/store/authStore.ts—setTokenspersists the new expiry fields (no new action).frontend/src/pages/OAuthCallbackPage.tsx— passidle_expires_at+absolute_expires_atthroughsetTokens({...})at line 102.frontend/src/hooks/useAuthSessionExpiry.ts— NEW.frontend/src/components/common/SessionExpiryToast.tsx— NEW.frontend/src/components/layout/AppLayout.tsx— mount toast.frontend/src/pages/account/AccountSecuritySettingsPage.tsx— NEW (policy form + Active Sessions section with two revoke buttons + confirmation modal).frontend/src/pages/AccountSettingsPage.tsx— add link card.frontend/src/router.tsx— register route.frontend/src/pages/LoginPage.tsx—?reason=session_expiredbanner.
Docs
.ai/DECISIONS.md— entry for the 3d/14d default + per-account-override architecture.CURRENT-STATE.md— add session policy to "auth surface" summary.
Approx ~600 LoC across backend + frontend, plus tests.
9. Resolved decisions & follow-ups
Decisions baked into this plan (not open questions):
- Audit logging is required. PATCH
/accounts/me/securitywrites oneaccount.session_policy_updateaudit event; POST/revoke-sessionswritesaccount.sessions_revoked_bulk. Security-relevant by definition. Covered in §6 tests #16 and #21 and §8 backend file list. - Presets are Strict and Standard only, plus Custom. No "Loose" preset; owners who want a loose policy can use Custom and own the choice explicitly.
- Tightening policy mid-session does NOT force-logout existing sessions — but owners can force it via the bulk-revoke endpoint in §4.11. Existing sessions continue under the policy snapshot they were issued under unless explicitly revoked. The Account Security page surfaces this in copy (§4.8).
- Bulk revoke is account-scoped, two-mode (
all/others). Per-user device lists are out of scope (§4.11).
Follow-up issues to file after this plan is approved (not blocking this PR):
- Super-admin global lock with UI — today, env-var ceilings cover this. File an issue to expose
SESSION_*_MAXas a sysadmin-editable setting if/when a customer asks. - Per-user device list + per-device revoke — refresh tokens would gain
user_agent+ip+last_used_atcolumns; a new "Active devices" page would let users self-revoke individual sessions. File only if a real ask arrives. The account-wide bulk revoke covers the breach-response use case in the meantime. - Per-user (not per-account) policy — out of scope. File only if a real ask arrives.
10. Sequence of commits
feat(auth): add session policy settings + account columns + migration(settings + model + migration + DB CHECK; no behavior change yet).feat(auth): distinguish idle expiry from invalid refresh tokens(decode_refresh_token_strict,session_expired_idledetail, test #11). Lands the error-detail taxonomy from §4.10 before anything depends on it.feat(auth): embed auth_time/idle_max/abs_max in refresh tokens(security.py+_mint_session_tokenshelper called from/auth/login,/auth/login/json, both OAuth callbacks;TokenandOAuthCallbackResponsegainidle_expires_at+absolute_expires_at). Refresh still doesn't enforce absolute cap yet.feat(auth): enforce absolute session cap in /auth/refresh(atomic-revoke-then-check,session_expired_absolutedetail, grandfather logic, tests #8–#13).feat(api): add GET/PATCH /accounts/me/security endpoint(router, schemas, owner gate, bounds + partial-override invariant validation, audit logging on PATCH).feat(api): add POST /accounts/me/security/revoke-sessions(bulk-revoke endpoint withscope=all|others, single-UPDATE implementation, audit logging, tests #17–#22).feat(ui): handle session_expired_{idle,absolute} in axios interceptor + authStore(new fields persisted, legacy-state migration, redirect to/login?reason=session_expired).feat(ui): add AccountSecuritySettingsPage + AppLayout toast + login banner(Strict/Standard/Custom presets, Active Sessions section with two revoke buttons + confirmation modal,useAuthSessionExpiry, expiry-soon toast,?reason=session_expiredbanner).docs: add decision entry + update CURRENT-STATE auth surface(.ai/DECISIONS.md,CURRENT-STATE.md).
Each commit independently passes pytest --override-ini="addopts=" and npm run build. The two backend behavior gates (#2 and #4) ship behind no flag — they're the point of the work — but they're sequenced so any rollback is a single commit.
Review checklist before implementation:
- Defaults confirmed: 3d idle / 14d absolute.
- Per-account override approved.
- Grandfather strategy (one free rotation) approved vs hard cutover.
- Error-detail taxonomy approved (idle vs absolute distinct on the wire; same UX in the frontend).
- Audit logging is a requirement, not optional.
- Loose preset dropped; Strict / Standard / Custom only.
- ISO timestamps (not Unix ints) for
idle_expires_at/absolute_expires_ateverywhere. - DB CHECK constraint scope documented; partial-override case validated app-side.
- System bounds in §4.3 acceptable as specified (15min floor, 30d idle ceiling, 90d absolute ceiling).
- Final approval on commit sequence in §10.
- No conflict with Phase O cutover sequencing (this can ship before OR after EIN/Stripe lands; independent path).
- File the kill-all-sessions follow-up issue per §9 before implementation begins, so the Account Security page can link to it (or leave the support-contact copy in place).