feat: add correlation ids and tighten frontend security headers (#299)

* feat: add correlation ids and tighten frontend security headers

* docs: remove obsolete project setup guide

* fix: restore health config flags for compatibility

* test(frontend): align auth fetch assertions with correlation headers
This commit is contained in:
Daniel Volz
2026-02-24 21:21:30 +01:00
committed by GitHub
parent 63cd9ef19b
commit 26475fd3d0
9 changed files with 130 additions and 133 deletions
+2
View File
@@ -14,6 +14,8 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' https://api.github.com; frame-src 'self'; form-action 'self'; upgrade-insecure-requests" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always;
# Allow larger file uploads (for medication images and data import/export)
client_max_body_size 50M;
+58 -21
View File
@@ -2,6 +2,7 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { withCorrelation } from "../utils/correlation";
import { log } from "../utils/logger";
import { ConfirmModal } from "./ConfirmModal";
import { PasswordInput } from "./PasswordInput";
@@ -62,7 +63,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState | null>(null);
const [loading, setLoading] = useState(true);
const [authError, setAuthError] = useState<string | null>(null);
// Track if initial fetch has been done to prevent duplicate calls
const initialFetchDone = useRef(false);
@@ -96,10 +96,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
async function fetchAuthState(retryCount = 0) {
const maxRetries = 3;
const retryDelay = 1000; // 1 second
let correlationId: string | null = null;
try {
setAuthError(null);
const res = await fetch("/api/auth/state");
const correlated = withCorrelation(undefined, "fe-auth-state");
correlationId = correlated.correlationId;
const res = await fetch("/api/auth/state", correlated.init);
if (!res.ok) {
throw new Error(`Server error: ${res.status}`);
}
@@ -112,7 +115,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
setLoading(false);
} catch (err) {
log.error(`Failed to fetch auth state (attempt ${retryCount + 1}/${maxRetries + 1}):`, err);
log.error(`Failed to fetch auth state (attempt ${retryCount + 1}/${maxRetries + 1}):`, err, {
correlationId,
});
// Retry on connection errors or 5xx errors (server might be restarting)
if (retryCount < maxRetries) {
@@ -127,27 +132,38 @@ export function AuthProvider({ children }: { children: ReactNode }) {
async function refreshUser() {
try {
const res = await fetch("/api/auth/me", { credentials: "include" });
const { correlationId, init } = withCorrelation({ credentials: "include" }, "fe-auth-me");
const res = await fetch("/api/auth/me", init);
if (res.ok) {
const userData = await res.json();
setUser(userData);
log.debug("[Auth] Session user loaded", { userId: userData.id, correlationId });
} else if (res.status === 401) {
// Access token expired - try to refresh it
log.info("[Auth] Access token invalid, attempting refresh", { correlationId });
const refreshed = await tryRefreshToken();
if (refreshed) {
// Retry /auth/me with new token
const retryRes = await fetch("/api/auth/me", { credentials: "include" });
const retry = withCorrelation({ credentials: "include" }, "fe-auth-me-retry");
const retryRes = await fetch("/api/auth/me", retry.init);
if (retryRes.ok) {
const userData = await retryRes.json();
setUser(userData);
log.info("[Auth] Session restored after token refresh", {
userId: userData.id,
correlationId: retry.correlationId,
});
return;
}
}
log.warn("[Auth] Session refresh failed, clearing local user state", { correlationId });
setUser(null);
} else {
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
setUser(null);
}
} catch {
} catch (error) {
log.error("[Auth] Failed to refresh user", { error });
setUser(null);
}
}
@@ -155,31 +171,46 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Try to refresh the access token using the refresh token
async function tryRefreshToken(): Promise<boolean> {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
const { correlationId, init } = withCorrelation(
{
method: "POST",
credentials: "include",
},
"fe-auth-refresh"
);
const res = await fetch("/api/auth/refresh", init);
if (!res.ok) {
log.warn("[Auth] Token refresh rejected", { status: res.status, correlationId });
}
return res.ok;
} catch {
} catch (error) {
log.error("[Auth] Token refresh request failed", { error });
return false;
}
}
async function login(username: string, password: string, rememberMe: boolean = false) {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password, rememberMe }),
});
const { correlationId, init } = withCorrelation(
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password, rememberMe }),
},
"fe-auth-login"
);
log.info("[Auth] Login requested", { username, rememberMe, correlationId });
const res = await fetch("/api/auth/login", init);
if (!res.ok) {
const data = await res.json();
log.warn("[Auth] Login failed", { username, status: res.status, code: data.code, correlationId });
throw new Error(data.error || "Login failed");
}
const data = await res.json();
setUser(data.user);
log.info("[Auth] Login successful", { userId: data.user?.id, username: data.user?.username, correlationId });
}
async function register(username: string, password: string) {
@@ -203,11 +234,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
async function logout() {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "include",
});
const { correlationId, init } = withCorrelation(
{
method: "POST",
credentials: "include",
},
"fe-auth-logout"
);
log.info("[Auth] Logout requested", { userId: user?.id ?? null, correlationId });
await fetch("/api/auth/logout", init);
setUser(null);
log.info("[Auth] Logout completed", { correlationId });
}
async function updateProfile(data: { currentPassword?: string; newPassword?: string }) {
@@ -370,10 +370,13 @@ describe("AppHeader", () => {
fireEvent.click(userMenuBtn);
fireEvent.click(screen.getByText(/auth\.signOut/i));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith("/api/auth/logout", {
method: "POST",
credentials: "include",
});
expect(fetch).toHaveBeenCalledWith(
"/api/auth/logout",
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
});
});
});
+17 -14
View File
@@ -39,7 +39,7 @@ describe("AuthProvider", () => {
renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith("/api/auth/state");
expect(fetch).toHaveBeenCalledWith("/api/auth/state", expect.anything());
});
});
@@ -55,7 +55,7 @@ describe("AuthProvider", () => {
// Wait for the initial fetch to complete
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith("/api/auth/state");
expect(fetch).toHaveBeenCalledWith("/api/auth/state", expect.anything());
});
// Wait a bit more to ensure no additional calls happen
@@ -94,18 +94,21 @@ describe("AuthProvider", () => {
const response = await result.current.authFetch("/api/medications", { method: "GET" });
expect(response.ok).toBe(true);
expect(fetch).toHaveBeenNthCalledWith(2, "/api/medications", {
method: "GET",
credentials: "include",
});
expect(fetch).toHaveBeenNthCalledWith(3, "/api/auth/refresh", {
method: "POST",
credentials: "include",
});
expect(fetch).toHaveBeenNthCalledWith(4, "/api/medications", {
method: "GET",
credentials: "include",
});
expect(fetch).toHaveBeenNthCalledWith(
2,
"/api/medications",
expect.objectContaining({ method: "GET", credentials: "include" })
);
expect(fetch).toHaveBeenNthCalledWith(
3,
"/api/auth/refresh",
expect.objectContaining({ method: "POST", credentials: "include" })
);
expect(fetch).toHaveBeenNthCalledWith(
4,
"/api/medications",
expect.objectContaining({ method: "GET", credentials: "include" })
);
});
it("authFetch logs user out when refresh fails", async () => {
+7 -5
View File
@@ -1,13 +1,15 @@
function createCorrelationId(prefix = "fe"): string {
export function createCorrelationId(prefix: string = "fe"): string {
const randomPart = Math.random().toString(36).slice(2, 10);
return `${prefix}-${Date.now()}-${randomPart}`;
return `${prefix}-${Date.now().toString(36)}-${randomPart}`;
}
export function withCorrelation(init: RequestInit, prefix = "fe"): { correlationId: string; init: RequestInit } {
export function withCorrelation(
init?: RequestInit,
prefix: string = "fe"
): { correlationId: string; init: RequestInit } {
const correlationId = createCorrelationId(prefix);
const headers = new Headers(init.headers ?? undefined);
const headers = new Headers(init?.headers ?? {});
headers.set("x-correlation-id", correlationId);
return {
correlationId,
init: {