TASK-004: Bootstrap frontend auth state from backend session check

Validates session on app mount by calling GET /api/auth/session instead of relying
solely on cached sessionStorage. This ensures the UI state always reflects server
reality — expired or revoked sessions are detected immediately.

Changes:
- Backend: Add GET /api/auth/session endpoint (requires valid session, returns 200/401)
- Frontend: Add useSessionValidation hook for mount-time validation
- Frontend: Add SessionValidationLoading component for validation spinner
- Frontend: Update AuthProvider to call validation on mount with loading state
- Frontend: Add validateSession API function
- Docs: Update Features.md with session validation behavior
- Docs: Update Web-Development.md with session validation pattern

Handles three outcomes:
1. Valid session (200): Proceed with cached state
2. Invalid session (401): Clear sessionStorage and redirect to login
3. Network error: Don't logout (backend may be temporarily unreachable)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 12:00:21 +02:00
parent d982fe3efc
commit 29daaa9906
11 changed files with 1314 additions and 15 deletions

View File

@@ -30,6 +30,14 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces
- After entering the correct password the user is taken to the page they originally requested. - After entering the correct password the user is taken to the page they originally requested.
- A logout option is available from every page so the user can end their session. - A logout option is available from every page so the user can end their session.
### Session Validation on App Load
- On app mount (page reload or initial load), the frontend validates the cached session with the backend by calling `GET /api/auth/session`.
- While the validation check is in flight, a loading spinner is displayed to avoid UI flicker.
- If the backend returns **200**, the session is valid and the app proceeds normally.
- If the backend returns **401**, the session has expired or been revoked (server-side DB deletion, restart, etc.), and the user is logged out and redirected to the login page.
- If a **network error** occurs (backend temporarily unreachable), the user is not logged out — the app assumes the backend will recover and continues with the cached session state. The next API call will trigger a 401 if the session is actually invalid.
--- ---
## 3. Ban Overview (Dashboard) ## 3. Ban Overview (Dashboard)

File diff suppressed because it is too large Load Diff

View File

@@ -471,6 +471,38 @@ const load = useCallback(() => {
}, []); }, []);
``` ```
### Session Validation on App Mount
The `AuthProvider` uses the `useSessionValidation` hook to validate the cached session with the backend on app mount. This pattern ensures that the UI state always reflects reality — expired or revoked sessions are detected immediately, not after the first API call.
**How it works:**
1. `useSessionValidation` is called during `AuthProvider` initialization.
2. It calls `GET /api/auth/session`, which requires a valid session cookie/header.
3. While the check is in flight, a loading spinner (`SessionValidationLoading`) is displayed.
4. **On 200 (valid session):** The app proceeds with the cached session state.
5. **On 401 (invalid session):** The user is logged out and redirected to `/login`.
6. **On network error:** The error is logged but the user is not logged out — the backend may be temporarily unreachable. The next API call will trigger a 401 if needed.
**Example hook signature:**
```ts
interface UseSessionValidationResult {
isLoading: boolean;
error: Error | null;
}
function useSessionValidation(
onSessionValid: () => void,
onSessionExpired: () => void,
onNetworkError?: (error: Error) => void,
): UseSessionValidationResult {
// Calls validateSession() and handles the three outcomes.
}
```
This pattern prevents **stale session flicker** — the brief moment when a user sees the authenticated UI before the first API call reveals a 401. It also handles scenarios where the session cookie has expired server-side (server restart, session duration elapsed, manual DB deletion) before the frontend detects it.
--- ---
## 8. Naming Conventions ## 8. Naming Conventions

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
import structlog import structlog
from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi import APIRouter, HTTPException, Request, Response, status
from app.dependencies import DbDep, SessionCacheDep, SessionRepoDep, SettingsDep from app.dependencies import AuthDep, DbDep, SessionCacheDep, SessionRepoDep, SettingsDep
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse from app.models.auth import LoginRequest, LoginResponse, LogoutResponse
from app.services import auth_service from app.services import auth_service
from app.utils.constants import SESSION_COOKIE_NAME from app.utils.constants import SESSION_COOKIE_NAME
@@ -76,6 +76,31 @@ async def login(
return LoginResponse(token=signed_token, expires_at=expires_at) return LoginResponse(token=signed_token, expires_at=expires_at)
@router.get(
"/session",
summary="Validate the current session",
)
async def validate_session(
_: AuthDep,
) -> dict[str, bool]:
"""Validate the current session.
This endpoint requires a valid session and returns 200 if the session is
valid and still active. If the session is invalid, expired, or missing,
FastAPI's ``require_auth`` dependency returns 401 automatically.
The frontend calls this on mount to bootstrap its authentication state
from the backend rather than relying solely on cached ``sessionStorage``.
Args:
_: The injected session object (unused, but its presence triggers validation).
Returns:
A simple JSON object confirming the session is valid.
"""
return {"valid": True}
@router.post( @router.post(
"/logout", "/logout",
response_model=LogoutResponse, response_model=LogoutResponse,

View File

@@ -202,6 +202,68 @@ class TestRequireAuth:
assert call_count == 2 assert call_count == 2
# ---------------------------------------------------------------------------
# Session validation (Task 4)
# ---------------------------------------------------------------------------
class TestValidateSession:
"""GET /api/auth/session."""
async def test_validate_session_returns_200_with_valid_token(
self, client: AsyncClient
) -> None:
"""Validate session returns 200 for a valid authenticated request."""
await _do_setup(client)
await _login(client)
response = await client.get("/api/auth/session")
assert response.status_code == 200
assert response.json() == {"valid": True}
async def test_validate_session_returns_401_without_token(
self, client: AsyncClient
) -> None:
"""Validate session returns 401 when no token is present."""
await _do_setup(client)
response = await client.get("/api/auth/session")
assert response.status_code == 401
async def test_validate_session_returns_401_with_invalid_token(
self, client: AsyncClient
) -> None:
"""Validate session returns 401 for an invalid or expired token."""
await _do_setup(client)
response = await client.get(
"/api/auth/session",
headers={"Authorization": "Bearer invalidtoken"},
)
assert response.status_code == 401
async def test_validate_session_with_cookie(
self, client: AsyncClient
) -> None:
"""Validate session works with cookie-based authentication."""
await _do_setup(client)
token = await _login(client)
# Login sets the cookie on the client automatically via httpx.
response = await client.get("/api/auth/session")
assert response.status_code == 200
assert response.json() == {"valid": True}
async def test_validate_session_after_logout(
self, client: AsyncClient
) -> None:
"""Validate session returns 401 after logout."""
await _do_setup(client)
token = await _login(client)
await client.post("/api/auth/logout")
response = await client.get(
"/api/auth/session",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 401
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Session-token cache (Task 4) # Session-token cache (Task 4)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -1,7 +1,7 @@
/** /**
* Authentication API functions. * Authentication API functions.
* *
* Wraps calls to POST /api/auth/login and POST /api/auth/logout * Wraps calls to POST /api/auth/login, POST /api/auth/logout, and GET /api/auth/session
* using the central typed fetch client. * using the central typed fetch client.
*/ */
@@ -27,3 +27,17 @@ export async function login(password: string): Promise<LoginResponse> {
export async function logout(): Promise<LogoutResponse> { export async function logout(): Promise<LogoutResponse> {
return api.post<LogoutResponse>(ENDPOINTS.authLogout, {}); return api.post<LogoutResponse>(ENDPOINTS.authLogout, {});
} }
/**
* Validate the current session with the backend.
*
* This is called on app mount to confirm the session is still valid on the
* server side, rather than relying solely on the cached sessionStorage flag.
*
* @param signal - Optional abort signal for request cancellation.
* @returns An object confirming the session is valid.
* @throws {ApiError} When the session is invalid or expired (401).
*/
export async function validateSession(signal?: AbortSignal): Promise<{ valid: boolean }> {
return api.get<{ valid: boolean }>(ENDPOINTS.authSession, signal);
}

View File

@@ -23,6 +23,7 @@ export const ENDPOINTS = {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
authLogin: "/auth/login", authLogin: "/auth/login",
authLogout: "/auth/logout", authLogout: "/auth/logout",
authSession: "/auth/session",
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Dashboard // Dashboard

View File

@@ -0,0 +1,35 @@
/**
* Session validation loading indicator.
*
* Displayed on app mount while the backend validates the session.
*/
import { makeStyles, Spinner } from "@fluentui/react-components";
import React from "react";
const useStyles = makeStyles({
container: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
width: "100%",
flexDirection: "column",
gap: "16px",
},
});
/**
* Loading indicator shown during session validation on app mount.
*
* @returns React element displaying a centered spinner and message.
*/
export function SessionValidationLoading(): React.JSX.Element {
const styles = useStyles();
return (
<div className={styles.container}>
<Spinner size="large" label="Validating session…" />
</div>
);
}

View File

@@ -0,0 +1,74 @@
/**
* Hook for validating the session on app mount.
*
* Calls the backend to confirm the session is still valid, rather than relying
* solely on cached sessionStorage. Handles network errors gracefully by not
* logging out — only explicit 401/403 responses trigger logout.
*/
import { useCallback, useEffect, useState } from "react";
import * as authApi from "../api/auth";
import { ApiError, isAuthError } from "../api/client";
export interface UseSessionValidationResult {
/** `true` when the validation check is in flight. */
isLoading: boolean;
/** An error if validation failed for a reason other than 401/403. */
error: Error | null;
}
/**
* Validate the session on mount.
*
* This hook is called once during app initialization to confirm the session
* is valid on the backend. It handles three cases:
*
* 1. **Valid session (200)** — `onSessionValid()` is called.
* 2. **Invalid session (401/403)** — `onSessionExpired()` is called to log out.
* 3. **Network error** — `onNetworkError()` is called. The user is not logged out
* because the backend may be temporarily unreachable.
*
* @param onSessionValid - Callback when session is confirmed valid.
* @param onSessionExpired - Callback when session is invalid (401/403).
* @param onNetworkError - Callback when a network error occurs.
* @returns An object with `isLoading` and `error` states.
*/
export function useSessionValidation(
onSessionValid: () => void,
onSessionExpired: () => void,
onNetworkError?: (error: Error) => void,
): UseSessionValidationResult {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
const validate = useCallback(async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
await authApi.validateSession();
onSessionValid();
} catch (err) {
if (err instanceof ApiError && isAuthError(err)) {
// Explicit 401/403 — session is invalid or expired.
onSessionExpired();
} else if (err instanceof Error) {
// Network error or other issue — don't log out.
setError(err);
onNetworkError?.(err);
} else {
const unknownError = new Error("Unknown error during session validation");
setError(unknownError);
onNetworkError?.(unknownError);
}
} finally {
setIsLoading(false);
}
}, [onSessionValid, onSessionExpired, onNetworkError]);
useEffect(() => {
void validate();
}, [validate]);
return { isLoading, error };
}

View File

@@ -11,12 +11,18 @@
* accepts the request (returns 2xx) and not authenticated when the backend * accepts the request (returns 2xx) and not authenticated when the backend
* rejects it (returns 401/403). * rejects it (returns 401/403).
* *
* On app mount, the provider validates the cached session state with the backend
* by calling `GET /api/auth/session`. While this check is in flight, a loading
* indicator is shown. If the backend returns 401/403, the session is cleared.
* If a network error occurs, the user is not logged out (the backend may be
* temporarily unreachable).
*
* The `isAuthenticated` state persists in `sessionStorage` to survive page * The `isAuthenticated` state persists in `sessionStorage` to survive page
* refreshes within the browser tab but is cleared on tab close. The session * refreshes within the browser tab but is cleared on tab close. The session
* cookie itself persists according to the backend's cookie settings. * cookie itself persists according to the backend's cookie settings.
*/ */
import { import React, {
createContext, createContext,
useCallback, useCallback,
useEffect, useEffect,
@@ -26,6 +32,8 @@ import {
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import * as authApi from "../api/auth"; import * as authApi from "../api/auth";
import { setUnauthorizedHandler } from "../api/client"; import { setUnauthorizedHandler } from "../api/client";
import { SessionValidationLoading } from "../components/SessionValidationLoading";
import { useSessionValidation } from "../hooks/useSessionValidation";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Context // Context
@@ -56,6 +64,10 @@ const IS_AUTHENTICATED_KEY = "bangui_authenticated";
* *
* Place this inside `<FluentProvider>` and `<BrowserRouter>` so all * Place this inside `<FluentProvider>` and `<BrowserRouter>` so all
* descendants can call `useAuth()`. * descendants can call `useAuth()`.
*
* On mount, validates the cached session with the backend. While validation
* is in progress, a loading indicator is shown. If validation fails with 401,
* the user is logged out. Network errors do not cause logout.
*/ */
export function AuthProvider({ export function AuthProvider({
children, children,
@@ -66,6 +78,7 @@ export function AuthProvider({
const stored = sessionStorage.getItem(IS_AUTHENTICATED_KEY); const stored = sessionStorage.getItem(IS_AUTHENTICATED_KEY);
return stored === "true"; return stored === "true";
}); });
const [isValidating, setIsValidating] = useState<boolean>(isAuthenticated);
const navigate = useNavigate(); const navigate = useNavigate();
const handleSessionExpired = useCallback((): void => { const handleSessionExpired = useCallback((): void => {
@@ -74,6 +87,18 @@ export function AuthProvider({
navigate("/login", { replace: true }); navigate("/login", { replace: true });
}, [navigate]); }, [navigate]);
const handleSessionValid = useCallback((): void => {
// Session is already set from sessionStorage; no action needed.
}, []);
const handleValidationError = useCallback(
(error: Error): void => {
// Network error — log but don't logout.
console.warn("Session validation network error:", error);
},
[],
);
useEffect((): (() => void) => { useEffect((): (() => void) => {
setUnauthorizedHandler((): void => { setUnauthorizedHandler((): void => {
handleSessionExpired(); handleSessionExpired();
@@ -83,6 +108,16 @@ export function AuthProvider({
}; };
}, [handleSessionExpired]); }, [handleSessionExpired]);
const validationResult = useSessionValidation(
handleSessionValid,
handleSessionExpired,
handleValidationError,
);
useEffect(() => {
setIsValidating(validationResult.isLoading);
}, [validationResult.isLoading]);
const login = useCallback(async (password: string): Promise<void> => { const login = useCallback(async (password: string): Promise<void> => {
await authApi.login(password); await authApi.login(password);
sessionStorage.setItem(IS_AUTHENTICATED_KEY, "true"); sessionStorage.setItem(IS_AUTHENTICATED_KEY, "true");
@@ -104,5 +139,10 @@ export function AuthProvider({
[isAuthenticated, login, logout], [isAuthenticated, login, logout],
); );
// Show loading spinner while validating session on mount.
if (isValidating && isAuthenticated) {
return <SessionValidationLoading />;
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
} }

View File

@@ -41,10 +41,12 @@ describe("AuthProvider", () => {
it("calls handler to clear auth state and redirect to /login", async () => { it("calls handler to clear auth state and redirect to /login", async () => {
sessionStorage.setItem("bangui_authenticated", "true"); sessionStorage.setItem("bangui_authenticated", "true");
let capturedHandler: (() => void) | null = null; const capturedHandlers: Array<(() => void) | null> = [];
vi.spyOn(clientModule, "setUnauthorizedHandler").mockImplementation((handler) => { vi.spyOn(clientModule, "setUnauthorizedHandler").mockImplementation(
capturedHandler = handler; (handler: (() => void) | null) => {
}); capturedHandlers.push(handler);
},
);
render( render(
<FluentProvider theme={webLightTheme}> <FluentProvider theme={webLightTheme}>
@@ -59,6 +61,7 @@ describe("AuthProvider", () => {
); );
// Invoke the handler that was captured during render // Invoke the handler that was captured during render
const capturedHandler = capturedHandlers[0];
if (typeof capturedHandler === "function") { if (typeof capturedHandler === "function") {
capturedHandler(); capturedHandler();
} }