From ee8ad1bc676370f3d3935929b75547b36e3f74b0 Mon Sep 17 00:00:00 2001 From: Zheng Feng Date: Sun, 21 Jun 2026 15:47:53 +0800 Subject: [PATCH] feat(auth): add OIDC SSO support (#3506) Add provider-agnostic OIDC authentication with Keycloak-compatible configuration, frontend SSO UI support --- backend/app/gateway/app.py | 5 + backend/app/gateway/auth/local_provider.py | 28 ++ backend/app/gateway/auth/models.py | 1 + backend/app/gateway/auth/oidc.py | 433 ++++++++++++++++++ backend/app/gateway/auth/oidc_state.py | 121 +++++ backend/app/gateway/auth/user_provisioning.py | 112 +++++ backend/app/gateway/auth_disabled.py | 1 + backend/app/gateway/auth_middleware.py | 3 + backend/app/gateway/routers/auth.py | 335 +++++++++++++- backend/docs/AUTH_DESIGN.md | 13 +- backend/docs/SSO.md | 280 +++++++++++ .../harness/deerflow/config/app_config.py | 2 + .../harness/deerflow/config/auth_config.py | 73 +++ backend/tests/test_auth.py | 7 + backend/tests/test_auth_type_system.py | 15 + .../tests/test_gateway_lifespan_shutdown.py | 6 +- backend/tests/test_oidc_auth.py | 407 ++++++++++++++++ config.example.yaml | 50 ++ .../src/app/(auth)/auth/callback/page.tsx | 73 +++ frontend/src/app/(auth)/login/page.tsx | 83 +++- .../settings/account-settings-page.tsx | 104 +++-- frontend/src/core/auth/auth-disabled-user.ts | 1 + frontend/src/core/auth/static-user.ts | 1 + frontend/src/core/auth/types.ts | 5 +- frontend/src/core/i18n/locales/en-US.ts | 4 + frontend/src/core/i18n/locales/types.ts | 3 + frontend/src/core/i18n/locales/zh-CN.ts | 4 + 27 files changed, 2102 insertions(+), 68 deletions(-) create mode 100644 backend/app/gateway/auth/oidc.py create mode 100644 backend/app/gateway/auth/oidc_state.py create mode 100644 backend/app/gateway/auth/user_provisioning.py create mode 100644 backend/docs/SSO.md create mode 100644 backend/packages/harness/deerflow/config/auth_config.py create mode 100644 backend/tests/test_oidc_auth.py create mode 100644 frontend/src/app/(auth)/auth/callback/page.tsx diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 7e080a587..528d2cbe4 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -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 diff --git a/backend/app/gateway/auth/local_provider.py b/backend/app/gateway/auth/local_provider.py index fee810ad1..11a7d8e3f 100644 --- a/backend/app/gateway/auth/local_provider.py +++ b/backend/app/gateway/auth/local_provider.py @@ -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) diff --git a/backend/app/gateway/auth/models.py b/backend/app/gateway/auth/models.py index 25c6476fe..79cf9a537 100644 --- a/backend/app/gateway/auth/models.py +++ b/backend/app/gateway/auth/models.py @@ -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')") diff --git a/backend/app/gateway/auth/oidc.py b/backend/app/gateway/auth/oidc.py new file mode 100644 index 000000000..744c558f2 --- /dev/null +++ b/backend/app/gateway/auth/oidc.py @@ -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) diff --git a/backend/app/gateway/auth/oidc_state.py b/backend/app/gateway/auth/oidc_state.py new file mode 100644 index 000000000..96161ca30 --- /dev/null +++ b/backend/app/gateway/auth/oidc_state.py @@ -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}", + ) diff --git a/backend/app/gateway/auth/user_provisioning.py b/backend/app/gateway/auth/user_provisioning.py new file mode 100644 index 000000000..228856c5b --- /dev/null +++ b/backend/app/gateway/auth/user_provisioning.py @@ -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" diff --git a/backend/app/gateway/auth_disabled.py b/backend/app/gateway/auth_disabled.py index ef8e6e78c..9b24cd03f 100644 --- a/backend/app/gateway/auth_disabled.py +++ b/backend/app/gateway/auth_disabled.py @@ -53,4 +53,5 @@ def get_auth_disabled_user(): system_role="admin", needs_setup=False, token_version=0, + oauth_provider=None, ) diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py index 6d71186a0..b85b35d07 100644 --- a/backend/app/gateway/auth_middleware.py +++ b/backend/app/gateway/auth_middleware.py @@ -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", } ) diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py index ee4f074d2..9c5fdf18c 100644 --- a/backend/app/gateway/routers/auth.py +++ b/backend/app/gateway/routers/auth.py @@ -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 diff --git a/backend/docs/AUTH_DESIGN.md b/backend/docs/AUTH_DESIGN.md index 9a740871d..5c5918756 100644 --- a/backend/docs/AUTH_DESIGN.md +++ b/backend/docs/AUTH_DESIGN.md @@ -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 |---|---|---| | 无 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 |---|---| | `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 | diff --git a/backend/docs/SSO.md b/backend/docs/SSO.md new file mode 100644 index 000000000..dd92a335d --- /dev/null +++ b/backend/docs/SSO.md @@ -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: + : + 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 \ No newline at end of file diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index b114b2cdf..e3cc1f7b3 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -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, diff --git a/backend/packages/harness/deerflow/config/auth_config.py b/backend/packages/harness/deerflow/config/auth_config.py new file mode 100644 index 000000000..b8ee0c74a --- /dev/null +++ b/backend/packages/harness/deerflow/config/auth_config.py @@ -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") diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index b16546fdf..4491bb8c5 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_auth_type_system.py b/backend/tests/test_auth_type_system.py index 226d3812c..008f36914 100644 --- a/backend/tests/test_auth_type_system.py +++ b/backend/tests/test_auth_type_system.py @@ -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() diff --git a/backend/tests/test_gateway_lifespan_shutdown.py b/backend/tests/test_gateway_lifespan_shutdown.py index a694ab00a..4481ed041 100644 --- a/backend/tests/test_gateway_lifespan_shutdown.py +++ b/backend/tests/test_gateway_lifespan_shutdown.py @@ -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 diff --git a/backend/tests/test_oidc_auth.py b/backend/tests/test_oidc_auth.py new file mode 100644 index 000000000..08ccc74b6 --- /dev/null +++ b/backend/tests/test_oidc_auth.py @@ -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" diff --git a/config.example.yaml b/config.example.yaml index 75d82c882..5ef7ccf18 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/frontend/src/app/(auth)/auth/callback/page.tsx b/frontend/src/app/(auth)/auth/callback/page.tsx new file mode 100644 index 000000000..a48ade103 --- /dev/null +++ b/frontend/src/app/(auth)/auth/callback/page.tsx @@ -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 ( +
+
+ {status === "loading" && ( + <> +
+

Signing you in...

+ + )} + {status === "success" && ( +

Redirecting...

+ )} + {status === "error" && ( +

+ Authentication failed. Redirecting to login... +

+ )} +
+
+ ); +} diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 6fb48a572..33964fa44 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -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 = { + 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() { + {ssoProviders.length > 0 && ( +
+ {isLogin && ( +
+
+ +
+
+ + Or continue with + +
+
+ )} + {showSsoHint && ( +

+ If your account uses single sign-on, sign in with the option + below instead. +

+ )} + {ssoProviders.map((provider) => ( + + ))} +
+ )} +
- -
- setCurrentPassword(e.target.value)} - required - /> - setNewPassword(e.target.value)} - required - minLength={8} - /> - setConfirmPassword(e.target.value)} - required - minLength={8} - /> - {error &&

{error}

} - {message &&

{message}

} - -
-
+ {!isSsoUser ? ( + +
+ setCurrentPassword(e.target.value)} + required + /> + setNewPassword(e.target.value)} + required + minLength={8} + /> + setConfirmPassword(e.target.value)} + required + minLength={8} + /> + {error &&

{error}

} + {message &&

{message}

} + +
+
+ ) : ( + +

+ {t.settings.account.ssoPasswordMessage.replace( + "{provider}", + user?.oauth_provider ?? "", + )} +

+
+ )}