Stabilize auth cookies for proxied split-domain deployments
This commit is contained in:
@@ -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://:<strong-password>@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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user