Fix auth session persistence with HttpOnly cookies and CSRF
This commit is contained in:
11
README.md
11
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
|
||||
|
||||
|
||||
15
REPORT.md
15
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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=["*"],
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user