Compare commits

..

2 Commits

Author SHA1 Message Date
1cd7d6541d update dockerfile 2026-03-02 17:53:26 -03:00
ec6a20ebd1 Stabilize auth cookies for proxied split-domain deployments 2026-03-02 17:50:16 -03:00
5 changed files with 49 additions and 8 deletions

View File

@@ -23,6 +23,9 @@ AUTH_LOGIN_FAILURE_LIMIT=5
AUTH_LOGIN_FAILURE_WINDOW_SECONDS=900 AUTH_LOGIN_FAILURE_WINDOW_SECONDS=900
AUTH_LOGIN_LOCKOUT_BASE_SECONDS=30 AUTH_LOGIN_LOCKOUT_BASE_SECONDS=30
AUTH_LOGIN_LOCKOUT_MAX_SECONDS=900 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 APP_SETTINGS_ENCRYPTION_KEY=ChangeMe-Settings-Encryption-Key
TYPESENSE_API_KEY=ChangeMe-Typesense-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_URL=rediss://:<strong-password>@redis.example.internal:6379/0
# REDIS_SECURITY_MODE=strict # REDIS_SECURITY_MODE=strict
# REDIS_TLS_MODE=required # REDIS_TLS_MODE=required
# AUTH_COOKIE_DOMAIN=example.com
# AUTH_COOKIE_SAMESITE=none
# PROVIDER_BASE_URL_ALLOW_HTTP=false # PROVIDER_BASE_URL_ALLOW_HTTP=false
# PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK=false # PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK=false
# PROVIDER_BASE_URL_ALLOWLIST=["api.openai.com"] # PROVIDER_BASE_URL_ALLOWLIST=["api.openai.com"]

View File

@@ -3,6 +3,7 @@
import logging import logging
import secrets import secrets
from datetime import UTC, datetime from datetime import UTC, datetime
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -13,6 +14,7 @@ from app.api.auth import (
CSRF_COOKIE_NAME, CSRF_COOKIE_NAME,
require_user_or_admin, require_user_or_admin,
) )
from app.core.config import get_settings
from app.db.base import get_session from app.db.base import get_session
from app.schemas.auth import ( from app.schemas.auth import (
AuthLoginRequest, AuthLoginRequest,
@@ -66,9 +68,30 @@ def _is_https_request(request: Request) -> bool:
if forwarded_protocol: if forwarded_protocol:
return forwarded_protocol == "https" return forwarded_protocol == "https"
request_url = getattr(request, "url", None) request_url = getattr(request, "url", None)
if request_url is None: request_scheme = str(getattr(request_url, "scheme", "")).lower() if request_url is not None else ""
return False if request_scheme == "https":
return str(getattr(request_url, "scheme", "")).lower() == "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: 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"): if response is None or not hasattr(response, "set_cookie"):
return return
expires_seconds = _session_cookie_ttl_seconds(expires_at) expires_seconds = _session_cookie_ttl_seconds(expires_at)
cookie_domain = _resolve_cookie_domain()
same_site_mode = _resolve_cookie_samesite(secure)
response.set_cookie( response.set_cookie(
SESSION_COOKIE_NAME, SESSION_COOKIE_NAME,
value=session_token, value=session_token,
max_age=expires_seconds, max_age=expires_seconds,
httponly=True, httponly=True,
secure=secure, secure=secure,
samesite="strict", samesite=same_site_mode,
path="/", path="/",
domain=cookie_domain,
) )
@@ -107,14 +133,17 @@ def _set_csrf_cookie(
if response is None or not hasattr(response, "set_cookie"): if response is None or not hasattr(response, "set_cookie"):
return return
cookie_domain = _resolve_cookie_domain()
same_site_mode = _resolve_cookie_samesite(secure)
response.set_cookie( response.set_cookie(
CSRF_COOKIE_NAME, CSRF_COOKIE_NAME,
value=csrf_token, value=csrf_token,
max_age=_session_cookie_ttl_seconds(expires_at), max_age=_session_cookie_ttl_seconds(expires_at),
httponly=False, httponly=False,
secure=secure, secure=secure,
samesite="strict", samesite=same_site_mode,
path="/", 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"): if response is None or not hasattr(response, "delete_cookie"):
return return
response.delete_cookie(SESSION_COOKIE_NAME, path="/") cookie_domain = _resolve_cookie_domain()
response.delete_cookie(CSRF_COOKIE_NAME, path="/") 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) @router.post("/login", response_model=AuthLoginResponse)

View File

@@ -33,6 +33,8 @@ class Settings(BaseSettings):
auth_login_failure_window_seconds: int = 900 auth_login_failure_window_seconds: int = 900
auth_login_lockout_base_seconds: int = 30 auth_login_lockout_base_seconds: int = 30
auth_login_lockout_max_seconds: int = 900 auth_login_lockout_max_seconds: int = 900
auth_cookie_domain: str = ""
auth_cookie_samesite: str = "auto"
storage_root: Path = Path("/data/storage") storage_root: Path = Path("/data/storage")
upload_chunk_size: int = 4 * 1024 * 1024 upload_chunk_size: int = 4 * 1024 * 1024
max_upload_files_per_request: int = 50 max_upload_files_per_request: int = 50

View File

@@ -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_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_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_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_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_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"]` | | `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` - hostnames extracted from `CORS_ORIGINS`
- optional explicit hostnames from `VITE_ALLOWED_HOSTS` - optional explicit hostnames from `VITE_ALLOWED_HOSTS`
- `VITE_ALLOWED_HOSTS` only affects development mode where Vite is running. - `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. - 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. - Protected media and file download flows still use authenticated fetch plus blob/object URL handling.

View File

@@ -1,4 +1,4 @@
FROM node:22-slim AS base FROM node:20-slim AS base
WORKDIR /app WORKDIR /app