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
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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**.*
|
||||||
|
|||||||
+3
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")[: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}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -153,6 +153,7 @@
|
|||||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & 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 & 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 & IA</h2>
|
<h2>Agent & 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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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": [
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user