Sunshine bf740ffa90
feat(auth): add personal access tokens for programmatic API access (#5041)
* feat(auth): add personal access tokens for programmatic API access (#4849)

Backend-first implementation of the PAT contract from #4849: show-once
dfp_ tokens bound to their owning user (AUTH_SOURCE_PAT,
is_internal=false), digest-only storage (migration 0017), strict
credential precedence (invalid Bearer is a 401, never cookie fallback),
CSRF double-submit skipped only for Bearer requests while
auth-endpoint origin checks still run, scopes intersecting the authz
route permissions, session-auth-only PAT management and password
changes, and throttled best-effort last_used_at stamps.

* fix(auth): harden PAT scope boundary and schema parity from adversarial review

Independent review of the initial draft found: (1) scopes only constrained
the threads/runs permission axis while admin routes treated a PAT as its
(possibly admin) owner — is_admin_user now rejects PAT callers outright
since no scope grants admin capability; (2) the model declared a column
UNIQUE constraint while migration 0017 created a named unique index, so
downgrade failed on create_all-bootstrapped DBs — both now use the named
unique index; (3) auth-disabled mode is an operator override and now stays
ahead of the Bearer check so a stray Authorization header cannot 401 an
E2E sandbox; plus wiring the previously-unused constants, bounding the
last_used_at stamp cache, and four new tests (middleware-level expiry,
expires_in_days, admin-capability rejection with session control, and the
auth-disabled precedence).

* docs(api): document personal access tokens for programmatic API access

* fix(auth): close PAT security boundaries from review (default-deny routes, extension admin suppression)

P1-1: scope intersection only constrains @require_permission routes, so
undecorated mutation routes (DELETE /api/memory, POST /api/agents, Lark
credential switching, channel config) accepted a PAT holding a single read
scope. AuthMiddleware now enforces a default-deny route policy in
auth/pat.py: PAT requests are admitted only to the thread/run lifecycle
routes the v1 scopes govern; everything else answers 403 regardless of
scopes. Session-cookie callers are unaffected.

P1-2: the extension principal resolver projected is_admin/roles from the
raw system_role, so an admin-owned PAT passed
deerflow_extension_api.require_admin on contributed routes despite the
documented no-admin guarantee. The projection is now PAT-aware and
suppresses every admin signal for PAT callers, mirroring
deps.is_admin_user.

Both fixes carry regression tests (route outside policy 403 + session
control; production resolver admin suppression), and API.md documents the
default-deny boundary.

* fix(auth): enforce PAT scopes on stateless run entry and harden decorator

Follow-up hardening from an independent audit of the P1 fixes:

- POST /api/runs/stream and /api/runs/wait were the only allowlisted run
  entrypoints without @require_permission, so a threads:read-only PAT
  could still start runs (same bug class as P1-1, now closed): both now
  carry @require_permission("runs", "create"). POST /api/threads and
  POST /api/threads/search gain threads:write / threads:read for the
  same reason. Authorization-disabled deployments see no change (the
  permission set resolves to all permissions).
- require_permission now binds the wrapped signature to locate a
  positionally-passed request before injecting the test stub, fixing
  'got multiple values for argument' on direct positional unit-test
  calls.
- API.md: the intro PAT example used GET /api/models, which the new
  default-deny policy 403s — replaced with GET /api/threads; the
  default-deny route list now spells out method sets.

Regression test: threads:read-only PAT is 403 on the decorated stateless
entry while a runs:create PAT passes.

* fix(auth): address review P2s (empty Authorization header, PAT name trimming, API example)

- CSRFMiddleware treats an explicitly empty Authorization header as
  present (is None), so an invalid credential always reaches
  AuthMiddleware's uniform 401 instead of a CSRF 403 that varies by
  method/CSRF state. Regression: empty-header request dies at auth.
- PATCreateRequest strips the name and rejects whitespace-only values
  before token generation; created names are stored trimmed.
- API.md intro PAT example now uses the implemented
  POST /api/threads/search endpoint (GET /api/threads does not exist).
- AGENTS.md trimmed back under the guidance soft budget after the
  upstream merge.

* fix(auth): tighten PAT route policy to implemented methods only

The allowlist admitted GET /api/threads, a method no router implements.
Pre-authorizing a dead method weakens the default-deny boundary: a
future GET collection route added without a permission decorator would
become PAT-reachable without an explicit policy change. Restrict the
rule to POST, fix the stale GET description in API.md's PAT
constraints, and document the default-deny boundary accurately in the
gateway AGENTS.md guidance (only the threads/runs allowlist is
PAT-reachable; every other authenticated route 403s PAT callers).

Audited every remaining rule against the mounted routers: all other
method+path entries map to real routes. Regression:
test_pat_policy_does_not_pre_authorize_unimplemented_methods.

* test(auth): guarantee the negative digest test mutates the token

token[:-1] + "X" is identical to the original whenever the generated
token already ends in X (1/62), making the negative digest assertion
fail intermittently. Choose the replacement character based on the
existing tail so the mutated token always differs.

* fix(auth): require runs:cancel for cancel-then-stream requests

stream_existing_run is gated at runs:read so action-less stream joins
work with read-only credentials, but its ?action=interrupt|rollback
branch cancels the run — a separate permission. A runs:read-only PAT
passed both the PAT route policy and the route decorator and could
interrupt or roll back an active run, bypassing the runs:cancel scope.

Decorators cannot express query-parameter-conditional permissions, so
the check lives in require_cancel_permission_when_action(), applied at
the top of the handler. Regression drives the real helper through the
production middleware: runs:read-only PAT + action is 403, the same
token joins action-less, runs:read+cancel passes, session control
unaffected.

* docs(changelog): add the PAT feature entry

* docs(readme): add personal access tokens section

Repo documentation-update policy requires user-facing features to
update README.md in the same changeset; the PAT feature previously
touched only backend/docs/API.md and the gateway AGENTS.md.

* fix(auth): require runs:cancel for mutating multitask strategies

All five run-creation entrypoints were gated only by runs:create, but
RunCreateRequest.multitask_strategy accepts interrupt/rollback and
start_run forwards it to create_or_reject, which terminates an
already-active run. A runs:create-only PAT could therefore kill an
existing run through a create request, bypassing runs:cancel.

Decorators cannot express body-parameter-conditional permissions, and
per-route checks leave the same hole for the next entrypoint, so the
gate lives in start_run itself — the single choke point every
run-creation path (HTTP routes and internal launchers) flows through.
Regenerate launches pass multitask_strategy="reject" and are
unaffected; requests without a stamped auth context (internal/test
compositions) skip the gate.

The check is the shared authz.require_cancel_permission_if primitive;
require_cancel_permission_when_action now delegates to it, so every
request dimension that carries cancel capability (query action, body
strategy) flows through one gate.

Regression drives the real middleware stack: runs:create-only PAT +
interrupt/rollback is 403 with the exact detail, reject (explicit and
default) stays available, runs:create+cancel passes, session control
unaffected; a source anchor pins the gate inside start_run.

* fix(runs): keep observer joins from applying creator cancel-on-disconnect

sse_consumer's finally block applied the record's on_disconnect=cancel
policy on ANY consumer's disconnect. The join surfaces (GET /join and
the action-less GET/POST stream join) feed it the existing RunRecord,
so anyone with thread read access — including a runs:read-only PAT —
could cancel a locally-owned running run simply by closing the SSE
connection, without runs:cancel. The policy expresses the creator's
intent for their own connection; an observer's disconnect must never
be read as that intent.

sse_consumer gains apply_on_disconnect (default True). The two join
surfaces pass False; the creating endpoints (thread-scoped and
stateless create-and-stream) keep the creator semantics unchanged.
wait_for_run_completion needs no change: its callers are creator-side
or post-explicit-cancel paths only.

Regression exercises a real generator close — the same machinery
Starlette drives on client disconnect — against the production
sse_consumer: creator stream disconnect cancels, observer join
disconnect does not; a wiring anchor pins both join call sites and the
creator defaults. API.md documents the cancel-capability constraint
(this fix plus the action/strategy gates) in PAT Constraints.

* test(auth): pin the multitask gate behaviorally; state wait invariant

Independent adversarial review of the round-5 fixes found the P1-a
regression only mirror-pinned: the source anchor could be satisfied by
a comment, and deleting the gate from start_run would not fail the
suite. This drives the production start_run directly — a create-only
auth context gets 403 with the exact detail for interrupt, and a
reject request with no cancel permission at all proceeds past the gate
(never a permission 403).

Also documents wait_for_run_completion's creator-side invariant
(every caller is the creating endpoint or post-explicit-cancel) so a
future observer wiring thinks twice before reusing it — the one-caller-
away variant of the observer-disconnect P1.

* docs(changelog): correct the PAT entry's digest and route-policy description

The entry said HMAC digests (the implementation stores SHA-256 digests,
as documented in API.md and pinned by the repository tests) and claimed
the route policy admits 'implemented stateless endpoints' (it admits
the thread/run lifecycle routes, narrowing further by scopes). Also
notes the cancel-capability gate now covering action and multitask
strategies.

* fix(auth): enumerate the PAT runs route policy per implemented subroute

The runs subtree rule was a GET|POST /runs(/.*)? wildcard — it
pre-authorized every current and future subroute under /runs, including
methods the router never implemented (e.g. GET /runs/stream), which is
the same latent default-deny weakening the threads collection rule was
tightened for: a future route added under /runs would become
PAT-reachable without an explicit policy change.

The wildcard is replaced with six segment-precise rules covering exactly
the 14 implemented method+path combinations; the {run_id} slot
necessarily matches any single segment, so the POST-only collection
names (stream, wait, regenerate, edit-regenerate) are excluded from the
GET run-id rule via negative lookahead — no dead method stays
pre-authorized. Behavior for implemented routes is unchanged.

test_pat_runs_policy_admits_exactly_the_mounted_routes derives the
expected set from the mounted thread_runs router instead of a
hand-maintained list: every implemented GET/POST route under /runs must
be admitted, routes in this router outside the subtree stay denied, and
representative unimplemented neighbors are denied — so adding a route
under /runs now fails CI until it is explicitly allowlisted, and a
removed route leaves a dead rule visible. API.md's PAT constraints list
the enumerated routes and drops a feedback mention that belonged to the
stateless /api/runs axis.

* docs(migration): add the 0017 renumbering coordination note to 0017

The PR's migration-coordination comment states each migration file
carries the note; the file did not. Adds it: numbering was generated
against main head 0016 alongside #5078 and #4843; whoever merges first
keeps the slot, the others renumber on rebase (revision/down_revision
plus the bootstrap head assertions).

* fix(auth): pad base62 tokens to a fixed 43-char width

int.from_bytes discards leading zero bytes, so the unpadded encoder
returned a variable-length body — empty for all-zero input, and shorter
than 40 characters for any draw below 62**39 (~1 in 14.5M), leaving
test_generate_pat_token_format probabilistically flaky and the token
body without stable width (review round 6, P3).

_base62 now left-pads with "0" to _base62_width(len(data)) — the exact
integer digit count (62^43 > 2^256 > 62^42, so 43 for 32 bytes). The
format test asserts the exact fixed width instead of a probabilistic
floor, and a new unit test pins the all-zero, leading-zero-byte, and
max-value edges deterministically.
2026-08-29 23:50:45 +08:00

1005 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Authentication endpoints."""
import asyncio
import logging
import os
import re
import secrets
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,
)
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 deerflow.config.auth_config import OIDCProviderConfig
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 ────────────────────────────────────────────────────────
# In-process dict — not shared across workers.
#
# **Limitation**: with multi-worker deployments (e.g., gunicorn -w N), each
# worker maintains its own lockout table, so an attacker effectively gets
# N × _MAX_LOGIN_ATTEMPTS guesses before being locked out everywhere. For
# production multi-worker setups, replace this with a shared store (Redis,
# database-backed counter) to enforce a true per-IP limit.
_MAX_LOGIN_ATTEMPTS = 5
_LOCKOUT_SECONDS = 300 # 5 minutes
# ip → (fail_count, lock_until_timestamp)
_login_attempts: dict[str, tuple[int, float]] = {}
def _trusted_proxies() -> list:
"""Parse ``AUTH_TRUSTED_PROXIES`` env var into a list of ip_network objects.
Comma-separated CIDR or single-IP entries. Empty / unset = no proxy is
trusted (direct mode). Invalid entries are skipped with a logger warning.
Read live so env-var overrides take effect immediately and tests can
``monkeypatch.setenv`` without poking a module-level cache.
"""
raw = os.getenv("AUTH_TRUSTED_PROXIES", "").strip()
if not raw:
return []
nets = []
for entry in raw.split(","):
entry = entry.strip()
if not entry:
continue
try:
nets.append(ip_network(entry, strict=False))
except ValueError:
logger.warning("AUTH_TRUSTED_PROXIES: ignoring invalid entry %r", entry)
return nets
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 or single IPs). 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.
- 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.
"""
peer_host = request.client.host if request.client else None
trusted = _trusted_proxies()
if trusted and peer_host:
try:
peer_ip = ip_address(peer_host)
if any(peer_ip in net for net in trusted):
real_ip = request.headers.get("x-real-ip", "").strip()
if real_ip:
return real_ip
except ValueError:
# peer_host wasn't a parseable IP (e.g. "unknown") — fall through
pass
return peer_host or "unknown"
def _check_rate_limit(ip: str) -> None:
"""Raise 429 if the IP is currently locked out."""
record = _login_attempts.get(ip)
if record is None:
return
fail_count, lock_until = record
if fail_count >= _MAX_LOGIN_ATTEMPTS:
if time.time() < lock_until:
raise HTTPException(
status_code=429,
detail="Too many login attempts. Try again later.",
)
del _login_attempts[ip]
_MAX_TRACKED_IPS = 10000
def _record_login_failure(ip: str) -> None:
"""Record a failed login attempt for the given IP."""
# Evict expired lockouts when dict grows too large
if len(_login_attempts) >= _MAX_TRACKED_IPS:
now = time.time()
expired = [k for k, (c, t) in _login_attempts.items() if c >= _MAX_LOGIN_ATTEMPTS and now >= t]
for k in expired:
del _login_attempts[k]
# If still too large, evict cheapest-to-lose half: below-threshold
# IPs (lock_until=0.0) sort first, then earliest-expiring lockouts.
if len(_login_attempts) >= _MAX_TRACKED_IPS:
by_time = sorted(_login_attempts.items(), key=lambda kv: kv[1][1])
for k, _ in by_time[: len(by_time) // 2]:
del _login_attempts[k]
record = _login_attempts.get(ip)
if record is None:
_login_attempts[ip] = (1, 0.0)
else:
new_count = record[0] + 1
lock_until = time.time() + _LOCKOUT_SECONDS if new_count >= _MAX_LOGIN_ATTEMPTS else 0.0
_login_attempts[ip] = (new_count, lock_until)
def _record_login_success(ip: str) -> None:
"""Clear failure counter for the given IP on successful login."""
_login_attempts.pop(ip, None)
# ── 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 = _get_client_ip(request)
_check_rate_limit(client_ip)
user = await get_local_provider().authenticate({"email": form_data.username, "password": form_data.password})
if user is None:
_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(),
)
_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."""
user = await get_current_user_from_request(request)
return UserResponse(
id=str(user.id),
email=user.email,
system_role=user.system_role,
needs_setup=user.needs_setup,
oauth_provider=user.oauth_provider,
)
# ── 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 = _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.
On success, the admin account is created with ``needs_setup=False`` and
the session cookie is set.
"""
admin_count = await get_local_provider().count_admin_users()
if admin_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(),
)
try:
user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="admin", needs_setup=False)
except ValueError:
admin_count = await get_local_provider().count_admin_users()
if admin_count == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already registered").model_dump(),
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").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)
# ── 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 secrets.compare_digest(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