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.
- 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

View File

@@ -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.

View File

@@ -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()

View File

@@ -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)

View File

@@ -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=["*"],
)

View File

@@ -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):

View File

@@ -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 <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.
- `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
- 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.

View File

@@ -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

View File

@@ -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<void> => {
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<void> => {
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();

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({
error,

View File

@@ -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<void> {
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<string | null> = [];
const requestCsrfHeaders: Array<string | null> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
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<void> {
);
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<Response> => {
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<Response> => {
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<Response> => {
return new Response(
@@ -169,8 +169,12 @@ async function runApiTests(): Promise<void> {
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;
}
}
}

View File

@@ -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<RequestInit, 'headers'> & { 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<Response> {
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<AuthLoginResponse> {
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<AuthSessionInfo> {
}
/**
* Revokes the current authenticated bearer session.
* Revokes the current authenticated session.
*/
export async function logoutCurrentSession(): Promise<void> {
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 {
access_token: string;
access_token?: string;
token_type: 'bearer';
csrf_token?: string;
}
/**