From ec6a20ebd1a865baf0e9f43f4fe0d8d87375c1ef Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Mon, 2 Mar 2026 17:50:16 -0300 Subject: [PATCH] Stabilize auth cookies for proxied split-domain deployments --- .env.example | 5 ++++ backend/app/api/routes_auth.py | 44 ++++++++++++++++++++++++----- backend/app/core/config.py | 2 ++ doc/operations-and-configuration.md | 4 +++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 870e2e7..9a318b8 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,9 @@ AUTH_LOGIN_FAILURE_LIMIT=5 AUTH_LOGIN_FAILURE_WINDOW_SECONDS=900 AUTH_LOGIN_LOCKOUT_BASE_SECONDS=30 AUTH_LOGIN_LOCKOUT_MAX_SECONDS=900 +# Optional cookie controls for split frontend/api hosts: +# AUTH_COOKIE_DOMAIN=docs.lan +# AUTH_COOKIE_SAMESITE=auto APP_SETTINGS_ENCRYPTION_KEY=ChangeMe-Settings-Encryption-Key TYPESENSE_API_KEY=ChangeMe-Typesense-Key @@ -50,6 +53,8 @@ VITE_ALLOWED_HOSTS= # REDIS_URL=rediss://:@redis.example.internal:6379/0 # REDIS_SECURITY_MODE=strict # REDIS_TLS_MODE=required +# AUTH_COOKIE_DOMAIN=example.com +# AUTH_COOKIE_SAMESITE=none # PROVIDER_BASE_URL_ALLOW_HTTP=false # PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK=false # PROVIDER_BASE_URL_ALLOWLIST=["api.openai.com"] diff --git a/backend/app/api/routes_auth.py b/backend/app/api/routes_auth.py index 4aaaced..dadb1fa 100644 --- a/backend/app/api/routes_auth.py +++ b/backend/app/api/routes_auth.py @@ -3,6 +3,7 @@ import logging import secrets from datetime import UTC, datetime +from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.orm import Session @@ -13,6 +14,7 @@ from app.api.auth import ( CSRF_COOKIE_NAME, require_user_or_admin, ) +from app.core.config import get_settings from app.db.base import get_session from app.schemas.auth import ( AuthLoginRequest, @@ -66,9 +68,30 @@ def _is_https_request(request: Request) -> bool: 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" + request_scheme = str(getattr(request_url, "scheme", "")).lower() if request_url is not None else "" + if request_scheme == "https": + return True + + parsed_public_base_url = urlparse(get_settings().public_base_url.strip()) + return parsed_public_base_url.scheme.lower() == "https" + + +def _resolve_cookie_domain() -> str | None: + """Returns optional cookie domain override for multi-subdomain deployments.""" + + configured_domain = get_settings().auth_cookie_domain.strip().lower().lstrip(".") + if not configured_domain or "." not in configured_domain: + return None + return configured_domain + + +def _resolve_cookie_samesite(secure_cookie: bool) -> str: + """Returns cookie SameSite mode with secure-aware defaults for browser compatibility.""" + + configured_mode = get_settings().auth_cookie_samesite.strip().lower() + if configured_mode in {"strict", "lax", "none"}: + return configured_mode + return "none" if secure_cookie else "lax" def _session_cookie_ttl_seconds(expires_at: datetime) -> int: @@ -85,14 +108,17 @@ def _set_session_cookie(response: Response, session_token: str, *, expires_at: d if response is None or not hasattr(response, "set_cookie"): return expires_seconds = _session_cookie_ttl_seconds(expires_at) + cookie_domain = _resolve_cookie_domain() + same_site_mode = _resolve_cookie_samesite(secure) response.set_cookie( SESSION_COOKIE_NAME, value=session_token, max_age=expires_seconds, httponly=True, secure=secure, - samesite="strict", + samesite=same_site_mode, path="/", + domain=cookie_domain, ) @@ -107,14 +133,17 @@ def _set_csrf_cookie( if response is None or not hasattr(response, "set_cookie"): return + cookie_domain = _resolve_cookie_domain() + same_site_mode = _resolve_cookie_samesite(secure) response.set_cookie( CSRF_COOKIE_NAME, value=csrf_token, max_age=_session_cookie_ttl_seconds(expires_at), httponly=False, secure=secure, - samesite="strict", + samesite=same_site_mode, path="/", + domain=cookie_domain, ) @@ -123,8 +152,9 @@ def _clear_session_cookies(response: Response) -> None: 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="/") + cookie_domain = _resolve_cookie_domain() + response.delete_cookie(SESSION_COOKIE_NAME, path="/", domain=cookie_domain) + response.delete_cookie(CSRF_COOKIE_NAME, path="/", domain=cookie_domain) @router.post("/login", response_model=AuthLoginResponse) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2a1d7ab..830c239 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -33,6 +33,8 @@ class Settings(BaseSettings): auth_login_failure_window_seconds: int = 900 auth_login_lockout_base_seconds: int = 30 auth_login_lockout_max_seconds: int = 900 + auth_cookie_domain: str = "" + auth_cookie_samesite: str = "auto" storage_root: Path = Path("/data/storage") upload_chunk_size: int = 4 * 1024 * 1024 max_upload_files_per_request: int = 50 diff --git a/doc/operations-and-configuration.md b/doc/operations-and-configuration.md index e739223..e439a6a 100644 --- a/doc/operations-and-configuration.md +++ b/doc/operations-and-configuration.md @@ -81,6 +81,8 @@ Use `.env.example` as baseline. The table below documents user-managed settings | `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 | +| `AUTH_COOKIE_DOMAIN` | empty (host-only cookies) | parent frontend/API domain for split hosts, for example `docs.lan` | +| `AUTH_COOKIE_SAMESITE` | `auto` | `none` for cross-origin frontend/API deployments, `lax` or `strict` for same-origin | | `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"]` | @@ -133,6 +135,8 @@ Recommended LIVE pattern: - hostnames extracted from `CORS_ORIGINS` - optional explicit hostnames from `VITE_ALLOWED_HOSTS` - `VITE_ALLOWED_HOSTS` only affects development mode where Vite is running. +- API auth cookies support optional domain and SameSite configuration through `AUTH_COOKIE_DOMAIN` and `AUTH_COOKIE_SAMESITE`. +- HTTPS cookie security detection falls back to `PUBLIC_BASE_URL` scheme when proxy headers are missing. - 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.