Files
BanGUI/frontend/src/providers/AuthProvider.tsx
Lukas 2e5ac092bf fix(auth): suppress misleading 502 warning during session validation
A 502 Bad Gateway is a server/gateway error, not a network error.
Logging it as a 'Session validation network error' is noisy and
misleading during startup when nginx is temporarily unreachable.

Silently skip the console.warn for 5xx errors in handleValidationError
while keeping the warning for actual network errors.
2026-05-22 20:47:57 +02:00

248 lines
9.2 KiB
TypeScript

/**
* Authentication context and provider.
*
* Manages the user's authenticated state and exposes `login`, `logout`, and
* `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).
*
* 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
* refreshes within the browser tab but is cleared on tab close. The session
* cookie itself persists according to the backend's cookie settings.
*
* **Cross-Tab Synchronization:**
* The provider uses BroadcastChannel API to synchronize logout events across
* tabs in real-time. On logout, a message is broadcast to all other tabs so they
* immediately reflect the logout state without requiring a page refresh or API call.
* If the browser does not support BroadcastChannel, the storage event listener
* serves as a fallback (though it only fires when storage is changed in another tab).
*
* **Auth Error Handling & Deduplication:**
* AuthProvider registers two auth error handlers to ensure that 401/403 errors
* are never silently swallowed:
* - `setUnauthorizedHandler()` in `api/client.ts` — handles auth errors from the
* API client layer before they reach hooks
* - `setAuthErrorHandler()` in `utils/fetchError.ts` — handles auth errors that
* reach hooks and ensures deterministic error handling with fallback logging
*
* A module-level `isLoggingOut` flag in each file prevents double-firing: the
* first handler to fire sets its flag, and the second checks it before invoking
* its handler. Both flags are reset in `handleSessionExpired()` so future 401s
* can trigger the handlers again after the user reaches the login page.
*
* This dual-handler approach with deduplication ensures auth errors are caught
* at multiple layers, preventing silent error loss regardless of where the
* error occurs, while avoiding the React state mutation race caused by
* concurrent logout dispatches.
*/
import React, {
createContext,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useNavigate } from "react-router-dom";
import * as authApi from "../api/auth";
import { ApiError, setUnauthorizedHandler, resetLogoutState, clearSessionCorrelationId } from "../api/client";
import { setAuthErrorHandler, resetLogoutState as resetFetchErrorLogoutState } from "../utils/fetchError";
import { STORAGE_KEY_AUTHENTICATED } from "../utils/constants";
import { SessionValidationLoading } from "../components/SessionValidationLoading";
import { useSessionValidation } from "../hooks/useSessionValidation";
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
export interface AuthContextValue {
/** `true` when the backend considers the session valid. */
isAuthenticated: boolean;
/** `true` while the session is being validated on app mount. */
isValidating: boolean;
/**
* Authenticate with the master password.
* Throws an `ApiError` on failure.
*/
login: (password: string) => Promise<void>;
/** Revoke the current session and clear local state. */
logout: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const BROADCAST_CHANNEL_NAME = "bangui_auth" as const;
const BROADCAST_MESSAGE_TYPE_LOGOUT = "logout" as const;
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
/**
* Wraps the application and provides authentication state to all children.
*
* Place this inside `<FluentProvider>` and `<BrowserRouter>` so all
* 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.
*
* Listens for cross-tab logout events via BroadcastChannel (or storage events
* as fallback) to immediately reflect logout in other tabs.
*/
export function AuthProvider({
children,
}: {
children: React.ReactNode;
}): React.JSX.Element {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => {
const stored = sessionStorage.getItem(STORAGE_KEY_AUTHENTICATED);
return stored === "true";
});
const [isValidating, setIsValidating] = useState<boolean>(isAuthenticated);
const navigate = useNavigate();
const handleSessionExpired = useCallback((): void => {
sessionStorage.removeItem(STORAGE_KEY_AUTHENTICATED);
setIsAuthenticated(false);
// Reset deduplication flags so future 401s can trigger handlers again.
resetLogoutState();
resetFetchErrorLogoutState();
navigate("/login", { replace: true });
}, [navigate]);
const handleSessionValid = useCallback((): void => {
// Session is already set from sessionStorage; no action needed.
}, []);
const handleValidationError = useCallback(
(error: Error): void => {
// Suppress noisy warning for 5xx gateway errors (e.g. 502 Bad Gateway)
// during startup — these are server-side issues, not network issues.
if (error instanceof ApiError && error.status >= 500) {
return;
}
// Network error — log but don't logout.
console.warn("Session validation network error:", error);
},
[],
);
// Handle storage events from other tabs (fallback sync mechanism)
useEffect((): (() => void) => {
const handleStorageChange = (event: StorageEvent): void => {
// Only respond to changes in our authentication key
if (event.key === STORAGE_KEY_AUTHENTICATED) {
// If the storage was cleared in another tab, logout this tab
if (event.newValue === null) {
handleSessionExpired();
}
}
};
window.addEventListener("storage", handleStorageChange);
return (): void => {
window.removeEventListener("storage", handleStorageChange);
};
}, [handleSessionExpired]);
// Handle BroadcastChannel messages for real-time cross-tab sync
useEffect((): (() => void) => {
let channel: BroadcastChannel | null = null;
try {
channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
channel.onmessage = (event: MessageEvent): void => {
if (event.data?.type === BROADCAST_MESSAGE_TYPE_LOGOUT) {
handleSessionExpired();
}
};
} catch {
// BroadcastChannel not supported; storage event listener serves as fallback
}
return (): void => {
if (channel) {
channel.close();
}
};
}, [handleSessionExpired]);
useEffect((): (() => void) => {
setUnauthorizedHandler((): void => {
handleSessionExpired();
});
setAuthErrorHandler((): void => {
handleSessionExpired();
});
return (): void => {
setUnauthorizedHandler(null);
setAuthErrorHandler(null);
};
}, [handleSessionExpired]);
const validationResult = useSessionValidation(
handleSessionValid,
handleSessionExpired,
handleValidationError,
);
useEffect(() => {
setIsValidating(validationResult.isLoading);
}, [validationResult.isLoading]);
const login = useCallback(async (password: string): Promise<void> => {
await authApi.login(password);
sessionStorage.setItem(STORAGE_KEY_AUTHENTICATED, "true");
setIsAuthenticated(true);
}, []);
const logout = useCallback(async (): Promise<void> => {
try {
await authApi.logout();
} finally {
// Always clear local state even if the API call fails (e.g. expired session).
sessionStorage.removeItem(STORAGE_KEY_AUTHENTICATED);
setIsAuthenticated(false);
clearSessionCorrelationId();
// Broadcast logout event to other tabs for immediate sync
try {
const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
channel.postMessage({ type: BROADCAST_MESSAGE_TYPE_LOGOUT });
channel.close();
} catch {
// BroadcastChannel not supported; storage event will fire on other tabs automatically
}
}
}, []);
const value = useMemo<AuthContextValue>(
() => ({ isAuthenticated, isValidating, login, logout }),
[isAuthenticated, isValidating, login, logout],
);
// Show loading spinner while validating session on mount.
if (isValidating && isAuthenticated) {
return <SessionValidationLoading />;
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}