feat(auth): session expiration policy (3d idle / 14d absolute) + per-account override + bulk revoke #168

Merged
chihlasm merged 13 commits from feat/session-expiration-policy into main 2026-05-14 04:33:50 +00:00

13 Commits

Author SHA1 Message Date
e5b26245ca docs: add architecture reports, public-landing routing plan, build-a-page tutorial, self-serve signup phase-2 design
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m45s
CI / e2e (pull_request) Successful in 10m13s
CI / backend (pull_request) Successful in 11m27s
- docs/architecture/: god-node map + report (2026-05-06), workflows.json/html + analysis snapshot
- docs/plans/2026-05-13-public-landing-routing-refactor.md
- docs/tutorials/build-a-page.md
- abc-feat-self-serve-signup-phase-2-design-20260507-112020.md (root)

Core dumps (core.144926, core.145678, docs/architecture/core.1392564) and
agent .remember/ state are intentionally left untracked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:59:29 -04:00
dc88797469 feat(welcome): two-button PSA CTA in step-2 — Connect now / Connect later
Picking a real PSA in /welcome/step-2 now swaps the primary action from a
single "Continue" + a tiny "Connect now →" link into an explicit choice:
"Connect <PSA> now" (saves primary_psa and routes to /account/integrations)
or "Connect later" (saves primary_psa and continues to step 3). The old
link never actually persisted primary_psa before navigating — that's now
fixed. "No PSA yet" and no-selection states keep the original single
Continue button. Skip-this-step and Skip-the-rest are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:59:18 -04:00
cbb4b25671 fix(ui): drop setState-in-effect in useAuthSessionExpiry
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m11s
CI / backend (pull_request) Successful in 10m43s
CI surfaced react-hooks/set-state-in-effect on the synchronous
setState(computeState(token)) inside the useEffect body. The earlier
shape mirrored token -> state via an effect, which is exactly the
"you might not need an effect" pattern React 19's eslint rule now
flags.

Switch to derived state: compute during render, use a useReducer
tick to force re-render on the 30s cadence (so relative timestamps
stay current even when token props don't change). Same observable
behavior, no cascading renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 20:15:11 -04:00
8d79dd93b8 feat(dashboard): focus same-page Start Session input from NextStep CTA and checklist
Some checks failed
Mirror to GitHub / mirror (push) Successful in 6s
CI / frontend (pull_request) Failing after 1m26s
CI / e2e (pull_request) Successful in 10m3s
CI / backend (pull_request) Successful in 10m10s
The "Start a session" CTAs on the NextStepCard and SetupChecklist used to
Link-navigate, which left the user on the same page (the Start Session
input lives on the dashboard) without any visible response. Replace those
CTAs with a custom window-event dispatch (FOCUS_START_SESSION_EVENT) that
the StartSessionInput listens for: scroll the input into view, focus the
textarea, and pulse a ring for 900ms so the click feels intentional. The
NextStepCard also locally hides itself after firing so the user isn't
double-prompted while typing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 17:56:30 -04:00
1106f79611 docs: add session-expiration-policy decision entry + CURRENT-STATE summary
Ninth and final commit in the session-expiration-policy series.

- .ai/DECISIONS.md: new entry documenting the two-window model
  (3d idle / 14d absolute defaults), per-account override design,
  grandfather strategy, error-detail taxonomy on the wire, and the
  rejected alternatives (idle-only / absolute-only / hard SECRET_KEY
  cutover / Loose preset / reveal-on-Custom UI / modal-stays-open
  for scope=all). Includes consequences and follow-up tickets.
- CURRENT-STATE.md: 'Recently shipped' entry summarizing the 8-commit
  series across backend (migration, claims, enforcement, two
  endpoints) and frontend (page, hook, toast, banner, modal),
  referencing the plan + design-review file.

Pending after this commit: open PR, merge, file the per-user
device-list + super-admin global-ceiling follow-up issues per plan §9.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 17:09:09 -04:00
c7cd711859 feat: AccountSecuritySettingsPage + active-users list + toast + login banner
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>
2026-05-13 17:07:14 -04:00
aad554bb9c feat(ui): handle session_expired_{idle,absolute} in axios interceptor
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>
2026-05-13 16:33:56 -04:00
cabd745a2b feat(api): add POST /accounts/me/security/revoke-sessions
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>
2026-05-13 16:31:10 -04:00
8cfaef6a9d feat(api): add GET/PATCH /accounts/me/security endpoint
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>
2026-05-13 16:28:51 -04:00
b21d2fc234 feat(auth): enforce absolute session cap in /auth/refresh
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>
2026-05-13 16:26:00 -04:00
d6a02ee8da feat(auth): embed auth_time/idle_max/abs_max in refresh tokens at every login
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>
2026-05-13 16:22:53 -04:00
2375948b7a feat(auth): distinguish idle expiry from invalid refresh tokens
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>
2026-05-13 16:11:01 -04:00
92fa3bc6ab feat(auth): add session policy settings + account columns + migration
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>
2026-05-13 15:52:21 -04:00