From ffc88e33b4ec55b275b6a57e9f59ab3a23ec9878 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Sun, 2 Aug 2026 04:51:54 +0200 Subject: [PATCH] feat(auth): add server-backed demo sessions The browser treated sessionStorage as the source of truth for the logged-in user and never verified or invalidated the server-side session cookie: no GET /api/v1/demo/session or POST /api/v1/demo/logout endpoint existed, and a central 401 handler was defined but never wired up. Add both endpoints; the session-check response is marked Cache-Control: no-store to avoid the browser serving a stale "authenticated" response right after logout. AuthProvider now verifies against the server on every mount (sessionStorage only caches presentation state to avoid a login-screen flash), subscribes to a central 401 listener on the API client, and RequireAuth shows a loading state during verification instead of flashing protected content or the wrong role. --- .env.example | 4 ++ backend/app/api/routers/demo.py | 36 ++++++++++- backend/app/core/config.py | 1 + backend/tests/test_auth.py | 29 +++++++++ frontend/src/api/client.ts | 11 ++++ frontend/src/components/RequireAuth.tsx | 10 +++- frontend/src/context/AuthContext.tsx | 80 +++++++++++++++++++------ 7 files changed, 149 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 01f0a1b..7203756 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,10 @@ POSTGRES_PASSWORD=mobilityops APP_SECRET=replace-in-production DEMO_TODAY=2026-08-01 TZ=Europe/Brussels +# Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the +# current Unraid review environment); set true only once MobilityOps is served over HTTPS, +# otherwise browsers will silently drop the cookie and no one can log in. +SESSION_COOKIE_SECURE=false # n8n N8N_BASE_URL=http://n8n:5678 diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py index 3e60cf2..85c3b6f 100644 --- a/backend/app/api/routers/demo.py +++ b/backend/app/api/routers/demo.py @@ -1,14 +1,15 @@ from __future__ import annotations import time +import uuid -from fastapi import APIRouter, Depends, Response +from fastapi import APIRouter, Depends, Request, Response from sqlalchemy import select from sqlalchemy.orm import Session -from app.api.deps import get_db, require_operations_manager +from app.api.deps import get_current_user, get_db, require_operations_manager from app.core.config import get_settings -from app.core.security import SessionPayload, create_session_token +from app.core.security import SessionPayload, create_session_token, read_session_token from app.models.user import User from app.schemas import CurrentUser, DemoLoginRequest from app.seed_loader import reset_and_seed @@ -41,6 +42,7 @@ def demo_login( token, httponly=True, samesite="lax", + secure=settings.session_cookie_secure, max_age=settings.session_ttl_seconds, ) record_audit_event( @@ -56,6 +58,34 @@ def demo_login( return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=body.role) +@router.get("/session", response_model=CurrentUser) +def get_session( + response: Response, user: CurrentUser = Depends(get_current_user) +) -> CurrentUser: + # Never let the browser (or an intermediary) cache an authentication check — a stale + # cached 200 here would keep showing a logged-out browser as authenticated. + response.headers["Cache-Control"] = "no-store" + return user + + +@router.post("/logout") +def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict: + token = request.cookies.get(settings.session_cookie_name) + payload = read_session_token(token) if token else None + if payload is not None: + record_audit_event( + db, + actor_type="user", + actor_id=uuid.UUID(payload.user_id), + actor_label=payload.display_name, + action="demo_logout", + entity_type="user", + ) + db.commit() + response.delete_cookie(settings.session_cookie_name) + return {"status": "logged_out"} + + @router.post("/reset") def demo_reset( response: Response, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ab21ae1..e31f5ce 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -25,6 +25,7 @@ class Settings(BaseSettings): app_secret: str = "replace-in-production" session_cookie_name: str = "mobilityops_session" session_ttl_seconds: int = 60 * 60 * 8 + session_cookie_secure: bool = False seed_dir: str = "/app/seed" knowledge_dir: str = "/app/knowledge/procedures" mcp_hub_service_token: str = "replace-me-mcp-hub-token" diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 4931bde..8bbeeb8 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -18,3 +18,32 @@ def test_operations_manager_can_reset_demo(ops_client): response = ops_client.post("/api/v1/demo/reset") assert response.status_code == 200 assert response.json()["counts"]["vehicles"] == 50 + + +def test_session_endpoint_requires_authentication(client): + response = client.get("/api/v1/demo/session") + assert response.status_code == 401 + + +def test_session_endpoint_confirms_logged_in_user(ops_client): + response = ops_client.get("/api/v1/demo/session") + assert response.status_code == 200 + body = response.json() + assert body["role"] == "operations_manager" + assert body["public_ref"] == "USR-OPS" + + +def test_logout_invalidates_session(ops_client): + confirmed = ops_client.get("/api/v1/demo/session") + assert confirmed.status_code == 200 + + logout = ops_client.post("/api/v1/demo/logout") + assert logout.status_code == 200 + + after = ops_client.get("/api/v1/demo/session") + assert after.status_code == 401 + + +def test_logout_without_a_session_is_safe(client): + response = client.post("/api/v1/demo/logout") + assert response.status_code == 200 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8022147..21176b2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,13 @@ const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; +type UnauthorizedListener = () => void; +const unauthorizedListeners = new Set(); + +export function onUnauthorized(listener: UnauthorizedListener): () => void { + unauthorizedListeners.add(listener); + return () => unauthorizedListeners.delete(listener); +} + export class ApiError extends Error { status: number; code: string; @@ -31,6 +39,9 @@ async function request(path: string, init?: RequestInit): Promise { body = undefined; } const error = body?.error; + if (response.status === 401) { + unauthorizedListeners.forEach((listener) => listener()); + } throw new ApiError( response.status, error?.code ?? String(response.status), diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx index 70dec36..d967baf 100644 --- a/frontend/src/components/RequireAuth.tsx +++ b/frontend/src/components/RequireAuth.tsx @@ -1,9 +1,17 @@ import type { ReactNode } from "react"; import { Navigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; +import { LoadingState } from "./PageChrome"; export function RequireAuth({ children }: { children: ReactNode }) { - const { user } = useAuth(); + const { user, loading } = useAuth(); + if (loading) { + return ( +
+ +
+ ); + } if (!user) { return ; } diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 73960a0..b45dbf4 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,45 +1,93 @@ -import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; -import { api, ApiError } from "../api/client"; +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; +import { api, onUnauthorized } from "../api/client"; import type { CurrentUser, Role } from "../api/types"; interface AuthState { user: CurrentUser | null; loading: boolean; loginAs: (role: Role) => Promise; - logout: () => void; + logout: () => Promise; } const AuthContext = createContext(undefined); const STORAGE_KEY = "mobilityops.demo-user"; +function readCachedUser(): CurrentUser | null { + const stored = sessionStorage.getItem(STORAGE_KEY); + return stored ? (JSON.parse(stored) as CurrentUser) : null; +} + +function cacheUser(user: CurrentUser | null) { + if (user) { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user)); + } else { + sessionStorage.removeItem(STORAGE_KEY); + } +} + export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(() => { - const stored = sessionStorage.getItem(STORAGE_KEY); - return stored ? (JSON.parse(stored) as CurrentUser) : null; - }); - const [loading, setLoading] = useState(false); + // The cached value only avoids a login-screen flash while the server round trip below is + // in flight; it is never trusted on its own. `loading` stays true until the server has + // confirmed (or rejected) the session cookie. + const [user, setUser] = useState(() => readCachedUser()); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + api + .get("/api/v1/demo/session") + .then((confirmed) => { + if (cancelled) return; + setUser(confirmed); + cacheUser(confirmed); + }) + .catch(() => { + if (cancelled) return; + setUser(null); + cacheUser(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect( + () => + onUnauthorized(() => { + setUser(null); + cacheUser(null); + }), + [], + ); const loginAs = useCallback(async (role: Role) => { setLoading(true); try { const loggedIn = await api.post("/api/v1/demo/login", { role }); setUser(loggedIn); - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(loggedIn)); + cacheUser(loggedIn); } finally { setLoading(false); } }, []); - const logout = useCallback(() => { + const logout = useCallback(async () => { setUser(null); - sessionStorage.removeItem(STORAGE_KEY); + cacheUser(null); + try { + await api.post("/api/v1/demo/logout"); + } catch { + // Best effort: the cookie is cleared server-side when it works, and the client has + // already dropped its own state either way. + } }, []); return ( - - {children} - + {children} ); } @@ -48,7 +96,3 @@ export function useAuth(): AuthState { if (!ctx) throw new Error("useAuth must be used within AuthProvider"); return ctx; } - -export function isSessionExpired(error: unknown): boolean { - return error instanceof ApiError && error.status === 401; -}