Compare commits
10 Commits
e50a2150d5
...
8d79dd93b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d79dd93b8 | |||
| 1106f79611 | |||
| c7cd711859 | |||
| aad554bb9c | |||
| cabd745a2b | |||
| 8cfaef6a9d | |||
| b21d2fc234 | |||
| d6a02ee8da | |||
| 2375948b7a | |||
| 92fa3bc6ab |
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
214
backend/app/api/endpoints/account_security.py
Normal file
214
backend/app/api/endpoints/account_security.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""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 datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select, update as sa_update
|
||||
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.refresh_token import RefreshToken
|
||||
from app.models.user import User
|
||||
from app.schemas.account_security import (
|
||||
ActiveUser,
|
||||
RevokeSessionsRequest,
|
||||
RevokeSessionsResponse,
|
||||
SessionPolicyResponse,
|
||||
SessionPolicyUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/accounts/me/security", tags=["account-security"])
|
||||
|
||||
|
||||
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,
|
||||
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,
|
||||
active_users=active_users,
|
||||
)
|
||||
|
||||
|
||||
async def _load_account(db: AsyncSession, account_id) -> Account:
|
||||
return (
|
||||
await db.execute(select(Account).where(Account.id == account_id))
|
||||
).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)
|
||||
active_users = await _load_active_users(db, current_user.account_id)
|
||||
return _policy_response(account, active_users)
|
||||
|
||||
|
||||
@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)
|
||||
active_users = await _load_active_users(db, account.id)
|
||||
return _policy_response(account, active_users)
|
||||
|
||||
|
||||
@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)
|
||||
@@ -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,108 @@ 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 _resolve_refresh_claims(
|
||||
payload: dict, user: User, db: AsyncSession
|
||||
) -> tuple[int, int, int]:
|
||||
"""Return (auth_time, idle_max_seconds, abs_max_seconds) for a refresh.
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
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),
|
||||
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 +426,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 +451,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)
|
||||
@@ -381,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(
|
||||
@@ -400,35 +508,31 @@ 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
|
||||
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))
|
||||
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="User not found"
|
||||
detail="session_expired_absolute",
|
||||
)
|
||||
|
||||
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)
|
||||
await db.commit()
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=new_refresh_token_str,
|
||||
token_type="bearer"
|
||||
token = await _mint_with_claims(
|
||||
user, auth_time, idle_max_seconds, abs_max_seconds, db
|
||||
)
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -33,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:
|
||||
@@ -49,7 +98,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 +113,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())
|
||||
|
||||
@@ -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
|
||||
|
||||
77
backend/app/schemas/account_security.py
Normal file
77
backend/app/schemas/account_security.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Schemas for /accounts/me/security — session-policy management.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
# 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.
|
||||
|
||||
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
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
782
backend/tests/test_session_policy.py
Normal file
782
backend/tests/test_session_policy.py
Normal file
@@ -0,0 +1,782 @@
|
||||
"""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: 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)
|
||||
- Commit 6: POST /accounts/me/security/revoke-sessions (#17-#22)
|
||||
"""
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
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 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, test_user: 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
|
||||
|
||||
# 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
|
||||
):
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@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
|
||||
493
docs/plans/2026-05-13-session-expiration-policy.md
Normal file
493
docs/plans/2026-05-13-session-expiration-policy.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# 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": "<user_id>", "type": "refresh", "jti": "<uuid>", "exp": <idle_exp> }
|
||||
```
|
||||
|
||||
New refresh-token JWT:
|
||||
```json
|
||||
{
|
||||
"sub": "<user_id>",
|
||||
"type": "refresh",
|
||||
"jti": "<uuid>",
|
||||
"exp": <idle_exp>, // unchanged semantics, now = idle window
|
||||
"auth_time": <login_unix_ts>, // original login (Unix seconds); NOT reset on rotation
|
||||
"idle_max": <idle_seconds>, // captured at login (account policy snapshot, seconds)
|
||||
"abs_max": <abs_seconds> // captured at login (account policy snapshot, seconds)
|
||||
}
|
||||
```
|
||||
|
||||
**Unit convention (single source of truth):**
|
||||
|
||||
| Surface | Unit | Why |
|
||||
|---|---|---|
|
||||
| `Settings.SESSION_*_MINUTES`, `accounts.session_*_minutes`, PATCH `/accounts/me/security` request/response, frontend form inputs | **minutes** | Human-readable, matches the column names, what owners actually edit |
|
||||
| `idle_max`, `abs_max` inside the refresh JWT, `auth_time` | **seconds (Unix)** | Lets `auth_time + abs_max` be direct Unix math against `int(time.time())` with no conversion at check time |
|
||||
| `idle_expires_at`, `absolute_expires_at` on API responses, `useAuthSessionExpiry` hook | **ISO 8601 UTC strings** | Matches the rest of the API surface (`DateTime(timezone=True)` everywhere) |
|
||||
|
||||
`resolve_session_policy(account)` (see §4.4) returns minutes; the `_mint_session_tokens` helper multiplies by 60 once when stamping the JWT. That's the only place the conversion happens.
|
||||
|
||||
Why snapshot `idle_max`/`abs_max` into the JWT instead of looking up the account policy on every refresh? Two reasons:
|
||||
|
||||
- Refresh path stays DB-cheap (one query, not two).
|
||||
- If an owner tightens the policy after a user has logged in, the user's existing session continues under the policy in effect at login — fairer UX, matches what Okta and Microsoft do. New logins pick up the tightened policy.
|
||||
|
||||
Counter-consideration: if an owner *loosens* policy, existing sessions stay tight until next login. Acceptable; users won't notice. The owner-tightens case (security event) is the one that matters, and a kill-all-sessions admin button covers that scenario (out of scope here — log an issue).
|
||||
|
||||
### 4.3 Per-account policy storage
|
||||
|
||||
New columns on `accounts`:
|
||||
|
||||
| Column | Type | Nullable | Meaning |
|
||||
|---|---|---|---|
|
||||
| `session_idle_minutes` | `Integer` | yes | NULL = use system default |
|
||||
| `session_absolute_minutes` | `Integer` | yes | NULL = use system default |
|
||||
|
||||
Minutes (not days) so admins can configure shorter windows for high-security tenants if needed. Stored as Integer to match existing pattern; conversion to `timedelta` happens at use site.
|
||||
|
||||
System-imposed bounds (in `Settings`, environment-overridable):
|
||||
|
||||
| Setting | Default | Floor | Ceiling |
|
||||
|---|---|---|---|
|
||||
| `SESSION_IDLE_MINUTES_DEFAULT` | 4320 (3d) | n/a | n/a |
|
||||
| `SESSION_ABSOLUTE_MINUTES_DEFAULT` | 20160 (14d) | n/a | n/a |
|
||||
| `SESSION_IDLE_MINUTES_MIN` | 15 | hard floor | account override cannot go below |
|
||||
| `SESSION_IDLE_MINUTES_MAX` | 43200 (30d) | account override cannot go above | |
|
||||
| `SESSION_ABSOLUTE_MINUTES_MIN` | 60 (1h) | hard floor | |
|
||||
| `SESSION_ABSOLUTE_MINUTES_MAX` | 129600 (90d) | account override cannot go above | |
|
||||
|
||||
Plus invariant: an account's *effective* idle window must not exceed its *effective* absolute window. Enforcement is layered:
|
||||
|
||||
- **App-level (PATCH endpoint, authoritative):** before writing the row, resolve both effective values (`override ?? system_default`) and reject when effective idle > effective absolute. This is the only place that knows the current system defaults, so it's the only place that can catch a partial-override hole 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,
|
||||
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):**
|
||||
|
||||
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 `<SessionExpiryToast />` 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).
|
||||
- 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. 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).
|
||||
|
||||
**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": <int> }
|
||||
```
|
||||
|
||||
**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/<hash>_session_policy_columns.py` — NEW (manual; per Lesson 77, never `--rev-id`).
|
||||
- `backend/tests/test_session_policy.py` — NEW.
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/api/client.ts` — interceptor branches on 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: 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.
|
||||
|
||||
---
|
||||
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
49
frontend/src/api/accountSecurity.ts
Normal file
49
frontend/src/api/accountSecurity.ts
Normal file
@@ -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<SessionPolicyResponse> {
|
||||
const response = await apiClient.get<SessionPolicyResponse>('/accounts/me/security')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async update(body: SessionPolicyUpdateRequest): Promise<SessionPolicyResponse> {
|
||||
const response = await apiClient.patch<SessionPolicyResponse>('/accounts/me/security', body)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async revokeSessions(scope: 'all' | 'others'): Promise<RevokeSessionsResponse> {
|
||||
const response = await apiClient.post<RevokeSessionsResponse>(
|
||||
'/accounts/me/security/revoke-sessions',
|
||||
{ scope },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
89
frontend/src/components/account/RevokeSessionsModal.tsx
Normal file
89
frontend/src/components/account/RevokeSessionsModal.tsx
Normal file
@@ -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<void>
|
||||
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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={busy ? () => undefined : onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'rounded-md border px-4 py-2 text-sm font-medium',
|
||||
'border-border text-foreground hover:bg-card-hover',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'rounded-md border px-4 py-2 text-sm font-medium',
|
||||
'border-danger/40 bg-danger/10 text-danger hover:bg-danger/15',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
{busy ? 'Working…' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-foreground">{body}</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
125
frontend/src/components/common/SessionExpiryToast.tsx
Normal file
125
frontend/src/components/common/SessionExpiryToast.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'fixed top-4 right-4 z-50 max-w-md rounded-lg border p-4 shadow-lg',
|
||||
'flex items-start gap-3',
|
||||
isIdle
|
||||
? 'bg-warning-dim border-warning/30 text-warning'
|
||||
: 'bg-info-dim border-info/30 text-info',
|
||||
)}
|
||||
>
|
||||
<Icon size={18} className="mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{isIdle
|
||||
? 'Your session times out in 5 minutes.'
|
||||
: `Your session ends at ${deadlineLabel} for security.`}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs opacity-90">
|
||||
{isIdle
|
||||
? 'Click to stay signed in.'
|
||||
: "You'll need to sign in again."}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{isIdle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStay}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-xs font-medium',
|
||||
'bg-warning/20 text-warning border border-warning/40 hover:bg-warning/30',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
{busy ? 'Refreshing…' : 'Stay signed in'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignInNow}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-xs font-medium',
|
||||
'bg-info/20 text-info border border-info/40 hover:bg-info/30',
|
||||
)}
|
||||
>
|
||||
Sign in now
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="text-xs opacity-70 hover:opacity-100"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="Dismiss"
|
||||
className="shrink-0 opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Link
|
||||
to={next.ctaPath}
|
||||
data-testid="next-step-cta"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{next.ctaLabel}
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
{next.key === 'ran_session' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new Event(FOCUS_START_SESSION_EVENT))
|
||||
setLocallyHidden(true)
|
||||
}}
|
||||
data-testid="next-step-cta"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{next.ctaLabel}
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={next.ctaPath}
|
||||
data-testid="next-step-cta"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{next.ctaLabel}
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
</div>
|
||||
) : item.key === 'ran_session' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.dispatchEvent(new Event(FOCUS_START_SESSION_EVENT))}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
|
||||
'hover:bg-[rgba(255,255,255,0.04)]',
|
||||
)}
|
||||
data-testid={`checklist-item-${item.key}`}
|
||||
data-done="false"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-border" />
|
||||
<span className="flex-1 text-foreground">{item.label}</span>
|
||||
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={item.path}
|
||||
|
||||
@@ -18,19 +18,38 @@ const SUGGESTIONS: { icon: LucideIcon; label: string }[] = [
|
||||
|
||||
const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx'
|
||||
|
||||
export const FOCUS_START_SESSION_EVENT = 'rf:focus-start-session'
|
||||
|
||||
export function StartSessionInput() {
|
||||
const [value, setValue] = useState('')
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [logContent, setLogContent] = useState('')
|
||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [nudge, setNudge] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div
|
||||
className="w-full"
|
||||
ref={wrapperRef}
|
||||
className="w-full scroll-mt-6"
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -198,9 +218,11 @@ export function StartSessionInput() {
|
||||
>
|
||||
{/* Main input area */}
|
||||
<div className={cn(
|
||||
'relative rounded-2xl border bg-card transition-all',
|
||||
'relative rounded-2xl border bg-card transition-all duration-300',
|
||||
isDragOver ? 'border-primary/50 bg-primary/5' : 'border-border',
|
||||
'focus-within:border-[rgba(96,165,250,0.25)] focus-within:ring-1 focus-within:ring-[rgba(96,165,250,0.1)]'
|
||||
nudge
|
||||
? 'border-[rgba(96,165,250,0.6)] ring-2 ring-[rgba(96,165,250,0.35)] shadow-[0_0_0_6px_rgba(96,165,250,0.12)]'
|
||||
: 'focus-within:border-[rgba(96,165,250,0.25)] focus-within:ring-1 focus-within:ring-[rgba(96,165,250,0.1)]'
|
||||
)}>
|
||||
{/* Drag overlay */}
|
||||
{isDragOver && (
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<SessionExpiryToast />
|
||||
<div
|
||||
className={cn('app-shell relative z-1', sidebarPinned && 'app-shell--pinned')}
|
||||
data-testid="app-shell"
|
||||
|
||||
66
frontend/src/hooks/useAuthSessionExpiry.ts
Normal file
66
frontend/src/hooks/useAuthSessionExpiry.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
const SOON_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export type ExpiryWarning = 'none' | 'soon' | 'now'
|
||||
export type ExpiryReason = 'idle' | 'absolute'
|
||||
|
||||
interface ExpiryState {
|
||||
idleExpiresAt: Date | null
|
||||
absoluteExpiresAt: Date | null
|
||||
warning: ExpiryWarning
|
||||
/**
|
||||
* Which window is the closer (and therefore the active) deadline. Used by
|
||||
* SessionExpiryToast to pick the right copy + action button: idle gets
|
||||
* "Stay signed in" (calls /auth/refresh); absolute is informational only.
|
||||
*/
|
||||
reason: ExpiryReason | null
|
||||
}
|
||||
|
||||
function computeState(token: ReturnType<typeof useAuthStore.getState>['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<ExpiryState>(() => 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
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
<SettingsRow
|
||||
to="/account/security"
|
||||
icon={<Shield className="h-4 w-4" />}
|
||||
title="Session security"
|
||||
description="Session-expiration policy and active sessions"
|
||||
/>
|
||||
<SettingsRow
|
||||
to="/account/categories"
|
||||
icon={<FolderTree className="h-4 w-4" />}
|
||||
|
||||
@@ -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() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showSessionExpiredBanner && (
|
||||
<div className="rounded-md border border-info/30 bg-info-dim px-4 py-3 flex items-start gap-2">
|
||||
<Info size={16} className="text-info mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-info">
|
||||
You were signed out for security. Sign back in to continue.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6" data-testid="login-form">
|
||||
<div className="card-flat p-6 space-y-4">
|
||||
{(error || localError) && (
|
||||
|
||||
@@ -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()
|
||||
|
||||
353
frontend/src/pages/account/AccountSecuritySettingsPage.tsx
Normal file
353
frontend/src/pages/account/AccountSecuritySettingsPage.tsx
Normal file
@@ -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<Exclude<Preset, 'custom'>, { 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<SessionPolicyResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [preset, setPreset] = useState<Preset>('strict')
|
||||
const [idleMin, setIdleMin] = useState<string>('')
|
||||
const [absMin, setAbsMin] = useState<string>('')
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="animate-spin text-primary" size={24} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeUserCount = data.active_users.length
|
||||
const solo = activeUserCount <= 1
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8 px-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield size={20} className="text-primary" />
|
||||
<h1 className="text-xl font-heading font-bold text-foreground">Session Security</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Control how long sessions can last before users must sign in again.
|
||||
</p>
|
||||
|
||||
{/* ── Policy card ────────────────────────────────────────────────── */}
|
||||
<div className="card-flat rounded-2xl p-6 space-y-5">
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="text-sm font-medium text-foreground mb-1">Policy</legend>
|
||||
|
||||
{(['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 (
|
||||
<label
|
||||
key={p}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
isSelected
|
||||
? 'border-primary/40 bg-primary/[0.06]'
|
||||
: 'border-border hover:bg-card-hover',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="preset"
|
||||
value={p}
|
||||
checked={isSelected}
|
||||
onChange={() => handlePresetChange(p)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{labels.label}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{labels.sub}</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
{/* Custom inputs — always visible, disabled outside Custom */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="font-sans text-xs uppercase tracking-widest text-muted-foreground block mb-1.5">
|
||||
Idle minutes
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={idleMin}
|
||||
onChange={(e) => 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)',
|
||||
}}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
{validation.idleErr && !customDisabled
|
||||
? validation.idleErr
|
||||
: `Between ${data.idle_minutes_min} and ${data.idle_minutes_max} min`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-sans text-xs uppercase tracking-widest text-muted-foreground block mb-1.5">
|
||||
Absolute minutes
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={absMin}
|
||||
onChange={(e) => 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)',
|
||||
}}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
{validation.absErr && !customDisabled
|
||||
? validation.absErr
|
||||
: `Between ${data.absolute_minutes_min} and ${data.absolute_minutes_max} min`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !validation.ok}
|
||||
className="bg-primary text-white font-semibold text-sm rounded-lg px-5 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all disabled:opacity-40 flex items-center gap-2"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
Save Policy
|
||||
</button>
|
||||
{success && <span className="text-sm text-emerald-400">Settings saved</span>}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground border-t border-border pt-3">
|
||||
New policy applies the next time each person signs in. Use{' '}
|
||||
<span className="font-medium text-foreground">Active sessions</span> below to force it
|
||||
immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Active sessions card ───────────────────────────────────────── */}
|
||||
<div className="card-flat rounded-2xl p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground">Active sessions</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{solo
|
||||
? 'Only you are signed in to this account.'
|
||||
: `${activeUserCount} people are signed in to this account.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{data.active_users.map((u) => {
|
||||
const isMe = u.user_id === currentUserId
|
||||
return (
|
||||
<li
|
||||
key={u.user_id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<span className="truncate">{u.name}</span>
|
||||
{isMe && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
(you)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{u.email} · last signed in {relativeFromNow(u.last_login_at)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{!solo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalScope('others')}
|
||||
className="rounded-md border border-border bg-transparent px-3 py-1.5 text-sm font-medium text-foreground hover:bg-card-hover"
|
||||
>
|
||||
Sign out everyone except me
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalScope('all')}
|
||||
className="rounded-md border border-danger/40 bg-danger/10 px-3 py-1.5 text-sm font-medium text-danger hover:bg-danger/15"
|
||||
>
|
||||
{solo ? 'Sign me out everywhere' : 'Sign me out and everyone else'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RevokeSessionsModal
|
||||
isOpen={modalScope !== null}
|
||||
scope={modalScope ?? 'others'}
|
||||
activeUserCount={activeUserCount}
|
||||
onClose={() => setModalScope(null)}
|
||||
onConfirm={handleRevokeConfirm}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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([
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'security',
|
||||
element: (
|
||||
<ProtectedRoute requiredRole="owner">
|
||||
{page(AccountSecuritySettingsPage)}
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{ path: 'target-lists', element: page(TargetListsPage) },
|
||||
{
|
||||
path: 'integrations',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user