Compare commits

..

12 Commits

23 changed files with 1224 additions and 311 deletions

View File

@@ -19,6 +19,10 @@ AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
AUTH_BOOTSTRAP_ADMIN_PASSWORD=ChangeMe-Admin-Password
AUTH_BOOTSTRAP_USER_USERNAME=user
AUTH_BOOTSTRAP_USER_PASSWORD=ChangeMe-User-Password
AUTH_LOGIN_FAILURE_LIMIT=5
AUTH_LOGIN_FAILURE_WINDOW_SECONDS=900
AUTH_LOGIN_LOCKOUT_BASE_SECONDS=30
AUTH_LOGIN_LOCKOUT_MAX_SECONDS=900
APP_SETTINGS_ENCRYPTION_KEY=ChangeMe-Settings-Encryption-Key
TYPESENSE_API_KEY=ChangeMe-Typesense-Key

View File

@@ -73,6 +73,73 @@ Stop the stack:
docker compose down
```
## Security Must-Know Before Real User Deployment
The items below port the `MUST KNOW User-Dependent Risks` from `REPORT.md` into explicit operator actions.
### High: Development-first defaults can be promoted to production
Avoid:
- Set `APP_ENV=production`.
- Set `PROVIDER_BASE_URL_ALLOW_HTTP=false`.
- Set `PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK=false`.
- Set a strict non-empty `PROVIDER_BASE_URL_ALLOWLIST` for approved provider hosts only.
- Set `PUBLIC_BASE_URL` to HTTPS.
- Restrict `CORS_ORIGINS` to exact production frontend origins.
- Use `REDIS_URL` with `rediss://`.
- Set `REDIS_SECURITY_MODE=strict`.
- Set `REDIS_TLS_MODE=required`.
- Keep `HOST_BIND_IP=127.0.0.1` and expose services only through an HTTPS reverse proxy.
Remedy:
- Immediately correct the values above and redeploy `api` and `worker` (`docker compose up -d api worker`).
- Rotate `AUTH_BOOTSTRAP_*` credentials, provider API keys, and Redis credentials if insecure values were used in a reachable environment.
- Re-check `.env.example` and `docker-compose.yml` before each production promotion.
### Medium: Login throttle IP identity depends on proxy trust model
Current behavior:
- Login throttle identity currently uses `request.client.host` directly.
Avoid:
- Deploy so the backend receives true client IP addresses and does not collapse all traffic to one proxy source IP.
- Validate lockout behavior with multiple client IPs before going live behind a proxy.
Remedy:
- If lockouts affect many users at once, temporarily increase `AUTH_LOGIN_FAILURE_LIMIT` and tune lockout timings to reduce impact while mitigation is in progress.
- Update network and proxy topology so client IP identity is preserved for the backend, then re-run lockout validation tests.
### Medium: API documentation endpoints are exposed by default
Avoid:
- Block public access to `/docs`, `/redoc`, and `/openapi.json` at the reverse proxy or edge firewall.
- Keep docs endpoints reachable only from trusted internal/admin networks.
Remedy:
- Add deny rules for those paths immediately and reload the proxy.
- Verify those routes return `403` or `404` from untrusted networks.
### Medium: Auth session tokens are cookie-based
Avoid:
- 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 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
Avoid:
- Keep Typesense on isolated internal networks only.
- Do not expose Typesense service ports directly to untrusted networks.
Remedy:
- For cross-host or untrusted network paths, terminate TLS in front of Typesense (or use equivalent secure service networking) and require encrypted transport for all clients.
## Common Operations
Start or rebuild:

149
REPORT.md
View File

@@ -1,149 +0,0 @@
# Security Production Readiness Report
Date: 2026-03-01
Repository: /Users/bedas/Developer/GitHub/dcm
Review type: Static code and configuration review (no runtime penetration testing)
## Scope
- Backend API and worker: `backend/app`
- Frontend API client/auth transport: `frontend/src`
- Compose and environment defaults: `docker-compose.yml`, `.env`
## Method and Limits
- Reviewed source and configuration files in the current checkout.
- Verified findings with direct file evidence.
- Did not run dynamic security testing, dependency CVE scanning, or infrastructure perimeter testing.
## Confirmed Product Security Findings
### Critical
1. Browser-exposed shared bearer token path (`VITE_API_TOKEN` fallback)
- Severity: Critical
- Why this is a product issue: The frontend code supports a build-time token fallback and injects it into all API requests. This creates a shared credential model in browser code.
- Impact: Any user with browser access can recover and reuse the token, collapsing auth boundaries and auditability.
- Exploit path: Open app -> inspect runtime/bundle or intercepted request -> replay bearer token against protected API endpoints.
- Evidence:
- `frontend/src/lib/api.ts:39`
- `frontend/src/lib/api.ts:98`
- `frontend/src/lib/api.ts:111`
- `frontend/src/lib/api.ts:155`
- `docker-compose.yml:123`
- `backend/app/api/router.py:25`
- `backend/app/api/router.py:37`
- Production recommendation:
- Remove browser-side static token fallback.
- Use per-user server-issued auth (session or short-lived JWT) with role-bound authorization.
### High
1. CORS policy is effectively any HTTP/HTTPS origin, with credentials enabled
- Severity: High
- Why this is a product issue: CORS middleware enables `allow_origin_regex` that matches broad web origins and sets `allow_credentials=True`.
- Impact: If credentials are present, cross-origin access risk increases and token abuse becomes easier from arbitrary origins.
- Exploit path: Malicious origin performs cross-origin requests with available credentials and can read API responses under permissive CORS policy.
- Evidence:
- `backend/app/main.py:21`
- `backend/app/main.py:41`
- `backend/app/main.py:42`
- `backend/app/main.py:44`
- Production recommendation:
- Replace regex-based broad origin acceptance with explicit trusted origin allowlist.
- Keep `allow_credentials=False` unless strictly required for cookie-based flows.
### Medium
1. Sensitive processing content is persisted in logs by default
- Severity: Medium
- Why this is a product issue: Pipeline logging records OCR text, extraction text, prompts, and LLM outputs into persistent processing logs.
- Impact: Increased confidentiality risk and larger data-retention blast radius if logs are queried or exfiltrated.
- Exploit path: Access to admin log endpoints or database allows retrieval of sensitive operational content.
- Evidence:
- `backend/app/worker/tasks.py:619`
- `backend/app/worker/tasks.py:638`
- `backend/app/services/routing_pipeline.py:789`
- `backend/app/services/routing_pipeline.py:802`
- `backend/app/services/routing_pipeline.py:814`
- `backend/app/core/config.py:45`
- Production recommendation:
- Default to metadata-only logs.
- Disable persistent storage of prompt/response/raw extracted text unless temporary debug mode is explicitly enabled with strict TTL.
2. Markdown export endpoint is unbounded and memory-amplifiable
- Severity: Medium
- Why this is a product issue: Export loads all matching documents and builds ZIP in-memory with `BytesIO`, without hard limits on selection size.
- Impact: Authenticated users can trigger high memory use and service degradation.
- Exploit path: Repeated wide `path_prefix` exports cause large in-memory archive construction.
- Evidence:
- `backend/app/api/routes_documents.py:402`
- `backend/app/api/routes_documents.py:412`
- `backend/app/api/routes_documents.py:416`
- `backend/app/api/routes_documents.py:418`
- `backend/app/api/routes_documents.py:421`
- `backend/app/api/routes_documents.py:425`
- Production recommendation:
- Enforce max export document count and total bytes.
- Stream archive generation to temp files.
- Add endpoint rate limiting.
## Risks Requiring Product Decision or Further Verification
1. Authorization model appears role-based without per-document ownership boundaries
- Evidence:
- `backend/app/models/document.py:29`
- `backend/app/api/router.py:19`
- `backend/app/api/router.py:31`
- Question: Is this intentionally single-operator, or should production support multi-user/tenant data isolation?
2. Worker startup command uses raw Redis URL string and bypasses in-code URL security validator at startup
- Evidence:
- `docker-compose.yml:81`
- `backend/app/worker/queue.py:15`
- Question: Should worker startup also enforce `validate_redis_url_security` before consuming jobs?
3. Provider key encryption uses custom cryptographic construction
- Evidence:
- `backend/app/services/app_settings.py:131`
- `backend/app/services/app_settings.py:154`
- `backend/app/services/app_settings.py:176`
- Question: Are compliance or internal policy requirements demanding standardized AEAD primitives from vetted cryptography libraries?
## User-Managed Configuration Observations (Not Product Defects)
These are deployment/operator choices and should be tracked separately from code defects.
1. Development-mode posture in local `.env`
- Evidence:
- `.env:1`
- `.env:3`
- Notes: `APP_ENV=development` and anonymous development access are enabled.
2. Local `.env` includes placeholder shared API token values
- Evidence:
- `.env:15`
- `.env:16`
- `.env:31`
- Notes: If replaced with real values and reused, this increases operational risk. This is operator responsibility.
3. Compose defaults allow permissive provider egress controls
- Evidence:
- `docker-compose.yml:51`
- `docker-compose.yml:52`
- `.env:21`
- `.env:22`
- `.env:23`
- Notes: Allowing HTTP/private-network provider targets is a deployment policy choice.
4. Internal service transport defaults are plaintext in local stack
- Evidence:
- `docker-compose.yml:56`
- `.env:11`
- Notes: `http`/`redis://` may be acceptable for isolated local dev, but not for exposed production networks.
## Production Readiness Priority Order
1. Remove browser static token model and adopt per-user auth.
2. Tighten CORS to explicit trusted origins only.
3. Reduce persistent sensitive logging to metadata by default.
4. Add hard limits and streaming behavior for markdown export.
5. Resolve product decisions on tenant isolation, worker Redis security enforcement, and cryptography standardization.

View File

@@ -8,6 +8,10 @@ AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
AUTH_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-admin-password
AUTH_BOOTSTRAP_USER_USERNAME=user
AUTH_BOOTSTRAP_USER_PASSWORD=replace-with-random-user-password
AUTH_LOGIN_FAILURE_LIMIT=5
AUTH_LOGIN_FAILURE_WINDOW_SECONDS=900
AUTH_LOGIN_LOCKOUT_BASE_SECONDS=30
AUTH_LOGIN_LOCKOUT_MAX_SECONDS=900
APP_SETTINGS_ENCRYPTION_KEY=replace-with-random-settings-encryption-key
PROCESSING_LOG_STORE_MODEL_IO_TEXT=false
PROCESSING_LOG_STORE_PAYLOAD_TEXT=false

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(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_auth)],
session: Annotated[Session, Depends(get_session)],
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_auth),
csrf_header: str | None = Header(None, alias=CSRF_HEADER_NAME),
csrf_cookie: str | None = Cookie(None, alias=CSRF_COOKIE_NAME),
session_cookie: str | None = Cookie(None, alias=SESSION_COOKIE_NAME),
session: 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,9 +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,
@@ -12,10 +21,23 @@ from app.schemas.auth import (
AuthSessionResponse,
AuthUserResponse,
)
from app.services.auth_login_throttle import (
check_login_throttle,
clear_login_throttle,
record_failed_login_attempt,
)
try:
from fastapi import Response
except (ImportError, AttributeError):
from fastapi.responses import Response
from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
router = APIRouter(prefix="/auth", tags=["auth"])
logger = logging.getLogger(__name__)
LOGIN_THROTTLED_DETAIL = "Too many login attempts. Try again later."
LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL = "Login rate limiter backend unavailable"
def _request_ip_address(request: Request) -> str | None:
@@ -31,13 +53,106 @@ def _request_user_agent(request: Request) -> str | None:
return user_agent[:512] if user_agent else None
def _retry_after_headers(retry_after_seconds: int) -> dict[str, str]:
"""Returns a bounded Retry-After header payload for throttled authentication responses."""
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,
session: Session = Depends(get_session),
) -> AuthLoginResponse:
"""Authenticates username and password and returns an issued bearer session token."""
"""Authenticates credentials with throttle protection and returns issued session metadata."""
ip_address = _request_ip_address(request)
try:
throttle_status = check_login_throttle(
username=payload.username,
ip_address=ip_address,
)
except RuntimeError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL,
) from error
if throttle_status.is_throttled:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=LOGIN_THROTTLED_DETAIL,
headers=_retry_after_headers(throttle_status.retry_after_seconds),
)
user = authenticate_user(
session,
@@ -45,22 +160,67 @@ def login(
password=payload.password,
)
if user is None:
try:
lockout_seconds = record_failed_login_attempt(
username=payload.username,
ip_address=ip_address,
)
except RuntimeError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL,
) from error
if lockout_seconds > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=LOGIN_THROTTLED_DETAIL,
headers=_retry_after_headers(lockout_seconds),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
try:
clear_login_throttle(
username=payload.username,
ip_address=ip_address,
)
except RuntimeError:
logger.warning(
"Failed to clear login throttle state after successful authentication: username=%s ip=%s",
payload.username.strip().lower(),
ip_address or "",
)
issued_session = issue_user_session(
session,
user=user,
user_agent=_request_user_agent(request),
ip_address=_request_ip_address(request),
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,
)
@@ -80,10 +240,11 @@ def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResp
@router.post("/logout", response_model=AuthLogoutResponse)
def logout(
response: Response,
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,
@@ -91,4 +252,6 @@ def logout(
)
if revoked:
session.commit()
_clear_session_cookies(response)
return AuthLogoutResponse(revoked=revoked)

View File

@@ -50,6 +50,31 @@ def _scope_document_statement_for_auth_context(statement, auth_context: AuthCont
return statement.where(Document.owner_user_id == auth_context.user_id)
def _is_predefined_entry_visible_to_auth_context(entry: dict[str, object], auth_context: AuthContext) -> bool:
"""Returns whether one predefined catalog entry is visible to the active caller role."""
if auth_context.role == UserRole.ADMIN:
return True
return bool(entry.get("global_shared", False))
def _collect_visible_predefined_values(
entries: list[dict[str, object]],
*,
auth_context: AuthContext,
) -> set[str]:
"""Collects normalized predefined values visible for the active caller role."""
visible_values: set[str] = set()
for entry in entries:
if not _is_predefined_entry_visible_to_auth_context(entry, auth_context):
continue
normalized = str(entry.get("value", "")).strip()
if normalized:
visible_values.add(normalized)
return visible_values
def _ensure_document_access(document: Document, auth_context: AuthContext) -> None:
"""Enforces owner-level access for non-admin users and raises not-found on violations."""
@@ -397,9 +422,10 @@ def list_tags(
rows = session.execute(statement).scalars().all()
tags = {tag for row in rows for tag in row if tag}
tags.update(
str(item.get("value", "")).strip()
for item in read_predefined_tags_settings()
if str(item.get("value", "")).strip()
_collect_visible_predefined_values(
read_predefined_tags_settings(),
auth_context=auth_context,
)
)
tags = sorted(tags)
return {"tags": tags}
@@ -421,9 +447,10 @@ def list_paths(
rows = session.execute(statement).scalars().all()
paths = {row for row in rows if row}
paths.update(
str(item.get("value", "")).strip()
for item in read_predefined_paths_settings()
if str(item.get("value", "")).strip()
_collect_visible_predefined_values(
read_predefined_paths_settings(),
auth_context=auth_context,
)
)
paths = sorted(paths)
return {"paths": paths}

View File

@@ -29,6 +29,10 @@ class Settings(BaseSettings):
auth_password_pbkdf2_iterations: int = 390000
auth_session_token_bytes: int = 32
auth_session_pepper: str = ""
auth_login_failure_limit: int = 5
auth_login_failure_window_seconds: int = 900
auth_login_lockout_base_seconds: int = 30
auth_login_lockout_max_seconds: int = 900
storage_root: Path = Path("/data/storage")
upload_chunk_size: int = 4 * 1024 * 1024
max_upload_files_per_request: int = 50

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

@@ -0,0 +1,187 @@
"""Redis-backed brute-force protections for authentication login requests."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from redis.exceptions import RedisError
from app.core.config import Settings, get_settings
from app.services.authentication import normalize_username
from app.worker.queue import get_redis
logger = logging.getLogger(__name__)
USERNAME_SUBJECT_KIND = "username"
IP_SUBJECT_KIND = "ip"
UNKNOWN_USERNAME_SUBJECT = "unknown-username"
UNKNOWN_IP_SUBJECT = "unknown-ip"
@dataclass(frozen=True)
class LoginThrottlePolicy:
"""Captures login throttle policy values resolved from runtime settings."""
failure_limit: int
failure_window_seconds: int
lockout_base_seconds: int
lockout_max_seconds: int
@dataclass(frozen=True)
class LoginThrottleStatus:
"""Represents whether login attempts are currently throttled and retry metadata."""
is_throttled: bool
retry_after_seconds: int = 0
def _bounded_int(value: int, *, minimum: int, maximum: int) -> int:
"""Clamps one integer value to an inclusive minimum and maximum range."""
return max(minimum, min(maximum, int(value)))
def _resolve_policy(settings: Settings) -> LoginThrottlePolicy:
"""Resolves login throttle policy from settings with defensive value bounds."""
failure_limit = _bounded_int(settings.auth_login_failure_limit, minimum=1, maximum=1000)
failure_window_seconds = _bounded_int(settings.auth_login_failure_window_seconds, minimum=30, maximum=86400)
lockout_base_seconds = _bounded_int(settings.auth_login_lockout_base_seconds, minimum=1, maximum=3600)
lockout_max_seconds = _bounded_int(settings.auth_login_lockout_max_seconds, minimum=1, maximum=86400)
if lockout_max_seconds < lockout_base_seconds:
lockout_max_seconds = lockout_base_seconds
return LoginThrottlePolicy(
failure_limit=failure_limit,
failure_window_seconds=failure_window_seconds,
lockout_base_seconds=lockout_base_seconds,
lockout_max_seconds=lockout_max_seconds,
)
def _normalize_login_identity(username: str, ip_address: str | None) -> tuple[str, str]:
"""Normalizes username and source IP identity values used by throttle storage keys."""
normalized_username = normalize_username(username) or UNKNOWN_USERNAME_SUBJECT
normalized_ip = (ip_address or "").strip()[:64] or UNKNOWN_IP_SUBJECT
return normalized_username, normalized_ip
def _identity_subjects(username: str, ip_address: str | None) -> tuple[tuple[str, str], tuple[str, str]]:
"""Builds the username and IP throttle subject tuples for one login attempt."""
normalized_username, normalized_ip = _normalize_login_identity(username, ip_address)
return (
(USERNAME_SUBJECT_KIND, normalized_username),
(IP_SUBJECT_KIND, normalized_ip),
)
def _failure_key(*, subject_kind: str, subject_value: str) -> str:
"""Builds the Redis key used to track failed login counts for one subject."""
return f"dcm:auth-login:fail:{subject_kind}:{subject_value}"
def _lock_key(*, subject_kind: str, subject_value: str) -> str:
"""Builds the Redis key used to store active lockout state for one subject."""
return f"dcm:auth-login:lock:{subject_kind}:{subject_value}"
def _next_lockout_seconds(*, failure_count: int, policy: LoginThrottlePolicy) -> int:
"""Computes exponential lockout duration when failed attempts exceed configured limit."""
if failure_count <= policy.failure_limit:
return 0
additional_failures = failure_count - policy.failure_limit - 1
lockout_seconds = policy.lockout_base_seconds
while additional_failures > 0 and lockout_seconds < policy.lockout_max_seconds:
lockout_seconds = min(policy.lockout_max_seconds, lockout_seconds * 2)
additional_failures -= 1
return lockout_seconds
def check_login_throttle(*, username: str, ip_address: str | None) -> LoginThrottleStatus:
"""Returns active login throttle status for the username and source IP identity tuple."""
redis_client = get_redis()
try:
retry_after_seconds = 0
for subject_kind, subject_value in _identity_subjects(username, ip_address):
subject_ttl = int(redis_client.ttl(_lock_key(subject_kind=subject_kind, subject_value=subject_value)))
if subject_ttl == -1:
retry_after_seconds = max(retry_after_seconds, 1)
elif subject_ttl > 0:
retry_after_seconds = max(retry_after_seconds, subject_ttl)
except RedisError as error:
raise RuntimeError("Login throttle backend unavailable") from error
return LoginThrottleStatus(
is_throttled=retry_after_seconds > 0,
retry_after_seconds=retry_after_seconds,
)
def record_failed_login_attempt(*, username: str, ip_address: str | None) -> int:
"""Records one failed login attempt and returns active lockout seconds, if any."""
settings = get_settings()
policy = _resolve_policy(settings)
normalized_username, normalized_ip = _normalize_login_identity(username, ip_address)
redis_client = get_redis()
try:
highest_failure_count = 0
active_lockout_seconds = 0
for subject_kind, subject_value in (
(USERNAME_SUBJECT_KIND, normalized_username),
(IP_SUBJECT_KIND, normalized_ip),
):
failure_key = _failure_key(subject_kind=subject_kind, subject_value=subject_value)
pipeline = redis_client.pipeline(transaction=True)
pipeline.incr(failure_key, 1)
pipeline.expire(failure_key, policy.failure_window_seconds + 5)
count_value, _ = pipeline.execute()
failure_count = int(count_value)
highest_failure_count = max(highest_failure_count, failure_count)
lockout_seconds = _next_lockout_seconds(failure_count=failure_count, policy=policy)
if lockout_seconds > 0:
redis_client.set(
_lock_key(subject_kind=subject_kind, subject_value=subject_value),
"1",
ex=lockout_seconds,
)
active_lockout_seconds = max(active_lockout_seconds, lockout_seconds)
except RedisError as error:
raise RuntimeError("Login throttle backend unavailable") from error
logger.warning(
"Authentication login failure: username=%s ip=%s failed_attempts=%s lockout_seconds=%s",
normalized_username,
normalized_ip,
highest_failure_count,
active_lockout_seconds,
)
return active_lockout_seconds
def clear_login_throttle(*, username: str, ip_address: str | None) -> None:
"""Clears username and source-IP login throttle state after successful authentication."""
normalized_username, normalized_ip = _normalize_login_identity(username, ip_address)
redis_client = get_redis()
keys = [
_failure_key(subject_kind=USERNAME_SUBJECT_KIND, subject_value=normalized_username),
_lock_key(subject_kind=USERNAME_SUBJECT_KIND, subject_value=normalized_username),
_failure_key(subject_kind=IP_SUBJECT_KIND, subject_value=normalized_ip),
_lock_key(subject_kind=IP_SUBJECT_KIND, subject_value=normalized_ip),
]
try:
redis_client.delete(*keys)
except RedisError as error:
raise RuntimeError("Login throttle backend unavailable") from error

View File

@@ -40,6 +40,48 @@ if "pydantic_settings" not in sys.modules:
if "fastapi" not in sys.modules:
fastapi_stub = ModuleType("fastapi")
class _APIRouter:
"""Minimal APIRouter stand-in supporting decorator registration."""
def __init__(self, *args: object, **kwargs: object) -> None:
self.args = args
self.kwargs = kwargs
def post(self, *_args: object, **_kwargs: object): # type: ignore[no-untyped-def]
"""Returns no-op decorator for POST route declarations."""
def decorator(func): # type: ignore[no-untyped-def]
return func
return decorator
def get(self, *_args: object, **_kwargs: object): # type: ignore[no-untyped-def]
"""Returns no-op decorator for GET route declarations."""
def decorator(func): # type: ignore[no-untyped-def]
return func
return decorator
def patch(self, *_args: object, **_kwargs: object): # type: ignore[no-untyped-def]
"""Returns no-op decorator for PATCH route declarations."""
def decorator(func): # type: ignore[no-untyped-def]
return func
return decorator
def delete(self, *_args: object, **_kwargs: object): # type: ignore[no-untyped-def]
"""Returns no-op decorator for DELETE route declarations."""
def decorator(func): # type: ignore[no-untyped-def]
return func
return decorator
class _Request:
"""Minimal request placeholder for route function import compatibility."""
class _HTTPException(Exception):
"""Minimal HTTPException compatible with route dependency tests."""
@@ -54,6 +96,7 @@ if "fastapi" not in sys.modules:
HTTP_401_UNAUTHORIZED = 401
HTTP_403_FORBIDDEN = 403
HTTP_429_TOO_MANY_REQUESTS = 429
HTTP_503_SERVICE_UNAVAILABLE = 503
def _depends(dependency): # type: ignore[no-untyped-def]
@@ -61,11 +104,52 @@ if "fastapi" not in sys.modules:
return dependency
def _query(default=None, **_kwargs): # type: ignore[no-untyped-def]
"""Returns FastAPI-like query defaults for dependency-light route imports."""
return default
def _file(default=None, **_kwargs): # type: ignore[no-untyped-def]
"""Returns FastAPI-like file defaults for dependency-light route imports."""
return default
def _form(default=None, **_kwargs): # type: ignore[no-untyped-def]
"""Returns FastAPI-like form defaults for dependency-light route imports."""
return default
class _UploadFile:
"""Minimal UploadFile placeholder for route import compatibility."""
fastapi_stub.APIRouter = _APIRouter
fastapi_stub.Depends = _depends
fastapi_stub.File = _file
fastapi_stub.Form = _form
fastapi_stub.HTTPException = _HTTPException
fastapi_stub.Query = _query
fastapi_stub.Request = _Request
fastapi_stub.UploadFile = _UploadFile
fastapi_stub.status = _Status()
sys.modules["fastapi"] = fastapi_stub
if "fastapi.responses" not in sys.modules:
fastapi_responses_stub = ModuleType("fastapi.responses")
class _Response:
"""Minimal response placeholder for route import compatibility."""
class _FileResponse(_Response):
"""Minimal file response placeholder for route import compatibility."""
class _StreamingResponse(_Response):
"""Minimal streaming response placeholder for route import compatibility."""
fastapi_responses_stub.Response = _Response
fastapi_responses_stub.FileResponse = _FileResponse
fastapi_responses_stub.StreamingResponse = _StreamingResponse
sys.modules["fastapi.responses"] = fastapi_responses_stub
if "fastapi.security" not in sys.modules:
fastapi_security_stub = ModuleType("fastapi.security")
@@ -238,8 +322,14 @@ if "app.services.handwriting_style" not in sys.modules:
return None
def _delete_many_handwriting_style_documents(*_args: object, **_kwargs: object) -> None:
"""No-op bulk style document delete stub for route import compatibility."""
return None
handwriting_style_stub.assign_handwriting_style = _assign_handwriting_style
handwriting_style_stub.delete_handwriting_style_document = _delete_handwriting_style_document
handwriting_style_stub.delete_many_handwriting_style_documents = _delete_many_handwriting_style_documents
sys.modules["app.services.handwriting_style"] = handwriting_style_stub
if "app.services.routing_pipeline" not in sys.modules:
@@ -274,10 +364,13 @@ if "app.services.routing_pipeline" not in sys.modules:
from fastapi import HTTPException
from app.api.auth import AuthContext, require_admin
from app.api import routes_auth as auth_routes_module
from app.api import routes_documents as documents_routes_module
from app.core import config as config_module
from app.models.auth import UserRole
from app.models.processing_log import sanitize_processing_log_payload_value, sanitize_processing_log_text
from app.schemas.processing_logs import ProcessingLogEntryResponse
from app.services import auth_login_throttle as auth_login_throttle_module
from app.services import extractor as extractor_module
from app.worker import tasks as worker_tasks_module
@@ -328,6 +421,498 @@ class AuthDependencyTests(unittest.TestCase):
self.assertEqual(resolved.role, UserRole.ADMIN)
class DocumentCatalogVisibilityTests(unittest.TestCase):
"""Verifies predefined tag and path discovery visibility by caller role."""
class _ScalarSequence:
"""Provides SQLAlchemy-like scalar result chaining for route unit tests."""
def __init__(self, values: list[object]) -> None:
self._values = values
def scalars(self) -> "DocumentCatalogVisibilityTests._ScalarSequence":
"""Returns self to emulate `.scalars().all()` call chains."""
return self
def all(self) -> list[object]:
"""Returns deterministic sequence values for route helper queries."""
return list(self._values)
class _SessionStub:
"""Returns a fixed scalar sequence for route metadata queries."""
def __init__(self, values: list[object]) -> None:
self._values = values
def execute(self, _statement: object) -> "DocumentCatalogVisibilityTests._ScalarSequence":
"""Ignores query details and returns deterministic scalar sequence results."""
return DocumentCatalogVisibilityTests._ScalarSequence(self._values)
@staticmethod
def _auth_context(role: UserRole) -> AuthContext:
"""Builds deterministic auth context fixtures for document discovery tests."""
return AuthContext(
user_id=uuid.uuid4(),
username=f"{role.value}-user",
role=role,
session_id=uuid.uuid4(),
expires_at=datetime.now(UTC),
)
def test_non_admin_only_receives_global_shared_predefined_tags_and_paths(self) -> None:
"""User role receives only globally shared predefined values in discovery responses."""
session = self._SessionStub(
values=[
["owner-tag", ""],
["owner-only-tag"],
]
)
predefined_tags = [
{"value": "SharedTag", "global_shared": True},
{"value": "InternalTag", "global_shared": False},
{"value": "ImplicitPrivateTag"},
]
predefined_paths = [
{"value": "Shared/Path", "global_shared": True},
{"value": "Internal/Path", "global_shared": False},
{"value": "Implicit/Private"},
]
with (
patch.object(documents_routes_module, "read_predefined_tags_settings", return_value=predefined_tags),
patch.object(documents_routes_module, "read_predefined_paths_settings", return_value=predefined_paths),
):
tags_response = documents_routes_module.list_tags(
include_trashed=False,
auth_context=self._auth_context(UserRole.USER),
session=session,
)
paths_response = documents_routes_module.list_paths(
include_trashed=False,
auth_context=self._auth_context(UserRole.USER),
session=self._SessionStub(values=["Owner/Path"]),
)
self.assertEqual(tags_response["tags"], ["SharedTag", "owner-only-tag", "owner-tag"])
self.assertEqual(paths_response["paths"], ["Owner/Path", "Shared/Path"])
def test_admin_receives_full_predefined_tags_and_paths_catalog(self) -> None:
"""Admin role receives full predefined values regardless of global-sharing scope."""
predefined_tags = [
{"value": "SharedTag", "global_shared": True},
{"value": "InternalTag", "global_shared": False},
{"value": "ImplicitPrivateTag"},
]
predefined_paths = [
{"value": "Shared/Path", "global_shared": True},
{"value": "Internal/Path", "global_shared": False},
{"value": "Implicit/Private"},
]
with (
patch.object(documents_routes_module, "read_predefined_tags_settings", return_value=predefined_tags),
patch.object(documents_routes_module, "read_predefined_paths_settings", return_value=predefined_paths),
):
tags_response = documents_routes_module.list_tags(
include_trashed=False,
auth_context=self._auth_context(UserRole.ADMIN),
session=self._SessionStub(values=[["admin-tag"]]),
)
paths_response = documents_routes_module.list_paths(
include_trashed=False,
auth_context=self._auth_context(UserRole.ADMIN),
session=self._SessionStub(values=["Admin/Path"]),
)
self.assertEqual(
tags_response["tags"],
["ImplicitPrivateTag", "InternalTag", "SharedTag", "admin-tag"],
)
self.assertEqual(
paths_response["paths"],
["Admin/Path", "Implicit/Private", "Internal/Path", "Shared/Path"],
)
class _FakeRedisPipeline:
"""Provides deterministic Redis pipeline behavior for login throttle tests."""
def __init__(self, redis_client: "_FakeRedis") -> None:
self._redis_client = redis_client
self._operations: list[tuple[str, tuple[object, ...]]] = []
def incr(self, key: str, amount: int) -> "_FakeRedisPipeline":
"""Queues one counter increment operation for pipeline execution."""
self._operations.append(("incr", (key, amount)))
return self
def expire(self, key: str, ttl_seconds: int) -> "_FakeRedisPipeline":
"""Queues one key expiration operation for pipeline execution."""
self._operations.append(("expire", (key, ttl_seconds)))
return self
def execute(self) -> list[object]:
"""Executes queued operations in order and returns Redis-like result values."""
results: list[object] = []
for operation, arguments in self._operations:
if operation == "incr":
key, amount = arguments
previous = int(self._redis_client.values.get(str(key), 0))
updated = previous + int(amount)
self._redis_client.values[str(key)] = updated
results.append(updated)
elif operation == "expire":
key, ttl_seconds = arguments
self._redis_client.ttl_seconds[str(key)] = int(ttl_seconds)
results.append(True)
return results
class _FakeRedis:
"""In-memory Redis replacement with TTL behavior needed by throttle tests."""
def __init__(self) -> None:
self.values: dict[str, object] = {}
self.ttl_seconds: dict[str, int] = {}
def pipeline(self, transaction: bool = True) -> _FakeRedisPipeline:
"""Creates a fake transaction pipeline for grouped increment operations."""
_ = transaction
return _FakeRedisPipeline(self)
def set(self, key: str, value: str, ex: int | None = None) -> bool:
"""Stores key values and optional TTL metadata used by lockout keys."""
self.values[key] = value
if ex is not None:
self.ttl_seconds[key] = int(ex)
return True
def ttl(self, key: str) -> int:
"""Returns TTL for existing keys or Redis-compatible missing-key indicator."""
return int(self.ttl_seconds.get(key, -2))
def delete(self, *keys: str) -> int:
"""Deletes keys and returns number of removed entries."""
removed_count = 0
for key in keys:
if key in self.values:
self.values.pop(key, None)
removed_count += 1
self.ttl_seconds.pop(key, None)
return removed_count
def _login_throttle_settings(
*,
failure_limit: int = 2,
failure_window_seconds: int = 60,
lockout_base_seconds: int = 10,
lockout_max_seconds: int = 40,
) -> SimpleNamespace:
"""Builds deterministic login-throttle settings for service-level unit coverage."""
return SimpleNamespace(
auth_login_failure_limit=failure_limit,
auth_login_failure_window_seconds=failure_window_seconds,
auth_login_lockout_base_seconds=lockout_base_seconds,
auth_login_lockout_max_seconds=lockout_max_seconds,
)
class AuthLoginThrottleServiceTests(unittest.TestCase):
"""Verifies login throttle lockout progression, cap behavior, and clear semantics."""
def test_failed_attempts_trigger_lockout_after_limit(self) -> None:
"""Failed attempts beyond configured limit activate login lockouts."""
fake_redis = _FakeRedis()
with (
patch.object(
auth_login_throttle_module,
"get_settings",
return_value=_login_throttle_settings(failure_limit=2, lockout_base_seconds=12),
),
patch.object(auth_login_throttle_module, "get_redis", return_value=fake_redis),
):
self.assertEqual(
auth_login_throttle_module.record_failed_login_attempt(
username="admin",
ip_address="203.0.113.10",
),
0,
)
self.assertEqual(
auth_login_throttle_module.record_failed_login_attempt(
username="admin",
ip_address="203.0.113.10",
),
0,
)
lockout_seconds = auth_login_throttle_module.record_failed_login_attempt(
username="admin",
ip_address="203.0.113.10",
)
status = auth_login_throttle_module.check_login_throttle(
username="admin",
ip_address="203.0.113.10",
)
self.assertEqual(lockout_seconds, 12)
self.assertTrue(status.is_throttled)
self.assertEqual(status.retry_after_seconds, 12)
def test_lockout_duration_escalates_and_respects_max_cap(self) -> None:
"""Repeated failures after threshold double lockout duration up to configured maximum."""
fake_redis = _FakeRedis()
with (
patch.object(
auth_login_throttle_module,
"get_settings",
return_value=_login_throttle_settings(
failure_limit=1,
lockout_base_seconds=10,
lockout_max_seconds=25,
),
),
patch.object(auth_login_throttle_module, "get_redis", return_value=fake_redis),
):
first_lockout = auth_login_throttle_module.record_failed_login_attempt(
username="admin",
ip_address="198.51.100.15",
)
second_lockout = auth_login_throttle_module.record_failed_login_attempt(
username="admin",
ip_address="198.51.100.15",
)
third_lockout = auth_login_throttle_module.record_failed_login_attempt(
username="admin",
ip_address="198.51.100.15",
)
fourth_lockout = auth_login_throttle_module.record_failed_login_attempt(
username="admin",
ip_address="198.51.100.15",
)
self.assertEqual(first_lockout, 0)
self.assertEqual(second_lockout, 10)
self.assertEqual(third_lockout, 20)
self.assertEqual(fourth_lockout, 25)
def test_clear_login_throttle_removes_active_lockout_state(self) -> None:
"""Successful login clears active lockout keys for username and IP subjects."""
fake_redis = _FakeRedis()
with (
patch.object(
auth_login_throttle_module,
"get_settings",
return_value=_login_throttle_settings(
failure_limit=1,
lockout_base_seconds=15,
lockout_max_seconds=30,
),
),
patch.object(auth_login_throttle_module, "get_redis", return_value=fake_redis),
):
auth_login_throttle_module.record_failed_login_attempt(
username="admin",
ip_address="192.0.2.20",
)
auth_login_throttle_module.record_failed_login_attempt(
username="admin",
ip_address="192.0.2.20",
)
throttled_before_clear = auth_login_throttle_module.check_login_throttle(
username="admin",
ip_address="192.0.2.20",
)
auth_login_throttle_module.clear_login_throttle(
username="admin",
ip_address="192.0.2.20",
)
throttled_after_clear = auth_login_throttle_module.check_login_throttle(
username="admin",
ip_address="192.0.2.20",
)
self.assertTrue(throttled_before_clear.is_throttled)
self.assertFalse(throttled_after_clear.is_throttled)
self.assertEqual(throttled_after_clear.retry_after_seconds, 0)
def test_backend_errors_raise_runtime_error(self) -> None:
"""Redis backend failures are surfaced as RuntimeError for caller fail-closed handling."""
class _BrokenRedis:
"""Raises RedisError for all Redis interactions used by login throttle service."""
def ttl(self, _key: str) -> int:
raise auth_login_throttle_module.RedisError("redis unavailable")
with patch.object(auth_login_throttle_module, "get_redis", return_value=_BrokenRedis()):
with self.assertRaises(RuntimeError):
auth_login_throttle_module.check_login_throttle(
username="admin",
ip_address="203.0.113.88",
)
class AuthLoginRouteThrottleTests(unittest.TestCase):
"""Verifies `/auth/login` route throttle responses and success-flow state clearing."""
class _SessionStub:
"""Tracks commit calls for route-level login tests without database dependencies."""
def __init__(self) -> None:
self.commit_count = 0
def commit(self) -> None:
"""Records one commit invocation."""
self.commit_count += 1
@staticmethod
def _response_stub() -> SimpleNamespace:
"""Builds a minimal response object for direct route invocation."""
return SimpleNamespace(
set_cookie=lambda *_args, **_kwargs: None,
delete_cookie=lambda *_args, **_kwargs: None,
)
@staticmethod
def _request_stub(ip_address: str = "203.0.113.2", user_agent: str = "unit-test") -> SimpleNamespace:
"""Builds request-like object containing client host and user-agent header fields."""
return SimpleNamespace(
client=SimpleNamespace(host=ip_address),
headers={"user-agent": user_agent},
)
def test_login_rejects_when_precheck_reports_active_throttle(self) -> None:
"""Pre-auth throttle checks return a stable 429 response without credential lookup."""
payload = auth_routes_module.AuthLoginRequest(username="admin", password="bad-password")
session = self._SessionStub()
throttled = auth_login_throttle_module.LoginThrottleStatus(
is_throttled=True,
retry_after_seconds=21,
)
with (
patch.object(auth_routes_module, "check_login_throttle", return_value=throttled),
patch.object(auth_routes_module, "authenticate_user") as authenticate_mock,
):
with self.assertRaises(HTTPException) as raised:
auth_routes_module.login(
payload=payload,
request=self._request_stub(),
response=self._response_stub(),
session=session,
)
self.assertEqual(raised.exception.status_code, 429)
self.assertEqual(raised.exception.detail, auth_routes_module.LOGIN_THROTTLED_DETAIL)
self.assertEqual(raised.exception.headers.get("Retry-After"), "21")
authenticate_mock.assert_not_called()
self.assertEqual(session.commit_count, 0)
def test_login_returns_throttle_response_when_failure_crosses_limit(self) -> None:
"""Failed credentials return stable 429 response once lockout threshold is crossed."""
payload = auth_routes_module.AuthLoginRequest(username="admin", password="bad-password")
session = self._SessionStub()
with (
patch.object(
auth_routes_module,
"check_login_throttle",
return_value=auth_login_throttle_module.LoginThrottleStatus(
is_throttled=False,
retry_after_seconds=0,
),
),
patch.object(auth_routes_module, "authenticate_user", return_value=None),
patch.object(auth_routes_module, "record_failed_login_attempt", return_value=30),
):
with self.assertRaises(HTTPException) as raised:
auth_routes_module.login(
payload=payload,
request=self._request_stub(),
response=self._response_stub(),
session=session,
)
self.assertEqual(raised.exception.status_code, 429)
self.assertEqual(raised.exception.detail, auth_routes_module.LOGIN_THROTTLED_DETAIL)
self.assertEqual(raised.exception.headers.get("Retry-After"), "30")
self.assertEqual(session.commit_count, 0)
def test_login_clears_throttle_state_after_successful_authentication(self) -> None:
"""Successful login clears throttle state and commits issued session token."""
payload = auth_routes_module.AuthLoginRequest(username="admin", password="correct-password")
session = self._SessionStub()
fake_user = SimpleNamespace(
id=uuid.uuid4(),
username="admin",
role=UserRole.ADMIN,
)
fake_session = SimpleNamespace(
token="session-token",
expires_at=datetime.now(UTC),
)
with (
patch.object(
auth_routes_module,
"check_login_throttle",
return_value=auth_login_throttle_module.LoginThrottleStatus(
is_throttled=False,
retry_after_seconds=0,
),
),
patch.object(auth_routes_module, "authenticate_user", return_value=fake_user),
patch.object(auth_routes_module, "clear_login_throttle") as clear_mock,
patch.object(auth_routes_module, "issue_user_session", return_value=fake_session),
):
response = auth_routes_module.login(
payload=payload,
request=self._request_stub(),
response=self._response_stub(),
session=session,
)
self.assertEqual(response.access_token, "session-token")
self.assertEqual(response.user.username, "admin")
clear_mock.assert_called_once()
self.assertEqual(session.commit_count, 1)
def test_login_returns_503_when_throttle_backend_is_unavailable(self) -> None:
"""Throttle backend errors fail closed with a deterministic 503 login response."""
payload = auth_routes_module.AuthLoginRequest(username="admin", password="password")
session = self._SessionStub()
with patch.object(auth_routes_module, "check_login_throttle", side_effect=RuntimeError("redis down")):
with self.assertRaises(HTTPException) as raised:
auth_routes_module.login(
payload=payload,
request=self._request_stub(),
response=self._response_stub(),
session=session,
)
self.assertEqual(raised.exception.status_code, 503)
self.assertEqual(raised.exception.detail, auth_routes_module.LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL)
self.assertEqual(session.commit_count, 0)
class ProviderBaseUrlValidationTests(unittest.TestCase):
"""Verifies allowlist, scheme, and private-network SSRF protections."""

View File

@@ -29,6 +29,7 @@ def _install_main_import_stubs() -> dict[str, ModuleType | None]:
"app.core.config",
"app.db.base",
"app.services.app_settings",
"app.services.authentication",
"app.services.handwriting_style",
"app.services.storage",
"app.services.typesense_index",
@@ -139,6 +140,14 @@ def _install_main_import_stubs() -> dict[str, ModuleType | None]:
app_settings_stub.ensure_app_settings = ensure_app_settings
sys.modules["app.services.app_settings"] = app_settings_stub
authentication_stub = ModuleType("app.services.authentication")
def ensure_bootstrap_users() -> None:
"""No-op bootstrap user initializer for middleware scope tests."""
authentication_stub.ensure_bootstrap_users = ensure_bootstrap_users
sys.modules["app.services.authentication"] = authentication_stub
handwriting_style_stub = ModuleType("app.services.handwriting_style")
def ensure_handwriting_style_collection() -> None:

View File

@@ -6,7 +6,7 @@ This directory contains technical documentation for DMS.
- `../README.md` - project overview, setup, and quick operations
- `architecture-overview.md` - backend, frontend, and infrastructure architecture
- `api-contract.md` - API endpoint contract grouped by route module, including session auth, role and ownership scope, upload limits, and settings or processing-log security constraints
- `api-contract.md` - API endpoint contract grouped by route module, including session auth, login throttle responses, role and ownership scope, upload limits, and settings or processing-log security constraints
- `data-model-reference.md` - database entity definitions and lifecycle states
- `operations-and-configuration.md` - runtime operations, hardened compose defaults, DEV and LIVE security values, and persisted settings configuration behavior
- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, authenticated media delivery under session auth, processing-log timeline behavior, and settings helper-copy guidance

View File

@@ -13,10 +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.
- Clients send issued tokens as `Authorization: Bearer <token>`.
- 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.
- 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.
@@ -35,6 +37,10 @@ Ownership rules:
- `POST /auth/login`
- Body model: `AuthLoginRequest`
- Response model: `AuthLoginResponse`
- Additional responses:
- `401` for invalid credentials
- `429` for throttled login attempts, with stable message and `Retry-After` header
- `503` when the login rate-limiter backend is unavailable
- `GET /auth/me`
- Response model: `AuthSessionResponse`
- `POST /auth/logout`
@@ -56,9 +62,15 @@ Ownership rules:
- `GET /documents/tags`
- Query: `include_trashed`
- Response: `{ "tags": string[] }`
- Behavior:
- all document-assigned tags visible to caller scope are included
- predefined tags are role-filtered: `admin` receives full catalog, `user` receives only entries with `global_shared=true`
- `GET /documents/paths`
- Query: `include_trashed`
- Response: `{ "paths": string[] }`
- Behavior:
- all document-assigned logical paths visible to caller scope are included
- predefined paths are role-filtered: `admin` receives full catalog, `user` receives only entries with `global_shared=true`
- `GET /documents/types`
- Query: `include_trashed`
- Response: `{ "types": string[] }`

View File

@@ -52,7 +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 uses server-issued per-user session tokens persisted with `setRuntimeApiToken` and read by `getRuntimeApiToken`.
- 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,13 +45,18 @@ 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`
- optional `AUTH_BOOTSTRAP_USER_USERNAME`
- optional `AUTH_BOOTSTRAP_USER_PASSWORD`
- Frontend signs in through `/api/v1/auth/login` and stores issued session token in browser session storage.
- Login brute-force protection is enabled by default and keyed by username and source IP:
- `AUTH_LOGIN_FAILURE_LIMIT`
- `AUTH_LOGIN_FAILURE_WINDOW_SECONDS`
- `AUTH_LOGIN_LOCKOUT_BASE_SECONDS`
- `AUTH_LOGIN_LOCKOUT_MAX_SECONDS`
- Frontend signs in through `/api/v1/auth/login` and relies on browser session persistence for valid cookie-backed sessions.
## DEV And LIVE Configuration Matrix
@@ -67,6 +72,10 @@ Use `.env.example` as baseline. The table below documents user-managed settings
| `REDIS_URL` | `redis://:<password>@redis:6379/0` in isolated local network | `rediss://:<password>@redis.internal:6379/0` |
| `REDIS_SECURITY_MODE` | `compat` or `auto` | `strict` |
| `REDIS_TLS_MODE` | `allow_insecure` or `auto` | `required` |
| `AUTH_LOGIN_FAILURE_LIMIT` | default `5` | tune to identity-protection policy and support requirements |
| `AUTH_LOGIN_FAILURE_WINDOW_SECONDS` | default `900` | tune to identity-protection policy and support requirements |
| `AUTH_LOGIN_LOCKOUT_BASE_SECONDS` | default `30` | tune to identity-protection policy and support requirements |
| `AUTH_LOGIN_LOCKOUT_MAX_SECONDS` | default `900` | tune to identity-protection policy and support requirements |
| `PROVIDER_BASE_URL_ALLOW_HTTP` | `true` only when intentionally testing local HTTP provider endpoints | `false` |
| `PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK` | `true` only for trusted local development targets | `false` |
| `PROVIDER_BASE_URL_ALLOWLIST` | allow needed test hosts | explicit production allowlist, for example `["api.openai.com"]` |
@@ -89,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
@@ -99,6 +108,7 @@ Recommended LIVE pattern:
- legacy `enc-v1` payloads are read for backward compatibility
- new writes use `enc-v2`
- Processing logs default to metadata-only persistence.
- Login endpoint applies escalating temporary lockout on repeated failed credentials using Redis-backed subject keys for username and source IP.
- Markdown export enforces:
- max document count
- max total markdown bytes
@@ -109,7 +119,7 @@ Recommended LIVE pattern:
## Frontend Runtime
- Frontend no longer consumes `VITE_API_TOKEN`.
- Session token storage key is `dcm.access_token` in browser session storage.
- 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

@@ -47,6 +47,10 @@ services:
AUTH_BOOTSTRAP_ADMIN_PASSWORD: ${AUTH_BOOTSTRAP_ADMIN_PASSWORD:?AUTH_BOOTSTRAP_ADMIN_PASSWORD must be set}
AUTH_BOOTSTRAP_USER_USERNAME: ${AUTH_BOOTSTRAP_USER_USERNAME:-}
AUTH_BOOTSTRAP_USER_PASSWORD: ${AUTH_BOOTSTRAP_USER_PASSWORD:-}
AUTH_LOGIN_FAILURE_LIMIT: ${AUTH_LOGIN_FAILURE_LIMIT:-5}
AUTH_LOGIN_FAILURE_WINDOW_SECONDS: ${AUTH_LOGIN_FAILURE_WINDOW_SECONDS:-900}
AUTH_LOGIN_LOCKOUT_BASE_SECONDS: ${AUTH_LOGIN_LOCKOUT_BASE_SECONDS:-30}
AUTH_LOGIN_LOCKOUT_MAX_SECONDS: ${AUTH_LOGIN_LOCKOUT_MAX_SECONDS:-900}
APP_SETTINGS_ENCRYPTION_KEY: ${APP_SETTINGS_ENCRYPTION_KEY:?APP_SETTINGS_ENCRYPTION_KEY must be set}
PROVIDER_BASE_URL_ALLOWLIST: '${PROVIDER_BASE_URL_ALLOWLIST:-[]}'
PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-true}

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';
@@ -48,53 +46,23 @@ function toRequestUrl(input: RequestInfo | URL): string {
return input.url;
}
/**
* Creates a minimal session storage implementation for Node-based tests.
*/
function createMemorySessionStorage(): Storage {
const values = new Map<string, string>();
return {
get length(): number {
return values.size;
},
clear(): void {
values.clear();
},
getItem(key: string): string | null {
return values.has(key) ? values.get(key) ?? null : null;
},
key(index: number): string | null {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key: string): void {
values.delete(key);
},
setItem(key: string, value: string): void {
values.set(key, String(value));
},
};
}
/**
* Runs API helper tests for authenticated media and auth session workflows.
*/
async function runApiTests(): Promise<void> {
const originalFetch = globalThis.fetch;
const sessionStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
const globalWithDocument = globalThis as typeof globalThis & { document?: { cookie?: string } };
const originalDocument = globalWithDocument.document;
try {
Object.defineProperty(globalThis, 'sessionStorage', {
configurable: true,
writable: true,
value: createMemorySessionStorage(),
});
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;
@@ -113,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 session token readback to match persisted 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(
@@ -202,13 +169,12 @@ async function runApiTests(): Promise<void> {
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
} finally {
setRuntimeApiToken(null);
if (sessionStorageDescriptor) {
Object.defineProperty(globalThis, 'sessionStorage', sessionStorageDescriptor);
} else {
delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
}
globalThis.fetch = originalFetch;
if (originalDocument !== undefined) {
globalWithDocument.document = originalDocument;
} else {
delete globalWithDocument.document;
}
}
}

View File

@@ -36,90 +36,69 @@ function resolveApiBase(): string {
const API_BASE = resolveApiBase();
/**
* Session storage key used for per-user runtime token persistence.
* CSRF cookie contract used by authenticated requests.
*/
export const API_TOKEN_RUNTIME_STORAGE_KEY = 'dcm.access_token';
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 persisted for current browser session.
* Resolves the runtime CSRF token from browser cookie storage for API requests.
*/
export function getRuntimeApiToken(): string | undefined {
if (typeof globalThis.sessionStorage === 'undefined') {
return undefined;
}
try {
return normalizeBearerToken(globalThis.sessionStorage.getItem(API_TOKEN_RUNTIME_STORAGE_KEY));
} catch {
return undefined;
}
function resolveCsrfToken(): string | undefined {
return getCookieValue(CSRF_COOKIE_NAME);
}
/**
* Resolves bearer token from authenticated browser-session storage.
* 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 session storage.
*
* @param token Token value to persist for this browser session; clears persisted token when empty.
* Merges request headers and appends CSRF metadata for state-changing requests.
*/
export function setRuntimeApiToken(token: string | null | undefined): void {
if (typeof globalThis.sessionStorage === 'undefined') {
return;
}
try {
const normalized = normalizeBearerToken(token);
if (normalized) {
globalThis.sessionStorage.setItem(API_TOKEN_RUNTIME_STORAGE_KEY, normalized);
return;
}
globalThis.sessionStorage.removeItem(API_TOKEN_RUNTIME_STORAGE_KEY);
} catch {
return;
}
}
/**
* 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 } : {}),
});
}
@@ -183,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(),
@@ -220,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;
}
/**