Fix misleading auth token storage in sessionStorage

- Remove JWT token and expires_at from sessionStorage
- Simplify AuthProvider to use boolean isAuthenticated flag
- Persist only isAuthenticated boolean for page-reload continuity
- Update AuthProvider test to verify new auth model
- Add comprehensive auth documentation to Web-Development.md explaining:
  - Cookie-based authentication model
  - How frontend auth state persists
  - Why tokens are no longer stored
  - Error handling flow for 401/403 responses

The authentication model is cookie-based: the backend sets bangui_session
cookie on login, frontend automatically includes it via credentials:
'include', and the backend is the sole authority on session validity.
Previously stored tokens were never actually used and made the auth model
misleading during development.

Fixes TASK-STATE-04.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-23 09:30:29 +02:00
parent 941502b710
commit f3d6574160
4 changed files with 67 additions and 64 deletions

View File

@@ -1,23 +1,3 @@
### TASK-STATE-03 — `DashboardFilterBar` Has Dual State Source
**Where found**
`frontend/src/components/DashboardFilterBar.tsx`. The component reads from both `DashboardFilterProvider` context and from props using `??` as a fallback. `HistoryPage` passes filter values via props without mounting a `DashboardFilterProvider`, so the context values are `undefined` and the `??` fallback silently provides context defaults. `DashboardPage` uses context, so props are `undefined` and context values apply. Both pages render the same component but through different code paths.
**Goal**
Choose one data source and use it consistently. The recommended approach is to use props everywhere: `DashboardPage` should read from context via `useDashboardFilter()` and pass the values explicitly to `DashboardFilterBar` as props, exactly like `HistoryPage` does. This makes the component's behaviour predictable — it always reads from props, never from context.
**Possible traps and issues**
- `DashboardFilterProvider` may be used by other components on the dashboard. Audit all consumers of `useDashboardFilter()` before removing the context read from `DashboardFilterBar`.
- The `??` fallback chain must be fully removed; otherwise the dual-source behaviour can creep back.
**Docs changes needed**
None required.
**Why this is needed**
Silent dual-source components are a debugging hazard. A developer adding a new consumer of `DashboardFilterBar` has no obvious signal about which data source is active, leading to subtle bugs when one source overrides the other.
---
### TASK-STATE-04 — Token in `sessionStorage` Is Never Sent (Misleading Auth Model)
**Where found**

View File

@@ -485,7 +485,42 @@ if (data.length > MAX_VISIBLE_BANS) { ... }
---
## 11. Error Handling
## 10. Authentication
### Session Model
The authentication model is **cookie-based**:
1. **Login:** The frontend sends the master password (SHA256-hashed) to `POST /api/auth/login`. The backend validates it, creates a session, and returns an HTTP response with a `Set-Cookie` header containing `bangui_session`.
2. **Requests:** All API requests automatically include the session cookie via `credentials: "include"` in the fetch options. The frontend does **not** send an Authorization header or token in the request body.
3. **Session validity:** The backend is the **sole authority** on whether a session is valid. The frontend is authenticated when the backend accepts the request (returns 2xx) and is not authenticated when the backend rejects it (returns 401 or 403).
4. **Logout:** The frontend sends `POST /api/auth/logout`, and the backend invalidates the session and clears the cookie.
### Frontend Auth State
- The `AuthProvider` context (`providers/AuthProvider.tsx`) manages a simple boolean `isAuthenticated` state.
- On successful login, `isAuthenticated` is set to `true` and persisted to `sessionStorage` for page-reload continuity.
- On logout or when `SESSION_EXPIRED_EVENT` fires (triggered by a 401/403 API response), `isAuthenticated` is set to `false` and cleared from `sessionStorage`.
- The `sessionStorage` entry (`bangui_authenticated`) survives page refreshes within the same tab but is automatically cleared when the tab closes.
- The session cookie persists according to the backend's cookie settings (typically for the duration of the browser session or as configured server-side).
### Why Not Token-Based?
The frontend previously stored JWT tokens in `sessionStorage` but never actually used them. The authentication model is entirely cookie-based (handled by the browser automatically), making stored tokens confusing and misleading. If token-based auth is needed in the future, the storage approach would need to change significantly (e.g., to include Authorization headers in all requests). For now, the only persistent state the frontend needs is the boolean `isAuthenticated` flag.
### Error Handling
When an API request returns 401 or 403:
1. The `client.ts` module dispatches a `SESSION_EXPIRED_EVENT`.
2. The `AuthProvider` listener handles it by clearing `isAuthenticated` and redirecting to `/login`.
3. Hooks must use `handleFetchError` (from `utils/fetchError.ts`) to avoid displaying auth errors as user-facing error messages.
---
## 12. Error Handling
- Wrap API calls in `try-catch` inside hooks — components should never see raw exceptions.
- **All hook catch blocks must use `handleFetchError` rather than directly calling `setError`.** This ensures auth errors (401/403) are routed to the global session-expiry flow instead of displaying confusing error text in the UI. Use the pattern: `handleFetchError(err, setError, "User-friendly fallback message")`.
@@ -496,7 +531,7 @@ if (data.length > MAX_VISIBLE_BANS) { ... }
---
## 12. Performance
## 13. Performance
- Use `React.memo` only when profiling reveals unnecessary re-renders — do not wrap every component by default.
- Use `useMemo` and `useCallback` for expensive computations and stable callback references passed to child components — not for trivial values.
@@ -506,7 +541,7 @@ if (data.length > MAX_VISIBLE_BANS) { ... }
---
## 13. Accessibility
## 14. Accessibility
- Use semantic HTML elements (`<button>`, `<nav>`, `<table>`, `<main>`, `<header>`) — not `<div>` with click handlers.
- Every interactive element must be **keyboard accessible** (focusable, operable with Enter/Space/Escape as appropriate).
@@ -517,7 +552,7 @@ if (data.length > MAX_VISIBLE_BANS) { ... }
---
## 14. Testing
## 15. Testing
- Write tests for every new component, hook, and utility function.
- Use **Vitest** (or Jest) as the test runner and **React Testing Library** for component tests.

View File

@@ -2,9 +2,18 @@
* Authentication context and provider.
*
* Manages the user's authenticated state and exposes `login`, `logout`, and
* `isAuthenticated` through `useAuth()`. The session token is persisted in
* `sessionStorage` so it survives page refreshes within the browser tab but
* is automatically cleared when the tab is closed.
* `isAuthenticated` through `useAuth()`.
*
* The authentication model is **cookie-based**: the backend sets a session
* cookie (`bangui_session`) on successful login, and the frontend automatically
* includes it in all requests via `credentials: "include"`. The backend is the
* authority on session validity; the frontend is authenticated when the backend
* accepts the request (returns 2xx) and not authenticated when the backend
* rejects it (returns 401/403).
*
* The `isAuthenticated` state persists in `sessionStorage` to survive page
* refreshes within the browser tab but is cleared on tab close. The session
* cookie itself persists according to the backend's cookie settings.
*/
import {
@@ -19,16 +28,11 @@ import * as authApi from "../api/auth";
import { SESSION_EXPIRED_EVENT } from "../api/client";
// ---------------------------------------------------------------------------
// Types
// Context
// ---------------------------------------------------------------------------
interface AuthState {
token: string | null;
expiresAt: string | null;
}
export interface AuthContextValue {
/** `true` when a valid session token is held in state. */
/** `true` when the backend considers the session valid. */
isAuthenticated: boolean;
/**
* Authenticate with the master password.
@@ -39,14 +43,9 @@ export interface AuthContextValue {
logout: () => Promise<void>;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
export const AuthContext = createContext<AuthContextValue | null>(null);
const SESSION_KEY = "bangui_token";
const SESSION_EXPIRES_KEY = "bangui_expires_at";
const IS_AUTHENTICATED_KEY = "bangui_authenticated";
// ---------------------------------------------------------------------------
// Provider
@@ -63,16 +62,15 @@ export function AuthProvider({
}: {
children: React.ReactNode;
}): React.JSX.Element {
const [auth, setAuth] = useState<AuthState>(() => ({
token: sessionStorage.getItem(SESSION_KEY),
expiresAt: sessionStorage.getItem(SESSION_EXPIRES_KEY),
}));
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => {
const stored = sessionStorage.getItem(IS_AUTHENTICATED_KEY);
return stored === "true";
});
const navigate = useNavigate();
const handleSessionExpired = useCallback((): void => {
sessionStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(SESSION_EXPIRES_KEY);
setAuth({ token: null, expiresAt: null });
sessionStorage.removeItem(IS_AUTHENTICATED_KEY);
setIsAuthenticated(false);
navigate("/login", { replace: true });
}, [navigate]);
@@ -83,17 +81,10 @@ export function AuthProvider({
};
}, [handleSessionExpired]);
const isAuthenticated = useMemo<boolean>(() => {
if (!auth.token || !auth.expiresAt) return false;
// Treat the session as expired if the expiry time has passed.
return new Date(auth.expiresAt) > new Date();
}, [auth]);
const login = useCallback(async (password: string): Promise<void> => {
const response = await authApi.login(password);
sessionStorage.setItem(SESSION_KEY, response.token);
sessionStorage.setItem(SESSION_EXPIRES_KEY, response.expires_at);
setAuth({ token: response.token, expiresAt: response.expires_at });
await authApi.login(password);
sessionStorage.setItem(IS_AUTHENTICATED_KEY, "true");
setIsAuthenticated(true);
}, []);
const logout = useCallback(async (): Promise<void> => {
@@ -101,9 +92,8 @@ export function AuthProvider({
await authApi.logout();
} finally {
// Always clear local state even if the API call fails (e.g. expired session).
sessionStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(SESSION_EXPIRES_KEY);
setAuth({ token: null, expiresAt: null });
sessionStorage.removeItem(IS_AUTHENTICATED_KEY);
setIsAuthenticated(false);
}
}, []);

View File

@@ -18,8 +18,7 @@ describe("AuthProvider", () => {
});
it("clears auth state and redirects to /login when session-expired fires", async () => {
sessionStorage.setItem("bangui_token", "token");
sessionStorage.setItem("bangui_expires_at", new Date(Date.now() + 10000).toISOString());
sessionStorage.setItem("bangui_authenticated", "true");
render(
<FluentProvider theme={webLightTheme}>
@@ -38,7 +37,6 @@ describe("AuthProvider", () => {
await waitFor(() => {
expect(screen.getByTestId("location")).toHaveTextContent("/login");
});
expect(sessionStorage.getItem("bangui_token")).toBeNull();
expect(sessionStorage.getItem("bangui_expires_at")).toBeNull();
expect(sessionStorage.getItem("bangui_authenticated")).toBeNull();
});
});