Fix auth session persistence with HttpOnly cookies and CSRF

This commit is contained in:
2026-03-01 21:39:22 -03:00
parent a9333ec973
commit 26eae1a09b
14 changed files with 255 additions and 108 deletions

View File

@@ -119,16 +119,17 @@ Remedy:
- Add deny rules for those paths immediately and reload the proxy. - Add deny rules for those paths immediately and reload the proxy.
- Verify those routes return `403` or `404` from untrusted networks. - 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: 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 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: Remedy:
- If XSS is suspected, revoke active sessions, rotate privileged credentials, and redeploy frontend fixes before restoring user access. - If script injection is suspected, revoke active sessions, rotate bootstrap credentials, and redeploy frontend fixes before restoring access.
- Treat exposed browser sessions as compromised until revocation and credential rotation are complete. - 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 ### Low: Typesense transport defaults to HTTP on internal network

View File

@@ -22,21 +22,26 @@ Performed a read-only static review of:
## Blocking Security Issues (Code-Level) ## Blocking Security Issues (Code-Level)
### 3) Medium - Bearer token stored in browser sessionStorage ### 3) Medium - Token persistence risk in browser storage (Remediated)
Impact: 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: 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: 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: 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. - If bearer-in-JS remains, enforce strict CSP, remove inline script execution, and add strong dependency hygiene.

View File

@@ -5,7 +5,8 @@ from datetime import datetime
from typing import Annotated from typing import Annotated
from uuid import UUID 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 fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -14,7 +15,26 @@ from app.models.auth import UserRole
from app.services.authentication import resolve_auth_session 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) 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) @dataclass(frozen=True)
@@ -28,8 +48,14 @@ class AuthContext:
expires_at: datetime 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: 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, 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( def get_request_auth_context(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_auth)], 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)], session: Annotated[Session, Depends(get_session)],
) -> AuthContext: ) -> 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: token = credentials.credentials.strip() if credentials is not None and credentials.credentials else ""
_raise_unauthorized() using_cookie_session = False
token = credentials.credentials.strip() if not token:
token = (session_cookie or "").strip()
using_cookie_session = True
if not token: if not token:
_raise_unauthorized() _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) resolved_session = resolve_auth_session(session, token=token)
if resolved_session is None or resolved_session.user is None: if resolved_session is None or resolved_session.user is None:
_raise_unauthorized() _raise_unauthorized()

View File

@@ -1,11 +1,18 @@
"""Authentication endpoints for credential login, session introspection, and logout.""" """Authentication endpoints for credential login, session introspection, and logout."""
import logging import logging
import secrets
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session 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.db.base import get_session
from app.schemas.auth import ( from app.schemas.auth import (
AuthLoginRequest, 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 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"]) router = APIRouter(prefix="/auth", tags=["auth"])
logger = logging.getLogger(__name__) 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)))} 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) @router.post("/login", response_model=AuthLoginResponse)
def login( def login(
payload: AuthLoginRequest, payload: AuthLoginRequest,
request: Request, request: Request,
response: Response | None = None,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> AuthLoginResponse: ) -> 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) ip_address = _request_ip_address(request)
try: try:
@@ -120,10 +201,27 @@ def login(
ip_address=ip_address, ip_address=ip_address,
) )
session.commit() 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, 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), 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) @router.post("/logout", response_model=AuthLogoutResponse)
def logout( def logout(
response: Response | None = None,
context: AuthContext = Depends(require_user_or_admin), context: AuthContext = Depends(require_user_or_admin),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> AuthLogoutResponse: ) -> AuthLogoutResponse:
"""Revokes current bearer session token and confirms logout state.""" """Revokes current session token and clears client auth cookies."""
revoked = revoke_auth_session( revoked = revoke_auth_session(
session, session,
@@ -154,4 +253,6 @@ def logout(
) )
if revoked: if revoked:
session.commit() session.commit()
_clear_session_cookies(response)
return AuthLogoutResponse(revoked=revoked) return AuthLogoutResponse(revoked=revoked)

View File

@@ -39,7 +39,7 @@ def create_app() -> FastAPI:
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=allowed_origins, allow_origins=allowed_origins,
allow_credentials=False, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )

View File

@@ -38,8 +38,9 @@ class AuthSessionResponse(BaseModel):
class AuthLoginResponse(AuthSessionResponse): class AuthLoginResponse(AuthSessionResponse):
"""Represents one newly issued bearer token and associated user context.""" """Represents one newly issued bearer token and associated user context."""
access_token: str access_token: str | None = None
token_type: str = "bearer" token_type: str = "bearer"
csrf_token: str | None = None
class AuthLogoutResponse(BaseModel): class AuthLogoutResponse(BaseModel):

View File

@@ -13,11 +13,12 @@ Primary implementation modules:
## Authentication And Authorization ## 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. - 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. - Login brute-force protection enforces Redis-backed throttle checks keyed by username and source IP.
- Clients send issued tokens as `Authorization: Bearer <token>`. - State-changing requests from browser clients must send `x-csrf-token: <dcm_csrf>` in request headers (double-submit pattern).
- For non-browser API clients, the optional `Authorization: Bearer <token>` path remains supported when the token is sent explicitly.
- `GET /auth/me` returns current identity and role. - `GET /auth/me` returns current identity and role.
- `POST /auth/logout` revokes current session token. - `POST /auth/logout` revokes current session token.

View File

@@ -52,8 +52,7 @@ Do not hardcode new palette or spacing values in component styles when a token a
## Authenticated Media Delivery ## 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. - 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`. - Runtime auth is cookie-backed; valid sessions are reused by browser reload and tab reuse while the `dcm_session` cookie remains valid.
- Users must sign in again after a full browser reload, new tab launch, or browser restart because tokens are not persisted in browser storage.
- Static build-time token distribution is not supported. - 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. - 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. - Download actions for original files and markdown exports must use authenticated blob fetches plus controlled browser download triggers.

View File

@@ -45,7 +45,7 @@ docker compose logs -f
## Authentication Model ## Authentication Model
- Legacy shared build-time frontend token behavior was removed. - 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: - Bootstrap users are provisioned from environment:
- `AUTH_BOOTSTRAP_ADMIN_USERNAME` - `AUTH_BOOTSTRAP_ADMIN_USERNAME`
- `AUTH_BOOTSTRAP_ADMIN_PASSWORD` - `AUTH_BOOTSTRAP_ADMIN_PASSWORD`
@@ -56,7 +56,7 @@ docker compose logs -f
- `AUTH_LOGIN_FAILURE_WINDOW_SECONDS` - `AUTH_LOGIN_FAILURE_WINDOW_SECONDS`
- `AUTH_LOGIN_LOCKOUT_BASE_SECONDS` - `AUTH_LOGIN_LOCKOUT_BASE_SECONDS`
- `AUTH_LOGIN_LOCKOUT_MAX_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 ## DEV And LIVE Configuration Matrix
@@ -98,7 +98,7 @@ Recommended LIVE pattern:
2. Keep container published ports bound to localhost or internal network. 2. Keep container published ports bound to localhost or internal network.
3. Set `PUBLIC_BASE_URL` and `VITE_API_BASE` to final HTTPS URLs. 3. Set `PUBLIC_BASE_URL` and `VITE_API_BASE` to final HTTPS URLs.
4. Set `CORS_ORIGINS` to exact HTTPS frontend origins. 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 ## Security Controls
@@ -119,8 +119,7 @@ Recommended LIVE pattern:
## Frontend Runtime ## Frontend Runtime
- Frontend no longer consumes `VITE_API_TOKEN`. - Frontend no longer consumes `VITE_API_TOKEN`.
- Session tokens are not persisted to browser storage. - Session authentication is cookie-based; browser reloads and new tabs can reuse an active session until it expires or is revoked.
- Users must sign in again after full page reload, opening a new tab, or browser restart.
- Protected media and file download flows still use authenticated fetch plus blob/object URL handling. - Protected media and file download flows still use authenticated fetch plus blob/object URL handling.
## Validation Checklist ## Validation Checklist

View File

@@ -20,7 +20,6 @@ import {
deleteDocument, deleteDocument,
exportContentsMarkdown, exportContentsMarkdown,
getCurrentAuthSession, getCurrentAuthSession,
getRuntimeApiToken,
getAppSettings, getAppSettings,
listDocuments, listDocuments,
listPaths, listPaths,
@@ -30,7 +29,6 @@ import {
loginWithPassword, loginWithPassword,
logoutCurrentSession, logoutCurrentSession,
resetAppSettings, resetAppSettings,
setRuntimeApiToken,
searchDocuments, searchDocuments,
trashDocument, trashDocument,
updateAppSettings, 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<void> => { const handleLogin = useCallback(async (username: string, password: string): Promise<void> => {
setIsAuthenticating(true); setIsAuthenticating(true);
setAuthError(null); setAuthError(null);
try { try {
const payload = await loginWithPassword(username, password); const payload = await loginWithPassword(username, password);
setRuntimeApiToken(payload.access_token);
setAuthUser(payload.user); setAuthUser(payload.user);
setAuthPhase('authenticated'); setAuthPhase('authenticated');
setError(null); setError(null);
} catch (caughtError) { } catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : 'Login failed'; const message = caughtError instanceof Error ? caughtError.message : 'Login failed';
setAuthError(message); setAuthError(message);
setRuntimeApiToken(null);
setAuthUser(null); setAuthUser(null);
setAuthPhase('unauthenticated'); setAuthPhase('unauthenticated');
resetApplicationState(); resetApplicationState();
@@ -192,7 +188,6 @@ export default function App(): JSX.Element {
try { try {
await logoutCurrentSession(); await logoutCurrentSession();
} catch {} } catch {}
setRuntimeApiToken(null);
setAuthUser(null); setAuthUser(null);
setAuthError(null); setAuthError(null);
setAuthPhase('unauthenticated'); setAuthPhase('unauthenticated');
@@ -303,13 +298,6 @@ export default function App(): JSX.Element {
}, [isAdmin]); }, [isAdmin]);
useEffect(() => { useEffect(() => {
const existingToken = getRuntimeApiToken();
if (!existingToken) {
setAuthPhase('unauthenticated');
setAuthUser(null);
return;
}
const resolveSession = async (): Promise<void> => { const resolveSession = async (): Promise<void> => {
try { try {
const sessionPayload = await getCurrentAuthSession(); const sessionPayload = await getCurrentAuthSession();
@@ -317,7 +305,6 @@ export default function App(): JSX.Element {
setAuthError(null); setAuthError(null);
setAuthPhase('authenticated'); setAuthPhase('authenticated');
} catch { } catch {
setRuntimeApiToken(null);
setAuthUser(null); setAuthUser(null);
setAuthPhase('unauthenticated'); setAuthPhase('unauthenticated');
resetApplicationState(); resetApplicationState();

View File

@@ -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({ export default function LoginScreen({
error, error,

View File

@@ -5,10 +5,8 @@ import {
getCurrentAuthSession, getCurrentAuthSession,
getDocumentPreviewBlob, getDocumentPreviewBlob,
getDocumentThumbnailBlob, getDocumentThumbnailBlob,
getRuntimeApiToken,
loginWithPassword, loginWithPassword,
logoutCurrentSession, logoutCurrentSession,
setRuntimeApiToken,
updateDocumentMetadata, updateDocumentMetadata,
} from './api.ts'; } from './api.ts';
@@ -53,15 +51,18 @@ function toRequestUrl(input: RequestInfo | URL): string {
*/ */
async function runApiTests(): Promise<void> { async function runApiTests(): Promise<void> {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const globalWithDocument = globalThis as typeof globalThis & { document?: { cookie?: string } };
const originalDocument = globalWithDocument.document;
try { try {
setRuntimeApiToken(null);
const requestUrls: string[] = []; const requestUrls: string[] = [];
const requestAuthHeaders: Array<string | null> = []; const requestAuthHeaders: Array<string | null> = [];
const requestCsrfHeaders: Array<string | null> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
requestUrls.push(toRequestUrl(input)); 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 }); return new Response('preview-bytes', { status: 200 });
}) as typeof fetch; }) as typeof fetch;
@@ -80,27 +81,26 @@ async function runApiTests(): Promise<void> {
); );
assert(requestAuthHeaders[0] === null, `Expected no auth header for thumbnail request, got "${requestAuthHeaders[0]}"`); 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(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'); globalWithDocument.document = {
assert(getRuntimeApiToken() === 'session-user-token', 'Expected runtime token readback to match active token'); cookie: 'dcm_csrf=csrf-session-token',
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { };
const authHeader = new Headers(init?.headers).get('Authorization'); let metadataCsrfHeader: string | null = null;
assert(authHeader === 'Bearer session-user-token', `Expected session token auth header, got "${authHeader}"`); let metadataContentType: string | null = null;
return new Response('preview-bytes', { status: 200 }); let metadataAuthHeader: string | null = null;
}) as typeof fetch;
await getDocumentPreviewBlob('doc-session-auth');
let mergedContentType: string | null = null;
let mergedAuthorization: string | null = null;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const headers = new Headers(init?.headers); const headers = new Headers(init?.headers);
mergedContentType = headers.get('Content-Type'); metadataCsrfHeader = headers.get('x-csrf-token');
mergedAuthorization = headers.get('Authorization'); metadataAuthHeader = headers.get('Authorization');
metadataContentType = headers.get('Content-Type');
return new Response('{}', { status: 200 }); return new Response('{}', { status: 200 });
}) as typeof fetch; }) as typeof fetch;
await updateDocumentMetadata('doc-headers', { original_filename: 'renamed.pdf' }); await updateDocumentMetadata('doc-headers', { original_filename: 'renamed.pdf' });
assert(mergedContentType === 'application/json', `Expected JSON content type to be preserved, got "${mergedContentType}"`); assert(metadataContentType === 'application/json', `Expected JSON content type to be preserved, got "${metadataContentType}"`);
assert(mergedAuthorization === 'Bearer session-user-token', `Expected auth header, got "${mergedAuthorization}"`); assert(metadataAuthHeader === null, `Expected no auth header, got "${metadataAuthHeader}"`);
assert(metadataCsrfHeader === 'csrf-session-token', `Expected CSRF header, got "${metadataCsrfHeader}"`);
globalThis.fetch = (async (): Promise<Response> => { globalThis.fetch = (async (): Promise<Response> => {
return new Response( return new Response(
@@ -169,8 +169,12 @@ async function runApiTests(): Promise<void> {
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown'); await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
} finally { } finally {
setRuntimeApiToken(null);
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
if (originalDocument !== undefined) {
globalWithDocument.document = originalDocument;
} else {
delete globalWithDocument.document;
}
} }
} }

View File

@@ -36,73 +36,69 @@ function resolveApiBase(): string {
const API_BASE = resolveApiBase(); const API_BASE = resolveApiBase();
/** /**
* In-memory bearer token scoped to the active frontend runtime. * CSRF cookie contract used by authenticated requests.
*
* This value is intentionally not persisted to browser storage.
*/ */
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<RequestInit, 'headers'> & { headers?: HeadersInit }; type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
type ApiErrorPayload = { detail?: string } | null; 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 { function getCookieValue(name: string): string | undefined {
if (typeof candidate !== 'string') { if (typeof document === "undefined") {
return undefined; return undefined;
} }
const normalized = candidate.trim(); const rawCookie = document.cookie ?? "";
return normalized ? normalized : undefined; 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 { function resolveCsrfToken(): string | undefined {
return runtimeApiToken; return getCookieValue(CSRF_COOKIE_NAME);
} }
/** /**
* Resolves bearer token from active runtime memory. * Returns whether a method should include CSRF metadata.
*/ */
function resolveApiToken(): string | undefined { function requiresCsrfHeader(method: string): boolean {
return getRuntimeApiToken(); const normalizedMethod = method.toUpperCase();
return !CSRF_SAFE_METHODS.has(normalizedMethod);
} }
/** /**
* Stores or clears the per-user runtime API token in memory. * Merges request headers and appends CSRF metadata for state-changing requests.
*
* @param token Token value for the active runtime; clears token when empty.
*/ */
export function setRuntimeApiToken(token: string | null | undefined): void { function buildRequestHeaders(method: string, headers?: HeadersInit): Headers | undefined {
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;
}
const requestHeaders = new Headers(headers); const requestHeaders = new Headers(headers);
if (apiToken) { if (method && requiresCsrfHeader(method)) {
requestHeaders.set('Authorization', `Bearer ${apiToken}`); const csrfToken = resolveCsrfToken();
if (csrfToken) {
requestHeaders.set(CSRF_HEADER_NAME, csrfToken);
}
} }
return requestHeaders; 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<Response> { function apiRequest(input: string, init: ApiRequestInit = {}): Promise<Response> {
const headers = buildRequestHeaders(init.headers); const method = init.method ?? "GET";
const headers = buildRequestHeaders(method, init.headers);
return fetch(input, { return fetch(input, {
...init, ...init,
credentials: 'include',
...(headers ? { headers } : {}), ...(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<AuthLoginResponse> { export async function loginWithPassword(username: string, password: string): Promise<AuthLoginResponse> {
const response = await fetch(`${API_BASE}/auth/login`, { const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
username: username.trim(), username: username.trim(),
@@ -203,7 +200,7 @@ export async function getCurrentAuthSession(): Promise<AuthSessionInfo> {
} }
/** /**
* Revokes the current authenticated bearer session. * Revokes the current authenticated session.
*/ */
export async function logoutCurrentSession(): Promise<void> { export async function logoutCurrentSession(): Promise<void> {
const response = await apiRequest(`${API_BASE}/auth/logout`, { const response = await apiRequest(`${API_BASE}/auth/logout`, {

View File

@@ -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 { export interface AuthLoginResponse extends AuthSessionInfo {
access_token: string; access_token?: string;
token_type: 'bearer'; token_type: 'bearer';
csrf_token?: string;
} }
/** /**