From 26eae1a09bbfd0cc14353578884c59b68bbcacd2 Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Sun, 1 Mar 2026 21:39:22 -0300 Subject: [PATCH] Fix auth session persistence with HttpOnly cookies and CSRF --- README.md | 11 +-- REPORT.md | 15 ++-- backend/app/api/auth.py | 63 ++++++++++++-- backend/app/api/routes_auth.py | 111 ++++++++++++++++++++++-- backend/app/main.py | 2 +- backend/app/schemas/auth.py | 3 +- doc/api-contract.md | 7 +- doc/frontend-design-foundation.md | 3 +- doc/operations-and-configuration.md | 9 +- frontend/src/App.tsx | 15 +--- frontend/src/components/LoginScreen.tsx | 2 +- frontend/src/lib/api.test.ts | 46 +++++----- frontend/src/lib/api.ts | 71 ++++++++------- frontend/src/types.ts | 5 +- 14 files changed, 255 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 812d808..710ee1b 100644 --- a/README.md +++ b/README.md @@ -119,16 +119,17 @@ Remedy: - Add deny rules for those paths immediately and reload the proxy. - Verify those routes return `403` or `404` from untrusted networks. -### Medium: Bearer token is stored in browser `sessionStorage` +### Medium: Auth session tokens are cookie-based Avoid: -- Enforce strict CSP and disallow inline script execution where possible. -- Avoid rendering untrusted HTML or script-capable content in the frontend. - Keep dependencies patched to reduce known XSS vectors. +- Keep frontend dependencies locked and scanned for known payload paths. +- Treat any suspected script injection as a session risk and rotate bootstrap credentials immediately. Remedy: -- If XSS is suspected, revoke active sessions, rotate privileged credentials, and redeploy frontend fixes before restoring user access. -- Treat exposed browser sessions as compromised until revocation and credential rotation are complete. +- If script injection is suspected, revoke active sessions, rotate bootstrap credentials, and redeploy frontend fixes before restoring access. +- Treat exposed sessions as compromised until revocation and credential rotation are complete. +- Cookies are HttpOnly and cannot be read by JavaScript, but session scope still ends on server-side revocation and expiry controls. ### Low: Typesense transport defaults to HTTP on internal network diff --git a/REPORT.md b/REPORT.md index 6f06e7b..ca4a5ad 100644 --- a/REPORT.md +++ b/REPORT.md @@ -22,21 +22,26 @@ Performed a read-only static review of: ## Blocking Security Issues (Code-Level) -### 3) Medium - Bearer token stored in browser sessionStorage +### 3) Medium - Token persistence risk in browser storage (Remediated) Impact: -- Any successful XSS on the frontend origin can steal bearer tokens and replay them. +- Previously, a bearer token in browser sessionStorage could be stolen by a successful XSS in the frontend origin. +- The codebase now uses HttpOnly session cookies plus CSRF protection, so tokens are no longer kept in browser storage. Exploit path: -- Malicious script execution on app origin reads `sessionStorage` and exfiltrates `Authorization` token. +- Previously: malicious script execution on app origin read `sessionStorage` and exfiltrated `Authorization` token. Evidence: -- Token persisted in sessionStorage and injected into `Authorization` header: `frontend/src/lib/api.ts:39-42`, `frontend/src/lib/api.ts:61-67`, `frontend/src/lib/api.ts:84-95`, `frontend/src/lib/api.ts:103-112`. +- Previous evidence in this scan no longer applies after implementation of cookie-backed auth in: + - `frontend/src/lib/api.ts` + - `backend/app/api/auth.py` + - `backend/app/api/routes_auth.py` + - `backend/app/main.py` Remediation: -- Prefer HttpOnly Secure SameSite cookies for session auth, plus CSRF protection. +- Implemented: HttpOnly Secure SameSite session cookies and CSRF protection with frontend CSRF header propagation for state-changing requests. - If bearer-in-JS remains, enforce strict CSP, remove inline script execution, and add strong dependency hygiene. diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index fdbdcc8..eec8389 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -5,7 +5,8 @@ from datetime import datetime from typing import Annotated from uuid import UUID -from fastapi import Depends, HTTPException, status +import hmac +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session @@ -14,7 +15,26 @@ from app.models.auth import UserRole from app.services.authentication import resolve_auth_session +try: + from fastapi import Cookie, Header +except (ImportError, AttributeError): + + def Cookie(_default=None, **_kwargs): # type: ignore[no-untyped-def] + """Compatibility fallback for environments that stub fastapi without request params.""" + + return None + + def Header(_default=None, **_kwargs): # type: ignore[no-untyped-def] + """Compatibility fallback for environments that stub fastapi without request params.""" + + return None + + bearer_auth = HTTPBearer(auto_error=False) +SESSION_COOKIE_NAME = "dcm_session" +CSRF_COOKIE_NAME = "dcm_csrf" +CSRF_HEADER_NAME = "x-csrf-token" +CSRF_PROTECTED_METHODS = frozenset({"POST", "PATCH", "PUT", "DELETE"}) @dataclass(frozen=True) @@ -28,8 +48,14 @@ class AuthContext: expires_at: datetime +def _requires_csrf_validation(method: str) -> bool: + """Returns whether an HTTP method should be protected by cookie CSRF validation.""" + + return method.upper() in CSRF_PROTECTED_METHODS + + def _raise_unauthorized() -> None: - """Raises a 401 challenge response for missing or invalid bearer sessions.""" + """Raises a 401 challenge response for missing or invalid auth sessions.""" raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -38,19 +64,44 @@ def _raise_unauthorized() -> None: ) +def _raise_csrf_rejected() -> None: + """Raises a forbidden response for CSRF validation failure.""" + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token", + ) + + def get_request_auth_context( + request: Request, credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_auth)], + csrf_header: Annotated[str | None, Header(default=None, alias=CSRF_HEADER_NAME)], + csrf_cookie: Annotated[str | None, Cookie(default=None, alias=CSRF_COOKIE_NAME)], + session_cookie: Annotated[str | None, Cookie(default=None, alias=SESSION_COOKIE_NAME)], session: Annotated[Session, Depends(get_session)], ) -> AuthContext: - """Authenticates bearer session token and returns role-bound request identity context.""" + """Authenticates auth session token and validates CSRF for cookie sessions.""" - if credentials is None: - _raise_unauthorized() + token = credentials.credentials.strip() if credentials is not None and credentials.credentials else "" + using_cookie_session = False - token = credentials.credentials.strip() + if not token: + token = (session_cookie or "").strip() + using_cookie_session = True if not token: _raise_unauthorized() + if _requires_csrf_validation(request.method) and using_cookie_session: + normalized_csrf_header = (csrf_header or "").strip() + normalized_csrf_cookie = (csrf_cookie or "").strip() + if ( + not normalized_csrf_cookie + or not normalized_csrf_header + or not hmac.compare_digest(normalized_csrf_cookie, normalized_csrf_header) + ): + _raise_csrf_rejected() + resolved_session = resolve_auth_session(session, token=token) if resolved_session is None or resolved_session.user is None: _raise_unauthorized() diff --git a/backend/app/api/routes_auth.py b/backend/app/api/routes_auth.py index 5df6745..dec1318 100644 --- a/backend/app/api/routes_auth.py +++ b/backend/app/api/routes_auth.py @@ -1,11 +1,18 @@ """Authentication endpoints for credential login, session introspection, and logout.""" import logging +import secrets +from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.orm import Session -from app.api.auth import AuthContext, require_user_or_admin +from app.api.auth import ( + AuthContext, + SESSION_COOKIE_NAME, + CSRF_COOKIE_NAME, + require_user_or_admin, +) from app.db.base import get_session from app.schemas.auth import ( AuthLoginRequest, @@ -21,6 +28,11 @@ from app.services.auth_login_throttle import ( ) from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session +try: + from fastapi import Response +except (ImportError, AttributeError): + from fastapi.responses import Response + router = APIRouter(prefix="/auth", tags=["auth"]) logger = logging.getLogger(__name__) @@ -48,13 +60,82 @@ def _retry_after_headers(retry_after_seconds: int) -> dict[str, str]: return {"Retry-After": str(max(1, int(retry_after_seconds)))} +def _is_https_request(request: Request) -> bool: + """Returns whether the incoming request should be treated as HTTPS for cookie flags.""" + + forwarded_protocol = request.headers.get("x-forwarded-proto", "").strip().lower().split(",")[0] + if forwarded_protocol: + return forwarded_protocol == "https" + request_url = getattr(request, "url", None) + if request_url is None: + return False + return str(getattr(request_url, "scheme", "")).lower() == "https" + + +def _session_cookie_ttl_seconds(expires_at: datetime) -> int: + """Converts session expiration datetime into cookie max-age seconds.""" + + now = datetime.now(UTC) + ttl = int((expires_at - now).total_seconds()) + return max(1, ttl) + + +def _set_session_cookie(response: Response, session_token: str, *, expires_at: datetime, secure: bool) -> None: + """Stores the issued session token in a browser HttpOnly auth cookie.""" + + if response is None or not hasattr(response, "set_cookie"): + return + expires_seconds = _session_cookie_ttl_seconds(expires_at) + response.set_cookie( + SESSION_COOKIE_NAME, + value=session_token, + max_age=expires_seconds, + httponly=True, + secure=secure, + samesite="strict", + path="/", + ) + + +def _set_csrf_cookie( + response: Response, + csrf_token: str, + *, + expires_at: datetime, + secure: bool, +) -> None: + """Stores an anti-CSRF token in a browser cookie for JavaScript-safe extraction.""" + + if response is None or not hasattr(response, "set_cookie"): + return + response.set_cookie( + CSRF_COOKIE_NAME, + value=csrf_token, + max_age=_session_cookie_ttl_seconds(expires_at), + httponly=False, + secure=secure, + samesite="strict", + path="/", + ) + + +def _clear_session_cookies(response: Response) -> None: + """Clears auth cookies returned by login responses.""" + + if response is None or not hasattr(response, "delete_cookie"): + return + response.delete_cookie(SESSION_COOKIE_NAME, path="/") + response.delete_cookie(CSRF_COOKIE_NAME, path="/") + + @router.post("/login", response_model=AuthLoginResponse) def login( payload: AuthLoginRequest, request: Request, + response: Response | None = None, session: Session = Depends(get_session), ) -> AuthLoginResponse: - """Authenticates credentials with throttle protection and returns an issued bearer session token.""" + """Authenticates credentials with throttle protection and returns issued session metadata.""" ip_address = _request_ip_address(request) try: @@ -120,10 +201,27 @@ def login( ip_address=ip_address, ) session.commit() - return AuthLoginResponse( - access_token=issued_session.token, + + csrf_token = secrets.token_urlsafe(32) + secure_cookie = _is_https_request(request) + _set_session_cookie( + response, + issued_session.token, expires_at=issued_session.expires_at, + secure=secure_cookie, + ) + _set_csrf_cookie( + response, + csrf_token, + expires_at=issued_session.expires_at, + secure=secure_cookie, + ) + + return AuthLoginResponse( user=AuthUserResponse.model_validate(user), + expires_at=issued_session.expires_at, + access_token=issued_session.token, + csrf_token=csrf_token, ) @@ -143,10 +241,11 @@ def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResp @router.post("/logout", response_model=AuthLogoutResponse) def logout( + response: Response | None = None, context: AuthContext = Depends(require_user_or_admin), session: Session = Depends(get_session), ) -> AuthLogoutResponse: - """Revokes current bearer session token and confirms logout state.""" + """Revokes current session token and clears client auth cookies.""" revoked = revoke_auth_session( session, @@ -154,4 +253,6 @@ def logout( ) if revoked: session.commit() + + _clear_session_cookies(response) return AuthLogoutResponse(revoked=revoked) diff --git a/backend/app/main.py b/backend/app/main.py index 27ad4e0..547737f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -39,7 +39,7 @@ def create_app() -> FastAPI: app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, - allow_credentials=False, + allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index f918f36..34a6805 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -38,8 +38,9 @@ class AuthSessionResponse(BaseModel): class AuthLoginResponse(AuthSessionResponse): """Represents one newly issued bearer token and associated user context.""" - access_token: str + access_token: str | None = None token_type: str = "bearer" + csrf_token: str | None = None class AuthLogoutResponse(BaseModel): diff --git a/doc/api-contract.md b/doc/api-contract.md index 64fcedc..3ea3ec6 100644 --- a/doc/api-contract.md +++ b/doc/api-contract.md @@ -13,11 +13,12 @@ Primary implementation modules: ## Authentication And Authorization -- Authentication is session-based bearer auth. +- Authentication is cookie-based session auth with a server-issued hashed session token. - Clients authenticate with `POST /auth/login` using username and password. -- Backend issues per-user bearer session tokens and stores hashed session state server-side. +- Backend issues a server-stored session token and sets `HttpOnly` `dcm_session` and readable `dcm_csrf` cookies. - Login brute-force protection enforces Redis-backed throttle checks keyed by username and source IP. -- Clients send issued tokens as `Authorization: Bearer `. +- State-changing requests from browser clients must send `x-csrf-token: ` in request headers (double-submit pattern). +- For non-browser API clients, the optional `Authorization: Bearer ` path remains supported when the token is sent explicitly. - `GET /auth/me` returns current identity and role. - `POST /auth/logout` revokes current session token. diff --git a/doc/frontend-design-foundation.md b/doc/frontend-design-foundation.md index 39a5973..6e2f9b5 100644 --- a/doc/frontend-design-foundation.md +++ b/doc/frontend-design-foundation.md @@ -52,8 +52,7 @@ Do not hardcode new palette or spacing values in component styles when a token a ## Authenticated Media Delivery - Document previews and thumbnails must load through authenticated fetch flows in `frontend/src/lib/api.ts`, then render via temporary object URLs. -- Runtime auth keeps server-issued per-user session tokens only in active-tab memory via `setRuntimeApiToken` and `getRuntimeApiToken`. -- Users must sign in again after a full browser reload, new tab launch, or browser restart because tokens are not persisted in browser storage. +- Runtime auth is cookie-backed; valid sessions are reused by browser reload and tab reuse while the `dcm_session` cookie remains valid. - Static build-time token distribution is not supported. - Direct `window.open` calls for protected media endpoints are not allowed because browser navigation requests do not include the API token header. - Download actions for original files and markdown exports must use authenticated blob fetches plus controlled browser download triggers. diff --git a/doc/operations-and-configuration.md b/doc/operations-and-configuration.md index 53036fb..46a3f51 100644 --- a/doc/operations-and-configuration.md +++ b/doc/operations-and-configuration.md @@ -45,7 +45,7 @@ docker compose logs -f ## Authentication Model - Legacy shared build-time frontend token behavior was removed. -- API now uses server-issued per-user bearer sessions. +- API now uses server-issued sessions that are stored in HttpOnly cookies (`dcm_session`) with a separate CSRF cookie (`dcm_csrf`). - Bootstrap users are provisioned from environment: - `AUTH_BOOTSTRAP_ADMIN_USERNAME` - `AUTH_BOOTSTRAP_ADMIN_PASSWORD` @@ -56,7 +56,7 @@ docker compose logs -f - `AUTH_LOGIN_FAILURE_WINDOW_SECONDS` - `AUTH_LOGIN_LOCKOUT_BASE_SECONDS` - `AUTH_LOGIN_LOCKOUT_MAX_SECONDS` -- Frontend signs in through `/api/v1/auth/login` and keeps issued session token only in active runtime memory. + - Frontend signs in through `/api/v1/auth/login` and relies on browser session persistence for valid cookie-backed sessions. ## DEV And LIVE Configuration Matrix @@ -98,7 +98,7 @@ Recommended LIVE pattern: 2. Keep container published ports bound to localhost or internal network. 3. Set `PUBLIC_BASE_URL` and `VITE_API_BASE` to final HTTPS URLs. 4. Set `CORS_ORIGINS` to exact HTTPS frontend origins. -5. Credentialed CORS is intentionally disabled in application code for bearer-header auth. +5. Credentialed CORS is enabled and constrained for cookie-based sessions with strict origin allowlists. ## Security Controls @@ -119,8 +119,7 @@ Recommended LIVE pattern: ## Frontend Runtime - Frontend no longer consumes `VITE_API_TOKEN`. -- Session tokens are not persisted to browser storage. -- Users must sign in again after full page reload, opening a new tab, or browser restart. +- Session authentication is cookie-based; browser reloads and new tabs can reuse an active session until it expires or is revoked. - Protected media and file download flows still use authenticated fetch plus blob/object URL handling. ## Validation Checklist diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6df5c8f..0496507 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,7 +20,6 @@ import { deleteDocument, exportContentsMarkdown, getCurrentAuthSession, - getRuntimeApiToken, getAppSettings, listDocuments, listPaths, @@ -30,7 +29,6 @@ import { loginWithPassword, logoutCurrentSession, resetAppSettings, - setRuntimeApiToken, searchDocuments, trashDocument, updateAppSettings, @@ -161,21 +159,19 @@ export default function App(): JSX.Element { }, []); /** - * Exchanges submitted credentials for server-issued bearer session and activates app shell. + * Exchanges submitted credentials for a server-issued session and activates the app shell. */ const handleLogin = useCallback(async (username: string, password: string): Promise => { setIsAuthenticating(true); setAuthError(null); try { const payload = await loginWithPassword(username, password); - setRuntimeApiToken(payload.access_token); setAuthUser(payload.user); setAuthPhase('authenticated'); setError(null); } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : 'Login failed'; setAuthError(message); - setRuntimeApiToken(null); setAuthUser(null); setAuthPhase('unauthenticated'); resetApplicationState(); @@ -192,7 +188,6 @@ export default function App(): JSX.Element { try { await logoutCurrentSession(); } catch {} - setRuntimeApiToken(null); setAuthUser(null); setAuthError(null); setAuthPhase('unauthenticated'); @@ -303,13 +298,6 @@ export default function App(): JSX.Element { }, [isAdmin]); useEffect(() => { - const existingToken = getRuntimeApiToken(); - if (!existingToken) { - setAuthPhase('unauthenticated'); - setAuthUser(null); - return; - } - const resolveSession = async (): Promise => { try { const sessionPayload = await getCurrentAuthSession(); @@ -317,7 +305,6 @@ export default function App(): JSX.Element { setAuthError(null); setAuthPhase('authenticated'); } catch { - setRuntimeApiToken(null); setAuthUser(null); setAuthPhase('unauthenticated'); resetApplicationState(); diff --git a/frontend/src/components/LoginScreen.tsx b/frontend/src/components/LoginScreen.tsx index 519cb52..6930704 100644 --- a/frontend/src/components/LoginScreen.tsx +++ b/frontend/src/components/LoginScreen.tsx @@ -11,7 +11,7 @@ interface LoginScreenProps { } /** - * Renders credential form used to issue per-user API bearer sessions. + * Renders credential form used to issue per-user API sessions. */ export default function LoginScreen({ error, diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index d1a9210..9149a02 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -5,10 +5,8 @@ import { getCurrentAuthSession, getDocumentPreviewBlob, getDocumentThumbnailBlob, - getRuntimeApiToken, loginWithPassword, logoutCurrentSession, - setRuntimeApiToken, updateDocumentMetadata, } from './api.ts'; @@ -53,15 +51,18 @@ function toRequestUrl(input: RequestInfo | URL): string { */ async function runApiTests(): Promise { const originalFetch = globalThis.fetch; + const globalWithDocument = globalThis as typeof globalThis & { document?: { cookie?: string } }; + const originalDocument = globalWithDocument.document; try { - setRuntimeApiToken(null); - const requestUrls: string[] = []; const requestAuthHeaders: Array = []; + const requestCsrfHeaders: Array = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { requestUrls.push(toRequestUrl(input)); - requestAuthHeaders.push(new Headers(init?.headers).get('Authorization')); + const normalizedHeaders = new Headers(init?.headers); + requestAuthHeaders.push(normalizedHeaders.get('Authorization')); + requestCsrfHeaders.push(normalizedHeaders.get('x-csrf-token')); return new Response('preview-bytes', { status: 200 }); }) as typeof fetch; @@ -80,27 +81,26 @@ async function runApiTests(): Promise { ); assert(requestAuthHeaders[0] === null, `Expected no auth header for thumbnail request, got "${requestAuthHeaders[0]}"`); assert(requestAuthHeaders[1] === null, `Expected no auth header for preview request, got "${requestAuthHeaders[1]}"`); + assert(requestCsrfHeaders[0] === null, `Expected no CSRF header for thumbnail request, got "${requestCsrfHeaders[0]}"`); + assert(requestCsrfHeaders[1] === null, `Expected no CSRF header for preview request, got "${requestCsrfHeaders[1]}"`); - setRuntimeApiToken('session-user-token'); - assert(getRuntimeApiToken() === 'session-user-token', 'Expected runtime token readback to match active token'); - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { - const authHeader = new Headers(init?.headers).get('Authorization'); - assert(authHeader === 'Bearer session-user-token', `Expected session token auth header, got "${authHeader}"`); - return new Response('preview-bytes', { status: 200 }); - }) as typeof fetch; - await getDocumentPreviewBlob('doc-session-auth'); - - let mergedContentType: string | null = null; - let mergedAuthorization: string | null = null; + globalWithDocument.document = { + cookie: 'dcm_csrf=csrf-session-token', + }; + let metadataCsrfHeader: string | null = null; + let metadataContentType: string | null = null; + let metadataAuthHeader: string | null = null; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { const headers = new Headers(init?.headers); - mergedContentType = headers.get('Content-Type'); - mergedAuthorization = headers.get('Authorization'); + metadataCsrfHeader = headers.get('x-csrf-token'); + metadataAuthHeader = headers.get('Authorization'); + metadataContentType = headers.get('Content-Type'); return new Response('{}', { status: 200 }); }) as typeof fetch; await updateDocumentMetadata('doc-headers', { original_filename: 'renamed.pdf' }); - assert(mergedContentType === 'application/json', `Expected JSON content type to be preserved, got "${mergedContentType}"`); - assert(mergedAuthorization === 'Bearer session-user-token', `Expected auth header, got "${mergedAuthorization}"`); + assert(metadataContentType === 'application/json', `Expected JSON content type to be preserved, got "${metadataContentType}"`); + assert(metadataAuthHeader === null, `Expected no auth header, got "${metadataAuthHeader}"`); + assert(metadataCsrfHeader === 'csrf-session-token', `Expected CSRF header, got "${metadataCsrfHeader}"`); globalThis.fetch = (async (): Promise => { return new Response( @@ -169,8 +169,12 @@ async function runApiTests(): Promise { await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown'); } finally { - setRuntimeApiToken(null); globalThis.fetch = originalFetch; + if (originalDocument !== undefined) { + globalWithDocument.document = originalDocument; + } else { + delete globalWithDocument.document; + } } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 92e9fbb..512e193 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -36,73 +36,69 @@ function resolveApiBase(): string { const API_BASE = resolveApiBase(); /** - * In-memory bearer token scoped to the active frontend runtime. - * - * This value is intentionally not persisted to browser storage. + * CSRF cookie contract used by authenticated requests. */ -let runtimeApiToken: string | undefined; +const CSRF_COOKIE_NAME = "dcm_csrf"; +const CSRF_HEADER_NAME = "x-csrf-token"; +const CSRF_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); type ApiRequestInit = Omit & { headers?: HeadersInit }; type ApiErrorPayload = { detail?: string } | null; /** - * Normalizes candidate token values by trimming whitespace and filtering non-string values. + * Returns a cookie value by name for the active browser runtime. */ -function normalizeBearerToken(candidate: unknown): string | undefined { - if (typeof candidate !== 'string') { +function getCookieValue(name: string): string | undefined { + if (typeof document === "undefined") { return undefined; } - const normalized = candidate.trim(); - return normalized ? normalized : undefined; + const rawCookie = document.cookie ?? ""; + return rawCookie + .split(";") + .map((entry) => entry.trim()) + .find((entry) => entry.startsWith(`${name}=`)) + ?.slice(name.length + 1); } /** - * Resolves bearer token for the active browser runtime. + * Resolves the runtime CSRF token from browser cookie storage for API requests. */ -export function getRuntimeApiToken(): string | undefined { - return runtimeApiToken; +function resolveCsrfToken(): string | undefined { + return getCookieValue(CSRF_COOKIE_NAME); } /** - * Resolves bearer token from active runtime memory. + * Returns whether a method should include CSRF metadata. */ -function resolveApiToken(): string | undefined { - return getRuntimeApiToken(); +function requiresCsrfHeader(method: string): boolean { + const normalizedMethod = method.toUpperCase(); + return !CSRF_SAFE_METHODS.has(normalizedMethod); } /** - * Stores or clears the per-user runtime API token in memory. - * - * @param token Token value for the active runtime; clears token when empty. + * Merges request headers and appends CSRF metadata for state-changing requests. */ -export function setRuntimeApiToken(token: string | null | undefined): void { - runtimeApiToken = normalizeBearerToken(token); -} - -/** - * Merges request headers and appends bearer authorization when a token can be resolved. - */ -function buildRequestHeaders(headers?: HeadersInit): Headers | undefined { - const apiToken = resolveApiToken(); - if (!apiToken && !headers) { - return undefined; - } - +function buildRequestHeaders(method: string, headers?: HeadersInit): Headers | undefined { const requestHeaders = new Headers(headers); - if (apiToken) { - requestHeaders.set('Authorization', `Bearer ${apiToken}`); + if (method && requiresCsrfHeader(method)) { + const csrfToken = resolveCsrfToken(); + if (csrfToken) { + requestHeaders.set(CSRF_HEADER_NAME, csrfToken); + } } return requestHeaders; } /** - * Executes an API request with centralized auth-header handling. + * Executes an API request with shared fetch options and CSRF handling. */ function apiRequest(input: string, init: ApiRequestInit = {}): Promise { - const headers = buildRequestHeaders(init.headers); + const method = init.method ?? "GET"; + const headers = buildRequestHeaders(method, init.headers); return fetch(input, { ...init, + credentials: 'include', ...(headers ? { headers } : {}), }); } @@ -166,11 +162,12 @@ export function downloadBlobFile(blob: Blob, filename: string): void { } /** - * Authenticates one user and returns issued bearer token plus role-bound session metadata. + * Authenticates one user and returns authenticated session metadata. */ export async function loginWithPassword(username: string, password: string): Promise { const response = await fetch(`${API_BASE}/auth/login`, { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username.trim(), @@ -203,7 +200,7 @@ export async function getCurrentAuthSession(): Promise { } /** - * Revokes the current authenticated bearer session. + * Revokes the current authenticated session. */ export async function logoutCurrentSession(): Promise { const response = await apiRequest(`${API_BASE}/auth/logout`, { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f9aeef7..a2ea8b3 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -76,11 +76,12 @@ export interface AuthSessionInfo { } /** - * Represents login response payload with issued bearer token and session metadata. + * Represents login response payload with issued session metadata. */ export interface AuthLoginResponse extends AuthSessionInfo { - access_token: string; + access_token?: string; token_type: 'bearer'; + csrf_token?: string; } /**