"""FlowDeck — v6.7.0 SSO: SAML 2.0 + OIDC endpoints and admin config API. Two families of routes: * ``/auth/saml/*`` and ``/auth/oidc/*`` — the browser flows (login redirect, ACS callback, SP metadata, Single Logout). The callback endpoints are CSRF-exempt (cross-site POST from the IdP) and instead protected by the single-use ``sso_requests`` relay token + full assertion validation. * ``/api/v2/sso/*`` — admin configuration API (session admin or Bearer token with write scope), consumed by Settings → Admin → SSO / Enterprise. Every attempt — success or rejection — lands in ``sso_login_history``. """ from __future__ import annotations import logging import secrets import time from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.auth.providers import oidc_provider, saml_provider from app.auth.session import SessionManager from app.services import sso_provisioning as sso from app.services.api_v2_helpers import has_scope, resolve_bearer_token logger = logging.getLogger(__name__) router = APIRouter(tags=["sso"]) DEFAULT_NEXT = "/workspaces" # ── Rate limiting (design §5.2: 5 SSO attempts / minute / IP) ────────────── _RATE_WINDOW = 60.0 _RATE_MAX = 5 _rate_store: dict[str, tuple[float, int]] = {} def _rate_ok(request: Request, bucket: str = "sso") -> bool: from app.config import settings if not settings.rate_limit_enabled: return True ip = request.client.host if request.client else "unknown" key = f"{bucket}:{ip}" now = time.time() window, count = _rate_store.get(key, (0.0, 0)) if now - window > _RATE_WINDOW: _rate_store[key] = (now, 1) return True if count >= _RATE_MAX: return False _rate_store[key] = (window, count + 1) return True def _page(title: str, body: str, status: int = 200) -> HTMLResponse: """Small standalone error/info page (same styling as the login page).""" return HTMLResponse( f"""
{safe}
", status=403, ) # ═══════════════════════════════ SAML 2.0 ════════════════════════════════ @router.get("/auth/saml/login") async def saml_login(request: Request, next: str = DEFAULT_NEXT): """SP-initiated SSO: issue an AuthnRequest and redirect to the IdP.""" if not _rate_ok(request, "saml"): return _page("Too many attempts", "Please wait a minute and try again.
", status=429) cfg = _sso_config_or_error() if not cfg or cfg["provider_type"] != "saml": return _page( "SAML not configured", "Single Sign-On has not been set up by the server administrator.
" "", status=404, ) cfg = sso.ensure_sp_keypair(cfg) # RelayState = "Please wait a minute and try again.
", status=429) form = await request.form() saml_response = str(form.get("SAMLResponse") or "") relay_state = str(form.get("RelayState") or "") if not saml_response: return _login_error("Missing SAMLResponse", cfg=None, request=request) cfg = _sso_config_or_error() if not cfg or cfg["provider_type"] != "saml": return _login_error("SAML is not configured", cfg=None, request=request) authn_id, _, csrf_token = relay_state.partition(".") pending = sso.peek_request("saml_authn", authn_id) if not pending and sso.was_consumed("saml_authn", authn_id): # Same assertion twice: the single-use row is already spent. return _login_error( "Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request ) if not pending or not csrf_token or not secrets.compare_digest( pending.get("relay_state", ""), csrf_token ): return _login_error( "Unknown or expired login request (start again from the login page)", cfg=cfg, request=request, ) try: identity = saml_provider.process_response( request, cfg, {"SAMLResponse": saml_response, "RelayState": relay_state}, authn_id ) except saml_provider.SAMLError as err: return _login_error(str(err), cfg=cfg, identifier=authn_id, request=request) # Single-use: the same AuthnRequest id can never authenticate twice. consumed = sso.consume_request("saml_authn", authn_id, csrf_token) if not consumed: return _login_error( "Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request ) claims = sso.identity_from_saml(identity, cfg) identifier = sso.sso_identifier_field(claims) try: user = sso.handle_sso_login(claims, provider_type="saml", cfg=cfg, request=request) except sso.SSOProvisioningError as err: # _login_error() below records the failed attempt itself. return _login_error(str(err), cfg=cfg, identifier=identifier, request=request) sso.log_sso_login( user_id=user["id"], provider_type="saml", provider_name=cfg.get("name") or "SSO", identifier=identifier, request=request, success=True, ) user_data = dict(user) user_data["_sso_name_id"] = identity.name_id user_data["_sso_session_index"] = identity.session_index response = RedirectResponse(consumed.get("next_path") or DEFAULT_NEXT, status_code=302) response.set_cookie( "flowdeck_session", _session_cookie(user_data, request), httponly=True, max_age=86400 * 7, samesite="lax", path="/", ) return response @router.get("/auth/saml/metadata") async def saml_metadata(request: Request): """SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…).""" cfg = _sso_config_or_error() if not cfg or cfg["provider_type"] != "saml": return _page("SAML not configured", "No SAML configuration found.
", status=404) cfg = sso.ensure_sp_keypair(cfg) try: xml = saml_provider.metadata_xml(request, cfg) except saml_provider.SAMLError as err: return _page("Metadata error", f"{err}
", status=500) return HTMLResponse(xml, media_type="application/samlmetadata+xml") async def _saml_logout(request: Request, next: str = "/auth/login?provider=local"): """Single Logout: SP-initiated (our logout button) or IdP-initiated. * no SAML payload → build a LogoutRequest to the IdP (after revoking the local session); * ``SAMLRequest`` / ``SAMLResponse`` present → process it (LogoutResponse of our own SLO, or a LogoutRequest issued by the IdP). """ form = dict(await request.form()) if request.method == "POST" else {} query = dict(request.query_params) cfg = _sso_config_or_error() if not cfg or cfg["provider_type"] != "saml": response = RedirectResponse(next, status_code=302) response.delete_cookie("flowdeck_session") return response payload = form.get("SAMLRequest") or form.get("SAMLResponse") or query.get("SAMLResponse") if payload: try: url, errors = saml_provider.process_slo_form(request, cfg, form, query) except saml_provider.SAMLError as err: logger.warning("SLO processing failed: %s", err) return _login_error(str(err), cfg=cfg, request=request) if errors: return _login_error( "; ".join(errors)[:300], cfg=cfg, request=request ) response = RedirectResponse(url or next, status_code=302) response.delete_cookie("flowdeck_session") return response # SP-initiated cookie = request.cookies.get("flowdeck_session", "") user = SessionManager.decode_session(cookie) if cookie else None response = RedirectResponse(next, status_code=302) if cookie: sid = SessionManager.session_id(cookie) if sid: SessionManager.revoke_session(sid) response.delete_cookie("flowdeck_session") if user and cfg.get("slo_url") and user.get("_sso_name_id"): try: logout_url = saml_provider.build_logout_url( request, cfg, return_to=sso.safe_next_path(next), name_id=user.get("_sso_name_id", ""), session_index=user.get("_sso_session_index", ""), ) # Keep the cookie-clearing headers built above: hand the browser # to the IdP with our local session already dead. response = RedirectResponse(logout_url, status_code=302) response.delete_cookie("flowdeck_session") return response except saml_provider.SAMLError as err: logger.warning("SP-initiated SLO failed: %s", err) return response @router.get("/auth/saml/logout") async def saml_logout(request: Request, next: str = "/auth/login?provider=local"): """SP-initiated Single Logout (GET) — hands the browser to the IdP.""" return await _saml_logout(request, next) @router.post("/auth/saml/logout") async def saml_logout_post(request: Request, next: str = "/auth/login?provider=local"): """IdP-initiated Single Logout (POST with SAMLRequest/SAMLResponse).""" return await _saml_logout(request, next) # ═════════════════════════════════ OIDC ═══════════════════════════════════ @router.get("/auth/oidc/login") async def oidc_login(request: Request, next: str = DEFAULT_NEXT): """Redirect to the OIDC provider (authorization code + PKCE).""" if not _rate_ok(request, "oidc"): return _page("Too many attempts", "Please wait a minute and try again.
", status=429) cfg = _sso_config_or_error() if not cfg or cfg["provider_type"] != "oidc": return _page( "OIDC not configured", "Single Sign-On has not been set up by the server administrator.
" "", status=404, ) try: doc = await oidc_provider.discover(cfg["issuer_url"]) except oidc_provider.OIDCError as err: return _login_error(str(err), cfg=cfg, request=request) from app.auth.providers.saml_provider import external_base_url state = secrets.token_hex(32) nonce = secrets.token_hex(16) verifier, challenge = oidc_provider.pkce_pair() sso.create_request( "oidc", request_id=state, relay_state=nonce, code_verifier=verifier, next_path=sso.safe_next_path(next), ) url = oidc_provider.build_authorize_url( doc, client_id=cfg["client_id"], redirect_uri=f"{external_base_url(request)}/auth/oidc/callback", scope=cfg.get("scope") or "openid profile email", state=state, nonce=nonce, code_challenge=challenge, ) return RedirectResponse(url, status_code=302) async def _oidc_callback(request: Request): """OIDC callback: exchange the code, validate the ID token, open a session.""" if not _rate_ok(request, "oidc-cb"): return _page("Too many attempts", "Please wait a minute and try again.
", status=429) params = dict(request.query_params) if request.method == "POST": params.update({k: str(v) for k, v in (await request.form()).items()}) cfg = _sso_config_or_error() if not cfg or cfg["provider_type"] != "oidc": return _login_error("OIDC is not configured", cfg=None, request=request) if params.get("error"): return _login_error( f"Provider error: {params.get('error')} {params.get('error_description', '')}".strip(), cfg=cfg, request=request, ) code, state = params.get("code", ""), params.get("state", "") pending = sso.consume_request("oidc", state) if not code or not pending: return _login_error( "Unknown or expired OIDC state (start again from the login page)", cfg=cfg, request=request, ) from app.auth.providers.saml_provider import external_base_url try: doc = await oidc_provider.discover(cfg["issuer_url"]) tokens = await oidc_provider.exchange_code( doc, client_id=cfg["client_id"], client_secret=sso.client_secret_value(cfg), code=code, redirect_uri=f"{external_base_url(request)}/auth/oidc/callback", code_verifier=pending.get("code_verifier", ""), ) jwks = await _fetch_jwks(doc) claims = oidc_provider.validate_id_token( tokens.get("id_token", ""), issuer=cfg["issuer_url"], client_id=cfg["client_id"], nonce=pending.get("relay_state", ""), jwks=jwks, ) userinfo = await oidc_provider.fetch_userinfo(doc, tokens.get("access_token", "")) except oidc_provider.OIDCError as err: return _login_error(str(err), cfg=cfg, identifier=state, request=request) merged = {**claims, **userinfo} identity = oidc_provider.claims_to_identity(merged, cfg.get("attribute_mapping") or None) identifier = sso.sso_identifier_field(identity) try: user = sso.handle_sso_login(identity, provider_type="oidc", cfg=cfg, request=request) except sso.SSOProvisioningError as err: # _login_error() below records the failed attempt itself. return _login_error(str(err), cfg=cfg, identifier=identifier, request=request) sso.log_sso_login( user_id=user["id"], provider_type="oidc", provider_name=cfg.get("name") or "SSO", identifier=identifier, request=request, success=True, ) response = RedirectResponse(pending.get("next_path") or DEFAULT_NEXT, status_code=302) response.set_cookie( "flowdeck_session", _session_cookie(dict(user), request), httponly=True, max_age=86400 * 7, samesite="lax", path="/", ) return response @router.get("/auth/oidc/callback") async def oidc_callback(request: Request): """OIDC callback (GET, authorization code in the query string).""" return await _oidc_callback(request) @router.post("/auth/oidc/callback") async def oidc_callback_post(request: Request): """OIDC callback (POST, form_post response mode).""" return await _oidc_callback(request) async def _fetch_jwks(doc: dict) -> dict: url = doc.get("jwks_uri") if not url: raise oidc_provider.OIDCError("Discovery document has no jwks_uri") import httpx try: async with httpx.AsyncClient(timeout=15) as client: r = await client.get(url) r.raise_for_status() data = r.json() except Exception as err: raise oidc_provider.OIDCError(f"Could not fetch the issuer JWKS: {err}") from err if not isinstance(data, dict) or not data.get("keys"): raise oidc_provider.OIDCError("Issuer JWKS contains no keys") return data async def _oidc_logout(request: Request, next: str = "/auth/login?provider=local"): """Local logout + RP-initiated logout at the provider when supported.""" cfg = _sso_config_or_error() response = RedirectResponse(next, status_code=302) cookie = request.cookies.get("flowdeck_session", "") if cookie: sid = SessionManager.session_id(cookie) if sid: SessionManager.revoke_session(sid) response.delete_cookie("flowdeck_session") if cfg and cfg["provider_type"] == "oidc": try: doc = await oidc_provider.discover(cfg["issuer_url"]) end_session = doc.get("end_session_endpoint") if end_session: from urllib.parse import urlencode from app.auth.providers.saml_provider import external_base_url qs = urlencode({ "client_id": cfg["client_id"], "post_logout_redirect_uri": external_base_url(request) + next, }) sep = "&" if "?" in end_session else "?" return RedirectResponse(f"{end_session}{sep}{qs}", status_code=302) except oidc_provider.OIDCError as err: logger.debug("RP-initiated logout skipped: %s", err) return response @router.get("/auth/oidc/logout") async def oidc_logout(request: Request, next: str = "/auth/login?provider=local"): """OIDC logout (GET) — local session first, then the IdP end-session URL.""" return await _oidc_logout(request, next) @router.post("/auth/oidc/logout") async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=local"): """OIDC logout (POST).""" return await _oidc_logout(request, next) # ═══════════════════════ Admin configuration API ══════════════════════════ async def _require_admin(request: Request, *, write: bool) -> dict: """Admin identity: Bearer token (scope read/write) or an admin session. Session-authenticated writes also need the CSRF header — ``/api/v2`` is exempted in the middleware, so the check lives here for this router. """ auth_header = request.headers.get("authorization") or "" if auth_header.lower().startswith("bearer "): user = resolve_bearer_token(auth_header[7:].strip()) if not user: raise HTTPException(status_code=401, detail="Invalid or expired token") scopes = user.get("_token_scopes") or "" need = "write" if write else "read" if not (has_scope(scopes, need) or has_scope(scopes, "admin")): raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {need}") if not user.get("is_admin"): raise HTTPException(status_code=403, detail="Admin access required") return user from app.db import get_conn user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user or not user.get("id"): raise HTTPException(status_code=401, detail="Not authenticated") with get_conn() as conn: row = conn.execute( "SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (user["id"],), ).fetchone() if not row or not row["is_admin"]: raise HTTPException(status_code=403, detail="Admin access required") if write and request.method in ("POST", "PUT", "PATCH", "DELETE"): cookie = request.cookies.get("csrf_token", "") header = request.headers.get("X-CSRF-Token", "") if not cookie or not header or not secrets.compare_digest(cookie, header): raise HTTPException(status_code=403, detail="CSRF validation failed") return dict(row) @router.get("/api/v2/sso/providers") async def sso_providers(request: Request): """Public: what the login page should show (button list + sso_only flag).""" cfg = _sso_config_or_error() if not cfg: return {"providers": [], "sso_only": False} from app.auth.providers.saml_provider import external_base_url base = external_base_url(request) login_path = "/auth/saml/login" if cfg["provider_type"] == "saml" else "/auth/oidc/login" return { "providers": [{ "type": cfg["provider_type"], "name": cfg.get("name") or "Company SSO", "icon": "🏢", "login_url": f"{login_path}?next={DEFAULT_NEXT}", }], "sso_only": bool(cfg.get("sso_only")), "base_url": base, } @router.get("/api/v2/sso/config") async def get_sso_config_api(request: Request): """Read the current SSO configuration (secrets never returned).""" await _require_admin(request, write=False) cfg = _sso_config_or_error() return sso.public_config_view(cfg) @router.post("/api/v2/sso/config") @router.put("/api/v2/sso/config") async def save_sso_config_api(request: Request): """Create/replace the SSO configuration (admin, scope write).""" admin = await _require_admin(request, write=True) try: payload = await request.json() except Exception as err: raise HTTPException(status_code=400, detail="Invalid JSON body") from err try: saved = sso.save_sso_config(payload, created_by=admin.get("id")) except sso.SSOConfigError as err: raise HTTPException(status_code=400, detail=str(err)) from err from app.services.api_v2_helpers import audit_log audit_log(admin, "sso.config.save", "sso_config", saved.get("id", 0), f"provider={saved.get('provider_type')}", request) return sso.public_config_view(saved) @router.delete("/api/v2/sso/config") async def delete_sso_config_api(request: Request): """Disable SSO — local logins keep working (design §8 « SSO disable »).""" admin = await _require_admin(request, write=True) removed = sso.delete_sso_config() from app.services.api_v2_helpers import audit_log audit_log(admin, "sso.config.disable", "sso_config", 0, "", request) return {"status": "ok", "disabled": removed} @router.get("/api/v2/sso/workspaces") async def sso_workspaces(request: Request): """Workspaces available for default assignment / group mapping.""" await _require_admin(request, write=False) from app.db import get_conn with get_conn() as conn: rows = conn.execute( "SELECT id, name, owner_id FROM workspaces ORDER BY name" ).fetchall() cfg = _sso_config_or_error() return { "workspaces": [dict(r) for r in rows], "default_workspace_id": (cfg or {}).get("default_workspace_id"), "sso_only": bool((cfg or {}).get("sso_only")), "provisioned_users": sso.provisioned_count(), } @router.post("/api/v2/sso/sync") async def sso_sync(request: Request): """Re-apply group → workspace role mapping for every SSO user.""" admin = await _require_admin(request, write=True) try: result = sso.force_sync_all_groups() except sso.SSOProvisioningError as err: raise HTTPException(status_code=400, detail=str(err)) from err from app.services.api_v2_helpers import audit_log audit_log(admin, "sso.sync", "sso_config", 0, str(result), request) return {"status": "ok", **result} @router.get("/api/v2/sso/history") async def sso_history(request: Request, limit: int = 50): """Audit trail of SSO login attempts (successes and rejections).""" await _require_admin(request, write=False) from app.db import get_conn limit = max(1, min(int(limit or 50), 200)) with get_conn() as conn: rows = conn.execute( """SELECT h.id, h.user_id, u.login, h.provider_type, h.provider_name, h.sso_identifier, h.ip_address, h.success, h.error_message, h.created_at FROM sso_login_history h LEFT JOIN users u ON u.id = h.user_id ORDER BY h.id DESC LIMIT ?""", (limit,), ).fetchall() out = [] for r in rows: d = dict(r) ident = d.get("sso_identifier") or "" if "|" in ident: # drop the stored group list from the UI payload d["sso_identifier"] = ident.split("|", 1)[0] d["success"] = bool(d["success"]) out.append(d) return {"history": out}