mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(auth): key the compose login lockout by client, not by nginx Failed logins are counted per client IP, and the Gateway honors X-Real-IP only from a peer listed in AUTH_TRUSTED_PROXIES. The compose files never set it, and every browser request reaches the Gateway from the nginx container, so all logins shared nginx's address: five wrong passwords from anyone locked every user out for five minutes. AUTH_TRUSTED_PROXIES now also accepts hostnames, resolved off the event loop and cached for 10 seconds (failed lookups included, and only when X-Real-IP is present), and both compose files default it to the bundled nginx service, whose address Docker assigns. nginx overwrites X-Real-IP with $remote_addr on every Gateway route; a test pins that for the three maintained configs. * docs(changelog): reference #6519 * fix(auth): share one trusted-proxy lookup across concurrent cache misses A burst of setup-status / login requests on an expired hostname entry started one getaddrinfo per request while DNS was slow, occupying the default executor that password verification also uses. The cache check and an in-flight task registration now happen without a yield between them; later callers join that task through asyncio.shield, so a cancelled request cannot cancel the lookup the others joined, and the task caches its own result. The compose default keeps `:-` on purpose: every browser request arrives through nginx, so an empty AUTH_TRUSTED_PROXIES would only bring back the shared lockout. The compose comment and .env.example say so, and the rendered-compose test now covers the explicitly empty value. * fix(docker): keep a .env AUTH_TRUSTED_PROXIES under make docker-start docker.sh start runs Compose from docker/ without --env-file, so the dev compose file's ${AUTH_TRUSTED_PROXIES:-nginx} interpolated from the shell only, and the resolved environment entry outranked the operator's value loaded through env_file. Export it from .env alongside the proxy variables (a shell export still wins), and strip a CRLF before the quotes. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
1162 lines
49 KiB
Python
1162 lines
49 KiB
Python
"""Authentication endpoints."""
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import re
|
||
import socket
|
||
import time
|
||
import urllib.parse
|
||
from ipaddress import ip_address, ip_network
|
||
|
||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
|
||
from fastapi.security import OAuth2PasswordRequestForm
|
||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||
from starlette.responses import RedirectResponse
|
||
|
||
from app.gateway.auth import (
|
||
UserResponse,
|
||
create_access_token,
|
||
login_throttle,
|
||
)
|
||
from app.gateway.auth.config import get_auth_config
|
||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||
from app.gateway.auth.oidc import OIDCError, OIDCService
|
||
from app.gateway.auth.oidc_state import (
|
||
OIDCStatePayload,
|
||
compute_code_challenge,
|
||
delete_state_cookie,
|
||
generate_code_verifier,
|
||
generate_nonce,
|
||
generate_oidc_state,
|
||
get_state_cookie,
|
||
set_state_cookie,
|
||
)
|
||
from app.gateway.auth.pat import PAT_MAX_NAME_LENGTH
|
||
from app.gateway.auth.session_cookie import ACCESS_TOKEN_COOKIE_NAME, SESSION_PERSISTENCE_COOKIE_NAME, set_session_cookie
|
||
from app.gateway.auth.session_cookie_state import SKIP_AUTH_CSRF_COOKIE_STATE_ATTR
|
||
from app.gateway.auth.user_provisioning import get_or_provision_oidc_user
|
||
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, _request_origin, auth_csrf_cookie_settings, generate_csrf_token, is_secure_request
|
||
from app.gateway.deps import get_current_user_from_request, get_local_provider
|
||
from app.gateway.utils import constant_time_equals
|
||
from deerflow.config.auth_config import OIDCProviderConfig
|
||
from deerflow.persistence.login_throttle import LoginThrottleStore
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||
|
||
|
||
# ── Request/Response Models ──────────────────────────────────────────────
|
||
|
||
|
||
class LoginResponse(BaseModel):
|
||
"""Response model for login — token only lives in HttpOnly cookie."""
|
||
|
||
expires_in: int # seconds
|
||
needs_setup: bool = False
|
||
|
||
|
||
# Top common-password blocklist. Drawn from the public SecLists "10k worst
|
||
# passwords" set, lowercased + length>=8 only (shorter ones already fail
|
||
# the min_length check). Kept tight on purpose: this is the **lower bound**
|
||
# defense, not a full HIBP / passlib check, and runs in-process per request.
|
||
_COMMON_PASSWORDS: frozenset[str] = frozenset(
|
||
{
|
||
"password",
|
||
"password1",
|
||
"password12",
|
||
"password123",
|
||
"password1234",
|
||
"12345678",
|
||
"123456789",
|
||
"1234567890",
|
||
"qwerty12",
|
||
"qwertyui",
|
||
"qwerty123",
|
||
"abc12345",
|
||
"abcd1234",
|
||
"iloveyou",
|
||
"letmein1",
|
||
"welcome1",
|
||
"welcome123",
|
||
"admin123",
|
||
"administrator",
|
||
"passw0rd",
|
||
"p@ssw0rd",
|
||
"monkey12",
|
||
"trustno1",
|
||
"sunshine",
|
||
"princess",
|
||
"football",
|
||
"baseball",
|
||
"superman",
|
||
"batman123",
|
||
"starwars",
|
||
"dragon123",
|
||
"master123",
|
||
"shadow12",
|
||
"michael1",
|
||
"jennifer",
|
||
"computer",
|
||
}
|
||
)
|
||
|
||
|
||
def _password_is_common(password: str) -> bool:
|
||
"""Case-insensitive blocklist check.
|
||
|
||
Lowercases the input so trivial mutations like ``Password`` /
|
||
``PASSWORD`` are also rejected. Does not normalize digit substitutions
|
||
(``p@ssw0rd`` is included as a literal entry instead) — keeping the
|
||
rule cheap and predictable.
|
||
"""
|
||
return password.lower() in _COMMON_PASSWORDS
|
||
|
||
|
||
def _validate_strong_password(value: str) -> str:
|
||
"""Pydantic field-validator body shared by Register + ChangePassword.
|
||
|
||
Constraint = function, not type-level mixin. The two request models
|
||
have no "is-a" relationship; they only share the password-strength
|
||
rule. Lifting it into a free function lets each model bind it via
|
||
``@field_validator(field_name)`` without inheritance gymnastics.
|
||
"""
|
||
if _password_is_common(value):
|
||
raise ValueError("Password is too common; choose a stronger password.")
|
||
return value
|
||
|
||
|
||
class RegisterRequest(BaseModel):
|
||
"""Request model for user registration."""
|
||
|
||
email: EmailStr
|
||
password: str = Field(..., min_length=8)
|
||
remember_me: bool = True
|
||
|
||
_strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
|
||
|
||
|
||
class ChangePasswordRequest(BaseModel):
|
||
"""Request model for password change (also handles setup flow)."""
|
||
|
||
current_password: str
|
||
new_password: str = Field(..., min_length=8)
|
||
new_email: EmailStr | None = None
|
||
remember_me: bool | None = None
|
||
|
||
_strong_password = field_validator("new_password")(classmethod(lambda cls, v: _validate_strong_password(v)))
|
||
|
||
|
||
class MessageResponse(BaseModel):
|
||
"""Generic message response."""
|
||
|
||
message: str
|
||
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _set_session_cookie(response: Response, token: str, request: Request, *, remember_me: bool | None = None) -> None:
|
||
"""Set the access_token HttpOnly cookie on the response."""
|
||
set_session_cookie(response, request, token, remember_me=remember_me)
|
||
|
||
|
||
# ── Rate Limiting ────────────────────────────────────────────────────────
|
||
# Failed logins are counted per client IP in a ``LoginThrottleStore``
|
||
# (``deerflow.persistence.login_throttle``). The store is resolved once per
|
||
# process by ``app.gateway.auth.login_throttle`` — the Gateway lifespan
|
||
# installs it from the startup config and the persistence engine, a bare app
|
||
# resolves it lazily on the first throttle call — and ``auth.local.
|
||
# throttle_storage`` picks the implementation: the shared ``login_throttle``
|
||
# table whenever an application database exists (every replica sharing that
|
||
# database enforces one lockout per IP), otherwise the historical in-process
|
||
# counter (per process: N replicas give an attacker N × max_login_attempts
|
||
# guesses; ``deps._validate_login_throttle_storage`` warns about that).
|
||
#
|
||
# The policy values are operator-configurable via auth.local.max_login_attempts /
|
||
# auth.local.lockout_seconds (read live per call, matching _local_registration_enabled,
|
||
# so a config reload applies to the next login without a Gateway restart). The
|
||
# no-config.yaml fallback is the LocalAuthConfig model defaults — a single source
|
||
# of truth, not a second copy of the numbers.
|
||
#
|
||
# A lock's stored duration always matches the policy the lock was last
|
||
# evaluated under (its creation counts as an evaluation, and every check that
|
||
# leaves the lock active commits the then-current duration, decreases
|
||
# included): a lowered lockout_seconds releases an active lock early, a raised
|
||
# one extends it — and a sentence that already served the last-evaluated
|
||
# duration is never resurrected. The stores implement that contract
|
||
# (``deerflow/persistence/login_throttle/base.py``); this module only sequences
|
||
# the calls and keeps the config read off the event loop.
|
||
|
||
|
||
async def _login_throttle_store() -> LoginThrottleStore:
|
||
"""The process-wide store, resolving it off the loop on first use in a bare app."""
|
||
store = login_throttle.installed_login_throttle_store()
|
||
if store is None:
|
||
# Resolution reads config.yaml (stat + hash); never on the loop.
|
||
store = await asyncio.to_thread(login_throttle.resolve_and_install_from_live_config)
|
||
return store
|
||
|
||
|
||
def _login_throttle_policy() -> tuple[int, float]:
|
||
"""(max_login_attempts, lockout_seconds) from auth.local config, read live.
|
||
|
||
Only ``FileNotFoundError`` falls back to the model defaults, matching
|
||
``_local_registration_enabled``: ``config.yaml`` is absent in bare-app
|
||
contexts that never load it (tests build the gateway without one), and the
|
||
throttle must keep its pre-config-era behavior there. A malformed config
|
||
propagates instead — like every other config consumer, and so an operator
|
||
who tightened the policy never silently gets the more permissive defaults.
|
||
|
||
Callers on request paths resolve this at most once per helper invocation;
|
||
``get_app_config`` re-hashes the config file on every call, and the login
|
||
endpoint is unauthenticated.
|
||
"""
|
||
from deerflow.config.app_config import get_app_config
|
||
from deerflow.config.auth_config import LocalAuthConfig
|
||
|
||
try:
|
||
local = get_app_config().auth.local
|
||
except FileNotFoundError:
|
||
local = LocalAuthConfig()
|
||
return local.max_login_attempts, local.lockout_seconds
|
||
|
||
|
||
# Hostname entries (e.g. the compose service ``nginx``) are re-resolved at most
|
||
# this often: a restarted proxy container comes back on a new address, and
|
||
# nginx's own resolver uses the same validity (docker/nginx/nginx.conf).
|
||
_TRUSTED_PROXY_HOST_TTL_SECONDS = 10.0
|
||
_HOSTNAME_LABEL = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?")
|
||
# hostname -> (monotonic expiry, resolved addresses). Failed lookups are cached
|
||
# as an empty set too, so unauthenticated requests cannot drive a lookup storm.
|
||
_trusted_proxy_host_cache: dict[str, tuple[float, frozenset]] = {}
|
||
# hostname -> the lookup in progress. Concurrent cache misses (a burst of
|
||
# setup-status / login requests when the entry expires) join it, so a refresh
|
||
# costs one resolver call however many requests arrive while DNS is slow.
|
||
_trusted_proxy_host_inflight: dict[str, asyncio.Task[frozenset]] = {}
|
||
|
||
|
||
def _is_hostname(entry: str) -> bool:
|
||
"""RFC 1123 hostname whose last label is not numeric (so ``10.0.0`` is not one)."""
|
||
labels = entry.split(".")
|
||
return len(entry) <= 253 and all(_HOSTNAME_LABEL.fullmatch(label) for label in labels) and not labels[-1].isdigit()
|
||
|
||
|
||
def _trusted_proxies() -> tuple[list, list[str]]:
|
||
"""Parse ``AUTH_TRUSTED_PROXIES`` into (ip_network objects, hostnames).
|
||
|
||
Comma-separated CIDR, single-IP or hostname entries. Empty / unset = no
|
||
proxy is trusted (direct mode). A hostname stands for every address it
|
||
resolves to, so a proxy without a fixed address (the compose ``nginx``
|
||
service) can still be named. Invalid entries are skipped with a logger
|
||
warning. The variable is read live so env-var overrides take effect
|
||
immediately and tests can ``monkeypatch.setenv``; only the addresses a
|
||
hostname resolves to are cached (``_trusted_proxy_host_addresses``).
|
||
"""
|
||
raw = os.getenv("AUTH_TRUSTED_PROXIES", "").strip()
|
||
if not raw:
|
||
return [], []
|
||
nets = []
|
||
hostnames = []
|
||
for entry in raw.split(","):
|
||
entry = entry.strip()
|
||
if not entry:
|
||
continue
|
||
try:
|
||
nets.append(ip_network(entry, strict=False))
|
||
except ValueError:
|
||
if _is_hostname(entry):
|
||
hostnames.append(entry)
|
||
else:
|
||
logger.warning("AUTH_TRUSTED_PROXIES: ignoring invalid entry %r", entry)
|
||
return nets, hostnames
|
||
|
||
|
||
async def _resolve_trusted_proxy_host(hostname: str) -> frozenset:
|
||
"""Resolve ``hostname`` in the loop's executor and cache the result (or the failure)."""
|
||
try:
|
||
infos = await asyncio.get_running_loop().getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||
addresses = frozenset(ip_address(info[4][0]) for info in infos)
|
||
except (OSError, ValueError) as exc:
|
||
logger.warning("AUTH_TRUSTED_PROXIES: could not resolve %r (%s); not trusting it for %.0fs", hostname, exc, _TRUSTED_PROXY_HOST_TTL_SECONDS)
|
||
addresses = frozenset()
|
||
_trusted_proxy_host_cache[hostname] = (time.monotonic() + _TRUSTED_PROXY_HOST_TTL_SECONDS, addresses)
|
||
return addresses
|
||
|
||
|
||
async def _trusted_proxy_host_addresses(hostname: str) -> frozenset:
|
||
"""Addresses ``hostname`` resolves to, cached for ``_TRUSTED_PROXY_HOST_TTL_SECONDS``.
|
||
|
||
The lookup runs in the loop's executor (``getaddrinfo`` blocks). A failed
|
||
lookup trusts nothing until the entry expires, which is the direct-mode
|
||
behavior, not an error for the request. One lookup per hostname runs at a
|
||
time: the cache check and the in-flight registration happen without a
|
||
yield in between, and later callers join that task. Callers wait through
|
||
``asyncio.shield``, so one cancelled request (a client disconnect) cannot
|
||
cancel the lookup the others joined; the task caches its own result, so
|
||
it is kept even if every caller went away.
|
||
"""
|
||
cached = _trusted_proxy_host_cache.get(hostname)
|
||
if cached is not None and time.monotonic() < cached[0]:
|
||
return cached[1]
|
||
task = _trusted_proxy_host_inflight.get(hostname)
|
||
if task is None:
|
||
task = asyncio.get_running_loop().create_task(_resolve_trusted_proxy_host(hostname))
|
||
_trusted_proxy_host_inflight[hostname] = task
|
||
|
||
def _release(done: asyncio.Task[frozenset]) -> None:
|
||
if _trusted_proxy_host_inflight.get(hostname) is done:
|
||
del _trusted_proxy_host_inflight[hostname]
|
||
|
||
task.add_done_callback(_release)
|
||
return await asyncio.shield(task)
|
||
|
||
|
||
async def _is_trusted_proxy(peer_ip) -> bool:
|
||
nets, hostnames = _trusted_proxies()
|
||
if any(peer_ip in net for net in nets):
|
||
return True
|
||
for hostname in hostnames:
|
||
if peer_ip in await _trusted_proxy_host_addresses(hostname):
|
||
return True
|
||
return False
|
||
|
||
|
||
async def _get_client_ip(request: Request) -> str:
|
||
"""Extract the real client IP for rate limiting.
|
||
|
||
Trust model:
|
||
|
||
- The TCP peer (``request.client.host``) is always the baseline. It is
|
||
whatever the kernel reports as the connecting socket — unforgeable
|
||
by the client itself.
|
||
- ``X-Real-IP`` is **only** honored if the TCP peer is in the
|
||
``AUTH_TRUSTED_PROXIES`` allowlist (set via env var, comma-separated
|
||
CIDR, single IPs or hostnames). When set, the gateway is assumed to be
|
||
behind a reverse proxy (nginx, Cloudflare, ALB, …) that overwrites
|
||
``X-Real-IP`` with the original client address. The compose files
|
||
name their bundled ``nginx`` service here by default.
|
||
- With no ``AUTH_TRUSTED_PROXIES`` set, ``X-Real-IP`` is silently
|
||
ignored — closing the bypass where any client could rotate the
|
||
header to dodge per-IP rate limits in dev / direct-gateway mode.
|
||
|
||
``X-Forwarded-For`` is intentionally NOT used because it is naturally
|
||
client-controlled at the *first* hop and the trust chain is harder to
|
||
audit per-request. A request without ``X-Real-IP`` never resolves a
|
||
hostname entry.
|
||
"""
|
||
peer_host = request.client.host if request.client else None
|
||
real_ip = request.headers.get("x-real-ip", "").strip()
|
||
|
||
if peer_host and real_ip:
|
||
try:
|
||
peer_ip = ip_address(peer_host)
|
||
except ValueError:
|
||
# peer_host wasn't a parseable IP (e.g. "unknown") — fall through
|
||
peer_ip = None
|
||
if peer_ip is not None and await _is_trusted_proxy(peer_ip):
|
||
return real_ip
|
||
|
||
return peer_host or "unknown"
|
||
|
||
|
||
async def _check_rate_limit(ip: str) -> None:
|
||
"""Raise 429 if the IP is currently locked out.
|
||
|
||
The policy is handed to the store as an async callable and resolved
|
||
lazily, inside the store's single session: a clean IP (no failed attempts
|
||
recorded — the overwhelming majority of logins) never pays the config
|
||
read, and ``get_app_config`` re-hashes config.yaml on every call while
|
||
this endpoint is unauthenticated. A recorded IP — including an
|
||
already-locked attacker flooding the endpoint — resolves it exactly once,
|
||
off the event loop via ``asyncio.to_thread`` (the stat + hash must not
|
||
block the loop), and the SQL store serves the probe, the resolution and
|
||
the decision from one connection-pool checkout instead of two.
|
||
|
||
The policy read is a yield point: another request for the same IP may
|
||
delete or replace the record meanwhile, so the store decides on a fresh
|
||
read taken after it and guards its own mutations against that snapshot —
|
||
a record replaced mid-flight (e.g. a successful login followed by a new
|
||
failure) is never clobbered by a stale decision.
|
||
"""
|
||
store = await _login_throttle_store()
|
||
|
||
async def policy() -> tuple[int, float]:
|
||
return await asyncio.to_thread(_login_throttle_policy)
|
||
|
||
remaining = await store.check(ip, policy=policy, now=time.time())
|
||
if remaining > 0.0:
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail="Too many login attempts. Try again later.",
|
||
)
|
||
|
||
|
||
async def _record_login_failure(ip: str) -> None:
|
||
"""Record a failed login attempt for the given IP.
|
||
|
||
Policy resolution runs off the event loop (see ``_check_rate_limit``):
|
||
this is the first config read for a previously clean IP, and the login
|
||
endpoint is unauthenticated.
|
||
"""
|
||
store = await _login_throttle_store()
|
||
try:
|
||
max_attempts, lockout_seconds = await asyncio.to_thread(_login_throttle_policy)
|
||
except Exception:
|
||
# A malformed config keeps failing loudly, but dropping the failure
|
||
# here would leave the IP clean — and a clean IP skips the config
|
||
# read in _check_rate_limit, so every subsequent wrong password would
|
||
# reach authenticate() again: unlimited password verification for as
|
||
# long as the file stays broken. Count under the model defaults so
|
||
# the throttle fails closed (the next check reads the broken config
|
||
# before authenticate), then re-raise.
|
||
from deerflow.config.auth_config import LocalAuthConfig
|
||
|
||
fallback = LocalAuthConfig()
|
||
await store.record_failure(ip, max_attempts=fallback.max_login_attempts, lockout_seconds=fallback.lockout_seconds, now=time.time())
|
||
raise
|
||
await store.record_failure(ip, max_attempts=max_attempts, lockout_seconds=lockout_seconds, now=time.time())
|
||
|
||
|
||
async def _record_login_success(ip: str) -> None:
|
||
"""Clear the failure counter for the given IP on successful login."""
|
||
store = await _login_throttle_store()
|
||
await store.reset(ip)
|
||
|
||
|
||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
@router.post("/login/local", response_model=LoginResponse)
|
||
async def login_local(
|
||
request: Request,
|
||
response: Response,
|
||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||
remember_me: bool = Form(default=True),
|
||
):
|
||
"""Local email/password login."""
|
||
client_ip = await _get_client_ip(request)
|
||
await _check_rate_limit(client_ip)
|
||
|
||
user = await get_local_provider().authenticate({"email": form_data.username, "password": form_data.password})
|
||
|
||
if user is None:
|
||
await _record_login_failure(client_ip)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Incorrect email or password").model_dump(),
|
||
)
|
||
|
||
await _record_login_success(client_ip)
|
||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||
_set_session_cookie(response, token, request, remember_me=remember_me)
|
||
|
||
return LoginResponse(
|
||
expires_in=get_auth_config().token_expiry_days * 24 * 3600,
|
||
needs_setup=user.needs_setup,
|
||
)
|
||
|
||
|
||
def _local_registration_enabled() -> bool:
|
||
"""Whether visitors may self-register a local account.
|
||
|
||
Local registration bypasses the OIDC provisioning policy entirely
|
||
(allowed_email_domains, require_verified_email, auto_create_users are only
|
||
enforced in the SSO callback), so SSO-provisioned deployments need a way to
|
||
close this path.
|
||
|
||
``config.yaml`` is absent in bare-app contexts that never load it (tests build the
|
||
gateway without one). Registration was unconditionally open before this gate existed,
|
||
so an absent config file falls back to that same default rather than turning these two
|
||
endpoints into a hard dependency on the file. Only ``FileNotFoundError`` is caught:
|
||
a malformed config must not silently re-open a closed deployment, so it propagates.
|
||
|
||
``/register`` reads this fresh on every request (``get_app_config`` reloads on file
|
||
change); ``/setup-status`` may serve it up to 60s stale via its per-IP result cache.
|
||
"""
|
||
from deerflow.config.app_config import get_app_config
|
||
|
||
try:
|
||
return get_app_config().auth.local.allow_registration
|
||
except FileNotFoundError:
|
||
return True
|
||
|
||
|
||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||
async def register(request: Request, response: Response, body: RegisterRequest):
|
||
"""Register a new user account (always 'user' role).
|
||
|
||
The first admin is created explicitly through /initialize. This endpoint creates regular users.
|
||
Auto-login by setting the session cookie.
|
||
|
||
Returns 403 when ``auth.local.allow_registration`` is false.
|
||
"""
|
||
if not _local_registration_enabled():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail=AuthErrorResponse(code=AuthErrorCode.REGISTRATION_DISABLED, message="Self-registration is disabled on this deployment").model_dump(),
|
||
)
|
||
|
||
try:
|
||
user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="user")
|
||
except ValueError:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already registered").model_dump(),
|
||
)
|
||
|
||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||
_set_session_cookie(response, token, request, remember_me=body.remember_me)
|
||
|
||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, oauth_provider=user.oauth_provider)
|
||
|
||
|
||
@router.post("/logout", response_model=MessageResponse)
|
||
async def logout(request: Request, response: Response):
|
||
"""Logout current user by clearing the cookie."""
|
||
is_https = is_secure_request(request)
|
||
response.delete_cookie(key=ACCESS_TOKEN_COOKIE_NAME, secure=is_https, samesite="lax")
|
||
response.delete_cookie(key=CSRF_COOKIE_NAME, secure=is_https, samesite="strict")
|
||
response.delete_cookie(key=SESSION_PERSISTENCE_COOKIE_NAME, secure=is_https, samesite="lax")
|
||
setattr(request.state, SKIP_AUTH_CSRF_COOKIE_STATE_ATTR, True)
|
||
return MessageResponse(message="Successfully logged out")
|
||
|
||
|
||
@router.post("/change-password", response_model=MessageResponse)
|
||
async def change_password(request: Request, response: Response, body: ChangePasswordRequest):
|
||
"""Change password for the currently authenticated user.
|
||
|
||
Also handles the first-boot setup flow:
|
||
- If new_email is provided, updates email (checks uniqueness)
|
||
- If user.needs_setup is True and new_email is given, clears needs_setup
|
||
- Always increments token_version to invalidate old sessions
|
||
- Re-issues session cookie with new token_version
|
||
"""
|
||
from app.gateway.auth.password import hash_password_async, verify_password_async
|
||
from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED, AUTH_SOURCE_PAT
|
||
|
||
user = await get_current_user_from_request(request)
|
||
|
||
if getattr(request.state, "auth_source", None) in {AUTH_SOURCE_PAT, AUTH_SOURCE_AUTH_DISABLED}:
|
||
# PAT-authenticated callers must not alter auth state (#4849 point 6);
|
||
# auth-disabled mode has no passwords to change.
|
||
if getattr(request.state, "auth_source", None) == AUTH_SOURCE_PAT:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="Password changes require interactive session authentication",
|
||
)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=AuthErrorResponse(
|
||
code=AuthErrorCode.INVALID_CREDENTIALS,
|
||
message="Password changes are not available when DEER_FLOW_AUTH_DISABLED=1.",
|
||
).model_dump(),
|
||
)
|
||
|
||
if user.password_hash is None:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="OAuth users cannot change password").model_dump())
|
||
|
||
if not await verify_password_async(body.current_password, user.password_hash):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Current password is incorrect").model_dump())
|
||
|
||
provider = get_local_provider()
|
||
|
||
# Update email if provided
|
||
if body.new_email is not None:
|
||
existing = await provider.get_user_by_email(body.new_email)
|
||
if existing and str(existing.id) != str(user.id):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already in use").model_dump())
|
||
user.email = body.new_email
|
||
|
||
# Update password + bump version
|
||
user.password_hash = await hash_password_async(body.new_password)
|
||
user.token_version += 1
|
||
|
||
# Clear setup flag if this is the setup flow
|
||
if user.needs_setup and body.new_email is not None:
|
||
user.needs_setup = False
|
||
|
||
await provider.update_user(user)
|
||
|
||
# Re-issue cookie with new token_version
|
||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||
_set_session_cookie(response, token, request, remember_me=body.remember_me)
|
||
_set_csrf_cookie(response, request)
|
||
|
||
return MessageResponse(message="Password changed successfully")
|
||
|
||
|
||
@router.get("/me", response_model=UserResponse)
|
||
async def get_me(request: Request):
|
||
"""Get current authenticated user info, including effective permissions."""
|
||
user = await get_current_user_from_request(request)
|
||
auth = getattr(getattr(request, "state", None), "auth", None)
|
||
if auth is not None:
|
||
# AuthMiddleware already resolved the per-request permission set (with
|
||
# PAT-scope intersection and internal-caller semantics applied) — reuse
|
||
# it instead of evaluating the provider a second time.
|
||
permissions: list[str] = list(auth.permissions)
|
||
else:
|
||
# Middleware-less composition: resolve exactly as _authenticate does.
|
||
from app.gateway.authz import resolve_route_permissions_for_request
|
||
|
||
permissions = await resolve_route_permissions_for_request(request, user)
|
||
return UserResponse(
|
||
id=str(user.id),
|
||
email=user.email,
|
||
system_role=user.system_role,
|
||
needs_setup=user.needs_setup,
|
||
oauth_provider=user.oauth_provider,
|
||
permissions=permissions,
|
||
)
|
||
|
||
|
||
# ── Personal Access Tokens (#4849) ────────────────────────────────────────
|
||
|
||
|
||
def require_session_source(request: Request) -> None:
|
||
"""Reject non-session credentials from auth-state-altering routes.
|
||
|
||
PAT-authenticated callers must not manage PATs or change passwords
|
||
(#4849 point 6): a leaked automation token could otherwise mint fresh
|
||
long-lived credentials or lock out the human owner.
|
||
"""
|
||
from app.gateway.auth_disabled import AUTH_SOURCE_SESSION
|
||
|
||
if getattr(request.state, "auth_source", None) != AUTH_SOURCE_SESSION:
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="This endpoint requires interactive session authentication")
|
||
|
||
|
||
class PATCreateRequest(BaseModel):
|
||
name: str = Field(min_length=1, max_length=PAT_MAX_NAME_LENGTH)
|
||
scopes: list[str] = Field(min_length=1)
|
||
expires_in_days: int | None = Field(default=None, ge=1, le=365) # None = never expires
|
||
|
||
@field_validator("name")
|
||
@classmethod
|
||
def _strip_and_require_non_empty_name(cls, value: str) -> str:
|
||
# A whitespace-only name passes min_length but would persist as an
|
||
# empty label; the trimmed value is what gets stored and shown.
|
||
stripped = value.strip()
|
||
if not stripped:
|
||
raise ValueError("PAT name must contain at least one non-whitespace character")
|
||
return stripped
|
||
|
||
|
||
class PATCreatedResponse(BaseModel):
|
||
"""Create response — ``token`` is the raw show-once credential."""
|
||
|
||
id: str
|
||
name: str
|
||
scopes: list[str]
|
||
expires_at: str | None
|
||
created_at: str
|
||
token: str
|
||
|
||
|
||
class PATSummaryResponse(BaseModel):
|
||
id: str
|
||
name: str
|
||
scopes: list[str]
|
||
expires_at: str | None
|
||
last_used_at: str | None
|
||
created_at: str
|
||
revoked_at: str | None
|
||
|
||
|
||
def _pat_summary(record: dict) -> PATSummaryResponse:
|
||
return PATSummaryResponse(
|
||
id=str(record["id"]),
|
||
name=str(record["name"]),
|
||
scopes=list(record.get("scopes") or []),
|
||
expires_at=str(record["expires_at"]) if record.get("expires_at") else None,
|
||
last_used_at=str(record["last_used_at"]) if record.get("last_used_at") else None,
|
||
created_at=str(record["created_at"]),
|
||
revoked_at=str(record["revoked_at"]) if record.get("revoked_at") else None,
|
||
)
|
||
|
||
|
||
@router.post("/pats", status_code=status.HTTP_201_CREATED, response_model=PATCreatedResponse, dependencies=[Depends(require_session_source)])
|
||
async def create_pat(request: Request, body: PATCreateRequest):
|
||
"""Create a personal access token for the session user.
|
||
|
||
The raw token is returned exactly once and cannot be retrieved again;
|
||
only its SHA-256 digest is persisted.
|
||
"""
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
from app.gateway.auth.pat import generate_pat_token, pat_token_digest, validate_scopes
|
||
from app.gateway.deps import get_pat_repo
|
||
|
||
user = await get_current_user_from_request(request)
|
||
try:
|
||
scopes = validate_scopes(body.scopes)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||
|
||
token = generate_pat_token()
|
||
expires_at = datetime.now(UTC) + timedelta(days=body.expires_in_days) if body.expires_in_days is not None else None
|
||
record = await get_pat_repo(request).create(
|
||
user_id=str(user.id),
|
||
name=body.name.strip(),
|
||
scopes=scopes,
|
||
token_digest=pat_token_digest(token),
|
||
expires_at=expires_at,
|
||
)
|
||
return PATCreatedResponse(
|
||
id=str(record["id"]),
|
||
name=str(record["name"]),
|
||
scopes=list(record.get("scopes") or []),
|
||
expires_at=str(record["expires_at"]) if record.get("expires_at") else None,
|
||
created_at=str(record["created_at"]),
|
||
token=token,
|
||
)
|
||
|
||
|
||
@router.get("/pats", response_model=list[PATSummaryResponse], dependencies=[Depends(require_session_source)])
|
||
async def list_pats(request: Request):
|
||
"""List the session user's tokens. Never returns digests or raw tokens."""
|
||
from app.gateway.deps import get_pat_repo
|
||
|
||
user = await get_current_user_from_request(request)
|
||
records = await get_pat_repo(request).list_for_user(str(user.id))
|
||
return [_pat_summary(record) for record in records]
|
||
|
||
|
||
@router.delete("/pats/{pat_id}", response_model=MessageResponse, dependencies=[Depends(require_session_source)])
|
||
async def revoke_pat(request: Request, pat_id: str):
|
||
"""Revoke one of the session user's tokens. Revocation is immediate."""
|
||
from app.gateway.deps import get_pat_repo
|
||
|
||
user = await get_current_user_from_request(request)
|
||
revoked = await get_pat_repo(request).revoke(pat_id, str(user.id))
|
||
if not revoked:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found")
|
||
return MessageResponse(message="Token revoked")
|
||
|
||
|
||
# Per-IP cache: ip → (timestamp, result_dict).
|
||
# Returns the cached result within the TTL instead of 429, because
|
||
# the answer (whether an admin exists) rarely changes and returning
|
||
# 429 breaks multi-tab / post-restart reconnection storms.
|
||
_SETUP_STATUS_CACHE: dict[str, tuple[float, dict]] = {}
|
||
_SETUP_STATUS_CACHE_TTL_SECONDS = 60
|
||
_MAX_TRACKED_SETUP_STATUS_IPS = 10000
|
||
_SETUP_STATUS_INFLIGHT: dict[str, asyncio.Task[dict]] = {}
|
||
_SETUP_STATUS_INFLIGHT_GUARD = asyncio.Lock()
|
||
|
||
|
||
@router.get("/setup-status")
|
||
async def setup_status(request: Request):
|
||
"""Check if an admin account exists. Returns needs_setup=True when no admin exists."""
|
||
client_ip = await _get_client_ip(request)
|
||
now = time.time()
|
||
|
||
# Return cached result when within TTL — avoids 429 on multi-tab reconnection.
|
||
cached = _SETUP_STATUS_CACHE.get(client_ip)
|
||
if cached is not None:
|
||
cached_time, cached_result = cached
|
||
if now - cached_time < _SETUP_STATUS_CACHE_TTL_SECONDS:
|
||
return cached_result
|
||
|
||
async with _SETUP_STATUS_INFLIGHT_GUARD:
|
||
# Recheck cache after waiting for the inflight guard.
|
||
now = time.time()
|
||
cached = _SETUP_STATUS_CACHE.get(client_ip)
|
||
if cached is not None:
|
||
cached_time, cached_result = cached
|
||
if now - cached_time < _SETUP_STATUS_CACHE_TTL_SECONDS:
|
||
return cached_result
|
||
|
||
task = _SETUP_STATUS_INFLIGHT.get(client_ip)
|
||
if task is None:
|
||
# Evict stale entries when dict grows too large to bound memory usage.
|
||
if len(_SETUP_STATUS_CACHE) >= _MAX_TRACKED_SETUP_STATUS_IPS:
|
||
cutoff = now - _SETUP_STATUS_CACHE_TTL_SECONDS
|
||
stale = [k for k, (t, _) in _SETUP_STATUS_CACHE.items() if t < cutoff]
|
||
for k in stale:
|
||
del _SETUP_STATUS_CACHE[k]
|
||
if len(_SETUP_STATUS_CACHE) >= _MAX_TRACKED_SETUP_STATUS_IPS:
|
||
by_time = sorted(_SETUP_STATUS_CACHE.items(), key=lambda entry: entry[1][0])
|
||
for k, _ in by_time[: len(by_time) // 2]:
|
||
del _SETUP_STATUS_CACHE[k]
|
||
|
||
async def _compute_setup_status() -> dict:
|
||
admin_count = await get_local_provider().count_admin_users()
|
||
return {"needs_setup": admin_count == 0, "registration_enabled": _local_registration_enabled()}
|
||
|
||
task = asyncio.create_task(_compute_setup_status())
|
||
_SETUP_STATUS_INFLIGHT[client_ip] = task
|
||
|
||
try:
|
||
result = await task
|
||
finally:
|
||
async with _SETUP_STATUS_INFLIGHT_GUARD:
|
||
if _SETUP_STATUS_INFLIGHT.get(client_ip) is task:
|
||
del _SETUP_STATUS_INFLIGHT[client_ip]
|
||
|
||
# Cache only the stable "initialized" result to avoid stale setup redirects.
|
||
if result["needs_setup"] is False:
|
||
_SETUP_STATUS_CACHE[client_ip] = (time.time(), result)
|
||
else:
|
||
_SETUP_STATUS_CACHE.pop(client_ip, None)
|
||
return result
|
||
|
||
|
||
class InitializeAdminRequest(BaseModel):
|
||
"""Request model for first-boot admin account creation."""
|
||
|
||
email: EmailStr
|
||
password: str = Field(..., min_length=8)
|
||
remember_me: bool = True
|
||
|
||
_strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
|
||
|
||
|
||
@router.post("/initialize", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||
async def initialize_admin(request: Request, response: Response, body: InitializeAdminRequest):
|
||
"""Create the first admin account on initial system setup.
|
||
|
||
Only callable when no admin exists. Returns 409 Conflict if an admin
|
||
already exists, including when a concurrent first-boot request won the
|
||
claim: the account is created through an atomic check-and-insert rather
|
||
than a count followed by a create.
|
||
|
||
On success, the admin account is created with ``needs_setup=False`` and
|
||
the session cookie is set.
|
||
"""
|
||
already_initialized = HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(),
|
||
)
|
||
|
||
# Fast path only: an initialized system answers without hashing a
|
||
# password. The claim below is what actually decides, because a count
|
||
# read here cannot exclude a request already in flight.
|
||
if await get_local_provider().count_admin_users() > 0:
|
||
raise already_initialized
|
||
|
||
try:
|
||
user = await get_local_provider().create_first_admin(email=body.email, password=body.password)
|
||
except ValueError:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already registered").model_dump(),
|
||
)
|
||
|
||
if user is None:
|
||
raise already_initialized
|
||
|
||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||
_set_session_cookie(response, token, request, remember_me=body.remember_me)
|
||
|
||
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, oauth_provider=user.oauth_provider)
|
||
|
||
|
||
# ── OIDC / SSO Endpoints ────────────────────────────────────────────────
|
||
|
||
_OIDC_PROVIDER_KEY_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
|
||
|
||
|
||
def _get_oidc_service() -> OIDCService:
|
||
"""Get (or create) the singleton OIDC service instance."""
|
||
if not hasattr(_get_oidc_service, "_instance"):
|
||
_get_oidc_service._instance = OIDCService() # type: ignore[attr-defined]
|
||
return _get_oidc_service._instance # type: ignore[attr-defined]
|
||
|
||
|
||
async def close_oidc_service() -> None:
|
||
service = getattr(_get_oidc_service, "_instance", None)
|
||
if service is not None:
|
||
await service.close()
|
||
delattr(_get_oidc_service, "_instance")
|
||
|
||
|
||
def _set_csrf_cookie(response: Response, request: Request) -> None:
|
||
"""Set the CSRF double-submit cookie (needed for GET-based OIDC callback)."""
|
||
csrf_token = generate_csrf_token()
|
||
secure, max_age = auth_csrf_cookie_settings(request)
|
||
response.set_cookie(
|
||
key=CSRF_COOKIE_NAME,
|
||
value=csrf_token,
|
||
httponly=False, # Must be JS-readable for Double Submit Cookie pattern
|
||
secure=secure,
|
||
samesite="strict",
|
||
# Persist for the same lifetime as the access_token (see _set_session_cookie)
|
||
# so the double-submit pair is evicted together, never leaving a logged-in
|
||
# session whose csrf_token was dropped (e.g. iOS Safari PWA termination).
|
||
max_age=max_age,
|
||
)
|
||
|
||
|
||
def _resolve_oidc_redirect_uri(request: Request, provider_id: str, provider_config: OIDCProviderConfig) -> str:
|
||
"""Resolve the redirect URI for an OIDC provider.
|
||
|
||
Prefers the explicitly configured ``redirect_uri``. Falls back to
|
||
constructing one from the request's own base URL for development.
|
||
"""
|
||
if provider_config.redirect_uri:
|
||
return provider_config.redirect_uri
|
||
|
||
# Development fallback: build from the request's proxy-aware origin (honors
|
||
# Forwarded / X-Forwarded-* the same way CSRF origin checks do) rather than
|
||
# the raw Host header, so a spoofed Host cannot steer the IdP redirect_uri
|
||
# and the scheme reflects the real client-facing protocol behind a proxy.
|
||
origin = _request_origin(request)
|
||
if not origin:
|
||
origin = f"{request.url.scheme}://{request.headers.get('host', 'localhost:8001')}"
|
||
return f"{origin}/api/v1/auth/callback/{provider_id}"
|
||
|
||
|
||
@router.get("/providers")
|
||
async def list_auth_providers():
|
||
"""List enabled SSO providers for the login page.
|
||
|
||
Returns only safe frontend metadata — no secrets, endpoints, or
|
||
internal configuration.
|
||
"""
|
||
from deerflow.config.app_config import get_app_config
|
||
|
||
app_config = get_app_config()
|
||
oidc_config = app_config.auth.oidc
|
||
|
||
if not oidc_config.enabled:
|
||
return {"providers": []}
|
||
|
||
providers = []
|
||
for provider_id, provider_cfg in oidc_config.providers.items():
|
||
providers.append(
|
||
{
|
||
"id": provider_id,
|
||
"display_name": provider_cfg.display_name,
|
||
"type": "oidc",
|
||
}
|
||
)
|
||
return {"providers": providers}
|
||
|
||
|
||
@router.get("/oauth/{provider}")
|
||
async def oauth_login(
|
||
request: Request,
|
||
provider: str,
|
||
next: str | None = None, # noqa: A002 (shadowing built-in is intentional — this is the query param name)
|
||
remember_me: bool = True,
|
||
):
|
||
"""Initiate OIDC login flow.
|
||
|
||
Redirects to the OIDC provider's authorization URL with state, nonce,
|
||
and PKCE parameters. The ``next`` query parameter specifies where to
|
||
redirect after successful login (default: /workspace).
|
||
"""
|
||
from deerflow.config.app_config import get_app_config
|
||
|
||
app_config = get_app_config()
|
||
oidc_config = app_config.auth.oidc
|
||
|
||
if not oidc_config.enabled:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SSO authentication is not enabled")
|
||
|
||
if not _OIDC_PROVIDER_KEY_RE.match(provider):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid provider ID")
|
||
|
||
provider_config = oidc_config.providers.get(provider)
|
||
if not provider_config:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unknown SSO provider: {provider}")
|
||
|
||
# Validate `next` / open redirect prevention
|
||
redirect_path = validate_next_param(next) or "/workspace"
|
||
|
||
# Resolve redirect URI
|
||
redirect_uri = _resolve_oidc_redirect_uri(request, provider, provider_config)
|
||
|
||
# Generate state, nonce, PKCE
|
||
state_value = generate_oidc_state()
|
||
nonce_value = generate_nonce() if provider_config.nonce_enabled else None
|
||
code_verifier = generate_code_verifier() if provider_config.pkce_enabled else None
|
||
code_challenge = compute_code_challenge(code_verifier) if code_verifier else None
|
||
|
||
# Get provider metadata via discovery
|
||
overrides = {
|
||
"authorization_endpoint": provider_config.authorization_endpoint,
|
||
"token_endpoint": provider_config.token_endpoint,
|
||
"userinfo_endpoint": provider_config.userinfo_endpoint,
|
||
"jwks_uri": provider_config.jwks_uri,
|
||
}
|
||
service = _get_oidc_service()
|
||
try:
|
||
metadata = await service.discover(provider_config.issuer, overrides)
|
||
except OIDCError as exc:
|
||
logger.error("OIDC discovery failed for provider %s: %s", provider, exc)
|
||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Failed to connect to SSO provider")
|
||
|
||
auth_url = service.build_authorization_url(
|
||
metadata=metadata,
|
||
client_id=provider_config.client_id,
|
||
redirect_uri=redirect_uri,
|
||
scopes=provider_config.scopes,
|
||
state=state_value,
|
||
nonce=nonce_value,
|
||
code_challenge=code_challenge,
|
||
)
|
||
|
||
# Set signed state cookie
|
||
state_payload = OIDCStatePayload(
|
||
provider=provider,
|
||
state=state_value,
|
||
nonce=nonce_value,
|
||
code_verifier=code_verifier,
|
||
next_path=redirect_path,
|
||
remember_me=remember_me,
|
||
)
|
||
redirect_response = RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
|
||
set_state_cookie(redirect_response, request, state_payload)
|
||
|
||
return redirect_response
|
||
|
||
|
||
@router.get("/callback/{provider}")
|
||
async def oauth_callback(
|
||
request: Request,
|
||
provider: str,
|
||
code: str | None = None,
|
||
state: str | None = None,
|
||
error: str | None = None,
|
||
error_description: str | None = None,
|
||
):
|
||
"""OIDC callback endpoint.
|
||
|
||
Handles the OIDC provider's redirect after user authorization.
|
||
Validates the state cookie, exchanges the code for tokens, validates
|
||
the ID token, provisions/links the DeerFlow user, and sets the
|
||
session cookie.
|
||
"""
|
||
from deerflow.config.app_config import get_app_config
|
||
|
||
app_config = get_app_config()
|
||
oidc_config = app_config.auth.oidc
|
||
|
||
# ── Provider error ───────────────────────────────────────────────
|
||
if error:
|
||
logger.warning("OIDC provider returned error for %s: %s (description: %s)", provider, error, error_description)
|
||
redirect = _build_error_redirect(oidc_config.frontend_base_url, "sso_failed")
|
||
return RedirectResponse(url=redirect, status_code=status.HTTP_302_FOUND)
|
||
|
||
if not oidc_config.enabled:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SSO authentication is not enabled")
|
||
|
||
if not _OIDC_PROVIDER_KEY_RE.match(provider):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid provider ID")
|
||
|
||
provider_config = oidc_config.providers.get(provider)
|
||
if not provider_config:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unknown SSO provider: {provider}")
|
||
|
||
if not code or not state:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing code or state parameter")
|
||
|
||
# ── Verify state cookie ──────────────────────────────────────────
|
||
state_payload = get_state_cookie(request, provider)
|
||
if not state_payload:
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing or expired OIDC state cookie")
|
||
|
||
if not constant_time_equals(state_payload.state, state):
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="OIDC state mismatch")
|
||
|
||
# ── Resolve redirect URI ─────────────────────────────────────────
|
||
redirect_uri = _resolve_oidc_redirect_uri(request, provider, provider_config)
|
||
|
||
# ── Get metadata ─────────────────────────────────────────────────
|
||
overrides = {
|
||
"authorization_endpoint": provider_config.authorization_endpoint,
|
||
"token_endpoint": provider_config.token_endpoint,
|
||
"userinfo_endpoint": provider_config.userinfo_endpoint,
|
||
"jwks_uri": provider_config.jwks_uri,
|
||
}
|
||
service = _get_oidc_service()
|
||
try:
|
||
metadata = await service.discover(provider_config.issuer, overrides)
|
||
except OIDCError as exc:
|
||
logger.error("OIDC discovery failed for provider %s during callback: %s", provider, exc)
|
||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Failed to connect to SSO provider")
|
||
|
||
# ── Authenticate ─────────────────────────────────────────────────
|
||
try:
|
||
identity = await service.authenticate_callback(
|
||
provider_id=provider,
|
||
metadata=metadata,
|
||
client_id=provider_config.client_id,
|
||
client_secret=provider_config.client_secret,
|
||
code=code,
|
||
redirect_uri=redirect_uri,
|
||
code_verifier=state_payload.code_verifier,
|
||
nonce=state_payload.nonce,
|
||
auth_method=provider_config.token_endpoint_auth_method,
|
||
)
|
||
except OIDCError as exc:
|
||
logger.error("OIDC callback authentication failed for %s: %s", provider, exc)
|
||
redirect = _build_error_redirect(oidc_config.frontend_base_url, "sso_failed")
|
||
return RedirectResponse(url=redirect, status_code=status.HTTP_302_FOUND)
|
||
|
||
# ── Provision / link user ────────────────────────────────────────
|
||
try:
|
||
result = await get_or_provision_oidc_user(provider, provider_config, identity, get_local_provider())
|
||
except HTTPException as exc:
|
||
error_map = {
|
||
status.HTTP_403_FORBIDDEN: "sso_not_allowed",
|
||
status.HTTP_409_CONFLICT: "sso_account_exists",
|
||
}
|
||
error_code = error_map.get(exc.status_code, "sso_failed")
|
||
logger.warning("OIDC user provisioning failed for %s (%s): %s", identity.email, provider, exc.detail)
|
||
redirect = _build_error_redirect(oidc_config.frontend_base_url, error_code)
|
||
return RedirectResponse(url=redirect, status_code=status.HTTP_302_FOUND)
|
||
|
||
user = result["user"]
|
||
|
||
# ── Issue DeerFlow session ───────────────────────────────────────
|
||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||
|
||
# Revalidate as defense-in-depth if future state writers populate this target.
|
||
redirect_target = validate_next_param(state_payload.next_path) or "/workspace"
|
||
frontend_base = oidc_config.frontend_base_url or ""
|
||
callback_redirect = f"{frontend_base}/auth/callback?next={urllib.parse.quote(redirect_target)}"
|
||
|
||
redirect_response = RedirectResponse(url=callback_redirect, status_code=status.HTTP_302_FOUND)
|
||
|
||
# Set session cookie (reuse existing helper)
|
||
_set_session_cookie(redirect_response, token, request, remember_me=state_payload.remember_me)
|
||
|
||
# Set CSRF cookie (callback is a GET, so CSRF middleware won't set it)
|
||
_set_csrf_cookie(redirect_response, request)
|
||
|
||
# Delete state cookie
|
||
delete_state_cookie(redirect_response, request, provider)
|
||
|
||
return redirect_response
|
||
|
||
|
||
def _build_error_redirect(frontend_base_url: str | None, error_code: str) -> str:
|
||
"""Build a frontend redirect URL with an error parameter."""
|
||
base = frontend_base_url or ""
|
||
return f"{base}/login?error={error_code}"
|
||
|
||
|
||
def validate_next_param(next_param: str | None) -> str | None:
|
||
"""Validate and sanitize the ``next`` redirect parameter.
|
||
|
||
Only allows relative paths starting with ``/``. Rejects protocol-relative
|
||
URLs (``//``), absolute URLs, URLs with embedded protocols, and backslashes
|
||
that URL parsers may reinterpret as forward slashes.
|
||
"""
|
||
if not next_param:
|
||
return None
|
||
if not next_param.startswith("/"):
|
||
return None
|
||
if next_param.startswith("//") or next_param.startswith("http://") or next_param.startswith("https://"):
|
||
return None
|
||
if "\\" in next_param:
|
||
return None
|
||
if ":" in next_param:
|
||
return None
|
||
return next_param
|