diff --git a/backend/app/api/routes_auth.py b/backend/app/api/routes_auth.py index dadb1fa..c7c9dbe 100644 --- a/backend/app/api/routes_auth.py +++ b/backend/app/api/routes_auth.py @@ -30,9 +30,14 @@ from app.services.auth_login_throttle import ( ) try: - from fastapi import Response + from fastapi import Cookie, Response except (ImportError, AttributeError): from fastapi.responses import Response + + def Cookie(_default=None, **_kwargs): # type: ignore[no-untyped-def] + """Compatibility fallback for environments that stub fastapi without request params.""" + + return None from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session router = APIRouter(prefix="/auth", tags=["auth"]) @@ -255,9 +260,13 @@ def login( @router.get("/me", response_model=AuthSessionResponse) -def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResponse: +def me( + context: AuthContext = Depends(require_user_or_admin), + csrf_cookie: str | None = Cookie(None, alias=CSRF_COOKIE_NAME), +) -> AuthSessionResponse: """Returns current authenticated session identity and expiration metadata.""" + normalized_csrf_cookie = (csrf_cookie or "").strip() or None return AuthSessionResponse( expires_at=context.expires_at, user=AuthUserResponse( @@ -265,6 +274,7 @@ def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResp username=context.username, role=context.role, ), + csrf_token=normalized_csrf_cookie, ) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 34a6805..faed5b3 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -33,6 +33,7 @@ class AuthSessionResponse(BaseModel): user: AuthUserResponse expires_at: datetime + csrf_token: str | None = None class AuthLoginResponse(AuthSessionResponse): diff --git a/doc/api-contract.md b/doc/api-contract.md index 3ea3ec6..3fd0314 100644 --- a/doc/api-contract.md +++ b/doc/api-contract.md @@ -19,7 +19,7 @@ Primary implementation modules: - Login brute-force protection enforces Redis-backed throttle checks keyed by username and source IP. - 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. +- `GET /auth/me` returns current identity, role, and current CSRF token. - `POST /auth/logout` revokes current session token. Role matrix: diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 512e193..082178b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -41,6 +41,7 @@ const API_BASE = resolveApiBase(); const CSRF_COOKIE_NAME = "dcm_csrf"; const CSRF_HEADER_NAME = "x-csrf-token"; const CSRF_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); +const CSRF_SESSION_STORAGE_KEY = "dcm_csrf_token"; type ApiRequestInit = Omit & { headers?: HeadersInit }; @@ -65,7 +66,38 @@ function getCookieValue(name: string): string | undefined { * Resolves the runtime CSRF token from browser cookie storage for API requests. */ function resolveCsrfToken(): string | undefined { - return getCookieValue(CSRF_COOKIE_NAME); + const cookieToken = getCookieValue(CSRF_COOKIE_NAME); + if (cookieToken) { + return cookieToken; + } + return loadStoredCsrfToken(); +} + +/** + * Loads the runtime CSRF token from browser session storage. + */ +function loadStoredCsrfToken(): string | undefined { + if (typeof window === "undefined") { + return undefined; + } + const rawValue = window.sessionStorage.getItem(CSRF_SESSION_STORAGE_KEY); + const normalizedValue = rawValue?.trim(); + return normalizedValue ? normalizedValue : undefined; +} + +/** + * Persists or clears a runtime CSRF token in browser session storage. + */ +function persistCsrfToken(token: string | undefined | null): void { + if (typeof window === "undefined") { + return; + } + const normalizedValue = typeof token === "string" ? token.trim() : ""; + if (!normalizedValue) { + window.sessionStorage.removeItem(CSRF_SESSION_STORAGE_KEY); + return; + } + window.sessionStorage.setItem(CSRF_SESSION_STORAGE_KEY, normalizedValue); } /** @@ -181,7 +213,9 @@ export async function loginWithPassword(username: string, password: string): Pro } throw new Error('Login failed'); } - return response.json() as Promise; + const payload = await (response.json() as Promise); + persistCsrfToken(payload.csrf_token); + return payload; } /** @@ -196,7 +230,9 @@ export async function getCurrentAuthSession(): Promise { } throw new Error('Failed to load authentication session'); } - return response.json() as Promise; + const payload = await (response.json() as Promise); + persistCsrfToken(payload.csrf_token); + return payload; } /** @@ -206,6 +242,7 @@ export async function logoutCurrentSession(): Promise { const response = await apiRequest(`${API_BASE}/auth/logout`, { method: 'POST', }); + persistCsrfToken(undefined); if (!response.ok && response.status !== 401) { const detail = await responseErrorDetail(response); if (detail) { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a2ea8b3..083e4e8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -73,6 +73,7 @@ export interface AuthUser { export interface AuthSessionInfo { user: AuthUser; expires_at: string; + csrf_token?: string; } /**