feat(auth): add OIDC SSO support (#3506)

Add provider-agnostic OIDC authentication with Keycloak-compatible configuration, frontend SSO UI support
This commit is contained in:
Zheng Feng 2026-06-21 15:47:53 +08:00 committed by GitHub
parent 5ddf698895
commit ee8ad1bc67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2102 additions and 68 deletions

View file

@ -226,6 +226,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
yield
try:
await auth.close_oidc_service()
except Exception:
logger.exception("Failed to close OIDC service")
# Stop channel service on shutdown (bounded to prevent worker hang)
try:
from app.channels.service import stop_channel_service

View file

@ -102,3 +102,31 @@ class LocalAuthProvider(AuthProvider):
async def get_user_by_email(self, email: str) -> User | None:
"""Get user by email."""
return await self._repo.get_user_by_email(email)
async def create_oauth_user(
self,
email: str,
oauth_provider: str,
oauth_id: str,
system_role: str = "user",
) -> User:
"""Create a new user from an OAuth/OIDC login.
Args:
email: Verified email from the OIDC provider
oauth_provider: Provider ID (e.g. 'keycloak', 'google')
oauth_id: User's subject claim from the ID token
system_role: Role to assign ("admin" or "user")
Returns:
Created User instance
"""
user = User(
email=email,
password_hash=None,
system_role=system_role,
needs_setup=False,
oauth_provider=oauth_provider,
oauth_id=oauth_id,
)
return await self._repo.create_user(user)

View file

@ -39,3 +39,4 @@ class UserResponse(BaseModel):
email: str
system_role: Literal["admin", "user"]
needs_setup: bool = False
oauth_provider: str | None = Field(None, description="OAuth/SSO provider ID if the user logged in via SSO (e.g. 'keycloak')")

View file

@ -0,0 +1,433 @@
"""OIDC (OpenID Connect) authentication service.
Provides provider-agnostic OIDC operations: discovery, authorization URL
generation, token exchange, ID token validation, and userinfo retrieval.
"""
from __future__ import annotations
import logging
import secrets
import time
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode
import httpx
import jwt
from jwt import PyJWK
logger = logging.getLogger(__name__)
# ── Data types ────────────────────────────────────────────────────────────
OIDC_DISCOVERY_PATH = "/.well-known/openid-configuration"
METADATA_CACHE_TTL = 300 # 5 minutes
JWKS_CACHE_TTL = 300
@dataclass(frozen=True)
class OIDCMetadata:
"""Resolved OIDC provider metadata after discovery."""
issuer: str
authorization_endpoint: str
token_endpoint: str
userinfo_endpoint: str | None
jwks_uri: str
@dataclass(frozen=True)
class OIDCIdentity:
"""Normalized identity extracted from an OIDC provider response."""
provider: str
subject: str
email: str
email_verified: bool
name: str | None
claims: dict[str, Any]
class OIDCError(Exception):
"""Base error for OIDC operations. Message is safe for API responses."""
class OIDCProviderError(OIDCError):
"""The OIDC provider returned an error (e.g. access_denied)."""
class OIDCValidationError(OIDCError):
"""ID token validation failed."""
class OIDCUserInfoMismatch(OIDCError):
"""UserInfo sub does not match ID token sub."""
# ── Service ────────────────────────────────────────────────────────────────
class OIDCService:
"""OIDC authentication service.
Uses in-process caching for provider metadata and JWKS. The cache is
keyed by the provider's ``issuer`` — different providers get separate
entries. TTLs are configurable via constructor arguments.
"""
def __init__(
self,
metadata_cache_ttl: float = METADATA_CACHE_TTL,
jwks_cache_ttl: float = JWKS_CACHE_TTL,
) -> None:
self._metadata_cache: dict[str, tuple[float, dict[str, Any]]] = {}
self._jwks_cache: dict[str, tuple[float, dict[str, Any]]] = {}
self._metadata_ttl = metadata_cache_ttl
self._jwks_ttl = jwks_cache_ttl
self._http = httpx.AsyncClient(timeout=httpx.Timeout(15.0))
async def close(self) -> None:
"""Close the underlying HTTP client."""
await self._http.aclose()
# ── Discovery ──────────────────────────────────────────────────────────
async def discover(self, issuer: str, overrides: dict[str, str | None] | None = None) -> OIDCMetadata:
"""Fetch and cache OIDC discovery metadata from the issuer.
``overrides`` may contain endpoint URIs to override discovery values
(e.g. for providers with non-standard endpoints).
"""
now = time.time()
cached = self._metadata_cache.get(issuer)
if cached and now - cached[0] < self._metadata_ttl:
return self._metadata_from_dict(cached[1], overrides)
discovery_url = issuer.rstrip("/") + OIDC_DISCOVERY_PATH
try:
resp = await self._http.get(discovery_url)
resp.raise_for_status()
data: dict[str, Any] = resp.json()
except httpx.HTTPStatusError as exc:
raise OIDCError(f"OIDC discovery failed for issuer {issuer}: HTTP {exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"OIDC discovery failed for issuer {issuer}: {exc}") from exc
discovered_issuer = data.get("issuer")
if not discovered_issuer:
raise OIDCError(f"OIDC discovery response from {issuer} is missing the issuer field")
# RFC 8414 §4: the metadata issuer must equal the configured issuer.
# Pinning it prevents a tampered/rogue discovery document from steering
# the accepted `iss` (and thus the ID-token forgery surface) to an
# attacker-chosen value.
if discovered_issuer.rstrip("/") != issuer.rstrip("/"):
raise OIDCError(f"OIDC discovered issuer '{discovered_issuer}' does not match configured issuer '{issuer}'")
self._metadata_cache[issuer] = (now, data)
return self._metadata_from_dict(data, overrides)
def _metadata_from_dict(self, data: dict[str, Any], overrides: dict[str, str | None] | None) -> OIDCMetadata:
"""Build OIDCMetadata from a discovery dict, applying endpoint overrides."""
overrides = overrides or {}
return OIDCMetadata(
issuer=data["issuer"],
authorization_endpoint=overrides.get("authorization_endpoint") or data["authorization_endpoint"],
token_endpoint=overrides.get("token_endpoint") or data["token_endpoint"],
userinfo_endpoint=overrides.get("userinfo_endpoint") or data.get("userinfo_endpoint"),
jwks_uri=overrides.get("jwks_uri") or data["jwks_uri"],
)
# ── Authorization URL ──────────────────────────────────────────────────
def build_authorization_url(
self,
metadata: OIDCMetadata,
client_id: str,
redirect_uri: str,
scopes: list[str],
state: str,
nonce: str | None = None,
code_challenge: str | None = None,
) -> str:
"""Build the OIDC authorization URL for the provider.
Returns a URL the browser should be redirected to.
"""
params: dict[str, str] = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": " ".join(scopes),
"state": state,
}
if nonce:
params["nonce"] = nonce
if code_challenge:
params["code_challenge"] = code_challenge
params["code_challenge_method"] = "S256"
return f"{metadata.authorization_endpoint}?{urlencode(params)}"
# ── Token exchange ─────────────────────────────────────────────────────
async def exchange_code(
self,
metadata: OIDCMetadata,
client_id: str,
client_secret: str | None,
code: str,
redirect_uri: str,
code_verifier: str | None = None,
auth_method: str = "client_secret_post",
) -> dict[str, Any]:
"""Exchange the authorization code for tokens at the token endpoint."""
data: dict[str, str] = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
}
if code_verifier:
data["code_verifier"] = code_verifier
headers: dict[str, str] = {"Accept": "application/json"}
if auth_method == "client_secret_basic" and client_secret:
import base64
creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode("ascii")
headers["Authorization"] = f"Basic {creds}"
elif auth_method == "client_secret_post" and client_secret:
data["client_secret"] = client_secret
try:
resp = await self._http.post(metadata.token_endpoint, data=data, headers=headers)
resp.raise_for_status()
return resp.json()
except httpx.HTTPStatusError as exc:
body = "unknown"
try:
body = exc.response.text[:200]
except Exception:
pass
raise OIDCError(f"Token exchange failed: HTTP {exc.response.status_code}{body}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"Token exchange failed: {exc}") from exc
# ── JWKS loading ───────────────────────────────────────────────────────
async def _load_jwks(self, jwks_uri: str, force_refresh: bool = False) -> dict[str, Any]:
"""Load (and cache) JWKS from the provider.
Set ``force_refresh=True`` to bypass the cache (e.g. on a kid miss).
"""
now = time.time()
cached = self._jwks_cache.get(jwks_uri)
if not force_refresh and cached and now - cached[0] < self._jwks_ttl:
return cached[1]
try:
resp = await self._http.get(jwks_uri)
resp.raise_for_status()
data: dict[str, Any] = resp.json()
except httpx.HTTPStatusError as exc:
raise OIDCError(f"JWKS fetch failed: HTTP {exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"JWKS fetch failed: {exc}") from exc
self._jwks_cache[jwks_uri] = (now, data)
return data
async def _resolve_signing_key(
self,
jwks_data: dict[str, Any],
kid: str | None,
algorithm: str,
jwks_uri: str,
) -> Any | None:
"""Find the signing key matching ``kid`` in the JWKS.
Returns the key object or ``None`` if no match is found. Catches
invalid JWK entries (e.g. wrong key type for the algorithm) and
logs a warning so a single bad entry does not crash validation.
"""
for jwk_dict in jwks_data.get("keys", []):
if kid and jwk_dict.get("kid") != kid:
continue
try:
jwk = PyJWK(jwk_dict, algorithm=algorithm)
return jwk.key
except jwt.PyJWTError as exc:
logger.warning("Skipping invalid JWK (kid=%s) from %s: %s", kid, jwks_uri, exc)
if not kid:
# No kid in token — try next key
continue
# kid was specified and this key is the one — fail fast
raise OIDCValidationError(f"JWK for kid={kid} is invalid: {exc}") from exc
return None
# ── ID token validation ────────────────────────────────────────────────
async def validate_id_token(
self,
metadata: OIDCMetadata,
client_id: str,
id_token: str,
nonce: str | None = None,
) -> dict[str, Any]:
"""Validate the ID token and return its claims.
Validates: signature (via JWKS), issuer, audience, expiration,
issued-at, and nonce (if provided).
"""
jwks_data = await self._load_jwks(metadata.jwks_uri)
# Resolve the signing key from the JWKS using the token's kid header
jwt_header = jwt.get_unverified_header(id_token)
kid = jwt_header.get("kid")
alg = jwt_header.get("alg", "RS256")
allowed_algorithms = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"]
if alg not in allowed_algorithms:
raise OIDCValidationError(f"ID token uses unsupported algorithm '{alg}'")
# Resolve signing key, refetching JWKS once on kid miss for key rotation
signing_key = await self._resolve_signing_key(jwks_data, kid, alg, metadata.jwks_uri)
if signing_key is None:
jwks_data = await self._load_jwks(metadata.jwks_uri, force_refresh=True)
signing_key = await self._resolve_signing_key(jwks_data, kid, alg, metadata.jwks_uri)
if signing_key is None:
raise OIDCValidationError(f"No matching JWK found for kid={kid} after JWKS refresh")
try:
claims = jwt.decode(
id_token,
key=signing_key,
algorithms=allowed_algorithms,
audience=client_id,
issuer=metadata.issuer,
options={
"verify_exp": True,
"verify_iat": True,
"require": ["exp", "iss", "sub", "aud"],
},
)
except jwt.ExpiredSignatureError:
raise OIDCValidationError("ID token has expired")
except jwt.InvalidIssuerError:
raise OIDCValidationError("ID token has an invalid issuer")
except jwt.InvalidAudienceError:
raise OIDCValidationError("ID token has an invalid audience")
except jwt.PyJWTError as exc:
raise OIDCValidationError(f"ID token validation failed: {exc}") from exc
# Validate nonce if expected
if nonce is not None:
token_nonce = claims.get("nonce")
if not token_nonce:
raise OIDCValidationError("ID token is missing the nonce claim")
if not _constant_time_compare(nonce, token_nonce):
raise OIDCValidationError("ID token nonce does not match")
return claims
# ── UserInfo ────────────────────────────────────────────────────────────
async def fetch_userinfo(self, metadata: OIDCMetadata, access_token: str, expected_sub: str) -> dict[str, Any]:
"""Fetch userinfo from the UserInfo endpoint.
Validates that the ``sub`` claim matches ``expected_sub``
(from the ID token) to prevent userinfo injection.
"""
if not metadata.userinfo_endpoint:
return {}
headers = {"Authorization": f"Bearer {access_token}"}
try:
resp = await self._http.get(metadata.userinfo_endpoint, headers=headers)
resp.raise_for_status()
userinfo: dict[str, Any] = resp.json()
except httpx.HTTPStatusError as exc:
raise OIDCError(f"UserInfo fetch failed: HTTP {exc.response.status_code}") from exc
except httpx.RequestError as exc:
raise OIDCError(f"UserInfo fetch failed: {exc}") from exc
if userinfo.get("sub") and userinfo["sub"] != expected_sub:
raise OIDCUserInfoMismatch("UserInfo sub does not match ID token sub")
return userinfo
# ── Orchestrated callback ──────────────────────────────────────────────
async def authenticate_callback(
self,
provider_id: str,
metadata: OIDCMetadata,
client_id: str,
client_secret: str | None,
code: str,
redirect_uri: str,
code_verifier: str | None = None,
nonce: str | None = None,
auth_method: str = "client_secret_post",
) -> OIDCIdentity:
"""Orchestrate the full OIDC callback: token exchange, ID token validation, userinfo.
Returns a normalized ``OIDCIdentity``.
"""
token_response = await self.exchange_code(
metadata=metadata,
client_id=client_id,
client_secret=client_secret,
code=code,
redirect_uri=redirect_uri,
code_verifier=code_verifier,
auth_method=auth_method,
)
id_token = token_response.get("id_token")
if not id_token:
raise OIDCError("Token response is missing id_token")
access_token = token_response.get("access_token", "")
claims = await self.validate_id_token(
metadata=metadata,
client_id=client_id,
id_token=id_token,
nonce=nonce,
)
# Fetch userinfo for email/name if not present in ID token
userinfo: dict[str, Any] = {}
if metadata.userinfo_endpoint and access_token:
try:
userinfo = await self.fetch_userinfo(
metadata=metadata,
access_token=access_token,
expected_sub=claims["sub"],
)
except OIDCError as exc:
logger.warning("OIDC userinfo fetch failed (continuing with ID token): %s", exc)
# Merge userinfo into claims (userinfo takes precedence for email)
merged = {**claims, **userinfo}
email = merged.get("email") or ""
email_verified = merged.get("email_verified") is True
return OIDCIdentity(
provider=provider_id,
subject=claims["sub"],
email=email,
email_verified=email_verified,
name=merged.get("name"),
claims=merged,
)
def _constant_time_compare(a: str, b: str) -> bool:
"""Constant-time string comparison."""
return secrets.compare_digest(a, b)

View file

@ -0,0 +1,121 @@
"""OIDC state management via signed HttpOnly cookies.
Stores OIDC state, nonce, and PKCE verifier in a short-lived signed cookie
instead of server-side storage. This keeps the implementation stateless and
compatible with multi-worker deployments without Redis.
"""
from __future__ import annotations
import secrets
import time
import jwt
from fastapi import Request, Response
from pydantic import BaseModel, Field
from app.gateway.auth.config import get_auth_config
from app.gateway.csrf_middleware import is_secure_request
OIDC_STATE_COOKIE_PREFIX = "df_oidc_state_"
OIDC_STATE_MAX_AGE = 300 # 5 minutes
OIDC_STATE_BYTES = 32
OIDC_NONCE_BYTES = 16
OIDC_CODE_VERIFIER_BYTES = 32
class OIDCStatePayload(BaseModel):
"""Payload stored inside the signed OIDC state cookie."""
provider: str = Field(description="OIDC provider ID (must match the state cookie)") # noqa: E501
state: str = Field(description="Cryptographically random state value — compared in constant time with the query param") # noqa: E501
nonce: str | None = Field(default=None, description="OIDC nonce, verified against the ID token nonce claim")
code_verifier: str | None = Field(default=None, description="PKCE code verifier, sent during token exchange")
next_path: str = Field(default="/workspace", description="Redirect target after successful auth")
issued_at: float = Field(default_factory=time.time, description="Unix timestamp of cookie creation")
def _sign_state_payload(payload: OIDCStatePayload) -> str:
"""Sign the state payload with the JWT secret to prevent tampering."""
secret = get_auth_config().jwt_secret
return jwt.encode(payload.model_dump(), secret, algorithm="HS256")
def _verify_state_signed(signed: str, max_age: int = OIDC_STATE_MAX_AGE) -> OIDCStatePayload | None:
"""Verify a signed state payload and return it, or None if invalid/expired."""
secret = get_auth_config().jwt_secret
try:
decoded = jwt.decode(signed, secret, algorithms=["HS256"])
payload = OIDCStatePayload(**decoded)
if time.time() - payload.issued_at > max_age:
return None
return payload
except jwt.PyJWTError:
return None
def generate_oidc_state() -> str:
"""Generate a cryptographically random state string."""
return secrets.token_urlsafe(OIDC_STATE_BYTES)
def generate_nonce() -> str:
"""Generate a cryptographically random nonce for ID token validation."""
return secrets.token_urlsafe(OIDC_NONCE_BYTES)
def generate_code_verifier() -> str:
"""Generate a PKCE code verifier (plain random string)."""
return secrets.token_urlsafe(OIDC_CODE_VERIFIER_BYTES)
def compute_code_challenge(verifier: str) -> str:
"""Compute the S256 PKCE code challenge from a verifier."""
import hashlib
return _base64url_encode(hashlib.sha256(verifier.encode("ascii")).digest())
def _base64url_encode(data: bytes) -> str:
"""Base64url-encode without padding, as required by RFC 7636 and OIDC."""
import base64
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _cookie_name(provider: str) -> str:
return f"{OIDC_STATE_COOKIE_PREFIX}{provider}"
def set_state_cookie(response: Response, request: Request, payload: OIDCStatePayload) -> None:
"""Set the signed OIDC state cookie on the response."""
signed = _sign_state_payload(payload)
is_https = is_secure_request(request)
response.set_cookie(
key=_cookie_name(payload.provider),
value=signed,
httponly=True,
secure=is_https,
samesite="lax",
max_age=OIDC_STATE_MAX_AGE,
path=f"/api/v1/auth/callback/{payload.provider}",
)
def get_state_cookie(request: Request, provider: str) -> OIDCStatePayload | None:
"""Read and verify the signed OIDC state cookie for the given provider."""
signed = request.cookies.get(_cookie_name(provider))
if not signed:
return None
return _verify_state_signed(signed)
def delete_state_cookie(response: Response, request: Request, provider: str) -> None:
"""Delete the OIDC state cookie."""
is_https = is_secure_request(request)
response.delete_cookie(
key=_cookie_name(provider),
secure=is_https,
samesite="lax",
path=f"/api/v1/auth/callback/{provider}",
)

View file

@ -0,0 +1,112 @@
"""User provisioning for OIDC logins.
Handles the logic of finding existing users, auto-creating new ones, and
enforcing email domain restrictions. A pre-existing local account is never
auto-linked to an OIDC identity: an email collision blocks the SSO login with
a 409 instead, so an SSO login can never seize a local password account.
"""
from __future__ import annotations
import logging
from fastapi import HTTPException, status
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.oidc import OIDCIdentity
from deerflow.config.auth_config import OIDCProviderConfig
logger = logging.getLogger(__name__)
async def get_or_provision_oidc_user(
provider_id: str,
provider_config: OIDCProviderConfig,
identity: OIDCIdentity,
local_provider: LocalAuthProvider,
) -> dict:
"""Resolve an OIDC identity to a DeerFlow user.
Flow:
1. Look up existing user by (provider, subject)
2. If not found, enforce domain/email-verified rules
3. Block if a local account already owns the email (never auto-link)
4. Auto-create if enabled
Returns a dict with ``user`` (the User model instance) and ``created`` (bool).
"""
# 1. Existing OAuth link
existing = await local_provider.get_user_by_oauth(provider_id, identity.subject)
if existing:
return {"user": existing, "created": False}
# 2. Verified email requirement
if provider_config.require_verified_email and not identity.email_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=("Your email could not be verified by the identity provider. Please contact your administrator."),
)
if not identity.email:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The identity provider did not provide an email address.",
)
email = identity.email.lower()
# 3. Domain restriction
if provider_config.allowed_email_domains:
domain = email.rsplit("@", 1)[-1]
if domain not in {d.lower().lstrip("@") for d in provider_config.allowed_email_domains}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your email domain is not allowed. Please use an approved email address.",
)
# 4. Block if a local account already owns this email. We never auto-link an
# SSO identity onto a pre-existing local account, since that would let an SSO
# login take over a password account that happens to share the email.
local_user = await local_provider.get_user_by_email(email)
if local_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=("An account with this email already exists. Contact your administrator to link it to your SSO account."),
)
# 5. Auto-create
if not provider_config.auto_create_users:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Automatic account creation is disabled. Contact your administrator.",
)
role = _resolve_role(email, provider_config.admin_emails)
try:
user = await local_provider.create_oauth_user(
email=email,
oauth_provider=provider_id,
oauth_id=identity.subject,
system_role=role,
)
except ValueError:
# Lost a race: a concurrent callback (double-click, replayed code) already
# inserted a row that collides on the unique index. Re-resolve instead of
# bubbling a raw 500. If the winner created this same identity, return it;
# otherwise the email now belongs to a different account → 409.
existing = await local_provider.get_user_by_oauth(provider_id, identity.subject)
if existing:
return {"user": existing, "created": False}
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=("An account with this email already exists. Contact your administrator to link it to your SSO account."),
) from None
logger.info("Auto-created OIDC user %s (provider=%s, role=%s)", email, provider_id, role)
return {"user": user, "created": True}
def _resolve_role(email: str, admin_emails: list[str]) -> str:
"""Return ``admin`` if the email is in the admin list, otherwise ``user``."""
email_lower = email.lower()
return "admin" if any(e.lower() == email_lower for e in admin_emails) else "user"

View file

@ -53,4 +53,5 @@ def get_auth_disabled_user():
system_role="admin",
needs_setup=False,
token_version=0,
oauth_provider=None,
)

View file

@ -34,6 +34,8 @@ _PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/docs",
"/redoc",
"/openapi.json",
"/api/v1/auth/oauth/",
"/api/v1/auth/callback/",
)
# Exact auth paths that are public (login/register/status check).
@ -45,6 +47,7 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
"/api/v1/auth/initialize",
"/api/v1/auth/providers",
}
)

View file

@ -3,12 +3,16 @@
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, 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,
@ -16,8 +20,21 @@ from app.gateway.auth import (
)
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.csrf_middleware import is_secure_request
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.user_provisioning import get_or_provision_oidc_user
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, _request_origin, 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__)
@ -320,7 +337,7 @@ async def register(request: Request, response: Response, body: RegisterRequest):
token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, token, request)
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
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)
@ -390,7 +407,13 @@ async def change_password(request: Request, response: Response, body: ChangePass
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)
return UserResponse(
id=str(user.id),
email=user.email,
system_role=user.system_role,
needs_setup=user.needs_setup,
oauth_provider=user.oauth_provider,
)
# Per-IP cache: ip → (timestamp, result_dict).
@ -499,39 +522,301 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, token, request)
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role)
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, oauth_provider=user.oauth_provider)
# ── OAuth Endpoints (Future/Placeholder) ─────────────────────────────────
# ── 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()
is_https = is_secure_request(request)
response.set_cookie(
key=CSRF_COOKIE_NAME,
value=csrf_token,
httponly=False, # Must be JS-readable for Double Submit Cookie pattern
secure=is_https,
samesite="strict",
)
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(provider: str):
"""Initiate OAuth login flow.
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)
):
"""Initiate OIDC login flow.
Redirects to the OAuth provider's authorization URL.
Currently a placeholder - requires OAuth provider implementation.
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).
"""
if provider not in ["github", "google"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported OAuth provider: {provider}",
)
from deerflow.config.app_config import get_app_config
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="OAuth login not yet implemented",
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,
)
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(provider: str, code: str, state: str):
"""OAuth callback endpoint.
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 OAuth provider's callback after user authorization.
Currently a placeholder.
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.
"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="OAuth callback not yet implemented",
)
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)
redirect_target = 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)
# 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, and URLs with embedded protocols.
"""
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
return next_param

View file

@ -16,7 +16,6 @@
非目标:
- 当前 OAuth 端点只是占位,尚未实现第三方登录。
- 当前用户角色只有 `admin``user`,尚未实现细粒度 RBAC。
- 当前登录限速是进程内字典,多 worker 下不是全局精确限速。
@ -155,6 +154,9 @@ enum UserScope:
- `/api/v1/auth/logout`
- `/api/v1/auth/setup-status`
- `/api/v1/auth/initialize`
- `/api/v1/auth/providers`
- `/api/v1/auth/oauth/` (所有子路径)
- `/api/v1/auth/callback/` (所有子路径)
其余路径都要求有效 `access_token` cookie。存在 cookie 但 JWT 无效、过期、用户不存在或 `token_version` 不匹配时,直接返回 401而不是让请求穿透到业务路由。
@ -303,7 +305,7 @@ PYTHONPATH=. python scripts/migrate_user_isolation.py --user-id <target-user-id>
|---|---|---|
| 无 admin 时注册普通用户 | 允许注册普通 `user` | 如产品要求先初始化 admin`/register` 加 gate |
| 登录限速 | 进程内 dict单 worker 精确,多 worker 近似 | Redis / DB-backed rate limiter |
| OAuth | 端点占位,未实现 | 接入 provider 并统一 `token_version` / role 语义 |
| OAuth / OIDC | 已实现通用 OIDC SSOKeycloak, Google, Azure AD, Okta 等),支持 PKCE + nonce、auto-provisioning、email domain 限制(详见 [SSO.md](SSO.md) | 支持 RP-initiated logout、自定义 scope 映射 |
| IM 用户隔离 | channel 使用 `default` 内部用户 | 建立外部用户到 DeerFlow user 的映射 |
| 绝对 memory path | 显式共享 memory | UI / docs 明确提示 opt-out 风险 |
@ -313,8 +315,13 @@ PYTHONPATH=. python scripts/migrate_user_isolation.py --user-id <target-user-id>
|---|---|
| `app/gateway/auth_middleware.py` | 全局认证门、JWT 严格验证、写入 user context |
| `app/gateway/csrf_middleware.py` | CSRF double-submit 和 auth Origin 校验 |
| `app/gateway/routers/auth.py` | initialize/login/register/logout/me/change-password |
| `app/gateway/routers/auth.py` | initialize/login/register/logout/me/change-password + SSO OIDC 端点providers/oauth/callback |
| `app/gateway/auth/jwt.py` | JWT 创建与解析 |
| `app/gateway/auth/oidc.py` | OIDC 核心服务discovery、token exchange、ID token 验证、userinfo |
| `app/gateway/auth/oidc_state.py` | OIDC state 管理signed cookie 存储 state/nonce/code_verifier |
| `app/gateway/auth/user_provisioning.py` | OIDC 用户自动创建、email linking、domain 限制 |
| `app/gateway/auth/models.py` | 用户数据模型(含 `oauth_provider` / `oauth_id` |
| `packages/harness/deerflow/config/auth_config.py` | OIDC 配置模型OIDCProviderConfig / OIDCAuthConfig |
| `app/gateway/auth/reset_admin.py` | 密码 reset CLI |
| `app/gateway/auth/credential_file.py` | 0600 凭据文件写入 |
| `app/gateway/authz.py` | 路由权限与 owner check |

280
backend/docs/SSO.md Normal file
View file

@ -0,0 +1,280 @@
# SSO / OIDC Authentication
DeerFlow supports single sign-on (SSO) via any OpenID Connect (OIDC) 2.0 compliant provider. This includes Keycloak, Google Workspace, Azure AD, Okta, and many others.
## Architecture
The OIDC flow uses the **Authorization Code flow** with PKCE (S256) and nonce validation for defense in depth:
```
Browser Gateway OIDC Provider
│ │ │
│ 1. Click "Login with X" │ │
│ ─────────────────────────▶ │ │
│ │ 2. Build auth URL │
│ │ + state (signed cookie)│
│ │ + PKCE code_challenge │
│ │ + nonce │
│ │ │
│ 3. Redirect to provider │ │
│ ◀────────────────────────── │ │
│ │ │
│ ──────────────────────────────────────────────────────▶ │
│ │ 4. User authenticates │
│ ◀────────────────────────────────────────────────────── │
│ 5. Auth code + state │ │
│ │ │
│ 6. Callback → Gateway │ │
│ ─────────────────────────▶ │ │
│ │ 7. Validate state cookie │
│ │ 8. Exchange code + PKCE │
│ │ ─────────────────────▶ │
│ │ ◀──── tokens ──────────│
│ │ 9. Validate ID token │
│ │ (JWKS, iss, aud, nonce)│
│ │ 10. Fetch userinfo │
│ │ ─────────────────────▶ │
│ │ ◀──── user claims ─────│
│ │ 11. Provision/link user │
│ │ 12. Set session + CSRF │
│ │ cookies │
│ ◀─ redirect to /auth/callback │
│ │ │
│ 13. Frontend detects auth │ │
│ redirects to workspace │ │
```
**Key design decisions:**
- **State via signed cookie** — No server-side session store or Redis needed. The OIDC state (provider, nonce, code_verifier, next path) is signed with the JWT secret and stored in an HttpOnly cookie.
- **PKCE + nonce enabled by default** — Even though confidential clients could use `client_secret`, PKCE provides an extra layer of security.
- **No email auto-linking** — a pre-existing local (email/password) account is never auto-linked to an SSO identity. If the IdP-reported email collides with an existing local account, the SSO login is blocked with a 409 so an SSO login can never seize a password account.
- **Existing DeerFlow JWT** — After successful OIDC authentication, DeerFlow creates its own JWT session cookie. The OIDC provider's tokens are never exposed to the browser.
## Configuration
### Step 1: Enable OIDC in `config.yaml`
```yaml
auth:
oidc:
enabled: true
frontend_base_url: http://localhost:3000
providers:
keycloak:
display_name: Keycloak
issuer: http://localhost:8080/realms/deerflow
client_id: deerflow
client_secret: $KEYCLOAK_CLIENT_SECRET
redirect_uri: http://localhost:8001/api/v1/auth/callback/keycloak
scopes:
- openid
- email
- profile
```
### Step 2: Set the client secret as an environment variable
```bash
export KEYCLOAK_CLIENT_SECRET="your-client-secret"
```
Or create a `.env` file in the `backend/` directory:
```
KEYCLOAK_CLIENT_SECRET=your-client-secret
```
### Step 3: Restart the backend
```bash
cd backend && make dev
```
## Provider Configuration
### Per-Provider Options
```yaml
providers:
<provider-id>:
display_name: "Display Name" # Shown on the SSO button
issuer: "https://..." # OIDC issuer URL (must match the provider's .well-known/openid-configuration)
client_id: "..." # OAuth2 client ID
client_secret: $SECRET # OAuth2 client secret (supports $ENV_VAR)
redirect_uri: "..." # Optional: explicit callback URL
scopes: # Default: ["openid", "email", "profile"]
- openid
- email
token_endpoint_auth_method: "client_secret_post" # client_secret_post, client_secret_basic, or none
# User provisioning
auto_create_users: true # Auto-create DeerFlow account on first SSO login (default: true)
require_verified_email: true # Reject logins without verified email (default: true)
allowed_email_domains: [] # Restrict to specific domains (default: no restriction)
admin_emails: [] # Auto-grant admin role to these emails (default: none)
# Security features (both enabled by default)
pkce_enabled: true
nonce_enabled: true
# Endpoint overrides (optional)
# Use if the provider has non-standard endpoints.
# authorization_endpoint: "https://..."
# token_endpoint: "https://..."
# userinfo_endpoint: "https://..."
# jwks_uri: "https://..."
```
### Endpoint Overrides
Some providers don't return all endpoints from their `.well-known/openid-configuration`. You can override specific endpoints:
```yaml
providers:
my-provider:
display_name: "My Provider"
issuer: "https://provider.example.com"
client_id: "..."
client_secret: $SECRET
authorization_endpoint: "https://provider.example.com/oauth2/authorize"
token_endpoint: "https://provider.example.com/oauth2/token"
userinfo_endpoint: "https://provider.example.com/oauth2/userinfo"
jwks_uri: "https://provider.example.com/oauth2/jwks"
```
## Local Keycloak Example
This section walks through setting up a local Keycloak instance with Podman or Docker for development.
### 1. Start Keycloak
```bash
# Using Podman
podman run -d \
--name keycloak \
-p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.1 \
start-dev
# Using Docker
docker run -d \
--name keycloak \
-p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.1 \
start-dev
```
### 2. Create a Realm and Client
1. Open the Keycloak admin console: http://localhost:8080
2. Log in with `admin` / `admin`
3. Create a new realm called `deerflow`
4. In the `deerflow` realm, go to **Clients** → **Create client**
5. Configure:
- **Client ID**: `deerflow`
- **Client authentication**: On (makes it a confidential client)
- **Standard flow**: Enabled
- **Valid redirect URIs**: `http://localhost:8001/api/v1/auth/callback/keycloak`
- **Valid post logout redirect URIs**: `http://localhost:3000/*`
- **Web origins**: `http://localhost:8001` (or `+` to allow all redirect URI origins)
6. After creating the client, go to the **Credentials** tab
7. Copy the **Client secret** — this is your `KEYCLOAK_CLIENT_SECRET`
### 3. Create a Test User
1. In the `deerflow` realm, go to **Users** → **Add user**
2. Set **Username**: `testuser`
3. Set **Email**: `testuser@example.com`
4. Set **Email verified**: On
5. Go to the **Credentials** tab
6. Set a password (e.g. `testpass123`)
7. Set **Temporary**: Off
### 4. Configure DeerFlow
Add to `config.yaml`:
```yaml
auth:
oidc:
enabled: true
frontend_base_url: http://localhost:3000
providers:
keycloak:
display_name: Keycloak
issuer: http://localhost:8080/realms/deerflow
client_id: deerflow
client_secret: $KEYCLOAK_CLIENT_SECRET
redirect_uri: http://localhost:8001/api/v1/auth/callback/keycloak
scopes:
- openid
- email
- profile
```
Set the secret:
```bash
export KEYCLOAK_CLIENT_SECRET="the-secret-from-step-2"
```
### 5. Restart and Test
```bash
cd backend && make dev
```
1. Open http://localhost:3000
2. On the login page, click **Login with Keycloak**
3. You'll be redirected to Keycloak's login page
4. Log in with `testuser` / `testpass123`
5. After successful authentication, you'll be redirected back to the DeerFlow workspace
## Account Settings for SSO Users
When a user logs in via SSO, the account settings page detects this (via the `oauth_provider` field returned by `/api/v1/auth/me`) and:
- Displays the SSO provider name (e.g. "Keycloak") in the profile section
- Replaces the password change form with an informational message
- Password changes must be done through the SSO provider, not DeerFlow
The backend also rejects password change requests for OAuth users:
```json
{
"code": "invalid_credentials",
"message": "OAuth users cannot change password"
}
```
## Public API Endpoints
| Endpoint | Description |
|----------|-------------|
| `GET /api/v1/auth/providers` | Returns list of enabled SSO providers (safe metadata only) |
| `GET /api/v1/auth/oauth/{provider}` | Initiates SSO login, redirects to the OIDC provider |
| `GET /api/v1/auth/callback/{provider}` | OIDC callback — exchanges code, creates session, redirects to frontend |
## Frontend Callback Flow
The frontend handles the post-SSO flow at `/auth/callback`:
1. After the backend processes the OIDC callback and sets cookies, it redirects to `{frontend_base_url}/auth/callback?next=...`
2. The callback page calls `GET /api/v1/auth/me`
3. On success: redirects to the workspace (or the original `next` path)
4. On failure: redirects to `/login?error=sso_failed`
## Security Notes
- **State cookies** are HttpOnly, SameSite=Lax, Max-Age=300 seconds, and signed with the JWT secret
- **PKCE** prevents authorization code interception attacks
- **Nonce** prevents ID token replay attacks
- **UserInfo sub check** ensures the UserInfo response matches the ID token subject
- **Reject alg=none** — ID tokens with algorithm "none" are always rejected
- **No email auto-linking** — SSO accounts are always separate from email/password accounts. An email collision with an existing local account blocks the SSO login (409) rather than merging the two.
- **Verified email requirement** — SSO users must have verified emails by default

View file

@ -12,6 +12,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
from deerflow.config.acp_config import ACPAgentConfig, load_acp_config_from_dict
from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict
from deerflow.config.auth_config import AuthAppConfig
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
from deerflow.config.database_config import DatabaseConfig
@ -129,6 +130,7 @@ class AppConfig(BaseModel):
)
loop_detection: LoopDetectionConfig = Field(default_factory=LoopDetectionConfig, description="Loop detection middleware configuration")
safety_finish_reason: SafetyFinishReasonConfig = Field(default_factory=SafetyFinishReasonConfig, description="Provider safety-filter finish_reason interception middleware configuration")
auth: AuthAppConfig = Field(default_factory=AuthAppConfig, description="Authentication configuration (local + OIDC SSO)")
model_config = ConfigDict(extra="allow")
database: DatabaseConfig = Field(
default_factory=DatabaseConfig,

View file

@ -0,0 +1,73 @@
"""OIDC / SSO authentication configuration models."""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
class OIDCProviderConfig(BaseModel):
"""Configuration for a single OIDC identity provider (Keycloak, Google, Azure AD, etc.)."""
display_name: str = Field(description="Human-readable name shown on the login button")
issuer: str = Field(description="OIDC issuer URL (e.g. https://keycloak.example.com/realms/deerflow)")
client_id: str = Field(description="OAuth2 client ID assigned by the provider")
client_secret: str | None = Field(default=None, description="OAuth2 client secret ($ENV_VAR references supported)")
redirect_uri: str | None = Field(default=None, description="Callback URL the provider will redirect to after auth")
scopes: list[str] = Field(
default_factory=lambda: ["openid", "email", "profile"],
description="OIDC scopes to request (must include openid)",
)
token_endpoint_auth_method: Literal["client_secret_post", "client_secret_basic", "none"] = Field(
default="client_secret_post",
description="How the client authenticates at the token endpoint",
)
# ── User provisioning ─────────────────────────────────────────────
auto_create_users: bool = Field(
default=True,
description="Automatically create a DeerFlow user on first SSO login",
)
require_verified_email: bool = Field(
default=True,
description="Reject authentication if the provider does not report the email as verified",
)
allowed_email_domains: list[str] = Field(
default_factory=list,
description="If non-empty, only allow users whose email domain is in this list (e.g. ['example.com'])",
)
admin_emails: list[str] = Field(
default_factory=list,
description="Users with these email addresses are automatically granted the admin role on first login",
)
# ── PKCE / nonce ──────────────────────────────────────────────────
pkce_enabled: bool = Field(default=True, description="Enable PKCE (S256) for the authorization code flow")
nonce_enabled: bool = Field(default=True, description="Include and validate the nonce claim in ID tokens")
# ── Endpoint overrides (for providers with non-standard discovery) ─
authorization_endpoint: str | None = Field(default=None)
token_endpoint: str | None = Field(default=None)
userinfo_endpoint: str | None = Field(default=None)
jwks_uri: str | None = Field(default=None)
class OIDCAuthConfig(BaseModel):
"""Top-level OIDC authentication configuration."""
enabled: bool = Field(default=False, description="Enable OIDC SSO authentication")
frontend_base_url: str | None = Field(
default=None,
description="Base URL of the frontend (used for callback redirects when behind a reverse proxy)",
)
providers: dict[str, OIDCProviderConfig] = Field(
default_factory=dict,
description="Map of provider IDs to their configuration (e.g. keycloak, google, azure)",
)
class AuthAppConfig(BaseModel):
"""Authentication configuration section for the DeerFlow app config."""
oidc: OIDCAuthConfig = Field(default_factory=OIDCAuthConfig, description="OIDC SSO authentication settings")

View file

@ -817,3 +817,10 @@ def test_authenticate_skips_rehash_for_v2_hash():
result = asyncio.run(provider.authenticate({"email": "v2@test.com", "password": password}))
assert result is not None
mock_repo.update_user.assert_not_called()
def test_validate_next_param_rejects_colon_paths():
from app.gateway.routers.auth import validate_next_param
assert validate_next_param("/workspace") == "/workspace"
assert validate_next_param("/:evil") is None

View file

@ -530,6 +530,21 @@ def test_api_auth_me_no_cookie_returns_structured_401():
assert "message" in body["detail"]
def test_api_auth_me_auth_disabled_returns_synthetic_user(monkeypatch):
_setup_config()
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = _get_auth_client()
resp = client.get("/api/v1/auth/me")
assert resp.status_code == 200
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID
body = resp.json()
assert body["id"] == AUTH_DISABLED_USER_ID
assert body["oauth_provider"] is None
def test_api_auth_me_expired_token_returns_structured_401():
"""/api/v1/auth/me with expired token → 401 with {code: 'token_expired'}."""
_setup_config()

View file

@ -11,7 +11,7 @@ from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import FastAPI
@ -39,10 +39,13 @@ async def _run_lifespan_with_hanging_stop() -> float:
async def fake_start():
return fake_service
close_oidc_service = AsyncMock()
with (
patch("app.gateway.app.get_app_config"),
patch("app.gateway.app.get_gateway_config", return_value=MagicMock(host="x", port=0)),
patch("app.gateway.app.langgraph_runtime", _noop_langgraph_runtime),
patch("app.gateway.app.auth.close_oidc_service", close_oidc_service),
patch("app.channels.service.start_channel_service", side_effect=fake_start),
patch("app.channels.service.stop_channel_service", side_effect=hang_forever),
):
@ -52,6 +55,7 @@ async def _run_lifespan_with_hanging_stop() -> float:
pass
elapsed = loop.time() - start
close_oidc_service.assert_awaited_once()
assert _SHUTDOWN_HOOK_TIMEOUT_SECONDS < 30.0, "Timeout constant must stay modest"
return elapsed

View file

@ -0,0 +1,407 @@
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from app.gateway.auth.models import User
from app.gateway.auth.oidc import OIDCError, OIDCIdentity, OIDCMetadata, OIDCService, OIDCValidationError
from app.gateway.auth.user_provisioning import get_or_provision_oidc_user
from deerflow.config.auth_config import OIDCProviderConfig
def _provider_config(**overrides):
return OIDCProviderConfig(
display_name="Test SSO",
issuer="https://issuer.example.com",
client_id="deer-flow",
**overrides,
)
def _identity(**overrides):
values = {
"provider": "keycloak",
"subject": "oidc-subject",
"email": "user@example.com",
"email_verified": True,
"name": "Test User",
"claims": {},
}
values.update(overrides)
return OIDCIdentity(**values)
@pytest.mark.asyncio
async def test_oidc_existing_local_account_blocks_sso_login_even_when_unverified():
local_user = User(email="user@example.com", password_hash="hash")
local_provider = AsyncMock()
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = local_user
with pytest.raises(HTTPException) as exc_info:
await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(
require_verified_email=False,
auto_create_users=False,
),
identity=_identity(email_verified=False),
local_provider=local_provider,
)
assert exc_info.value.status_code == 409
local_provider.update_user.assert_not_called()
@pytest.mark.asyncio
async def test_oidc_existing_local_account_blocks_sso_login_even_when_verified():
local_user = User(email="user@example.com", password_hash="hash")
local_provider = AsyncMock()
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = local_user
with pytest.raises(HTTPException) as exc_info:
await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(auto_create_users=False),
identity=_identity(subject="verified-subject"),
local_provider=local_provider,
)
assert exc_info.value.status_code == 409
local_provider.update_user.assert_not_called()
local_provider.create_oauth_user.assert_not_called()
@pytest.mark.asyncio
async def test_oidc_auto_create_assigns_admin_role_from_configured_email():
local_provider = AsyncMock()
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = None
created_user = User(
email="admin@example.com",
password_hash=None,
system_role="admin",
oauth_provider="keycloak",
oauth_id="admin-subject",
)
local_provider.create_oauth_user.return_value = created_user
result = await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(admin_emails=["ADMIN@example.com"]),
identity=_identity(subject="admin-subject", email="admin@example.com"),
local_provider=local_provider,
)
assert result == {"user": created_user, "created": True}
local_provider.create_oauth_user.assert_awaited_once_with(
email="admin@example.com",
oauth_provider="keycloak",
oauth_id="admin-subject",
system_role="admin",
)
@pytest.mark.asyncio
async def test_oidc_validate_id_token_refreshes_jwks_once_on_kid_miss(monkeypatch):
service = OIDCService()
metadata = OIDCMetadata(
issuer="https://issuer.example.com",
authorization_endpoint="https://issuer.example.com/auth",
token_endpoint="https://issuer.example.com/token",
userinfo_endpoint=None,
jwks_uri="https://issuer.example.com/jwks",
)
load_calls = []
resolve_results = [None, "signing-key"]
async def load_jwks(jwks_uri, force_refresh=False):
load_calls.append(force_refresh)
return {"keys": []}
async def resolve_signing_key(jwks_data, kid, algorithm, jwks_uri):
return resolve_results.pop(0)
monkeypatch.setattr(service, "_load_jwks", load_jwks)
monkeypatch.setattr(service, "_resolve_signing_key", resolve_signing_key)
monkeypatch.setattr("app.gateway.auth.oidc.jwt.get_unverified_header", lambda token: {"kid": "new-kid", "alg": "RS256"})
monkeypatch.setattr(
"app.gateway.auth.oidc.jwt.decode",
lambda *args, **kwargs: {"iss": metadata.issuer, "sub": "subject", "aud": "deer-flow", "exp": 9999999999},
)
claims = await service.validate_id_token(metadata, "deer-flow", "id-token")
assert claims["sub"] == "subject"
assert load_calls == [False, True]
await service.close()
@pytest.mark.asyncio
async def test_oidc_validate_id_token_rejects_hmac_algorithms(monkeypatch):
service = OIDCService()
metadata = OIDCMetadata(
issuer="https://issuer.example.com",
authorization_endpoint="https://issuer.example.com/auth",
token_endpoint="https://issuer.example.com/token",
userinfo_endpoint=None,
jwks_uri="https://issuer.example.com/jwks",
)
async def load_jwks(jwks_uri, force_refresh=False):
return {"keys": [{"kid": "kid", "kty": "oct", "k": "secret"}]}
async def resolve_signing_key(jwks_data, kid, algorithm, jwks_uri):
return "secret"
def decode(*args, **kwargs):
assert "HS256" not in kwargs["algorithms"]
raise OIDCValidationError("HMAC algorithms must not be accepted")
monkeypatch.setattr(service, "_load_jwks", load_jwks)
monkeypatch.setattr(service, "_resolve_signing_key", resolve_signing_key)
monkeypatch.setattr("app.gateway.auth.oidc.jwt.get_unverified_header", lambda token: {"kid": "kid", "alg": "HS256"})
monkeypatch.setattr("app.gateway.auth.oidc.jwt.decode", decode)
with pytest.raises(OIDCValidationError, match="unsupported algorithm"):
await service.validate_id_token(metadata, "deer-flow", "id-token")
await service.close()
@pytest.mark.asyncio
async def test_oidc_existing_account_lookup_uses_normalized_email():
local_user = User(email="user@example.com", password_hash="hash")
local_provider = AsyncMock()
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = local_user
with pytest.raises(HTTPException) as exc_info:
await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(auto_create_users=False),
identity=_identity(email="User@Example.COM"),
local_provider=local_provider,
)
assert exc_info.value.status_code == 409
local_provider.get_user_by_email.assert_awaited_once_with("user@example.com")
@pytest.mark.asyncio
async def test_oidc_auto_create_uses_normalized_email():
local_provider = AsyncMock()
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = None
created_user = User(email="user@example.com", password_hash=None, oauth_provider="keycloak", oauth_id="subject")
local_provider.create_oauth_user.return_value = created_user
await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(),
identity=_identity(subject="subject", email="User@Example.COM"),
local_provider=local_provider,
)
local_provider.create_oauth_user.assert_awaited_once_with(
email="user@example.com",
oauth_provider="keycloak",
oauth_id="subject",
system_role="user",
)
@pytest.mark.asyncio
async def test_oidc_metadata_from_dict_accepts_missing_overrides():
service = OIDCService()
metadata = service._metadata_from_dict(
{
"issuer": "https://issuer.example.com",
"authorization_endpoint": "https://issuer.example.com/auth",
"token_endpoint": "https://issuer.example.com/token",
"userinfo_endpoint": "https://issuer.example.com/userinfo",
"jwks_uri": "https://issuer.example.com/jwks",
},
None,
)
assert metadata.jwks_uri == "https://issuer.example.com/jwks"
await service.close()
@pytest.mark.asyncio
async def test_oidc_authenticate_callback_treats_string_false_email_verified_as_unverified(monkeypatch):
service = OIDCService()
metadata = OIDCMetadata(
issuer="https://issuer.example.com",
authorization_endpoint="https://issuer.example.com/auth",
token_endpoint="https://issuer.example.com/token",
userinfo_endpoint=None,
jwks_uri="https://issuer.example.com/jwks",
)
async def exchange_code(**kwargs):
return {"id_token": "id-token"}
async def validate_id_token(**kwargs):
return {"sub": "subject", "email": "user@example.com", "email_verified": "false"}
monkeypatch.setattr(service, "exchange_code", exchange_code)
monkeypatch.setattr(service, "validate_id_token", validate_id_token)
identity = await service.authenticate_callback(
provider_id="keycloak",
metadata=metadata,
client_id="deer-flow",
client_secret=None,
code="code",
redirect_uri="https://app.example.com/callback",
)
assert identity.email_verified is False
await service.close()
@pytest.mark.asyncio
async def test_oidc_provision_recovers_existing_user_on_create_race():
"""A concurrent create that loses the unique index re-resolves to the winner's row."""
created_user = User(email="user@example.com", password_hash=None, oauth_provider="keycloak", oauth_id="subject")
local_provider = AsyncMock()
# First lookup (by oauth) misses, then create races and raises, then re-lookup wins.
local_provider.get_user_by_oauth.side_effect = [None, created_user]
local_provider.get_user_by_email.return_value = None
local_provider.create_oauth_user.side_effect = ValueError("Email already registered: user@example.com")
result = await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(),
identity=_identity(subject="subject"),
local_provider=local_provider,
)
assert result == {"user": created_user, "created": False}
assert local_provider.get_user_by_oauth.await_count == 2
@pytest.mark.asyncio
async def test_oidc_provision_create_race_on_email_only_raises_409():
"""A create race that collides on email (different identity) surfaces a clean 409, not a 500."""
local_provider = AsyncMock()
# No existing oauth link before or after the race (email collision, not same subject).
local_provider.get_user_by_oauth.return_value = None
local_provider.get_user_by_email.return_value = None
local_provider.create_oauth_user.side_effect = ValueError("Email already registered: user@example.com")
with pytest.raises(HTTPException) as exc_info:
await get_or_provision_oidc_user(
provider_id="keycloak",
provider_config=_provider_config(),
identity=_identity(subject="subject"),
local_provider=local_provider,
)
assert exc_info.value.status_code == 409
@pytest.mark.asyncio
async def test_oidc_discover_rejects_mismatched_issuer(monkeypatch):
service = OIDCService()
class _Resp:
def raise_for_status(self):
return None
def json(self):
return {
"issuer": "https://evil.example.com",
"authorization_endpoint": "https://issuer.example.com/auth",
"token_endpoint": "https://issuer.example.com/token",
"jwks_uri": "https://issuer.example.com/jwks",
}
async def fake_get(url):
return _Resp()
monkeypatch.setattr(service._http, "get", fake_get)
with pytest.raises(OIDCError, match="does not match configured issuer"):
await service.discover("https://issuer.example.com")
await service.close()
@pytest.mark.asyncio
async def test_oidc_discover_accepts_issuer_with_trailing_slash_difference(monkeypatch):
service = OIDCService()
class _Resp:
def raise_for_status(self):
return None
def json(self):
return {
"issuer": "https://issuer.example.com/",
"authorization_endpoint": "https://issuer.example.com/auth",
"token_endpoint": "https://issuer.example.com/token",
"jwks_uri": "https://issuer.example.com/jwks",
}
async def fake_get(url):
return _Resp()
monkeypatch.setattr(service._http, "get", fake_get)
metadata = await service.discover("https://issuer.example.com")
assert metadata.issuer == "https://issuer.example.com/"
await service.close()
def _redirect_request(headers: dict, scheme: str = "http", netloc: str = "localhost:8001"):
from unittest.mock import MagicMock
req = MagicMock()
req.headers = headers
req.url.scheme = scheme
req.url.netloc = netloc
return req
def test_oidc_redirect_uri_prefers_configured_value():
from app.gateway.routers.auth import _resolve_oidc_redirect_uri
cfg = _provider_config(redirect_uri="https://app.example.com/api/v1/auth/callback/keycloak")
req = _redirect_request({"host": "attacker.example.com"})
assert _resolve_oidc_redirect_uri(req, "keycloak", cfg) == "https://app.example.com/api/v1/auth/callback/keycloak"
def test_oidc_redirect_uri_fallback_uses_forwarded_headers_not_raw_host():
from app.gateway.routers.auth import _resolve_oidc_redirect_uri
cfg = _provider_config()
# Raw Host is attacker-controlled; proxy-set X-Forwarded-* must win.
req = _redirect_request(
{
"host": "attacker.example.com",
"x-forwarded-host": "app.example.com",
"x-forwarded-proto": "https",
}
)
result = _resolve_oidc_redirect_uri(req, "keycloak", cfg)
assert result == "https://app.example.com/api/v1/auth/callback/keycloak"
def test_oidc_redirect_uri_fallback_plain_host_when_no_proxy_headers():
from app.gateway.routers.auth import _resolve_oidc_redirect_uri
cfg = _provider_config()
req = _redirect_request({"host": "localhost:8001"})
result = _resolve_oidc_redirect_uri(req, "keycloak", cfg)
assert result == "http://localhost:8001/api/v1/auth/callback/keycloak"

View file

@ -1429,3 +1429,53 @@ run_events:
# failure_threshold: 5
# # Time in seconds before attempting to recover (default: 60)
# recovery_timeout_sec: 60
# ============================================================================
# SSO / OIDC Authentication (optional)
# ============================================================================
# Enable SSO login via any OIDC-compatible provider (Keycloak, Google, Azure AD, Okta, etc.).
# When enabled, the login page will show SSO buttons alongside the standard email/password form.
#
# Provider configuration:
# - issuer: The OIDC issuer URL (e.g. https://keycloak.example.com/realms/deerflow)
# - client_id: OAuth2 client ID from the provider
# - client_secret: OAuth2 client secret ($ENV_VAR references supported)
# - redirect_uri: Callback URL. In production, this must match what you configure in
# the provider. Defaults to a self-derived URL in development.
#
# Keycloak setup:
# 1. Create a client with type "confidential" and Standard Flow enabled
# 2. Add Valid Redirect URI: http://localhost:8001/api/v1/auth/callback/keycloak
# 3. Add Web Origin: http://localhost:8001 (or your frontend origin)
# auth:
# oidc:
# enabled: true
# # Base URL of the frontend, used for redirects after SSO callback.
# # In production behind a reverse proxy, set this to the public frontend URL
# # (e.g. https://deerflow.example.com). In development, leave unset.
# # frontend_base_url: http://localhost:3000
# providers:
# keycloak:
# display_name: Keycloak
# issuer: https://keycloak.example.com/realms/deerflow
# client_id: deerflow
# client_secret: $KEYCLOAK_CLIENT_SECRET
# # Optional: explicitly set the callback URL.
# # redirect_uri: https://deerflow.example.com/api/v1/auth/callback/keycloak
# scopes:
# - openid
# - email
# - profile
# token_endpoint_auth_method: client_secret_post
#
# # User provisioning settings (safe defaults shown below):
# auto_create_users: true # Auto-create DeerFlow account on first SSO login
# require_verified_email: true # Reject SSO logins without verified email
# # allowed_email_domains: # Restrict to specific email domains
# # - example.com
# admin_emails: [] # Auto-grant admin role to these emails
#
# # Security features (enabled by default):
# pkce_enabled: true # PKCE (S256) for authorization code flow
# nonce_enabled: true # Nonce validation in ID tokens

View file

@ -0,0 +1,73 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useCallback, useRef } from "react";
/**
* Validates the next parameter only allows relative paths starting with /.
*/
function validateNextParam(next: string | null): string {
if (!next) return "/workspace";
if (!next.startsWith("/") || next.startsWith("//")) return "/workspace";
if (next.startsWith("http://") || next.startsWith("https://"))
return "/workspace";
if (next.includes(":")) return "/workspace";
return next;
}
export default function AuthCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<"loading" | "success" | "error">(
"loading",
);
const calledRef = useRef(false);
const doAuthCheck = useCallback(async () => {
if (calledRef.current) return;
calledRef.current = true;
const next = validateNextParam(searchParams.get("next"));
try {
const res = await fetch("/api/v1/auth/me", { credentials: "include" });
if (res.ok) {
setStatus("success");
// Small delay so the user sees the success message
setTimeout(() => router.replace(next), 300);
} else {
setStatus("error");
setTimeout(() => router.replace("/login?error=sso_failed"), 1500);
}
} catch {
setStatus("error");
setTimeout(() => router.replace("/login?error=sso_failed"), 1500);
}
}, [searchParams, router]);
useEffect(() => {
void doAuthCheck();
}, [doAuthCheck]);
return (
<div className="bg-background relative flex min-h-screen items-center justify-center">
<div className="text-center">
{status === "loading" && (
<>
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-current border-t-transparent" />
<p className="text-muted-foreground">Signing you in...</p>
</>
)}
{status === "success" && (
<p className="text-muted-foreground">Redirecting...</p>
)}
{status === "error" && (
<p className="text-muted-foreground">
Authentication failed. Redirecting to login...
</p>
)}
</div>
</div>
);
}

View file

@ -53,7 +53,28 @@ export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLogin, setIsLogin] = useState(true);
const [error, setError] = useState("");
const [ssoProviders, setSsoProviders] = useState<
{ id: string; display_name: string; type: string }[]
>([]);
// Extract error from query params (e.g., ?error=sso_failed)
const errorParam = searchParams.get("error");
const errorMessages: Record<string, string> = {
sso_failed: "SSO login failed. Please try again or use email login.",
sso_cancelled: "SSO login was cancelled.",
sso_account_exists:
"An account with this email already exists. Please sign in with your password or contact your administrator.",
sso_not_allowed:
"SSO login is not allowed for your account. Contact your administrator.",
};
const [error, setError] = useState(
errorParam ? (errorMessages[errorParam] ?? "Authentication failed.") : "",
);
// Soft hint shown after a failed login when SSO is configured: an SSO-only
// account has no local password, so the backend returns a generic
// "incorrect email or password" (deliberately, to avoid account enumeration).
// Nudge the user toward the SSO buttons without confirming the account exists.
const [showSsoHint, setShowSsoHint] = useState(false);
const [loading, setLoading] = useState(false);
// Get next parameter for validated redirect
@ -82,6 +103,22 @@ export default function LoginPage() {
// Ignore errors; user stays on login page
});
// Fetch SSO providers
void fetch("/api/v1/auth/providers")
.then((r) => r.json())
.then(
(data: {
providers: { id: string; display_name: string; type: string }[];
}) => {
if (!cancelled) {
setSsoProviders(data.providers ?? []);
}
},
)
.catch(() => {
// Ignore errors; no SSO providers shown
});
return () => {
cancelled = true;
};
@ -90,6 +127,7 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setShowSsoHint(false);
setLoading(true);
try {
@ -115,6 +153,11 @@ export default function LoginPage() {
const data = await res.json();
const authError = parseAuthError(data);
setError(authError.message);
// On a failed login with SSO configured, surface a hint pointing at the
// SSO buttons — the "wrong password" may really mean "this is an SSO account".
if (isLogin && ssoProviders.length > 0) {
setShowSsoHint(true);
}
return;
}
@ -187,12 +230,50 @@ export default function LoginPage() {
</Button>
</form>
{ssoProviders.length > 0 && (
<div className="space-y-2">
{isLogin && (
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
Or continue with
</span>
</div>
</div>
)}
{showSsoHint && (
<p className="text-muted-foreground text-center text-sm">
If your account uses single sign-on, sign in with the option
below instead.
</p>
)}
{ssoProviders.map((provider) => (
<Button
key={provider.id}
type="button"
variant="outline"
className="w-full"
disabled={loading}
onClick={() => {
window.location.href = `/api/v1/auth/oauth/${provider.id}?next=${encodeURIComponent(redirectPath)}`;
}}
>
Continue with {provider.display_name}
</Button>
))}
</div>
)}
<div className="text-center text-sm">
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setError("");
setShowSsoHint(false);
}}
className="text-blue-500 hover:underline"
>

View file

@ -15,6 +15,7 @@ import { SettingsSection } from "./settings-section";
export function AccountSettingsPage() {
const { user, logout } = useAuth();
const { t } = useI18n();
const isSsoUser = Boolean(user?.oauth_provider);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@ -83,47 +84,76 @@ export function AccountSettingsPage() {
<span className="text-sm font-medium capitalize">
{user?.system_role ?? "—"}
</span>
{isSsoUser && (
<>
<span className="text-muted-foreground text-sm">
{t.settings.account.ssoProvider}
</span>
<span className="text-sm font-medium capitalize">
{user?.oauth_provider}
</span>
</>
)}
</div>
</div>
</SettingsSection>
<SettingsSection
title={t.settings.account.changePasswordTitle}
description={t.settings.account.changePasswordDescription}
>
<form onSubmit={handleChangePassword} className="max-w-sm space-y-3">
<Input
type="password"
placeholder={t.settings.account.currentPassword}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
<Input
type="password"
placeholder={t.settings.account.newPassword}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
<Input
type="password"
placeholder={t.settings.account.confirmNewPassword}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
{error && <p className="text-sm text-red-500">{error}</p>}
{message && <p className="text-sm text-green-500">{message}</p>}
<Button type="submit" variant="outline" size="sm" disabled={loading}>
{loading
? t.settings.account.updating
: t.settings.account.updatePassword}
</Button>
</form>
</SettingsSection>
{!isSsoUser ? (
<SettingsSection
title={t.settings.account.changePasswordTitle}
description={t.settings.account.changePasswordDescription}
>
<form onSubmit={handleChangePassword} className="max-w-sm space-y-3">
<Input
type="password"
placeholder={t.settings.account.currentPassword}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
<Input
type="password"
placeholder={t.settings.account.newPassword}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
<Input
type="password"
placeholder={t.settings.account.confirmNewPassword}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
{error && <p className="text-sm text-red-500">{error}</p>}
{message && <p className="text-sm text-green-500">{message}</p>}
<Button
type="submit"
variant="outline"
size="sm"
disabled={loading}
>
{loading
? t.settings.account.updating
: t.settings.account.updatePassword}
</Button>
</form>
</SettingsSection>
) : (
<SettingsSection
title={t.settings.account.changePasswordTitle}
description={t.settings.account.ssoPasswordDescription}
>
<p className="text-muted-foreground text-sm">
{t.settings.account.ssoPasswordMessage.replace(
"{provider}",
user?.oauth_provider ?? "",
)}
</p>
</SettingsSection>
)}
<SettingsSection title="" description="">
<Button

View file

@ -5,6 +5,7 @@ export const AUTH_DISABLED_USER: User = {
email: "default@test.local",
system_role: "admin",
needs_setup: false,
oauth_provider: null,
};
const PRODUCTION_ENV_VALUES = new Set(["prod", "production"]);

View file

@ -5,4 +5,5 @@ export const STATIC_WEBSITE_USER: User = {
email: "static@example.local",
system_role: "admin",
needs_setup: false,
oauth_provider: null,
};

View file

@ -7,9 +7,12 @@ export const userSchema = z.object({
email: z.string().email(),
system_role: z.enum(["admin", "user"]),
needs_setup: z.boolean().optional().default(false),
oauth_provider: z.string().nullable().optional().default(null),
});
export type User = z.infer<typeof userSchema>;
export type User = Omit<z.infer<typeof userSchema>, "oauth_provider"> & {
oauth_provider?: string | null;
};
// ── SSR auth result (tagged union) ────────────────────────────────

View file

@ -533,8 +533,12 @@ export const enUS: Translations = {
profileTitle: "Profile",
email: "Email",
role: "Role",
ssoProvider: "SSO",
changePasswordTitle: "Change Password",
changePasswordDescription: "Update your account password.",
ssoPasswordDescription: "Password is managed by your SSO provider.",
ssoPasswordMessage:
"This account signs in with {provider}, so DeerFlow cannot manage or change its password here. Use your SSO provider's account settings instead.",
currentPassword: "Current password",
newPassword: "New password",
confirmNewPassword: "Confirm new password",

View file

@ -440,6 +440,9 @@ export interface Translations {
role: string;
changePasswordTitle: string;
changePasswordDescription: string;
ssoProvider: string;
ssoPasswordDescription: string;
ssoPasswordMessage: string;
currentPassword: string;
newPassword: string;
confirmNewPassword: string;

View file

@ -512,8 +512,12 @@ export const zhCN: Translations = {
profileTitle: "个人信息",
email: "邮箱",
role: "角色",
ssoProvider: "SSO",
changePasswordTitle: "修改密码",
changePasswordDescription: "更新你的账号密码。",
ssoPasswordDescription: "密码由你的 SSO 提供商管理。",
ssoPasswordMessage:
"此账号通过 {provider} 登录DeerFlow 无法在此管理或修改密码。请前往你的 SSO 提供商账号设置中进行操作。",
currentPassword: "当前密码",
newPassword: "新密码",
confirmNewPassword: "确认新密码",