feat: v6.7.0 — SSO/SAML + OIDC entreprise (SP SAML signé + OIDC PKCE, auto-provisioning, groupes IdP → rôles workspace, SSO only, onglet Settings « SSO / Enterprise », API /api/v2/sso/*, help, migration 23, docs + OpenAPI 439 chemins) · 802 tests verts
FlowDeck CI / lint (push) Successful in 1m28s
FlowDeck CI / test (push) Successful in 11m52s
FlowDeck CI / docker (push) Successful in 1m51s

This commit is contained in:
2026-09-24 13:32:17 -04:00
parent 9562f30366
commit d074689b18
25 changed files with 4507 additions and 31 deletions
+24
View File
@@ -58,3 +58,27 @@ SMTP_PASSWORD=
SMTP_FROM=FlowDeck <[email protected]> SMTP_FROM=FlowDeck <[email protected]>
SMTP_USE_TLS=true SMTP_USE_TLS=true
APP_BASE_URL=http://localhost:8080 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
+1
View File
@@ -1710,5 +1710,6 @@ docker compose restart flowdeck
- **Kanban flexible** — Colonnes custom, WIP limits - **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 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` - **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 - **Volume Docker persistant** — `/data` monté pour survie des données
- **PostgreSQL** — Migration optionnelle pour scaling - **PostgreSQL** — Migration optionnelle pour scaling
+75
View File
@@ -1,5 +1,80 @@
# Changelog - FlowDeck # 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 ## 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 > Dernière phase du plan agent en 5 phases (« Plateforme ») : l'agent devient
+1 -1
View File
@@ -2,7 +2,7 @@
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users. 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 ## Quick Start
+36 -2
View File
@@ -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) ## 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 » : > **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 > 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] **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] **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) - [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] **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) - [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 DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅ 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**.*
+1 -1
View File
@@ -1 +1 @@
6.6.0 6.7.0
+3 -2
View File
@@ -1,7 +1,7 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v6.6.0 | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v6.7.0 | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML (design seul) > **Cible**: parité Notion + intégration forge · **Reste roadmap**: ✅ rien — **roadmap v6.0.0 COMPLETED** (SSO/SAML livré v6.7.0)
## Avancement Global ## Avancement Global
@@ -27,6 +27,7 @@
| **v6.4.0** | **Realtime production (merge 3-voix, broadcast non bloquant)** | ✅ | **749+** | | **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.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.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 ## Blocs Complétés
+219
View File
@@ -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
+279
View File
@@ -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()
+20
View File
@@ -82,6 +82,26 @@ class Settings(BaseSettings):
smtp_use_tls: bool = True smtp_use_tls: bool = True
app_base_url: str = "http://localhost:8080" 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 # FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
# (deterministic rule-based planner so the agent works without any API key). # (deterministic rule-based planner so the agent works without any API key).
agent_enabled: bool = True agent_enabled: bool = True
+3 -1
View File
@@ -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.notifications import router as notifications_router
from app.routers.permissions import router as permissions_router from app.routers.permissions import router as permissions_router
from app.routers.realtime import router as realtime_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 api_router as web_clipper_api_router
from app.routers.web_clipper import router as web_clipper_router from app.routers.web_clipper import router as web_clipper_router
from app.services.webhook_outbound import init_webhook_tables from app.services.webhook_outbound import init_webhook_tables
@@ -123,7 +124,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="6.6.0", version="6.7.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
@@ -136,6 +137,7 @@ app.add_middleware(RateLimitMiddleware)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(sso_router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(board.router) app.include_router(board.router)
app.include_router(notes.router) app.include_router(notes.router)
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
""" """
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} 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): async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt # Webhook receiver, OAuth callback, and internal API are exempt
+84
View File
@@ -983,3 +983,87 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_pages_row " "CREATE INDEX IF NOT EXISTS idx_pages_row "
"ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL" "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)"
)
+74 -5
View File
@@ -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-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{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;} .oauth-btn:hover{background:#333;}
.sso-btn{border-color:rgba(35,131,226,.5);}
</style> </style>
</head> </head>
<body> <body>
@@ -88,11 +89,17 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
<div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div> <div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div>
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button> <button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
</form> </form>
<div class="oauth-section"> <div class="oauth-section" id="oauth-section">
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p> <p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button> <button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 <span id="github-btn-label">Login</span> with GitHub</button> <button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
</div> </div>
<!-- SSO / SAML + OIDC (v6.7.0) — buttons injected by loadSsoProviders() -->
<div class="oauth-section" id="sso-section" style="display:none">
<p id="sso-divider" style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
<div id="sso-buttons"></div>
<p id="sso-only-note" style="display:none;font-size:12px;color:rgba(255,255,255,.45);margin-top:12px;line-height:1.5;">This instance only accepts your organization account — local login is disabled.</p>
</div>
</div> </div>
<script> <script>
// Show session expired banner if ?expired=1 in URL // Show session expired banner if ?expired=1 in URL
@@ -101,6 +108,38 @@ let mode='login';
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;} function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;}
function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}} function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}}
async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/workspaces';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}} async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/workspaces';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}}
// SSO buttons (v6.7.0) — rendered from /api/v2/sso/providers
(async function loadSsoProviders(){
try{
const r = await fetch('/api/v2/sso/providers');
if(!r.ok) return;
const d = await r.json();
const providers = d.providers || [];
if(!providers.length) return;
const wrap = document.getElementById('sso-buttons');
providers.forEach(function(p){
const b = document.createElement('button');
b.className = 'oauth-btn sso-btn';
b.style.marginBottom = '8px';
b.title = 'Sign in with ' + (p.name || 'SSO');
b.onclick = function(){ window.location = p.login_url; };
const icon = document.createElement('span'); icon.textContent = p.icon || '🏢';
const label = document.createElement('span');
label.textContent = (mode === 'register' ? 'Sign up' : 'Login') + ' with ' + (p.name || 'SSO');
b.appendChild(icon); b.appendChild(label);
wrap.appendChild(b);
});
document.getElementById('sso-section').style.display = 'block';
if(d.sso_only){
// Local auth is refused server-side too — don't show a dead form.
const form = document.getElementById('login-form'); if(form) form.style.display = 'none';
const tabs = document.querySelector('.tabs'); if(tabs) tabs.style.display = 'none';
const oauth = document.getElementById('oauth-section'); if(oauth) oauth.style.display = 'none';
const note = document.getElementById('sso-only-note'); if(note) note.style.display = 'block';
const intro = document.querySelector('.login-box p'); if(intro) intro.textContent = 'Sign in with your organization account to continue';
}
}catch(e){}
})();
</script> </script>
</body> </body>
</html>""" </html>"""
@@ -191,6 +230,16 @@ async def register(request: Request):
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400) 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: with get_conn() as conn:
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone() existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
if existing: if existing:
@@ -260,7 +309,15 @@ async def local_login(request: Request):
conn.commit() conn.commit()
return JSONResponse({"error": "Invalid credentials"}, status_code=401) 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: with get_conn() as conn:
conn.execute( conn.execute(
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?", "UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
@@ -373,9 +430,21 @@ async def callback(
@router.get("/logout") @router.get("/logout")
async def logout(): async def logout(request: Request):
"""Clear session and redirect to login page.""" """Clear session and redirect to login page.
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
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") response.delete_cookie("flowdeck_session")
return response return response
+11
View File
@@ -489,6 +489,7 @@ async def help_page(request: Request):
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;} .help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;} .help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;} .help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style> </style>
<div class="help-page"> <div class="help-page">
<div class="help-hero"> <div class="help-hero">
@@ -590,6 +591,16 @@ FlowDeck supports three authentication methods:<br>
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br> <span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user. <b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
</p> </p>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
<b>Settings → Admin → SSO / Enterprise</b> (login history).
</p>
</div> </div>
<div class="help-section"> <div class="help-section">
+656
View File
@@ -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"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<title>FlowDeck — {title}</title><style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
display:flex;align-items:center;justify-content:center;min-height:100vh;}}
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;max-width:460px;text-align:center;}}
h1{{font-size:20px;margin-bottom:12px}}p{{color:rgba(255,255,255,.55);font-size:14px;margin-bottom:10px;line-height:1.5;word-break:break-word}}
a{{color:#2383E2;font-size:14px;text-decoration:none}}a:hover{{text-decoration:underline}}
</style></head><body><div class="box"><h1>{title}</h1>{body}</div></body></html>""",
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")[:400]
)
return _page(
"SSO sign-in failed",
f"<p>{safe}</p><p><a href=\"/auth/login?provider=local\">↩ Back to login</a></p>",
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", "<p>Please wait a minute and try again.</p>", status=429)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
return _page(
"SAML not configured",
"<p>Single Sign-On has not been set up by the server administrator.</p>"
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
status=404,
)
cfg = sso.ensure_sp_keypair(cfg)
# RelayState = "<AuthnRequest id>.<CSRF token>" — 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", "<p>Please wait a minute and try again.</p>", 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", "<p>No SAML configuration found.</p>", 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"<p>{err}</p>", 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", "<p>Please wait a minute and try again.</p>", status=429)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "oidc":
return _page(
"OIDC not configured",
"<p>Single Sign-On has not been set up by the server administrator.</p>"
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
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", "<p>Please wait a minute and try again.</p>", 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}
+68
View File
@@ -98,6 +98,74 @@ class PermissionManager:
).fetchone() ).fetchone()
return "owner" if owner else "viewer" 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: def can_read(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in READ_ROLES return self.role_in_workspace(workspace_id) in READ_ROLES
+783
View File
@@ -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 [email protected]":
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
+254
View File
@@ -153,6 +153,7 @@
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users &amp; Roles</div> <div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div> <div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div> <div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
</div> </div>
</template> </template>
</div> </div>
@@ -870,6 +871,141 @@
</div> </div>
</div> </div>
<!-- Admin: SSO / Enterprise (v6.7.0) -->
<div x-show="activeSection==='admin-sso'">
<h2>SSO / Enterprise</h2>
<p class="section-desc">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.</p>
<!-- Status -->
<div class="llm-summary" x-show="ssoCfg.id || ssoSource==='env'">
<div class="llm-summary-icon">🔐</div>
<div class="llm-summary-body">
<div class="llm-summary-title" x-text="ssoStatusTitle()"></div>
<div class="llm-summary-desc" x-text="ssoStatusDesc()"></div>
</div>
<div class="llm-summary-side">
<span class="llm-badge" :class="ssoSource==='env' ? 'warn' : 'ok'" x-text="ssoSource==='env' ? 'via .env' : 'Actif'"></span>
</div>
</div>
<!-- Provider -->
<div class="setting-group">
<h3>Fournisseur</h3>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
<select class="settings-input" x-model="ssoCfg.provider_type" style="max-width:220px;">
<option value="saml">SAML 2.0</option>
<option value="oidc">OpenID Connect</option>
</select>
<input type="text" class="settings-input" placeholder="Nom affiché (ex : Company SSO)" x-model="ssoCfg.name" style="max-width:300px;">
</div>
</div>
<!-- SAML -->
<div class="setting-group" x-show="ssoCfg.provider_type==='saml'">
<h3>Configuration SAML</h3>
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Entity ID (IdP)</div>
<input type="text" class="settings-input" x-model="ssoCfg.entity_id" placeholder="https://idp.example.com/saml/metadata" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Sign-On URL</div>
<input type="text" class="settings-input" x-model="ssoCfg.sso_url" placeholder="https://idp.example.com/saml/sso" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Logout URL (optionnel)</div>
<input type="text" class="settings-input" x-model="ssoCfg.slo_url" placeholder="https://idp.example.com/saml/slo" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Certificat de signature de l'IdP (PEM)</div>
<textarea class="settings-input" rows="6" x-model="ssoCfg.x509_certificate" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Mapping des attributs (JSON)</div>
<textarea class="settings-input" rows="4" x-model="ssoAttrJson" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder='{"email":"email","full_name":"displayName","groups":"groups"}'></textarea>
<label style="display:flex;gap:8px;align-items:center;font-size:13px;margin-top:12px;">
<input type="checkbox" x-model="ssoCfg.sign_requests"> Signer les AuthnRequests / LogoutRequests
</label>
<div style="margin-top:14px;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;font-size:13px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<span>SP metadata à donner à l'IdP :</span>
<code style="font-size:12px;user-select:all;" x-text="ssoMetadataUrl()"></code>
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" @click="copySsoMetadata()">Copier</button>
</div>
</div>
<!-- OIDC -->
<div class="setting-group" x-show="ssoCfg.provider_type==='oidc'">
<h3>Configuration OpenID Connect</h3>
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Issuer URL</div>
<input type="text" class="settings-input" x-model="ssoCfg.issuer_url" placeholder="https://idp.example.com/realms/flowdeck" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client ID</div>
<input type="text" class="settings-input" x-model="ssoCfg.client_id" placeholder="flowdeck" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client Secret</div>
<div class="pw-wrapper" style="max-width:560px;">
<input type="password" class="settings-input" x-model="ssoCfg.client_secret"
:placeholder="ssoCfg.client_secret_set ? 'Déjà enregistré — laisser vide pour conserver' : 'Client secret'"
style="padding-right:36px;">
</div>
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Scope</div>
<input type="text" class="settings-input" x-model="ssoCfg.scope" placeholder="openid profile email" style="max-width:560px;">
<p style="font-size:12px;color:var(--text-dim);margin-top:10px;">L'échange du code utilise PKCE (S256) et le nonce est vérifié à chaque login.</p>
</div>
<!-- Provisioning -->
<div class="setting-group">
<h3>Provisioning &amp; accès</h3>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
<input type="checkbox" x-model="ssoCfg.auto_provision" style="margin-top:3px;">
<span><b>Auto-provision</b> — créer le compte au premier login (sinon seuls les comptes existants passent)</span>
</label>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
<input type="checkbox" x-model="ssoCfg.sso_only" style="margin-top:3px;">
<span><b>SSO only</b> — désactiver le login local (les administrateurs gardent le leur)</span>
</label>
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Espace par défaut des utilisateurs SSO</div>
<select class="settings-input" x-model="ssoCfg.default_workspace_id" style="max-width:320px;">
<option value="">— Aucun —</option>
<template x-for="w in ssoWorkspaces" :key="w.id">
<option :value="w.id" x-text="w.name"></option>
</template>
</select>
</div>
<!-- Group mapping -->
<div class="setting-group">
<h3>Groupes IdP → rôles workspace</h3>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Groupe SSO</th><th>Rôle</th><th>Espace</th><th></th></tr></thead>
<tbody>
<template x-for="(g, i) in ssoGroups" :key="i">
<tr>
<td><input class="settings-input" x-model="g.sso_group" placeholder="FlowDeck Admins" style="min-width:170px;"></td>
<td>
<select class="settings-input" x-model="g.workspace_role" style="min-width:110px;">
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="viewer">viewer</option>
</select>
</td>
<td>
<select class="settings-input" x-model="g.workspace_id" style="min-width:150px;">
<option value="">Espace par défaut</option>
<template x-for="w in ssoWorkspaces" :key="w.id">
<option :value="w.id" x-text="w.name"></option>
</template>
</select>
</td>
<td><button class="btn btn-secondary" style="font-size:12px;padding:4px 8px;" @click="removeSsoGroup(i)">✕</button></td>
</tr>
</template>
<tr x-show="!ssoGroups.length"><td colspan="4" style="text-align:center;color:var(--text-dim);padding:14px;">Aucune règle — les groupes de l'IdP ne modifient aucun rôle.</td></tr>
</tbody>
</table>
</div>
<button class="btn btn-secondary" style="font-size:13px;margin-top:10px;" @click="addSsoGroup()">+ Ajouter une règle</button>
</div>
<!-- Actions -->
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px;">
<button class="btn btn-primary" style="font-size:13px;" @click="saveSsoConfig()" :disabled="ssoSaving" x-text="ssoSaving ? 'Enregistrement…' : 'Enregistrer la configuration SSO'"></button>
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="disableSso()">Désactiver le SSO</button>
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="syncSsoGroups()">Re-sync des groupes</button>
<span x-show="ssoMsg" x-text="ssoMsg" style="font-size:13px;" :style="{color: ssoOk ? 'var(--toast-success-bg, #00CC66)' : 'var(--danger)'}"></span>
</div>
<p x-show="ssoSource==='env'" class="section-desc" style="margin-top:10px;">Configuration actuellement lue depuis les variables <code>SSO_*</code> du .env — un enregistrement ici la remplace.</p>
</div>
<!-- Agent & IA: per-user provider keys + admin global default --> <!-- Agent & IA: per-user provider keys + admin global default -->
<div x-show="activeSection==='llm'"> <div x-show="activeSection==='llm'">
<h2>Agent &amp; IA</h2> <h2>Agent &amp; IA</h2>
@@ -1115,6 +1251,17 @@ function settingsInit() {
backupMsg: '', backupMsg: '',
backupOk: false, 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() { async init() {
await this.loadTags(); await this.loadTags();
await this.loadGiteaStatus(); await this.loadGiteaStatus();
@@ -1935,6 +2082,113 @@ function settingsInit() {
} catch(e) { this.clipTestMsg = 'Erreur réseau'; } } catch(e) { this.clipTestMsg = 'Erreur réseau'; }
finally { this.clipTesting = false; } 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) ── // ── v5.2.0 Backups (admin) ──
async loadBackups() { async loadBackups() {
try { try {
+38 -2
View File
@@ -7,6 +7,10 @@
> marketplace de skills (`/api/v2/skills/*`) dans `app/routers/api_v2_agent.py`, logique > 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 > 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. > (`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. > Les sections ci-dessous décrivent les conventions cibles et restent la référence de conception.
> **Dernière mise à jour** : 2026-09-24 > **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. > **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). (catalogue `app/services/webhook_outbound.py`, abonnement `agent.*` possible).
4. Chaque mutation écrit `api_audit_log` (`agent.create`, `agent.run`, `skill.import`, …). 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 ### 3.1 Auth & tokens
+16 -14
View File
@@ -1,6 +1,6 @@
# V6.0.0 — SSO / SAML : Enterprise Authentication # 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 > **Date** : 2026-09-15
> **Route** : `feat/v6-sso-saml` → `develop` → `main` > **Route** : `feat/v6-sso-saml` → `develop` → `main`
> **Dépendances** : v4.0.0 Accounts & Integrations (OAuth2 Gitea/GitHub existant) > **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_ENTITY_ID=https://sts.windows.net/{tenant-id}/
SSO_SSO_URL=https://login.microsoftonline.com/{tenant-id}/saml2 SSO_SSO_URL=https://login.microsoftonline.com/{tenant-id}/saml2
SSO_SLO_URL=https://login.microsoftonline.com/{tenant-id}/saml2/logout 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_ATTRIBUTE_MAPPING={"login":"nameid","email":"email","full_name":"name"}
SSO_AUTO_PROVISION=true SSO_AUTO_PROVISION=true
SSO_DEFAULT_WORKSPACE_ID=1 SSO_DEFAULT_WORKSPACE_ID=1
@@ -432,18 +432,20 @@ ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'local';
## 9. Checklist d'implémentation ## 9. Checklist d'implémentation
1. **`auth/providers/saml_provider.py`** — wrapper python3-saml ou pysaml2 > ✅ **Tout est livré en v6.7.0** (`tests/test_v67_sso.py`, 38 tests) :
2. **`auth/providers/oidc_provider.py`** — wrapper OIDC (authlib ou httpx)
3. **`auth/routers/sso.py`** — endpoints SSO/OIDC 1. ✅ **`auth/providers/saml_provider.py`** — wrapper python3-saml (`app/auth/providers/saml_provider.py`)
4. **Migration `sso_config`** + `sso_login_history` + colonne `auth_method` sur `users` 2. ✅ **`auth/providers/oidc_provider.py`** — wrapper OIDC authlib (`app/auth/providers/oidc_provider.py`)
5. **`services/sso_provisioning.py`** — auto-provision + group mapping 3. ✅ **`routers/sso.py`** — endpoints SSO/OIDC (`app/routers/sso.py` + API admin `/api/v2/sso/*`)
6. **Extension `settings.html`** — UI admin SSO 4. ✅ **Migration 23 `sso_config`** + `sso_login_history` + `sso_requests` + colonne `auth_method` sur `users` (déjà présente)
7. **Extension `login.html`** — boutons SSO 5. ✅ **`services/sso_provisioning.py`** — auto-provision + group mapping
8. **SP metadata endpoint** (`/auth/saml/metadata`) 6. ✅ **Extension `settings.html`** — UI admin SSO (onglet « SSO / Enterprise »)
9. **Security** — validation assertions, rate limiting, audit log 7. ✅ **Extension page de login** — boutons SSO (nom dynamique)
10. **Tests** — tous les scénarios SSO 8. ✅ **SP metadata endpoint** (`/auth/saml/metadata`)
11. **Documentation utilisateur** — `/help` section SSO setup 9. ✅ **Security** — validation assertions (sign/aud/dest/InResponseTo), anti-replay `sso_requests`, rate limit login, historique
12. **Dépendance** — `python3-saml` ou `pysaml2`, `authlib` dans requirements.txt 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
--- ---
+504 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "6.6.0" "version": "6.7.0"
}, },
"paths": { "paths": {
"/auth/register": { "/auth/register": {
@@ -161,7 +161,7 @@
"auth" "auth"
], ],
"summary": "Logout", "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", "operationId": "logout_auth_logout_get",
"responses": { "responses": {
"200": { "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": { "/trash": {
"get": { "get": {
"tags": [ "tags": [
+3
View File
@@ -18,3 +18,6 @@ beautifulsoup4==4.*
PyYAML==6.* PyYAML==6.*
python-docx>=1.1 python-docx>=1.1
pypdf==6.* pypdf==6.*
python3-saml==1.16.0
authlib==1.8.0
cryptography>=42.0
File diff suppressed because it is too large Load Diff