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>
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>
Fifth commit in the session-expiration-policy series. Surfaces the
session-policy override controls to account owners.
- schemas/account_security.py: NEW. SessionPolicyResponse returns both
the override (Optional[int]) and the effective value (always present)
plus the system min/max bounds, so the frontend can render the
Custom-preset form without re-implementing the defaults logic.
SessionPolicyUpdateRequest accepts NULL to clear an override.
- endpoints/account_security.py: NEW. GET and PATCH on /me/security.
Owner-only via require_account_owner. PATCH validates per-field
bounds, then validates the effective idle <= absolute invariant
(catching the partial-override case the DB CHECK can't see), then
writes the row + an account.session_policy_update audit event with
old/new/effective_old/effective_new payload.
- router.py: registers the new router under _tenant_deps next to
accounts.router.
Tests added in test_session_policy.py (8 cases):
- GET returns NULL overrides + Strict defaults + system bounds.
- PATCH persists override; next login JWT reflects new values
(60min/240min -> idle_max=3600, abs_max=14400 seconds).
- PATCH rejects idle < min (422).
- PATCH rejects absolute > max (422).
- PATCH rejects idle > absolute when both are set (422).
- PATCH rejects partial override that produces effective idle >
effective absolute (idle=43200, absolute=NULL with default 20160).
- Engineer-role user gets 403.
- PATCH writes exactly one audit row with the expected payload shape.
16/16 in test_session_policy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fourth commit in the session-expiration-policy series. The gate that
ends "logged in forever" — refresh now rejects tokens whose original
login (auth_time) is older than abs_max seconds.
Algorithm (plan §4.5):
1. Decode JWT (dep already handles idle expiry).
2. Load user; reject inactive/missing as invalid_refresh_token.
3. Resolve effective auth_time/idle_max/abs_max, grandfathering
pre-PR tokens by snapshotting current account policy.
4. Atomically revoke the JTI regardless of outcome — this consumes
the token whether or not the absolute check passes, so an
absolute-expired token cannot be replayed forever.
5. If the atomic UPDATE matched zero rows -> invalid_refresh_token.
6. If now >= auth_time + abs_max -> commit the revoke explicitly
(so it survives the rollback hook in get_admin_db) and 401
session_expired_absolute.
7. Otherwise mint via _mint_with_claims, carrying claims forward.
Boundary check uses `>=`, not `>` — a deadline equal to now is
expired. _refresh_session_tokens (commit 3) replaced by two narrower
helpers: _resolve_refresh_claims (grandfather logic, no mint) and
_mint_with_claims (mint with explicit claims, no grandfather). Makes
the endpoint's algorithm read top-down without indirection.
Tests added in test_session_policy.py:
- #8: backdate auth_time by exactly abs_max -> session_expired_absolute
at the deadline boundary.
- #9: same token tried twice; first returns session_expired_absolute
AND consumes the row; second returns invalid_refresh_token.
- #12: legacy token without auth_time/idle_max/abs_max gets one
successful rotation; new JWT carries fresh policy snapshot from
the account (3d/14d defaults under Strict).
25/25 across test_session_policy + test_auth + test_oauth_callbacks.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Third commit in the session-expiration-policy series. Every refresh token
issued from now on carries the policy snapshot in its JWT (in seconds,
for direct Unix math), and every login/OAuth response surfaces both
expiry windows as ISO timestamps. /auth/refresh carries the claims
forward unchanged — including auth_time, which never resets on rotation.
Does NOT yet enforce the absolute cap — that's commit 4, sequenced so
the gate can be reverted independently if pilots hit an edge case.
But the wire is fully populated, and a grandfather path is already in
_refresh_session_tokens for tokens issued before this PR.
Key changes:
- core/security.py: create_refresh_token signature changes to
(user_id, *, auth_time, idle_max_seconds, abs_max_seconds). Adds
resolve_session_policy(account) -> (idle_minutes, absolute_minutes)
applying defaults for NULL overrides.
- schemas/token.py + schemas/oauth.py: Token and OAuthCallbackResponse
gain idle_expires_at + absolute_expires_at (Optional[datetime],
Pydantic emits ISO 8601 UTC strings).
- endpoints/auth.py: new _mint_session_tokens(user, db) and
_refresh_session_tokens(payload, user, db) helpers. /auth/login,
/auth/login/json, and /auth/refresh now route through them. The
refresh endpoint's pre-existing "Refresh token has been revoked"
error normalized to the taxonomy detail "invalid_refresh_token".
- endpoints/oauth.py: both Google and Microsoft callbacks call
_mint_session_tokens; OAuthCallbackResponse carries the expiry
fields through.
- tests: two new cases in test_session_policy.py — login_json embeds
the claims with strict defaults (3d/14d -> 259200/1209600 sec) and
surfaces matching ISO expiry fields; refresh carries auth_time,
idle_max, abs_max forward unchanged across rotation.
35/35 across test_session_policy + test_auth + test_oauth_callbacks +
test_account_invite_lookup + test_account_management.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Second commit in the session-expiration-policy series. Lands the
error-detail taxonomy from §4.10 of the plan; no UI-visible change yet
because the frontend interceptor (commit 7) doesn't read the new detail
strings, but the wire is now ready for it.
Today every /auth/refresh failure returns 401 "Invalid refresh token"
regardless of cause, so the frontend has no way to distinguish "your
session ended for security" from "we don't recognize this token at
all." This commit introduces:
- decode_refresh_token_strict(): wraps jose.jwt.decode and raises a new
IdleTokenExpired exception (from ExpiredSignatureError) so callers
can branch on idle expiry. All other jose failures still propagate
as JWTError. The legacy decode_token() is preserved for access-token,
password-reset, and email-verification paths that don't need the
distinction.
- get_refresh_token_payload(): now maps IdleTokenExpired ->
"session_expired_idle", JWTError and wrong-type tokens ->
"invalid_refresh_token".
- test_session_policy.py: new test file (will accumulate cases across
the series). Three tests for the taxonomy: idle-expired returns
session_expired_idle; wrong type returns invalid_refresh_token; bad
signature returns invalid_refresh_token.
20/20 across test_session_policy + test_auth + test_oauth_callbacks.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>