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

@@ -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();
});
});