feat(auth): idle timeout and maximum session age, session-only cookies

Sessions used to last as long as a 7-day refresh token kept being renewed,
and the auth cookies outlived a browser restart. Settings -> Security now
sets an idle timeout (default 15 min, 5 min to 3 h) and a maximum session
length (default 10 h, 1 to 12 h); both are clamped when read.

The refresh token lives exactly the idle timeout and is renewed on activity.
The access token lives half of it, at most 30 min. Every token carries
sat (sign-in time), copied on refresh: nothing outlives sat + max, and
/auth/refresh refuses past it. That holds for password, MFA, passkey, SAML
and OIDC sign-ins alike. Refresh tokens from before sat are refused, so
everyone signs in once after the upgrade.

Auth cookies are session cookies and go when the browser closes. In the
SPA, SessionGuard renews on load and on keyboard/mouse activity and takes
the page to /login when the idle timeout or maximum age passes, so an
abandoned screen does not stay readable. Activity is shared across tabs.
This commit is contained in:
2026-11-04 08:55:48 +01:00
parent 406d1ca6d9
commit d50c5144f4
13 changed files with 447 additions and 65 deletions
+30
View File
@@ -420,6 +420,36 @@ and `/auth/passkeys/*` in one file. Specific routes (e.g. `/auth/login`, `/auth/
`/auth/users/{id}/reset-password`, `/auth/users/{id}/reset-mfa`) keep
priority — Next.js prefers more specific segments over catch-alls.
## Session lifetime
`app/auth/session_policy.py`, Settings → Security:
| Setting | Default | Range | Effect |
|---|---|---|---|
| `session_idle_timeout_minutes` | 15 | 5–180 | No activity for this long → signed out |
| `session_max_hours` | 10 | 1–12 | Hard end after sign-in, however active |
Values are clamped when read, so a hand-edited row cannot exceed the range.
- **Idle**: the refresh token lives exactly the idle timeout; each refresh
issues a new one. The access token lives half of it (max 30 min). The SPA
(`components/auth/SessionGuard.tsx`) renews on load and on keyboard/mouse
activity (at most once a minute) and takes the page to `/login?reason=idle` when the
timeout passes, so an abandoned screen does not stay readable. Activity is
shared across tabs (localStorage), so a background tab does not sign out a
user working in another.
- **Maximum age**: tokens carry `sat` (sign-in time), copied on refresh. No
token outlives `sat + max`, `/auth/refresh` refuses past it, the SPA
redirects to `/login?reason=expired`. Refresh tokens from before `sat`
are refused, so everyone signs in once after the upgrade.
- **Cookies** are session cookies (no `Max-Age`/`Expires`): closing the
browser drops them. Closing one tab does not — cookies belong to the
browser, not the tab; that case is covered by the idle timeout. A browser
that restores session cookies ("continue where you left off") gets nothing
past the token `exp`.
- Job-status polls (scan running) renew the server session through the 401
path; the on-screen idle logout still applies.
## Passkeys (passwordless sign-in)
**Sign in with a passkey** on the login page replaces password *and* TOTP:
+4 -2
View File
@@ -135,19 +135,21 @@ def create_access_token(
return encoded_jwt
def create_refresh_token(data: Dict[str, Any]) -> str:
def create_refresh_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
Erstellt JWT Refresh Token (längere Gültigkeit)
Args:
data: Payload-Daten
expires_delta: Custom Expiry-Zeit (default: 7 Tage). Sessions setzen
hier den Idle-Timeout (app/auth/session_policy.py).
Returns:
JWT Refresh Token
"""
to_encode = data.copy()
now = datetime.now(timezone.utc)
expire = now + timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS)
expire = now + (expires_delta or timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS))
to_encode.update({
"exp": expire,
+83
View File
@@ -0,0 +1,83 @@
"""
Session lifetime policy: idle timeout and absolute maximum session age.
Settings (KV in `settings` table, Settings → Security in the UI):
- `session_idle_timeout_minutes` default 15, clamped 5–180
- `session_max_hours` default 10, clamped 1–12
Idle: the refresh token lives exactly the idle timeout, and every refresh
(the SPA renews on user activity) issues a new one. A session nobody uses
for that long can no longer be renewed. The access token lives half of it,
at most 30 min, so it cannot outlast an idle session by much.
Absolute: every token carries `sat` (session auth time, epoch seconds),
copied unchanged on refresh. No token outlives sat + max age, and refresh
refuses past it — the user signs in again, however active they are.
Values are clamped when READ, so a hand-edited setting can never stretch a
session past the limits. Changes apply to running sessions at their next
refresh.
"""
from __future__ import annotations
import time
from datetime import timedelta
from typing import Optional
from sqlalchemy.orm import Session
from app.auth.jwt_handler import (
JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
create_access_token,
create_refresh_token,
)
from app.models.setting import Setting
from app.models.user import User
KEY_IDLE_MINUTES = "session_idle_timeout_minutes"
KEY_MAX_HOURS = "session_max_hours"
IDLE_DEFAULT, IDLE_MIN, IDLE_MAX = 15, 5, 180
MAX_AGE_DEFAULT, MAX_AGE_MIN, MAX_AGE_MAX = 10, 1, 12
class SessionExpired(Exception):
"""The session reached its absolute maximum age."""
def _int_setting(db: Session, key: str, default: int, lo: int, hi: int) -> int:
row = db.query(Setting).filter(Setting.key == key).first()
try:
value = int((row.value or "").strip()) if row else default
except ValueError:
value = default
return max(lo, min(hi, value))
def idle_timeout_minutes(db: Session) -> int:
return _int_setting(db, KEY_IDLE_MINUTES, IDLE_DEFAULT, IDLE_MIN, IDLE_MAX)
def max_session_hours(db: Session) -> int:
return _int_setting(db, KEY_MAX_HOURS, MAX_AGE_DEFAULT, MAX_AGE_MIN, MAX_AGE_MAX)
def session_end(db: Session, started_at: float) -> int:
"""Epoch second at which the session started at `started_at` ends for good."""
return int(started_at) + max_session_hours(db) * 3600
def issue_tokens(db: Session, user: User, started_at: Optional[float] = None) -> tuple[str, str]:
"""(access, refresh) for a new session, or a renewal of the session that
started at `started_at`. Raises SessionExpired past the maximum age."""
now = time.time()
sat = int(started_at) if started_at else int(now)
remaining = session_end(db, sat) - now
if remaining <= 0:
raise SessionExpired()
idle = idle_timeout_minutes(db) * 60
data = {"sub": user.username, "role": user.role.value, "sat": sat}
access = create_access_token(data, expires_delta=timedelta(
seconds=min(JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, idle // 2, remaining)))
refresh = create_refresh_token(data, expires_delta=timedelta(seconds=min(idle, remaining)))
return access, refresh
+47 -37
View File
@@ -21,11 +21,8 @@ from app.auth.jwt_handler import (
hash_password,
verify_password,
create_access_token,
create_refresh_token,
decode_token,
get_password_strength,
JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
JWT_REFRESH_TOKEN_EXPIRE_DAYS,
TOKEN_TYPE_MFA_CHALLENGE,
TOKEN_TYPE_MFA_SETUP,
)
@@ -34,7 +31,7 @@ from app.services.audit_trail import record_change
from app.auth.orchestrator import AuthOrchestrator
from app.auth.strategies.base import AuthError
from app.auth import totp as totp_helper
from app.auth import mfa_policy
from app.auth import mfa_policy, session_policy
from app.auth.passkey import enabled as passkeys_enabled, suggested as passkey_suggested
from app.auth.token_revocation import revoke as revoke_token
from app.models.user import AuthProvider
@@ -206,24 +203,27 @@ def check_account_lockout(user: User, db: Session) -> bool:
def _set_auth_cookies(response: JSONResponse, access_token: str, refresh_token: str) -> None:
response.set_cookie(
"access_token",
access_token,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
max_age=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
path="/",
)
response.set_cookie(
"refresh_token",
refresh_token,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
max_age=JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
path="/",
)
# Session cookies (no max_age/expires): the browser drops them when it
# closes. Lifetime is enforced server-side by the token `exp`
# (app/auth/session_policy.py), so a browser that restores its session
# cookies on restart still gets nothing past the idle timeout.
for name, value in (("access_token", access_token), ("refresh_token", refresh_token)):
response.set_cookie(
name,
value,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path="/",
)
def _presented_access_token(request: Request) -> Optional[str]:
"""Access token from the Authorization header, else the cookie."""
bearer = request.headers.get("authorization")
if bearer and bearer.lower().startswith("bearer "):
return bearer.split(" ", 1)[1].strip() or None
return request.cookies.get("access_token")
# ============================================
@@ -232,9 +232,7 @@ def _set_auth_cookies(response: JSONResponse, access_token: str, refresh_token:
def _issue_session_response(user: User, db: Session) -> JSONResponse:
"""Generate JWTs and JSON response payload for a successfully authenticated user."""
token_data = {"sub": user.username, "role": user.role.value}
access_token = create_access_token(token_data)
refresh_token = create_refresh_token(token_data)
access_token, refresh_token = session_policy.issue_tokens(db, user)
response = JSONResponse(content={
"access_token": access_token,
"refresh_token": refresh_token,
@@ -666,12 +664,7 @@ def logout(
tokens are rejected by the get_current_user / refresh dependencies.
"""
# Revoke access token JTI
bearer = request.headers.get("authorization")
access_token = None
if bearer and bearer.lower().startswith("bearer "):
access_token = bearer.split(" ", 1)[1].strip()
if not access_token:
access_token = request.cookies.get("access_token")
access_token = _presented_access_token(request)
if access_token:
payload = decode_token(access_token)
if payload and payload.get("jti"):
@@ -753,10 +746,20 @@ def refresh_token(
detail="User not found or deactivated"
)
# Generate new access token
token_data = {"sub": user.username, "role": user.role.value}
access_token = create_access_token(token_data)
new_refresh_token = create_refresh_token(token_data)
# Same session, new idle window; `sat` is carried so the absolute
# maximum age keeps counting from the original sign-in. A refresh token
# from before `sat` existed cannot say when its session began (rotation
# reset its iat), so it ends here: one re-login after the upgrade.
try:
if not payload.get("sat"):
raise session_policy.SessionExpired()
access_token, new_refresh_token = session_policy.issue_tokens(
db, user, started_at=payload["sat"])
except session_policy.SessionExpired:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired — please sign in again",
)
response = JSONResponse(content={
"access_token": access_token,
@@ -842,10 +845,13 @@ def setup_admin(
@router.get("/me")
def get_current_user_info(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
def get_current_user_info(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Returns information about current user
Returns information about current user, plus the session limits the SPA
enforces on screen (idle logout, hard end at `expires_at`, epoch seconds).
"""
claims = decode_token(_presented_access_token(request) or "") or {}
started_at = claims.get("sat") or claims.get("iat") or 0
return {
"id": current_user.id,
"username": current_user.username,
@@ -854,6 +860,10 @@ def get_current_user_info(current_user: User = Depends(get_current_user), db: Se
"auth_provider": current_user.auth_provider.value if current_user.auth_provider else "local",
"mfa_enabled": bool(current_user.totp_enabled),
"passkey_suggested": passkey_suggested(db, current_user),
"session": {
"idle_timeout_minutes": session_policy.idle_timeout_minutes(db),
"expires_at": session_policy.session_end(db, started_at),
},
"is_active": current_user.is_active,
"is_verified": current_user.is_verified,
"created_at": current_user.created_at,
+4 -8
View File
@@ -28,12 +28,10 @@ from itsdangerous import BadSignature, URLSafeTimedSerializer
from sqlalchemy.orm import Session
from app.auth.jwt_handler import (
JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
JWT_REFRESH_TOKEN_EXPIRE_DAYS,
create_access_token,
create_refresh_token,
TOKEN_TYPE_MFA_SETUP,
)
from app.auth import session_policy
from app.auth.orchestrator import AuthOrchestrator
from app.auth.strategies.base import AuthError
from app.auth.strategies.oidc_strategy import OidcStrategy
@@ -321,19 +319,17 @@ async def oidc_callback(
status_code=302,
)
token_payload = {"sub": user.username, "role": user.role.value}
access_jwt = create_access_token(token_payload)
refresh_jwt = create_refresh_token(token_payload)
access_jwt, refresh_jwt = session_policy.issue_tokens(db, user)
redirect_to = os.getenv("DASHBOARD_URL", "/").rstrip("/") + "/"
response = RedirectResponse(redirect_to, status_code=302)
response.set_cookie(
"access_token", access_jwt, httponly=True, secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE, max_age=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, path="/",
samesite=COOKIE_SAMESITE, path="/", # session cookie, see auth._set_auth_cookies
)
response.set_cookie(
"refresh_token", refresh_jwt, httponly=True, secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE, max_age=JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400, path="/",
samesite=COOKIE_SAMESITE, path="/", # session cookie, see auth._set_auth_cookies
)
# Drop the OIDC state cookie now that we're done
response.delete_cookie(OIDC_STATE_COOKIE, path="/")
+4 -8
View File
@@ -23,12 +23,10 @@ from fastapi.responses import RedirectResponse, Response
from sqlalchemy.orm import Session
from app.auth.jwt_handler import (
JWT_ACCESS_TOKEN_EXPIRE_MINUTES,
JWT_REFRESH_TOKEN_EXPIRE_DAYS,
create_access_token,
create_refresh_token,
TOKEN_TYPE_MFA_SETUP,
)
from app.auth import session_policy
from app.auth.orchestrator import AuthOrchestrator
from app.auth.strategies.base import AuthError
from app.auth.strategies.saml_strategy import SamlStrategy
@@ -208,9 +206,7 @@ async def saml_acs(request: Request, db: Session = Depends(get_db)):
status_code=302,
)
token_payload = {"sub": user.username, "role": user.role.value}
access_jwt = create_access_token(token_payload)
refresh_jwt = create_refresh_token(token_payload)
access_jwt, refresh_jwt = session_policy.issue_tokens(db, user)
relay_state = form_dict.get("RelayState") or "/"
# Defend against open redirect: only allow same-origin paths.
@@ -220,11 +216,11 @@ async def saml_acs(request: Request, db: Session = Depends(get_db)):
response = RedirectResponse(relay_state, status_code=302)
response.set_cookie(
"access_token", access_jwt, httponly=True, secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE, max_age=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, path="/",
samesite=COOKIE_SAMESITE, path="/", # session cookie, see auth._set_auth_cookies
)
response.set_cookie(
"refresh_token", refresh_jwt, httponly=True, secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE, max_age=JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400, path="/",
samesite=COOKIE_SAMESITE, path="/", # session cookie, see auth._set_auth_cookies
)
return response
+4
View File
@@ -26,6 +26,10 @@ export default function LoginPage() {
// Fetch enabled providers so we render the right buttons.
useEffect(() => {
// Sent here by SessionGuard when the session ended on its own.
const reason = new URLSearchParams(window.location.search).get('reason');
if (reason === 'idle') setError('Signed out after inactivity.');
else if (reason === 'expired') setError('Your session reached its maximum length. Please sign in again.');
api.get('/auth/providers')
.then(res => {
setProviders(res.data?.providers || []);
+45 -3
View File
@@ -1814,8 +1814,10 @@ export default function SettingsPage() {
const [mfaEnforcedRoles, setMfaEnforcedRoles] = useState<string[]>(['admin', 'editor']);
const [mfaEnforceSso, setMfaEnforceSso] = useState(false);
const [mfaSavingStatus, setMfaSavingStatus] = useState({ message: '', type: '' });
// Default mirrors app/auth/mfa_policy.py.
// Defaults mirror app/auth/mfa_policy.py and app/auth/session_policy.py.
const [totpAlgorithm, setTotpAlgorithm] = useState('SHA256');
const [sessionIdleMinutes, setSessionIdleMinutes] = useState('15');
const [sessionMaxHours, setSessionMaxHours] = useState('10');
const [smtpConfigured, setSmtpConfigured] = useState(false);
const [isSmtpModalOpen, setIsSmtpModalOpen] = useState(false);
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
@@ -2180,7 +2182,7 @@ export default function SettingsPage() {
useEffect(() => {
(async () => {
try {
const [dig, sched, hour, delay, cap, lockPerm, lifecycle, mail, totpAlg] = await Promise.all([
const [dig, sched, hour, delay, cap, lockPerm, lifecycle, mail, totpAlg, idle, maxAge] = await Promise.all([
api.get('/api/v1/settings/email_template_new_vuln_digest').catch(() => ({ data: { value: null } })),
api.get('/api/v1/settings/notification_schedule').catch(() => ({ data: { value: null } })),
api.get('/api/v1/settings/notification_nightly_hour').catch(() => ({ data: { value: null } })),
@@ -2190,6 +2192,8 @@ export default function SettingsPage() {
api.get('/api/v1/settings/notification_lifecycle_mode').catch(() => ({ data: { value: null } })),
api.get('/api/v1/settings/mail_enabled').catch(() => ({ data: { value: null } })),
api.get('/api/v1/settings/totp_algorithm').catch(() => ({ data: { value: null } })),
api.get('/api/v1/settings/session_idle_timeout_minutes').catch(() => ({ data: { value: null } })),
api.get('/api/v1/settings/session_max_hours').catch(() => ({ data: { value: null } })),
]);
if (dig.data?.value) { try { const p = JSON.parse(dig.data.value); setDigestTemplate({ subject: p.subject || '', body: p.body || '' }); } catch { } }
if (sched.data?.value) setNotificationSchedule(String(sched.data.value).toLowerCase() === 'nightly' ? 'nightly' : 'per_sync');
@@ -2200,6 +2204,8 @@ export default function SettingsPage() {
if (lifecycle.data?.value) setNotificationLifecycleMode(String(lifecycle.data.value).toLowerCase() === 'include' ? 'include' : 'exclude');
if (mail.data?.value != null) setMailEnabled(String(mail.data.value).toLowerCase() !== 'false');
if (totpAlg.data?.value) setTotpAlgorithm(String(totpAlg.data.value).toUpperCase());
if (idle.data?.value) setSessionIdleMinutes(String(idle.data.value));
if (maxAge.data?.value) setSessionMaxHours(String(maxAge.data.value));
} catch { /* non-admin 403s → keep defaults */ }
})();
}, []);
@@ -2756,7 +2762,7 @@ export default function SettingsPage() {
{active === 'security' && userRole === 'admin' && (
<div className="bg-white border border-gray-200 shadow-sm rounded-sm p-6 mb-6">
<h3 className="text-lg font-bold text-gray-900 font-mono mb-4 border-b border-gray-100 pb-2 flex items-center gap-x-3">
Security — MFA Enforcement
Security — MFA &amp; Sessions
{mfaSavingStatus.message && (
<span className={`rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset ${
mfaSavingStatus.type === 'success'
@@ -2851,6 +2857,42 @@ export default function SettingsPage() {
<option value="SHA512">SHA-512</option>
</select>
</li>
<li className="flex items-center justify-between gap-x-6 py-4">
<div className="min-w-0">
<p className="text-sm font-semibold leading-6 text-gray-900 font-mono">Idle timeout</p>
<div className="mt-1 text-xs leading-5 text-gray-500">
Sign a session out after this long without keyboard or mouse activity. Activity extends it, up to the maximum session length.
</div>
</div>
<select
value={sessionIdleMinutes}
onChange={(e) => { setSessionIdleMinutes(e.target.value); handleSaveSecuritySetting('session_idle_timeout_minutes', e.target.value); }}
className="rounded-md border-0 py-1.5 pl-3 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-truevuln-blue sm:text-sm font-mono"
>
{[5, 10, 15, 20, 30, 45, 60, 90, 120, 180].map((m) => (
<option key={m} value={String(m)}>{m < 60 ? `${m} min` : `${m / 60} h`}</option>
))}
</select>
</li>
<li className="flex items-center justify-between gap-x-6 py-4">
<div className="min-w-0">
<p className="text-sm font-semibold leading-6 text-gray-900 font-mono">Maximum session length</p>
<div className="mt-1 text-xs leading-5 text-gray-500">
Hard limit from sign-in, however active the user is; then they sign in again. Applies to running sessions at their next renewal. Auth cookies are session cookies and end when the browser closes.
</div>
</div>
<select
value={sessionMaxHours}
onChange={(e) => { setSessionMaxHours(e.target.value); handleSaveSecuritySetting('session_max_hours', e.target.value); }}
className="rounded-md border-0 py-1.5 pl-3 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-truevuln-blue sm:text-sm font-mono"
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<option key={h} value={String(h)}>{h} h</option>
))}
</select>
</li>
</ul>
</div>
)}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useEffect } from 'react';
import { renewSession } from '../../lib/api';
// Last user activity across all tabs, so a tab left in the background does
// not sign out a user who is working in another one.
const LAST_ACTIVITY_KEY = 'tv_last_activity';
const RENEW_EVERY_MS = 60_000;
const ACTIVITY_EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart'] as const;
/**
* On-screen half of the session policy (app/auth/session_policy.py).
*
* The server already refuses a session past its idle timeout or maximum age,
* but only on the next request — a dashboard left open on an abandoned desk
* would stay readable. This takes the page down to /login at the same limits.
* User activity renews the session (at most once a minute), so reading a page
* without any request still counts as activity on the server too.
*/
export default function SessionGuard({ idleMinutes, expiresAt }: { idleMinutes: number; expiresAt: number }) {
useEffect(() => {
const idleMs = idleMinutes * 60_000;
let lastLocal = Date.now();
let lastRenew = 0;
let ended = false;
const store = (t: number) => { try { localStorage.setItem(LAST_ACTIVITY_KEY, String(t)); } catch { /* private mode */ } };
const shared = () => { try { return Number(localStorage.getItem(LAST_ACTIVITY_KEY)) || 0; } catch { return 0; } };
const end = (reason: 'idle' | 'expired') => {
if (ended) return;
ended = true;
// Best effort: revoke whatever is still valid. Plain fetch, not the
// api client — its 401 handling would race this redirect.
fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/auth/logout`, { method: 'POST', credentials: 'include' })
.catch(() => null)
.finally(() => { window.location.href = `/login?reason=${reason}`; });
};
// A failed renewal is not the end: another tab may have just rotated
// the one-use refresh token, or the network blipped. A session that
// really is gone shows on the next request (api.ts → /login).
const renew = () => { lastRenew = Date.now(); renewSession().catch(() => null); };
const onActivity = () => {
const now = Date.now();
if (now - lastLocal < 1000) return; // pointermove fires constantly
lastLocal = now;
store(now);
if (now - lastRenew > RENEW_EVERY_MS) renew();
};
// A (re)load counts as activity — renew so the server agrees.
store(lastLocal);
renew();
ACTIVITY_EVENTS.forEach((e) => window.addEventListener(e, onActivity, { passive: true }));
const tick = setInterval(() => {
const now = Date.now();
if (now >= expiresAt * 1000) end('expired');
else if (now - Math.max(lastLocal, shared()) >= idleMs) end('idle');
}, 10_000);
return () => {
clearInterval(tick);
ACTIVITY_EVENTS.forEach((e) => window.removeEventListener(e, onActivity));
};
}, [idleMinutes, expiresAt]);
return null;
}
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
import { Bars3Icon } from '@heroicons/react/24/outline';
import Drawer from './Drawer';
import PasskeyHint from '../../auth/PasskeyHint';
import SessionGuard from '../../auth/SessionGuard';
import api from '../../../lib/api';
import { UserInfo } from '../../../types';
@@ -61,6 +62,7 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-white">
{user?.session && <SessionGuard idleMinutes={user.session.idle_timeout_minutes} expiresAt={user.session.expires_at} />}
<Drawer sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} user={user} />
<div className="lg:pl-72">
+14 -7
View File
@@ -6,10 +6,10 @@ const api = axios.create({
withCredentials: true,
});
// Response interceptor: on a 401, first try to silently renew the 30-min
// access token from the (still valid, 7-day) refresh cookie and retry the
// Response interceptor: on a 401, first try to silently renew the short-lived
// access token from the (idle-timeout-long) refresh cookie and retry the
// original request — so an actively-used session is NOT bounced to /login every
// 30 minutes. Only when the refresh itself fails (refresh token expired/revoked
// few minutes. Only when the refresh itself fails (refresh token expired/revoked
// = genuinely inactive) do we redirect. Applies to all roles.
//
// Single-flight: the backend ROTATES the refresh token (revokes it on use), so
@@ -48,6 +48,16 @@ api.interceptors.request.use((config) => {
});
let refreshInFlight: Promise<unknown> | null = null;
// Renew the session now (SessionGuard calls this on user activity, so reading
// a page without fetching still counts against the server's idle timeout).
// Shares the single-flight below: the refresh token is one-use.
export function renewSession(): Promise<unknown> {
if (!refreshInFlight) {
refreshInFlight = api.post('/auth/refresh').finally(() => { refreshInFlight = null; });
}
return refreshInFlight;
}
api.interceptors.response.use(
(response) => {
if ((response.config?.url || '').includes('/auth/me')) {
@@ -68,13 +78,10 @@ api.interceptors.response.use(
if (error.response?.status === 401 && !isAuthEndpoint && !original._retried) {
original._retried = true;
try {
refreshInFlight = refreshInFlight || api.post('/auth/refresh');
await refreshInFlight; // cookie sent via withCredentials
await renewSession(); // cookie sent via withCredentials
return api(original); // replay the original request
} catch {
// fall through to the redirect below
} finally {
refreshInFlight = null;
}
}
if (error.response?.status === 401) {
+2
View File
@@ -181,6 +181,8 @@ export interface UserInfo {
email: string;
role: 'admin' | 'editor' | 'readonly';
passkey_suggested?: boolean; // show the "add a passkey" hint
// Limits SessionGuard enforces on screen; expires_at is epoch seconds.
session?: { idle_timeout_minutes: number; expires_at: number };
}
export interface Group {
+137
View File
@@ -0,0 +1,137 @@
"""
Session lifetime: idle timeout (refresh token lives exactly that long and is
renewed on activity) and absolute maximum session age (`sat` claim, carried
across refreshes, hard stop). Auth cookies are session-only.
"""
import os
import time
os.environ.setdefault("JWT_SECRET_KEY", "0" * 64)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402
import app.models # noqa: F401,E402
from app.auth import session_policy # noqa: E402
from app.auth.jwt_handler import create_refresh_token, decode_token, hash_password # noqa: E402
from app.database import get_db # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.setting import Setting # noqa: E402
from app.models.user import User, UserRole # noqa: E402
from app.rate_limiter import limiter # noqa: E402
from app.routers import auth # noqa: E402
PASSWORD = "Str0ng!Passw0rd"
@pytest.fixture
def env():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
db.add(User(username="alice", email="a@example.com", role=UserRole.EDITOR,
is_active=True, password_hash=hash_password(PASSWORD)))
db.commit()
app = FastAPI()
app.state.limiter = limiter
limiter.reset()
app.include_router(auth.router)
app.dependency_overrides[get_db] = lambda: db
return TestClient(app), db
def _set(db, key, value):
db.add(Setting(key=key, value=value))
db.commit()
def _login(client):
res = client.post("/auth/login", json={"username": "alice", "password": PASSWORD})
assert res.status_code == 200, res.text
return res
def _lifetime(token):
p = decode_token(token)
return p["exp"] - p["iat"]
def test_defaults_idle_15_minutes_and_max_10_hours(env):
client, db = env
body = _login(client).json()
assert _lifetime(body["refresh_token"]) == pytest.approx(15 * 60, abs=2)
assert _lifetime(body["access_token"]) <= 15 * 60 / 2 + 1
sat = decode_token(body["access_token"])["sat"]
assert sat == pytest.approx(time.time(), abs=5)
me = client.get("/auth/me", headers={"Authorization": f"Bearer {body['access_token']}"}).json()
assert me["session"]["idle_timeout_minutes"] == 15
assert me["session"]["expires_at"] == sat + 10 * 3600
def test_cookies_are_session_only(env):
client, _ = env
cookies = _login(client).headers.get_list("set-cookie")
assert len(cookies) == 2
for c in cookies:
assert "max-age" not in c.lower() and "expires" not in c.lower(), c
def test_settings_are_clamped(env):
_, db = env
_set(db, "session_idle_timeout_minutes", "999")
_set(db, "session_max_hours", "48")
assert session_policy.idle_timeout_minutes(db) == 180
assert session_policy.max_session_hours(db) == 12
db.query(Setting).filter_by(key="session_idle_timeout_minutes").one().value = "1"
db.query(Setting).filter_by(key="session_max_hours").one().value = "nonsense"
db.commit()
assert session_policy.idle_timeout_minutes(db) == 5
assert session_policy.max_session_hours(db) == 10
def test_access_token_is_capped_at_30_minutes(env):
client, db = env
_set(db, "session_idle_timeout_minutes", "180")
body = _login(client).json()
assert _lifetime(body["refresh_token"]) == pytest.approx(180 * 60, abs=2)
assert _lifetime(body["access_token"]) <= 30 * 60 + 1
def test_refresh_slides_the_idle_window_but_keeps_the_session_start(env):
client, _ = env
first = _login(client).json()
sat = decode_token(first["refresh_token"])["sat"]
res = client.post("/auth/refresh", json={"refresh_token": first["refresh_token"]})
assert res.status_code == 200, res.text
renewed = res.json()["refresh_token"]
assert decode_token(renewed)["sat"] == sat
assert _lifetime(renewed) == pytest.approx(15 * 60, abs=2)
def test_refresh_past_max_age_is_refused(env):
client, _ = env
started = int(time.time()) - 10 * 3600 - 1
stale = create_refresh_token({"sub": "alice", "role": "editor", "sat": started})
res = client.post("/auth/refresh", json={"refresh_token": stale})
assert res.status_code == 401
assert "expired" in res.json()["detail"].lower()
def test_no_token_outlives_the_max_age(env):
client, _ = env
started = int(time.time()) - 10 * 3600 + 60 # one minute left
token = create_refresh_token({"sub": "alice", "role": "editor", "sat": started})
body = client.post("/auth/refresh", json={"refresh_token": token}).json()
for t in (body["access_token"], body["refresh_token"]):
assert decode_token(t)["exp"] <= started + 10 * 3600
def test_legacy_refresh_token_without_sat_is_refused(env):
"""Rotation reset its iat, so it cannot prove when the session began."""
client, _ = env
legacy = create_refresh_token({"sub": "alice", "role": "editor"})
assert client.post("/auth/refresh", json={"refresh_token": legacy}).status_code == 401