mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
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:
parent
5ddf698895
commit
ee8ad1bc67
27 changed files with 2102 additions and 68 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')")
|
||||
|
|
|
|||
433
backend/app/gateway/auth/oidc.py
Normal file
433
backend/app/gateway/auth/oidc.py
Normal 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)
|
||||
121
backend/app/gateway/auth/oidc_state.py
Normal file
121
backend/app/gateway/auth/oidc_state.py
Normal 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}",
|
||||
)
|
||||
112
backend/app/gateway/auth/user_provisioning.py
Normal file
112
backend/app/gateway/auth/user_provisioning.py
Normal 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"
|
||||
|
|
@ -53,4 +53,5 @@ def get_auth_disabled_user():
|
|||
system_role="admin",
|
||||
needs_setup=False,
|
||||
token_version=0,
|
||||
oauth_provider=None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 SSO(Keycloak, 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
280
backend/docs/SSO.md
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
73
backend/packages/harness/deerflow/config/auth_config.py
Normal file
73
backend/packages/harness/deerflow/config/auth_config.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
407
backend/tests/test_oidc_auth.py
Normal file
407
backend/tests/test_oidc_auth.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
73
frontend/src/app/(auth)/auth/callback/page.tsx
Normal file
73
frontend/src/app/(auth)/auth/callback/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -5,4 +5,5 @@ export const STATIC_WEBSITE_USER: User = {
|
|||
email: "static@example.local",
|
||||
system_role: "admin",
|
||||
needs_setup: false,
|
||||
oauth_provider: null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -512,8 +512,12 @@ export const zhCN: Translations = {
|
|||
profileTitle: "个人信息",
|
||||
email: "邮箱",
|
||||
role: "角色",
|
||||
ssoProvider: "SSO",
|
||||
changePasswordTitle: "修改密码",
|
||||
changePasswordDescription: "更新你的账号密码。",
|
||||
ssoPasswordDescription: "密码由你的 SSO 提供商管理。",
|
||||
ssoPasswordMessage:
|
||||
"此账号通过 {provider} 登录,DeerFlow 无法在此管理或修改密码。请前往你的 SSO 提供商账号设置中进行操作。",
|
||||
currentPassword: "当前密码",
|
||||
newPassword: "新密码",
|
||||
confirmNewPassword: "确认新密码",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue