From 92fa3bc6ab54fd3dcfe1b181a4d4387427973428 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 15:52:21 -0400 Subject: [PATCH 01/13] feat(auth): add session policy settings + account columns + migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ..._add_session_policy_columns_to_accounts.py | 72 +++ backend/app/core/config.py | 13 + backend/app/models/account.py | 6 + .../2026-05-13-session-expiration-policy.md | 435 ++++++++++++++++++ 4 files changed, 526 insertions(+) create mode 100644 backend/alembic/versions/b269a1add160_add_session_policy_columns_to_accounts.py create mode 100644 docs/plans/2026-05-13-session-expiration-policy.md diff --git a/backend/alembic/versions/b269a1add160_add_session_policy_columns_to_accounts.py b/backend/alembic/versions/b269a1add160_add_session_policy_columns_to_accounts.py new file mode 100644 index 00000000..366baecd --- /dev/null +++ b/backend/alembic/versions/b269a1add160_add_session_policy_columns_to_accounts.py @@ -0,0 +1,72 @@ +"""add_session_policy_columns_to_accounts + +Revision ID: b269a1add160 +Revises: 4ce3e594cb87 +Create Date: 2026-05-13 19:50:51.343777 + +Adds per-account session-policy overrides. NULL on either column means +"use the system default from Settings.SESSION_*_MINUTES_DEFAULT." The +CHECK constraint is defense-in-depth for the both-set case; the partial- +override case (one NULL, one set) is validated at the app layer because +the DB cannot see Settings. + +See docs/plans/2026-05-13-session-expiration-policy.md for full design. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'b269a1add160' +down_revision: Union[str, None] = '4ce3e594cb87' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'accounts', + sa.Column( + 'session_idle_minutes', + sa.Integer(), + nullable=True, + comment=( + 'Account override for idle session window in minutes. ' + 'NULL = use Settings.SESSION_IDLE_MINUTES_DEFAULT.' + ), + ), + ) + op.add_column( + 'accounts', + sa.Column( + 'session_absolute_minutes', + sa.Integer(), + nullable=True, + comment=( + 'Account override for absolute session lifetime in minutes. ' + 'NULL = use Settings.SESSION_ABSOLUTE_MINUTES_DEFAULT.' + ), + ), + ) + op.create_check_constraint( + 'session_idle_le_absolute_when_both_set', + 'accounts', + '(' + 'session_idle_minutes IS NULL ' + 'OR session_absolute_minutes IS NULL ' + 'OR session_idle_minutes <= session_absolute_minutes' + ')', + ) + op.execute( + "COMMENT ON CONSTRAINT session_idle_le_absolute_when_both_set ON accounts IS " + "'Defense in depth: catches idle > absolute when both are overridden. " + "Partial-override case (one NULL, one set) is validated at the app layer " + "against current system defaults, since the DB cannot see Settings.'" + ) + + +def downgrade() -> None: + op.drop_constraint('session_idle_le_absolute_when_both_set', 'accounts', type_='check') + op.drop_column('accounts', 'session_absolute_minutes') + op.drop_column('accounts', 'session_idle_minutes') diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9c5bd838..d1582265 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -69,6 +69,19 @@ class Settings(BaseSettings): ACCESS_TOKEN_EXPIRE_MINUTES: int = 5 REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + # Session policy — see docs/plans/2026-05-13-session-expiration-policy.md + # Refresh tokens enforce two windows: idle (between rotations) and absolute + # (from original login). Defaults can be overridden per-account, bounded by + # the MIN/MAX values below. Values are minutes everywhere except inside the + # refresh JWT, where idle_max/abs_max are stored as seconds for direct + # Unix-time math. + SESSION_IDLE_MINUTES_DEFAULT: int = 4320 # 3 days + SESSION_ABSOLUTE_MINUTES_DEFAULT: int = 20160 # 14 days + SESSION_IDLE_MINUTES_MIN: int = 15 + SESSION_IDLE_MINUTES_MAX: int = 43200 # 30 days + SESSION_ABSOLUTE_MINUTES_MIN: int = 60 # 1 hour + SESSION_ABSOLUTE_MINUTES_MAX: int = 129600 # 90 days + # Security BCRYPT_ROUNDS: int = 12 diff --git a/backend/app/models/account.py b/backend/app/models/account.py index aa2c5750..b036d20f 100644 --- a/backend/app/models/account.py +++ b/backend/app/models/account.py @@ -44,6 +44,12 @@ class Account(Base): Integer, nullable=True, default=100, server_default="100" ) + # Session policy override (NULL = use Settings.SESSION_*_MINUTES_DEFAULT). + # Validated at the app layer because the DB cannot see Settings; a DB + # CHECK constraint covers the both-set case only. + session_idle_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + session_absolute_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + # Custom branding (Task 9) branding_logo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) branding_primary_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True) # hex like #06b6d4 diff --git a/docs/plans/2026-05-13-session-expiration-policy.md b/docs/plans/2026-05-13-session-expiration-policy.md new file mode 100644 index 00000000..4431bd4e --- /dev/null +++ b/docs/plans/2026-05-13-session-expiration-policy.md @@ -0,0 +1,435 @@ +# 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/refresh` mints 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 + +1. **System-level absolute cap** — no session can exceed N days regardless of activity. +2. **Idle cap** — sessions inactive for N days must require re-login. +3. **Per-account owner override** — account owners can tighten or (within sysadmin-imposed ceilings) loosen the policy for their account. +4. **Graceful UX** — users get warned before forced re-login; rotation continues to be silent within the active window. +5. **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`): +```json +{ "sub": "", "type": "refresh", "jti": "", "exp": } +``` + +New refresh-token JWT: +```json +{ + "sub": "", + "type": "refresh", + "jti": "", + "exp": , // unchanged semantics, now = idle window + "auth_time": , // original login (Unix seconds); NOT reset on rotation + "idle_max": , // captured at login (account policy snapshot, seconds) + "abs_max": // 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 like `session_idle_minutes=43200, session_absolute_minutes=NULL` when 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 see `Settings`. 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 + +```python +# 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: +1. Decodes refresh JWT (via `get_refresh_token_payload` dep). +2. Atomically revokes old JTI (`UPDATE … SET revoked_at=now() WHERE token_hash=? AND revoked_at IS NULL RETURNING …`). +3. Mints new refresh + access tokens with same `sub`. + +New algorithm (precise): + +1. Decode refresh JWT (idle expiry already surfaced as `session_expired_idle` by `decode_refresh_token_strict`; see §4.10). +2. **NEW:** load `user` and `user.account` by `sub` from 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 with `detail="invalid_refresh_token"` (existing behavior, unchanged). +3. **NEW (grandfather path):** if `auth_time` is missing from the payload (legacy token issued before this PR), treat it as `now()` and snapshot the loaded account's current policy via `resolve_session_policy(account)` into `idle_max`/`abs_max`. One free rotation under the new policy. +4. **NEW:** compute `absolute_deadline = auth_time + abs_max` (both in Unix seconds). Compare with `now >= absolute_deadline`, not `>` — a token whose deadline equals `now()` is expired, not valid. +5. **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 NULL` and falls through to the existing "invalid or revoked refresh token" 401. +6. If the atomic UPDATE matched zero rows (already revoked): 401 with `detail="invalid_refresh_token"`. +7. If `now >= absolute_deadline`: 401 with `detail="session_expired_absolute"`. (The row is already revoked from step 5.) +8. Otherwise mint new tokens, **carrying forward `auth_time`, `idle_max`, `abs_max` unchanged** 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 `idleExpiresAt` and `absoluteExpiresAt` from `authStore`. +- Returns `{ idleExpiresAt, absoluteExpiresAt, warning, reason }` where `warning ∈ {"none", "soon", "now"}` and `reason ∈ {"idle", "absolute"}` indicating which window is closer. +- "soon" fires at T-5min on whichever window comes first. +- Pairs with a top-of-app `` mounted in `AppLayout.tsx`. + +**Modified:** `frontend/src/api/client.ts` interceptor +- On 401 with `detail="session_expired_absolute"` **or** `detail="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 `/login` without 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 both `login()` and the OAuth flow. Extend the `Token` type with `idle_expires_at` + `absolute_expires_at`; `setTokens` writes them to store + localStorage alongside the access/refresh tokens. No new action. +- The Axios refresh interceptor (`api/client.ts:139`) destructures `access_token, refresh_token` today — extend to read the two new fields and call `setTokens` so refreshed sessions update their expiry metadata. +- **Legacy-state migration:** on store rehydrate, if tokens exist but `idle_expires_at` / `absolute_expires_at` are missing from localStorage, leave them `null` and let the next `/auth/refresh` populate them via response fields. The hook treats `null` as "unknown — don't warn yet." No forced logout for pre-deploy localStorage. + +**Modified:** `frontend/src/pages/OAuthCallbackPage.tsx` +- The `setTokens({...})` call at `OAuthCallbackPage.tsx:102` currently passes `{access_token, refresh_token, token_type}` from the `OAuthCallbackResponse`. Add `idle_expires_at` and `absolute_expires_at` to 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 `/account` routing with `requireRoleOwner` style 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). + +```sql +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:** +```json +{ "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:** +```json +{ "revoked_count": } +``` + +**Behavior:** +- Single SQL UPDATE: `refresh_tokens.revoked_at = now()` for rows where `user_id IN (SELECT id FROM users WHERE account_id = :caller_account_id)` AND `revoked_at IS NULL`. If `scope="others"`, also AND `user_id != caller.id`. +- All affected users' next `/auth/refresh` matches zero rows in the atomic revoke (§4.5 step 5) → 401 `invalid_refresh_token` → redirect to `/login` (no banner — the user was signed out by an admin, not by expiry; the plain `/login` redirect 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 `/login` after 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 as `auth_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): + +1. **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. +2. **Account override honored** — owner PATCHes `session_idle_minutes=60`, `session_absolute_minutes=240` → next login JWT has `idle_max=3600`, `abs_max=14400` (seconds). +3. **Override bounds enforced** — PATCH idle below `SESSION_IDLE_MINUTES_MIN` → 422; PATCH absolute above `SESSION_ABSOLUTE_MINUTES_MAX` → 422. +4. **Invariant enforced (both-set)** — PATCH idle=300, absolute=120 → 422. +5. **Invariant enforced (partial override)** — system default absolute=20160; PATCH idle=43200 with absolute=NULL → 422 (effective idle > effective absolute, app-layer check). +6. **DB constraint catches both-set inversion** — direct SQL `UPDATE accounts SET session_idle_minutes=300, session_absolute_minutes=120` rolls back with `CheckViolation`. +7. **Non-owner cannot PATCH** — engineer/viewer get 403. +8. **Refresh respects absolute cap (boundary)** — set `auth_time = now - abs_max` exactly → refresh 401 with `session_expired_absolute` (deadline check is `>=`, not `>`). +9. **Absolute-expired token is consumed** — attempt #1 returns `session_expired_absolute`; attempt #2 with the same token returns `invalid_refresh_token` (row was revoked atomically in #1, cannot be replayed). +10. **Refresh extends idle but not absolute** — rotate twice within `abs_max`; both succeed; `auth_time` unchanged across rotations. +11. **Idle expiry (boundary)** — set refresh `exp = now` → 401 with `session_expired_idle` (not generic `invalid_refresh_token`). +12. **Grandfather path** — legacy refresh token without `auth_time`/`idle_max`/`abs_max` → one successful rotation; new JWT has all three claims, `auth_time≈now()`. +13. **Tightening after login doesn't affect existing sessions** — login under policy A, owner tightens to policy B, refresh succeeds under A's snapshot. +14. **`/auth/login/json` carries new claims and response fields** — JWT decode shows `auth_time`/`idle_max`/`abs_max`; response body has `idle_expires_at` + `absolute_expires_at` as ISO strings. +15. **OAuth callback responses include expiry fields** — `/auth/google/callback` and `/auth/microsoft/callback` `OAuthCallbackResponse` bodies have both `idle_expires_at` and `absolute_expires_at`. Mock the Google/Microsoft token-exchange step; assert on the final response shape. +16. **Policy update writes audit row** — PATCH `/accounts/me/security` emits one `account.session_policy_update` audit event with `actor_user_id`, `account_id`, and a payload of `{old: {...}, new: {...}, effective_old: {...}, effective_new: {...}}`. Verify via the existing audit-log query in `core/audit.py`. +17. **Bulk revoke scope=all** — seed three active refresh tokens for two users in the account (caller + one other). POST `/accounts/me/security/revoke-sessions` with `{"scope": "all"}` → `revoked_count=3`; caller's own refresh token is now revoked too. Their next `/auth/refresh` → 401 `invalid_refresh_token`. +18. **Bulk revoke scope=others** — same seed. POST with `{"scope": "others"}` → `revoked_count=2` (caller's token survives). Caller's `/auth/refresh` still succeeds; the other user's `/auth/refresh` → 401 `invalid_refresh_token`. +19. **Bulk revoke is account-scoped** — seed tokens for users in account A and account B. Owner of A POSTs revoke → `revoked_count` reflects only A's tokens; B's tokens remain active. +20. **Bulk revoke is owner-only** — engineer/viewer POST → 403; super_admin POST against `/me` works only if they own an account (the endpoint is `/me`, not `/{account_id}`). +21. **Bulk revoke writes audit row** — `account.sessions_revoked_bulk` with `{actor_user_id, account_id, scope, revoked_count}`. +22. **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`): + +- `useAuthSessionExpiry` returns `"soon"` within 5min of whichever of `idleExpiresAt`/`absoluteExpiresAt` comes first; `reason` field indicates which. +- Axios interceptor on 401 with `session_expired_absolute` redirects to `/login?reason=session_expired` instead of attempting refresh. +- Axios interceptor on 401 with `session_expired_idle` does the same. +- Axios interceptor on 401 with `invalid_refresh_token` redirects to `/login` *without* the reason banner. +- `authStore` rehydrate handles legacy localStorage shape (no `idleExpiresAt`/`absoluteExpiresAt`) without throwing or forced logout; hook treats `null` as "no warning." + +Manual: +- Log in as `owner@`, set **Custom (idle=60 min, absolute=240 min)** under Account → Session Security, log out, log in as `engineer@` (same account), decode the refresh JWT in localStorage, confirm `idle_max=3600` and `abs_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/refresh` populates the new fields. + +## 7. Rollout + +1. Land migration + backend changes behind no flag (the absolute cap is the whole point — flagging it defeats the purpose). +2. 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. +3. After deploy, watch Sentry for spikes in `session_expired_absolute` 401s (expected: tiny — only legacy tokens approaching 14-day mark hit this) and unexpected refresh failures. +4. 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` — new `SESSION_*` settings (defaults + min/max bounds). +- `backend/app/core/security.py` — `create_refresh_token` signature change (accepts `auth_time`/`idle_max`/`abs_max`), `resolve_session_policy(account)` helper, `decode_refresh_token_strict()` that distinguishes `ExpiredSignatureError` from generic `JWTError`. +- `backend/app/api/deps.py` — update `get_refresh_token_payload` to surface idle-expiry as `session_expired_idle` instead 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) -> Token` helper, login + login/json call sites. +- `backend/app/api/endpoints/oauth.py` — both callbacks call `_mint_session_tokens`; `OAuthCallbackResponse` gains the two new fields. +- `backend/app/schemas/token.py` — `Token` (`token.py:5`) adds `idle_expires_at` + `absolute_expires_at` (ISO strings). +- `backend/app/schemas/oauth.py` — `OAuthCallbackResponse` adds 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` — add `account.session_policy_update` event type (or use the existing generic emitter if it accepts free-form types — verify during impl). +- `backend/alembic/versions/_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 both `session_expired_idle` and `session_expired_absolute` (same redirect target `/login?reason=session_expired`); also propagates new expiry fields from successful `/auth/refresh` responses into `setTokens`. +- `frontend/src/api/auth.ts` — `Token` type adds the two new ISO fields. +- `frontend/src/store/authStore.ts` — `setTokens` persists the new expiry fields (no new action). +- `frontend/src/pages/OAuthCallbackPage.tsx` — pass `idle_expires_at` + `absolute_expires_at` through `setTokens({...})` 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_expired` banner. + +### 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/security` writes one `account.session_policy_update` audit event; POST `/revoke-sessions` writes `account.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): + +1. **Super-admin global lock with UI** — today, env-var ceilings cover this. File an issue to expose `SESSION_*_MAX` as a sysadmin-editable setting if/when a customer asks. +2. **Per-user device list + per-device revoke** — refresh tokens would gain `user_agent` + `ip` + `last_used_at` columns; 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. +3. **Per-user (not per-account) policy** — out of scope. File only if a real ask arrives. + +## 10. Sequence of commits + +1. `feat(auth): add session policy settings + account columns + migration` (settings + model + migration + DB CHECK; no behavior change yet). +2. `feat(auth): distinguish idle expiry from invalid refresh tokens` (`decode_refresh_token_strict`, `session_expired_idle` detail, test #11). Lands the error-detail taxonomy from §4.10 before anything depends on it. +3. `feat(auth): embed auth_time/idle_max/abs_max in refresh tokens` (`security.py` + `_mint_session_tokens` helper called from `/auth/login`, `/auth/login/json`, both OAuth callbacks; `Token` and `OAuthCallbackResponse` gain `idle_expires_at` + `absolute_expires_at`). Refresh still doesn't enforce absolute cap yet. +4. `feat(auth): enforce absolute session cap in /auth/refresh` (atomic-revoke-then-check, `session_expired_absolute` detail, grandfather logic, tests #8–#13). +5. `feat(api): add GET/PATCH /accounts/me/security endpoint` (router, schemas, owner gate, bounds + partial-override invariant validation, audit logging on PATCH). +6. `feat(api): add POST /accounts/me/security/revoke-sessions` (bulk-revoke endpoint with `scope=all|others`, single-UPDATE implementation, audit logging, tests #17–#22). +7. `feat(ui): handle session_expired_{idle,absolute} in axios interceptor + authStore` (new fields persisted, legacy-state migration, redirect to `/login?reason=session_expired`). +8. `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_expired` banner). +9. `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:** + +- [x] Defaults confirmed: 3d idle / 14d absolute. +- [x] Per-account override approved. +- [x] Grandfather strategy (one free rotation) approved vs hard cutover. +- [x] Error-detail taxonomy approved (idle vs absolute distinct on the wire; same UX in the frontend). +- [x] Audit logging is a requirement, not optional. +- [x] Loose preset dropped; Strict / Standard / Custom only. +- [x] ISO timestamps (not Unix ints) for `idle_expires_at` / `absolute_expires_at` everywhere. +- [x] 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). From 2375948b7a6aaef27da33a302d00ce44d637479a Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 16:11:01 -0400 Subject: [PATCH 02/13] feat(auth): distinguish idle expiry from invalid refresh tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/deps.py | 39 ++++++++-- backend/app/core/security.py | 36 +++++++++- backend/tests/test_session_policy.py | 103 +++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_session_policy.py diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 67717d45..6314e63e 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -7,7 +7,13 @@ from sqlalchemy import select import sentry_sdk from app.core.database import get_db -from app.core.security import decode_token +from jose import JWTError + +from app.core.security import ( + IdleTokenExpired, + decode_refresh_token_strict, + decode_token, +) from app.models.user import User from app.models.plan_limits import PlanLimits from app.core.tenant_context import set_current_account_id, clear_current_account_id @@ -101,12 +107,35 @@ async def get_current_user_optional( async def get_refresh_token_payload( token: Annotated[str, Depends(oauth2_scheme)] ) -> dict: - """Extract and validate a refresh token from the Authorization header.""" - payload = decode_token(token) - if payload is None or payload.get("type") != "refresh": + """Extract and validate a refresh token from the Authorization header. + + Returns one of three outcomes via HTTP 401 `detail`: + - `session_expired_idle` — JWT signature valid but `exp` past + - `invalid_refresh_token` — any other decode failure, or `type != "refresh"` + - (200 path) — returns the decoded payload + + The frontend uses these to choose between the "your session ended for + security" banner and a plain logout redirect. See + docs/plans/2026-05-13-session-expiration-policy.md §4.10. + """ + try: + payload = decode_refresh_token_strict(token) + except IdleTokenExpired: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid refresh token", + detail="session_expired_idle", + headers={"WWW-Authenticate": "Bearer"}, + ) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid_refresh_token", + headers={"WWW-Authenticate": "Bearer"}, + ) + if payload.get("type") != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid_refresh_token", headers={"WWW-Authenticate": "Bearer"}, ) return payload diff --git a/backend/app/core/security.py b/backend/app/core/security.py index f5e2f460..d37fad42 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -5,9 +5,18 @@ import uuid from datetime import datetime, timedelta, timezone from typing import Optional from jose import JWTError, jwt +from jose.exceptions import ExpiredSignatureError from passlib.context import CryptContext from .config import settings + +class IdleTokenExpired(Exception): + """Raised by decode_refresh_token_strict when a refresh JWT is past its `exp`. + + Distinct from JWTError so callers can map idle expiry to `session_expired_idle` + on the wire while all other decode failures map to `invalid_refresh_token`. + """ + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -49,7 +58,14 @@ def hash_token(jti: str) -> str: def decode_token(token: str) -> Optional[dict]: - """Decode and validate a JWT token.""" + """Decode and validate a JWT token. + + Collapses all jose errors (including expiry) into None — preserved for + access tokens, password-reset tokens, and email-verification tokens where + the caller does not need to distinguish expiry from invalid. Refresh tokens + use decode_refresh_token_strict instead so they can map idle expiry to + `session_expired_idle` distinctly. + """ try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) return payload @@ -57,6 +73,24 @@ def decode_token(token: str) -> Optional[dict]: return None +def decode_refresh_token_strict(token: str) -> dict: + """Decode a refresh token, distinguishing idle expiry from invalid. + + Raises: + IdleTokenExpired: token signature is valid but `exp` is past — i.e. the + idle window has elapsed. + JWTError: any other decode failure (bad signature, malformed, wrong + algorithm). + + Type discrimination (`type == "refresh"`) is the caller's responsibility — + this function only inspects the JWT itself. + """ + try: + return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + except ExpiredSignatureError as e: + raise IdleTokenExpired() from e + + def create_password_reset_token(user_id: str) -> str: """Create a JWT password reset token (30-minute expiry, unique JTI).""" jti = str(uuid.uuid4()) diff --git a/backend/tests/test_session_policy.py b/backend/tests/test_session_policy.py new file mode 100644 index 00000000..b0e0d145 --- /dev/null +++ b/backend/tests/test_session_policy.py @@ -0,0 +1,103 @@ +"""Tests for the session-expiration-policy series. + +See docs/plans/2026-05-13-session-expiration-policy.md. +Test numbers below correspond to the cases listed in §6 of the plan. + +This file grows across commits — commit 2 lands the error-detail +taxonomy tests (#11 + a wrong-type case + a bad-signature case). +""" + +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +from httpx import AsyncClient +from jose import jwt + +from app.core.config import settings + + +def _encode_refresh_token( + *, + sub: str, + exp: datetime, + token_type: str = "refresh", + secret: str | None = None, +) -> str: + """Build a refresh JWT with arbitrary `exp` for testing. + + Bypasses create_refresh_token so tests can produce already-expired + tokens, wrong-type tokens, or wrong-signature tokens. + """ + return jwt.encode( + { + "sub": sub, + "type": token_type, + "jti": str(uuid.uuid4()), + "exp": exp, + }, + secret or settings.SECRET_KEY, + algorithm=settings.ALGORITHM, + ) + + +class TestRefreshTokenErrorTaxonomy: + """§6 test #11 — refresh-token error-detail taxonomy. + + `/auth/refresh` distinguishes idle expiry from generic invalid-token + failures via `detail`, so the frontend can choose between the "session + ended for security" banner and a plain logout redirect. + """ + + @pytest.mark.asyncio + async def test_idle_expired_refresh_returns_session_expired_idle( + self, client: AsyncClient, test_user: dict + ): + token = _encode_refresh_token( + sub=test_user["user_data"]["id"], + exp=datetime.now(timezone.utc) - timedelta(seconds=1), + ) + + response = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "session_expired_idle" + + @pytest.mark.asyncio + async def test_wrong_type_token_returns_invalid_refresh_token( + self, client: AsyncClient, test_user: dict + ): + token = _encode_refresh_token( + sub=test_user["user_data"]["id"], + exp=datetime.now(timezone.utc) + timedelta(minutes=5), + token_type="access", + ) + + response = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "invalid_refresh_token" + + @pytest.mark.asyncio + async def test_bad_signature_returns_invalid_refresh_token( + self, client: AsyncClient, test_user: dict + ): + token = _encode_refresh_token( + sub=test_user["user_data"]["id"], + exp=datetime.now(timezone.utc) + timedelta(minutes=5), + secret="not-the-real-secret-key", + ) + + response = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "invalid_refresh_token" From d6a02ee8dadc5a0a8a02e9947e47e77001b8f6fe Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 16:22:53 -0400 Subject: [PATCH 03/13] feat(auth): embed auth_time/idle_max/abs_max in refresh tokens at every login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/endpoints/auth.py | 142 +++++++++++++++++++-------- backend/app/api/endpoints/oauth.py | 29 +++--- backend/app/core/security.py | 54 ++++++++-- backend/app/schemas/oauth.py | 7 ++ backend/app/schemas/token.py | 7 ++ backend/tests/test_session_policy.py | 82 +++++++++++++++- 6 files changed, 255 insertions(+), 66 deletions(-) diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 62cce07c..ef42147e 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -20,6 +20,7 @@ from app.core.security import ( create_email_verification_token, decode_token, hash_token, + resolve_session_policy, ) from app.models.user import User from app.models.invite_code import InviteCode @@ -67,6 +68,97 @@ async def store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) db.add(token_record) +async def _mint_session_tokens(user: User, db: AsyncSession) -> Token: + """Mint a fresh refresh+access pair for a new login. + + Snapshots the account's current session policy into the refresh JWT + (auth_time/idle_max/abs_max) and registers the JTI in refresh_tokens. + Caller is responsible for committing the session. Use this for every + NEW login (password, OAuth, etc.) — for /auth/refresh use + _refresh_session_tokens instead, which carries claims forward. + + See docs/plans/2026-05-13-session-expiration-policy.md §4.6. + """ + account = ( + await db.execute(select(Account).where(Account.id == user.account_id)) + ).scalar_one() + idle_minutes, abs_minutes = resolve_session_policy(account) + idle_max_seconds = idle_minutes * 60 + abs_max_seconds = abs_minutes * 60 + + now = datetime.now(timezone.utc) + auth_time_unix = int(now.timestamp()) + + refresh_token_str = create_refresh_token( + user_id=str(user.id), + auth_time=auth_time_unix, + idle_max_seconds=idle_max_seconds, + abs_max_seconds=abs_max_seconds, + ) + access_token = create_access_token(data={"sub": str(user.id)}) + await store_refresh_token(db, refresh_token_str, user.id) + + return Token( + access_token=access_token, + refresh_token=refresh_token_str, + token_type="bearer", + must_change_password=user.must_change_password, + idle_expires_at=now + timedelta(seconds=idle_max_seconds), + absolute_expires_at=datetime.fromtimestamp( + auth_time_unix + abs_max_seconds, tz=timezone.utc + ), + ) + + +async def _refresh_session_tokens( + payload: dict, user: User, db: AsyncSession +) -> Token: + """Carry session-policy claims forward across a refresh-token rotation. + + Grandfathers legacy tokens issued before this PR (no auth_time claim) + by snapshotting the account's current policy and treating now() as + auth_time — i.e. one free rotation under the new policy. Caller + commits. + + Does NOT enforce the absolute cap — that lands in the next commit so + the cap can be rolled back independently if needed. + """ + auth_time = payload.get("auth_time") + idle_max_seconds = payload.get("idle_max") + abs_max_seconds = payload.get("abs_max") + + if auth_time is None or idle_max_seconds is None or abs_max_seconds is None: + # Grandfather path — legacy token from before the session-policy PR. + account = ( + await db.execute(select(Account).where(Account.id == user.account_id)) + ).scalar_one() + idle_minutes, abs_minutes = resolve_session_policy(account) + auth_time = int(datetime.now(timezone.utc).timestamp()) + idle_max_seconds = idle_minutes * 60 + abs_max_seconds = abs_minutes * 60 + + now = datetime.now(timezone.utc) + refresh_token_str = create_refresh_token( + user_id=str(user.id), + auth_time=auth_time, + idle_max_seconds=idle_max_seconds, + abs_max_seconds=abs_max_seconds, + ) + access_token = create_access_token(data={"sub": str(user.id)}) + await store_refresh_token(db, refresh_token_str, user.id) + + return Token( + access_token=access_token, + refresh_token=refresh_token_str, + token_type="bearer", + must_change_password=user.must_change_password, + idle_expires_at=now + timedelta(seconds=idle_max_seconds), + absolute_expires_at=datetime.fromtimestamp( + auth_time + abs_max_seconds, tz=timezone.utc + ), + ) + + def _generate_display_code() -> str: """Generate a random 8-character alphanumeric display code.""" chars = string.ascii_uppercase + string.digits @@ -323,20 +415,9 @@ async def login( # Update last login user.last_login = datetime.now(timezone.utc) - # Create tokens - access_token = create_access_token(data={"sub": str(user.id)}) - refresh_token_str = create_refresh_token(data={"sub": str(user.id)}) - - # Store refresh token hash in DB - await store_refresh_token(db, refresh_token_str, user.id) + token = await _mint_session_tokens(user, db) await db.commit() - - return Token( - access_token=access_token, - refresh_token=refresh_token_str, - token_type="bearer", - must_change_password=user.must_change_password, - ) + return token @router.post("/login/json", response_model=Token) @@ -359,19 +440,9 @@ async def login_json( user.last_login = datetime.now(timezone.utc) - access_token = create_access_token(data={"sub": str(user.id)}) - refresh_token_str = create_refresh_token(data={"sub": str(user.id)}) - - # Store refresh token hash in DB - await store_refresh_token(db, refresh_token_str, user.id) + token = await _mint_session_tokens(user, db) await db.commit() - - return Token( - access_token=access_token, - refresh_token=refresh_token_str, - token_type="bearer", - must_change_password=user.must_change_password, - ) + return token @router.post("/refresh", response_model=Token) @@ -402,10 +473,12 @@ async def refresh_token( revoked_row = result.fetchone() if not revoked_row: - # Either the token doesn't exist or was already revoked/used + # Either the token doesn't exist or was already revoked/used. + # Surfaced to the frontend as a plain logout — not "session + # expired" — because the user did not hit a policy boundary. raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token has been revoked" + detail="invalid_refresh_token" ) result = await db.execute(select(User).where(User.id == user_id)) @@ -414,21 +487,12 @@ async def refresh_token( if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="User not found" + detail="invalid_refresh_token" ) - access_token = create_access_token(data={"sub": str(user.id)}) - new_refresh_token_str = create_refresh_token(data={"sub": str(user.id)}) - - # Store new refresh token - await store_refresh_token(db, new_refresh_token_str, user.id) + token = await _refresh_session_tokens(payload, user, db) await db.commit() - - return Token( - access_token=access_token, - refresh_token=new_refresh_token_str, - token_type="bearer" - ) + return token @router.get("/me", response_model=UserResponse) diff --git a/backend/app/api/endpoints/oauth.py b/backend/app/api/endpoints/oauth.py index 446c686f..233b50b6 100644 --- a/backend/app/api/endpoints/oauth.py +++ b/backend/app/api/endpoints/oauth.py @@ -7,10 +7,9 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.endpoints.auth import store_refresh_token +from app.api.endpoints.auth import _mint_session_tokens from app.core.admin_database import get_admin_db from app.core.config import settings -from app.core.security import create_access_token, create_refresh_token from app.models.account import Account from app.models.account_invite import AccountInvite from app.models.oauth_identity import OAuthIdentity @@ -187,17 +186,14 @@ async def google_callback( account_invite_code=payload.account_invite_code, invited_email=payload.invited_email, ) - refresh_token_str = create_refresh_token({"sub": str(user.id)}) - # Persist the refresh-token JTI so the first /auth/refresh call doesn't - # reject this token as "revoked" (the rotation logic requires a row to - # mark as used). _sign_in_or_register already committed; this needs a - # second commit. - await store_refresh_token(db, refresh_token_str, user.id) + token = await _mint_session_tokens(user, db) await db.commit() return OAuthCallbackResponse( - access_token=create_access_token({"sub": str(user.id)}), - refresh_token=refresh_token_str, + access_token=token.access_token, + refresh_token=token.refresh_token, is_new_user=is_new, + idle_expires_at=token.idle_expires_at, + absolute_expires_at=token.absolute_expires_at, ) @@ -217,15 +213,12 @@ async def microsoft_callback( account_invite_code=payload.account_invite_code, invited_email=payload.invited_email, ) - refresh_token_str = create_refresh_token({"sub": str(user.id)}) - # Persist the refresh-token JTI so the first /auth/refresh call doesn't - # reject this token as "revoked" (the rotation logic requires a row to - # mark as used). _sign_in_or_register already committed; this needs a - # second commit. - await store_refresh_token(db, refresh_token_str, user.id) + token = await _mint_session_tokens(user, db) await db.commit() return OAuthCallbackResponse( - access_token=create_access_token({"sub": str(user.id)}), - refresh_token=refresh_token_str, + access_token=token.access_token, + refresh_token=token.refresh_token, is_new_user=is_new, + idle_expires_at=token.idle_expires_at, + absolute_expires_at=token.absolute_expires_at, ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index d37fad42..bc53615e 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -42,14 +42,54 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - return encoded_jwt -def create_refresh_token(data: dict) -> str: - """Create a JWT refresh token with a unique jti for revocation tracking.""" - to_encode = data.copy() - expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) +def create_refresh_token( + user_id: str, + *, + auth_time: int, + idle_max_seconds: int, + abs_max_seconds: int, +) -> str: + """Create a JWT refresh token with session-policy claims embedded. + + The JWT carries five claims beyond the standard `sub`/`type`/`jti`: + + - `auth_time`: Unix-seconds timestamp of the original login; never reset + on rotation. Used by `/auth/refresh` to enforce the absolute cap. + - `idle_max`: idle window in seconds, snapshotted from the account's + policy at login. Carried forward across rotations unchanged. + - `abs_max`: absolute lifetime in seconds, snapshotted at login. + - `exp`: current idle deadline (`now + idle_max`). Standard JWT expiry. + + See docs/plans/2026-05-13-session-expiration-policy.md §4.2 for the unit + convention (everything outside the JWT is minutes; inside the JWT it's + seconds so `auth_time + abs_max` is direct Unix math). + """ + now = datetime.now(timezone.utc) + expire = now + timedelta(seconds=idle_max_seconds) jti = str(uuid.uuid4()) - to_encode.update({"exp": expire, "type": "refresh", "jti": jti}) - encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) - return encoded_jwt + to_encode = { + "sub": user_id, + "type": "refresh", + "jti": jti, + "exp": expire, + "auth_time": auth_time, + "idle_max": idle_max_seconds, + "abs_max": abs_max_seconds, + } + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def resolve_session_policy(account) -> tuple[int, int]: + """Return (idle_minutes, absolute_minutes) for an account. + + NULL overrides fall back to the system defaults from Settings. Partial + overrides (one column NULL, one set) are intentionally allowed at this + layer; the PATCH /accounts/me/security endpoint validates the resolved + effective values to enforce idle <= absolute. See plan §4.3. + """ + idle = account.session_idle_minutes or settings.SESSION_IDLE_MINUTES_DEFAULT + absolute = account.session_absolute_minutes or settings.SESSION_ABSOLUTE_MINUTES_DEFAULT + return idle, absolute def hash_token(jti: str) -> str: diff --git a/backend/app/schemas/oauth.py b/backend/app/schemas/oauth.py index da30a913..e7411ef4 100644 --- a/backend/app/schemas/oauth.py +++ b/backend/app/schemas/oauth.py @@ -1,3 +1,5 @@ +from datetime import datetime + from pydantic import BaseModel @@ -16,6 +18,11 @@ class OAuthCallbackResponse(BaseModel): refresh_token: str token_type: str = "bearer" is_new_user: bool + # Session-policy expiry windows — mirrors Token in token.py so the + # frontend can drive expiry-soon toasts identically for password and + # OAuth logins. + idle_expires_at: datetime | None = None + absolute_expires_at: datetime | None = None class InviteLookupResponse(BaseModel): diff --git a/backend/app/schemas/token.py b/backend/app/schemas/token.py index 49ceabda..de5179e7 100644 --- a/backend/app/schemas/token.py +++ b/backend/app/schemas/token.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from pydantic import BaseModel @@ -7,6 +8,12 @@ class Token(BaseModel): refresh_token: str token_type: str = "bearer" must_change_password: bool = False + # Session-policy expiry windows derived from the refresh JWT. Frontend + # uses these to drive the "your session ends soon" toast and to know + # when /auth/refresh will reject for absolute expiry. See + # docs/plans/2026-05-13-session-expiration-policy.md §4.2. + idle_expires_at: Optional[datetime] = None + absolute_expires_at: Optional[datetime] = None class TokenPayload(BaseModel): diff --git a/backend/tests/test_session_policy.py b/backend/tests/test_session_policy.py index b0e0d145..a1d1de83 100644 --- a/backend/tests/test_session_policy.py +++ b/backend/tests/test_session_policy.py @@ -3,8 +3,9 @@ See docs/plans/2026-05-13-session-expiration-policy.md. Test numbers below correspond to the cases listed in §6 of the plan. -This file grows across commits — commit 2 lands the error-detail -taxonomy tests (#11 + a wrong-type case + a bad-signature case). +This file grows across commits: +- Commit 2: error-detail taxonomy (#11 + wrong-type + bad-signature) +- Commit 3: claims embedded at login + response fields surfaced (#1, #14) """ import uuid @@ -101,3 +102,80 @@ class TestRefreshTokenErrorTaxonomy: assert response.status_code == 401 assert response.json()["detail"] == "invalid_refresh_token" + + +class TestSessionPolicyClaims: + """§6 tests #1 and #14 — session-policy claims stamped at login. + + Every token-issuing endpoint embeds auth_time/idle_max/abs_max in + the refresh JWT and surfaces idle_expires_at/absolute_expires_at on + the response. + """ + + @pytest.mark.asyncio + async def test_login_json_embeds_session_claims_with_defaults( + self, client: AsyncClient, test_user: dict + ): + before = datetime.now(timezone.utc) + + response = await client.post( + "/api/v1/auth/login/json", + json={ + "email": test_user["email"], + "password": test_user["password"], + }, + ) + assert response.status_code == 200, response.json() + body = response.json() + after = datetime.now(timezone.utc) + + # Response surfaces both expiry windows as ISO strings. + assert body["idle_expires_at"] is not None + assert body["absolute_expires_at"] is not None + idle_at = datetime.fromisoformat(body["idle_expires_at"]) + abs_at = datetime.fromisoformat(body["absolute_expires_at"]) + # Strict default: 3 days idle, 14 days absolute. + assert timedelta(days=3) - timedelta(seconds=10) <= idle_at - before <= timedelta(days=3) + timedelta(seconds=10) + assert timedelta(days=14) - timedelta(seconds=10) <= abs_at - before <= timedelta(days=14) + timedelta(seconds=10) + + # JWT carries the claims in seconds, plus auth_time as Unix seconds. + decoded = jwt.decode( + body["refresh_token"], settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + assert decoded["idle_max"] == 3 * 24 * 60 * 60 # 259200 + assert decoded["abs_max"] == 14 * 24 * 60 * 60 # 1209600 + assert int(before.timestamp()) <= decoded["auth_time"] <= int(after.timestamp()) + + @pytest.mark.asyncio + async def test_refresh_carries_claims_forward_unchanged( + self, client: AsyncClient, test_user: dict + ): + # Login produces the original session. + login_resp = await client.post( + "/api/v1/auth/login/json", + json={"email": test_user["email"], "password": test_user["password"]}, + ) + original_refresh = login_resp.json()["refresh_token"] + original_payload = jwt.decode( + original_refresh, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + + # Refresh rotates the token but must carry auth_time/idle_max/abs_max + # forward unchanged so the absolute window doesn't slide. + refresh_resp = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {original_refresh}"}, + ) + assert refresh_resp.status_code == 200, refresh_resp.json() + new_refresh = refresh_resp.json()["refresh_token"] + new_payload = jwt.decode( + new_refresh, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + + assert new_payload["auth_time"] == original_payload["auth_time"] + assert new_payload["idle_max"] == original_payload["idle_max"] + assert new_payload["abs_max"] == original_payload["abs_max"] + # Idle deadline does slide because exp = now + idle_max. + assert new_payload["exp"] >= original_payload["exp"] + # JTI rotates. + assert new_payload["jti"] != original_payload["jti"] From b21d2fc23457d35042cabfed862c971f1b9e92a7 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 16:26:00 -0400 Subject: [PATCH 04/13] feat(auth): enforce absolute session cap in /auth/refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/endpoints/auth.py | 92 +++++++++++++------ backend/tests/test_session_policy.py | 131 +++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 26 deletions(-) diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index ef42147e..fa73d819 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -110,25 +110,21 @@ async def _mint_session_tokens(user: User, db: AsyncSession) -> Token: ) -async def _refresh_session_tokens( +async def _resolve_refresh_claims( payload: dict, user: User, db: AsyncSession -) -> Token: - """Carry session-policy claims forward across a refresh-token rotation. +) -> tuple[int, int, int]: + """Return (auth_time, idle_max_seconds, abs_max_seconds) for a refresh. - Grandfathers legacy tokens issued before this PR (no auth_time claim) - by snapshotting the account's current policy and treating now() as - auth_time — i.e. one free rotation under the new policy. Caller - commits. - - Does NOT enforce the absolute cap — that lands in the next commit so - the cap can be rolled back independently if needed. + Grandfathers legacy tokens issued before the session-policy PR: tokens + missing any of auth_time/idle_max/abs_max get treated as if just minted + under the account's current policy. One free rotation under the new + rules — see plan §5.1. Callers that have the claims use them as-is. """ auth_time = payload.get("auth_time") idle_max_seconds = payload.get("idle_max") abs_max_seconds = payload.get("abs_max") if auth_time is None or idle_max_seconds is None or abs_max_seconds is None: - # Grandfather path — legacy token from before the session-policy PR. account = ( await db.execute(select(Account).where(Account.id == user.account_id)) ).scalar_one() @@ -137,6 +133,21 @@ async def _refresh_session_tokens( idle_max_seconds = idle_minutes * 60 abs_max_seconds = abs_minutes * 60 + return auth_time, idle_max_seconds, abs_max_seconds + + +async def _mint_with_claims( + user: User, + auth_time: int, + idle_max_seconds: int, + abs_max_seconds: int, + db: AsyncSession, +) -> Token: + """Mint a refresh+access pair carrying explicit session-policy claims. + + Used by /auth/refresh after the grandfather + absolute-cap checks + have already produced the effective claim values. Caller commits. + """ now = datetime.now(timezone.utc) refresh_token_str = create_refresh_token( user_id=str(user.id), @@ -452,13 +463,39 @@ async def refresh_token( payload: Annotated[dict, Depends(get_refresh_token_payload)], db: Annotated[AsyncSession, Depends(get_admin_db)] ): - """Refresh access token using refresh token (rotation: old token is revoked).""" + """Refresh access token, enforcing both idle and absolute session windows. + + Algorithm (see plan §4.5): + + 1. Decode refresh JWT (the dep already rejects idle-expired tokens with + session_expired_idle). + 2. Load the user. If missing or inactive, 401 invalid_refresh_token. + 3. Resolve effective auth_time/idle_max/abs_max (grandfather legacy + tokens that pre-date this PR). + 4. Atomically revoke the JTI regardless of outcome — so an absolute- + expired token cannot be replayed; the second attempt finds it + already revoked and gets invalid_refresh_token instead. + 5. If the atomic UPDATE matched zero rows, 401 invalid_refresh_token. + 6. If now >= auth_time + abs_max, 401 session_expired_absolute. + 7. Otherwise mint new tokens carrying the claims forward. + """ user_id = payload.get("sub") jti = payload.get("jti") - # Atomically revoke the old refresh token (token rotation). - # Using a conditional UPDATE prevents the race where two concurrent - # refresh requests both read revoked_at=NULL and both succeed. + user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none() + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid_refresh_token", + ) + + auth_time, idle_max_seconds, abs_max_seconds = await _resolve_refresh_claims( + payload, user, db + ) + + # Atomically revoke the old refresh token first — this consumes the + # token regardless of whether the absolute check passes, so an absolute- + # expired token cannot be replayed. if jti: token_hash = hash_token(jti) result = await db.execute( @@ -471,26 +508,29 @@ async def refresh_token( .returning(RefreshToken.id, RefreshToken.user_id) ) revoked_row = result.fetchone() - if not revoked_row: - # Either the token doesn't exist or was already revoked/used. - # Surfaced to the frontend as a plain logout — not "session - # expired" — because the user did not hit a policy boundary. raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="invalid_refresh_token" + detail="invalid_refresh_token", ) - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - - if not user: + # Absolute-window check. Boundary is `>=`, not `>` — a deadline equal to + # now is expired. The token row has already been revoked above, so the + # client cannot retry this token even though we're raising after the + # consume. + now_unix = int(datetime.now(timezone.utc).timestamp()) + if now_unix >= auth_time + abs_max_seconds: + # Commit the revoke so the consumed-on-failure invariant survives + # any subsequent rollback in the request lifecycle. + await db.commit() raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="invalid_refresh_token" + detail="session_expired_absolute", ) - token = await _refresh_session_tokens(payload, user, db) + token = await _mint_with_claims( + user, auth_time, idle_max_seconds, abs_max_seconds, db + ) await db.commit() return token diff --git a/backend/tests/test_session_policy.py b/backend/tests/test_session_policy.py index a1d1de83..301f351a 100644 --- a/backend/tests/test_session_policy.py +++ b/backend/tests/test_session_policy.py @@ -6,6 +6,7 @@ Test numbers below correspond to the cases listed in §6 of the plan. This file grows across commits: - Commit 2: error-detail taxonomy (#11 + wrong-type + bad-signature) - Commit 3: claims embedded at login + response fields surfaced (#1, #14) +- Commit 4: absolute-cap enforcement + grandfather path (#8, #9, #12) """ import uuid @@ -179,3 +180,133 @@ class TestSessionPolicyClaims: assert new_payload["exp"] >= original_payload["exp"] # JTI rotates. assert new_payload["jti"] != original_payload["jti"] + + +def _backdate_auth_time(refresh_token: str, *, seconds_back: int) -> str: + """Re-sign a refresh JWT with an earlier auth_time, preserving JTI. + + The DB row in refresh_tokens is keyed on hash(jti), so preserving jti + lets the atomic revoke step still find the row. Used to simulate + "this session is past its absolute cap" without waiting two weeks. + """ + payload = jwt.decode( + refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + payload["auth_time"] = payload["auth_time"] - seconds_back + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +class TestAbsoluteCap: + """§6 tests #8, #9, #12 — absolute-cap enforcement and grandfather path.""" + + @pytest.mark.asyncio + async def test_refresh_at_absolute_deadline_rejects( + self, client: AsyncClient, test_user: dict + ): + """§6 test #8 — boundary check uses `>=`, not `>`. + + A token whose auth_time + abs_max equals now() is expired, not + valid. Backdate the original token's auth_time by exactly abs_max + seconds so now >= deadline. + """ + login_resp = await client.post( + "/api/v1/auth/login/json", + json={"email": test_user["email"], "password": test_user["password"]}, + ) + original = login_resp.json()["refresh_token"] + abs_max = jwt.decode( + original, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + )["abs_max"] + + expired = _backdate_auth_time(original, seconds_back=abs_max) + + response = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {expired}"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "session_expired_absolute" + + @pytest.mark.asyncio + async def test_absolute_expired_token_is_consumed( + self, client: AsyncClient, test_user: dict + ): + """§6 test #9 — first attempt returns session_expired_absolute and + revokes the row; second attempt sees the revoked row and returns + invalid_refresh_token. Prevents replay of an absolute-expired token. + """ + login_resp = await client.post( + "/api/v1/auth/login/json", + json={"email": test_user["email"], "password": test_user["password"]}, + ) + original = login_resp.json()["refresh_token"] + abs_max = jwt.decode( + original, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + )["abs_max"] + expired = _backdate_auth_time(original, seconds_back=abs_max + 1) + + first = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {expired}"}, + ) + assert first.status_code == 401 + assert first.json()["detail"] == "session_expired_absolute" + + second = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {expired}"}, + ) + assert second.status_code == 401 + assert second.json()["detail"] == "invalid_refresh_token" + + @pytest.mark.asyncio + async def test_grandfather_path_for_legacy_token( + self, client: AsyncClient, test_user: dict, test_db + ): + """§6 test #12 — refresh token issued before this PR (no auth_time + claim) gets one successful rotation; the new token has fresh + auth_time/idle_max/abs_max claims snapshotted from current policy. + """ + from app.core.security import hash_token + from app.models.refresh_token import RefreshToken + + login_resp = await client.post( + "/api/v1/auth/login/json", + json={"email": test_user["email"], "password": test_user["password"]}, + ) + original = login_resp.json()["refresh_token"] + original_payload = jwt.decode( + original, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + + # Strip the new claims to simulate a token issued before this PR. + # JTI preserved so the DB-side revoke still finds the row. + legacy_payload = { + "sub": original_payload["sub"], + "type": "refresh", + "jti": original_payload["jti"], + "exp": original_payload["exp"], + } + legacy_token = jwt.encode( + legacy_payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM + ) + + response = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {legacy_token}"}, + ) + + assert response.status_code == 200, response.json() + new_payload = jwt.decode( + response.json()["refresh_token"], + settings.SECRET_KEY, + algorithms=[settings.ALGORITHM], + ) + assert new_payload.get("auth_time") is not None + assert new_payload.get("idle_max") == 3 * 24 * 60 * 60 + assert new_payload.get("abs_max") == 14 * 24 * 60 * 60 + # auth_time was set to ~now during grandfather, not preserved from + # the legacy token (since the legacy token didn't have one). + now_unix = int(datetime.now(timezone.utc).timestamp()) + assert abs(new_payload["auth_time"] - now_unix) < 10 From 8cfaef6a9d7b02c73cc0d8158737687d9f5ada09 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 16:28:51 -0400 Subject: [PATCH 05/13] 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 --- backend/app/api/endpoints/account_security.py | 138 +++++++++++++ backend/app/api/router.py | 2 + backend/app/schemas/account_security.py | 56 ++++++ backend/tests/test_session_policy.py | 182 ++++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 backend/app/api/endpoints/account_security.py create mode 100644 backend/app/schemas/account_security.py diff --git a/backend/app/api/endpoints/account_security.py b/backend/app/api/endpoints/account_security.py new file mode 100644 index 00000000..43c3bbfe --- /dev/null +++ b/backend/app/api/endpoints/account_security.py @@ -0,0 +1,138 @@ +"""Account session-policy endpoints — owner-only. + +GET /accounts/me/security — read the policy + system bounds. +PATCH /accounts/me/security — set or clear the per-account override. + +POST /accounts/me/security/revoke-sessions lands in the next commit. + +See docs/plans/2026-05-13-session-expiration-policy.md §4.7 / §4.11. +""" +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import require_account_owner +from app.core.admin_database import get_admin_db +from app.core.audit import log_audit +from app.core.config import settings +from app.core.security import resolve_session_policy +from app.models.account import Account +from app.models.user import User +from app.schemas.account_security import ( + SessionPolicyResponse, + SessionPolicyUpdateRequest, +) + +router = APIRouter(prefix="/accounts/me/security", tags=["account-security"]) + + +def _policy_response(account: Account) -> SessionPolicyResponse: + eff_idle, eff_abs = resolve_session_policy(account) + return SessionPolicyResponse( + idle_minutes=account.session_idle_minutes, + absolute_minutes=account.session_absolute_minutes, + effective_idle_minutes=eff_idle, + effective_absolute_minutes=eff_abs, + idle_minutes_min=settings.SESSION_IDLE_MINUTES_MIN, + idle_minutes_max=settings.SESSION_IDLE_MINUTES_MAX, + absolute_minutes_min=settings.SESSION_ABSOLUTE_MINUTES_MIN, + absolute_minutes_max=settings.SESSION_ABSOLUTE_MINUTES_MAX, + ) + + +async def _load_account(db: AsyncSession, account_id) -> Account: + return ( + await db.execute(select(Account).where(Account.id == account_id)) + ).scalar_one() + + +@router.get("", response_model=SessionPolicyResponse) +async def get_session_policy( + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +): + account = await _load_account(db, current_user.account_id) + return _policy_response(account) + + +@router.patch("", response_model=SessionPolicyResponse) +async def update_session_policy( + body: SessionPolicyUpdateRequest, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +): + account = await _load_account(db, current_user.account_id) + + # Snapshot effective values BEFORE change, for audit. + old_idle = account.session_idle_minutes + old_abs = account.session_absolute_minutes + effective_old_idle, effective_old_abs = resolve_session_policy(account) + + new_idle = body.idle_minutes + new_abs = body.absolute_minutes + + # Per-field bound checks. NULL clears the override and is always valid. + if new_idle is not None and not ( + settings.SESSION_IDLE_MINUTES_MIN <= new_idle <= settings.SESSION_IDLE_MINUTES_MAX + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + f"idle_minutes must be between {settings.SESSION_IDLE_MINUTES_MIN} " + f"and {settings.SESSION_IDLE_MINUTES_MAX}" + ), + ) + if new_abs is not None and not ( + settings.SESSION_ABSOLUTE_MINUTES_MIN <= new_abs <= settings.SESSION_ABSOLUTE_MINUTES_MAX + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + f"absolute_minutes must be between {settings.SESSION_ABSOLUTE_MINUTES_MIN} " + f"and {settings.SESSION_ABSOLUTE_MINUTES_MAX}" + ), + ) + + # Effective-value invariant: idle must not exceed absolute after defaults. + # The DB CHECK only catches the both-set case; this catches the partial- + # override case where (e.g.) idle=43200 with absolute=NULL would yield an + # effective idle larger than the system default absolute. + effective_new_idle = new_idle if new_idle is not None else settings.SESSION_IDLE_MINUTES_DEFAULT + effective_new_abs = new_abs if new_abs is not None else settings.SESSION_ABSOLUTE_MINUTES_DEFAULT + if effective_new_idle > effective_new_abs: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + f"Effective idle ({effective_new_idle}min) cannot exceed effective " + f"absolute ({effective_new_abs}min)" + ), + ) + + account.session_idle_minutes = new_idle + account.session_absolute_minutes = new_abs + + await log_audit( + db, + user_id=current_user.id, + account_id=account.id, + action="account.session_policy_update", + resource_type="account", + resource_id=account.id, + details={ + "old": {"idle_minutes": old_idle, "absolute_minutes": old_abs}, + "new": {"idle_minutes": new_idle, "absolute_minutes": new_abs}, + "effective_old": { + "idle_minutes": effective_old_idle, + "absolute_minutes": effective_old_abs, + }, + "effective_new": { + "idle_minutes": effective_new_idle, + "absolute_minutes": effective_new_abs, + }, + }, + ) + await db.commit() + await db.refresh(account) + return _policy_response(account) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index ce587d4f..f8f13a35 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -72,6 +72,7 @@ from app.api.endpoints import ( webhooks, accounts, account_invite_lookup, + account_security, ) api_router = APIRouter() @@ -144,6 +145,7 @@ api_router.include_router(folders.router, dependencies=_tenant_deps) api_router.include_router(step_categories.router, dependencies=_pro_deps) api_router.include_router(steps.router, dependencies=_pro_deps) api_router.include_router(accounts.router, dependencies=_tenant_deps) +api_router.include_router(account_security.router, dependencies=_tenant_deps) api_router.include_router(shares.router, dependencies=_tenant_deps) api_router.include_router(tree_markdown.router, dependencies=_tenant_deps) api_router.include_router(ratings.router, dependencies=_tenant_deps) diff --git a/backend/app/schemas/account_security.py b/backend/app/schemas/account_security.py new file mode 100644 index 00000000..f70d607b --- /dev/null +++ b/backend/app/schemas/account_security.py @@ -0,0 +1,56 @@ +"""Schemas for /accounts/me/security — session-policy management. + +See docs/plans/2026-05-13-session-expiration-policy.md §4.7 and §4.11. +""" +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +class SessionPolicyResponse(BaseModel): + """GET /accounts/me/security — the policy in effect for this account. + + Surfaces both the override (which may be NULL) and the effective value + (after defaults applied) so the frontend can show the current state + without re-implementing the defaults logic. + """ + + # Per-account override values, NULL = "use system default." + idle_minutes: Optional[int] = Field( + default=None, + description="Account override; NULL means use the system default.", + ) + absolute_minutes: Optional[int] = Field(default=None) + + # Effective values after defaults applied (always non-NULL). + effective_idle_minutes: int + effective_absolute_minutes: int + + # System-imposed bounds for the Custom-preset form inputs. + idle_minutes_min: int + idle_minutes_max: int + absolute_minutes_min: int + absolute_minutes_max: int + + +class SessionPolicyUpdateRequest(BaseModel): + """PATCH /accounts/me/security — set or clear the per-account override. + + Pass `null` for either field to clear the override and fall back to the + system default. Both bounds checks and the idle <= absolute invariant + are validated against the *effective* values at the endpoint, since the + DB CHECK constraint only covers the both-set case. + """ + + idle_minutes: Optional[int] = None + absolute_minutes: Optional[int] = None + + +class RevokeSessionsRequest(BaseModel): + """POST /accounts/me/security/revoke-sessions — bulk-revoke refresh tokens.""" + + scope: Literal["all", "others"] = "all" + + +class RevokeSessionsResponse(BaseModel): + revoked_count: int diff --git a/backend/tests/test_session_policy.py b/backend/tests/test_session_policy.py index 301f351a..5c314f6b 100644 --- a/backend/tests/test_session_policy.py +++ b/backend/tests/test_session_policy.py @@ -7,6 +7,7 @@ This file grows across commits: - Commit 2: error-detail taxonomy (#11 + wrong-type + bad-signature) - Commit 3: claims embedded at login + response fields surfaced (#1, #14) - Commit 4: absolute-cap enforcement + grandfather path (#8, #9, #12) +- Commit 5: GET/PATCH /accounts/me/security (#2, #3, #4, #5, #7, #16) """ import uuid @@ -196,6 +197,187 @@ def _backdate_auth_time(refresh_token: str, *, seconds_back: int) -> str: return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) +class TestSessionPolicyEndpoint: + """§6 tests #2, #3, #4, #5, #7, #16 — GET/PATCH /accounts/me/security.""" + + @pytest.mark.asyncio + async def test_get_returns_defaults_and_bounds( + self, client: AsyncClient, auth_headers: dict + ): + response = await client.get( + "/api/v1/accounts/me/security", headers=auth_headers + ) + assert response.status_code == 200, response.json() + body = response.json() + + # No override yet -> effective values are the system defaults. + assert body["idle_minutes"] is None + assert body["absolute_minutes"] is None + assert body["effective_idle_minutes"] == 4320 # 3d Strict default + assert body["effective_absolute_minutes"] == 20160 # 14d + assert body["idle_minutes_min"] == 15 + assert body["idle_minutes_max"] == 43200 + assert body["absolute_minutes_min"] == 60 + assert body["absolute_minutes_max"] == 129600 + + @pytest.mark.asyncio + async def test_patch_persists_override_and_returns_new_state( + self, client: AsyncClient, auth_headers: dict + ): + response = await client.patch( + "/api/v1/accounts/me/security", + headers=auth_headers, + json={"idle_minutes": 60, "absolute_minutes": 240}, + ) + assert response.status_code == 200, response.json() + body = response.json() + assert body["idle_minutes"] == 60 + assert body["absolute_minutes"] == 240 + assert body["effective_idle_minutes"] == 60 + assert body["effective_absolute_minutes"] == 240 + + # Next login picks up the new policy. + login_resp = await client.post( + "/api/v1/auth/login/json", + json={"email": "test@example.com", "password": "TestPassword123!"}, + ) + new_payload = jwt.decode( + login_resp.json()["refresh_token"], + settings.SECRET_KEY, + algorithms=[settings.ALGORITHM], + ) + assert new_payload["idle_max"] == 60 * 60 # 3600 seconds + assert new_payload["abs_max"] == 240 * 60 # 14400 seconds + + @pytest.mark.asyncio + async def test_patch_rejects_idle_below_min( + self, client: AsyncClient, auth_headers: dict + ): + response = await client.patch( + "/api/v1/accounts/me/security", + headers=auth_headers, + json={"idle_minutes": 5, "absolute_minutes": 60}, + ) + assert response.status_code == 422 + assert "idle_minutes" in response.json()["detail"] + + @pytest.mark.asyncio + async def test_patch_rejects_absolute_above_max( + self, client: AsyncClient, auth_headers: dict + ): + response = await client.patch( + "/api/v1/accounts/me/security", + headers=auth_headers, + json={"absolute_minutes": 200000}, + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_patch_rejects_idle_greater_than_absolute_both_set( + self, client: AsyncClient, auth_headers: dict + ): + response = await client.patch( + "/api/v1/accounts/me/security", + headers=auth_headers, + json={"idle_minutes": 300, "absolute_minutes": 120}, + ) + assert response.status_code == 422 + assert "exceed" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_patch_rejects_partial_override_when_effective_invalid( + self, client: AsyncClient, auth_headers: dict + ): + """§6 test #5 — partial override: idle=43200, absolute=NULL -> + effective idle (43200) > effective absolute (20160 default) -> 422. + """ + response = await client.patch( + "/api/v1/accounts/me/security", + headers=auth_headers, + json={"idle_minutes": 43200, "absolute_minutes": None}, + ) + assert response.status_code == 422 + assert "exceed" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_non_owner_cannot_patch( + self, client: AsyncClient, test_user: dict, test_db + ): + """§6 test #7 — engineer role is forbidden.""" + from app.models.user import User + from sqlalchemy import select + + # Add a second user in the same account with account_role=engineer. + result = await test_db.execute( + select(User).where(User.email == test_user["email"]) + ) + owner = result.scalar_one() + engineer = User( + email="engineer-policy@example.com", + password_hash=owner.password_hash, # reuse the bcrypt hash + name="Engineer", + role="engineer", + is_super_admin=False, + is_active=True, + account_id=owner.account_id, + account_role="engineer", + email_verified_at=datetime.now(timezone.utc), + ) + test_db.add(engineer) + await test_db.commit() + + login_resp = await client.post( + "/api/v1/auth/login/json", + json={ + "email": "engineer-policy@example.com", + "password": test_user["password"], + }, + ) + assert login_resp.status_code == 200 + engineer_headers = { + "Authorization": f"Bearer {login_resp.json()['access_token']}" + } + + response = await client.patch( + "/api/v1/accounts/me/security", + headers=engineer_headers, + json={"idle_minutes": 60, "absolute_minutes": 240}, + ) + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_patch_writes_audit_row( + self, client: AsyncClient, auth_headers: dict, test_db + ): + """§6 test #16 — PATCH emits one account.session_policy_update + audit event with old/new + effective_old/new payload. + """ + from app.models.audit_log import AuditLog + from sqlalchemy import select + + response = await client.patch( + "/api/v1/accounts/me/security", + headers=auth_headers, + json={"idle_minutes": 120, "absolute_minutes": 480}, + ) + assert response.status_code == 200 + + result = await test_db.execute( + select(AuditLog).where(AuditLog.action == "account.session_policy_update") + ) + rows = result.scalars().all() + assert len(rows) == 1 + entry = rows[0] + assert entry.resource_type == "account" + assert entry.details["new"] == {"idle_minutes": 120, "absolute_minutes": 480} + assert entry.details["effective_new"] == { + "idle_minutes": 120, + "absolute_minutes": 480, + } + assert entry.details["effective_old"]["idle_minutes"] == 4320 # default + assert entry.details["effective_old"]["absolute_minutes"] == 20160 + + class TestAbsoluteCap: """§6 tests #8, #9, #12 — absolute-cap enforcement and grandfather path.""" From cabd745a2ba2c4e94ef9017fb1e8ae6cd3e52b4c Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 16:31:10 -0400 Subject: [PATCH 06/13] feat(api): add POST /accounts/me/security/revoke-sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/endpoints/account_security.py | 53 +++- backend/tests/test_session_policy.py | 275 ++++++++++++++++++ 2 files changed, 327 insertions(+), 1 deletion(-) diff --git a/backend/app/api/endpoints/account_security.py b/backend/app/api/endpoints/account_security.py index 43c3bbfe..d54916d8 100644 --- a/backend/app/api/endpoints/account_security.py +++ b/backend/app/api/endpoints/account_security.py @@ -7,10 +7,11 @@ POST /accounts/me/security/revoke-sessions lands in the next commit. See docs/plans/2026-05-13-session-expiration-policy.md §4.7 / §4.11. """ +from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select +from sqlalchemy import select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import require_account_owner @@ -19,8 +20,11 @@ from app.core.audit import log_audit from app.core.config import settings from app.core.security import resolve_session_policy from app.models.account import Account +from app.models.refresh_token import RefreshToken from app.models.user import User from app.schemas.account_security import ( + RevokeSessionsRequest, + RevokeSessionsResponse, SessionPolicyResponse, SessionPolicyUpdateRequest, ) @@ -136,3 +140,50 @@ async def update_session_policy( await db.commit() await db.refresh(account) return _policy_response(account) + + +@router.post("/revoke-sessions", response_model=RevokeSessionsResponse) +async def revoke_sessions( + body: RevokeSessionsRequest, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +): + """Bulk-revoke refresh tokens for users in the caller's account. + + `scope="all"` revokes every active session in the account, including + the caller's own. `scope="others"` preserves the caller's sessions. + The caller's access token is NOT revoked (we don't track access JTIs); + it dies on its 5-minute timer. For `scope="all"`, the frontend is + expected to log the caller out locally after the response. + + See docs/plans/2026-05-13-session-expiration-policy.md §4.11. + """ + # Subquery: refresh-token rows belonging to users in this account. + user_ids_subq = select(User.id).where(User.account_id == current_user.account_id) + + stmt = ( + sa_update(RefreshToken) + .where( + RefreshToken.user_id.in_(user_ids_subq), + RefreshToken.revoked_at.is_(None), + ) + .values(revoked_at=datetime.now(timezone.utc)) + .returning(RefreshToken.id) + ) + if body.scope == "others": + stmt = stmt.where(RefreshToken.user_id != current_user.id) + + result = await db.execute(stmt) + revoked_count = len(result.all()) + + await log_audit( + db, + user_id=current_user.id, + account_id=current_user.account_id, + action="account.sessions_revoked_bulk", + resource_type="account", + resource_id=current_user.account_id, + details={"scope": body.scope, "revoked_count": revoked_count}, + ) + await db.commit() + return RevokeSessionsResponse(revoked_count=revoked_count) diff --git a/backend/tests/test_session_policy.py b/backend/tests/test_session_policy.py index 5c314f6b..2d7c3f99 100644 --- a/backend/tests/test_session_policy.py +++ b/backend/tests/test_session_policy.py @@ -8,6 +8,7 @@ This file grows across commits: - Commit 3: claims embedded at login + response fields surfaced (#1, #14) - Commit 4: absolute-cap enforcement + grandfather path (#8, #9, #12) - Commit 5: GET/PATCH /accounts/me/security (#2, #3, #4, #5, #7, #16) +- Commit 6: POST /accounts/me/security/revoke-sessions (#17-#22) """ import uuid @@ -378,6 +379,280 @@ class TestSessionPolicyEndpoint: assert entry.details["effective_old"]["absolute_minutes"] == 20160 +async def _seed_extra_account_user( + test_db, *, email: str, account_id, password_hash: str, role: str = "engineer" +): + """Add a second user under an existing account for revoke-scope tests.""" + from app.models.user import User + + user = User( + email=email, + password_hash=password_hash, + name=email, + role="engineer", + is_super_admin=False, + is_active=True, + account_id=account_id, + account_role=role, + email_verified_at=datetime.now(timezone.utc), + ) + test_db.add(user) + await test_db.commit() + return user + + +class TestBulkRevoke: + """§6 tests #17-#22 — POST /accounts/me/security/revoke-sessions.""" + + @pytest.mark.asyncio + async def test_revoke_all_kills_callers_own_session( + self, client: AsyncClient, test_user: dict, test_db + ): + """§6 test #17 — scope=all includes the caller's own token. After + the response, the caller's refresh_token gets invalid_refresh_token + on next /auth/refresh. + """ + from app.models.user import User + from sqlalchemy import select + + owner = ( + await test_db.execute( + select(User).where(User.email == test_user["email"]) + ) + ).scalar_one() + await _seed_extra_account_user( + test_db, + email="member-revoke-all@example.com", + account_id=owner.account_id, + password_hash=owner.password_hash, + ) + + # Owner logs in (also seeds owner's refresh-token row). + owner_login = await client.post( + "/api/v1/auth/login/json", + json={"email": test_user["email"], "password": test_user["password"]}, + ) + owner_refresh = owner_login.json()["refresh_token"] + owner_access = owner_login.json()["access_token"] + + # Member also logs in so there's another active refresh-token row. + member_login = await client.post( + "/api/v1/auth/login/json", + json={ + "email": "member-revoke-all@example.com", + "password": test_user["password"], + }, + ) + assert member_login.status_code == 200 + + response = await client.post( + "/api/v1/accounts/me/security/revoke-sessions", + headers={"Authorization": f"Bearer {owner_access}"}, + json={"scope": "all"}, + ) + assert response.status_code == 200, response.json() + assert response.json()["revoked_count"] == 2 + + # Owner's own refresh now returns invalid_refresh_token. + retry = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {owner_refresh}"}, + ) + assert retry.status_code == 401 + assert retry.json()["detail"] == "invalid_refresh_token" + + @pytest.mark.asyncio + async def test_revoke_others_preserves_callers_session( + self, client: AsyncClient, test_user: dict, test_db + ): + """§6 test #18 — scope=others excludes the caller's user_id from + the bulk update. Caller can still refresh; other users cannot. + """ + from app.models.user import User + from sqlalchemy import select + + owner = ( + await test_db.execute( + select(User).where(User.email == test_user["email"]) + ) + ).scalar_one() + await _seed_extra_account_user( + test_db, + email="member-revoke-others@example.com", + account_id=owner.account_id, + password_hash=owner.password_hash, + ) + + owner_login = await client.post( + "/api/v1/auth/login/json", + json={"email": test_user["email"], "password": test_user["password"]}, + ) + owner_refresh = owner_login.json()["refresh_token"] + owner_access = owner_login.json()["access_token"] + + member_login = await client.post( + "/api/v1/auth/login/json", + json={ + "email": "member-revoke-others@example.com", + "password": test_user["password"], + }, + ) + member_refresh = member_login.json()["refresh_token"] + + response = await client.post( + "/api/v1/accounts/me/security/revoke-sessions", + headers={"Authorization": f"Bearer {owner_access}"}, + json={"scope": "others"}, + ) + assert response.status_code == 200 + assert response.json()["revoked_count"] == 1 + + # Owner's refresh still works. + owner_retry = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {owner_refresh}"}, + ) + assert owner_retry.status_code == 200 + + # Member's refresh is dead. + member_retry = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {member_refresh}"}, + ) + assert member_retry.status_code == 401 + assert member_retry.json()["detail"] == "invalid_refresh_token" + + @pytest.mark.asyncio + async def test_revoke_is_account_scoped( + self, client: AsyncClient, test_user: dict, test_admin: dict + ): + """§6 test #19 — owner of account A cannot revoke tokens in account B. + + test_admin lives in its own account. After test_user's owner runs + revoke-all, test_admin's session continues to work. + """ + owner_login = await client.post( + "/api/v1/auth/login/json", + json={"email": test_user["email"], "password": test_user["password"]}, + ) + owner_access = owner_login.json()["access_token"] + + admin_login = await client.post( + "/api/v1/auth/login/json", + json={"email": test_admin["email"], "password": test_admin["password"]}, + ) + admin_refresh = admin_login.json()["refresh_token"] + + response = await client.post( + "/api/v1/accounts/me/security/revoke-sessions", + headers={"Authorization": f"Bearer {owner_access}"}, + json={"scope": "all"}, + ) + assert response.status_code == 200 + # Only test_user's own session is revoked. + assert response.json()["revoked_count"] == 1 + + admin_retry = await client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {admin_refresh}"}, + ) + assert admin_retry.status_code == 200 + + @pytest.mark.asyncio + async def test_revoke_engineer_forbidden( + self, client: AsyncClient, test_user: dict, test_db + ): + """§6 test #20 — engineer-role member gets 403.""" + from app.models.user import User + from sqlalchemy import select + + owner = ( + await test_db.execute( + select(User).where(User.email == test_user["email"]) + ) + ).scalar_one() + await _seed_extra_account_user( + test_db, + email="engineer-revoke@example.com", + account_id=owner.account_id, + password_hash=owner.password_hash, + ) + + engineer_login = await client.post( + "/api/v1/auth/login/json", + json={ + "email": "engineer-revoke@example.com", + "password": test_user["password"], + }, + ) + engineer_access = engineer_login.json()["access_token"] + + response = await client.post( + "/api/v1/accounts/me/security/revoke-sessions", + headers={"Authorization": f"Bearer {engineer_access}"}, + json={"scope": "all"}, + ) + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_revoke_writes_audit_row( + self, client: AsyncClient, test_user: dict, test_db + ): + """§6 test #21 — emits one account.sessions_revoked_bulk event.""" + from app.models.audit_log import AuditLog + from sqlalchemy import select + + owner_login = await client.post( + "/api/v1/auth/login/json", + json={"email": test_user["email"], "password": test_user["password"]}, + ) + owner_access = owner_login.json()["access_token"] + + response = await client.post( + "/api/v1/accounts/me/security/revoke-sessions", + headers={"Authorization": f"Bearer {owner_access}"}, + json={"scope": "all"}, + ) + assert response.status_code == 200 + + result = await test_db.execute( + select(AuditLog).where(AuditLog.action == "account.sessions_revoked_bulk") + ) + rows = result.scalars().all() + assert len(rows) == 1 + entry = rows[0] + assert entry.details["scope"] == "all" + assert entry.details["revoked_count"] == 1 + + @pytest.mark.asyncio + async def test_revoke_is_idempotent( + self, client: AsyncClient, test_user: dict + ): + """§6 test #22 — second immediate POST returns revoked_count=0 + (no already-revoked rows get double-stamped or counted again). + """ + owner_login = await client.post( + "/api/v1/auth/login/json", + json={"email": test_user["email"], "password": test_user["password"]}, + ) + owner_access = owner_login.json()["access_token"] + + first = await client.post( + "/api/v1/accounts/me/security/revoke-sessions", + headers={"Authorization": f"Bearer {owner_access}"}, + json={"scope": "others"}, # owner's own session preserved + ) + assert first.status_code == 200 + + second = await client.post( + "/api/v1/accounts/me/security/revoke-sessions", + headers={"Authorization": f"Bearer {owner_access}"}, + json={"scope": "others"}, + ) + assert second.status_code == 200 + assert second.json()["revoked_count"] == 0 + + class TestAbsoluteCap: """§6 tests #8, #9, #12 — absolute-cap enforcement and grandfather path.""" From aad554bb9c63f7c89d3ef8286e9e3d5812b75809 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 16:33:56 -0400 Subject: [PATCH 07/13] feat(ui): handle session_expired_{idle,absolute} in axios interceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/api/auth.ts | 2 ++ frontend/src/api/client.ts | 28 ++++++++++++++++++++---- frontend/src/pages/OAuthCallbackPage.tsx | 2 ++ frontend/src/types/auth.ts | 6 +++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index a5762fe0..160b6f8e 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -6,6 +6,8 @@ export interface OAuthCallbackResponse { refresh_token: string token_type: string is_new_user: boolean + idle_expires_at?: string | null + absolute_expires_at?: string | null } export const authApi = { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 89920130..a8aa9516 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -136,15 +136,18 @@ apiClient.interceptors.response.use( }, }) - const { access_token, refresh_token } = response.data + const { access_token, refresh_token, idle_expires_at, absolute_expires_at } = response.data localStorage.setItem('access_token', access_token) localStorage.setItem('refresh_token', refresh_token) - // Sync Zustand auth store + // Sync Zustand auth store — include the new expiry fields so + // useAuthSessionExpiry stays accurate after each refresh. useAuthStore.getState().setTokens({ access_token, refresh_token, token_type: 'bearer', + idle_expires_at, + absolute_expires_at, }) isRefreshing = false @@ -159,11 +162,28 @@ apiClient.interceptors.response.use( isRefreshing = false onRefreshFailed(refreshError) - // Refresh failed — clear tokens and redirect to login + // Refresh failed — clear tokens and redirect to login. The redirect + // target depends on WHY the refresh failed (plan §4.10): + // - session_expired_idle / session_expired_absolute: the user hit a + // policy boundary. Show the calm "session ended for security" + // banner via ?reason=session_expired. + // - invalid_refresh_token (or anything else): plain logout, no + // banner — the user wasn't kicked by policy, the token just + // wasn't recognized. + const refreshAxiosErr = refreshError as AxiosError + const refreshDetail = (refreshAxiosErr.response?.data as { detail?: string })?.detail + const isPolicyExpiry = + refreshDetail === 'session_expired_idle' || + refreshDetail === 'session_expired_absolute' + localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') useAuthStore.getState().logout() - window.location.href = '/login' + if (!window.location.pathname.startsWith('/login')) { + window.location.href = isPolicyExpiry + ? '/login?reason=session_expired' + : '/login' + } return Promise.reject(refreshError) } } diff --git a/frontend/src/pages/OAuthCallbackPage.tsx b/frontend/src/pages/OAuthCallbackPage.tsx index 6fc7ed02..b32dd080 100644 --- a/frontend/src/pages/OAuthCallbackPage.tsx +++ b/frontend/src/pages/OAuthCallbackPage.tsx @@ -103,6 +103,8 @@ export function OAuthCallbackPage() { access_token: result.access_token, refresh_token: result.refresh_token, token_type: result.token_type || 'bearer', + idle_expires_at: result.idle_expires_at, + absolute_expires_at: result.absolute_expires_at, }) // Hydrate user / account / subscription. await fetchUser() diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 717c298d..a524e6c6 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -3,6 +3,12 @@ export interface Token { refresh_token: string token_type: string must_change_password?: boolean + // ISO 8601 UTC strings derived from the refresh JWT's idle and absolute + // session windows. Used by useAuthSessionExpiry to drive the + // "your session ends soon" toast and the forced-logout fallback when + // /auth/refresh rejects with session_expired_{idle,absolute}. + idle_expires_at?: string | null + absolute_expires_at?: string | null } export interface AuthState { From c7cd7118592db7ed30575ee56d5c0185fb8c684a Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 17:07:14 -0400 Subject: [PATCH 08/13] feat: AccountSecuritySettingsPage + active-users list + toast + login banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . 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 --- backend/app/api/endpoints/account_security.py | 31 +- backend/app/schemas/account_security.py | 21 ++ backend/tests/test_session_policy.py | 15 +- .../2026-05-13-session-expiration-policy.md | 78 +++- frontend/src/api/accountSecurity.ts | 49 +++ .../account/RevokeSessionsModal.tsx | 89 +++++ .../components/common/SessionExpiryToast.tsx | 125 +++++++ frontend/src/components/layout/AppLayout.tsx | 2 + frontend/src/hooks/useAuthSessionExpiry.ts | 66 ++++ frontend/src/pages/AccountSettingsPage.tsx | 7 + frontend/src/pages/LoginPage.tsx | 15 + .../account/AccountSecuritySettingsPage.tsx | 353 ++++++++++++++++++ frontend/src/router.tsx | 9 + 13 files changed, 846 insertions(+), 14 deletions(-) create mode 100644 frontend/src/api/accountSecurity.ts create mode 100644 frontend/src/components/account/RevokeSessionsModal.tsx create mode 100644 frontend/src/components/common/SessionExpiryToast.tsx create mode 100644 frontend/src/hooks/useAuthSessionExpiry.ts create mode 100644 frontend/src/pages/account/AccountSecuritySettingsPage.tsx diff --git a/backend/app/api/endpoints/account_security.py b/backend/app/api/endpoints/account_security.py index d54916d8..c03a1ab8 100644 --- a/backend/app/api/endpoints/account_security.py +++ b/backend/app/api/endpoints/account_security.py @@ -23,6 +23,7 @@ from app.models.account import Account from app.models.refresh_token import RefreshToken from app.models.user import User from app.schemas.account_security import ( + ActiveUser, RevokeSessionsRequest, RevokeSessionsResponse, SessionPolicyResponse, @@ -32,7 +33,9 @@ from app.schemas.account_security import ( router = APIRouter(prefix="/accounts/me/security", tags=["account-security"]) -def _policy_response(account: Account) -> SessionPolicyResponse: +def _policy_response( + account: Account, active_users: list[ActiveUser] +) -> SessionPolicyResponse: eff_idle, eff_abs = resolve_session_policy(account) return SessionPolicyResponse( idle_minutes=account.session_idle_minutes, @@ -43,6 +46,7 @@ def _policy_response(account: Account) -> SessionPolicyResponse: idle_minutes_max=settings.SESSION_IDLE_MINUTES_MAX, absolute_minutes_min=settings.SESSION_ABSOLUTE_MINUTES_MIN, absolute_minutes_max=settings.SESSION_ABSOLUTE_MINUTES_MAX, + active_users=active_users, ) @@ -52,13 +56,33 @@ async def _load_account(db: AsyncSession, account_id) -> Account: ).scalar_one() +async def _load_active_users(db: AsyncSession, account_id) -> list[ActiveUser]: + """Return distinct users in this account who currently hold an + un-revoked refresh token. See plan §4.7.""" + from app.models.refresh_token import RefreshToken + + stmt = ( + select(User.id, User.name, User.email, User.last_login) + .join(RefreshToken, RefreshToken.user_id == User.id) + .where(User.account_id == account_id, RefreshToken.revoked_at.is_(None)) + .distinct() + .order_by(User.last_login.desc().nulls_last()) + ) + rows = (await db.execute(stmt)).all() + return [ + ActiveUser(user_id=row.id, name=row.name, email=row.email, last_login_at=row.last_login) + for row in rows + ] + + @router.get("", response_model=SessionPolicyResponse) async def get_session_policy( current_user: Annotated[User, Depends(require_account_owner)], db: Annotated[AsyncSession, Depends(get_admin_db)], ): account = await _load_account(db, current_user.account_id) - return _policy_response(account) + active_users = await _load_active_users(db, current_user.account_id) + return _policy_response(account, active_users) @router.patch("", response_model=SessionPolicyResponse) @@ -139,7 +163,8 @@ async def update_session_policy( ) await db.commit() await db.refresh(account) - return _policy_response(account) + active_users = await _load_active_users(db, account.id) + return _policy_response(account, active_users) @router.post("/revoke-sessions", response_model=RevokeSessionsResponse) diff --git a/backend/app/schemas/account_security.py b/backend/app/schemas/account_security.py index f70d607b..d66a8366 100644 --- a/backend/app/schemas/account_security.py +++ b/backend/app/schemas/account_security.py @@ -2,11 +2,28 @@ See docs/plans/2026-05-13-session-expiration-policy.md §4.7 and §4.11. """ +from datetime import datetime from typing import Literal, Optional +from uuid import UUID from pydantic import BaseModel, Field +class ActiveUser(BaseModel): + """One row in the active-users list on GET /accounts/me/security. + + Rendered as 'name (email) · logged in 2d ago' on the Account Security + page. `last_login_at` reflects the last successful sign-in, not the last + refresh-token use — that requires the deferred refresh_tokens.last_used_at + follow-up (see plan §9). + """ + + user_id: UUID + name: str + email: str + last_login_at: Optional[datetime] = None + + class SessionPolicyResponse(BaseModel): """GET /accounts/me/security — the policy in effect for this account. @@ -32,6 +49,10 @@ class SessionPolicyResponse(BaseModel): absolute_minutes_min: int absolute_minutes_max: int + # Active sessions in this account — users with at least one un-revoked + # refresh token. Drives the Active Sessions section in the UI. + active_users: list[ActiveUser] = Field(default_factory=list) + class SessionPolicyUpdateRequest(BaseModel): """PATCH /accounts/me/security — set or clear the per-account override. diff --git a/backend/tests/test_session_policy.py b/backend/tests/test_session_policy.py index 2d7c3f99..b1fd0e67 100644 --- a/backend/tests/test_session_policy.py +++ b/backend/tests/test_session_policy.py @@ -203,7 +203,7 @@ class TestSessionPolicyEndpoint: @pytest.mark.asyncio async def test_get_returns_defaults_and_bounds( - self, client: AsyncClient, auth_headers: dict + self, client: AsyncClient, auth_headers: dict, test_user: dict ): response = await client.get( "/api/v1/accounts/me/security", headers=auth_headers @@ -221,6 +221,19 @@ class TestSessionPolicyEndpoint: assert body["absolute_minutes_min"] == 60 assert body["absolute_minutes_max"] == 129600 + # active_users reflects users with un-revoked refresh tokens. + # auth_headers logged the owner in once, so they should appear. + assert isinstance(body["active_users"], list) + assert len(body["active_users"]) >= 1 + emails = [u["email"] for u in body["active_users"]] + assert test_user["email"] in emails + # Schema check on one row. + first = body["active_users"][0] + assert "user_id" in first + assert "name" in first + assert "email" in first + assert "last_login_at" in first + @pytest.mark.asyncio async def test_patch_persists_override_and_returns_new_state( self, client: AsyncClient, auth_headers: dict diff --git a/docs/plans/2026-05-13-session-expiration-policy.md b/docs/plans/2026-05-13-session-expiration-policy.md index 4431bd4e..184c5a67 100644 --- a/docs/plans/2026-05-13-session-expiration-policy.md +++ b/docs/plans/2026-05-13-session-expiration-policy.md @@ -172,12 +172,19 @@ Each of the four token-issuing endpoints (login, login/json, both OAuth callback 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} +GET /accounts/me/security → returns { + idle_minutes, absolute_minutes, + effective_idle_minutes, effective_absolute_minutes, + system_min/max bounds, + active_users: [{user_id, name, email, last_login_at}, ...] + } 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. +**`active_users` field** (added during plan-design-review pass on 2026-05-13): the GET response includes a list of users with at least one un-revoked refresh token in this account. Query: `SELECT DISTINCT u.id, u.email, u.name, u.last_login FROM users u JOIN refresh_tokens rt ON rt.user_id = u.id WHERE u.account_id = :acct AND rt.revoked_at IS NULL`. The frontend uses this to render the "Active sessions" section with names + relative last-login timestamps (see §4.8) rather than a faceless count. Caveat: `last_login` updates only at login, not on refresh — so the relative timestamp is honest about "when they signed in," not "last touched the app." Per-refresh activity needs the deferred `refresh_tokens.last_used_at` follow-up (§9). + ### 4.8 Frontend changes **Response-field naming (single scheme, used everywhere):** @@ -197,6 +204,12 @@ ISO strings (not Unix ints) for consistency with the rest of the API surface, wh - "soon" fires at T-5min on whichever window comes first. - Pairs with a top-of-app `` mounted in `AppLayout.tsx`. +**SessionExpiryToast — differentiated by `reason`** (locked during plan-design-review): +- **`reason === "idle"`** (idle window is closer): warning-amber tone. Copy: *"Your session times out in 5 minutes."* Action button: `[Stay signed in]` → triggers a manual `/auth/refresh` call (resets the idle window). On success, toast dismisses + the store updates `idleExpiresAt`. On failure (e.g. absolute cap is also nearby and the refresh hits `session_expired_absolute`), fall through to the standard 401-handling redirect. +- **`reason === "absolute"`** (absolute window is closer): info-cyan tone (matching the `?reason=session_expired` banner). Copy: *"Your session ends at HH:MM for security. You'll need to sign in again."* No action button — nothing the user can do extends an absolute cap. Optional secondary action: `[Sign in now]` link to `/login` for users who want to re-auth proactively. +- Toast does not auto-dismiss (persists until acted on or window expires). +- Re-fires only after a successful `/auth/refresh` extends the idle window past T-5min and we cross back into "soon" later. Does not nag. + **Modified:** `frontend/src/api/client.ts` interceptor - On 401 with `detail="session_expired_absolute"` **or** `detail="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 `/login` without the reason banner). @@ -211,14 +224,44 @@ ISO strings (not Unix ints) for consistency with the rest of the API surface, wh - The `setTokens({...})` call at `OAuthCallbackPage.tsx:102` currently passes `{access_token, refresh_token, token_type}` from the `OAuthCallbackResponse`. Add `idle_expires_at` and `absolute_expires_at` to 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 `/account` routing with `requireRoleOwner` style 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. +- Lives under existing `/account` routing with `requireRoleOwner` style guard. Card lives in `AccountSettingsPage.tsx` grid alongside Branding / Chat Retention; **hidden entirely for non-owners** (matches existing role-conditional rendering at `AccountSettingsPage.tsx:597-651`). +- Page shell matches `ChatRetentionSettingsPage.tsx`: `max-w-2xl mx-auto py-8 px-6`, header row with Lucide icon + Bricolage 22px page title, `card-flat rounded-2xl p-6 space-y-6` body. +- **Vertical order (top → bottom):** + 1. Page header (Lucide `Shield` icon + "Session Security") + 2. One-line intro paragraph (`text-muted-foreground`): *"Control how long sessions can last before users must sign in again."* + 3. **Session policy** card: three radios (Strict / Standard / Custom) with effective minute values visible per option ("Strict — 3d idle, 14d absolute"), then two numeric inputs (Idle minutes, Absolute minutes). **Inputs are always visible; disabled when a preset is selected.** Below inputs: hint text showing the system min/max from the GET response. Save button (primary) + inline `text-emerald-400 "Settings saved"` success ping for 3s after save (matching `ChatRetentionSettingsPage.tsx:112-114`). + 4. Info line directly below Save: *"New policy applies the next time each person signs in. Use **Active sessions** below to force it immediately."* (`text-muted-foreground`, bold on "Active sessions" — anchor link or just visual emphasis). + 5. Visual divider (1px `border-default`). + 6. **Active sessions** section (see below for details). +- **Initial GET loading state:** centered `Loader2 animate-spin` page-body, matching `ChatRetentionSettingsPage.tsx:46-51`. +- **Inline validation** on Custom inputs: debounced 300ms; red border (`border-danger`) + small error text below field; Save button disabled when any field is invalid. Server-side 422 from PATCH surfaces via the existing axios interceptor toast. + +**Active sessions section (within the same page):** +- GET response includes `active_users: [{user_id, name, email, last_login_at}, ...]` — backend addition; see §4.7. +- Section header: "Active sessions" +- Subhead: "N people are signed in to this account." (singular: "Only you are signed in.") +- Active-users list: one row per active user — `name (email) · logged in 2d ago` (relative time from `last_login_at`). Caller's own row marked with a small "(you)" tag. +- Buttons below the list — count-aware: + - **count > 1:** Two ghost buttons side-by-side — `[Sign out everyone except me]` and `[Sign me out and everyone else]` (the latter uses `text-danger` color to telegraph the self-impact). + - **count = 1 (solo owner):** Hide the "except me" button (it would revoke 0 — confusing). Show only `[Sign me out everywhere]` (still useful — signs the owner out from their other devices). + +**Bulk-revoke confirmation modal** (via `components/common/Modal.tsx`): +- **scope=others:** title *"Sign out other users?"* · body *"This signs out the N other active users in your account. They'll need to sign in again. You stay signed in."* · buttons `[Cancel]` (ghost) + `[Sign out N users]` (`text-danger`). +- **scope=all:** title *"Sign out everyone?"* · body *"This signs out all N active users including yourself. Everyone will need to sign in again."* · buttons `[Cancel]` (ghost) + `[Sign out everyone]` (`text-danger`). +- After success: modal closes, `toast.success("Signed out N sessions")`. For scope=all: 1.5s delay → `useAuthStore.getState().logout()` + `window.location = '/login'` (no banner — they just did this, they know why they're here). + +**Modified:** `AccountSettingsPage.tsx` +- Add a "Session Security" link card to the existing grid (owner-only visibility, alongside Branding / Chat Retention). Lucide `Shield` icon. + +**New login page banner:** when `?reason=session_expired` is present, show a small info-tone banner **above the email/password form**: +- Background: `info-dim` (cyan-dim, `rgba(103,232,249,0.10)` dark / `rgba(8,145,178,0.07)` light per DESIGN-SYSTEM.md) +- Text color: `info` text token +- Border: `1px solid info-dim` +- Padding: 12px 16px, `radius-sm` (5px) +- Icon: Lucide `Info` (16px, info color, left-aligned) +- Copy: *"You were signed out for security. Sign back in to continue."* +- Not dismissable — disappears naturally when the user submits the form (the query string clears on navigate). +- Note: this is the first cyan info-tone banner in the app; sets the precedent we'll reuse for future neutral system messages. **Modified:** `AccountSettingsPage.tsx` - Add a "Session Security" link card to the existing grid (owner-only visibility). @@ -412,7 +455,7 @@ Follow-up issues to file after this plan is approved (not blocking this PR): 5. `feat(api): add GET/PATCH /accounts/me/security endpoint` (router, schemas, owner gate, bounds + partial-override invariant validation, audit logging on PATCH). 6. `feat(api): add POST /accounts/me/security/revoke-sessions` (bulk-revoke endpoint with `scope=all|others`, single-UPDATE implementation, audit logging, tests #17–#22). 7. `feat(ui): handle session_expired_{idle,absolute} in axios interceptor + authStore` (new fields persisted, legacy-state migration, redirect to `/login?reason=session_expired`). -8. `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_expired` banner). +8. `feat: AccountSecuritySettingsPage + active-users list + toasts + login banner` (Strict/Standard/Custom presets with always-visible-disabled Custom inputs, count-aware Active Sessions section with name/email/last-login rows, differentiated SessionExpiryToast for idle-vs-absolute, cyan info-tone login banner, scope=all auto-redirect-after-toast UX. Includes a small backend addition: `active_users` field on `GET /accounts/me/security` — see §4.7). 9. `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. @@ -433,3 +476,18 @@ Each commit independently passes `pytest --override-ini="addopts="` and `npm run - [ ] 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). + +--- + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | not run | +| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | not run | +| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | not run (the plan itself was eng-reviewed inline across 7 commits — backend complete & green) | +| Design Review | `/plan-design-review` | UI/UX gaps | 1 | CLEAR (PLAN) | score: 4/10 → 9/10, 7 decisions added | +| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | not run | + +**UNRESOLVED:** 0 design decisions; 3 plan-level checklist items remain (system bounds, commit sequence, Phase O sequencing — none block design). +**VERDICT:** DESIGN CLEARED — page layout, state coverage, post-revoke flow, toast logic, login banner tone, and form copy all locked. Commit 8 has a complete spec. diff --git a/frontend/src/api/accountSecurity.ts b/frontend/src/api/accountSecurity.ts new file mode 100644 index 00000000..cd7166ba --- /dev/null +++ b/frontend/src/api/accountSecurity.ts @@ -0,0 +1,49 @@ +import apiClient from './client' + +export interface ActiveUser { + user_id: string + name: string + email: string + last_login_at: string | null +} + +export interface SessionPolicyResponse { + idle_minutes: number | null + absolute_minutes: number | null + effective_idle_minutes: number + effective_absolute_minutes: number + idle_minutes_min: number + idle_minutes_max: number + absolute_minutes_min: number + absolute_minutes_max: number + active_users: ActiveUser[] +} + +export interface SessionPolicyUpdateRequest { + idle_minutes: number | null + absolute_minutes: number | null +} + +export interface RevokeSessionsResponse { + revoked_count: number +} + +export const accountSecurityApi = { + async get(): Promise { + const response = await apiClient.get('/accounts/me/security') + return response.data + }, + + async update(body: SessionPolicyUpdateRequest): Promise { + const response = await apiClient.patch('/accounts/me/security', body) + return response.data + }, + + async revokeSessions(scope: 'all' | 'others'): Promise { + const response = await apiClient.post( + '/accounts/me/security/revoke-sessions', + { scope }, + ) + return response.data + }, +} diff --git a/frontend/src/components/account/RevokeSessionsModal.tsx b/frontend/src/components/account/RevokeSessionsModal.tsx new file mode 100644 index 00000000..9fe853fb --- /dev/null +++ b/frontend/src/components/account/RevokeSessionsModal.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react' +import { Modal } from '@/components/common/Modal' +import { cn } from '@/lib/utils' + +interface RevokeSessionsModalProps { + isOpen: boolean + onClose: () => void + onConfirm: () => Promise + scope: 'all' | 'others' + activeUserCount: number +} + +/** + * Confirmation modal for bulk session revocation. Two scopes: + * + * - "others" — revokes other users' sessions, caller stays signed in. + * - "all" — revokes everyone including the caller; the parent handles + * the post-revoke auto-redirect to /login (see plan §4.8 D4). + */ +export function RevokeSessionsModal({ + isOpen, + onClose, + onConfirm, + scope, + activeUserCount, +}: RevokeSessionsModalProps) { + const [busy, setBusy] = useState(false) + + const isAll = scope === 'all' + const otherCount = isAll ? activeUserCount : Math.max(activeUserCount - 1, 0) + + const title = isAll ? 'Sign out everyone?' : 'Sign out other users?' + const body = isAll + ? `This signs out all ${activeUserCount} active users including yourself. Everyone will need to sign in again.` + : `This signs out the ${otherCount} other active users in your account. They'll need to sign in again. You stay signed in.` + const confirmLabel = isAll + ? 'Sign out everyone' + : otherCount === 1 + ? 'Sign out 1 user' + : `Sign out ${otherCount} users` + + const handleConfirm = async () => { + setBusy(true) + try { + await onConfirm() + } finally { + setBusy(false) + } + } + + return ( + undefined : onClose} + title={title} + size="sm" + footer={ +
+ + +
+ } + > +

{body}

+
+ ) +} diff --git a/frontend/src/components/common/SessionExpiryToast.tsx b/frontend/src/components/common/SessionExpiryToast.tsx new file mode 100644 index 00000000..23ab37e3 --- /dev/null +++ b/frontend/src/components/common/SessionExpiryToast.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { AlertCircle, Info, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useAuthSessionExpiry } from '@/hooks/useAuthSessionExpiry' +import { authApi } from '@/api/auth' +import { useAuthStore } from '@/store/authStore' + +/** + * Top-of-app notice that fires when the session is within 5 minutes of + * idle OR absolute expiry. Behavior differs by which window is closer + * (per docs/plans/2026-05-13-session-expiration-policy.md §4.8): + * + * - Idle: warning-amber tone, "Stay signed in" button hits /auth/refresh. + * - Absolute: info-cyan tone, no action — re-auth is required. + * + * Persists until the user dismisses, refreshes, or the window expires. + */ +export function SessionExpiryToast() { + const { warning, reason, idleExpiresAt, absoluteExpiresAt } = useAuthSessionExpiry() + const setTokens = useAuthStore((s) => s.setTokens) + const navigate = useNavigate() + const [busy, setBusy] = useState(false) + const [dismissed, setDismissed] = useState(false) + + if (warning !== 'soon' || dismissed) return null + + const handleStay = async () => { + setBusy(true) + try { + const refreshed = await authApi.refresh() + localStorage.setItem('access_token', refreshed.access_token) + localStorage.setItem('refresh_token', refreshed.refresh_token) + setTokens(refreshed) + setDismissed(true) + } catch { + // The axios interceptor handles the redirect on session_expired_*; + // if we land here, something else went wrong — just close the toast. + setDismissed(true) + } finally { + setBusy(false) + } + } + + const handleSignInNow = () => navigate('/login') + + // ── Format the deadline for the absolute case ── + const deadline = reason === 'idle' ? idleExpiresAt : absoluteExpiresAt + const deadlineLabel = deadline + ? deadline.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + : '' + + const isIdle = reason === 'idle' + const Icon = isIdle ? AlertCircle : Info + + return ( +
+ +
+

+ {isIdle + ? 'Your session times out in 5 minutes.' + : `Your session ends at ${deadlineLabel} for security.`} +

+

+ {isIdle + ? 'Click to stay signed in.' + : "You'll need to sign in again."} +

+
+ {isIdle ? ( + + ) : ( + + )} + +
+
+ +
+ ) +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 952bf776..5eaaf104 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -12,6 +12,7 @@ import { EmailVerificationBanner } from './EmailVerificationBanner' import { EmailVerificationGate } from '@/components/common/EmailVerificationGate' import { ViewTransitionOutlet } from './ViewTransitionOutlet' import { FeedbackWidget } from '@/components/common/FeedbackWidget' +import { SessionExpiryToast } from '@/components/common/SessionExpiryToast' import { cn } from '@/lib/utils' export function AppLayout() { @@ -69,6 +70,7 @@ export function AppLayout() { return ( <> +
['token']): ExpiryState { + const idleStr = token?.idle_expires_at + const absStr = token?.absolute_expires_at + if (!idleStr || !absStr) { + return { idleExpiresAt: null, absoluteExpiresAt: null, warning: 'none', reason: null } + } + const idle = new Date(idleStr) + const abs = new Date(absStr) + const now = Date.now() + const idleMs = idle.getTime() - now + const absMs = abs.getTime() - now + + // Closer window wins. + const reason: ExpiryReason = idleMs <= absMs ? 'idle' : 'absolute' + const closestMs = Math.min(idleMs, absMs) + + let warning: ExpiryWarning = 'none' + if (closestMs <= 0) warning = 'now' + else if (closestMs <= SOON_MS) warning = 'soon' + + return { idleExpiresAt: idle, absoluteExpiresAt: abs, warning, reason } +} + +/** + * Track how close the active session is to its idle/absolute deadline. + * + * Returns `warning: "soon"` within 5 min of whichever window comes first, + * and `reason: "idle" | "absolute"` so callers can choose the right UX + * (idle is recoverable via /auth/refresh; absolute is not). Re-evaluates + * every 30 seconds while authenticated; cheap (single Date subtraction). + * + * See docs/plans/2026-05-13-session-expiration-policy.md §4.8. + */ +export function useAuthSessionExpiry(): ExpiryState { + const token = useAuthStore((s) => s.token) + const [state, setState] = useState(() => computeState(token)) + + useEffect(() => { + setState(computeState(token)) + if (!token?.idle_expires_at || !token?.absolute_expires_at) return + const interval = window.setInterval(() => setState(computeState(token)), 30_000) + return () => window.clearInterval(interval) + }, [token]) + + return state +} diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index 35e79945..8b888f36 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -17,6 +17,7 @@ import { Plug, RefreshCw, Server, + Shield, UserCog, X, } from 'lucide-react' @@ -632,6 +633,12 @@ export function AccountSettingsPage() { title="Chat retention" description="Conversation retention and assistant data lifecycle" /> + } + title="Session security" + description="Session-expiration policy and active sessions" + /> } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 1a85b337..420fb8e2 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { Link, useNavigate, useLocation } from 'react-router-dom' +import { Info } from 'lucide-react' import { useAuthStore } from '@/store/authStore' import { BrandLogo } from '@/components/common/BrandLogo' import { PasswordInput } from '@/components/common/PasswordInput' @@ -17,6 +18,11 @@ export function LoginPage() { const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/' + // When the user lands here after the session-policy axios interceptor + // forcibly logged them out, show a calm info-tone banner above the form. + // See docs/plans/2026-05-13-session-expiration-policy.md §4.8. + const showSessionExpiredBanner = new URLSearchParams(location.search).get('reason') === 'session_expired' + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setLocalError('') @@ -60,6 +66,15 @@ export function LoginPage() {

+ {showSessionExpiredBanner && ( +
+ +

+ You were signed out for security. Sign back in to continue. +

+
+ )} +
{(error || localError) && ( diff --git a/frontend/src/pages/account/AccountSecuritySettingsPage.tsx b/frontend/src/pages/account/AccountSecuritySettingsPage.tsx new file mode 100644 index 00000000..9a759d81 --- /dev/null +++ b/frontend/src/pages/account/AccountSecuritySettingsPage.tsx @@ -0,0 +1,353 @@ +import { useEffect, useMemo, useState } from 'react' +import { Loader2, Save, Shield } from 'lucide-react' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' +import { useAuthStore } from '@/store/authStore' +import { accountSecurityApi, type SessionPolicyResponse } from '@/api/accountSecurity' +import { RevokeSessionsModal } from '@/components/account/RevokeSessionsModal' + +type Preset = 'strict' | 'standard' | 'custom' + +const PRESETS: Record, { idle: number; absolute: number; label: string; sub: string }> = { + strict: { idle: 4320, absolute: 20160, label: 'Strict', sub: '3 days idle · 14 days absolute' }, + standard: { idle: 10080, absolute: 43200, label: 'Standard', sub: '7 days idle · 30 days absolute' }, +} + +function detectPreset(idle: number, absolute: number): Preset { + if (idle === PRESETS.strict.idle && absolute === PRESETS.strict.absolute) return 'strict' + if (idle === PRESETS.standard.idle && absolute === PRESETS.standard.absolute) return 'standard' + return 'custom' +} + +function relativeFromNow(iso: string | null): string { + if (!iso) return 'unknown' + const diffMs = Date.now() - new Date(iso).getTime() + const m = Math.round(diffMs / 60_000) + if (m < 1) return 'just now' + if (m < 60) return `${m}m ago` + const h = Math.round(m / 60) + if (h < 24) return `${h}h ago` + const d = Math.round(h / 24) + if (d < 30) return `${d}d ago` + return new Date(iso).toLocaleDateString() +} + +export default function AccountSecuritySettingsPage() { + const currentUserId = useAuthStore((s) => s.user?.id) ?? null + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [preset, setPreset] = useState('strict') + const [idleMin, setIdleMin] = useState('') + const [absMin, setAbsMin] = useState('') + const [saving, setSaving] = useState(false) + const [success, setSuccess] = useState(false) + const [modalScope, setModalScope] = useState<'all' | 'others' | null>(null) + + useEffect(() => { + void load() + }, []) + + const load = async () => { + setLoading(true) + try { + const res = await accountSecurityApi.get() + setData(res) + const eff = res + const detected = detectPreset(eff.effective_idle_minutes, eff.effective_absolute_minutes) + setPreset(detected) + setIdleMin(String(eff.effective_idle_minutes)) + setAbsMin(String(eff.effective_absolute_minutes)) + } catch { + toast.error('Could not load security settings') + } finally { + setLoading(false) + } + } + + const customDisabled = preset !== 'custom' + + // Sync the inputs to the chosen preset so the visible values track the radio. + const handlePresetChange = (next: Preset) => { + setPreset(next) + if (next === 'strict') { + setIdleMin(String(PRESETS.strict.idle)) + setAbsMin(String(PRESETS.strict.absolute)) + } else if (next === 'standard') { + setIdleMin(String(PRESETS.standard.idle)) + setAbsMin(String(PRESETS.standard.absolute)) + } + } + + const validation = useMemo(() => { + if (!data) return { idleErr: null, absErr: null, ok: false } + const idle = parseInt(idleMin, 10) + const abs = parseInt(absMin, 10) + let idleErr: string | null = null + let absErr: string | null = null + if (!Number.isFinite(idle)) idleErr = 'Required' + else if (idle < data.idle_minutes_min || idle > data.idle_minutes_max) { + idleErr = `Between ${data.idle_minutes_min} and ${data.idle_minutes_max}` + } + if (!Number.isFinite(abs)) absErr = 'Required' + else if (abs < data.absolute_minutes_min || abs > data.absolute_minutes_max) { + absErr = `Between ${data.absolute_minutes_min} and ${data.absolute_minutes_max}` + } + if (!idleErr && !absErr && idle > abs) { + idleErr = 'Idle cannot exceed absolute' + } + return { idleErr, absErr, ok: !idleErr && !absErr } + }, [data, idleMin, absMin]) + + const handleSave = async () => { + if (!validation.ok || !data) return + setSaving(true) + setSuccess(false) + try { + const body = + preset === 'custom' + ? { idle_minutes: parseInt(idleMin, 10), absolute_minutes: parseInt(absMin, 10) } + : preset === 'strict' + ? { idle_minutes: PRESETS.strict.idle, absolute_minutes: PRESETS.strict.absolute } + : { idle_minutes: PRESETS.standard.idle, absolute_minutes: PRESETS.standard.absolute } + const updated = await accountSecurityApi.update(body) + setData(updated) + setSuccess(true) + setTimeout(() => setSuccess(false), 3000) + } catch { + // Global axios interceptor surfaces 422 via toast. + } finally { + setSaving(false) + } + } + + const handleRevokeConfirm = async () => { + if (!modalScope) return + const scope = modalScope + try { + const res = await accountSecurityApi.revokeSessions(scope) + toast.success(`Signed out ${res.revoked_count} sessions`) + setModalScope(null) + if (scope === 'all') { + // Per plan §4.8 D4: small delay so the user sees the toast, + // then clear local state and redirect to /login. + setTimeout(() => { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + useAuthStore.getState().logout() + window.location.href = '/login' + }, 1500) + } else { + // scope=others: reload to reflect the new (shorter) active-users list. + await load() + } + } catch { + // global handler + } + } + + if (loading || !data) { + return ( +
+ +
+ ) + } + + const activeUserCount = data.active_users.length + const solo = activeUserCount <= 1 + + return ( +
+
+ +

Session Security

+
+

+ Control how long sessions can last before users must sign in again. +

+ + {/* ── Policy card ────────────────────────────────────────────────── */} +
+
+ Policy + + {(['strict', 'standard', 'custom'] as const).map((p) => { + const isSelected = preset === p + const labels = p === 'custom' + ? { label: 'Custom', sub: 'Set your own idle and absolute windows below' } + : PRESETS[p] + return ( + + ) + })} +
+ + {/* Custom inputs — always visible, disabled outside Custom */} +
+
+ + setIdleMin(e.target.value)} + disabled={customDisabled} + min={data.idle_minutes_min} + max={data.idle_minutes_max} + className={cn( + 'w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5', + 'focus:outline-hidden focus:border-primary/30', + 'disabled:opacity-50 disabled:cursor-not-allowed', + validation.idleErr && !customDisabled && 'border-danger/50', + )} + style={{ + borderColor: + validation.idleErr && !customDisabled ? undefined : 'var(--color-border-default)', + }} + /> +

+ {validation.idleErr && !customDisabled + ? validation.idleErr + : `Between ${data.idle_minutes_min} and ${data.idle_minutes_max} min`} +

+
+
+ + setAbsMin(e.target.value)} + disabled={customDisabled} + min={data.absolute_minutes_min} + max={data.absolute_minutes_max} + className={cn( + 'w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5', + 'focus:outline-hidden focus:border-primary/30', + 'disabled:opacity-50 disabled:cursor-not-allowed', + validation.absErr && !customDisabled && 'border-danger/50', + )} + style={{ + borderColor: + validation.absErr && !customDisabled ? undefined : 'var(--color-border-default)', + }} + /> +

+ {validation.absErr && !customDisabled + ? validation.absErr + : `Between ${data.absolute_minutes_min} and ${data.absolute_minutes_max} min`} +

+
+
+ +
+ + {success && Settings saved} +
+ +

+ New policy applies the next time each person signs in. Use{' '} + Active sessions below to force it + immediately. +

+
+ + {/* ── Active sessions card ───────────────────────────────────────── */} +
+
+

Active sessions

+

+ {solo + ? 'Only you are signed in to this account.' + : `${activeUserCount} people are signed in to this account.`} +

+
+ +
    + {data.active_users.map((u) => { + const isMe = u.user_id === currentUserId + return ( +
  • +
    +
    + {u.name} + {isMe && ( + + (you) + + )} +
    +
    + {u.email} · last signed in {relativeFromNow(u.last_login_at)} +
    +
    +
  • + ) + })} +
+ +
+ {!solo && ( + + )} + +
+
+ + setModalScope(null)} + onConfirm={handleRevokeConfirm} + /> +
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 02ce56f2..cadd0032 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -101,6 +101,7 @@ const ProfileSettingsPage = lazyWithRetry(() => import('@/pages/account/ProfileS const TeamCategoriesPage = lazyWithRetry(() => import('@/pages/account/TeamCategoriesPage')) const TargetListsPage = lazyWithRetry(() => import('@/pages/account/TargetListsPage')) const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage')) +const AccountSecuritySettingsPage = lazyWithRetry(() => import('@/pages/account/AccountSecuritySettingsPage')) const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage')) const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage')) const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage')) @@ -341,6 +342,14 @@ export const router = sentryCreateBrowserRouter([ ), }, + { + path: 'security', + element: ( + + {page(AccountSecuritySettingsPage)} + + ), + }, { path: 'target-lists', element: page(TargetListsPage) }, { path: 'integrations', From 1106f796111804109b5124efb1e3a53d15485367 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 17:09:09 -0400 Subject: [PATCH 09/13] docs: add session-expiration-policy decision entry + CURRENT-STATE summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .ai/DECISIONS.md | 28 ++++++++++++++++++++++++++++ CURRENT-STATE.md | 2 ++ 2 files changed, 30 insertions(+) diff --git a/.ai/DECISIONS.md b/.ai/DECISIONS.md index df582fcf..02e1df14 100644 --- a/.ai/DECISIONS.md +++ b/.ai/DECISIONS.md @@ -13,6 +13,34 @@ --- +## 2026-05-13 — Session expiration policy: 3d idle / 14d absolute defaults + per-account override + +**Context:** User report: "I login to ResolutionFlow and never have to log back in." Investigation found refresh tokens at `REFRESH_TOKEN_EXPIRE_DAYS=7` with JTI rotation (`security.py:36`) — every `/auth/refresh` minted a fresh 7-day window. Net effect: a sliding 7-day session with no absolute cap. Visit once a week, logged in forever. Acceptable for pilot but not for MSP buyers whose SOC2 / cyber-insurance auditors require enforced session timeouts. Required for the same Phase O launch readiness as the other gates already in flight. + +**Decision:** Two-window model snapshotted into the refresh JWT at login. Defaults to Strict (3-day idle, 14-day absolute), bounded by env-var system min/max. Per-account override via two new `accounts` columns (NULL = use system default). Owner-only `GET/PATCH /accounts/me/security` endpoint with effective-value validation (partial-override case caught at the app layer because the DB CHECK can't see Settings). Sibling `POST /accounts/me/security/revoke-sessions` for `all|others`-scoped bulk revocation. Frontend: Strict/Standard/Custom presets, active-users list (name + email + last-login-ago), differentiated SessionExpiryToast (idle = warning amber with "Stay signed in" → `/auth/refresh`; absolute = info cyan, informational only), cyan info-tone banner on `/login?reason=session_expired`, auto-redirect after scope=all bulk-revoke. Error-detail taxonomy on the wire: `session_expired_idle`, `session_expired_absolute`, `invalid_refresh_token`. Grandfather path: legacy refresh tokens (no `auth_time` claim) get one free rotation under the new policy. Atomic-revoke-then-check on `/auth/refresh` so absolute-expired tokens can't be replayed. + +8 commits on `feat/session-expiration-policy` branch (`92fa3bc` → `c7cd711`), ~1300 LoC backend + frontend including 28 backend tests. Plan + design review at `docs/plans/2026-05-13-session-expiration-policy.md` (initial design score 4/10 → final 9/10 via `/plan-design-review`; 7 design decisions locked). + +**Rejected:** +- **Idle-only or absolute-only enforcement.** Idle without absolute is the current broken state (sliding forever). Absolute without idle is too strict — kicks users out daily. +- **Hard cutover on deploy (SECRET_KEY rotation).** Forces every pilot to log in again immediately; high support cost. Grandfather path is friendlier and adds ~50 lines of code. +- **Distinguish `session_revoked_by_admin` from `invalid_refresh_token` on the wire** for users whose sessions were killed via bulk-revoke. Requires tracking revocation reason per `refresh_tokens` row. Not worth the complexity for v1 — affected users see they're logged out, same as any other revoke. +- **Per-user device list with per-device revoke.** Refresh tokens don't carry device/user-agent metadata today. Account-wide bulk revoke covers the breach-response use case; per-device is a follow-up if pilots ask. +- **"Loose" preset (90d).** Strict default suggests we shouldn't ship a one-click loose option. Owners who want a loose policy can use Custom and own the choice explicitly. +- **Always-required `idle_minutes`+`absolute_minutes` (XOR-NULL invariant).** Forces owners who only want to override idle to also re-declare the absolute window, leaking the system default into account data. Partial overrides allowed; validated at the app layer against current defaults. +- **Reveal-on-Custom UI for the minute inputs.** Hidden-by-default-reveal-on-radio shifts page layout when Custom is selected. Always-visible-but-disabled is more stable and previews the Custom interaction. +- **Modal-stays-open-success-state for scope=all bulk-revoke.** User preferred auto-redirect-with-toast (more standard SaaS pattern); the toast acts as the success acknowledgment before /login loads. + +**Consequences:** +- "Logged in forever" is fixed. Every user sees a hard 14-day re-auth at minimum (3-day idle in practice for typical usage). +- Account owners get a complete self-service surface for policy + bulk session control. New `/account/security` route, owner-gated. +- Audit-log entries on both mutations: `account.session_policy_update` and `account.sessions_revoked_bulk`. SOC2-ready. +- Frontend `idle_expires_at` + `absolute_expires_at` flow through the entire auth surface (`Token`, `OAuthCallbackResponse`, `authStore`, persistence). `useAuthSessionExpiry` hook is the single source for "is the session about to end." +- Future improvements (filed as follow-ups in plan §9): per-user device list (requires `refresh_tokens.last_used_at` column), super-admin global ceiling UI, per-user policy. None block current shipping. +- Cyan info-tone banner on `/login` is the first of its kind in the app; sets precedent for future neutral system messages. + +--- + ## 2026-05-07 — Per-email allowlist (`INTERNAL_TESTER_EMAILS`) for self-serve soft cutover **Context:** Phase O Task 46 ("internal validation pass") needed a way to exercise the full self-serve flow against the prod backend before flipping `SELF_SERVE_ENABLED=true` for everyone. The plan doc described the mechanism but the backend support was never built — flagged in `SESSION_LOG.md` as a code blocker. Stripe live-mode setup is also gated on having a working internal-tester path in prod test mode. diff --git a/CURRENT-STATE.md b/CURRENT-STATE.md index 956bb596..90c9a983 100644 --- a/CURRENT-STATE.md +++ b/CURRENT-STATE.md @@ -14,6 +14,8 @@ Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover ( ## Recently shipped (post-0.1.0.0) +- **2026-05-13 — `feat/session-expiration-policy` (open)** Session expiration policy series — 8 commits, fixes the "logged in forever" bug and adds owner-side controls. Migration `b269a1add160` adds `accounts.session_idle_minutes` + `session_absolute_minutes` (NULL = use system default, defaults Strict 3d/14d via `Settings.SESSION_*_MINUTES_DEFAULT`). Refresh-token JWT carries `auth_time` + `idle_max` + `abs_max` claims (seconds) snapshotted at every login entry point (`/auth/login`, `/auth/login/json`, both OAuth callbacks). `/auth/refresh` enforces absolute cap (`now >= auth_time + abs_max` → 401 `session_expired_absolute`), atomic-revoke-then-check prevents replay. Error-detail taxonomy on the wire distinguishes `session_expired_idle` / `session_expired_absolute` / `invalid_refresh_token`. New owner-only `GET/PATCH /accounts/me/security` returns `{idle_minutes, absolute_minutes, effective_*, *_min/max, active_users}` with audit logging on PATCH. `POST /accounts/me/security/revoke-sessions` bulk-revokes refresh tokens for the account (`scope: "all" | "others"`), audited. Frontend: new `/account/security` page (Strict/Standard/Custom presets, active-users list with name + email + last-login-ago, count-aware revoke buttons + confirmation modal), `useAuthSessionExpiry` hook + top-of-app `SessionExpiryToast` (differentiated by idle vs absolute), cyan info-tone banner on `/login?reason=session_expired`. Plan + design review in `docs/plans/2026-05-13-session-expiration-policy.md` (initial 4/10 → 9/10 via `/plan-design-review`). 28 backend tests; tsc clean. Pending: open PR, merge, document follow-up issues (per-user device list, super-admin global ceiling UI). + - **2026-05-07 — PR #164 (open)** Plan taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist. Marketing surface (PricingPage, Stripe products) used `Starter / Pro / Enterprise` while backend was on `free / pro / team`, leaving `plan_billing` unseeded and `BillingPlan` schema accepting a literal that violated the FK. Migration `4ce3e594cb87`: rename `team` → `enterprise` in `plan_limits`, add `starter` row (caps interpolated between free and pro: `max_trees=10`, `sessions=75`, `ai=15/mo`), defensive update of any subscriptions on the `team` slug. Code rename across schemas, `Subscription` paid-plan checks, admin endpoints, and frontend `useSubscription`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match, picks active monthly recurring price, leaves annual fields NULL by design. Test-mode `plan_billing` populated for all 3 tiers in dev. Phase O Task 46 allowlist: `INTERNAL_TESTER_EMAILS` env var (comma-separated) bypasses `SELF_SERVE_ENABLED=false` for specific authenticated users — `Settings.is_self_serve_active_for(email)` centralizes the check; `/config/public` returns `self_serve_enabled=true` for allowlisted authenticated callers; `/auth/register` allows allowlisted emails to register without invite code. New `get_current_user_optional` dep for endpoints that work both anonymous and authed. - **2026-05-06 — PR #163** Seed test users marked email-verified. Fixed seeded users showing the email verification banner in dev/test, blocking flows that gate on `email_verified=True`. Squash-merged into main as `dad5e1f`. From 8d79dd93b8093ade3c67132d34dbf64a0db708a0 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 15:48:21 -0400 Subject: [PATCH 10/13] feat(dashboard): focus same-page Start Session input from NextStep CTA and checklist 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 --- .../src/components/dashboard/NextStepCard.tsx | 35 ++++++++++++++----- .../components/dashboard/SetupChecklist.tsx | 16 +++++++++ .../dashboard/StartSessionInput.tsx | 28 +++++++++++++-- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/dashboard/NextStepCard.tsx b/frontend/src/components/dashboard/NextStepCard.tsx index 5d54a266..9b158d5a 100644 --- a/frontend/src/components/dashboard/NextStepCard.tsx +++ b/frontend/src/components/dashboard/NextStepCard.tsx @@ -6,6 +6,7 @@ import type { OnboardingStatus } from '@/api/onboarding' import { useTrialBanner } from '@/hooks/useTrialBanner' import type { TrialBannerStage } from '@/hooks/useTrialBanner' import { useOnboardingStatus } from '@/hooks/useOnboardingStatus' +import { FOCUS_START_SESSION_EVENT } from '@/components/dashboard/StartSessionInput' /** * Next-step card — surfaces the single highest-priority incomplete onboarding @@ -114,9 +115,10 @@ export function pickNextStep( export function NextStepCard() { const status = useOnboardingStatus() const [locallyDismissed, setLocallyDismissed] = useState(false) + const [locallyHidden, setLocallyHidden] = useState(false) const { stage } = useTrialBanner() - if (!status || status.dismissed || locallyDismissed) return null + if (!status || status.dismissed || locallyDismissed || locallyHidden) return null const next = pickNextStep(status, stage) if (!next) return null @@ -154,14 +156,29 @@ export function NextStepCard() {
- - {next.ctaLabel} - - + {next.key === 'ran_session' ? ( + + ) : ( + + {next.ctaLabel} + + + )}
) diff --git a/frontend/src/components/dashboard/SetupChecklist.tsx b/frontend/src/components/dashboard/SetupChecklist.tsx index 7d8677b2..13ddcb1f 100644 --- a/frontend/src/components/dashboard/SetupChecklist.tsx +++ b/frontend/src/components/dashboard/SetupChecklist.tsx @@ -5,6 +5,7 @@ import type { OnboardingStatus } from '@/api/onboarding' import { useTrialBanner } from '@/hooks/useTrialBanner' import type { TrialBannerStage } from '@/hooks/useTrialBanner' import { useOnboardingStatus } from '@/hooks/useOnboardingStatus' +import { FOCUS_START_SESSION_EVENT } from '@/components/dashboard/StartSessionInput' /** * Unified setup checklist — single list (no SOLO/TEAM bifurcation). @@ -112,6 +113,21 @@ export function SetupChecklist() { {item.label} + ) : item.key === 'ran_session' ? ( + ) : ( ([]) const [isDragOver, setIsDragOver] = useState(false) + const [nudge, setNudge] = useState(false) const navigate = useNavigate() + const wrapperRef = useRef(null) const textareaRef = useRef(null) const fileInputRef = useRef(null) const dragCounterRef = useRef(0) useEffect(() => { textareaRef.current?.focus() }, []) + // External "focus me" trigger (e.g. NextStepCard "Start a session" CTA on + // the same page). Scrolls into view, focuses the textarea, and pulses a + // ring so the click feels intentional even when the input was already + // partially visible. + useEffect(() => { + const handler = () => { + wrapperRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + textareaRef.current?.focus({ preventScroll: true }) + setNudge(true) + window.setTimeout(() => setNudge(false), 900) + } + window.addEventListener(FOCUS_START_SESSION_EVENT, handler) + return () => window.removeEventListener(FOCUS_START_SESSION_EVENT, handler) + }, []) + // Auto-grow textarea useEffect(() => { const el = textareaRef.current @@ -190,7 +209,8 @@ export function StartSessionInput() { return (
{/* Main input area */}
{/* Drag overlay */} {isDragOver && ( From cbb4b256717a20bcf374f8bbaa4186febbf2633d Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 20:15:11 -0400 Subject: [PATCH 11/13] fix(ui): drop setState-in-effect in useAuthSessionExpiry 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 --- .../2026-03-04-survey-invite-tracking-design.md | 0 .../2026-03-04-survey-invite-tracking.md | 0 .../2026-03-05-admin-survey-responses.md | 0 ...2026-03-06-editor-embedded-flow-assist-design.md | 0 .../2026-03-06-editor-embedded-flow-assist-plan.md | 0 .../2026-03-06-procedural-flow-assist.md | 0 .../{ => archive}/2026-03-09-glow-edge-design.md | 0 .../2026-03-10-flexible-intake-design.md | 0 .../2026-03-11-session-closure-design.md | 0 .../{ => archive}/2026-03-11-session-closure.md | 0 .../2026-03-13-script-template-editor-design.md | 0 .../2026-03-13-script-template-editor-impl.md | 0 .../2026-03-14-connectwise-psa-integration-plan.md | 0 .../2026-03-14-parameter-detector-design.md | 0 .../2026-03-14-parameter-detector-plan.md | 0 ...26-03-16-stack-priorities-and-playwright-plan.md | 0 .../2026-03-18-flowpilot-first-pivot-phase1.md | 0 .../2026-03-18-flowpilot-first-pivot-phase2.md | 0 ...26-03-18-security-coverage-performance-design.md | 0 .../2026-03-18-security-coverage-performance.md | 0 .../2026-03-19-phase4-remaining-slices-impl.md | 0 .../2026-03-19-phase4-slice2-notifications.md | 0 ...026-03-19-phase5-analytics-enhancement-design.md | 0 .../2026-03-19-phase5-analytics-impl.md | 0 ...26-03-20-flowpilot-dashboard-sidebar-redesign.md | 0 .../2026-03-20-search-recall-evidence-design.md | 0 .../2026-03-20-search-recall-evidence-impl.md | 0 .../2026-03-23-copilot-first-dashboard.md | 0 .../2026-03-23-mid-session-status-updates.md | 0 .../2026-03-23-solutions-library-design.md | 0 .../{ => archive}/2026-03-23-unified-sessions.md | 0 .../2026-04-27-escalation-mode-wedge-design.md | 0 .../2026-04-27-escalation-mode-wedge-test-plan.md | 0 frontend/src/hooks/useAuthSessionExpiry.ts | 13 ++++++++----- 34 files changed, 8 insertions(+), 5 deletions(-) rename docs/plans/{ => archive}/2026-03-04-survey-invite-tracking-design.md (100%) rename docs/plans/{ => archive}/2026-03-04-survey-invite-tracking.md (100%) rename docs/plans/{ => archive}/2026-03-05-admin-survey-responses.md (100%) rename docs/plans/{ => archive}/2026-03-06-editor-embedded-flow-assist-design.md (100%) rename docs/plans/{ => archive}/2026-03-06-editor-embedded-flow-assist-plan.md (100%) rename docs/plans/{ => archive}/2026-03-06-procedural-flow-assist.md (100%) rename docs/plans/{ => archive}/2026-03-09-glow-edge-design.md (100%) rename docs/plans/{ => archive}/2026-03-10-flexible-intake-design.md (100%) rename docs/plans/{ => archive}/2026-03-11-session-closure-design.md (100%) rename docs/plans/{ => archive}/2026-03-11-session-closure.md (100%) rename docs/plans/{ => archive}/2026-03-13-script-template-editor-design.md (100%) rename docs/plans/{ => archive}/2026-03-13-script-template-editor-impl.md (100%) rename docs/plans/{ => archive}/2026-03-14-connectwise-psa-integration-plan.md (100%) rename docs/plans/{ => archive}/2026-03-14-parameter-detector-design.md (100%) rename docs/plans/{ => archive}/2026-03-14-parameter-detector-plan.md (100%) rename docs/plans/{ => archive}/2026-03-16-stack-priorities-and-playwright-plan.md (100%) rename docs/plans/{ => archive}/2026-03-18-flowpilot-first-pivot-phase1.md (100%) rename docs/plans/{ => archive}/2026-03-18-flowpilot-first-pivot-phase2.md (100%) rename docs/plans/{ => archive}/2026-03-18-security-coverage-performance-design.md (100%) rename docs/plans/{ => archive}/2026-03-18-security-coverage-performance.md (100%) rename docs/plans/{ => archive}/2026-03-19-phase4-remaining-slices-impl.md (100%) rename docs/plans/{ => archive}/2026-03-19-phase4-slice2-notifications.md (100%) rename docs/plans/{ => archive}/2026-03-19-phase5-analytics-enhancement-design.md (100%) rename docs/plans/{ => archive}/2026-03-19-phase5-analytics-impl.md (100%) rename docs/plans/{ => archive}/2026-03-20-flowpilot-dashboard-sidebar-redesign.md (100%) rename docs/plans/{ => archive}/2026-03-20-search-recall-evidence-design.md (100%) rename docs/plans/{ => archive}/2026-03-20-search-recall-evidence-impl.md (100%) rename docs/plans/{ => archive}/2026-03-23-copilot-first-dashboard.md (100%) rename docs/plans/{ => archive}/2026-03-23-mid-session-status-updates.md (100%) rename docs/plans/{ => archive}/2026-03-23-solutions-library-design.md (100%) rename docs/plans/{ => archive}/2026-03-23-unified-sessions.md (100%) rename docs/plans/{ => archive}/2026-04-27-escalation-mode-wedge-design.md (100%) rename docs/plans/{ => archive}/2026-04-27-escalation-mode-wedge-test-plan.md (100%) diff --git a/docs/plans/2026-03-04-survey-invite-tracking-design.md b/docs/plans/archive/2026-03-04-survey-invite-tracking-design.md similarity index 100% rename from docs/plans/2026-03-04-survey-invite-tracking-design.md rename to docs/plans/archive/2026-03-04-survey-invite-tracking-design.md diff --git a/docs/plans/2026-03-04-survey-invite-tracking.md b/docs/plans/archive/2026-03-04-survey-invite-tracking.md similarity index 100% rename from docs/plans/2026-03-04-survey-invite-tracking.md rename to docs/plans/archive/2026-03-04-survey-invite-tracking.md diff --git a/docs/plans/2026-03-05-admin-survey-responses.md b/docs/plans/archive/2026-03-05-admin-survey-responses.md similarity index 100% rename from docs/plans/2026-03-05-admin-survey-responses.md rename to docs/plans/archive/2026-03-05-admin-survey-responses.md diff --git a/docs/plans/2026-03-06-editor-embedded-flow-assist-design.md b/docs/plans/archive/2026-03-06-editor-embedded-flow-assist-design.md similarity index 100% rename from docs/plans/2026-03-06-editor-embedded-flow-assist-design.md rename to docs/plans/archive/2026-03-06-editor-embedded-flow-assist-design.md diff --git a/docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md b/docs/plans/archive/2026-03-06-editor-embedded-flow-assist-plan.md similarity index 100% rename from docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md rename to docs/plans/archive/2026-03-06-editor-embedded-flow-assist-plan.md diff --git a/docs/plans/2026-03-06-procedural-flow-assist.md b/docs/plans/archive/2026-03-06-procedural-flow-assist.md similarity index 100% rename from docs/plans/2026-03-06-procedural-flow-assist.md rename to docs/plans/archive/2026-03-06-procedural-flow-assist.md diff --git a/docs/plans/2026-03-09-glow-edge-design.md b/docs/plans/archive/2026-03-09-glow-edge-design.md similarity index 100% rename from docs/plans/2026-03-09-glow-edge-design.md rename to docs/plans/archive/2026-03-09-glow-edge-design.md diff --git a/docs/plans/2026-03-10-flexible-intake-design.md b/docs/plans/archive/2026-03-10-flexible-intake-design.md similarity index 100% rename from docs/plans/2026-03-10-flexible-intake-design.md rename to docs/plans/archive/2026-03-10-flexible-intake-design.md diff --git a/docs/plans/2026-03-11-session-closure-design.md b/docs/plans/archive/2026-03-11-session-closure-design.md similarity index 100% rename from docs/plans/2026-03-11-session-closure-design.md rename to docs/plans/archive/2026-03-11-session-closure-design.md diff --git a/docs/plans/2026-03-11-session-closure.md b/docs/plans/archive/2026-03-11-session-closure.md similarity index 100% rename from docs/plans/2026-03-11-session-closure.md rename to docs/plans/archive/2026-03-11-session-closure.md diff --git a/docs/plans/2026-03-13-script-template-editor-design.md b/docs/plans/archive/2026-03-13-script-template-editor-design.md similarity index 100% rename from docs/plans/2026-03-13-script-template-editor-design.md rename to docs/plans/archive/2026-03-13-script-template-editor-design.md diff --git a/docs/plans/2026-03-13-script-template-editor-impl.md b/docs/plans/archive/2026-03-13-script-template-editor-impl.md similarity index 100% rename from docs/plans/2026-03-13-script-template-editor-impl.md rename to docs/plans/archive/2026-03-13-script-template-editor-impl.md diff --git a/docs/plans/2026-03-14-connectwise-psa-integration-plan.md b/docs/plans/archive/2026-03-14-connectwise-psa-integration-plan.md similarity index 100% rename from docs/plans/2026-03-14-connectwise-psa-integration-plan.md rename to docs/plans/archive/2026-03-14-connectwise-psa-integration-plan.md diff --git a/docs/plans/2026-03-14-parameter-detector-design.md b/docs/plans/archive/2026-03-14-parameter-detector-design.md similarity index 100% rename from docs/plans/2026-03-14-parameter-detector-design.md rename to docs/plans/archive/2026-03-14-parameter-detector-design.md diff --git a/docs/plans/2026-03-14-parameter-detector-plan.md b/docs/plans/archive/2026-03-14-parameter-detector-plan.md similarity index 100% rename from docs/plans/2026-03-14-parameter-detector-plan.md rename to docs/plans/archive/2026-03-14-parameter-detector-plan.md diff --git a/docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md b/docs/plans/archive/2026-03-16-stack-priorities-and-playwright-plan.md similarity index 100% rename from docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md rename to docs/plans/archive/2026-03-16-stack-priorities-and-playwright-plan.md diff --git a/docs/plans/2026-03-18-flowpilot-first-pivot-phase1.md b/docs/plans/archive/2026-03-18-flowpilot-first-pivot-phase1.md similarity index 100% rename from docs/plans/2026-03-18-flowpilot-first-pivot-phase1.md rename to docs/plans/archive/2026-03-18-flowpilot-first-pivot-phase1.md diff --git a/docs/plans/2026-03-18-flowpilot-first-pivot-phase2.md b/docs/plans/archive/2026-03-18-flowpilot-first-pivot-phase2.md similarity index 100% rename from docs/plans/2026-03-18-flowpilot-first-pivot-phase2.md rename to docs/plans/archive/2026-03-18-flowpilot-first-pivot-phase2.md diff --git a/docs/plans/2026-03-18-security-coverage-performance-design.md b/docs/plans/archive/2026-03-18-security-coverage-performance-design.md similarity index 100% rename from docs/plans/2026-03-18-security-coverage-performance-design.md rename to docs/plans/archive/2026-03-18-security-coverage-performance-design.md diff --git a/docs/plans/2026-03-18-security-coverage-performance.md b/docs/plans/archive/2026-03-18-security-coverage-performance.md similarity index 100% rename from docs/plans/2026-03-18-security-coverage-performance.md rename to docs/plans/archive/2026-03-18-security-coverage-performance.md diff --git a/docs/plans/2026-03-19-phase4-remaining-slices-impl.md b/docs/plans/archive/2026-03-19-phase4-remaining-slices-impl.md similarity index 100% rename from docs/plans/2026-03-19-phase4-remaining-slices-impl.md rename to docs/plans/archive/2026-03-19-phase4-remaining-slices-impl.md diff --git a/docs/plans/2026-03-19-phase4-slice2-notifications.md b/docs/plans/archive/2026-03-19-phase4-slice2-notifications.md similarity index 100% rename from docs/plans/2026-03-19-phase4-slice2-notifications.md rename to docs/plans/archive/2026-03-19-phase4-slice2-notifications.md diff --git a/docs/plans/2026-03-19-phase5-analytics-enhancement-design.md b/docs/plans/archive/2026-03-19-phase5-analytics-enhancement-design.md similarity index 100% rename from docs/plans/2026-03-19-phase5-analytics-enhancement-design.md rename to docs/plans/archive/2026-03-19-phase5-analytics-enhancement-design.md diff --git a/docs/plans/2026-03-19-phase5-analytics-impl.md b/docs/plans/archive/2026-03-19-phase5-analytics-impl.md similarity index 100% rename from docs/plans/2026-03-19-phase5-analytics-impl.md rename to docs/plans/archive/2026-03-19-phase5-analytics-impl.md diff --git a/docs/plans/2026-03-20-flowpilot-dashboard-sidebar-redesign.md b/docs/plans/archive/2026-03-20-flowpilot-dashboard-sidebar-redesign.md similarity index 100% rename from docs/plans/2026-03-20-flowpilot-dashboard-sidebar-redesign.md rename to docs/plans/archive/2026-03-20-flowpilot-dashboard-sidebar-redesign.md diff --git a/docs/plans/2026-03-20-search-recall-evidence-design.md b/docs/plans/archive/2026-03-20-search-recall-evidence-design.md similarity index 100% rename from docs/plans/2026-03-20-search-recall-evidence-design.md rename to docs/plans/archive/2026-03-20-search-recall-evidence-design.md diff --git a/docs/plans/2026-03-20-search-recall-evidence-impl.md b/docs/plans/archive/2026-03-20-search-recall-evidence-impl.md similarity index 100% rename from docs/plans/2026-03-20-search-recall-evidence-impl.md rename to docs/plans/archive/2026-03-20-search-recall-evidence-impl.md diff --git a/docs/plans/2026-03-23-copilot-first-dashboard.md b/docs/plans/archive/2026-03-23-copilot-first-dashboard.md similarity index 100% rename from docs/plans/2026-03-23-copilot-first-dashboard.md rename to docs/plans/archive/2026-03-23-copilot-first-dashboard.md diff --git a/docs/plans/2026-03-23-mid-session-status-updates.md b/docs/plans/archive/2026-03-23-mid-session-status-updates.md similarity index 100% rename from docs/plans/2026-03-23-mid-session-status-updates.md rename to docs/plans/archive/2026-03-23-mid-session-status-updates.md diff --git a/docs/plans/2026-03-23-solutions-library-design.md b/docs/plans/archive/2026-03-23-solutions-library-design.md similarity index 100% rename from docs/plans/2026-03-23-solutions-library-design.md rename to docs/plans/archive/2026-03-23-solutions-library-design.md diff --git a/docs/plans/2026-03-23-unified-sessions.md b/docs/plans/archive/2026-03-23-unified-sessions.md similarity index 100% rename from docs/plans/2026-03-23-unified-sessions.md rename to docs/plans/archive/2026-03-23-unified-sessions.md diff --git a/docs/plans/2026-04-27-escalation-mode-wedge-design.md b/docs/plans/archive/2026-04-27-escalation-mode-wedge-design.md similarity index 100% rename from docs/plans/2026-04-27-escalation-mode-wedge-design.md rename to docs/plans/archive/2026-04-27-escalation-mode-wedge-design.md diff --git a/docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md b/docs/plans/archive/2026-04-27-escalation-mode-wedge-test-plan.md similarity index 100% rename from docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md rename to docs/plans/archive/2026-04-27-escalation-mode-wedge-test-plan.md diff --git a/frontend/src/hooks/useAuthSessionExpiry.ts b/frontend/src/hooks/useAuthSessionExpiry.ts index f32e4314..935c2c2c 100644 --- a/frontend/src/hooks/useAuthSessionExpiry.ts +++ b/frontend/src/hooks/useAuthSessionExpiry.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useReducer } from 'react' import { useAuthStore } from '@/store/authStore' const SOON_MS = 5 * 60 * 1000 // 5 minutes @@ -53,14 +53,17 @@ function computeState(token: ReturnType['token']): */ export function useAuthSessionExpiry(): ExpiryState { const token = useAuthStore((s) => s.token) - const [state, setState] = useState(() => computeState(token)) + // Derived state — computed during render, not synced via setState in an + // effect. The reducer here is only a tick counter that forces re-render on + // the 30s cadence so the relative timestamps stay current. See React 19 + // guidance: https://react.dev/learn/you-might-not-need-an-effect + const [, tick] = useReducer((n: number) => n + 1, 0) useEffect(() => { - setState(computeState(token)) if (!token?.idle_expires_at || !token?.absolute_expires_at) return - const interval = window.setInterval(() => setState(computeState(token)), 30_000) + const interval = window.setInterval(tick, 30_000) return () => window.clearInterval(interval) }, [token]) - return state + return computeState(token) } From dc887974694536219681dc8cc35f478a21de12e0 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 13 May 2026 23:59:18 -0400 Subject: [PATCH 12/13] =?UTF-8?q?feat(welcome):=20two-button=20PSA=20CTA?= =?UTF-8?q?=20in=20step-2=20=E2=80=94=20Connect=20now=20/=20Connect=20late?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- frontend/src/pages/welcome/WelcomeStep2.tsx | 120 ++++++++++++++------ 1 file changed, 84 insertions(+), 36 deletions(-) diff --git a/frontend/src/pages/welcome/WelcomeStep2.tsx b/frontend/src/pages/welcome/WelcomeStep2.tsx index 1b803894..c5f84805 100644 --- a/frontend/src/pages/welcome/WelcomeStep2.tsx +++ b/frontend/src/pages/welcome/WelcomeStep2.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' import { Loader2 } from 'lucide-react' import { useAuthStore } from '@/store/authStore' import { onboardingApi, type PrimaryPsa } from '@/api/onboarding' @@ -14,21 +14,24 @@ const PSA_OPTIONS: { value: PrimaryPsa; label: string; description: string }[] = /** * `/welcome/step-2` — second step of the welcome wizard. Captures the PSA the - * shop primarily uses. Selecting a non-`none` tile reveals a quiet "Connect - * now" link that navigates out to `/account/integrations`. The wizard's - * primary action is "Continue" — credential entry is intentionally OUT of - * the wizard (per spec). + * shop primarily uses. Selecting a non-`none` tile splits the primary CTA + * into "Connect now" (routes to `/account/integrations` after saving) + * and "Connect later" (continues to step 3). Both paths persist the + * `primary_psa` choice before navigating. */ export function WelcomeStep2() { const navigate = useNavigate() const fetchUser = useAuthStore((s) => s.fetchUser) const [primaryPsa, setPrimaryPsa] = useState(null) - const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null) + const [submitting, setSubmitting] = useState< + 'continue' | 'connect-now' | 'skip' | 'dismiss' | null + >(null) const [error, setError] = useState(null) const isBusy = submitting !== null const showConnectNow = primaryPsa !== null && primaryPsa !== 'none' + const selectedPsaLabel = PSA_OPTIONS.find((o) => o.value === primaryPsa)?.label const handleContinue = async () => { if (isBusy) return @@ -48,6 +51,24 @@ export function WelcomeStep2() { } } + const handleConnectNow = async () => { + if (isBusy || !showConnectNow) return + setError(null) + setSubmitting('connect-now') + try { + await onboardingApi.updateStep({ + step: 2, + action: 'complete', + data: { primary_psa: primaryPsa! }, + }) + await fetchUser() + navigate('/account/integrations') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + const handleSkipStep = async () => { if (isBusy) return setError(null) @@ -131,42 +152,69 @@ export function WelcomeStep2() { })}
- {showConnectNow && ( -
- - Connect now → - -
- )} - {error && (

{error}

)} -
- +
+ {showConnectNow ? ( + <> + + + + ) : ( + + )}