diff --git a/.env.example b/.env.example index 4b9690e..e34d2ea 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,27 @@ SMTP_PASSWORD= SMTP_FROM=FlowDeck SMTP_USE_TLS=true APP_BASE_URL=http://localhost:8080 + +# ── SSO / Enterprise (v6.7.0) ── +# Fallback de démarrage uniquement : dès qu'un admin enregistre une configuration +# dans Settings → Admin → SSO / Enterprise, la table `sso_config` prime sur le .env. +# Le bouton SSO n'apparaît sur la page de connexion que si une config est active. +# SSO_PROVIDER=saml # saml | oidc (vide = SSO désactivé) +# SSO_NAME=Company SSO # libellé du bouton +# SSO_ONLY=false # true = refuser le login local (les admins gardent le leur) +# SSO_AUTO_PROVISION=true # créer le compte au premier login SSO +# SAML : +# SSO_ENTITY_ID=https://idp.example.com/saml/metadata +# SSO_SSO_URL=https://idp.example.com/saml/sso +# SSO_SLO_URL=https://idp.example.com/saml/slo +# SSO_X509_CERTIFICATE=-----BEGIN CERTIFICATE----- +# SSO_SIGN_REQUESTS=false # signer les AuthnRequests / LogoutRequest +# OIDC : +# SSO_ISSUER_URL=https://auth.example.com/realms/flowdeck +# SSO_CLIENT_ID= +# SSO_CLIENT_SECRET= +# SSO_SCOPE=openid profile email +# Mapping (JSON) : +# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"} +# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}] +# SSO_DEFAULT_WORKSPACE_ID=0 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index caad899..0b2bc27 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1710,5 +1710,6 @@ docker compose restart flowdeck - **Kanban flexible** — Colonnes custom, WIP limits - **API publique REST v2** — `/api/v2` (v6.3.0) : Bearer + scopes `read/write/admin`, CRUD complet, pagination, RFC 7807, idempotence, audit, OpenAPI (`/docs`, `docs/openapi-v2.json`) ; `/api/v1` lecture seule (compat) - **API agent publique** — `/api/v2/agents/*` + `/api/v2/skills/*` (v6.6.0, agent phase 5) : wrappers Bearer sur `AgentEngine` (run synchrone JSON, journal + rollback, trigger externe) et marketplace de skills (export/import portable, galerie de presets) — `app/routers/api_v2_agent.py`, `app/services/skill_gallery.py` +- **SSO / SAML + OIDC entreprise** — v6.7.0 (Enterprise Auth, dernière feature v6.0.0) : SP SAML (`python3-saml`) + OIDC PKCE (`authlib`), auto-provisioning + mapping groupes IdP → rôles workspace, mode « SSO only », onglet admin « SSO / Enterprise », migration 23 (`sso_config`, `sso_login_history`, `sso_requests`), `/help` section SSO — `app/routers/sso.py`, `app/services/sso_provisioning.py`, `app/auth/providers/{saml,oidc}_provider.py` - **Volume Docker persistant** — `/data` monté pour survie des données - **PostgreSQL** — Migration optionnelle pour scaling diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e50eb..b549a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,80 @@ # Changelog - FlowDeck +## v6.7.0 (2026-09-24) — SSO / SAML + OIDC entreprise (Enterprise Auth) + +> Dernière feature de la roadmap v6.0.0 : authentification fédérée via un IdP +> d'entreprise (SAML 2.0 ou OpenID Connect + PKCE), auto-provisioning des +> comptes, mapping des groupes de l'IdP vers les rôles workspace, mode +> « SSO only » et configuration admin dans Settings. Design : +> `docs/V6_SSO_SAML_Enterprise_Auth.md` (checklist §9 cochée). + +### Added + +- **SAML 2.0 SP** — `app/auth/providers/saml_provider.py` (python3-saml 1.16.0) : + login SP-initié `GET /auth/saml/login` (RelayState = id AuthnRequest + jeton CSRF + en DB), ACS `POST /auth/saml/callback` (signature, audience, destination, + InResponseTo, anti-replay `sso_requests`), SP metadata `GET /auth/saml/metadata`, + SLO `GET|POST /auth/saml/logout` (relais LogoutRequest/Response vers l'IdP) ; + `allowSingleLabelDomains` activé pour les hôts homelab/LAN +- **OIDC + PKCE** — `app/auth/providers/oidc_provider.py` (authlib 1.8.0) : + `GET /auth/oidc/login` (`state` + `code_verifier` stockés en DB), + `GET|POST /auth/oidc/callback` (code → token → userinfo ; ID token vérifié via + JWKS : aud/iss/nonce/exp), discovery + JWKS mis en cache 1 h, + `GET|POST /auth/oidc/logout` (`end_session_endpoint` si présent) +- **Provisioning** — `app/services/sso_provisioning.py` : création automatique de + l'utilisateur au 1er login (`auth_method=saml|oidc`, lien via email), rôle par + défaut + `default_workspace_id`, mapping groupes IdP → rôles workspace + (re-synchronisé à chaque login + `POST /api/v2/sso/sync`), mode **SSO only** + (login local refusé sauf administrateurs — design §7.1), secrets chiffrés + Fernet dans `sso_config` (champ secret vide = conserver l'existant), détection + de rejeu via `sso_requests` (TTL 15 min), `sso_login_history` (succès + échecs), + `safe_next_path` (pas de redirection externe) +- **Routeur** — `app/routers/sso.py` : parcours navigateur + API + `GET|POST|PUT|DELETE /api/v2/sso/config` (admin, header `X-CSRF-Token`, + `audit_log`), `GET /api/v2/sso/providers` (public, boutons de la page de login), + `GET /api/v2/sso/workspaces`, `POST /api/v2/sso/sync`, `GET /api/v2/sso/history` ; + CSRF exclut les préfixes `/auth/saml` et `/auth/oidc` (POST IdP cross-site) +- **Migration 23** — tables `sso_config`, `sso_login_history`, `sso_requests` +- **UI admin** — onglet « SSO / Enterprise » dans Settings : statut, config + SAML (Entity ID, SSO/SLO URL, certificat PEM, mapping attributs JSON, signature + des AuthnRequests) et OIDC (issuer, client id/secret, scope), provisioning + (auto-provision, SSO only, espace par défaut), tableau groupes → rôles, + URL des métadonnées SP copiable, bouton « Re-sync des groupes », désactivation ; + bouton SSO sur la page de login (nom dynamique, masqué si non configuré) +- **`PermissionManager`** (design §7.2) — `is_sso_only_workspace()`, + `get_sso_roles()`, `sync_sso_permissions()` (les grants SSO sont des lignes + `workspace_members` explicites ; le fallback « viewer » implicite n'en est pas un) +- **Help** — section « Enterprise SSO » + badge `.help-badge.sso` dans `/help` +- **`.env` (fallback bootstrap)** — `SSO_PROVIDER`, `SSO_NAME`, `SSO_ONLY`, + `SSO_AUTO_PROVISION`, `SSO_ENTITY_ID`, `SSO_SSO_URL`, `SSO_SLO_URL`, + `SSO_X509_CERTIFICATE`, `SSO_SIGN_REQUESTS`, `SSO_ISSUER_URL`, `SSO_CLIENT_ID`, + `SSO_CLIENT_SECRET`, `SSO_SCOPE`, `SSO_ATTRIBUTE_MAPPING`, `SSO_GROUPS_MAPPING`, + `SSO_DEFAULT_WORKSPACE_ID` (la config admin prime sur l'environnement ; + section ajoutée dans `.env.example`) +- **Dépendances** — `python3-saml==1.16.0`, `authlib==1.8.0`, `cryptography>=42.0` + +### Changed + +- `/logout` délègue la déconnexion à l'IdP (SLO) quand la session vient de SAML, + puis détruit la session locale ; sinon comportement existant +- OpenAPI régénéré : `docs/openapi-v2.json` → **439 chemins**, `info.version = 6.7.0` + (operation IDs uniques — les routes multi-méthodes sont enregistrées en deux + routes mono-méthode) + +### Tests + +- `tests/test_v67_sso.py` : **38 tests** — API de configuration admin + (validation, CSRF, secret jamais renvoyé, secret vide préservé, réinitialisation, + DELETE), métadonnées SP, login SAML complet contre un IdP mock signé (y compris + SLO), OIDC complet (discovery/token/userinfo/JWKS mockés), cas négatifs + (mauvais audience/issuer/destination/nonce/state, rejeu, signature altérée, + assertion expirée), auto-provisioning, group mapping (nom → rôle workspace), + sso_only, historique, rate limit, endpoint public `providers`, extension + `PermissionManager` +- `pytest -n auto` → **802 passed, 0 skipped** · `ruff check app tests` OK · `eslint static/js` 0 problème + +--- + ## v6.6.0 (2026-09-24) — Agent phase 5 : API publique agent & skill marketplace > Dernière phase du plan agent en 5 phases (« Plateforme ») : l'agent devient diff --git a/README.md b/README.md index 41fb5fe..4705d68 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users. -> **v6.6.0** — Agent phase 5 : API publique agent (`/api/v2/agents`) + marketplace de skills · avant : v6.5.x synced blocks, v6.4.0 realtime, PWA offline +> **v6.7.0** — SSO / SAML + OIDC entreprise (auth fédérée IdP, auto-provisioning, group mapping, mode SSO only) · avant : v6.6.x agent API + marketplace, v6.5.x synced blocks, v6.4.0 realtime, PWA offline ## Quick Start diff --git a/ROADMAP.md b/ROADMAP.md index e87e7ee..91fd5f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -849,6 +849,40 @@ Détails livrés : --- +## v6.7.0 — SSO / SAML + OIDC entreprise (Enterprise Auth) ✅ (2026-09-24) + +> **Objectif** : dernière feature de la roadmap v6.0.0 — authentification fédérée +> via un IdP d'entreprise. **COMPLETED**. Design : [`docs/V6_SSO_SAML_Enterprise_Auth.md`](docs/V6_SSO_SAML_Enterprise_Auth.md). + +- [x] **SAML 2.0 SP** — `app/auth/providers/saml_provider.py` (python3-saml 1.16.0) : login SP-initié + (`/auth/saml/login`), ACS signé (signature/audience/destination/InResponseTo validés), + SP metadata public (`/auth/saml/metadata`), SLO (`/auth/saml/logout`) ; `allowSingleLabelDomains` + pour les hôts homelab/LAN +- [x] **OIDC + PKCE** — `app/auth/providers/oidc_provider.py` (authlib 1.8.0) : `state` + + `code_verifier` en DB, ID token vérifié via JWKS (aud/iss/nonce/exp), discovery + JWKS cachés 1 h +- [x] **Provisioning** — `app/services/sso_provisioning.py` : auto-provision au 1er login + (`auth_method=saml|oidc`, lien par email), rôle par défaut + `default_workspace_id`, + mapping groupes IdP → rôles workspace (re-synchronisé à chaque login), mode **SSO only** + (login local refusé, admins exceptés — design §7.1), secrets Fernet, anti-replay `sso_requests`, + historique `sso_login_history` +- [x] **Routeur** — `app/routers/sso.py` : parcours navigateur + API admin + `GET/POST/DELETE /api/v2/sso/config` (admin + CSRF), `GET /api/v2/sso/workspaces`, + `POST /api/v2/sso/sync` ; CSRF exclut `/auth/saml/*` + `/auth/oidc/*` +- [x] **Migration 23** — tables `sso_config`, `sso_login_history`, `sso_requests` +- [x] **UI admin** — onglet « SSO / Enterprise » dans Settings (config SAML/OIDC, provisioning, + groupes → rôles, URL métadonnées SP copiable, désactivation) ; bouton SSO sur la page de login +- [x] **`PermissionManager`** (design §7.2) — `is_sso_only_workspace()`, `get_sso_roles()`, + `sync_sso_permissions()` +- [x] **Help** — section « SSO (Enterprise) » + badge `.help-badge.sso` +- [x] **Docs** — CHANGELOG, WORKLOAD, README, ARCHITECTURE, `docs/API_GUIDE_V6.md` §2.5, + OpenAPI régénéré : `docs/openapi-v2.json` → **439 chemins**, `info.version = 6.7.0` +- [x] **Tests** — `tests/test_v67_sso.py` : **38 tests** (config admin + CSRF + secret préservé, + métadonnées SP, flots SAML/OIDC complets vs IdP mockés, négatifs aud/iss/nonce/replay/tamper, + group mapping, `sso_only`, SLO, historique, rate limit, extension `PermissionManager`) +- [x] **Version** — 6.7.0 · `ruff check app tests` OK · **suite 802 verts, 0 skip** + +--- + ## v6.6.0 — Agent phase 5 : API publique agent & skill marketplace ✅ (2026-09-24) > **Objectif** : dernière étape du plan d'igration en 5 phases de l'agent — « Plateforme » : > exposer l'agent en API publique pour les intégrations tierces et rendre les skills @@ -914,7 +948,7 @@ Détails livrés : - [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md) - [x] **Web Clipper** — extension navigateur ✅ (livré v6.2.0/6.2.1) — [📄 Conception détaillée](/docs/V6_Web_Clipper.md) - [x] **API publique complète** — REST API documentée (OpenAPI) ✅ (livré v6.3.0) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 OpenAPI](/docs/openapi-v2.json) -- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md) +- [x] **SSO/SAML** — enterprise authentication ✅ (livré v6.7.0) — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md) - [x] **Realtime editing (production)** ✅ livré **v6.4.0** (merge 3-voix au-delà du LWW, broadcast non bloquant) ; voir **v5.13.0** pour le socle (curseurs + présence) - [x] **Synced blocks (production)** ✅ livré **v6.5.0** (page contenu par ligne de database, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public) ; socle : **v5.14.0** (bloc de base) @@ -962,4 +996,4 @@ Quality DB views, Agent IA Palette → Realtime + E DB avancée, redo, drag&drop, bookmark, avancée Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅ -*Dernière mise à jour: 2026-09-24 — **v6.6.0 Agent phase 5 : API publique agent & skill marketplace COMPLETED** (`/api/v2/agents/*` en Bearer+scopes avec run synchrone JSON + audit/undo, export/import de skills + galerie de 6 presets, entrée « Galerie » branchée dans la palette `/` du panneau, webhooks `agent.run.started`/`failed` enfin émis, OpenAPI régénéré 427 chemins ; 15 tests dédiés) + **v6.5.1** webhooks_v2 (749 verts) + **v6.5.0** synced blocks + **v6.4.0** realtime + **v6.3.0** API v2 → **suite 764 verts, 0 skip**. Les 5 phases du plan agent sont ✅. Reste: **SSO/SAML**.* +*Dernière mise à jour: 2026-09-24 — **v6.7.0 SSO / SAML + OIDC entreprise COMPLETED** (SP SAML signé + OIDC PKCE, auto-provisioning, groupes IdP → rôles workspace, mode SSO only avec porte admin, onglet Settings « SSO / Enterprise », API `/api/v2/sso/*`, migration 23, OpenAPI régénéré, **38 tests dédiés**) + **v6.6.0** agent API & marketplace (suite 764 verts) + **v6.5.1** webhooks_v2 + **v6.5.0** synced blocks + **v6.4.0** realtime + **v6.3.0** API v2 → **roadmap v6.0.0 entièrement COMPLETED**, reste: **rien**. **Suite: 802 verts, 0 skip**.* diff --git a/VERSION b/VERSION index 826f5ce..f0e13c5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.6.0 +6.7.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index eafd25c..1791cc5 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,7 +1,7 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v6.6.0 | **Statut**: EN COURS 🔄 -> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML (design seul) +> **Début**: 2026-07-08 | **Version**: v6.7.0 | **Statut**: EN COURS 🔄 +> **Cible**: parité Notion + intégration forge · **Reste roadmap**: ✅ rien — **roadmap v6.0.0 COMPLETED** (SSO/SAML livré v6.7.0) ## Avancement Global @@ -27,6 +27,7 @@ | **v6.4.0** | **Realtime production (merge 3-voix, broadcast non bloquant)** | ✅ | **749+** | | **v6.5.0–v6.5.1** | **Synced blocks production (databases/vues) + webhooks v2 complets** | ✅ | **749** | | **v6.6.0** | **Agent phase 5 — API publique agent & skill marketplace** | ✅ | **764+** | +| **v6.7.0** | **SSO / SAML + OIDC entreprise (Enterprise Auth)** | ✅ | **802** | ## Blocs Complétés diff --git a/app/auth/providers.py b/app/auth/providers/__init__.py similarity index 100% rename from app/auth/providers.py rename to app/auth/providers/__init__.py diff --git a/app/auth/providers/oidc_provider.py b/app/auth/providers/oidc_provider.py new file mode 100644 index 0000000..e5846c4 --- /dev/null +++ b/app/auth/providers/oidc_provider.py @@ -0,0 +1,219 @@ +"""OIDC provider — authorization code flow with PKCE (v6.7.0). + +Discovery (``.well-known/openid-configuration``) is cached for an hour, the +ID token signature is verified against the issuer JWKS via authlib's JOSE +implementation, and ``iss`` / ``aud`` / ``exp`` / ``nonce`` are checked here +explicitly so the rules are visible and unit-testable. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import secrets +import time +import warnings + +import httpx + +logger = logging.getLogger(__name__) + +#: Default attribute mapping (design doc §3.2) — OIDC claim names. +DEFAULT_OIDC_MAPPING: dict[str, str] = { + "login": "sub", + "email": "email", + "full_name": "name", + "avatar_url": "picture", + "groups": "groups", +} + +_DISCOVERY_TTL = 3600.0 +_discovery_cache: dict[str, tuple[float, dict]] = {} + + +class OIDCError(Exception): + """OIDC processing failure — ``message`` is user-facing.""" + + +def pkce_pair() -> tuple[str, str]: + """Return ``(code_verifier, code_challenge)`` for the S256 method.""" + verifier = secrets.token_urlsafe(64) + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return verifier, challenge + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(data: str) -> bytes: + return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4)) + + +async def discover(issuer_url: str) -> dict: + """Fetch (and cache) the issuer's OIDC discovery document.""" + issuer = issuer_url.rstrip("/") + url = f"{issuer}/.well-known/openid-configuration" + now = time.time() + hit = _discovery_cache.get(issuer) + if hit and now - hit[0] < _DISCOVERY_TTL: + return hit[1] + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.get(url) + r.raise_for_status() + doc = r.json() + except Exception as err: + raise OIDCError(f"OIDC discovery failed ({url}): {err}") from err + if not doc.get("authorization_endpoint") or not doc.get("token_endpoint"): + raise OIDCError("OIDC discovery document is missing authorization/token endpoints") + _discovery_cache[issuer] = (now, doc) + return doc + + +def build_authorize_url( + doc: dict, + *, + client_id: str, + redirect_uri: str, + scope: str, + state: str, + nonce: str, + code_challenge: str, +) -> str: + from urllib.parse import urlencode + + params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": scope or "openid profile email", + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + sep = "&" if "?" in doc["authorization_endpoint"] else "?" + return doc["authorization_endpoint"] + sep + urlencode(params) + + +async def exchange_code( + doc: dict, *, client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str +) -> dict: + """Exchange the authorization code for tokens (PKCE, confidential client).""" + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_id, + "code_verifier": code_verifier, + } + auth = None + if client_secret: + auth = (client_id, client_secret) + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.post(doc["token_endpoint"], data=data, auth=auth) + except Exception as err: + raise OIDCError(f"OIDC token request failed: {err}") from err + if r.status_code != 200: + raise OIDCError(f"OIDC token endpoint returned {r.status_code}: {r.text[:300]}") + try: + tokens = r.json() + except Exception as err: + raise OIDCError(f"OIDC token endpoint returned a non-JSON body: {err}") from err + if "error" in tokens: + raise OIDCError(f"OIDC error: {tokens.get('error')} {tokens.get('error_description', '')}".strip()) + return tokens + + +async def fetch_userinfo(doc: dict, access_token: str) -> dict: + """Best-effort userinfo fetch (groups often only live there).""" + endpoint = doc.get("userinfo_endpoint") + if not endpoint or not access_token: + return {} + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"}) + if r.status_code != 200: + return {} + data = r.json() + return data if isinstance(data, dict) else {} + except Exception as err: # userinfo is optional enrichment + logger.debug("userinfo fetch failed: %s", err) + return {} + + +def validate_id_token( + id_token: str, *, issuer: str, client_id: str, nonce: str, jwks: dict +) -> dict: + """Verify the ID token signature and claims. Returns the claims dict.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from authlib.jose import JsonWebKey + from authlib.jose import jwt as jose_jwt + + if isinstance(id_token, bytes): + # authlib's jose.jwt.encode() returns bytes; IdP token endpoints send + # str — accept both instead of crashing on ``bytes.count(".")``. + id_token = id_token.decode() + if not id_token or id_token.count(".") != 2: + raise OIDCError("Missing or malformed ID token") + + try: + keyset = JsonWebKey.import_key_set(jwks) + except Exception as err: + raise OIDCError(f"Invalid issuer JWKS: {err}") from err + + try: + # Pick the key matching the token header (kid) when several are offered. + header = json.loads(_b64url_decode(id_token.split(".")[0])) + kid = header.get("kid") + key = keyset.get_by_kid(kid) if kid and hasattr(keyset, "get_by_kid") else None + token_obj = jose_jwt.decode(id_token, key or keyset) + except Exception as err: + raise OIDCError(f"ID token signature verification failed: {err}") from err + + claims = dict(token_obj) # authlib's JWTClaims is a dict subclass + now = int(time.time()) + + if claims.get("iss") != issuer.rstrip("/") and claims.get("iss") != issuer: + raise OIDCError(f"ID token issuer mismatch: {claims.get('iss')!r}") + aud = claims.get("aud") + aud_list = aud if isinstance(aud, list) else [aud] + if client_id not in aud_list: + raise OIDCError("ID token audience does not include this client") + exp = claims.get("exp") + if not isinstance(exp, int) or exp < now: + raise OIDCError("ID token expired") + iat = claims.get("iat") + if isinstance(iat, int) and iat > now + 300: + raise OIDCError("ID token issued in the future") + if nonce and claims.get("nonce") != nonce: + raise OIDCError("ID token nonce mismatch") + if not claims.get("sub"): + raise OIDCError("ID token has no subject") + return claims + + +def claims_to_identity(claims: dict, mapping: dict | None = None) -> dict: + """Map OIDC claims onto the shared ``{login, email, full_name, avatar_url, groups}`` shape.""" + mapping = mapping or DEFAULT_OIDC_MAPPING + identity: dict = {"_raw": claims} + for field in ("login", "email", "full_name", "avatar_url"): + source = mapping.get(field) or field + value = claims.get(source, "") + if isinstance(value, list): + value = value[0] if value else "" + identity[field] = str(value or "").strip() + groups = claims.get(mapping.get("groups", "groups"), []) + if isinstance(groups, str): + groups = [groups] + identity["groups"] = [str(g) for g in groups if g] + if not identity["email"]: + identity["email"] = claims.get("email", "") or "" + if not identity["full_name"]: + identity["full_name"] = claims.get("name", "") or identity["email"] + return identity diff --git a/app/auth/providers/saml_provider.py b/app/auth/providers/saml_provider.py new file mode 100644 index 0000000..bbb26ab --- /dev/null +++ b/app/auth/providers/saml_provider.py @@ -0,0 +1,279 @@ +"""SAML 2.0 Service Provider — wrapper around python3-saml (OneLogin toolkit). + +v6.7.0. Adapts FastAPI's ``Request`` to the toolkit's flat ``request_data`` +dict and builds the SP settings from the ``sso_config`` row. + +What the toolkit validates in strict mode (all covered by tests): +XML schema, signature of the assertion and/or the message against the IdP +certificate, ``Conditions`` timestamps, ``Audience``, ``Destination``, +``Issuer``, ``Status``, "exactly one assertion", and ``InResponseTo`` +against the AuthnRequest id we pass to ``process_response()`` — combined +with the single-use ``sso_requests`` store that makes replay impossible. +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from fastapi import Request + +logger = logging.getLogger(__name__) + +BINDING_HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" +BINDING_HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" +NAMEID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + +#: Default attribute mapping (design doc §3.2). ``nameid`` = the assertion's +#: NameID; every other value is matched against attribute Name / FriendlyName +#: / URI local part (so ``email`` finds both ``email`` and +#: ``http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress``). +DEFAULT_SAML_MAPPING: dict[str, str] = { + "login": "nameid", + "email": "nameid", + "full_name": "displayName", + "avatar_url": "avatar", + "groups": "groups", +} + + +class SAMLError(Exception): + """SAML processing failure — ``message`` is user-facing, ``reason`` is logged.""" + + +@dataclass +class SAMLIdentity: + """What a validated assertion tells us about the user.""" + + name_id: str + name_id_format: str = "" + session_index: str = "" + attributes: dict[str, list[str]] = field(default_factory=dict) + friendly_attributes: dict[str, list[str]] = field(default_factory=dict) + + def resolve(self, source: str) -> str: + """Resolve one mapped source (``nameid`` or an attribute name) → first value.""" + if not source or source == "nameid": + return self.name_id or "" + if source in self.attributes and self.attributes[source]: + return (self.attributes[source][0] or "").strip() + # FriendlyName match (case-insensitive) + lower = {k.lower(): v for k, v in self.friendly_attributes.items()} + if source.lower() in lower and lower[source.lower()]: + return (lower[source.lower()][0] or "").strip() + # URI local part match: ".../claims/emailaddress" ~ "emailaddress", and + # a mapping of "email" must still find ".../claims/emailaddress". + want = source.lower().lstrip("./") + for name, values in self.attributes.items(): + if not values: + continue + local = name.rsplit("/", 1)[-1].rsplit("}", 1)[-1].lower() + if local == want or local.endswith(want) or want.endswith(local): + return (values[0] or "").strip() + return "" + + +def external_base_url(request: Request) -> str: + """Scheme://host the user actually used (proxy-aware, like OAuth redirects).""" + proto = request.headers.get("x-forwarded-proto", "") + scheme = proto.split(",")[0].strip() or request.url.scheme or "http" + fwd_host = request.headers.get("x-forwarded-host", "") + host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080") + return f"{scheme}://{host}" + + +def saml_endpoints(request: Request) -> dict[str, str]: + """SP entity id + ACS/SLO/metadata URLs derived from the incoming request.""" + base = external_base_url(request) + return { + "entity_id": f"{base}/auth/saml/metadata", + "acs": f"{base}/auth/saml/callback", + "slo": f"{base}/auth/saml/logout", + "metadata": f"{base}/auth/saml/metadata", + } + + +def build_settings(cfg: dict, endpoints: dict[str, str]) -> dict: + """python3-saml settings dict built from a ``sso_config`` row.""" + sign_requests = bool(cfg.get("sign_requests")) + sp: dict = { + "entityId": endpoints["entity_id"], + "assertionConsumerService": { + "url": endpoints["acs"], + "binding": BINDING_HTTP_POST, + }, + "singleLogoutService": { + "url": endpoints["slo"], + "binding": BINDING_HTTP_REDIRECT, + }, + "NameIDFormat": NAMEID_FORMAT_EMAIL, + } + if cfg.get("sp_private_key") and cfg.get("sp_certificate"): + sp["privateKey"] = cfg["sp_private_key"] + sp["x509cert"] = cfg["sp_certificate"] + + idp: dict = { + "entityId": cfg.get("entity_id") or "", + "singleSignOnService": { + "url": cfg.get("sso_url") or "", + "binding": BINDING_HTTP_REDIRECT, + }, + "x509cert": cfg.get("x509_certificate") or "", + } + if cfg.get("slo_url"): + idp["singleLogoutService"] = {"url": cfg["slo_url"], "binding": BINDING_HTTP_REDIRECT} + + return { + "strict": True, + "debug": False, + "sp": sp, + "idp": idp, + "security": { + "authnRequestsSigned": sign_requests, + "logoutRequestSigned": sign_requests, + "logoutResponseSigned": False, + "wantMessagesSigned": False, + "wantAssertionsSigned": True, + "wantNameIdEncrypted": False, + "wantAssertionsEncrypted": False, + "wantXmlValidation": True, + "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", + "rejectDeprecatedAlgorithm": True, + # FlowDeck is self-hosted: LAN/homelab deploys commonly reach the + # SP through single-label hosts (http://flowdeck/, docker service + # names). python3-saml rejects those URLs unless this is on. + "allowSingleLabelDomains": True, + }, + } + + +def _request_data(request: Request, script_name: str, post_data: dict | None = None) -> dict: + """Flat request dict expected by ``OneLogin_Saml2_Auth``.""" + https = "on" if external_base_url(request).startswith("https") else "off" + return { + "https": https, + "http_host": request.headers.get("host", "localhost:8080"), + "script_name": script_name, + "request_uri": request.url.path, + "query_string": str(request.url.query or ""), + "get_data": dict(request.query_params), + "post_data": post_data or {}, + } + + +def _auth(request: Request, cfg: dict, script_name: str, post_data: dict | None = None): + from onelogin.saml2.auth import OneLogin_Saml2_Auth + + settings = build_settings(cfg, saml_endpoints(request)) + try: + return OneLogin_Saml2_Auth( + _request_data(request, script_name, post_data=post_data), old_settings=settings + ) + except Exception as err: # malformed IdP/SP config (bad cert, missing URL…) + raise SAMLError(f"Invalid SAML configuration: {err}") from err + + +def create_login(request: Request, cfg: dict, relay_state: str) -> tuple[str, str]: + """Build the AuthnRequest. Returns ``(redirect_url, authn_request_id)``.""" + auth = _auth(request, cfg, "/auth/saml/login") + try: + url = auth.login(return_to=relay_state) + except Exception as err: + raise SAMLError(f"Could not build the SAML AuthnRequest: {err}") from err + request_id = auth.get_last_request_id() or "" + if not request_id: + raise SAMLError("AuthnRequest was built without an id") + return url, request_id + + +def process_response(request: Request, cfg: dict, post_data: dict, request_id: str) -> SAMLIdentity: + """Validate the IdP's SAMLResponse and extract the identity. + + ``request_id`` is the id of the AuthnRequest we issued (from the + single-use ``sso_requests`` row): the toolkit rejects any response whose + ``InResponseTo`` does not match it. + """ + auth = _auth(request, cfg, "/auth/saml/callback", post_data=post_data) + try: + auth.process_response(request_id=request_id or None) + except Exception as err: + raise SAMLError(f"SAML response could not be processed: {err}") from err + + errors = auth.get_errors() + if errors: + raise SAMLError(auth.get_last_error_reason() or f"SAML errors: {', '.join(errors)}") + if not auth.is_authenticated(): + raise SAMLError("SAML response did not authenticate the user") + + name_id = auth.get_nameid() or "" + if not name_id: + raise SAMLError("SAML assertion carries no NameID") + return SAMLIdentity( + name_id=name_id, + name_id_format=auth.get_nameid_format() or "", + session_index=auth.get_session_index() or "", + attributes=auth.get_attributes() or {}, + friendly_attributes=auth.get_friendlyname_attributes() or {}, + ) + + +def metadata_xml(request: Request, cfg: dict) -> str: + """SP metadata XML (for the IdP configuration screen).""" + from onelogin.saml2.settings import OneLogin_Saml2_Settings + + settings = OneLogin_Saml2_Settings( + build_settings(cfg, saml_endpoints(request)), custom_base_path=None + ) + try: + xml = settings.get_sp_metadata() + except Exception as err: + raise SAMLError(f"Could not build the SP metadata: {err}") from err + if isinstance(xml, bytes): + xml = xml.decode("utf-8") + return xml + + +def build_logout_url(request: Request, cfg: dict, return_to: str, name_id: str, session_index: str) -> str: + """SP-initiated Single Logout (HTTP-Redirect LogoutRequest to the IdP).""" + auth = _auth(request, cfg, "/auth/saml/logout") + if not cfg.get("slo_url"): + raise SAMLError("The IdP has no Single Logout URL configured") + try: + return auth.logout( + return_to=return_to, + name_id=name_id or None, + session_index=session_index or None, + ) + except Exception as err: + raise SAMLError(f"Could not build the SAML LogoutRequest: {err}") from err + + +def process_slo_form(request: Request, cfg: dict, form: dict, query: dict) -> tuple[str | None, list[str]]: + """Process a LogoutRequest / LogoutResponse received from the IdP. + + ``form`` holds the POSTed fields, ``query`` the GET parameters (the + HTTP-Redirect binding delivers LogoutRequest/LogoutResponse there). + Returns ``(redirect_url, errors)``. + """ + from onelogin.saml2.auth import OneLogin_Saml2_Auth + + settings = build_settings(cfg, saml_endpoints(request)) + https = "on" if external_base_url(request).startswith("https") else "off" + post_data = {k: v for k, v in form.items() if k in ("SAMLRequest", "SAMLResponse", "RelayState")} + if not post_data: + post_data = {"SAMLResponse": query["SAMLResponse"]} if "SAMLResponse" in query else {} + req_data = { + "https": https, + "http_host": request.headers.get("host", "localhost:8080"), + "script_name": "/auth/saml/logout", + "request_uri": request.url.path, + "query_string": str(request.url.query or ""), + "get_data": dict(query), + "post_data": post_data, + } + auth = OneLogin_Saml2_Auth(req_data, old_settings=settings) + try: + url = auth.process_slo(keep_local_session=True) + except Exception as err: + raise SAMLError(f"SAML logout could not be processed: {err}") from err + return url, auth.get_errors() diff --git a/app/config.py b/app/config.py index 5f197ae..32a4997 100644 --- a/app/config.py +++ b/app/config.py @@ -82,6 +82,26 @@ class Settings(BaseSettings): smtp_use_tls: bool = True app_base_url: str = "http://localhost:8080" + # SSO / SAML + OIDC (v6.7.0) — bootstrap fallback ONLY: as soon as an admin + # saves a configuration in Settings → Admin → SSO / Enterprise, the + # `sso_config` table wins (see app/services/sso_provisioning.py). + sso_provider: str = "" # 'saml' | 'oidc' | '' (disabled) + sso_name: str = "Company SSO" # button label on the login page + sso_entity_id: str = "" # SAML: IdP entity id + sso_sso_url: str = "" # SAML: IdP SSO URL (HTTP-Redirect) + sso_slo_url: str = "" # SAML: IdP Single Logout URL + sso_x509_certificate: str = "" # SAML: IdP signing certificate (PEM) + sso_issuer_url: str = "" # OIDC: issuer identifier + sso_client_id: str = "" # OIDC: client id + sso_client_secret: str = "" # OIDC: client secret (env only) + sso_scope: str = "openid profile email" + sso_attribute_mapping: str = "" # JSON, defaults per provider + sso_groups_mapping: str = "[]" # JSON [{sso_group, workspace_role, workspace_id}] + sso_auto_provision: bool = True + sso_only: bool = False # refuse local login when true + sso_sign_requests: bool = False # sign AuthnRequest / LogoutRequest + sso_default_workspace_id: int = 0 + # FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode # (deterministic rule-based planner so the agent works without any API key). agent_enabled: bool = True diff --git a/app/main.py b/app/main.py index c9403ec..304ba35 100644 --- a/app/main.py +++ b/app/main.py @@ -50,6 +50,7 @@ from app.routers.imports import router as imports_router from app.routers.notifications import router as notifications_router from app.routers.permissions import router as permissions_router from app.routers.realtime import router as realtime_router +from app.routers.sso import router as sso_router from app.routers.web_clipper import api_router as web_clipper_api_router from app.routers.web_clipper import router as web_clipper_router from app.services.webhook_outbound import init_webhook_tables @@ -123,7 +124,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="6.6.0", + version="6.7.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, @@ -136,6 +137,7 @@ app.add_middleware(RateLimitMiddleware) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) app.include_router(auth.router) +app.include_router(sso_router) app.include_router(dashboard.router) app.include_router(board.router) app.include_router(notes.router) diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 7215323..a9fb676 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): """ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} - EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"} + EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/saml", "/auth/oidc", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"} async def dispatch(self, request: Request, call_next): # Webhook receiver, OAuth callback, and internal API are exempt diff --git a/app/migrations.py b/app/migrations.py index 79ae172..9847462 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -983,3 +983,87 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_pages_row " "ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL" ) + + +@register(23, "v6.7.0: SSO/SAML enterprise auth") +def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None: + """v6.7.0 — SSO/SAML 2.0 + OIDC enterprise authentication. + + ``sso_config`` — single active SSO provider (SAML or OIDC), managed + from Settings → Admin → SSO / Enterprise. Secrets + (``client_secret``, SP private key) are encrypted at + rest by ``app.services.sso_provisioning``. + ``sso_login_history`` — audit trail of every SSO login attempt (successes + AND rejections — signature failure, replay, no + local account…). + ``sso_requests`` — single-use anti-replay store: AuthnRequest ids and + OIDC states, CSRF relay tokens, PKCE verifiers and + the post-login redirect target. One row is consumed + by exactly one callback. + """ + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sso_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE, + provider_type TEXT NOT NULL DEFAULT 'saml', + name TEXT NOT NULL DEFAULT 'Company SSO', + entity_id TEXT NOT NULL DEFAULT '', + sso_url TEXT NOT NULL DEFAULT '', + slo_url TEXT DEFAULT '', + x509_certificate TEXT NOT NULL DEFAULT '', + issuer_url TEXT DEFAULT '', + client_id TEXT DEFAULT '', + client_secret TEXT DEFAULT '', + scope TEXT DEFAULT 'openid profile email', + attribute_mapping TEXT NOT NULL DEFAULT '{}', + groups_mapping TEXT NOT NULL DEFAULT '[]', + auto_provision INTEGER NOT NULL DEFAULT 1, + sso_only INTEGER NOT NULL DEFAULT 0, + sign_requests INTEGER NOT NULL DEFAULT 0, + default_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL, + sp_private_key TEXT DEFAULT '', + sp_certificate TEXT DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by INTEGER REFERENCES users(id) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sso_login_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + provider_type TEXT NOT NULL, + provider_name TEXT NOT NULL DEFAULT 'SSO', + sso_identifier TEXT, + ip_address TEXT DEFAULT '', + user_agent TEXT DEFAULT '', + success INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_sso_history_user " + "ON sso_login_history(user_id, created_at)" + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sso_requests ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + relay_state TEXT NOT NULL DEFAULT '', + code_verifier TEXT NOT NULL DEFAULT '', + next_path TEXT NOT NULL DEFAULT '/workspaces', + used INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)" + ) diff --git a/app/routers/auth.py b/app/routers/auth.py index 345c524..473aa67 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -63,6 +63,7 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont, .oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;} .oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;} .oauth-btn:hover{background:#333;} +.sso-btn{border-color:rgba(35,131,226,.5);} @@ -88,11 +89,17 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont, -
+

Or continue with

+ +
""" @@ -191,6 +230,16 @@ async def register(request: Request): from fastapi.responses import JSONResponse return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400) + # SSO-only instance (v6.7.0): local registration is refused — accounts are + # auto-provisioned by the IdP instead (admins still come from Settings). + from app.services.sso_provisioning import is_sso_only + if is_sso_only(): + from fastapi.responses import JSONResponse + return JSONResponse( + {"error": "Registration is disabled — sign in with your organization SSO"}, + status_code=403, + ) + with get_conn() as conn: existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone() if existing: @@ -260,7 +309,15 @@ async def local_login(request: Request): conn.commit() return JSONResponse({"error": "Invalid credentials"}, status_code=401) - # Successful login + # Successful local login — SSO-only instances keep a way in for admins + # only (every other account must use the IdP, design §7.1). + from app.services.sso_provisioning import is_sso_only + if is_sso_only() and not ud.get("is_admin"): + return JSONResponse( + {"error": "Local login is disabled on this instance — sign in with SSO"}, + status_code=403, + ) + with get_conn() as conn: conn.execute( "UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?", @@ -373,9 +430,21 @@ async def callback( @router.get("/logout") -async def logout(): - """Clear session and redirect to login page.""" - response = RedirectResponse(url="/auth/login?provider=local", status_code=302) +async def logout(request: Request): + """Clear session and redirect to login page. + + SAML sessions additionally hand over to the IdP's Single Logout when one + is configured (the actual cookie clearing happens on the SLO route). + """ + cookie = request.cookies.get("flowdeck_session", "") + user = SessionManager.decode_session(cookie) if cookie else None + local_target = "/auth/login?provider=local" + + if user and user.get("_sso_name_id"): + # SSO session → let /auth/saml/logout revoke locally + notify the IdP. + return RedirectResponse(url=f"/auth/saml/logout?next={local_target}", status_code=302) + + response = RedirectResponse(url=local_target, status_code=302) response.delete_cookie("flowdeck_session") return response diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 323416f..a69b2bf 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -489,6 +489,7 @@ async def help_page(request: Request): .help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;} .help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;} .help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;} +.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
@@ -590,6 +591,16 @@ FlowDeck supports three authentication methods:
GitHub OAuth Login or link your GitHub account in Settings → Integrations.

Tip: You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.

+

+SSO Enterprise SSO (v6.7.0) — sign in with your organization account.
+For administrators: open Settings → Admin → SSO / Enterprise and pick a provider:
+• SAML 2.0 — paste the IdP Entity ID, SSO URL and signing certificate, then give the IdP this +/auth/saml/metadata link (it contains the SP Entity ID, ACS URL and certificate).
+• OpenID Connect — paste the Issuer URL, Client ID and Client Secret (PKCE is used, scopes default to openid profile email).
+• Provisioning — accounts are created automatically on first login, groups from the IdP map to workspace roles, +and SSO only disables local login (admins keep their local door). Every attempt is audited in +Settings → Admin → SSO / Enterprise (login history). +

diff --git a/app/routers/sso.py b/app/routers/sso.py new file mode 100644 index 0000000..9d54d70 --- /dev/null +++ b/app/routers/sso.py @@ -0,0 +1,656 @@ +"""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""" +FlowDeck — {title}

{title}

{body}
""", + status_code=status, + ) + + +def _sso_config_or_error() -> dict | None: + cfg = sso.get_sso_config() + return sso.normalize_config(cfg) if cfg else None + + +def _session_cookie(user_data: dict, request: Request): + """Signed, revocable session cookie (same shape as local/OAuth logins).""" + return SessionManager.create_session(user_data, request) + + +def _login_error(message: str, *, cfg: dict | None, identifier: str = "", request=None) -> HTMLResponse: + provider_type = (cfg or {}).get("provider_type", "saml") + sso.log_sso_login( + user_id=None, + provider_type=provider_type, + provider_name=(cfg or {}).get("name") or "SSO", + identifier=identifier, + request=request, + success=False, + error=message, + ) + logger.warning("SSO login rejected: %s", message) + safe = ( + message.replace("&", "&").replace("<", "<").replace(">", ">")[:400] + ) + return _page( + "SSO sign-in failed", + f"

{safe}

↩ Back to login

", + 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.

" + "

↩ Use local login

", + status=404, + ) + + cfg = sso.ensure_sp_keypair(cfg) + # RelayState = "." — both checked at the ACS. + csrf_token = secrets.token_hex(16) + # The id is only known after building the request, so build it first with a + # placeholder relay state, then re-issue with the real one? python3-saml + # builds the AuthnRequest inside login(); we instead create the row right + # after login() returns the URL — but the RelayState is already embedded. + # So: generate the request id ourselves is not possible → build the URL, + # then patch the RelayState by rebuilding with the known id. + from urllib.parse import parse_qs, urlencode, urlparse + + provisional = saml_provider.create_login(request, cfg, relay_state="_pending_") + authn_id = provisional[1] + relay = f"{authn_id}.{csrf_token}" + sso.create_request( + "saml_authn", + request_id=authn_id, + relay_state=csrf_token, + next_path=sso.safe_next_path(next), + ) + # Replace the placeholder RelayState with the real token (same SAMLRequest). + parsed = urlparse(provisional[0]) + params = parse_qs(parsed.query) + params["RelayState"] = [relay] + flat = [(k, v) for k, values in params.items() for v in values] + url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(flat)}" + return RedirectResponse(url, status_code=302) + + +@router.post("/auth/saml/callback") +async def saml_callback(request: Request): + """Assertion Consumer Service — validate the SAMLResponse and open a session.""" + if not _rate_ok(request, "saml-cb"): + return _page("Too many attempts", "

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.

" + "

↩ Use local login

", + 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} diff --git a/app/services/permission_manager.py b/app/services/permission_manager.py index ca03099..f9952b6 100644 --- a/app/services/permission_manager.py +++ b/app/services/permission_manager.py @@ -98,6 +98,74 @@ class PermissionManager: ).fetchone() return "owner" if owner else "viewer" + # ── SSO (v6.7.0, design §7.2) ───────────────────────────────────────── + + def is_sso_only_workspace(self, workspace_id: int | None = None) -> bool: + """True when that workspace can only be reached through SSO. + + FlowDeck keeps a single instance-wide SSO-only switch (design §7.1 / + §4.2): when it is on, local login is refused for every non-admin, so + every workspace on the instance is effectively SSO-only. + ``workspace_id`` is accepted to mirror the design's per-workspace API. + """ + from app.services.sso_provisioning import is_sso_only + + return is_sso_only() + + def _user_auth_method(self) -> str: + with get_conn() as conn: + row = conn.execute( + "SELECT auth_method FROM users WHERE id=?", (self.user_id,) + ).fetchone() + return (row["auth_method"] or "local") if row else "local" + + def get_sso_roles( + self, user_id: int | None = None, workspace_id: int | None = None + ) -> list[str]: + """Roles granted to that user through SSO group mapping (design §7.2). + + SSO grants land in the regular ``workspace_members`` row (the mapping + is re-applied at every SSO login), so the answer is the explicit + membership role of a non-local account — local accounts and users + without an explicit grant (the implicit *viewer* fallback is not an + SSO grant) get ``[]``. + """ + if workspace_id is None: + return [] + pm = PermissionManager(int(user_id)) if (user_id and int(user_id) != self.user_id) else self + if pm._user_auth_method() == "local": + return [] + with get_conn() as conn: + row = conn.execute( + "SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", + (int(workspace_id), pm.user_id), + ).fetchone() + return [row["role"]] if row else [] + + def sync_sso_permissions( + self, + user_id: int | None, + sso_groups: list[str], + workspace_id: int | None = None, + ) -> list[int]: + """Re-apply the group → workspace role mapping (design §7.2). + + Delegates to ``sso_provisioning.sync_sso_groups`` (the single source + of truth used at login and by ``POST /api/v2/sso/sync``). Returns the + touched workspace ids, narrowed to ``workspace_id`` when given. + """ + from app.services.sso_provisioning import get_sso_config, sync_sso_groups + + cfg = get_sso_config() + if not cfg: + return [] + touched = sync_sso_groups(int(user_id or self.user_id), list(sso_groups or []), cfg) + if workspace_id is not None: + touched = [w for w in touched if int(w) == int(workspace_id)] + if touched: + self.invalidate() + return touched + def can_read(self, workspace_id: int | None) -> bool: return self.role_in_workspace(workspace_id) in READ_ROLES diff --git a/app/services/sso_provisioning.py b/app/services/sso_provisioning.py new file mode 100644 index 0000000..84e0eab --- /dev/null +++ b/app/services/sso_provisioning.py @@ -0,0 +1,783 @@ +"""v6.7.0 — SSO provisioning: config store, auto-provisioning, group mapping. + +Single source of truth for the SSO configuration (``sso_config`` table, with +an ``SSO_*`` environment fallback for bootstrap installs) and for what +happens when an IdP says "this is alice@corp.example": + +1. resolve the local account (by email → merge, else by login), +2. create it when ``auto_provision`` is on, else reject with an audit row, +3. sync attributes + map SSO groups to workspace roles, +4. hand back the user dict so the caller can mint a session. + +Secrets at rest: ``client_secret`` and the generated SP private key are +encrypted with a Fernet key derived from ``app_secret_key``. +""" +from __future__ import annotations + +import hashlib +import json +import logging +import secrets +import time +import urllib.parse + +from app.config import settings + +logger = logging.getLogger(__name__) + +VALID_PROVIDER_TYPES = ("saml", "oidc") + + +class SSOConfigError(Exception): + """Invalid SSO configuration payload (message shown to the admin).""" + + +class SSOProvisioningError(Exception): + """A login was rejected (no local account, missing attributes…).""" + + +# ── Secrets at rest ──────────────────────────────────────────────────────── + + +def _fernet(): + from cryptography.fernet import Fernet + + key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest() + import base64 + + return Fernet(base64.urlsafe_b64encode(key)) + + +def encrypt_secret(value: str) -> str: + if not value: + return "" + return _fernet().encrypt(value.encode()).decode() + + +def decrypt_secret(value: str) -> str: + if not value: + return "" + try: + return _fernet().decrypt(value.encode()).decode() + except Exception: + return "" # key rotated / not ours — treat as unset + + +# ── Config store ─────────────────────────────────────────────────────────── + + +def _default_mapping(provider_type: str) -> dict: + if provider_type == "oidc": + from app.auth.providers.oidc_provider import DEFAULT_OIDC_MAPPING + + return dict(DEFAULT_OIDC_MAPPING) + from app.auth.providers.saml_provider import DEFAULT_SAML_MAPPING + + return dict(DEFAULT_SAML_MAPPING) + + +def _env_config() -> dict | None: + """Bootstrap config from ``SSO_*`` env vars (design doc §3.3). + + Only used when the table holds no active row — the Settings UI always + wins once an admin saved a configuration. + """ + provider_type = (settings.sso_provider or "").strip().lower() + if provider_type not in VALID_PROVIDER_TYPES: + return None + cfg = { + "id": 0, + "provider_type": provider_type, + "name": settings.sso_name or "Company SSO", + "entity_id": settings.sso_entity_id, + "sso_url": settings.sso_sso_url, + "slo_url": settings.sso_slo_url, + "x509_certificate": settings.sso_x509_certificate, + "issuer_url": settings.sso_issuer_url, + "client_id": settings.sso_client_id, + "client_secret": settings.sso_client_secret, + "scope": settings.sso_scope, + "attribute_mapping": settings.sso_attribute_mapping, + "groups_mapping": settings.sso_groups_mapping, + "auto_provision": int(settings.sso_auto_provision), + "sso_only": int(settings.sso_only), + "sign_requests": int(settings.sso_sign_requests), + "default_workspace_id": settings.sso_default_workspace_id, + "sp_private_key": "", + "sp_certificate": "", + "workspace_id": None, + "active": 1, + "_source": "env", + } + if provider_type == "saml" and (not cfg["entity_id"] or not cfg["sso_url"]): + return None + if provider_type == "oidc" and (not cfg["issuer_url"] or not cfg["client_id"]): + return None + return cfg + + +def get_sso_config(require_active: bool = True) -> dict | None: + """Active SSO config as a dict (DB row, else env fallback).""" + row = _raw_row(require_active=require_active) + if row: + cfg = dict(row) + cfg["_source"] = "db" + return cfg + if not require_active: + return _env_config() + return _env_config() + + +def _raw_row(require_active: bool = True) -> dict | None: + """Raw ``sso_config`` row (``client_secret`` still encrypted, ``_source`` unset).""" + from app.db import get_conn + + try: + with get_conn() as conn: + where = "WHERE active=1" if require_active else "" + row = conn.execute( + f"SELECT * FROM sso_config {where} ORDER BY id LIMIT 1" + ).fetchone() + except Exception: # table missing (very old install) → env only + return None + return dict(row) if row else None + + +def client_secret_value(cfg: dict) -> str: + """Plaintext OIDC client secret (decrypted for DB rows, raw for env).""" + raw = (cfg or {}).get("client_secret") or "" + if not raw: + return "" + if (cfg or {}).get("_source") == "env": + return raw + return decrypt_secret(raw) + + +def _json_field(value, fallback): + if isinstance(value, (dict, list)): + return value + try: + parsed = json.loads(value or "") + return parsed if isinstance(parsed, type(fallback)) else fallback + except Exception: + return fallback + + +def _strict_json(value, expected, field: str): + """Parse a payload field and reject wrong shapes (before normalize, + which would otherwise silently coerce ``"[]"`` → ``{}``).""" + if value is None or value == "": + return expected() + if isinstance(value, (dict, list)): + parsed = value + else: + try: + parsed = json.loads(value) + except Exception as exc: + raise SSOConfigError(f"{field} must be valid JSON") from exc + if not isinstance(parsed, expected): + kind = "object" if expected is dict else "array" + raise SSOConfigError(f"{field} must be a JSON {kind}") + return parsed + + +def normalize_config(cfg: dict) -> dict: + """Parse JSON columns + fill defaults (single place for every consumer).""" + out = dict(cfg) + out["attribute_mapping"] = _json_field(out.get("attribute_mapping"), {}) + out["groups_mapping"] = _json_field(out.get("groups_mapping"), []) + if not out["attribute_mapping"]: + out["attribute_mapping"] = _default_mapping(out.get("provider_type", "saml")) + for key in ("entity_id", "sso_url", "slo_url", "x509_certificate", "issuer_url", + "client_id", "client_secret", "scope", "name"): + out[key] = (out.get(key) or "").strip() + for key in ("auto_provision", "sso_only", "sign_requests", "active"): + out[key] = int(out.get(key) or 0) + return out + + +def validate_config_payload(payload: dict) -> dict: + """Validate + sanitize an admin payload. Raises ``SSOConfigError``.""" + provider_type = str(payload.get("provider_type") or "").strip().lower() + if provider_type not in VALID_PROVIDER_TYPES: + raise SSOConfigError(f"provider_type must be one of {', '.join(VALID_PROVIDER_TYPES)}") + + payload = dict(payload) + payload["attribute_mapping"] = _strict_json( + payload.get("attribute_mapping"), dict, "attribute_mapping" + ) + payload["groups_mapping"] = _strict_json( + payload.get("groups_mapping"), list, "groups_mapping" + ) + cfg = normalize_config({**payload, "provider_type": provider_type}) + + if provider_type == "saml": + for field in ("entity_id", "sso_url"): + if not cfg[field]: + raise SSOConfigError(f"SAML requires '{field}'") + for field, url in (("sso_url", cfg["sso_url"]), ("slo_url", cfg["slo_url"])): + if url and not url.startswith(("http://", "https://")): + raise SSOConfigError(f"'{field}' must be an http(s) URL") + cert = cfg["x509_certificate"].strip() + if cert and "BEGIN CERTIFICATE" not in cert: + raise SSOConfigError("x509_certificate must be a PEM certificate") + if not cert: + raise SSOConfigError("SAML requires the IdP signing certificate (x509_certificate)") + cfg["x509_certificate"] = cert + else: + if not cfg["issuer_url"] or not cfg["client_id"]: + raise SSOConfigError("OIDC requires 'issuer_url' and 'client_id'") + if not cfg["issuer_url"].startswith(("http://", "https://")): + raise SSOConfigError("'issuer_url' must be an http(s) URL") + + if not isinstance(cfg["attribute_mapping"], dict): + raise SSOConfigError("attribute_mapping must be a JSON object") + if not isinstance(cfg["groups_mapping"], list): + raise SSOConfigError("groups_mapping must be a JSON array") + for entry in cfg["groups_mapping"]: + if not isinstance(entry, dict) or "sso_group" not in entry: + raise SSOConfigError("groups_mapping entries need at least an 'sso_group' key") + + ws = cfg.get("default_workspace_id") + cfg["default_workspace_id"] = int(ws) if ws not in (None, "", 0) else None + return cfg + + +def save_sso_config(payload: dict, created_by: int | None = None) -> dict: + """Create or replace the single SSO configuration (idempotent).""" + from app.db import get_conn + + cfg = validate_config_payload(payload) + columns = { + "provider_type": cfg["provider_type"], + "name": cfg.get("name") or "Company SSO", + "entity_id": cfg["entity_id"], + "sso_url": cfg["sso_url"], + "slo_url": cfg["slo_url"], + "x509_certificate": cfg["x509_certificate"], + "issuer_url": cfg["issuer_url"], + "client_id": cfg["client_id"], + "scope": cfg.get("scope") or "openid profile email", + "attribute_mapping": json.dumps(cfg["attribute_mapping"]), + "groups_mapping": json.dumps(cfg["groups_mapping"]), + "auto_provision": cfg["auto_provision"], + "sso_only": cfg["sso_only"], + "sign_requests": cfg["sign_requests"], + "default_workspace_id": cfg["default_workspace_id"], + "active": 1, + "updated_at": str(int(time.time())), + } + + # Secret handling: a blank incoming secret keeps the stored one (the raw + # row still holds the Fernet blob — never re-encrypt a decrypted value). + existing = _raw_row() or {} + if "client_secret" in cfg: + incoming = str(cfg.get("client_secret") or "").strip() + if incoming: + columns["client_secret"] = encrypt_secret(incoming) + else: + columns["client_secret"] = existing.get("client_secret") or "" + # SP keypair: keep an existing one, generate one for SAML if missing. + sp_key = existing.get("sp_private_key") or "" + sp_cert = existing.get("sp_certificate") or "" + if cfg["provider_type"] == "saml" and not (sp_key and sp_cert): + sp_key, sp_cert = generate_sp_keypair() + columns["sp_private_key"] = sp_key + columns["sp_certificate"] = sp_cert + if created_by: + columns["created_by"] = created_by + + with get_conn() as conn: + row = conn.execute("SELECT id FROM sso_config ORDER BY id LIMIT 1").fetchone() + if row: + sets = ", ".join(f"{k}=?" for k in columns) + conn.execute(f"UPDATE sso_config SET {sets} WHERE id=?", (*columns.values(), row["id"])) + cfg_id = row["id"] + else: + keys = ", ".join(columns) + placeholders = ", ".join("?" for _ in columns) + cur = conn.execute( + f"INSERT INTO sso_config ({keys}) VALUES ({placeholders})", tuple(columns.values()) + ) + cfg_id = cur.lastrowid + conn.commit() + saved = get_sso_config(require_active=False) + saved["id"] = cfg_id + return saved + + +def delete_sso_config() -> bool: + """Disable SSO entirely (local logins keep working).""" + from app.db import get_conn + + with get_conn() as conn: + cur = conn.execute("UPDATE sso_config SET active=0, updated_at=?", (str(int(time.time())),)) + conn.commit() + return cur.rowcount > 0 + + +def public_config_view(cfg: dict | None) -> dict: + """Config for the admin UI — secrets never leave the server.""" + if not cfg: + return {"configured": False} + cfg = normalize_config(cfg) + return { + "configured": True, + "id": cfg.get("id"), + "source": cfg.get("_source", "db"), + "provider_type": cfg["provider_type"], + "name": cfg.get("name") or "Company SSO", + "entity_id": cfg["entity_id"], + "sso_url": cfg["sso_url"], + "slo_url": cfg["slo_url"], + "x509_certificate": cfg["x509_certificate"], + "issuer_url": cfg["issuer_url"], + "client_id": cfg["client_id"], + "client_secret_set": bool(client_secret_value(cfg)), + "scope": cfg.get("scope") or "openid profile email", + "attribute_mapping": cfg["attribute_mapping"], + "groups_mapping": cfg["groups_mapping"], + "auto_provision": bool(cfg["auto_provision"]), + "sso_only": bool(cfg["sso_only"]), + "sign_requests": bool(cfg["sign_requests"]), + "default_workspace_id": cfg.get("default_workspace_id"), + "sp_certificate": cfg.get("sp_certificate") or "", + "active": bool(cfg.get("active", 1)), + "provisioned_users": provisioned_count(), + } + + +def is_sso_only(cfg: dict | None = None) -> bool: + """True when local login must be refused (design §7.1 / §4.2).""" + cfg = cfg if cfg is not None else get_sso_config() + return bool(cfg and normalize_config(cfg).get("sso_only")) + + +def provisioned_count() -> int: + from app.db import get_conn + + try: + with get_conn() as conn: + row = conn.execute( + "SELECT COUNT(*) AS n FROM users WHERE auth_method IN ('saml','oidc')" + ).fetchone() + return int(row["n"] if row is not None else 0) + except Exception: + return 0 + + +def generate_sp_keypair() -> tuple[str, str]: + """RSA-2048 key + self-signed certificate for the SP (metadata + signing).""" + import datetime + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, f"flowdeck-sp-{secrets.token_hex(4)}"), + ]) + now = datetime.datetime.now(datetime.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .sign(key, hashes.SHA256()) + ) + priv = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode() + public = cert.public_bytes(serialization.Encoding.PEM).decode() + return priv, public + + +def ensure_sp_keypair(cfg: dict) -> dict: + """Guarantee the SAML config carries an SP keypair (generates + persists).""" + if cfg.get("provider_type") != "saml": + return cfg + if cfg.get("sp_private_key") and cfg.get("sp_certificate"): + return cfg + from app.db import get_conn + + priv, cert = generate_sp_keypair() + try: + with get_conn() as conn: + conn.execute( + "UPDATE sso_config SET sp_private_key=?, sp_certificate=? WHERE id=?", + (priv, cert, cfg.get("id")), + ) + conn.commit() + except Exception as err: # env-sourced config has no row to update + logger.debug("SP keypair not persisted: %s", err) + cfg = dict(cfg) + cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert + return cfg + cfg = dict(cfg) + cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert + return cfg + + +# ── Audit ────────────────────────────────────────────────────────────────── + + +def log_sso_login( + *, + user_id: int | None, + provider_type: str, + provider_name: str, + identifier: str, + request, + success: bool, + error: str = "", +) -> None: + """Write one ``sso_login_history`` row (failures included — design §5.2).""" + ip = request.client.host if request is not None and getattr(request, "client", None) else "" + ua = (request.headers.get("user-agent", "") if request is not None else "")[:500] + try: + from app.db import get_conn + + with get_conn() as conn: + conn.execute( + """INSERT INTO sso_login_history + (user_id, provider_type, provider_name, sso_identifier, + ip_address, user_agent, success, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (user_id, provider_type, provider_name, (identifier or "")[:320], ip, ua, + 1 if success else 0, (error or "")[:500]), + ) + conn.commit() + except Exception as err: # audit must never break the login path + logger.warning("sso_login_history write failed: %s", err) + + +# ── Group mapping ────────────────────────────────────────────────────────── + + +def sync_sso_groups(user_id: int, sso_groups: list[str], cfg: dict) -> list[int]: + """Apply ``groups_mapping`` → ``workspace_members.role``. Returns touched ws ids.""" + from app.db import get_conn + + cfg = normalize_config(cfg) + mappings = cfg.get("groups_mapping") or [] + wanted = {g.strip().lower() for g in sso_groups if g and str(g).strip()} + touched: list[int] = [] + + with get_conn() as conn: + for entry in mappings: + group_name = str(entry.get("sso_group") or "").strip().lower() + if not group_name or group_name not in wanted: + continue + ws_id = entry.get("workspace_id") or cfg.get("default_workspace_id") + if not ws_id: + continue + role = str(entry.get("workspace_role") or "editor").strip() or "editor" + if role not in ("owner", "admin", "editor", "viewer"): + role = "editor" + conn.execute( + """INSERT INTO workspace_members (workspace_id, user_id, role) + VALUES (?, ?, ?) + ON CONFLICT(workspace_id, user_id) DO UPDATE SET role=excluded.role""", + (int(ws_id), user_id, role), + ) + touched.append(int(ws_id)) + + # Default workspace: every SSO user lands there as a plain member. + default_ws = cfg.get("default_workspace_id") + if default_ws: + conn.execute( + """INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) + VALUES (?, ?, 'editor')""", + (int(default_ws), user_id), + ) + if int(default_ws) not in touched: + touched.append(int(default_ws)) + conn.commit() + return touched + + +def force_sync_all_groups() -> dict: + """Re-apply the group mapping for every SSO user (``POST /api/v2/sso/sync``).""" + from app.db import get_conn + + cfg = get_sso_config() + if not cfg: + raise SSOProvisioningError("No SSO configuration") + cfg = normalize_config(cfg) + mapping = cfg.get("attribute_mapping") or {} + groups_source = mapping.get("groups", "groups") + updated = 0 + with get_conn() as conn: + rows = conn.execute( + "SELECT id, auth_method FROM users WHERE auth_method IN ('saml','oidc')" + ).fetchall() + for row in rows: + groups = _stored_groups(row["id"], groups_source, cfg) + if sync_sso_groups(row["id"], groups, cfg): + updated += 1 + return {"users": len(rows), "updated": updated} + + +def _stored_groups(user_id: int, source: str, cfg: dict) -> list[str]: + """Groups seen at the last login of that user (stored in attribute sync).""" + try: + from app.db import get_conn + + with get_conn() as conn: + row = conn.execute( + "SELECT sso_identifier FROM sso_login_history " + "WHERE user_id=? AND success=1 ORDER BY id DESC LIMIT 1", + (user_id,), + ).fetchone() + if not row or not row["sso_identifier"]: + return [] + raw = row["sso_identifier"] + if "|" in raw: + ident, _, groups_json = raw.partition("|") + groups = json.loads(groups_json or "[]") + return [str(g) for g in groups] if isinstance(groups, list) else [] + return [] + except Exception: + return [] + + +# ── Auto-provisioning ────────────────────────────────────────────────────── + + +def _unique_login(conn, base: str) -> str: + candidate = base + n = 1 + while conn.execute("SELECT 1 FROM users WHERE login=?", (candidate,)).fetchone(): + n += 1 + candidate = f"{base}_{n}" + return candidate + + +def identity_from_saml(identity, cfg: dict) -> dict: + """Apply the SAML attribute mapping to a validated assertion.""" + cfg = normalize_config(cfg) + mapping = cfg.get("attribute_mapping") or {} + out = { + "login": identity.resolve(mapping.get("login", "nameid")), + "email": identity.resolve(mapping.get("email", "nameid")), + "full_name": identity.resolve(mapping.get("full_name", "displayName")), + "avatar_url": identity.resolve(mapping.get("avatar_url", "avatar")), + "name_id": identity.name_id, + } + groups = identity.resolve(mapping.get("groups", "groups")) + if groups: + # Multi-valued SAML attribute: take every value of the resolved source. + source = mapping.get("groups", "groups") + values = identity.attributes.get(source) or identity.friendly_attributes.get(source) or [groups] + out["groups"] = [str(v).strip() for v in values if v and str(v).strip()] + else: + out["groups"] = [] + out["email"] = (out["email"] or "").strip().lower() + if "@" not in out["email"]: + # NameID may be a persistent opaque id — fall back to login when it is + # an email, otherwise leave empty (login will carry the identity). + out["email"] = out["email"] if "@" in (out["login"] or "") else "" + if not out["full_name"]: + out["full_name"] = out["email"] or out["login"] + out["login"] = out["login"] or out["email"] or f"sso_{identity.name_id[:32]}" + return out + + +def handle_sso_login(identity: dict, *, provider_type: str, cfg: dict, request) -> dict: + """Resolve/create the local user for an SSO identity. Returns the user dict. + + Raises ``SSOProvisioningError`` when the login must be refused (the + caller writes the audit row). + """ + from app.db import get_conn + + cfg = normalize_config(cfg) + email = (identity.get("email") or "").strip().lower() + login_hint = (identity.get("login") or "").strip() + if not email and not login_hint: + raise SSOProvisioningError( + "SSO assertion carries no usable email/login — check the attribute mapping" + ) + + with get_conn() as conn: + user = None + if email: + user = conn.execute( + "SELECT * FROM users WHERE lower(email)=? AND email!='' ORDER BY id LIMIT 1", + (email,), + ).fetchone() + if not user and login_hint: + user = conn.execute("SELECT * FROM users WHERE login=?", (login_hint,)).fetchone() + + if user: + # §7.1 — email match → merge: the existing account is reused and + # tagged with the SSO method (no duplicate account). + updates, params = [], [] + if identity.get("full_name"): + updates.append("full_name=?") + params.append(identity["full_name"]) + if email: + updates.append("email=?") + params.append(email) + if identity.get("avatar_url"): + updates.append("avatar_url=?") + params.append(identity["avatar_url"]) + updates.append("auth_method=?") + params.append(provider_type) + updates.append("last_login=?") + params.append(str(time.time())) + params.append(user["id"]) + conn.execute(f"UPDATE users SET {', '.join(updates)} WHERE id=?", params) + conn.commit() + row = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone() + else: + if not cfg.get("auto_provision"): + raise SSOProvisioningError( + "No local account for this SSO identity and auto-provisioning is disabled" + ) + base_login = login_hint or email + login = _unique_login(conn, base_login) + conn.execute( + """INSERT INTO users + (login, full_name, email, avatar_url, auth_method, is_admin, last_login) + VALUES (?, ?, ?, ?, ?, 0, ?)""", + ( + login, + identity.get("full_name") or email or login, + email, + identity.get("avatar_url") or "", + provider_type, + str(time.time()), + ), + ) + conn.commit() + row = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone() + + if not row: + raise SSOProvisioningError("Could not create or load the SSO user") + user_dict = dict(row) + sync_sso_groups(user_dict["id"], identity.get("groups") or [], cfg) + return user_dict + + +def sso_identifier_field(identity: dict) -> str: + """Audit identifier: ``nameid|["groups",...]`` (groups kept for re-sync).""" + ident = identity.get("name_id") or identity.get("email") or identity.get("login") or "" + groups = identity.get("groups") or [] + if groups: + return f"{ident}|{json.dumps(groups)}" + return ident + + +def safe_next_path(candidate: str | None) -> str: + """Sanitize the post-login redirect target (open-redirect guard).""" + if not candidate: + return "/workspaces" + candidate = str(candidate) + if not candidate.startswith("/") or candidate.startswith("//"): + return "/workspaces" + parsed = urllib.parse.urlsplit(candidate) + if parsed.scheme or parsed.netloc: + return "/workspaces" + return candidate + + +# ── Anti-replay request store ────────────────────────────────────────────── + +REQUEST_TTL_SECONDS = 600 # AuthnRequest / OIDC state lifetime + + +def create_request(kind: str, *, request_id: str, relay_state: str = "", + code_verifier: str = "", next_path: str = "/workspaces") -> None: + """Store a single-use SSO request (AuthnRequest id / OIDC state).""" + from app.db import get_conn + + purge_stale_requests() + with get_conn() as conn: + conn.execute( + """INSERT OR REPLACE INTO sso_requests + (id, kind, relay_state, code_verifier, next_path, used, created_at) + VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)""", + (request_id, kind, relay_state, code_verifier, safe_next_path(next_path)), + ) + conn.commit() + + +def consume_request(kind: str, request_id: str, relay_state: str = "") -> dict | None: + """Atomically consume a request. Returns the row, or None (replay/unknown).""" + if not request_id: + return None + from app.db import get_conn + + with get_conn() as conn: + conn.execute( + "DELETE FROM sso_requests WHERE created_at < datetime('now', ?)", + (f"-{REQUEST_TTL_SECONDS} seconds",), + ) + row = conn.execute( + "SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0", + (request_id, kind), + ).fetchone() + if not row: + return None + if relay_state and row["relay_state"] and not secrets.compare_digest( + row["relay_state"], relay_state + ): + return None + cur = conn.execute( + "UPDATE sso_requests SET used=1 WHERE id=? AND used=0", (request_id,) + ) + conn.commit() + if cur.rowcount != 1: + return None + return dict(row) + + +def peek_request(kind: str, request_id: str) -> dict | None: + """Read a request without consuming it (CSRF check before heavy validation).""" + if not request_id: + return None + from app.db import get_conn + + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0", + (request_id, kind), + ).fetchone() + return dict(row) if row else None + + +def was_consumed(kind: str, request_id: str) -> bool: + """True when this single-use request id was already spent (replay).""" + if not request_id: + return False + from app.db import get_conn + + with get_conn() as conn: + row = conn.execute( + "SELECT 1 FROM sso_requests WHERE id=? AND kind=? AND used=1", + (request_id, kind), + ).fetchone() + return row is not None + + +def purge_stale_requests() -> None: + try: + from app.db import get_conn + + with get_conn() as conn: + conn.execute( + "DELETE FROM sso_requests WHERE created_at < datetime('now', ?)", + (f"-{REQUEST_TTL_SECONDS} seconds",), + ) + conn.commit() + except Exception: + pass diff --git a/app/templates/settings.html b/app/templates/settings.html index 5c7d39f..1012ea3 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -153,6 +153,7 @@
{{ fd_icon("users",14) }} Users & Roles
{{ fd_icon("file-text",14) }} Audit Log
{{ fd_icon("download",14) }} Backups
+
{{ fd_icon("lock",14) }} SSO / Enterprise
@@ -870,6 +871,141 @@ + +
+

SSO / Enterprise

+

Connectez un IdP d'entreprise (SAML 2.0 ou OpenID Connect). Les comptes sont créés au premier login et les groupes de l'IdP deviennent des rôles workspace.

+ + +
+
🔐
+
+
+
+
+
+ +
+
+ + +
+

Fournisseur

+
+ + +
+
+ + +
+

Configuration SAML

+
Entity ID (IdP)
+ +
Single Sign-On URL
+ +
Single Logout URL (optionnel)
+ +
Certificat de signature de l'IdP (PEM)
+ +
Mapping des attributs (JSON)
+ + +
+ SP metadata à donner à l'IdP : + + +
+
+ + +
+

Configuration OpenID Connect

+
Issuer URL
+ +
Client ID
+ +
Client Secret
+
+ +
+
Scope
+ +

L'échange du code utilise PKCE (S256) et le nonce est vérifié à chaque login.

+
+ + +
+

Provisioning & accès

+ + +
Espace par défaut des utilisateurs SSO
+ +
+ + +
+

Groupes IdP → rôles workspace

+
+ + + + + + +
Groupe SSORôleEspace
Aucune règle — les groupes de l'IdP ne modifient aucun rôle.
+
+ +
+ + +
+ + + + +
+

Configuration actuellement lue depuis les variables SSO_* du .env — un enregistrement ici la remplace.

+
+

Agent & IA

@@ -1115,6 +1251,17 @@ function settingsInit() { backupMsg: '', backupOk: false, + // v6.7.0 SSO / Enterprise (admin) + ssoCfg: {id:null, provider_type:'saml', name:'', entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, scope:'openid profile email', attribute_mapping:{}, auto_provision:true, sso_only:false, sign_requests:false, default_workspace_id:''}, + ssoAttrJson: '{}', + ssoGroups: [], + ssoWorkspaces: [], + ssoSource: 'db', + ssoProvisioned: 0, + ssoSaving: false, + ssoMsg: '', + ssoOk: false, + async init() { await this.loadTags(); await this.loadGiteaStatus(); @@ -1935,6 +2082,113 @@ function settingsInit() { } catch(e) { this.clipTestMsg = 'Erreur réseau'; } finally { this.clipTesting = false; } }, + // ── v6.7.0 SSO / Enterprise (admin) ── + ssoStatusTitle() { + if (!this.ssoCfg.id && this.ssoSource !== 'env') return ''; + if (this.ssoSource === 'env') return 'Configuration lue depuis le .env (SSO_*)'; + return 'SSO actif — ' + (this.ssoCfg.name || (this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect')); + }, + ssoStatusDesc() { + var kind = this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect'; + return kind + ' · ' + this.ssoProvisioned + ' utilisateur(s) provisionné(s) · login local ' + (this.ssoCfg.sso_only ? 'DÉSACTIVÉ (SSO only)' : 'autorisé'); + }, + ssoMetadataUrl() { return window.location.origin + '/auth/saml/metadata'; }, + async copySsoMetadata() { + try { + await navigator.clipboard.writeText(this.ssoMetadataUrl()); + if (typeof toast === 'function') toast('SP metadata URL copiée'); + } catch(e) {} + }, + async loadSsoConfig() { + this.ssoMsg = ''; + try { + var r = await fetch('/api/v2/sso/config', {credentials:'same-origin'}); + if (r.ok) { + var d = await r.json(); + this.ssoSource = d.source || 'db'; + this.ssoProvisioned = d.provisioned_users || 0; + if (d.configured === false) { + this.ssoCfg = Object.assign({}, this.ssoCfg, {id:null, entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, name:''}); + } else { + this.ssoCfg = { + id: d.id || null, + provider_type: d.provider_type || 'saml', + name: d.name || '', + entity_id: d.entity_id || '', + sso_url: d.sso_url || '', + slo_url: d.slo_url || '', + x509_certificate: d.x509_certificate || '', + issuer_url: d.issuer_url || '', + client_id: d.client_id || '', + client_secret: '', + client_secret_set: !!d.client_secret_set, + scope: d.scope || 'openid profile email', + attribute_mapping: d.attribute_mapping || {}, + auto_provision: !!d.auto_provision, + sso_only: !!d.sso_only, + sign_requests: !!d.sign_requests, + default_workspace_id: d.default_workspace_id || '' + }; + this.ssoAttrJson = JSON.stringify(d.attribute_mapping || {}, null, 2); + this.ssoGroups = (d.groups_mapping || []).map(function(g){ return {sso_group: g.sso_group || '', workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id || ''}; }); + } + } + } catch(e) {} + try { + var w = await fetch('/api/v2/sso/workspaces', {credentials:'same-origin'}); + if (w.ok) { + var wd = await w.json(); + this.ssoWorkspaces = wd.workspaces || []; + this.ssoProvisioned = wd.provisioned_users || this.ssoProvisioned; + } + } catch(e) {} + }, + async saveSsoConfig() { + this.ssoMsg = ''; + var amap = {}; + try { amap = JSON.parse(this.ssoAttrJson || '{}'); } + catch(e) { this.ssoMsg = 'Attribute mapping : JSON invalide'; this.ssoOk = false; return; } + if (amap === null || typeof amap !== 'object' || Array.isArray(amap)) { + this.ssoMsg = 'Attribute mapping : doit être un objet JSON'; this.ssoOk = false; return; + } + this.ssoSaving = true; + var payload = Object.assign({}, this.ssoCfg, { + attribute_mapping: amap, + groups_mapping: this.ssoGroups.map(function(g){ return {sso_group: (g.sso_group||'').trim(), workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id ? parseInt(g.workspace_id, 10) : null}; }), + auto_provision: this.ssoCfg.auto_provision ? 1 : 0, + sso_only: this.ssoCfg.sso_only ? 1 : 0, + sign_requests: this.ssoCfg.sign_requests ? 1 : 0, + default_workspace_id: this.ssoCfg.default_workspace_id ? parseInt(this.ssoCfg.default_workspace_id, 10) : null + }); + delete payload.id; + try { + var r = await this.adminFetch('/api/v2/sso/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)}); + var d = null; try { d = await r.json(); } catch(e) {} + if (!r.ok) { this.ssoMsg = (d && (d.detail || d.error)) || ('Erreur ' + r.status); this.ssoOk = false; } + else { this.ssoMsg = 'Configuration enregistrée'; this.ssoOk = true; await this.loadSsoConfig(); } + } catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; } + finally { this.ssoSaving = false; } + }, + async disableSso() { + if (!confirm('Désactiver le SSO ? Les utilisateurs pourront de nouveau se connecter localement.')) return; + this.ssoMsg = ''; + try { + var r = await this.adminFetch('/api/v2/sso/config', {method:'DELETE'}); + if (r.ok) { this.ssoMsg = 'SSO désactivé'; this.ssoOk = true; await this.loadSsoConfig(); } + else { this.ssoMsg = 'Erreur ' + r.status; this.ssoOk = false; } + } catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; } + }, + async syncSsoGroups() { + this.ssoMsg = ''; + try { + var r = await this.adminFetch('/api/v2/sso/sync', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'}); + var d = null; try { d = await r.json(); } catch(e) {} + if (r.ok) { this.ssoMsg = 'Groupes re-synchronisés — ' + (d && d.updated || 0) + '/' + (d && d.users || 0) + ' utilisateur(s)'; this.ssoOk = true; await this.loadSsoConfig(); } + else { this.ssoMsg = (d && d.detail) || ('Erreur ' + r.status); this.ssoOk = false; } + } catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; } + }, + addSsoGroup() { this.ssoGroups.push({sso_group:'', workspace_role:'editor', workspace_id: this.ssoCfg.default_workspace_id || ''}); }, + removeSsoGroup(i) { this.ssoGroups.splice(i, 1); }, // ── v5.2.0 Backups (admin) ── async loadBackups() { try { diff --git a/docs/API_GUIDE_V6.md b/docs/API_GUIDE_V6.md index dba70b9..f51445e 100644 --- a/docs/API_GUIDE_V6.md +++ b/docs/API_GUIDE_V6.md @@ -7,6 +7,10 @@ > marketplace de skills (`/api/v2/skills/*`) dans `app/routers/api_v2_agent.py`, logique > partagée `app/services/skill_gallery.py`, OpenAPI régénéré (**427 chemins**), 15 tests dédiés > (`tests/test_v66_agent_api.py`). Voir §2.4. +> **v6.7.0 (2026-09-24)** — **SSO / SAML + OIDC entreprise** : parcours navigateur +> `/auth/saml/*` + `/auth/oidc/*` (hors scope `/api/v2`), API admin de configuration +> `/api/v2/sso/*` (session + admin + CSRF), logique partagée `app/services/sso_provisioning.py`, +> OpenAPI régénéré, 38 tests dédiés (`tests/test_v67_sso.py`). Voir §2.5. > Les sections ci-dessous décrivent les conventions cibles et restent la référence de conception. > **Dernière mise à jour** : 2026-09-24 > **Portée** : inventaire de l'API existante, conventions cibles, design CRUD par ressource, webhooks, sécurité, checklist d'implémentation. @@ -259,9 +263,41 @@ Mêmes endpoints (session cookie) côté interne : `/api/agent/skills/gallery`, (catalogue `app/services/webhook_outbound.py`, abonnement `agent.*` possible). 4. Chaque mutation écrit `api_audit_log` (`agent.create`, `agent.run`, `skill.import`, …). ---- +### 2.5 SSO / Enterprise auth (`app/routers/sso.py`, v6.7.0) -## 3. Conventions cibles pour l'API v2 +> **Authentification fédérée** : un IdP d'entreprise (SAML 2.0 ou OpenID Connect + PKCE) +> ouvre une session FlowDeck ; les comptes sont créés au premier login et les groupes de +> l'IdP deviennent des rôles workspace. Design : `docs/V6_SSO_SAML_Enterprise_Auth.md`. + +**Parcours navigateur (session cookie, CSRF exclu — POST IdP cross-site)** + +| Méthode | Route | Description | +|---------|-------|-------------| +| GET | `/auth/saml/login` | Redirection SP-initiée vers l'IdP (`RelayState` = next sûr) | +| POST | `/auth/saml/callback` | Assertion Consumer Service — signature/aud/dest/InResponseTo validés | +| GET | `/auth/saml/metadata` | Métadonnées SP XML (EntityID, ACS, SLO, certificat) | +| GET | `/auth/saml/logout` | SLO — relaye `SAMLRequest` à l'IdP puis détruit la session (POST accepté pour l'IdP) | +| GET | `/auth/oidc/login` | Redirection authorize (`state` + `code_verifier` en DB) | +| GET | `/auth/oidc/callback` | Code → token → userinfo ; ID token vérifié (JWKS, aud/iss/nonce/exp) | +| GET/POST | `/auth/oidc/logout` | Déconnexion OIDC (relaye à l'IdP si `end_session_endpoint`) | + +**API admin (session + rôle admin + header `X-CSRF-Token`)** + +| Méthode | Route | Description | +|---------|-------|-------------| +| GET | `/api/v2/sso/providers` | **Public** (page de login) : `{providers[{type,name,icon,login_url}], sso_only, base_url}` | +| GET | `/api/v2/sso/config` | Config publique (`{configured, source: db\|env, client_secret_set, provisioned_users}` — **jamais** le secret) | +| POST/PUT | `/api/v2/sso/config` | Crée/met à jour (`{configured:false}` = actif) ; champ secret vide = conserver | +| DELETE | `/api/v2/sso/config` | Désactive le SSO (les comptes SSO existants restent) | +| GET | `/api/v2/sso/workspaces` | Épingles pour le mapping (workspaces + `provisioned_users`) | +| POST | `/api/v2/sso/sync` | Re-synchronise les groupes de tous les utilisateurs SSO | +| GET | `/api/v2/sso/history` | Journal d'audit des tentatives (`limit` ≤ 200) | + +Règles : secrets chiffrés Fernet (`sso_config`), anti-replay `sso_requests` (TTL 15 min), +historique `sso_login_history` (succès/échecs), mode SSO only (login local refusé sauf admins), +bootstrap possible par variables `SSO_*` du `.env` (la config admin prime). + +--- ### 3.1 Auth & tokens diff --git a/docs/V6_SSO_SAML_Enterprise_Auth.md b/docs/V6_SSO_SAML_Enterprise_Auth.md index 1d910a2..2f697b9 100644 --- a/docs/V6_SSO_SAML_Enterprise_Auth.md +++ b/docs/V6_SSO_SAML_Enterprise_Auth.md @@ -1,6 +1,6 @@ # V6.0.0 — SSO / SAML : Enterprise Authentication -> **Statut** : Conception détaillée — v6.0.0 +> **Statut** : ✅ **Livré en v6.7.0** (2026-09-24) — conception historique ci-dessous > **Date** : 2026-09-15 > **Route** : `feat/v6-sso-saml` → `develop` → `main` > **Dépendances** : v4.0.0 Accounts & Integrations (OAuth2 Gitea/GitHub existant) @@ -181,7 +181,7 @@ SSO_PROVIDER=saml SSO_ENTITY_ID=https://sts.windows.net/{tenant-id}/ SSO_SSO_URL=https://login.microsoftonline.com/{tenant-id}/saml2 SSO_SLO_URL=https://login.microsoftonline.com/{tenant-id}/saml2/logout -SSO_X509_CERT="-----BEGIN CERTIFICATE-----\n..." +SSO_X509_CERTIFICATE="-----BEGIN CERTIFICATE-----\n..." SSO_ATTRIBUTE_MAPPING={"login":"nameid","email":"email","full_name":"name"} SSO_AUTO_PROVISION=true SSO_DEFAULT_WORKSPACE_ID=1 @@ -432,18 +432,20 @@ ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local'; ## 9. Checklist d'implémentation -1. **`auth/providers/saml_provider.py`** — wrapper python3-saml ou pysaml2 -2. **`auth/providers/oidc_provider.py`** — wrapper OIDC (authlib ou httpx) -3. **`auth/routers/sso.py`** — endpoints SSO/OIDC -4. **Migration `sso_config`** + `sso_login_history` + colonne `auth_method` sur `users` -5. **`services/sso_provisioning.py`** — auto-provision + group mapping -6. **Extension `settings.html`** — UI admin SSO -7. **Extension `login.html`** — boutons SSO -8. **SP metadata endpoint** (`/auth/saml/metadata`) -9. **Security** — validation assertions, rate limiting, audit log -10. **Tests** — tous les scénarios SSO -11. **Documentation utilisateur** — `/help` section SSO setup -12. **Dépendance** — `python3-saml` ou `pysaml2`, `authlib` dans requirements.txt +> ✅ **Tout est livré en v6.7.0** (`tests/test_v67_sso.py`, 38 tests) : + +1. ✅ **`auth/providers/saml_provider.py`** — wrapper python3-saml (`app/auth/providers/saml_provider.py`) +2. ✅ **`auth/providers/oidc_provider.py`** — wrapper OIDC authlib (`app/auth/providers/oidc_provider.py`) +3. ✅ **`routers/sso.py`** — endpoints SSO/OIDC (`app/routers/sso.py` + API admin `/api/v2/sso/*`) +4. ✅ **Migration 23 `sso_config`** + `sso_login_history` + `sso_requests` + colonne `auth_method` sur `users` (déjà présente) +5. ✅ **`services/sso_provisioning.py`** — auto-provision + group mapping +6. ✅ **Extension `settings.html`** — UI admin SSO (onglet « SSO / Enterprise ») +7. ✅ **Extension page de login** — boutons SSO (nom dynamique) +8. ✅ **SP metadata endpoint** (`/auth/saml/metadata`) +9. ✅ **Security** — validation assertions (sign/aud/dest/InResponseTo), anti-replay `sso_requests`, rate limit login, historique +10. ✅ **Tests** — `tests/test_v67_sso.py` : 38 scénarios (flots SAML/OIDC, négatifs, groupes, sso_only, SLO) +11. ✅ **Documentation utilisateur** — `/help` section « SSO (Enterprise) » +12. ✅ **Dépendance** — `python3-saml==1.16.0`, `authlib==1.8.0`, `cryptography>=42.0` dans requirements.txt --- diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 06654b5..4463a3c 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "6.6.0" + "version": "6.7.0" }, "paths": { "/auth/register": { @@ -161,7 +161,7 @@ "auth" ], "summary": "Logout", - "description": "Clear session and redirect to login page.", + "description": "Clear session and redirect to login page.\n\nSAML sessions additionally hand over to the IdP's Single Logout when one\nis configured (the actual cookie clearing happens on the SLO route).", "operationId": "logout_auth_logout_get", "responses": { "200": { @@ -195,6 +195,508 @@ } } }, + "/auth/saml/login": { + "get": { + "tags": [ + "sso" + ], + "summary": "Saml Login", + "description": "SP-initiated SSO: issue an AuthnRequest and redirect to the IdP.", + "operationId": "saml_login_auth_saml_login_get", + "parameters": [ + { + "name": "next", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "/workspaces", + "title": "Next" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/auth/saml/callback": { + "post": { + "tags": [ + "sso" + ], + "summary": "Saml Callback", + "description": "Assertion Consumer Service — validate the SAMLResponse and open a session.", + "operationId": "saml_callback_auth_saml_callback_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/auth/saml/metadata": { + "get": { + "tags": [ + "sso" + ], + "summary": "Saml Metadata", + "description": "SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…).", + "operationId": "saml_metadata_auth_saml_metadata_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/auth/saml/logout": { + "get": { + "tags": [ + "sso" + ], + "summary": "Saml Logout", + "description": "SP-initiated Single Logout (GET) — hands the browser to the IdP.", + "operationId": "saml_logout_auth_saml_logout_get", + "parameters": [ + { + "name": "next", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "/auth/login?provider=local", + "title": "Next" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "sso" + ], + "summary": "Saml Logout Post", + "description": "IdP-initiated Single Logout (POST with SAMLRequest/SAMLResponse).", + "operationId": "saml_logout_post_auth_saml_logout_post", + "parameters": [ + { + "name": "next", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "/auth/login?provider=local", + "title": "Next" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/auth/oidc/login": { + "get": { + "tags": [ + "sso" + ], + "summary": "Oidc Login", + "description": "Redirect to the OIDC provider (authorization code + PKCE).", + "operationId": "oidc_login_auth_oidc_login_get", + "parameters": [ + { + "name": "next", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "/workspaces", + "title": "Next" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/auth/oidc/callback": { + "get": { + "tags": [ + "sso" + ], + "summary": "Oidc Callback", + "description": "OIDC callback (GET, authorization code in the query string).", + "operationId": "oidc_callback_auth_oidc_callback_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "sso" + ], + "summary": "Oidc Callback Post", + "description": "OIDC callback (POST, form_post response mode).", + "operationId": "oidc_callback_post_auth_oidc_callback_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/auth/oidc/logout": { + "get": { + "tags": [ + "sso" + ], + "summary": "Oidc Logout", + "description": "OIDC logout (GET) — local session first, then the IdP end-session URL.", + "operationId": "oidc_logout_auth_oidc_logout_get", + "parameters": [ + { + "name": "next", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "/auth/login?provider=local", + "title": "Next" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "sso" + ], + "summary": "Oidc Logout Post", + "description": "OIDC logout (POST).", + "operationId": "oidc_logout_post_auth_oidc_logout_post", + "parameters": [ + { + "name": "next", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "/auth/login?provider=local", + "title": "Next" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/sso/providers": { + "get": { + "tags": [ + "sso" + ], + "summary": "Sso Providers", + "description": "Public: what the login page should show (button list + sso_only flag).", + "operationId": "sso_providers_api_v2_sso_providers_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v2/sso/config": { + "get": { + "tags": [ + "sso" + ], + "summary": "Get Sso Config Api", + "description": "Read the current SSO configuration (secrets never returned).", + "operationId": "get_sso_config_api_api_v2_sso_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "put": { + "tags": [ + "sso" + ], + "summary": "Save Sso Config Api", + "description": "Create/replace the SSO configuration (admin, scope write).", + "operationId": "save_sso_config_api_api_v2_sso_config_put", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "sso" + ], + "summary": "Save Sso Config Api", + "description": "Create/replace the SSO configuration (admin, scope write).", + "operationId": "save_sso_config_api_api_v2_sso_config_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "delete": { + "tags": [ + "sso" + ], + "summary": "Delete Sso Config Api", + "description": "Disable SSO — local logins keep working (design §8 « SSO disable »).", + "operationId": "delete_sso_config_api_api_v2_sso_config_delete", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v2/sso/workspaces": { + "get": { + "tags": [ + "sso" + ], + "summary": "Sso Workspaces", + "description": "Workspaces available for default assignment / group mapping.", + "operationId": "sso_workspaces_api_v2_sso_workspaces_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v2/sso/sync": { + "post": { + "tags": [ + "sso" + ], + "summary": "Sso Sync", + "description": "Re-apply group → workspace role mapping for every SSO user.", + "operationId": "sso_sync_api_v2_sso_sync_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v2/sso/history": { + "get": { + "tags": [ + "sso" + ], + "summary": "Sso History", + "description": "Audit trail of SSO login attempts (successes and rejections).", + "operationId": "sso_history_api_v2_sso_history_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/trash": { "get": { "tags": [ diff --git a/requirements.txt b/requirements.txt index e5d2a17..a983c64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,6 @@ beautifulsoup4==4.* PyYAML==6.* python-docx>=1.1 pypdf==6.* +python3-saml==1.16.0 +authlib==1.8.0 +cryptography>=42.0 diff --git a/tests/test_v67_sso.py b/tests/test_v67_sso.py new file mode 100644 index 0000000..4c00ac5 --- /dev/null +++ b/tests/test_v67_sso.py @@ -0,0 +1,1353 @@ +"""FlowDeck — v6.7.0 : SSO/SAML 2.0 + OIDC (enterprise auth). + +Covers the admin config API (validation, secrets, disable), the SP metadata +endpoint, a full SP-initiated SAML login against a mock IdP (real signatures +produced by python3-saml's own ``add_sign``), every rejection path of the +security table of the design doc (signature, expiry, audience, destination, +issuer, InResponseTo, replay, CSRF relay), auto-provisioning + attribute +merge + group → workspace role mapping, the SSO-only restriction on local +auth, the audit trail, the rate limiter, and the OIDC flow with a mocked +provider (discovery / token / JWKS + signed ID token). +""" +from __future__ import annotations + +import base64 +import datetime as dt +import secrets as pysecrets +from urllib.parse import parse_qs, urlparse + +import pytest + +# ── Mock IdP constants ───────────────────────────────────────────────────── +IDP_ENTITY = "https://idp.corp.test/saml/metadata" +IDP_SSO = "https://idp.corp.test/saml/sso" +IDP_SLO = "https://idp.corp.test/saml/slo" + +SAML_NS_ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion" +SAML_NS_PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol" + + +# ── Fixtures / helpers ───────────────────────────────────────────────────── + +@pytest.fixture(scope="session") +def idp_keypair() -> tuple[str, str]: + """RSA key + self-signed certificate used by the mock IdP (once per run).""" + import datetime + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "idp.corp.test")]) + now = datetime.datetime.now(datetime.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .sign(key, hashes.SHA256()) + ) + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode() + cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode() + return key_pem, cert_pem + + +_ADMIN_TOKENS: dict[int, str] = {} + + +def _clear_session_cookie(client) -> None: + """Drop EVERY flowdeck_session cookie in the jar (client- and server-set). + + httpx keys cookies by (name, domain, path): a cookie we set by hand has + domain='' while a server Set-Cookie lands with domain='testserver', so + both coexist and ``jar.get(name)`` then raises CookieConflict. Clear + before a request that will set a fresh session. + """ + client.cookies.delete("flowdeck_session") + + +def _be_admin(client) -> None: + """Make sure the ONLY session cookie in the jar is the admin one.""" + _clear_session_cookie(client) + client.cookies.set("flowdeck_session", _ADMIN_TOKENS[id(client)]) + + +def _admin_session(client) -> tuple[int, str]: + """Create an admin user and put its session + CSRF cookies on the client.""" + from app.auth.session import SessionManager + from app.db import get_conn + + login = f"ssoadmin_{pysecrets.token_hex(3)}" + with get_conn() as conn: + conn.execute( + "INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'SSO Admin', ?, 1)", + (login, f"{login}@test.dev"), + ) + row = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone() + conn.commit() + user = dict(row) + token = SessionManager.create_session(user) + _ADMIN_TOKENS[id(client)] = token + _clear_session_cookie(client) + client.cookies.set("flowdeck_session", token) + client.cookies.set("csrf_token", "csrf-sso-test-token") + return user["id"], login + + +def _save_config(client, payload: dict) -> dict: + r = client.post( + "/api/v2/sso/config", json=payload, headers={"X-CSRF-Token": "csrf-sso-test-token"} + ) + assert r.status_code == 200, r.text + return r.json() + + +def _saml_payload(cert_pem: str, **overrides) -> dict: + payload = { + "provider_type": "saml", + "name": "Corp SSO", + "entity_id": IDP_ENTITY, + "sso_url": IDP_SSO, + "slo_url": IDP_SLO, + "x509_certificate": cert_pem, + "attribute_mapping": {}, + "groups_mapping": [], + "auto_provision": 1, + "sso_only": 0, + "sign_requests": 0, + "default_workspace_id": None, + } + payload.update(overrides) + return payload + + +def _configure_saml(client, cert_pem: str, **overrides) -> dict: + _admin_session(client) + return _save_config(client, _saml_payload(cert_pem, **overrides)) + + +def _start_saml(client, next_path: str = "/workspaces") -> tuple[str, str, str]: + """Kick the SP-initiated flow → (authn_request_id, csrf relay, location).""" + r = client.get(f"/auth/saml/login?next={next_path}", follow_redirects=False) + assert r.status_code == 302, r.text + location = r.headers["location"] + assert location.startswith(IDP_SSO), location + query = parse_qs(urlparse(location).query) + assert query.get("SAMLRequest"), "AuthnRequest missing from the redirect" + relay = query["RelayState"][0] + rid, _, csrf = relay.partition(".") + assert rid and csrf + return rid, csrf, location + + +def _sp_endpoints(client) -> tuple[str, str]: + """(sp_entity_id, acs_url) as advertised by our own metadata. + + Careful: in SP metadata the SingleLogoutService comes BEFORE the + AssertionConsumerService, so grabbing the first ``Location=`` would + return the SLO URL — read the ACS element explicitly. + """ + r = client.get("/auth/saml/metadata") + assert r.status_code == 200, r.text + xml = r.text + entity = xml.split('entityID="', 1)[1].split('"', 1)[0] + acs_block = xml.split("AssertionConsumerService", 1)[1] + acs = acs_block.split('Location="', 1)[1].split('"', 1)[0] + return entity, acs + + +def _iso(moment: dt.datetime) -> str: + return moment.strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +def _attributes_xml(attributes: dict[str, list[str] | str]) -> str: + if not attributes: + return "" + rows = [] + for name, values in attributes.items(): + if isinstance(values, str): + values = [values] + vals = "".join(f"{v}" for v in values) + rows.append( + f'{vals}' + ) + return "" + "".join(rows) + "" + + +def build_saml_response( + key_pem: str, + cert_pem: str, + *, + sp_entity: str, + acs_url: str, + in_response_to: str, + name_id: str = "alice@corp.test", + attributes: dict | None = None, + audience: str | None = None, + destination: str | None = None, + issuer: str | None = None, + not_on_or_after: dt.datetime | None = None, + status_ok: bool = True, +) -> str: + """Build + sign (assertion) a SAMLResponse like a real IdP would, base64.""" + from onelogin.saml2.utils import OneLogin_Saml2_Utils + + issuer = issuer or IDP_ENTITY + audience = audience or sp_entity + destination = destination or acs_url + now = dt.datetime.now(dt.UTC) + not_on_or_after = not_on_or_after or (now + dt.timedelta(minutes=5)) + status = ( + "" + if status_ok + else "" + ) + + assertion = f""" + {issuer} + + {name_id} + + + + + + {audience} + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + {_attributes_xml(attributes if attributes else {"email": [name_id]})} +""" + signed = OneLogin_Saml2_Utils.add_sign(assertion, key_pem, cert_pem) + if isinstance(signed, bytes): + signed = signed.decode() + signed_assertion = signed + if signed_assertion.startswith("", 1)[1].strip() + + response = f""" + {issuer} + {status} + {signed_assertion} +""" + return base64.b64encode(response.encode()).decode() + + +def _post_response(client, b64_response: str, relay: str): + return client.post( + "/auth/saml/callback", + data={"SAMLResponse": b64_response, "RelayState": relay}, + follow_redirects=False, + ) + + +def _history(client, limit=50): + r = client.get(f"/api/v2/sso/history?limit={limit}") + assert r.status_code == 200, r.text + return r.json()["history"] + + +# ── Migration ────────────────────────────────────────────────────────────── + +def test_sso_tables_exist(client): + """Migration 23 creates sso_config / sso_login_history / sso_requests.""" + from app.db import get_conn + + with get_conn() as conn: + tables = { + r["name"] + for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + assert {"sso_config", "sso_login_history", "sso_requests"} <= tables + from app.migrations import current_version + + assert current_version(conn) >= 23 + + +# ── Admin config API ─────────────────────────────────────────────────────── + +def test_providers_endpoint_empty_without_config(client): + r = client.get("/api/v2/sso/providers") + assert r.status_code == 200 + assert r.json() == {"providers": [], "sso_only": False} + + +def test_config_requires_admin(client): + assert client.get("/api/v2/sso/config").status_code == 401 + _admin_session(client) + # demote to plain user → 403 + from app.db import get_conn + + with get_conn() as conn: + conn.execute("UPDATE users SET is_admin=0 WHERE login LIKE 'ssoadmin_%'") + conn.commit() + assert client.get("/api/v2/sso/config").status_code == 403 + + +def test_config_rejects_invalid_payloads(client, idp_keypair): + _, cert_pem = idp_keypair + _admin_session(client) + headers = {"X-CSRF-Token": "csrf-sso-test-token"} + + missing_cert = _saml_payload(cert_pem) + missing_cert["x509_certificate"] = "" + assert client.post("/api/v2/sso/config", json=missing_cert, headers=headers).status_code == 400 + + not_pem = _saml_payload(cert_pem) + not_pem["x509_certificate"] = "not-a-certificate" + assert client.post("/api/v2/sso/config", json=not_pem, headers=headers).status_code == 400 + + no_url = _saml_payload(cert_pem) + no_url["sso_url"] = "" + assert client.post("/api/v2/sso/config", json=no_url, headers=headers).status_code == 400 + + bad_oidc = {"provider_type": "oidc", "issuer_url": "https://issuer.test"} + assert client.post("/api/v2/sso/config", json=bad_oidc, headers=headers).status_code == 400 + + bad_mapping = _saml_payload(cert_pem, attribute_mapping="[]") + assert client.post("/api/v2/sso/config", json=bad_mapping, headers=headers).status_code == 400 + + +def test_config_roundtrip_and_secrets_never_leak(client, idp_keypair): + key_pem, cert_pem = idp_keypair + saved = _configure_saml(client, cert_pem) + assert saved["configured"] is True + assert saved["provider_type"] == "saml" + assert saved["provisioned_users"] == 0 + # The PEM IdP certificate is public data, the SP private key is not. + assert saved["x509_certificate"].strip().startswith("-----BEGIN CERTIFICATE-----") + assert "private" not in " ".join(saved.keys()) + assert "sp_private_key" not in saved + + r = client.get("/api/v2/sso/config") + body = r.text + assert "BEGIN PRIVATE KEY" not in body + assert key_pem.splitlines()[1] not in body + + # Public providers endpoint advertises the login button. + providers = client.get("/api/v2/sso/providers").json() + assert providers["providers"][0]["type"] == "saml" + assert providers["providers"][0]["login_url"].startswith("/auth/saml/login") + assert providers["sso_only"] is False + + +def test_config_secret_roundtrip_keeps_existing_value(client, idp_keypair): + """A blank client_secret on save must not wipe the stored one.""" + _, cert_pem = idp_keypair + _admin_session(client) + headers = {"X-CSRF-Token": "csrf-sso-test-token"} + + oidc = { + "provider_type": "oidc", + "issuer_url": "https://issuer.test", + "client_id": "flowdeck-client", + "client_secret": "super-secret", + } + assert client.post("/api/v2/sso/config", json=oidc, headers=headers).status_code == 200 + + from app.services import sso_provisioning as sso + + cfg = sso.get_sso_config() + assert sso.client_secret_value(cfg) == "super-secret" + assert cfg["client_secret"] != "super-secret" # encrypted at rest + + # Save again with a blank secret → value preserved. + oidc_blank = dict(oidc, client_secret="") + assert client.post("/api/v2/sso/config", json=oidc_blank, headers=headers).status_code == 200 + cfg = sso.get_sso_config() + assert sso.client_secret_value(cfg) == "super-secret" + + +def test_config_disable_turns_sso_off(client, idp_keypair): + _, cert_pem = idp_keypair + _configure_saml(client, cert_pem) + assert client.get("/api/v2/sso/providers").json()["providers"] + + r = client.delete( + "/api/v2/sso/config", headers={"X-CSRF-Token": "csrf-sso-test-token"} + ) + assert r.status_code == 200, r.text + + # SSO off → no button, local login still available. + assert client.get("/api/v2/sso/providers").json() == { + "providers": [], + "sso_only": False, + } + assert client.get("/auth/saml/login", follow_redirects=False).status_code == 404 + + +def test_workspaces_and_sync_endpoints(client, idp_keypair): + _, cert_pem = idp_keypair + uid, _ = _admin_session(client) + _save_config(client, _saml_payload(cert_pem)) + + from app.db import get_conn + + with get_conn() as conn: + ws = conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES ('Corp', ?)", (uid,) + ).lastrowid + conn.execute( + "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'editor')", + (ws, uid), + ) + conn.execute( + "INSERT INTO users (login, full_name, email, auth_method) VALUES " + "('sso-user', 'SSO User', 'sso-user@corp.test', 'saml')" + ) + conn.commit() + + data = client.get("/api/v2/sso/workspaces").json() + assert any(w["id"] == ws for w in data["workspaces"]) + assert data["provisioned_users"] == 1 + + r = client.post( + "/api/v2/sso/sync", headers={"X-CSRF-Token": "csrf-sso-test-token"} + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "ok" + assert r.json()["users"] == 1 + + +# ── SP metadata ──────────────────────────────────────────────────────────── + +def test_metadata_is_valid_sp_descriptor(client, idp_keypair): + _, cert_pem = idp_keypair + _configure_saml(client, cert_pem) + + r = client.get("/auth/saml/metadata") + assert r.status_code == 200 + assert "application/samlmetadata+xml" in r.headers["content-type"] + xml = r.text + assert "").decode(), "RelayState": "x.y"}, + follow_redirects=False, + ) + assert r.status_code == 403 + + +# ── Group mapping ────────────────────────────────────────────────────────── + +def test_group_mapping_sets_workspace_role(client, idp_keypair): + key_pem, cert_pem = idp_keypair + uid, _ = _admin_session(client) + _save_config( + client, + _saml_payload( + cert_pem, + groups_mapping=[ + {"sso_group": "FlowDeck Admins", "workspace_role": "admin"}, + {"sso_group": "FlowDeck Members", "workspace_role": "viewer"}, + ], + ), + ) + from app.db import get_conn + + with get_conn() as conn: + default_ws = conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES ('Default WS', ?)", (uid,) + ).lastrowid + mapped_ws = conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES ('Mapped WS', ?)", (uid,) + ).lastrowid + conn.commit() + # Re-save so default_workspace_id / mapping point at real workspaces. + _save_config( + client, + _saml_payload( + cert_pem, + default_workspace_id=default_ws, + groups_mapping=[ + {"sso_group": "FlowDeck Admins", "workspace_role": "admin", + "workspace_id": mapped_ws}, + ], + ), + ) + + sp_entity, acs = _sp_endpoints(client) + rid, csrf, _ = _start_saml(client) + signed = build_saml_response( + key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid, + name_id="bob@corp.test", + attributes={"email": "bob@corp.test", "groups": ["FlowDeck Admins", "Other Group"]}, + ) + r = _post_response(client, signed, f"{rid}.{csrf}") + assert r.status_code == 302, r.text + + with get_conn() as conn: + user = conn.execute( + "SELECT id FROM users WHERE email='bob@corp.test'" + ).fetchone() + mapped = conn.execute( + "SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", + (mapped_ws, user["id"]), + ).fetchone() + default_member = conn.execute( + "SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", + (default_ws, user["id"]), + ).fetchone() + assert mapped is not None and mapped["role"] == "admin" + assert default_member is not None # default workspace membership + + +# ── SSO-only restriction (design §7.1) ───────────────────────────────────── + +def test_sso_only_blocks_local_login_and_register(client, idp_keypair): + _, cert_pem = idp_keypair + _configure_saml(client, cert_pem, sso_only=1) + + from app.db import get_conn + from app.password_utils import hash_password + + with get_conn() as conn: + conn.execute( + "INSERT INTO users (login, full_name, email, password_hash) VALUES " + "('localguy', 'Local Guy', 'localguy@t.dev', ?)", + (hash_password("secret123"),), + ) + # An admin with a local password — the allowed back door (§7.1). + conn.execute( + "INSERT INTO users (login, full_name, email, is_admin, password_hash) VALUES " + "('keepadmin', 'Keep Admin', 'keepadmin@t.dev', 1, ?)", + (hash_password("adminpass1"),), + ) + conn.commit() + + r = client.post( + "/auth/local-login", json={"email": "localguy", "password": "secret123"} + ) + assert r.status_code == 403 + assert "SSO" in r.json()["error"] + + r2 = client.post( + "/auth/register", json={"email": "new@t.dev", "password": "secret123"} + ) + assert r2.status_code == 403 + + # Admins keep their local door open (design: « l'admin garde le sien »). + _clear_session_cookie(client) + admin_login = client.post( + "/auth/local-login", json={"email": "keepadmin", "password": "adminpass1"} + ) + assert admin_login.status_code == 200, admin_login.text + + # The login page tells the UI to hide the local form. + page = client.get("/auth/login?provider=local") + assert page.status_code == 200 + assert 'id="sso-section"' in page.text + assert "loadSsoProviders" in page.text + + +def test_local_login_still_works_when_sso_is_optional(client, idp_keypair): + _, cert_pem = idp_keypair + _configure_saml(client, cert_pem, sso_only=0) + + from app.db import get_conn + from app.password_utils import hash_password + + with get_conn() as conn: + conn.execute( + "INSERT INTO users (login, full_name, email, password_hash) VALUES " + "('mixeduser', 'Mixed', 'mixed@t.dev', ?)", + (hash_password("secret123"),), + ) + conn.commit() + + _clear_session_cookie(client) # local login will set its own session + r = client.post("/auth/local-login", json={"email": "mixeduser", "password": "secret123"}) + assert r.status_code == 200, r.text + assert client.cookies.get("flowdeck_session") + + +# ── Rate limiting (design §5.2) ──────────────────────────────────────────── + +def test_saml_login_rate_limited(client, idp_keypair): + _, cert_pem = idp_keypair + _configure_saml(client, cert_pem) + + from app.config import settings + from app.routers import sso as sso_router + + previous = settings.rate_limit_enabled + settings.rate_limit_enabled = True + sso_router._rate_store.clear() + try: + statuses = [ + client.get("/auth/saml/login", follow_redirects=False).status_code + for _ in range(7) + ] + finally: + settings.rate_limit_enabled = previous + sso_router._rate_store.clear() + + assert statuses[:5] == [302] * 5 + assert statuses[5:] == [429, 429] + + +# ── SLO ──────────────────────────────────────────────────────────────────── + +def test_saml_logout_sp_initiated_builds_logout_request(client, idp_keypair): + key_pem, cert_pem = idp_keypair + _configure_saml(client, cert_pem) + sp_entity, acs = _sp_endpoints(client) + rid, csrf, _ = _start_saml(client) + + signed = build_saml_response( + key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid, + name_id="slo@corp.test", attributes={"email": "slo@corp.test"}, + ) + _clear_session_cookie(client) # only the SSO session must be in the jar + assert _post_response(client, signed, f"{rid}.{csrf}").status_code == 302 + + from app.auth.session import SessionManager + + sso_sid = SessionManager.session_id(client.cookies.get("flowdeck_session") or "") + assert sso_sid # the SSO session we are about to kill + + # /auth/logout hands SSO sessions over to the SLO route… + r = client.get("/auth/logout", follow_redirects=False) + assert r.status_code == 302 + assert r.headers["location"].startswith("/auth/saml/logout") + + # …which revokes the session locally and redirects to the IdP SLO URL. + r2 = client.get("/auth/saml/logout?next=/auth/login?provider=local", + follow_redirects=False) + assert r2.status_code == 302 + assert r2.headers["location"].startswith(IDP_SLO) + assert "SAMLRequest" in r2.headers["location"] + assert r2.headers.get("set-cookie", "").find("flowdeck_session=;") >= 0 or \ + "flowdeck_session=\"\"" in r2.headers.get("set-cookie", "") + + # The SSO session is revoked server-side (the admin's stays alive). + from app.db import get_conn + + with get_conn() as conn: + row = conn.execute( + "SELECT revoked FROM user_sessions WHERE id=?", (sso_sid,) + ).fetchone() + assert row and row["revoked"] == 1 + cookie = client.cookies.get("flowdeck_session") + assert not cookie or SessionManager.decode_session(cookie) is None + + +# ── OIDC ─────────────────────────────────────────────────────────────────── + +def _oidc_payload(**overrides) -> dict: + payload = { + "provider_type": "oidc", + "name": "Corp OIDC", + "issuer_url": "https://issuer.corp.test", + "client_id": "flowdeck-client", + "client_secret": "s3cr3t", + "scope": "openid profile email groups", + "attribute_mapping": {}, + "groups_mapping": [], + "auto_provision": 1, + "sso_only": 0, + } + payload.update(overrides) + return payload + + +def _sign_id_token(key_pem: str, kid: str, payload: dict) -> str: + import warnings + + from authlib.jose import jwt as jose_jwt + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + token = jose_jwt.encode({"alg": "RS256", "kid": kid}, payload, key_pem) + # IdP token endpoints return str JSON; authlib encodes to bytes. + return token.decode() if isinstance(token, bytes) else token + + +@pytest.fixture +def oidc_provider_env(monkeypatch, idp_keypair, client): + """Save an OIDC config + mock discovery / token / userinfo / JWKS with a + signed ID token (no real network call can ever happen).""" + _admin_session(client) + _save_config(client, _oidc_payload()) + key_pem, cert_pem = idp_keypair + import base64 as b64 + import time as t + + from cryptography.hazmat.primitives import serialization + + from app.auth.providers import oidc_provider as op + + priv = serialization.load_pem_private_key(key_pem.encode(), password=None) + pub = priv.public_key().public_numbers() + + def b64u(i: int) -> str: + raw = i.to_bytes((i.bit_length() + 7) // 8, "big") + return b64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + jwk = { + "kty": "RSA", "kid": "oidc-test-key", "use": "sig", "alg": "RS256", + "n": b64u(pub.n), "e": b64u(pub.e), + } + doc = { + "issuer": "https://issuer.corp.test", + "authorization_endpoint": "https://issuer.corp.test/authorize", + "token_endpoint": "https://issuer.corp.test/token", + "userinfo_endpoint": "https://issuer.corp.test/userinfo", + "jwks_uri": "https://issuer.corp.test/jwks", + "end_session_endpoint": "https://issuer.corp.test/logout", + } + + async def fake_discover(issuer_url: str) -> dict: + return doc + + async def fake_exchange(doc_arg, **kwargs): + now = t.time() + id_token = _sign_id_token(key_pem, "oidc-test-key", { + "iss": "https://issuer.corp.test", + "aud": "flowdeck-client", + "sub": "oidc-sub-1", + "exp": int(now) + 300, + "iat": int(now), + "nonce": kwargs.get("_nonce") or _EXPECTED_NONCE["value"], + "email": "carol@corp.test", + "name": "Carol OIDC", + "groups": ["FlowDeck Admins"], + }) + return {"id_token": id_token, "access_token": "at-123", "token_type": "Bearer"} + + async def fake_userinfo(doc_arg, access_token: str) -> dict: + return {"preferred_username": "carol-oidc"} + + async def fake_jwks(doc_arg) -> dict: + return {"keys": [jwk]} + + monkeypatch.setattr(op, "discover", fake_discover) + monkeypatch.setattr(op, "exchange_code", fake_exchange) + monkeypatch.setattr(op, "fetch_userinfo", fake_userinfo) + import app.routers.sso as sso_router + + monkeypatch.setattr(sso_router, "_fetch_jwks", fake_jwks) + return {"nonce": _EXPECTED_NONCE} + + +# The nonce the mocked token must carry is written here by the login step. +_EXPECTED_NONCE = {"value": ""} + + +def test_oidc_login_redirects_with_pkce(client, oidc_provider_env): + r = client.get("/auth/oidc/login?next=/workspaces", follow_redirects=False) + assert r.status_code == 302, r.text + location = r.headers["location"] + query = parse_qs(urlparse(location).query) + assert location.startswith("https://issuer.corp.test/authorize") + assert query["response_type"] == ["code"] + assert query["client_id"] == ["flowdeck-client"] + assert query["code_challenge_method"] == ["S256"] + assert query["scope"] == ["openid profile email groups"] + state = query["state"][0] + _EXPECTED_NONCE["value"] = query["nonce"][0] + + from app.db import get_conn + + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM sso_requests WHERE id=? AND kind='oidc'", (state,) + ).fetchone() + assert row is not None + assert row["relay_state"] == _EXPECTED_NONCE["value"] + assert row["code_verifier"] + + +def test_oidc_login_404_without_config(client): + assert client.get("/auth/oidc/login", follow_redirects=False).status_code == 404 + + +def test_oidc_full_login_flow(client, oidc_provider_env): + r = client.get("/auth/oidc/login?next=/library", follow_redirects=False) + assert r.status_code == 302 + query = parse_qs(urlparse(r.headers["location"]).query) + _EXPECTED_NONCE["value"] = query["nonce"][0] + state = query["state"][0] + _clear_session_cookie(client) # callback will set the SSO session + + cb = client.get( + f"/auth/oidc/callback?code=abc123&state={state}", follow_redirects=False + ) + assert cb.status_code == 302, cb.text + assert cb.headers["location"] == "/library" + assert client.cookies.get("flowdeck_session") + + from app.db import get_conn + + with get_conn() as conn: + user = conn.execute( + "SELECT * FROM users WHERE email='carol@corp.test'" + ).fetchone() + hist = conn.execute( + "SELECT * FROM sso_login_history WHERE provider_type='oidc' AND success=1" + ).fetchall() + assert user is not None + assert user["auth_method"] == "oidc" + assert user["full_name"] == "Carol OIDC" + assert len(hist) == 1 + # Groups came through the attribute mapping. + assert "FlowDeck Admins" in hist[0]["sso_identifier"] + + +def test_oidc_rejects_unknown_state(client, oidc_provider_env): + admin_token = client.cookies.get("flowdeck_session") + cb = client.get( + "/auth/oidc/callback?code=abc&state=forged-state", follow_redirects=False + ) + assert cb.status_code == 403 + # Session untouched — still exactly the admin cookie we started with. + assert client.cookies.get("flowdeck_session") == admin_token + + +def test_oidc_rejects_replayed_state(client, oidc_provider_env): + r = client.get("/auth/oidc/login", follow_redirects=False) + query = parse_qs(urlparse(r.headers["location"]).query) + _EXPECTED_NONCE["value"] = query["nonce"][0] + state = query["state"][0] + + first = client.get( + f"/auth/oidc/callback?code=abc&state={state}", follow_redirects=False + ) + assert first.status_code == 302, first.text + client.cookies.delete("flowdeck_session") + + second = client.get( + f"/auth/oidc/callback?code=abc&state={state}", follow_redirects=False + ) + assert second.status_code == 403 + assert client.cookies.get("flowdeck_session") is None + + +def test_oidc_rejects_wrong_nonce(client, monkeypatch, idp_keypair): + """ID token nonce must match the one of the issued request (token swap).""" + import time as t + + import app.routers.sso as sso_router + from app.auth.providers import oidc_provider as op + + async def fake_discover(issuer_url): + return { + "issuer": "https://issuer.corp.test", + "authorization_endpoint": "https://issuer.corp.test/authorize", + "token_endpoint": "https://issuer.corp.test/token", + "jwks_uri": "https://issuer.corp.test/jwks", + } + + async def fake_exchange(doc, **kwargs): + key_pem, _ = idp_keypair + id_token = _sign_id_token(key_pem, "oidc-test-key", { + "iss": "https://issuer.corp.test", + "aud": "flowdeck-client", + "sub": "attacker", + "exp": int(t.time()) + 300, + "iat": int(t.time()), + "nonce": "not-the-right-nonce", + "email": "attacker@corp.test", + }) + return {"id_token": id_token, "access_token": "at"} + + async def fake_jwks(doc): + import base64 as b64 + + from cryptography.hazmat.primitives import serialization + + key_pem, _ = idp_keypair + priv = serialization.load_pem_private_key(key_pem.encode(), password=None) + pub = priv.public_key().public_numbers() + + def b64u(i): + raw = i.to_bytes((i.bit_length() + 7) // 8, "big") + return b64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + return {"keys": [{"kty": "RSA", "kid": "oidc-test-key", "use": "sig", + "alg": "RS256", "n": b64u(pub.n), "e": b64u(pub.e)}]} + + monkeypatch.setattr(op, "discover", fake_discover) + monkeypatch.setattr(op, "exchange_code", fake_exchange) + monkeypatch.setattr(op, "fetch_userinfo", lambda *a, **k: _empty_userinfo()) + monkeypatch.setattr(sso_router, "_fetch_jwks", fake_jwks) + + _admin_session(client) + _save_config(client, _oidc_payload()) + r = client.get("/auth/oidc/login", follow_redirects=False) + assert r.status_code == 302, r.text + query = parse_qs(urlparse(r.headers["location"]).query) + state = query["state"][0] + + cb = client.get( + f"/auth/oidc/callback?code=abc&state={state}", follow_redirects=False + ) + assert cb.status_code == 403 + assert "nonce" in cb.text.lower() + # Rejected token → no session issued, the admin cookie is untouched. + assert client.cookies.get("flowdeck_session") == _ADMIN_TOKENS[id(client)] + + from app.db import get_conn + + with get_conn() as conn: + user = conn.execute( + "SELECT 1 FROM users WHERE email='attacker@corp.test'" + ).fetchone() + assert user is None + + +async def _empty_userinfo() -> dict: + return {} + + +# ── Env bootstrap fallback (design §3.3) ─────────────────────────────────── + +def test_env_config_fallback_when_table_empty(client, monkeypatch): + """SSO_* env vars bootstrap a config when no admin ever saved one.""" + from app.config import settings + + monkeypatch.setattr(settings, "sso_provider", "saml") + monkeypatch.setattr(settings, "sso_entity_id", IDP_ENTITY) + monkeypatch.setattr(settings, "sso_sso_url", IDP_SSO) + monkeypatch.setattr(settings, "sso_x509_certificate", "-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----") + + from app.services import sso_provisioning as sso + + cfg = sso.get_sso_config() + assert cfg is not None + assert cfg["_source"] == "env" + assert cfg["provider_type"] == "saml" + + r = client.get("/api/v2/sso/providers") + assert r.json()["providers"][0]["type"] == "saml" + # The env config is visible but not admin-editable in the UI. + _admin_session(client) + assert client.get("/api/v2/sso/config").json()["source"] == "env" + + +# ── Audit trail ──────────────────────────────────────────────────────────── + +def test_history_endpoint_requires_admin_and_lists_attempts(client, idp_keypair): + key_pem, cert_pem = idp_keypair + _configure_saml(client, cert_pem) + sp_entity, acs = _sp_endpoints(client) + rid, csrf, _ = _start_saml(client) + + # one failure (bad signature) + one success + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + rogue = rsa.generate_private_key(public_exponent=65537, key_size=2048) + rogue_pem = rogue.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode() + bad = build_saml_response( + rogue_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid, + ) + assert _post_response(client, bad, f"{rid}.{csrf}").status_code == 403 + + rid2, csrf2, _ = _start_saml(client) + _clear_session_cookie(client) # success → server sets the SSO session + good = build_saml_response( + key_pem, cert_pem, sp_entity=sp_entity, acs_url=acs, in_response_to=rid2, + name_id="dave@corp.test", attributes={"email": "dave@corp.test"}, + ) + assert _post_response(client, good, f"{rid2}.{csrf2}").status_code == 302 + _be_admin(client) # history endpoint needs the admin session back + + history = _history(client) + assert len(history) >= 2 + successes = [h for h in history if h["success"]] + failures = [h for h in history if not h["success"]] + assert len(successes) == 1 + assert len(failures) == 1 + assert failures[0]["error_message"] + + # anonymous → 401 + client.cookies.delete("flowdeck_session") + assert client.get("/api/v2/sso/history").status_code == 401 + + +# ── PermissionManager extension (design §7.2) ───────────────────────────── + +def test_permission_manager_sso_methods(client, idp_keypair): + """§7.2 — is_sso_only_workspace / get_sso_roles / sync_sso_permissions.""" + _, cert_pem = idp_keypair + uid, _ = _admin_session(client) + + from app.db import get_conn + from app.services.permission_manager import PermissionManager + + with get_conn() as conn: + ws = conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES ('Corp', ?)", (uid,) + ).lastrowid + conn.execute( + "INSERT INTO users (login, full_name, email, auth_method) VALUES " + "('pm_local', 'PM Local', 'pm_local@t.dev', 'local')" + ) + conn.execute( + "INSERT INTO users (login, full_name, email, auth_method) VALUES " + "('pm_sso', 'PM SSO', 'pm_sso@t.dev', 'saml')" + ) + conn.commit() + pm_sso_id = conn.execute("SELECT id FROM users WHERE login='pm_sso'").fetchone()["id"] + pm_local_id = conn.execute("SELECT id FROM users WHERE login='pm_local'").fetchone()["id"] + + pm_sso = PermissionManager(pm_sso_id) + pm_local = PermissionManager(pm_local_id) + + # No SSO-only flag, no grants yet. + assert pm_sso.is_sso_only_workspace(ws) is False + assert pm_sso.get_sso_roles(workspace_id=ws) == [] + # A local account never carries SSO roles, even once it becomes a member. + with get_conn() as conn: + conn.execute( + "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'editor')", + (ws, pm_local_id), + ) + conn.commit() + assert pm_local.get_sso_roles(workspace_id=ws) == [] + + # Configure the mapping, then re-apply it through the manager. + _save_config( + client, + _saml_payload( + cert_pem, + groups_mapping=[ + {"sso_group": "FlowDeck Admins", "workspace_role": "admin", "workspace_id": ws} + ], + ), + ) + touched = pm_sso.sync_sso_permissions(pm_sso_id, ["FlowDeck Admins"], ws) + assert touched == [ws] + assert pm_sso.role_in_workspace(ws) == "admin" # cache invalidated + assert pm_sso.get_sso_roles(workspace_id=ws) == ["admin"] + # Unknown group → nothing touched (and the role stays put). + assert pm_sso.sync_sso_permissions(pm_sso_id, ["Nope Group"], ws) == [] + assert pm_sso.get_sso_roles(workspace_id=ws) == ["admin"] + + # SSO-only switch is instance-wide (design §7.1). + _save_config(client, _saml_payload(cert_pem, sso_only=1)) + assert pm_sso.is_sso_only_workspace(ws) is True + assert PermissionManager(uid).is_sso_only_workspace(ws) is True