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.
This commit is contained in:
NuklearRabbit
2026-08-02 04:51:54 +02:00
parent 56a65b2364
commit ffc88e33b4
7 changed files with 149 additions and 22 deletions
+4
View File
@@ -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
+33 -3
View File
@@ -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,
+1
View File
@@ -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"
+29
View File
@@ -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
+11
View File
@@ -1,5 +1,13 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
type UnauthorizedListener = () => void;
const unauthorizedListeners = new Set<UnauthorizedListener>();
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<T>(path: string, init?: RequestInit): Promise<T> {
body = undefined;
}
const error = body?.error;
if (response.status === 401) {
unauthorizedListeners.forEach((listener) => listener());
}
throw new ApiError(
response.status,
error?.code ?? String(response.status),
+9 -1
View File
@@ -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 (
<div className="page">
<LoadingState label="Verifying session…" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
+62 -18
View File
@@ -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<void>;
logout: () => void;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | undefined>(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<CurrentUser | null>(() => {
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<CurrentUser | null>(() => readCachedUser());
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
api
.get<CurrentUser>("/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<CurrentUser>("/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 (
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>
{children}
</AuthContext.Provider>
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>{children}</AuthContext.Provider>
);
}
@@ -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;
}