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

This commit is contained in:
2026-09-24 13:32:17 -04:00
parent 9562f30366
commit d074689b18
25 changed files with 4507 additions and 31 deletions
+219
View File
@@ -0,0 +1,219 @@
"""OIDC provider — authorization code flow with PKCE (v6.7.0).
Discovery (``.well-known/openid-configuration``) is cached for an hour, the
ID token signature is verified against the issuer JWKS via authlib's JOSE
implementation, and ``iss`` / ``aud`` / ``exp`` / ``nonce`` are checked here
explicitly so the rules are visible and unit-testable.
"""
from __future__ import annotations
import base64
import hashlib
import json
import logging
import secrets
import time
import warnings
import httpx
logger = logging.getLogger(__name__)
#: Default attribute mapping (design doc §3.2) — OIDC claim names.
DEFAULT_OIDC_MAPPING: dict[str, str] = {
"login": "sub",
"email": "email",
"full_name": "name",
"avatar_url": "picture",
"groups": "groups",
}
_DISCOVERY_TTL = 3600.0
_discovery_cache: dict[str, tuple[float, dict]] = {}
class OIDCError(Exception):
"""OIDC processing failure — ``message`` is user-facing."""
def pkce_pair() -> tuple[str, str]:
"""Return ``(code_verifier, code_challenge)`` for the S256 method."""
verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(data: str) -> bytes:
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
async def discover(issuer_url: str) -> dict:
"""Fetch (and cache) the issuer's OIDC discovery document."""
issuer = issuer_url.rstrip("/")
url = f"{issuer}/.well-known/openid-configuration"
now = time.time()
hit = _discovery_cache.get(issuer)
if hit and now - hit[0] < _DISCOVERY_TTL:
return hit[1]
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url)
r.raise_for_status()
doc = r.json()
except Exception as err:
raise OIDCError(f"OIDC discovery failed ({url}): {err}") from err
if not doc.get("authorization_endpoint") or not doc.get("token_endpoint"):
raise OIDCError("OIDC discovery document is missing authorization/token endpoints")
_discovery_cache[issuer] = (now, doc)
return doc
def build_authorize_url(
doc: dict,
*,
client_id: str,
redirect_uri: str,
scope: str,
state: str,
nonce: str,
code_challenge: str,
) -> str:
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": scope or "openid profile email",
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
sep = "&" if "?" in doc["authorization_endpoint"] else "?"
return doc["authorization_endpoint"] + sep + urlencode(params)
async def exchange_code(
doc: dict, *, client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str
) -> dict:
"""Exchange the authorization code for tokens (PKCE, confidential client)."""
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"code_verifier": code_verifier,
}
auth = None
if client_secret:
auth = (client_id, client_secret)
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.post(doc["token_endpoint"], data=data, auth=auth)
except Exception as err:
raise OIDCError(f"OIDC token request failed: {err}") from err
if r.status_code != 200:
raise OIDCError(f"OIDC token endpoint returned {r.status_code}: {r.text[:300]}")
try:
tokens = r.json()
except Exception as err:
raise OIDCError(f"OIDC token endpoint returned a non-JSON body: {err}") from err
if "error" in tokens:
raise OIDCError(f"OIDC error: {tokens.get('error')} {tokens.get('error_description', '')}".strip())
return tokens
async def fetch_userinfo(doc: dict, access_token: str) -> dict:
"""Best-effort userinfo fetch (groups often only live there)."""
endpoint = doc.get("userinfo_endpoint")
if not endpoint or not access_token:
return {}
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
if r.status_code != 200:
return {}
data = r.json()
return data if isinstance(data, dict) else {}
except Exception as err: # userinfo is optional enrichment
logger.debug("userinfo fetch failed: %s", err)
return {}
def validate_id_token(
id_token: str, *, issuer: str, client_id: str, nonce: str, jwks: dict
) -> dict:
"""Verify the ID token signature and claims. Returns the claims dict."""
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
from authlib.jose import JsonWebKey
from authlib.jose import jwt as jose_jwt
if isinstance(id_token, bytes):
# authlib's jose.jwt.encode() returns bytes; IdP token endpoints send
# str — accept both instead of crashing on ``bytes.count(".")``.
id_token = id_token.decode()
if not id_token or id_token.count(".") != 2:
raise OIDCError("Missing or malformed ID token")
try:
keyset = JsonWebKey.import_key_set(jwks)
except Exception as err:
raise OIDCError(f"Invalid issuer JWKS: {err}") from err
try:
# Pick the key matching the token header (kid) when several are offered.
header = json.loads(_b64url_decode(id_token.split(".")[0]))
kid = header.get("kid")
key = keyset.get_by_kid(kid) if kid and hasattr(keyset, "get_by_kid") else None
token_obj = jose_jwt.decode(id_token, key or keyset)
except Exception as err:
raise OIDCError(f"ID token signature verification failed: {err}") from err
claims = dict(token_obj) # authlib's JWTClaims is a dict subclass
now = int(time.time())
if claims.get("iss") != issuer.rstrip("/") and claims.get("iss") != issuer:
raise OIDCError(f"ID token issuer mismatch: {claims.get('iss')!r}")
aud = claims.get("aud")
aud_list = aud if isinstance(aud, list) else [aud]
if client_id not in aud_list:
raise OIDCError("ID token audience does not include this client")
exp = claims.get("exp")
if not isinstance(exp, int) or exp < now:
raise OIDCError("ID token expired")
iat = claims.get("iat")
if isinstance(iat, int) and iat > now + 300:
raise OIDCError("ID token issued in the future")
if nonce and claims.get("nonce") != nonce:
raise OIDCError("ID token nonce mismatch")
if not claims.get("sub"):
raise OIDCError("ID token has no subject")
return claims
def claims_to_identity(claims: dict, mapping: dict | None = None) -> dict:
"""Map OIDC claims onto the shared ``{login, email, full_name, avatar_url, groups}`` shape."""
mapping = mapping or DEFAULT_OIDC_MAPPING
identity: dict = {"_raw": claims}
for field in ("login", "email", "full_name", "avatar_url"):
source = mapping.get(field) or field
value = claims.get(source, "")
if isinstance(value, list):
value = value[0] if value else ""
identity[field] = str(value or "").strip()
groups = claims.get(mapping.get("groups", "groups"), [])
if isinstance(groups, str):
groups = [groups]
identity["groups"] = [str(g) for g in groups if g]
if not identity["email"]:
identity["email"] = claims.get("email", "") or ""
if not identity["full_name"]:
identity["full_name"] = claims.get("name", "") or identity["email"]
return identity
+279
View File
@@ -0,0 +1,279 @@
"""SAML 2.0 Service Provider — wrapper around python3-saml (OneLogin toolkit).
v6.7.0. Adapts FastAPI's ``Request`` to the toolkit's flat ``request_data``
dict and builds the SP settings from the ``sso_config`` row.
What the toolkit validates in strict mode (all covered by tests):
XML schema, signature of the assertion and/or the message against the IdP
certificate, ``Conditions`` timestamps, ``Audience``, ``Destination``,
``Issuer``, ``Status``, "exactly one assertion", and ``InResponseTo``
against the AuthnRequest id we pass to ``process_response()`` — combined
with the single-use ``sso_requests`` store that makes replay impossible.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from fastapi import Request
logger = logging.getLogger(__name__)
BINDING_HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
BINDING_HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
NAMEID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
#: Default attribute mapping (design doc §3.2). ``nameid`` = the assertion's
#: NameID; every other value is matched against attribute Name / FriendlyName
#: / URI local part (so ``email`` finds both ``email`` and
#: ``http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress``).
DEFAULT_SAML_MAPPING: dict[str, str] = {
"login": "nameid",
"email": "nameid",
"full_name": "displayName",
"avatar_url": "avatar",
"groups": "groups",
}
class SAMLError(Exception):
"""SAML processing failure — ``message`` is user-facing, ``reason`` is logged."""
@dataclass
class SAMLIdentity:
"""What a validated assertion tells us about the user."""
name_id: str
name_id_format: str = ""
session_index: str = ""
attributes: dict[str, list[str]] = field(default_factory=dict)
friendly_attributes: dict[str, list[str]] = field(default_factory=dict)
def resolve(self, source: str) -> str:
"""Resolve one mapped source (``nameid`` or an attribute name) → first value."""
if not source or source == "nameid":
return self.name_id or ""
if source in self.attributes and self.attributes[source]:
return (self.attributes[source][0] or "").strip()
# FriendlyName match (case-insensitive)
lower = {k.lower(): v for k, v in self.friendly_attributes.items()}
if source.lower() in lower and lower[source.lower()]:
return (lower[source.lower()][0] or "").strip()
# URI local part match: ".../claims/emailaddress" ~ "emailaddress", and
# a mapping of "email" must still find ".../claims/emailaddress".
want = source.lower().lstrip("./")
for name, values in self.attributes.items():
if not values:
continue
local = name.rsplit("/", 1)[-1].rsplit("}", 1)[-1].lower()
if local == want or local.endswith(want) or want.endswith(local):
return (values[0] or "").strip()
return ""
def external_base_url(request: Request) -> str:
"""Scheme://host the user actually used (proxy-aware, like OAuth redirects)."""
proto = request.headers.get("x-forwarded-proto", "")
scheme = proto.split(",")[0].strip() or request.url.scheme or "http"
fwd_host = request.headers.get("x-forwarded-host", "")
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
return f"{scheme}://{host}"
def saml_endpoints(request: Request) -> dict[str, str]:
"""SP entity id + ACS/SLO/metadata URLs derived from the incoming request."""
base = external_base_url(request)
return {
"entity_id": f"{base}/auth/saml/metadata",
"acs": f"{base}/auth/saml/callback",
"slo": f"{base}/auth/saml/logout",
"metadata": f"{base}/auth/saml/metadata",
}
def build_settings(cfg: dict, endpoints: dict[str, str]) -> dict:
"""python3-saml settings dict built from a ``sso_config`` row."""
sign_requests = bool(cfg.get("sign_requests"))
sp: dict = {
"entityId": endpoints["entity_id"],
"assertionConsumerService": {
"url": endpoints["acs"],
"binding": BINDING_HTTP_POST,
},
"singleLogoutService": {
"url": endpoints["slo"],
"binding": BINDING_HTTP_REDIRECT,
},
"NameIDFormat": NAMEID_FORMAT_EMAIL,
}
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
sp["privateKey"] = cfg["sp_private_key"]
sp["x509cert"] = cfg["sp_certificate"]
idp: dict = {
"entityId": cfg.get("entity_id") or "",
"singleSignOnService": {
"url": cfg.get("sso_url") or "",
"binding": BINDING_HTTP_REDIRECT,
},
"x509cert": cfg.get("x509_certificate") or "",
}
if cfg.get("slo_url"):
idp["singleLogoutService"] = {"url": cfg["slo_url"], "binding": BINDING_HTTP_REDIRECT}
return {
"strict": True,
"debug": False,
"sp": sp,
"idp": idp,
"security": {
"authnRequestsSigned": sign_requests,
"logoutRequestSigned": sign_requests,
"logoutResponseSigned": False,
"wantMessagesSigned": False,
"wantAssertionsSigned": True,
"wantNameIdEncrypted": False,
"wantAssertionsEncrypted": False,
"wantXmlValidation": True,
"signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256",
"rejectDeprecatedAlgorithm": True,
# FlowDeck is self-hosted: LAN/homelab deploys commonly reach the
# SP through single-label hosts (http://flowdeck/, docker service
# names). python3-saml rejects those URLs unless this is on.
"allowSingleLabelDomains": True,
},
}
def _request_data(request: Request, script_name: str, post_data: dict | None = None) -> dict:
"""Flat request dict expected by ``OneLogin_Saml2_Auth``."""
https = "on" if external_base_url(request).startswith("https") else "off"
return {
"https": https,
"http_host": request.headers.get("host", "localhost:8080"),
"script_name": script_name,
"request_uri": request.url.path,
"query_string": str(request.url.query or ""),
"get_data": dict(request.query_params),
"post_data": post_data or {},
}
def _auth(request: Request, cfg: dict, script_name: str, post_data: dict | None = None):
from onelogin.saml2.auth import OneLogin_Saml2_Auth
settings = build_settings(cfg, saml_endpoints(request))
try:
return OneLogin_Saml2_Auth(
_request_data(request, script_name, post_data=post_data), old_settings=settings
)
except Exception as err: # malformed IdP/SP config (bad cert, missing URL…)
raise SAMLError(f"Invalid SAML configuration: {err}") from err
def create_login(request: Request, cfg: dict, relay_state: str) -> tuple[str, str]:
"""Build the AuthnRequest. Returns ``(redirect_url, authn_request_id)``."""
auth = _auth(request, cfg, "/auth/saml/login")
try:
url = auth.login(return_to=relay_state)
except Exception as err:
raise SAMLError(f"Could not build the SAML AuthnRequest: {err}") from err
request_id = auth.get_last_request_id() or ""
if not request_id:
raise SAMLError("AuthnRequest was built without an id")
return url, request_id
def process_response(request: Request, cfg: dict, post_data: dict, request_id: str) -> SAMLIdentity:
"""Validate the IdP's SAMLResponse and extract the identity.
``request_id`` is the id of the AuthnRequest we issued (from the
single-use ``sso_requests`` row): the toolkit rejects any response whose
``InResponseTo`` does not match it.
"""
auth = _auth(request, cfg, "/auth/saml/callback", post_data=post_data)
try:
auth.process_response(request_id=request_id or None)
except Exception as err:
raise SAMLError(f"SAML response could not be processed: {err}") from err
errors = auth.get_errors()
if errors:
raise SAMLError(auth.get_last_error_reason() or f"SAML errors: {', '.join(errors)}")
if not auth.is_authenticated():
raise SAMLError("SAML response did not authenticate the user")
name_id = auth.get_nameid() or ""
if not name_id:
raise SAMLError("SAML assertion carries no NameID")
return SAMLIdentity(
name_id=name_id,
name_id_format=auth.get_nameid_format() or "",
session_index=auth.get_session_index() or "",
attributes=auth.get_attributes() or {},
friendly_attributes=auth.get_friendlyname_attributes() or {},
)
def metadata_xml(request: Request, cfg: dict) -> str:
"""SP metadata XML (for the IdP configuration screen)."""
from onelogin.saml2.settings import OneLogin_Saml2_Settings
settings = OneLogin_Saml2_Settings(
build_settings(cfg, saml_endpoints(request)), custom_base_path=None
)
try:
xml = settings.get_sp_metadata()
except Exception as err:
raise SAMLError(f"Could not build the SP metadata: {err}") from err
if isinstance(xml, bytes):
xml = xml.decode("utf-8")
return xml
def build_logout_url(request: Request, cfg: dict, return_to: str, name_id: str, session_index: str) -> str:
"""SP-initiated Single Logout (HTTP-Redirect LogoutRequest to the IdP)."""
auth = _auth(request, cfg, "/auth/saml/logout")
if not cfg.get("slo_url"):
raise SAMLError("The IdP has no Single Logout URL configured")
try:
return auth.logout(
return_to=return_to,
name_id=name_id or None,
session_index=session_index or None,
)
except Exception as err:
raise SAMLError(f"Could not build the SAML LogoutRequest: {err}") from err
def process_slo_form(request: Request, cfg: dict, form: dict, query: dict) -> tuple[str | None, list[str]]:
"""Process a LogoutRequest / LogoutResponse received from the IdP.
``form`` holds the POSTed fields, ``query`` the GET parameters (the
HTTP-Redirect binding delivers LogoutRequest/LogoutResponse there).
Returns ``(redirect_url, errors)``.
"""
from onelogin.saml2.auth import OneLogin_Saml2_Auth
settings = build_settings(cfg, saml_endpoints(request))
https = "on" if external_base_url(request).startswith("https") else "off"
post_data = {k: v for k, v in form.items() if k in ("SAMLRequest", "SAMLResponse", "RelayState")}
if not post_data:
post_data = {"SAMLResponse": query["SAMLResponse"]} if "SAMLResponse" in query else {}
req_data = {
"https": https,
"http_host": request.headers.get("host", "localhost:8080"),
"script_name": "/auth/saml/logout",
"request_uri": request.url.path,
"query_string": str(request.url.query or ""),
"get_data": dict(query),
"post_data": post_data,
}
auth = OneLogin_Saml2_Auth(req_data, old_settings=settings)
try:
url = auth.process_slo(keep_local_session=True)
except Exception as err:
raise SAMLError(f"SAML logout could not be processed: {err}") from err
return url, auth.get_errors()
+20
View File
@@ -82,6 +82,26 @@ class Settings(BaseSettings):
smtp_use_tls: bool = True
app_base_url: str = "http://localhost:8080"
# SSO / SAML + OIDC (v6.7.0) — bootstrap fallback ONLY: as soon as an admin
# saves a configuration in Settings → Admin → SSO / Enterprise, the
# `sso_config` table wins (see app/services/sso_provisioning.py).
sso_provider: str = "" # 'saml' | 'oidc' | '' (disabled)
sso_name: str = "Company SSO" # button label on the login page
sso_entity_id: str = "" # SAML: IdP entity id
sso_sso_url: str = "" # SAML: IdP SSO URL (HTTP-Redirect)
sso_slo_url: str = "" # SAML: IdP Single Logout URL
sso_x509_certificate: str = "" # SAML: IdP signing certificate (PEM)
sso_issuer_url: str = "" # OIDC: issuer identifier
sso_client_id: str = "" # OIDC: client id
sso_client_secret: str = "" # OIDC: client secret (env only)
sso_scope: str = "openid profile email"
sso_attribute_mapping: str = "" # JSON, defaults per provider
sso_groups_mapping: str = "[]" # JSON [{sso_group, workspace_role, workspace_id}]
sso_auto_provision: bool = True
sso_only: bool = False # refuse local login when true
sso_sign_requests: bool = False # sign AuthnRequest / LogoutRequest
sso_default_workspace_id: int = 0
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
# (deterministic rule-based planner so the agent works without any API key).
agent_enabled: bool = True
+3 -1
View File
@@ -50,6 +50,7 @@ from app.routers.imports import router as imports_router
from app.routers.notifications import router as notifications_router
from app.routers.permissions import router as permissions_router
from app.routers.realtime import router as realtime_router
from app.routers.sso import router as sso_router
from app.routers.web_clipper import api_router as web_clipper_api_router
from app.routers.web_clipper import router as web_clipper_router
from app.services.webhook_outbound import init_webhook_tables
@@ -123,7 +124,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="6.6.0",
version="6.7.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -136,6 +137,7 @@ app.add_middleware(RateLimitMiddleware)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
app.include_router(auth.router)
app.include_router(sso_router)
app.include_router(dashboard.router)
app.include_router(board.router)
app.include_router(notes.router)
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/saml", "/auth/oidc", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+84
View File
@@ -983,3 +983,87 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_pages_row "
"ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL"
)
@register(23, "v6.7.0: SSO/SAML enterprise auth")
def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
"""v6.7.0 — SSO/SAML 2.0 + OIDC enterprise authentication.
``sso_config`` — single active SSO provider (SAML or OIDC), managed
from Settings → Admin → SSO / Enterprise. Secrets
(``client_secret``, SP private key) are encrypted at
rest by ``app.services.sso_provisioning``.
``sso_login_history`` — audit trail of every SSO login attempt (successes
AND rejections — signature failure, replay, no
local account…).
``sso_requests`` — single-use anti-replay store: AuthnRequest ids and
OIDC states, CSRF relay tokens, PKCE verifiers and
the post-login redirect target. One row is consumed
by exactly one callback.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sso_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
provider_type TEXT NOT NULL DEFAULT 'saml',
name TEXT NOT NULL DEFAULT 'Company SSO',
entity_id TEXT NOT NULL DEFAULT '',
sso_url TEXT NOT NULL DEFAULT '',
slo_url TEXT DEFAULT '',
x509_certificate TEXT NOT NULL DEFAULT '',
issuer_url TEXT DEFAULT '',
client_id TEXT DEFAULT '',
client_secret TEXT DEFAULT '',
scope TEXT DEFAULT 'openid profile email',
attribute_mapping TEXT NOT NULL DEFAULT '{}',
groups_mapping TEXT NOT NULL DEFAULT '[]',
auto_provision INTEGER NOT NULL DEFAULT 1,
sso_only INTEGER NOT NULL DEFAULT 0,
sign_requests INTEGER NOT NULL DEFAULT 0,
default_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
sp_private_key TEXT DEFAULT '',
sp_certificate TEXT DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sso_login_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
provider_type TEXT NOT NULL,
provider_name TEXT NOT NULL DEFAULT 'SSO',
sso_identifier TEXT,
ip_address TEXT DEFAULT '',
user_agent TEXT DEFAULT '',
success INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_sso_history_user "
"ON sso_login_history(user_id, created_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sso_requests (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
relay_state TEXT NOT NULL DEFAULT '',
code_verifier TEXT NOT NULL DEFAULT '',
next_path TEXT NOT NULL DEFAULT '/workspaces',
used INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)"
)
+74 -5
View File
@@ -63,6 +63,7 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
.oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;}
.oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;}
.oauth-btn:hover{background:#333;}
.sso-btn{border-color:rgba(35,131,226,.5);}
</style>
</head>
<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>
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
</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>
<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>
</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>
<script>
// 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 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';}}
// 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>
</body>
</html>"""
@@ -191,6 +230,16 @@ async def register(request: Request):
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
# SSO-only instance (v6.7.0): local registration is refused — accounts are
# auto-provisioned by the IdP instead (admins still come from Settings).
from app.services.sso_provisioning import is_sso_only
if is_sso_only():
from fastapi.responses import JSONResponse
return JSONResponse(
{"error": "Registration is disabled — sign in with your organization SSO"},
status_code=403,
)
with get_conn() as conn:
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
if existing:
@@ -260,7 +309,15 @@ async def local_login(request: Request):
conn.commit()
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
# Successful login
# Successful local login — SSO-only instances keep a way in for admins
# only (every other account must use the IdP, design §7.1).
from app.services.sso_provisioning import is_sso_only
if is_sso_only() and not ud.get("is_admin"):
return JSONResponse(
{"error": "Local login is disabled on this instance — sign in with SSO"},
status_code=403,
)
with get_conn() as conn:
conn.execute(
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
@@ -373,9 +430,21 @@ async def callback(
@router.get("/logout")
async def logout():
"""Clear session and redirect to login page."""
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
async def logout(request: Request):
"""Clear session and redirect to login page.
SAML sessions additionally hand over to the IdP's Single Logout when one
is configured (the actual cookie clearing happens on the SLO route).
"""
cookie = request.cookies.get("flowdeck_session", "")
user = SessionManager.decode_session(cookie) if cookie else None
local_target = "/auth/login?provider=local"
if user and user.get("_sso_name_id"):
# SSO session → let /auth/saml/logout revoke locally + notify the IdP.
return RedirectResponse(url=f"/auth/saml/logout?next={local_target}", status_code=302)
response = RedirectResponse(url=local_target, status_code=302)
response.delete_cookie("flowdeck_session")
return response
+11
View File
@@ -489,6 +489,7 @@ async def help_page(request: Request):
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style>
<div class="help-page">
<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>
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
</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 class="help-section">
+656
View File
@@ -0,0 +1,656 @@
"""FlowDeck — v6.7.0 SSO: SAML 2.0 + OIDC endpoints and admin config API.
Two families of routes:
* ``/auth/saml/*`` and ``/auth/oidc/*`` — the browser flows (login redirect,
ACS callback, SP metadata, Single Logout). The callback endpoints are
CSRF-exempt (cross-site POST from the IdP) and instead protected by the
single-use ``sso_requests`` relay token + full assertion validation.
* ``/api/v2/sso/*`` — admin configuration API (session admin or Bearer token
with write scope), consumed by Settings → Admin → SSO / Enterprise.
Every attempt — success or rejection — lands in ``sso_login_history``.
"""
from __future__ import annotations
import logging
import secrets
import time
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.providers import oidc_provider, saml_provider
from app.auth.session import SessionManager
from app.services import sso_provisioning as sso
from app.services.api_v2_helpers import has_scope, resolve_bearer_token
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sso"])
DEFAULT_NEXT = "/workspaces"
# ── Rate limiting (design §5.2: 5 SSO attempts / minute / IP) ──────────────
_RATE_WINDOW = 60.0
_RATE_MAX = 5
_rate_store: dict[str, tuple[float, int]] = {}
def _rate_ok(request: Request, bucket: str = "sso") -> bool:
from app.config import settings
if not settings.rate_limit_enabled:
return True
ip = request.client.host if request.client else "unknown"
key = f"{bucket}:{ip}"
now = time.time()
window, count = _rate_store.get(key, (0.0, 0))
if now - window > _RATE_WINDOW:
_rate_store[key] = (now, 1)
return True
if count >= _RATE_MAX:
return False
_rate_store[key] = (window, count + 1)
return True
def _page(title: str, body: str, status: int = 200) -> HTMLResponse:
"""Small standalone error/info page (same styling as the login page)."""
return HTMLResponse(
f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<title>FlowDeck — {title}</title><style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
display:flex;align-items:center;justify-content:center;min-height:100vh;}}
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;max-width:460px;text-align:center;}}
h1{{font-size:20px;margin-bottom:12px}}p{{color:rgba(255,255,255,.55);font-size:14px;margin-bottom:10px;line-height:1.5;word-break:break-word}}
a{{color:#2383E2;font-size:14px;text-decoration:none}}a:hover{{text-decoration:underline}}
</style></head><body><div class="box"><h1>{title}</h1>{body}</div></body></html>""",
status_code=status,
)
def _sso_config_or_error() -> dict | None:
cfg = sso.get_sso_config()
return sso.normalize_config(cfg) if cfg else None
def _session_cookie(user_data: dict, request: Request):
"""Signed, revocable session cookie (same shape as local/OAuth logins)."""
return SessionManager.create_session(user_data, request)
def _login_error(message: str, *, cfg: dict | None, identifier: str = "", request=None) -> HTMLResponse:
provider_type = (cfg or {}).get("provider_type", "saml")
sso.log_sso_login(
user_id=None,
provider_type=provider_type,
provider_name=(cfg or {}).get("name") or "SSO",
identifier=identifier,
request=request,
success=False,
error=message,
)
logger.warning("SSO login rejected: %s", message)
safe = (
message.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")[:400]
)
return _page(
"SSO sign-in failed",
f"<p>{safe}</p><p><a href=\"/auth/login?provider=local\">↩ Back to login</a></p>",
status=403,
)
# ═══════════════════════════════ SAML 2.0 ════════════════════════════════
@router.get("/auth/saml/login")
async def saml_login(request: Request, next: str = DEFAULT_NEXT):
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
if not _rate_ok(request, "saml"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
return _page(
"SAML not configured",
"<p>Single Sign-On has not been set up by the server administrator.</p>"
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
status=404,
)
cfg = sso.ensure_sp_keypair(cfg)
# RelayState = "<AuthnRequest id>.<CSRF token>" — both checked at the ACS.
csrf_token = secrets.token_hex(16)
# The id is only known after building the request, so build it first with a
# placeholder relay state, then re-issue with the real one? python3-saml
# builds the AuthnRequest inside login(); we instead create the row right
# after login() returns the URL — but the RelayState is already embedded.
# So: generate the request id ourselves is not possible → build the URL,
# then patch the RelayState by rebuilding with the known id.
from urllib.parse import parse_qs, urlencode, urlparse
provisional = saml_provider.create_login(request, cfg, relay_state="_pending_")
authn_id = provisional[1]
relay = f"{authn_id}.{csrf_token}"
sso.create_request(
"saml_authn",
request_id=authn_id,
relay_state=csrf_token,
next_path=sso.safe_next_path(next),
)
# Replace the placeholder RelayState with the real token (same SAMLRequest).
parsed = urlparse(provisional[0])
params = parse_qs(parsed.query)
params["RelayState"] = [relay]
flat = [(k, v) for k, values in params.items() for v in values]
url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(flat)}"
return RedirectResponse(url, status_code=302)
@router.post("/auth/saml/callback")
async def saml_callback(request: Request):
"""Assertion Consumer Service — validate the SAMLResponse and open a session."""
if not _rate_ok(request, "saml-cb"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
form = await request.form()
saml_response = str(form.get("SAMLResponse") or "")
relay_state = str(form.get("RelayState") or "")
if not saml_response:
return _login_error("Missing SAMLResponse", cfg=None, request=request)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
return _login_error("SAML is not configured", cfg=None, request=request)
authn_id, _, csrf_token = relay_state.partition(".")
pending = sso.peek_request("saml_authn", authn_id)
if not pending and sso.was_consumed("saml_authn", authn_id):
# Same assertion twice: the single-use row is already spent.
return _login_error(
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
)
if not pending or not csrf_token or not secrets.compare_digest(
pending.get("relay_state", ""), csrf_token
):
return _login_error(
"Unknown or expired login request (start again from the login page)",
cfg=cfg, request=request,
)
try:
identity = saml_provider.process_response(
request, cfg, {"SAMLResponse": saml_response, "RelayState": relay_state}, authn_id
)
except saml_provider.SAMLError as err:
return _login_error(str(err), cfg=cfg, identifier=authn_id, request=request)
# Single-use: the same AuthnRequest id can never authenticate twice.
consumed = sso.consume_request("saml_authn", authn_id, csrf_token)
if not consumed:
return _login_error(
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
)
claims = sso.identity_from_saml(identity, cfg)
identifier = sso.sso_identifier_field(claims)
try:
user = sso.handle_sso_login(claims, provider_type="saml", cfg=cfg, request=request)
except sso.SSOProvisioningError as err:
# _login_error() below records the failed attempt itself.
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
sso.log_sso_login(
user_id=user["id"], provider_type="saml",
provider_name=cfg.get("name") or "SSO", identifier=identifier,
request=request, success=True,
)
user_data = dict(user)
user_data["_sso_name_id"] = identity.name_id
user_data["_sso_session_index"] = identity.session_index
response = RedirectResponse(consumed.get("next_path") or DEFAULT_NEXT, status_code=302)
response.set_cookie(
"flowdeck_session", _session_cookie(user_data, request),
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
)
return response
@router.get("/auth/saml/metadata")
async def saml_metadata(request: Request):
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
return _page("SAML not configured", "<p>No SAML configuration found.</p>", status=404)
cfg = sso.ensure_sp_keypair(cfg)
try:
xml = saml_provider.metadata_xml(request, cfg)
except saml_provider.SAMLError as err:
return _page("Metadata error", f"<p>{err}</p>", status=500)
return HTMLResponse(xml, media_type="application/samlmetadata+xml")
async def _saml_logout(request: Request, next: str = "/auth/login?provider=local"):
"""Single Logout: SP-initiated (our logout button) or IdP-initiated.
* no SAML payload → build a LogoutRequest to the IdP (after revoking the
local session);
* ``SAMLRequest`` / ``SAMLResponse`` present → process it (LogoutResponse
of our own SLO, or a LogoutRequest issued by the IdP).
"""
form = dict(await request.form()) if request.method == "POST" else {}
query = dict(request.query_params)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
response = RedirectResponse(next, status_code=302)
response.delete_cookie("flowdeck_session")
return response
payload = form.get("SAMLRequest") or form.get("SAMLResponse") or query.get("SAMLResponse")
if payload:
try:
url, errors = saml_provider.process_slo_form(request, cfg, form, query)
except saml_provider.SAMLError as err:
logger.warning("SLO processing failed: %s", err)
return _login_error(str(err), cfg=cfg, request=request)
if errors:
return _login_error(
"; ".join(errors)[:300], cfg=cfg, request=request
)
response = RedirectResponse(url or next, status_code=302)
response.delete_cookie("flowdeck_session")
return response
# SP-initiated
cookie = request.cookies.get("flowdeck_session", "")
user = SessionManager.decode_session(cookie) if cookie else None
response = RedirectResponse(next, status_code=302)
if cookie:
sid = SessionManager.session_id(cookie)
if sid:
SessionManager.revoke_session(sid)
response.delete_cookie("flowdeck_session")
if user and cfg.get("slo_url") and user.get("_sso_name_id"):
try:
logout_url = saml_provider.build_logout_url(
request, cfg,
return_to=sso.safe_next_path(next),
name_id=user.get("_sso_name_id", ""),
session_index=user.get("_sso_session_index", ""),
)
# Keep the cookie-clearing headers built above: hand the browser
# to the IdP with our local session already dead.
response = RedirectResponse(logout_url, status_code=302)
response.delete_cookie("flowdeck_session")
return response
except saml_provider.SAMLError as err:
logger.warning("SP-initiated SLO failed: %s", err)
return response
@router.get("/auth/saml/logout")
async def saml_logout(request: Request, next: str = "/auth/login?provider=local"):
"""SP-initiated Single Logout (GET) — hands the browser to the IdP."""
return await _saml_logout(request, next)
@router.post("/auth/saml/logout")
async def saml_logout_post(request: Request, next: str = "/auth/login?provider=local"):
"""IdP-initiated Single Logout (POST with SAMLRequest/SAMLResponse)."""
return await _saml_logout(request, next)
# ═════════════════════════════════ OIDC ═══════════════════════════════════
@router.get("/auth/oidc/login")
async def oidc_login(request: Request, next: str = DEFAULT_NEXT):
"""Redirect to the OIDC provider (authorization code + PKCE)."""
if not _rate_ok(request, "oidc"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "oidc":
return _page(
"OIDC not configured",
"<p>Single Sign-On has not been set up by the server administrator.</p>"
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
status=404,
)
try:
doc = await oidc_provider.discover(cfg["issuer_url"])
except oidc_provider.OIDCError as err:
return _login_error(str(err), cfg=cfg, request=request)
from app.auth.providers.saml_provider import external_base_url
state = secrets.token_hex(32)
nonce = secrets.token_hex(16)
verifier, challenge = oidc_provider.pkce_pair()
sso.create_request(
"oidc",
request_id=state,
relay_state=nonce,
code_verifier=verifier,
next_path=sso.safe_next_path(next),
)
url = oidc_provider.build_authorize_url(
doc,
client_id=cfg["client_id"],
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
scope=cfg.get("scope") or "openid profile email",
state=state,
nonce=nonce,
code_challenge=challenge,
)
return RedirectResponse(url, status_code=302)
async def _oidc_callback(request: Request):
"""OIDC callback: exchange the code, validate the ID token, open a session."""
if not _rate_ok(request, "oidc-cb"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
params = dict(request.query_params)
if request.method == "POST":
params.update({k: str(v) for k, v in (await request.form()).items()})
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "oidc":
return _login_error("OIDC is not configured", cfg=None, request=request)
if params.get("error"):
return _login_error(
f"Provider error: {params.get('error')} {params.get('error_description', '')}".strip(),
cfg=cfg, request=request,
)
code, state = params.get("code", ""), params.get("state", "")
pending = sso.consume_request("oidc", state)
if not code or not pending:
return _login_error(
"Unknown or expired OIDC state (start again from the login page)",
cfg=cfg, request=request,
)
from app.auth.providers.saml_provider import external_base_url
try:
doc = await oidc_provider.discover(cfg["issuer_url"])
tokens = await oidc_provider.exchange_code(
doc,
client_id=cfg["client_id"],
client_secret=sso.client_secret_value(cfg),
code=code,
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
code_verifier=pending.get("code_verifier", ""),
)
jwks = await _fetch_jwks(doc)
claims = oidc_provider.validate_id_token(
tokens.get("id_token", ""),
issuer=cfg["issuer_url"],
client_id=cfg["client_id"],
nonce=pending.get("relay_state", ""),
jwks=jwks,
)
userinfo = await oidc_provider.fetch_userinfo(doc, tokens.get("access_token", ""))
except oidc_provider.OIDCError as err:
return _login_error(str(err), cfg=cfg, identifier=state, request=request)
merged = {**claims, **userinfo}
identity = oidc_provider.claims_to_identity(merged, cfg.get("attribute_mapping") or None)
identifier = sso.sso_identifier_field(identity)
try:
user = sso.handle_sso_login(identity, provider_type="oidc", cfg=cfg, request=request)
except sso.SSOProvisioningError as err:
# _login_error() below records the failed attempt itself.
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
sso.log_sso_login(
user_id=user["id"], provider_type="oidc",
provider_name=cfg.get("name") or "SSO", identifier=identifier,
request=request, success=True,
)
response = RedirectResponse(pending.get("next_path") or DEFAULT_NEXT, status_code=302)
response.set_cookie(
"flowdeck_session", _session_cookie(dict(user), request),
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
)
return response
@router.get("/auth/oidc/callback")
async def oidc_callback(request: Request):
"""OIDC callback (GET, authorization code in the query string)."""
return await _oidc_callback(request)
@router.post("/auth/oidc/callback")
async def oidc_callback_post(request: Request):
"""OIDC callback (POST, form_post response mode)."""
return await _oidc_callback(request)
async def _fetch_jwks(doc: dict) -> dict:
url = doc.get("jwks_uri")
if not url:
raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
import httpx
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url)
r.raise_for_status()
data = r.json()
except Exception as err:
raise oidc_provider.OIDCError(f"Could not fetch the issuer JWKS: {err}") from err
if not isinstance(data, dict) or not data.get("keys"):
raise oidc_provider.OIDCError("Issuer JWKS contains no keys")
return data
async def _oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
"""Local logout + RP-initiated logout at the provider when supported."""
cfg = _sso_config_or_error()
response = RedirectResponse(next, status_code=302)
cookie = request.cookies.get("flowdeck_session", "")
if cookie:
sid = SessionManager.session_id(cookie)
if sid:
SessionManager.revoke_session(sid)
response.delete_cookie("flowdeck_session")
if cfg and cfg["provider_type"] == "oidc":
try:
doc = await oidc_provider.discover(cfg["issuer_url"])
end_session = doc.get("end_session_endpoint")
if end_session:
from urllib.parse import urlencode
from app.auth.providers.saml_provider import external_base_url
qs = urlencode({
"client_id": cfg["client_id"],
"post_logout_redirect_uri": external_base_url(request) + next,
})
sep = "&" if "?" in end_session else "?"
return RedirectResponse(f"{end_session}{sep}{qs}", status_code=302)
except oidc_provider.OIDCError as err:
logger.debug("RP-initiated logout skipped: %s", err)
return response
@router.get("/auth/oidc/logout")
async def oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
"""OIDC logout (GET) — local session first, then the IdP end-session URL."""
return await _oidc_logout(request, next)
@router.post("/auth/oidc/logout")
async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=local"):
"""OIDC logout (POST)."""
return await _oidc_logout(request, next)
# ═══════════════════════ Admin configuration API ══════════════════════════
async def _require_admin(request: Request, *, write: bool) -> dict:
"""Admin identity: Bearer token (scope read/write) or an admin session.
Session-authenticated writes also need the CSRF header — ``/api/v2`` is
exempted in the middleware, so the check lives here for this router.
"""
auth_header = request.headers.get("authorization") or ""
if auth_header.lower().startswith("bearer "):
user = resolve_bearer_token(auth_header[7:].strip())
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
scopes = user.get("_token_scopes") or ""
need = "write" if write else "read"
if not (has_scope(scopes, need) or has_scope(scopes, "admin")):
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {need}")
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
return user
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Not authenticated")
with get_conn() as conn:
row = conn.execute(
"SELECT id, login, full_name, email, is_admin FROM users WHERE id=?",
(user["id"],),
).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Admin access required")
if write and request.method in ("POST", "PUT", "PATCH", "DELETE"):
cookie = request.cookies.get("csrf_token", "")
header = request.headers.get("X-CSRF-Token", "")
if not cookie or not header or not secrets.compare_digest(cookie, header):
raise HTTPException(status_code=403, detail="CSRF validation failed")
return dict(row)
@router.get("/api/v2/sso/providers")
async def sso_providers(request: Request):
"""Public: what the login page should show (button list + sso_only flag)."""
cfg = _sso_config_or_error()
if not cfg:
return {"providers": [], "sso_only": False}
from app.auth.providers.saml_provider import external_base_url
base = external_base_url(request)
login_path = "/auth/saml/login" if cfg["provider_type"] == "saml" else "/auth/oidc/login"
return {
"providers": [{
"type": cfg["provider_type"],
"name": cfg.get("name") or "Company SSO",
"icon": "🏢",
"login_url": f"{login_path}?next={DEFAULT_NEXT}",
}],
"sso_only": bool(cfg.get("sso_only")),
"base_url": base,
}
@router.get("/api/v2/sso/config")
async def get_sso_config_api(request: Request):
"""Read the current SSO configuration (secrets never returned)."""
await _require_admin(request, write=False)
cfg = _sso_config_or_error()
return sso.public_config_view(cfg)
@router.post("/api/v2/sso/config")
@router.put("/api/v2/sso/config")
async def save_sso_config_api(request: Request):
"""Create/replace the SSO configuration (admin, scope write)."""
admin = await _require_admin(request, write=True)
try:
payload = await request.json()
except Exception as err:
raise HTTPException(status_code=400, detail="Invalid JSON body") from err
try:
saved = sso.save_sso_config(payload, created_by=admin.get("id"))
except sso.SSOConfigError as err:
raise HTTPException(status_code=400, detail=str(err)) from err
from app.services.api_v2_helpers import audit_log
audit_log(admin, "sso.config.save", "sso_config", saved.get("id", 0),
f"provider={saved.get('provider_type')}", request)
return sso.public_config_view(saved)
@router.delete("/api/v2/sso/config")
async def delete_sso_config_api(request: Request):
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
admin = await _require_admin(request, write=True)
removed = sso.delete_sso_config()
from app.services.api_v2_helpers import audit_log
audit_log(admin, "sso.config.disable", "sso_config", 0, "", request)
return {"status": "ok", "disabled": removed}
@router.get("/api/v2/sso/workspaces")
async def sso_workspaces(request: Request):
"""Workspaces available for default assignment / group mapping."""
await _require_admin(request, write=False)
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, owner_id FROM workspaces ORDER BY name"
).fetchall()
cfg = _sso_config_or_error()
return {
"workspaces": [dict(r) for r in rows],
"default_workspace_id": (cfg or {}).get("default_workspace_id"),
"sso_only": bool((cfg or {}).get("sso_only")),
"provisioned_users": sso.provisioned_count(),
}
@router.post("/api/v2/sso/sync")
async def sso_sync(request: Request):
"""Re-apply group → workspace role mapping for every SSO user."""
admin = await _require_admin(request, write=True)
try:
result = sso.force_sync_all_groups()
except sso.SSOProvisioningError as err:
raise HTTPException(status_code=400, detail=str(err)) from err
from app.services.api_v2_helpers import audit_log
audit_log(admin, "sso.sync", "sso_config", 0, str(result), request)
return {"status": "ok", **result}
@router.get("/api/v2/sso/history")
async def sso_history(request: Request, limit: int = 50):
"""Audit trail of SSO login attempts (successes and rejections)."""
await _require_admin(request, write=False)
from app.db import get_conn
limit = max(1, min(int(limit or 50), 200))
with get_conn() as conn:
rows = conn.execute(
"""SELECT h.id, h.user_id, u.login, h.provider_type, h.provider_name,
h.sso_identifier, h.ip_address, h.success, h.error_message,
h.created_at
FROM sso_login_history h LEFT JOIN users u ON u.id = h.user_id
ORDER BY h.id DESC LIMIT ?""",
(limit,),
).fetchall()
out = []
for r in rows:
d = dict(r)
ident = d.get("sso_identifier") or ""
if "|" in ident: # drop the stored group list from the UI payload
d["sso_identifier"] = ident.split("|", 1)[0]
d["success"] = bool(d["success"])
out.append(d)
return {"history": out}
+68
View File
@@ -98,6 +98,74 @@ class PermissionManager:
).fetchone()
return "owner" if owner else "viewer"
# ── SSO (v6.7.0, design §7.2) ─────────────────────────────────────────
def is_sso_only_workspace(self, workspace_id: int | None = None) -> bool:
"""True when that workspace can only be reached through SSO.
FlowDeck keeps a single instance-wide SSO-only switch (design §7.1 /
§4.2): when it is on, local login is refused for every non-admin, so
every workspace on the instance is effectively SSO-only.
``workspace_id`` is accepted to mirror the design's per-workspace API.
"""
from app.services.sso_provisioning import is_sso_only
return is_sso_only()
def _user_auth_method(self) -> str:
with get_conn() as conn:
row = conn.execute(
"SELECT auth_method FROM users WHERE id=?", (self.user_id,)
).fetchone()
return (row["auth_method"] or "local") if row else "local"
def get_sso_roles(
self, user_id: int | None = None, workspace_id: int | None = None
) -> list[str]:
"""Roles granted to that user through SSO group mapping (design §7.2).
SSO grants land in the regular ``workspace_members`` row (the mapping
is re-applied at every SSO login), so the answer is the explicit
membership role of a non-local account — local accounts and users
without an explicit grant (the implicit *viewer* fallback is not an
SSO grant) get ``[]``.
"""
if workspace_id is None:
return []
pm = PermissionManager(int(user_id)) if (user_id and int(user_id) != self.user_id) else self
if pm._user_auth_method() == "local":
return []
with get_conn() as conn:
row = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(int(workspace_id), pm.user_id),
).fetchone()
return [row["role"]] if row else []
def sync_sso_permissions(
self,
user_id: int | None,
sso_groups: list[str],
workspace_id: int | None = None,
) -> list[int]:
"""Re-apply the group → workspace role mapping (design §7.2).
Delegates to ``sso_provisioning.sync_sso_groups`` (the single source
of truth used at login and by ``POST /api/v2/sso/sync``). Returns the
touched workspace ids, narrowed to ``workspace_id`` when given.
"""
from app.services.sso_provisioning import get_sso_config, sync_sso_groups
cfg = get_sso_config()
if not cfg:
return []
touched = sync_sso_groups(int(user_id or self.user_id), list(sso_groups or []), cfg)
if workspace_id is not None:
touched = [w for w in touched if int(w) == int(workspace_id)]
if touched:
self.invalidate()
return touched
def can_read(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in READ_ROLES
+783
View File
@@ -0,0 +1,783 @@
"""v6.7.0 — SSO provisioning: config store, auto-provisioning, group mapping.
Single source of truth for the SSO configuration (``sso_config`` table, with
an ``SSO_*`` environment fallback for bootstrap installs) and for what
happens when an IdP says "this is [email protected]":
1. resolve the local account (by email → merge, else by login),
2. create it when ``auto_provision`` is on, else reject with an audit row,
3. sync attributes + map SSO groups to workspace roles,
4. hand back the user dict so the caller can mint a session.
Secrets at rest: ``client_secret`` and the generated SP private key are
encrypted with a Fernet key derived from ``app_secret_key``.
"""
from __future__ import annotations
import hashlib
import json
import logging
import secrets
import time
import urllib.parse
from app.config import settings
logger = logging.getLogger(__name__)
VALID_PROVIDER_TYPES = ("saml", "oidc")
class SSOConfigError(Exception):
"""Invalid SSO configuration payload (message shown to the admin)."""
class SSOProvisioningError(Exception):
"""A login was rejected (no local account, missing attributes…)."""
# ── Secrets at rest ────────────────────────────────────────────────────────
def _fernet():
from cryptography.fernet import Fernet
key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest()
import base64
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_secret(value: str) -> str:
if not value:
return ""
return _fernet().encrypt(value.encode()).decode()
def decrypt_secret(value: str) -> str:
if not value:
return ""
try:
return _fernet().decrypt(value.encode()).decode()
except Exception:
return "" # key rotated / not ours — treat as unset
# ── Config store ───────────────────────────────────────────────────────────
def _default_mapping(provider_type: str) -> dict:
if provider_type == "oidc":
from app.auth.providers.oidc_provider import DEFAULT_OIDC_MAPPING
return dict(DEFAULT_OIDC_MAPPING)
from app.auth.providers.saml_provider import DEFAULT_SAML_MAPPING
return dict(DEFAULT_SAML_MAPPING)
def _env_config() -> dict | None:
"""Bootstrap config from ``SSO_*`` env vars (design doc §3.3).
Only used when the table holds no active row — the Settings UI always
wins once an admin saved a configuration.
"""
provider_type = (settings.sso_provider or "").strip().lower()
if provider_type not in VALID_PROVIDER_TYPES:
return None
cfg = {
"id": 0,
"provider_type": provider_type,
"name": settings.sso_name or "Company SSO",
"entity_id": settings.sso_entity_id,
"sso_url": settings.sso_sso_url,
"slo_url": settings.sso_slo_url,
"x509_certificate": settings.sso_x509_certificate,
"issuer_url": settings.sso_issuer_url,
"client_id": settings.sso_client_id,
"client_secret": settings.sso_client_secret,
"scope": settings.sso_scope,
"attribute_mapping": settings.sso_attribute_mapping,
"groups_mapping": settings.sso_groups_mapping,
"auto_provision": int(settings.sso_auto_provision),
"sso_only": int(settings.sso_only),
"sign_requests": int(settings.sso_sign_requests),
"default_workspace_id": settings.sso_default_workspace_id,
"sp_private_key": "",
"sp_certificate": "",
"workspace_id": None,
"active": 1,
"_source": "env",
}
if provider_type == "saml" and (not cfg["entity_id"] or not cfg["sso_url"]):
return None
if provider_type == "oidc" and (not cfg["issuer_url"] or not cfg["client_id"]):
return None
return cfg
def get_sso_config(require_active: bool = True) -> dict | None:
"""Active SSO config as a dict (DB row, else env fallback)."""
row = _raw_row(require_active=require_active)
if row:
cfg = dict(row)
cfg["_source"] = "db"
return cfg
if not require_active:
return _env_config()
return _env_config()
def _raw_row(require_active: bool = True) -> dict | None:
"""Raw ``sso_config`` row (``client_secret`` still encrypted, ``_source`` unset)."""
from app.db import get_conn
try:
with get_conn() as conn:
where = "WHERE active=1" if require_active else ""
row = conn.execute(
f"SELECT * FROM sso_config {where} ORDER BY id LIMIT 1"
).fetchone()
except Exception: # table missing (very old install) → env only
return None
return dict(row) if row else None
def client_secret_value(cfg: dict) -> str:
"""Plaintext OIDC client secret (decrypted for DB rows, raw for env)."""
raw = (cfg or {}).get("client_secret") or ""
if not raw:
return ""
if (cfg or {}).get("_source") == "env":
return raw
return decrypt_secret(raw)
def _json_field(value, fallback):
if isinstance(value, (dict, list)):
return value
try:
parsed = json.loads(value or "")
return parsed if isinstance(parsed, type(fallback)) else fallback
except Exception:
return fallback
def _strict_json(value, expected, field: str):
"""Parse a payload field and reject wrong shapes (before normalize,
which would otherwise silently coerce ``"[]"`` → ``{}``)."""
if value is None or value == "":
return expected()
if isinstance(value, (dict, list)):
parsed = value
else:
try:
parsed = json.loads(value)
except Exception as exc:
raise SSOConfigError(f"{field} must be valid JSON") from exc
if not isinstance(parsed, expected):
kind = "object" if expected is dict else "array"
raise SSOConfigError(f"{field} must be a JSON {kind}")
return parsed
def normalize_config(cfg: dict) -> dict:
"""Parse JSON columns + fill defaults (single place for every consumer)."""
out = dict(cfg)
out["attribute_mapping"] = _json_field(out.get("attribute_mapping"), {})
out["groups_mapping"] = _json_field(out.get("groups_mapping"), [])
if not out["attribute_mapping"]:
out["attribute_mapping"] = _default_mapping(out.get("provider_type", "saml"))
for key in ("entity_id", "sso_url", "slo_url", "x509_certificate", "issuer_url",
"client_id", "client_secret", "scope", "name"):
out[key] = (out.get(key) or "").strip()
for key in ("auto_provision", "sso_only", "sign_requests", "active"):
out[key] = int(out.get(key) or 0)
return out
def validate_config_payload(payload: dict) -> dict:
"""Validate + sanitize an admin payload. Raises ``SSOConfigError``."""
provider_type = str(payload.get("provider_type") or "").strip().lower()
if provider_type not in VALID_PROVIDER_TYPES:
raise SSOConfigError(f"provider_type must be one of {', '.join(VALID_PROVIDER_TYPES)}")
payload = dict(payload)
payload["attribute_mapping"] = _strict_json(
payload.get("attribute_mapping"), dict, "attribute_mapping"
)
payload["groups_mapping"] = _strict_json(
payload.get("groups_mapping"), list, "groups_mapping"
)
cfg = normalize_config({**payload, "provider_type": provider_type})
if provider_type == "saml":
for field in ("entity_id", "sso_url"):
if not cfg[field]:
raise SSOConfigError(f"SAML requires '{field}'")
for field, url in (("sso_url", cfg["sso_url"]), ("slo_url", cfg["slo_url"])):
if url and not url.startswith(("http://", "https://")):
raise SSOConfigError(f"'{field}' must be an http(s) URL")
cert = cfg["x509_certificate"].strip()
if cert and "BEGIN CERTIFICATE" not in cert:
raise SSOConfigError("x509_certificate must be a PEM certificate")
if not cert:
raise SSOConfigError("SAML requires the IdP signing certificate (x509_certificate)")
cfg["x509_certificate"] = cert
else:
if not cfg["issuer_url"] or not cfg["client_id"]:
raise SSOConfigError("OIDC requires 'issuer_url' and 'client_id'")
if not cfg["issuer_url"].startswith(("http://", "https://")):
raise SSOConfigError("'issuer_url' must be an http(s) URL")
if not isinstance(cfg["attribute_mapping"], dict):
raise SSOConfigError("attribute_mapping must be a JSON object")
if not isinstance(cfg["groups_mapping"], list):
raise SSOConfigError("groups_mapping must be a JSON array")
for entry in cfg["groups_mapping"]:
if not isinstance(entry, dict) or "sso_group" not in entry:
raise SSOConfigError("groups_mapping entries need at least an 'sso_group' key")
ws = cfg.get("default_workspace_id")
cfg["default_workspace_id"] = int(ws) if ws not in (None, "", 0) else None
return cfg
def save_sso_config(payload: dict, created_by: int | None = None) -> dict:
"""Create or replace the single SSO configuration (idempotent)."""
from app.db import get_conn
cfg = validate_config_payload(payload)
columns = {
"provider_type": cfg["provider_type"],
"name": cfg.get("name") or "Company SSO",
"entity_id": cfg["entity_id"],
"sso_url": cfg["sso_url"],
"slo_url": cfg["slo_url"],
"x509_certificate": cfg["x509_certificate"],
"issuer_url": cfg["issuer_url"],
"client_id": cfg["client_id"],
"scope": cfg.get("scope") or "openid profile email",
"attribute_mapping": json.dumps(cfg["attribute_mapping"]),
"groups_mapping": json.dumps(cfg["groups_mapping"]),
"auto_provision": cfg["auto_provision"],
"sso_only": cfg["sso_only"],
"sign_requests": cfg["sign_requests"],
"default_workspace_id": cfg["default_workspace_id"],
"active": 1,
"updated_at": str(int(time.time())),
}
# Secret handling: a blank incoming secret keeps the stored one (the raw
# row still holds the Fernet blob — never re-encrypt a decrypted value).
existing = _raw_row() or {}
if "client_secret" in cfg:
incoming = str(cfg.get("client_secret") or "").strip()
if incoming:
columns["client_secret"] = encrypt_secret(incoming)
else:
columns["client_secret"] = existing.get("client_secret") or ""
# SP keypair: keep an existing one, generate one for SAML if missing.
sp_key = existing.get("sp_private_key") or ""
sp_cert = existing.get("sp_certificate") or ""
if cfg["provider_type"] == "saml" and not (sp_key and sp_cert):
sp_key, sp_cert = generate_sp_keypair()
columns["sp_private_key"] = sp_key
columns["sp_certificate"] = sp_cert
if created_by:
columns["created_by"] = created_by
with get_conn() as conn:
row = conn.execute("SELECT id FROM sso_config ORDER BY id LIMIT 1").fetchone()
if row:
sets = ", ".join(f"{k}=?" for k in columns)
conn.execute(f"UPDATE sso_config SET {sets} WHERE id=?", (*columns.values(), row["id"]))
cfg_id = row["id"]
else:
keys = ", ".join(columns)
placeholders = ", ".join("?" for _ in columns)
cur = conn.execute(
f"INSERT INTO sso_config ({keys}) VALUES ({placeholders})", tuple(columns.values())
)
cfg_id = cur.lastrowid
conn.commit()
saved = get_sso_config(require_active=False)
saved["id"] = cfg_id
return saved
def delete_sso_config() -> bool:
"""Disable SSO entirely (local logins keep working)."""
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute("UPDATE sso_config SET active=0, updated_at=?", (str(int(time.time())),))
conn.commit()
return cur.rowcount > 0
def public_config_view(cfg: dict | None) -> dict:
"""Config for the admin UI — secrets never leave the server."""
if not cfg:
return {"configured": False}
cfg = normalize_config(cfg)
return {
"configured": True,
"id": cfg.get("id"),
"source": cfg.get("_source", "db"),
"provider_type": cfg["provider_type"],
"name": cfg.get("name") or "Company SSO",
"entity_id": cfg["entity_id"],
"sso_url": cfg["sso_url"],
"slo_url": cfg["slo_url"],
"x509_certificate": cfg["x509_certificate"],
"issuer_url": cfg["issuer_url"],
"client_id": cfg["client_id"],
"client_secret_set": bool(client_secret_value(cfg)),
"scope": cfg.get("scope") or "openid profile email",
"attribute_mapping": cfg["attribute_mapping"],
"groups_mapping": cfg["groups_mapping"],
"auto_provision": bool(cfg["auto_provision"]),
"sso_only": bool(cfg["sso_only"]),
"sign_requests": bool(cfg["sign_requests"]),
"default_workspace_id": cfg.get("default_workspace_id"),
"sp_certificate": cfg.get("sp_certificate") or "",
"active": bool(cfg.get("active", 1)),
"provisioned_users": provisioned_count(),
}
def is_sso_only(cfg: dict | None = None) -> bool:
"""True when local login must be refused (design §7.1 / §4.2)."""
cfg = cfg if cfg is not None else get_sso_config()
return bool(cfg and normalize_config(cfg).get("sso_only"))
def provisioned_count() -> int:
from app.db import get_conn
try:
with get_conn() as conn:
row = conn.execute(
"SELECT COUNT(*) AS n FROM users WHERE auth_method IN ('saml','oidc')"
).fetchone()
return int(row["n"] if row is not None else 0)
except Exception:
return 0
def generate_sp_keypair() -> tuple[str, str]:
"""RSA-2048 key + self-signed certificate for the SP (metadata + signing)."""
import datetime
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, f"flowdeck-sp-{secrets.token_hex(4)}"),
])
now = datetime.datetime.now(datetime.UTC)
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(key, hashes.SHA256())
)
priv = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode()
public = cert.public_bytes(serialization.Encoding.PEM).decode()
return priv, public
def ensure_sp_keypair(cfg: dict) -> dict:
"""Guarantee the SAML config carries an SP keypair (generates + persists)."""
if cfg.get("provider_type") != "saml":
return cfg
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
return cfg
from app.db import get_conn
priv, cert = generate_sp_keypair()
try:
with get_conn() as conn:
conn.execute(
"UPDATE sso_config SET sp_private_key=?, sp_certificate=? WHERE id=?",
(priv, cert, cfg.get("id")),
)
conn.commit()
except Exception as err: # env-sourced config has no row to update
logger.debug("SP keypair not persisted: %s", err)
cfg = dict(cfg)
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
return cfg
cfg = dict(cfg)
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
return cfg
# ── Audit ──────────────────────────────────────────────────────────────────
def log_sso_login(
*,
user_id: int | None,
provider_type: str,
provider_name: str,
identifier: str,
request,
success: bool,
error: str = "",
) -> None:
"""Write one ``sso_login_history`` row (failures included — design §5.2)."""
ip = request.client.host if request is not None and getattr(request, "client", None) else ""
ua = (request.headers.get("user-agent", "") if request is not None else "")[:500]
try:
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"""INSERT INTO sso_login_history
(user_id, provider_type, provider_name, sso_identifier,
ip_address, user_agent, success, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, provider_type, provider_name, (identifier or "")[:320], ip, ua,
1 if success else 0, (error or "")[:500]),
)
conn.commit()
except Exception as err: # audit must never break the login path
logger.warning("sso_login_history write failed: %s", err)
# ── Group mapping ──────────────────────────────────────────────────────────
def sync_sso_groups(user_id: int, sso_groups: list[str], cfg: dict) -> list[int]:
"""Apply ``groups_mapping`` → ``workspace_members.role``. Returns touched ws ids."""
from app.db import get_conn
cfg = normalize_config(cfg)
mappings = cfg.get("groups_mapping") or []
wanted = {g.strip().lower() for g in sso_groups if g and str(g).strip()}
touched: list[int] = []
with get_conn() as conn:
for entry in mappings:
group_name = str(entry.get("sso_group") or "").strip().lower()
if not group_name or group_name not in wanted:
continue
ws_id = entry.get("workspace_id") or cfg.get("default_workspace_id")
if not ws_id:
continue
role = str(entry.get("workspace_role") or "editor").strip() or "editor"
if role not in ("owner", "admin", "editor", "viewer"):
role = "editor"
conn.execute(
"""INSERT INTO workspace_members (workspace_id, user_id, role)
VALUES (?, ?, ?)
ON CONFLICT(workspace_id, user_id) DO UPDATE SET role=excluded.role""",
(int(ws_id), user_id, role),
)
touched.append(int(ws_id))
# Default workspace: every SSO user lands there as a plain member.
default_ws = cfg.get("default_workspace_id")
if default_ws:
conn.execute(
"""INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role)
VALUES (?, ?, 'editor')""",
(int(default_ws), user_id),
)
if int(default_ws) not in touched:
touched.append(int(default_ws))
conn.commit()
return touched
def force_sync_all_groups() -> dict:
"""Re-apply the group mapping for every SSO user (``POST /api/v2/sso/sync``)."""
from app.db import get_conn
cfg = get_sso_config()
if not cfg:
raise SSOProvisioningError("No SSO configuration")
cfg = normalize_config(cfg)
mapping = cfg.get("attribute_mapping") or {}
groups_source = mapping.get("groups", "groups")
updated = 0
with get_conn() as conn:
rows = conn.execute(
"SELECT id, auth_method FROM users WHERE auth_method IN ('saml','oidc')"
).fetchall()
for row in rows:
groups = _stored_groups(row["id"], groups_source, cfg)
if sync_sso_groups(row["id"], groups, cfg):
updated += 1
return {"users": len(rows), "updated": updated}
def _stored_groups(user_id: int, source: str, cfg: dict) -> list[str]:
"""Groups seen at the last login of that user (stored in attribute sync)."""
try:
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT sso_identifier FROM sso_login_history "
"WHERE user_id=? AND success=1 ORDER BY id DESC LIMIT 1",
(user_id,),
).fetchone()
if not row or not row["sso_identifier"]:
return []
raw = row["sso_identifier"]
if "|" in raw:
ident, _, groups_json = raw.partition("|")
groups = json.loads(groups_json or "[]")
return [str(g) for g in groups] if isinstance(groups, list) else []
return []
except Exception:
return []
# ── Auto-provisioning ──────────────────────────────────────────────────────
def _unique_login(conn, base: str) -> str:
candidate = base
n = 1
while conn.execute("SELECT 1 FROM users WHERE login=?", (candidate,)).fetchone():
n += 1
candidate = f"{base}_{n}"
return candidate
def identity_from_saml(identity, cfg: dict) -> dict:
"""Apply the SAML attribute mapping to a validated assertion."""
cfg = normalize_config(cfg)
mapping = cfg.get("attribute_mapping") or {}
out = {
"login": identity.resolve(mapping.get("login", "nameid")),
"email": identity.resolve(mapping.get("email", "nameid")),
"full_name": identity.resolve(mapping.get("full_name", "displayName")),
"avatar_url": identity.resolve(mapping.get("avatar_url", "avatar")),
"name_id": identity.name_id,
}
groups = identity.resolve(mapping.get("groups", "groups"))
if groups:
# Multi-valued SAML attribute: take every value of the resolved source.
source = mapping.get("groups", "groups")
values = identity.attributes.get(source) or identity.friendly_attributes.get(source) or [groups]
out["groups"] = [str(v).strip() for v in values if v and str(v).strip()]
else:
out["groups"] = []
out["email"] = (out["email"] or "").strip().lower()
if "@" not in out["email"]:
# NameID may be a persistent opaque id — fall back to login when it is
# an email, otherwise leave empty (login will carry the identity).
out["email"] = out["email"] if "@" in (out["login"] or "") else ""
if not out["full_name"]:
out["full_name"] = out["email"] or out["login"]
out["login"] = out["login"] or out["email"] or f"sso_{identity.name_id[:32]}"
return out
def handle_sso_login(identity: dict, *, provider_type: str, cfg: dict, request) -> dict:
"""Resolve/create the local user for an SSO identity. Returns the user dict.
Raises ``SSOProvisioningError`` when the login must be refused (the
caller writes the audit row).
"""
from app.db import get_conn
cfg = normalize_config(cfg)
email = (identity.get("email") or "").strip().lower()
login_hint = (identity.get("login") or "").strip()
if not email and not login_hint:
raise SSOProvisioningError(
"SSO assertion carries no usable email/login — check the attribute mapping"
)
with get_conn() as conn:
user = None
if email:
user = conn.execute(
"SELECT * FROM users WHERE lower(email)=? AND email!='' ORDER BY id LIMIT 1",
(email,),
).fetchone()
if not user and login_hint:
user = conn.execute("SELECT * FROM users WHERE login=?", (login_hint,)).fetchone()
if user:
# §7.1 — email match → merge: the existing account is reused and
# tagged with the SSO method (no duplicate account).
updates, params = [], []
if identity.get("full_name"):
updates.append("full_name=?")
params.append(identity["full_name"])
if email:
updates.append("email=?")
params.append(email)
if identity.get("avatar_url"):
updates.append("avatar_url=?")
params.append(identity["avatar_url"])
updates.append("auth_method=?")
params.append(provider_type)
updates.append("last_login=?")
params.append(str(time.time()))
params.append(user["id"])
conn.execute(f"UPDATE users SET {', '.join(updates)} WHERE id=?", params)
conn.commit()
row = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
else:
if not cfg.get("auto_provision"):
raise SSOProvisioningError(
"No local account for this SSO identity and auto-provisioning is disabled"
)
base_login = login_hint or email
login = _unique_login(conn, base_login)
conn.execute(
"""INSERT INTO users
(login, full_name, email, avatar_url, auth_method, is_admin, last_login)
VALUES (?, ?, ?, ?, ?, 0, ?)""",
(
login,
identity.get("full_name") or email or login,
email,
identity.get("avatar_url") or "",
provider_type,
str(time.time()),
),
)
conn.commit()
row = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
if not row:
raise SSOProvisioningError("Could not create or load the SSO user")
user_dict = dict(row)
sync_sso_groups(user_dict["id"], identity.get("groups") or [], cfg)
return user_dict
def sso_identifier_field(identity: dict) -> str:
"""Audit identifier: ``nameid|["groups",...]`` (groups kept for re-sync)."""
ident = identity.get("name_id") or identity.get("email") or identity.get("login") or ""
groups = identity.get("groups") or []
if groups:
return f"{ident}|{json.dumps(groups)}"
return ident
def safe_next_path(candidate: str | None) -> str:
"""Sanitize the post-login redirect target (open-redirect guard)."""
if not candidate:
return "/workspaces"
candidate = str(candidate)
if not candidate.startswith("/") or candidate.startswith("//"):
return "/workspaces"
parsed = urllib.parse.urlsplit(candidate)
if parsed.scheme or parsed.netloc:
return "/workspaces"
return candidate
# ── Anti-replay request store ──────────────────────────────────────────────
REQUEST_TTL_SECONDS = 600 # AuthnRequest / OIDC state lifetime
def create_request(kind: str, *, request_id: str, relay_state: str = "",
code_verifier: str = "", next_path: str = "/workspaces") -> None:
"""Store a single-use SSO request (AuthnRequest id / OIDC state)."""
from app.db import get_conn
purge_stale_requests()
with get_conn() as conn:
conn.execute(
"""INSERT OR REPLACE INTO sso_requests
(id, kind, relay_state, code_verifier, next_path, used, created_at)
VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)""",
(request_id, kind, relay_state, code_verifier, safe_next_path(next_path)),
)
conn.commit()
def consume_request(kind: str, request_id: str, relay_state: str = "") -> dict | None:
"""Atomically consume a request. Returns the row, or None (replay/unknown)."""
if not request_id:
return None
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
(f"-{REQUEST_TTL_SECONDS} seconds",),
)
row = conn.execute(
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
(request_id, kind),
).fetchone()
if not row:
return None
if relay_state and row["relay_state"] and not secrets.compare_digest(
row["relay_state"], relay_state
):
return None
cur = conn.execute(
"UPDATE sso_requests SET used=1 WHERE id=? AND used=0", (request_id,)
)
conn.commit()
if cur.rowcount != 1:
return None
return dict(row)
def peek_request(kind: str, request_id: str) -> dict | None:
"""Read a request without consuming it (CSRF check before heavy validation)."""
if not request_id:
return None
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
(request_id, kind),
).fetchone()
return dict(row) if row else None
def was_consumed(kind: str, request_id: str) -> bool:
"""True when this single-use request id was already spent (replay)."""
if not request_id:
return False
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT 1 FROM sso_requests WHERE id=? AND kind=? AND used=1",
(request_id, kind),
).fetchone()
return row is not None
def purge_stale_requests() -> None:
try:
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
(f"-{REQUEST_TTL_SECONDS} seconds",),
)
conn.commit()
except Exception:
pass
+254
View File
@@ -153,6 +153,7 @@
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-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-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
</div>
</template>
</div>
@@ -870,6 +871,141 @@
</div>
</div>
<!-- Admin: SSO / Enterprise (v6.7.0) -->
<div x-show="activeSection==='admin-sso'">
<h2>SSO / Enterprise</h2>
<p class="section-desc">Connectez un IdP d'entreprise (SAML 2.0 ou OpenID Connect). Les comptes sont créés au premier login et les groupes de l'IdP deviennent des rôles workspace.</p>
<!-- Status -->
<div class="llm-summary" x-show="ssoCfg.id || ssoSource==='env'">
<div class="llm-summary-icon">🔐</div>
<div class="llm-summary-body">
<div class="llm-summary-title" x-text="ssoStatusTitle()"></div>
<div class="llm-summary-desc" x-text="ssoStatusDesc()"></div>
</div>
<div class="llm-summary-side">
<span class="llm-badge" :class="ssoSource==='env' ? 'warn' : 'ok'" x-text="ssoSource==='env' ? 'via .env' : 'Actif'"></span>
</div>
</div>
<!-- Provider -->
<div class="setting-group">
<h3>Fournisseur</h3>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
<select class="settings-input" x-model="ssoCfg.provider_type" style="max-width:220px;">
<option value="saml">SAML 2.0</option>
<option value="oidc">OpenID Connect</option>
</select>
<input type="text" class="settings-input" placeholder="Nom affiché (ex : Company SSO)" x-model="ssoCfg.name" style="max-width:300px;">
</div>
</div>
<!-- SAML -->
<div class="setting-group" x-show="ssoCfg.provider_type==='saml'">
<h3>Configuration SAML</h3>
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Entity ID (IdP)</div>
<input type="text" class="settings-input" x-model="ssoCfg.entity_id" placeholder="https://idp.example.com/saml/metadata" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Sign-On URL</div>
<input type="text" class="settings-input" x-model="ssoCfg.sso_url" placeholder="https://idp.example.com/saml/sso" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Single Logout URL (optionnel)</div>
<input type="text" class="settings-input" x-model="ssoCfg.slo_url" placeholder="https://idp.example.com/saml/slo" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Certificat de signature de l'IdP (PEM)</div>
<textarea class="settings-input" rows="6" x-model="ssoCfg.x509_certificate" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Mapping des attributs (JSON)</div>
<textarea class="settings-input" rows="4" x-model="ssoAttrJson" style="max-width:560px;font-family:var(--font-mono);font-size:12px;" placeholder='{"email":"email","full_name":"displayName","groups":"groups"}'></textarea>
<label style="display:flex;gap:8px;align-items:center;font-size:13px;margin-top:12px;">
<input type="checkbox" x-model="ssoCfg.sign_requests"> Signer les AuthnRequests / LogoutRequests
</label>
<div style="margin-top:14px;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;font-size:13px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<span>SP metadata à donner à l'IdP :</span>
<code style="font-size:12px;user-select:all;" x-text="ssoMetadataUrl()"></code>
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" @click="copySsoMetadata()">Copier</button>
</div>
</div>
<!-- OIDC -->
<div class="setting-group" x-show="ssoCfg.provider_type==='oidc'">
<h3>Configuration OpenID Connect</h3>
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;">Issuer URL</div>
<input type="text" class="settings-input" x-model="ssoCfg.issuer_url" placeholder="https://idp.example.com/realms/flowdeck" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client ID</div>
<input type="text" class="settings-input" x-model="ssoCfg.client_id" placeholder="flowdeck" style="max-width:560px;">
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Client Secret</div>
<div class="pw-wrapper" style="max-width:560px;">
<input type="password" class="settings-input" x-model="ssoCfg.client_secret"
:placeholder="ssoCfg.client_secret_set ? 'Déjà enregistré — laisser vide pour conserver' : 'Client secret'"
style="padding-right:36px;">
</div>
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Scope</div>
<input type="text" class="settings-input" x-model="ssoCfg.scope" placeholder="openid profile email" style="max-width:560px;">
<p style="font-size:12px;color:var(--text-dim);margin-top:10px;">L'échange du code utilise PKCE (S256) et le nonce est vérifié à chaque login.</p>
</div>
<!-- Provisioning -->
<div class="setting-group">
<h3>Provisioning &amp; accès</h3>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
<input type="checkbox" x-model="ssoCfg.auto_provision" style="margin-top:3px;">
<span><b>Auto-provision</b> — créer le compte au premier login (sinon seuls les comptes existants passent)</span>
</label>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;margin-bottom:8px;">
<input type="checkbox" x-model="ssoCfg.sso_only" style="margin-top:3px;">
<span><b>SSO only</b> — désactiver le login local (les administrateurs gardent le leur)</span>
</label>
<div style="font-size:12px;color:var(--text-dim);margin:12px 0 6px;">Espace par défaut des utilisateurs SSO</div>
<select class="settings-input" x-model="ssoCfg.default_workspace_id" style="max-width:320px;">
<option value="">— Aucun —</option>
<template x-for="w in ssoWorkspaces" :key="w.id">
<option :value="w.id" x-text="w.name"></option>
</template>
</select>
</div>
<!-- Group mapping -->
<div class="setting-group">
<h3>Groupes IdP → rôles workspace</h3>
<div class="table-wrap">
<table class="admin-table">
<thead><tr><th>Groupe SSO</th><th>Rôle</th><th>Espace</th><th></th></tr></thead>
<tbody>
<template x-for="(g, i) in ssoGroups" :key="i">
<tr>
<td><input class="settings-input" x-model="g.sso_group" placeholder="FlowDeck Admins" style="min-width:170px;"></td>
<td>
<select class="settings-input" x-model="g.workspace_role" style="min-width:110px;">
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="viewer">viewer</option>
</select>
</td>
<td>
<select class="settings-input" x-model="g.workspace_id" style="min-width:150px;">
<option value="">Espace par défaut</option>
<template x-for="w in ssoWorkspaces" :key="w.id">
<option :value="w.id" x-text="w.name"></option>
</template>
</select>
</td>
<td><button class="btn btn-secondary" style="font-size:12px;padding:4px 8px;" @click="removeSsoGroup(i)">✕</button></td>
</tr>
</template>
<tr x-show="!ssoGroups.length"><td colspan="4" style="text-align:center;color:var(--text-dim);padding:14px;">Aucune règle — les groupes de l'IdP ne modifient aucun rôle.</td></tr>
</tbody>
</table>
</div>
<button class="btn btn-secondary" style="font-size:13px;margin-top:10px;" @click="addSsoGroup()">+ Ajouter une règle</button>
</div>
<!-- Actions -->
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px;">
<button class="btn btn-primary" style="font-size:13px;" @click="saveSsoConfig()" :disabled="ssoSaving" x-text="ssoSaving ? 'Enregistrement…' : 'Enregistrer la configuration SSO'"></button>
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="disableSso()">Désactiver le SSO</button>
<button class="btn btn-secondary" style="font-size:13px;" x-show="ssoCfg.id" @click="syncSsoGroups()">Re-sync des groupes</button>
<span x-show="ssoMsg" x-text="ssoMsg" style="font-size:13px;" :style="{color: ssoOk ? 'var(--toast-success-bg, #00CC66)' : 'var(--danger)'}"></span>
</div>
<p x-show="ssoSource==='env'" class="section-desc" style="margin-top:10px;">Configuration actuellement lue depuis les variables <code>SSO_*</code> du .env — un enregistrement ici la remplace.</p>
</div>
<!-- Agent & IA: per-user provider keys + admin global default -->
<div x-show="activeSection==='llm'">
<h2>Agent &amp; IA</h2>
@@ -1115,6 +1251,17 @@ function settingsInit() {
backupMsg: '',
backupOk: false,
// v6.7.0 SSO / Enterprise (admin)
ssoCfg: {id:null, provider_type:'saml', name:'', entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, scope:'openid profile email', attribute_mapping:{}, auto_provision:true, sso_only:false, sign_requests:false, default_workspace_id:''},
ssoAttrJson: '{}',
ssoGroups: [],
ssoWorkspaces: [],
ssoSource: 'db',
ssoProvisioned: 0,
ssoSaving: false,
ssoMsg: '',
ssoOk: false,
async init() {
await this.loadTags();
await this.loadGiteaStatus();
@@ -1935,6 +2082,113 @@ function settingsInit() {
} catch(e) { this.clipTestMsg = 'Erreur réseau'; }
finally { this.clipTesting = false; }
},
// ── v6.7.0 SSO / Enterprise (admin) ──
ssoStatusTitle() {
if (!this.ssoCfg.id && this.ssoSource !== 'env') return '';
if (this.ssoSource === 'env') return 'Configuration lue depuis le .env (SSO_*)';
return 'SSO actif — ' + (this.ssoCfg.name || (this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect'));
},
ssoStatusDesc() {
var kind = this.ssoCfg.provider_type === 'saml' ? 'SAML 2.0' : 'OpenID Connect';
return kind + ' · ' + this.ssoProvisioned + ' utilisateur(s) provisionné(s) · login local ' + (this.ssoCfg.sso_only ? 'DÉSACTIVÉ (SSO only)' : 'autorisé');
},
ssoMetadataUrl() { return window.location.origin + '/auth/saml/metadata'; },
async copySsoMetadata() {
try {
await navigator.clipboard.writeText(this.ssoMetadataUrl());
if (typeof toast === 'function') toast('SP metadata URL copiée');
} catch(e) {}
},
async loadSsoConfig() {
this.ssoMsg = '';
try {
var r = await fetch('/api/v2/sso/config', {credentials:'same-origin'});
if (r.ok) {
var d = await r.json();
this.ssoSource = d.source || 'db';
this.ssoProvisioned = d.provisioned_users || 0;
if (d.configured === false) {
this.ssoCfg = Object.assign({}, this.ssoCfg, {id:null, entity_id:'', sso_url:'', slo_url:'', x509_certificate:'', issuer_url:'', client_id:'', client_secret:'', client_secret_set:false, name:''});
} else {
this.ssoCfg = {
id: d.id || null,
provider_type: d.provider_type || 'saml',
name: d.name || '',
entity_id: d.entity_id || '',
sso_url: d.sso_url || '',
slo_url: d.slo_url || '',
x509_certificate: d.x509_certificate || '',
issuer_url: d.issuer_url || '',
client_id: d.client_id || '',
client_secret: '',
client_secret_set: !!d.client_secret_set,
scope: d.scope || 'openid profile email',
attribute_mapping: d.attribute_mapping || {},
auto_provision: !!d.auto_provision,
sso_only: !!d.sso_only,
sign_requests: !!d.sign_requests,
default_workspace_id: d.default_workspace_id || ''
};
this.ssoAttrJson = JSON.stringify(d.attribute_mapping || {}, null, 2);
this.ssoGroups = (d.groups_mapping || []).map(function(g){ return {sso_group: g.sso_group || '', workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id || ''}; });
}
}
} catch(e) {}
try {
var w = await fetch('/api/v2/sso/workspaces', {credentials:'same-origin'});
if (w.ok) {
var wd = await w.json();
this.ssoWorkspaces = wd.workspaces || [];
this.ssoProvisioned = wd.provisioned_users || this.ssoProvisioned;
}
} catch(e) {}
},
async saveSsoConfig() {
this.ssoMsg = '';
var amap = {};
try { amap = JSON.parse(this.ssoAttrJson || '{}'); }
catch(e) { this.ssoMsg = 'Attribute mapping : JSON invalide'; this.ssoOk = false; return; }
if (amap === null || typeof amap !== 'object' || Array.isArray(amap)) {
this.ssoMsg = 'Attribute mapping : doit être un objet JSON'; this.ssoOk = false; return;
}
this.ssoSaving = true;
var payload = Object.assign({}, this.ssoCfg, {
attribute_mapping: amap,
groups_mapping: this.ssoGroups.map(function(g){ return {sso_group: (g.sso_group||'').trim(), workspace_role: g.workspace_role || 'editor', workspace_id: g.workspace_id ? parseInt(g.workspace_id, 10) : null}; }),
auto_provision: this.ssoCfg.auto_provision ? 1 : 0,
sso_only: this.ssoCfg.sso_only ? 1 : 0,
sign_requests: this.ssoCfg.sign_requests ? 1 : 0,
default_workspace_id: this.ssoCfg.default_workspace_id ? parseInt(this.ssoCfg.default_workspace_id, 10) : null
});
delete payload.id;
try {
var r = await this.adminFetch('/api/v2/sso/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
var d = null; try { d = await r.json(); } catch(e) {}
if (!r.ok) { this.ssoMsg = (d && (d.detail || d.error)) || ('Erreur ' + r.status); this.ssoOk = false; }
else { this.ssoMsg = 'Configuration enregistrée'; this.ssoOk = true; await this.loadSsoConfig(); }
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
finally { this.ssoSaving = false; }
},
async disableSso() {
if (!confirm('Désactiver le SSO ? Les utilisateurs pourront de nouveau se connecter localement.')) return;
this.ssoMsg = '';
try {
var r = await this.adminFetch('/api/v2/sso/config', {method:'DELETE'});
if (r.ok) { this.ssoMsg = 'SSO désactivé'; this.ssoOk = true; await this.loadSsoConfig(); }
else { this.ssoMsg = 'Erreur ' + r.status; this.ssoOk = false; }
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
},
async syncSsoGroups() {
this.ssoMsg = '';
try {
var r = await this.adminFetch('/api/v2/sso/sync', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
var d = null; try { d = await r.json(); } catch(e) {}
if (r.ok) { this.ssoMsg = 'Groupes re-synchronisés — ' + (d && d.updated || 0) + '/' + (d && d.users || 0) + ' utilisateur(s)'; this.ssoOk = true; await this.loadSsoConfig(); }
else { this.ssoMsg = (d && d.detail) || ('Erreur ' + r.status); this.ssoOk = false; }
} catch(e) { this.ssoMsg = 'Erreur réseau'; this.ssoOk = false; }
},
addSsoGroup() { this.ssoGroups.push({sso_group:'', workspace_role:'editor', workspace_id: this.ssoCfg.default_workspace_id || ''}); },
removeSsoGroup(i) { this.ssoGroups.splice(i, 1); },
// ── v5.2.0 Backups (admin) ──
async loadBackups() {
try {