diff --git a/backend/src/index.ts b/backend/src/index.ts index 5dcd91a..156c153 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,4 +1,6 @@ +import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; +import type { IncomingHttpHeaders } from "node:http"; import { resolve } from "node:path"; import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; @@ -45,6 +47,16 @@ import { parseCorsOrigins, } from "./utils/server-config.js"; +function sanitizeCorrelationId(headers: IncomingHttpHeaders): string | null { + const rawHeader = headers["x-correlation-id"]; + if (typeof rawHeader !== "string") return null; + const trimmed = rawHeader.trim(); + if (!trimmed) return null; + if (trimmed.length > 128) return null; + if (!/^[A-Za-z0-9._:-]+$/.test(trimmed)) return null; + return trimmed; +} + /** Create and configure Fastify app (without starting) */ export async function createApp(options?: { logLevel?: string; @@ -73,6 +85,13 @@ export async function createApp(options?: { const app = Fastify({ logger: { level: opts.logLevel }, + genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(), + }); + + app.addHook("onRequest", (request, reply, done) => { + request.correlationId = request.id; + reply.header("x-correlation-id", request.id); + done(); }); // Build config @@ -141,6 +160,13 @@ const app = Fastify({ logger: { level: env.LOG_LEVEL, }, + genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(), +}); + +app.addHook("onRequest", (request, reply, done) => { + request.correlationId = request.id; + reply.header("x-correlation-id", request.id); + done(); }); const origins = parseCorsOrigins(env.CORS_ORIGINS); diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts index e53529e..d7a9ab1 100644 --- a/backend/src/routes/oidc.ts +++ b/backend/src/routes/oidc.ts @@ -63,7 +63,7 @@ export async function oidcRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // GET /auth/oidc/login - Initiates OIDC flow // --------------------------------------------------------------------------- - app.get("/auth/oidc/login", async (_request, reply) => { + app.get("/auth/oidc/login", async (request, reply) => { try { const config = await getOIDCConfig(); @@ -105,7 +105,7 @@ export async function oidcRoutes(app: FastifyInstance) { return reply.redirect(authUrl.href); } catch (err: unknown) { - console.error("[OIDC] Login error:", err); + request.log.error({ err }, "[OIDC] Login initialization failed"); return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`); } }); @@ -120,7 +120,7 @@ export async function oidcRoutes(app: FastifyInstance) { // Handle OIDC provider errors if (error) { - console.error(`[OIDC] Provider error: ${error} - ${error_description}`); + app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error"); return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`); } @@ -131,14 +131,14 @@ export async function oidcRoutes(app: FastifyInstance) { // Verify state const storedState = request.unsignCookie(request.cookies.oidc_state || ""); if (!storedState.valid || storedState.value !== state) { - console.error("[OIDC] State mismatch"); + request.log.warn("[OIDC] State mismatch during callback validation"); return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`); } // Get code verifier const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || ""); if (!storedVerifier.valid || !storedVerifier.value) { - console.error("[OIDC] Missing code verifier"); + request.log.warn("[OIDC] Missing/invalid code verifier cookie"); return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`); } @@ -159,7 +159,7 @@ export async function oidcRoutes(app: FastifyInstance) { // Get user info const sub = tokens.claims()?.sub; if (!sub) { - console.error("[OIDC] Missing sub claim in token"); + request.log.error("[OIDC] Missing sub claim in token response"); return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`); } const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub); @@ -174,7 +174,10 @@ export async function oidcRoutes(app: FastifyInstance) { const oidcSubject = userInfo.sub; if (!username || !oidcSubject) { - console.error("[OIDC] Missing required user info:", { username, oidcSubject }); + request.log.error( + { hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) }, + "[OIDC] Missing required user info" + ); return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`); } @@ -214,7 +217,7 @@ export async function oidcRoutes(app: FastifyInstance) { const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173"; return reply.redirect(`${frontendUrl}/dashboard`); } catch (err: unknown) { - console.error("[OIDC] Callback error:", err); + request.log.error({ err }, "[OIDC] Callback processing failed"); return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`); } } @@ -255,7 +258,7 @@ async function findOrCreateOIDCUser( // Check if auto-create is enabled if (!env.OIDC_AUTO_CREATE_USERS) { - console.error(`[OIDC] User creation disabled and user not found: ${username}`); + // No logger is available in this helper, route-level logs already capture callback failures. return null; } diff --git a/backend/src/types/fastify.d.ts b/backend/src/types/fastify.d.ts index 9f4f905..463c93f 100644 --- a/backend/src/types/fastify.d.ts +++ b/backend/src/types/fastify.d.ts @@ -22,6 +22,7 @@ declare module "fastify" { interface FastifyRequest { user?: AuthUser | null; + correlationId?: string; } } diff --git a/docs/PROJECT_SETUP.md b/docs/PROJECT_SETUP.md deleted file mode 100644 index 1fe72ec..0000000 --- a/docs/PROJECT_SETUP.md +++ /dev/null @@ -1,80 +0,0 @@ -# GitHub Project Setup - -This repository includes a GitHub Actions workflow that automatically adds new issues to a GitHub Project for tracking feature requests and bugs. - -## Setup Steps - -### 1. Create a GitHub Project - -1. Go to your GitHub profile → **Projects** → **New project** -2. Choose the **Board** template (recommended for feature tracking) -3. Name it e.g. **MedAssist-ng Roadmap** -4. Configure the default columns: - - **Triage** – New issues land here - - **Backlog** – Accepted but not yet started - - **In Progress** – Currently being worked on - - **Done** – Completed - -### 2. Create a Personal Access Token (PAT) - -The workflow needs a token with project permissions. The built-in `GITHUB_TOKEN` does not support GitHub Projects. - -1. Go to **Settings** → **Developer settings** → **Personal access tokens** → **Fine-grained tokens** -2. Click **Generate new token** -3. Set: - - **Token name**: `add-to-project` - - **Expiration**: Choose an appropriate duration - - **Repository access**: Select **Only select repositories** → `DanielVolz/medassist-ng` - - **Permissions**: - - Repository permissions: **Issues** → Read - - Organization permissions (if applicable): **Projects** → Read and write - - For **user-owned projects**, you need a **classic** token with the `project` scope instead -4. Copy the generated token - -### 3. Add Repository Secrets and Variables - -1. Go to the repository → **Settings** → **Secrets and variables** → **Actions** -2. Add a **secret**: - - Name: `ADD_TO_PROJECT_PAT` - - Value: The PAT from step 2 -3. Add a **variable** (under the **Variables** tab): - - Name: `PROJECT_URL` - - Value: The full URL of your GitHub Project (e.g. `https://github.com/users/DanielVolz/projects/1`) - -### 4. Verify - -1. Create a test issue using the **✨ Feature Request** template -2. Check the **Actions** tab to see the workflow run -3. Verify the issue appears in your GitHub Project under **Triage** - -## How It Works - -The workflow (`.github/workflows/add-to-project.yml`) triggers when: -- A new issue is **opened** -- A label is **added** to an existing issue - -Issues with any of these labels are automatically added to the project: -- `enhancement` – Feature requests -- `bug` – Bug reports -- `triage` – New issues needing review - -Both the feature request and bug report issue templates automatically apply the `triage` label, so all new issues from templates are captured. - -## Customization - -### Adding more labels - -Edit `.github/workflows/add-to-project.yml` and add labels to the `labeled` field: - -```yaml -labeled: enhancement, bug, triage, documentation -``` - -### Restricting to feature requests only - -Change the `labeled` field to only include `enhancement`: - -```yaml -labeled: enhancement -label-operator: OR -``` diff --git a/frontend/nginx.conf b/frontend/nginx.conf index a51c3cb..eb274e7 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/src/components/Auth.tsx b/frontend/src/components/Auth.tsx index f6d55aa..21fabd6 100644 --- a/frontend/src/components/Auth.tsx +++ b/frontend/src/components/Auth.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [authError, setAuthError] = useState(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 { 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 }) { diff --git a/frontend/src/test/components/AppHeader.test.tsx b/frontend/src/test/components/AppHeader.test.tsx index 52be934..7bc4b19 100644 --- a/frontend/src/test/components/AppHeader.test.tsx +++ b/frontend/src/test/components/AppHeader.test.tsx @@ -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", + }) + ); }); }); }); diff --git a/frontend/src/test/components/Auth.test.tsx b/frontend/src/test/components/Auth.test.tsx index d18bd8a..8debaef 100644 --- a/frontend/src/test/components/Auth.test.tsx +++ b/frontend/src/test/components/Auth.test.tsx @@ -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 () => { diff --git a/frontend/src/utils/correlation.ts b/frontend/src/utils/correlation.ts index 6e387da..351f96e 100644 --- a/frontend/src/utils/correlation.ts +++ b/frontend/src/utils/correlation.ts @@ -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: {