Stabilize auth cookies for proxied split-domain deployments

This commit is contained in:
2026-03-02 17:50:16 -03:00
parent 83d6a4f367
commit ec6a20ebd1
4 changed files with 48 additions and 7 deletions

View File

@@ -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)