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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user