feat: v6.7.0 — SSO/SAML + OIDC entreprise (SP SAML signé + OIDC PKCE, auto-provisioning, groupes IdP → rôles workspace, SSO only, onglet Settings « SSO / Enterprise », API /api/v2/sso/*, help, migration 23, docs + OpenAPI 439 chemins) · 802 tests verts
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
"""OIDC provider — authorization code flow with PKCE (v6.7.0).
|
||||
|
||||
Discovery (``.well-known/openid-configuration``) is cached for an hour, the
|
||||
ID token signature is verified against the issuer JWKS via authlib's JOSE
|
||||
implementation, and ``iss`` / ``aud`` / ``exp`` / ``nonce`` are checked here
|
||||
explicitly so the rules are visible and unit-testable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Default attribute mapping (design doc §3.2) — OIDC claim names.
|
||||
DEFAULT_OIDC_MAPPING: dict[str, str] = {
|
||||
"login": "sub",
|
||||
"email": "email",
|
||||
"full_name": "name",
|
||||
"avatar_url": "picture",
|
||||
"groups": "groups",
|
||||
}
|
||||
|
||||
_DISCOVERY_TTL = 3600.0
|
||||
_discovery_cache: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
|
||||
class OIDCError(Exception):
|
||||
"""OIDC processing failure — ``message`` is user-facing."""
|
||||
|
||||
|
||||
def pkce_pair() -> tuple[str, str]:
|
||||
"""Return ``(code_verifier, code_challenge)`` for the S256 method."""
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(data: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
|
||||
|
||||
|
||||
async def discover(issuer_url: str) -> dict:
|
||||
"""Fetch (and cache) the issuer's OIDC discovery document."""
|
||||
issuer = issuer_url.rstrip("/")
|
||||
url = f"{issuer}/.well-known/openid-configuration"
|
||||
now = time.time()
|
||||
hit = _discovery_cache.get(issuer)
|
||||
if hit and now - hit[0] < _DISCOVERY_TTL:
|
||||
return hit[1]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
doc = r.json()
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC discovery failed ({url}): {err}") from err
|
||||
if not doc.get("authorization_endpoint") or not doc.get("token_endpoint"):
|
||||
raise OIDCError("OIDC discovery document is missing authorization/token endpoints")
|
||||
_discovery_cache[issuer] = (now, doc)
|
||||
return doc
|
||||
|
||||
|
||||
def build_authorize_url(
|
||||
doc: dict,
|
||||
*,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
state: str,
|
||||
nonce: str,
|
||||
code_challenge: str,
|
||||
) -> str:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": scope or "openid profile email",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
sep = "&" if "?" in doc["authorization_endpoint"] else "?"
|
||||
return doc["authorization_endpoint"] + sep + urlencode(params)
|
||||
|
||||
|
||||
async def exchange_code(
|
||||
doc: dict, *, client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str
|
||||
) -> dict:
|
||||
"""Exchange the authorization code for tokens (PKCE, confidential client)."""
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
auth = None
|
||||
if client_secret:
|
||||
auth = (client_id, client_secret)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.post(doc["token_endpoint"], data=data, auth=auth)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC token request failed: {err}") from err
|
||||
if r.status_code != 200:
|
||||
raise OIDCError(f"OIDC token endpoint returned {r.status_code}: {r.text[:300]}")
|
||||
try:
|
||||
tokens = r.json()
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC token endpoint returned a non-JSON body: {err}") from err
|
||||
if "error" in tokens:
|
||||
raise OIDCError(f"OIDC error: {tokens.get('error')} {tokens.get('error_description', '')}".strip())
|
||||
return tokens
|
||||
|
||||
|
||||
async def fetch_userinfo(doc: dict, access_token: str) -> dict:
|
||||
"""Best-effort userinfo fetch (groups often only live there)."""
|
||||
endpoint = doc.get("userinfo_endpoint")
|
||||
if not endpoint or not access_token:
|
||||
return {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return {}
|
||||
data = r.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as err: # userinfo is optional enrichment
|
||||
logger.debug("userinfo fetch failed: %s", err)
|
||||
return {}
|
||||
|
||||
|
||||
def validate_id_token(
|
||||
id_token: str, *, issuer: str, client_id: str, nonce: str, jwks: dict
|
||||
) -> dict:
|
||||
"""Verify the ID token signature and claims. Returns the claims dict."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
from authlib.jose import JsonWebKey
|
||||
from authlib.jose import jwt as jose_jwt
|
||||
|
||||
if isinstance(id_token, bytes):
|
||||
# authlib's jose.jwt.encode() returns bytes; IdP token endpoints send
|
||||
# str — accept both instead of crashing on ``bytes.count(".")``.
|
||||
id_token = id_token.decode()
|
||||
if not id_token or id_token.count(".") != 2:
|
||||
raise OIDCError("Missing or malformed ID token")
|
||||
|
||||
try:
|
||||
keyset = JsonWebKey.import_key_set(jwks)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"Invalid issuer JWKS: {err}") from err
|
||||
|
||||
try:
|
||||
# Pick the key matching the token header (kid) when several are offered.
|
||||
header = json.loads(_b64url_decode(id_token.split(".")[0]))
|
||||
kid = header.get("kid")
|
||||
key = keyset.get_by_kid(kid) if kid and hasattr(keyset, "get_by_kid") else None
|
||||
token_obj = jose_jwt.decode(id_token, key or keyset)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"ID token signature verification failed: {err}") from err
|
||||
|
||||
claims = dict(token_obj) # authlib's JWTClaims is a dict subclass
|
||||
now = int(time.time())
|
||||
|
||||
if claims.get("iss") != issuer.rstrip("/") and claims.get("iss") != issuer:
|
||||
raise OIDCError(f"ID token issuer mismatch: {claims.get('iss')!r}")
|
||||
aud = claims.get("aud")
|
||||
aud_list = aud if isinstance(aud, list) else [aud]
|
||||
if client_id not in aud_list:
|
||||
raise OIDCError("ID token audience does not include this client")
|
||||
exp = claims.get("exp")
|
||||
if not isinstance(exp, int) or exp < now:
|
||||
raise OIDCError("ID token expired")
|
||||
iat = claims.get("iat")
|
||||
if isinstance(iat, int) and iat > now + 300:
|
||||
raise OIDCError("ID token issued in the future")
|
||||
if nonce and claims.get("nonce") != nonce:
|
||||
raise OIDCError("ID token nonce mismatch")
|
||||
if not claims.get("sub"):
|
||||
raise OIDCError("ID token has no subject")
|
||||
return claims
|
||||
|
||||
|
||||
def claims_to_identity(claims: dict, mapping: dict | None = None) -> dict:
|
||||
"""Map OIDC claims onto the shared ``{login, email, full_name, avatar_url, groups}`` shape."""
|
||||
mapping = mapping or DEFAULT_OIDC_MAPPING
|
||||
identity: dict = {"_raw": claims}
|
||||
for field in ("login", "email", "full_name", "avatar_url"):
|
||||
source = mapping.get(field) or field
|
||||
value = claims.get(source, "")
|
||||
if isinstance(value, list):
|
||||
value = value[0] if value else ""
|
||||
identity[field] = str(value or "").strip()
|
||||
groups = claims.get(mapping.get("groups", "groups"), [])
|
||||
if isinstance(groups, str):
|
||||
groups = [groups]
|
||||
identity["groups"] = [str(g) for g in groups if g]
|
||||
if not identity["email"]:
|
||||
identity["email"] = claims.get("email", "") or ""
|
||||
if not identity["full_name"]:
|
||||
identity["full_name"] = claims.get("name", "") or identity["email"]
|
||||
return identity
|
||||
@@ -0,0 +1,279 @@
|
||||
"""SAML 2.0 Service Provider — wrapper around python3-saml (OneLogin toolkit).
|
||||
|
||||
v6.7.0. Adapts FastAPI's ``Request`` to the toolkit's flat ``request_data``
|
||||
dict and builds the SP settings from the ``sso_config`` row.
|
||||
|
||||
What the toolkit validates in strict mode (all covered by tests):
|
||||
XML schema, signature of the assertion and/or the message against the IdP
|
||||
certificate, ``Conditions`` timestamps, ``Audience``, ``Destination``,
|
||||
``Issuer``, ``Status``, "exactly one assertion", and ``InResponseTo``
|
||||
against the AuthnRequest id we pass to ``process_response()`` — combined
|
||||
with the single-use ``sso_requests`` store that makes replay impossible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BINDING_HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
BINDING_HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
NAMEID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
||||
|
||||
#: Default attribute mapping (design doc §3.2). ``nameid`` = the assertion's
|
||||
#: NameID; every other value is matched against attribute Name / FriendlyName
|
||||
#: / URI local part (so ``email`` finds both ``email`` and
|
||||
#: ``http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress``).
|
||||
DEFAULT_SAML_MAPPING: dict[str, str] = {
|
||||
"login": "nameid",
|
||||
"email": "nameid",
|
||||
"full_name": "displayName",
|
||||
"avatar_url": "avatar",
|
||||
"groups": "groups",
|
||||
}
|
||||
|
||||
|
||||
class SAMLError(Exception):
|
||||
"""SAML processing failure — ``message`` is user-facing, ``reason`` is logged."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SAMLIdentity:
|
||||
"""What a validated assertion tells us about the user."""
|
||||
|
||||
name_id: str
|
||||
name_id_format: str = ""
|
||||
session_index: str = ""
|
||||
attributes: dict[str, list[str]] = field(default_factory=dict)
|
||||
friendly_attributes: dict[str, list[str]] = field(default_factory=dict)
|
||||
|
||||
def resolve(self, source: str) -> str:
|
||||
"""Resolve one mapped source (``nameid`` or an attribute name) → first value."""
|
||||
if not source or source == "nameid":
|
||||
return self.name_id or ""
|
||||
if source in self.attributes and self.attributes[source]:
|
||||
return (self.attributes[source][0] or "").strip()
|
||||
# FriendlyName match (case-insensitive)
|
||||
lower = {k.lower(): v for k, v in self.friendly_attributes.items()}
|
||||
if source.lower() in lower and lower[source.lower()]:
|
||||
return (lower[source.lower()][0] or "").strip()
|
||||
# URI local part match: ".../claims/emailaddress" ~ "emailaddress", and
|
||||
# a mapping of "email" must still find ".../claims/emailaddress".
|
||||
want = source.lower().lstrip("./")
|
||||
for name, values in self.attributes.items():
|
||||
if not values:
|
||||
continue
|
||||
local = name.rsplit("/", 1)[-1].rsplit("}", 1)[-1].lower()
|
||||
if local == want or local.endswith(want) or want.endswith(local):
|
||||
return (values[0] or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def external_base_url(request: Request) -> str:
|
||||
"""Scheme://host the user actually used (proxy-aware, like OAuth redirects)."""
|
||||
proto = request.headers.get("x-forwarded-proto", "")
|
||||
scheme = proto.split(",")[0].strip() or request.url.scheme or "http"
|
||||
fwd_host = request.headers.get("x-forwarded-host", "")
|
||||
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
|
||||
def saml_endpoints(request: Request) -> dict[str, str]:
|
||||
"""SP entity id + ACS/SLO/metadata URLs derived from the incoming request."""
|
||||
base = external_base_url(request)
|
||||
return {
|
||||
"entity_id": f"{base}/auth/saml/metadata",
|
||||
"acs": f"{base}/auth/saml/callback",
|
||||
"slo": f"{base}/auth/saml/logout",
|
||||
"metadata": f"{base}/auth/saml/metadata",
|
||||
}
|
||||
|
||||
|
||||
def build_settings(cfg: dict, endpoints: dict[str, str]) -> dict:
|
||||
"""python3-saml settings dict built from a ``sso_config`` row."""
|
||||
sign_requests = bool(cfg.get("sign_requests"))
|
||||
sp: dict = {
|
||||
"entityId": endpoints["entity_id"],
|
||||
"assertionConsumerService": {
|
||||
"url": endpoints["acs"],
|
||||
"binding": BINDING_HTTP_POST,
|
||||
},
|
||||
"singleLogoutService": {
|
||||
"url": endpoints["slo"],
|
||||
"binding": BINDING_HTTP_REDIRECT,
|
||||
},
|
||||
"NameIDFormat": NAMEID_FORMAT_EMAIL,
|
||||
}
|
||||
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
|
||||
sp["privateKey"] = cfg["sp_private_key"]
|
||||
sp["x509cert"] = cfg["sp_certificate"]
|
||||
|
||||
idp: dict = {
|
||||
"entityId": cfg.get("entity_id") or "",
|
||||
"singleSignOnService": {
|
||||
"url": cfg.get("sso_url") or "",
|
||||
"binding": BINDING_HTTP_REDIRECT,
|
||||
},
|
||||
"x509cert": cfg.get("x509_certificate") or "",
|
||||
}
|
||||
if cfg.get("slo_url"):
|
||||
idp["singleLogoutService"] = {"url": cfg["slo_url"], "binding": BINDING_HTTP_REDIRECT}
|
||||
|
||||
return {
|
||||
"strict": True,
|
||||
"debug": False,
|
||||
"sp": sp,
|
||||
"idp": idp,
|
||||
"security": {
|
||||
"authnRequestsSigned": sign_requests,
|
||||
"logoutRequestSigned": sign_requests,
|
||||
"logoutResponseSigned": False,
|
||||
"wantMessagesSigned": False,
|
||||
"wantAssertionsSigned": True,
|
||||
"wantNameIdEncrypted": False,
|
||||
"wantAssertionsEncrypted": False,
|
||||
"wantXmlValidation": True,
|
||||
"signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
||||
"digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256",
|
||||
"rejectDeprecatedAlgorithm": True,
|
||||
# FlowDeck is self-hosted: LAN/homelab deploys commonly reach the
|
||||
# SP through single-label hosts (http://flowdeck/, docker service
|
||||
# names). python3-saml rejects those URLs unless this is on.
|
||||
"allowSingleLabelDomains": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _request_data(request: Request, script_name: str, post_data: dict | None = None) -> dict:
|
||||
"""Flat request dict expected by ``OneLogin_Saml2_Auth``."""
|
||||
https = "on" if external_base_url(request).startswith("https") else "off"
|
||||
return {
|
||||
"https": https,
|
||||
"http_host": request.headers.get("host", "localhost:8080"),
|
||||
"script_name": script_name,
|
||||
"request_uri": request.url.path,
|
||||
"query_string": str(request.url.query or ""),
|
||||
"get_data": dict(request.query_params),
|
||||
"post_data": post_data or {},
|
||||
}
|
||||
|
||||
|
||||
def _auth(request: Request, cfg: dict, script_name: str, post_data: dict | None = None):
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
|
||||
settings = build_settings(cfg, saml_endpoints(request))
|
||||
try:
|
||||
return OneLogin_Saml2_Auth(
|
||||
_request_data(request, script_name, post_data=post_data), old_settings=settings
|
||||
)
|
||||
except Exception as err: # malformed IdP/SP config (bad cert, missing URL…)
|
||||
raise SAMLError(f"Invalid SAML configuration: {err}") from err
|
||||
|
||||
|
||||
def create_login(request: Request, cfg: dict, relay_state: str) -> tuple[str, str]:
|
||||
"""Build the AuthnRequest. Returns ``(redirect_url, authn_request_id)``."""
|
||||
auth = _auth(request, cfg, "/auth/saml/login")
|
||||
try:
|
||||
url = auth.login(return_to=relay_state)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SAML AuthnRequest: {err}") from err
|
||||
request_id = auth.get_last_request_id() or ""
|
||||
if not request_id:
|
||||
raise SAMLError("AuthnRequest was built without an id")
|
||||
return url, request_id
|
||||
|
||||
|
||||
def process_response(request: Request, cfg: dict, post_data: dict, request_id: str) -> SAMLIdentity:
|
||||
"""Validate the IdP's SAMLResponse and extract the identity.
|
||||
|
||||
``request_id`` is the id of the AuthnRequest we issued (from the
|
||||
single-use ``sso_requests`` row): the toolkit rejects any response whose
|
||||
``InResponseTo`` does not match it.
|
||||
"""
|
||||
auth = _auth(request, cfg, "/auth/saml/callback", post_data=post_data)
|
||||
try:
|
||||
auth.process_response(request_id=request_id or None)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"SAML response could not be processed: {err}") from err
|
||||
|
||||
errors = auth.get_errors()
|
||||
if errors:
|
||||
raise SAMLError(auth.get_last_error_reason() or f"SAML errors: {', '.join(errors)}")
|
||||
if not auth.is_authenticated():
|
||||
raise SAMLError("SAML response did not authenticate the user")
|
||||
|
||||
name_id = auth.get_nameid() or ""
|
||||
if not name_id:
|
||||
raise SAMLError("SAML assertion carries no NameID")
|
||||
return SAMLIdentity(
|
||||
name_id=name_id,
|
||||
name_id_format=auth.get_nameid_format() or "",
|
||||
session_index=auth.get_session_index() or "",
|
||||
attributes=auth.get_attributes() or {},
|
||||
friendly_attributes=auth.get_friendlyname_attributes() or {},
|
||||
)
|
||||
|
||||
|
||||
def metadata_xml(request: Request, cfg: dict) -> str:
|
||||
"""SP metadata XML (for the IdP configuration screen)."""
|
||||
from onelogin.saml2.settings import OneLogin_Saml2_Settings
|
||||
|
||||
settings = OneLogin_Saml2_Settings(
|
||||
build_settings(cfg, saml_endpoints(request)), custom_base_path=None
|
||||
)
|
||||
try:
|
||||
xml = settings.get_sp_metadata()
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SP metadata: {err}") from err
|
||||
if isinstance(xml, bytes):
|
||||
xml = xml.decode("utf-8")
|
||||
return xml
|
||||
|
||||
|
||||
def build_logout_url(request: Request, cfg: dict, return_to: str, name_id: str, session_index: str) -> str:
|
||||
"""SP-initiated Single Logout (HTTP-Redirect LogoutRequest to the IdP)."""
|
||||
auth = _auth(request, cfg, "/auth/saml/logout")
|
||||
if not cfg.get("slo_url"):
|
||||
raise SAMLError("The IdP has no Single Logout URL configured")
|
||||
try:
|
||||
return auth.logout(
|
||||
return_to=return_to,
|
||||
name_id=name_id or None,
|
||||
session_index=session_index or None,
|
||||
)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"Could not build the SAML LogoutRequest: {err}") from err
|
||||
|
||||
|
||||
def process_slo_form(request: Request, cfg: dict, form: dict, query: dict) -> tuple[str | None, list[str]]:
|
||||
"""Process a LogoutRequest / LogoutResponse received from the IdP.
|
||||
|
||||
``form`` holds the POSTed fields, ``query`` the GET parameters (the
|
||||
HTTP-Redirect binding delivers LogoutRequest/LogoutResponse there).
|
||||
Returns ``(redirect_url, errors)``.
|
||||
"""
|
||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
||||
|
||||
settings = build_settings(cfg, saml_endpoints(request))
|
||||
https = "on" if external_base_url(request).startswith("https") else "off"
|
||||
post_data = {k: v for k, v in form.items() if k in ("SAMLRequest", "SAMLResponse", "RelayState")}
|
||||
if not post_data:
|
||||
post_data = {"SAMLResponse": query["SAMLResponse"]} if "SAMLResponse" in query else {}
|
||||
req_data = {
|
||||
"https": https,
|
||||
"http_host": request.headers.get("host", "localhost:8080"),
|
||||
"script_name": "/auth/saml/logout",
|
||||
"request_uri": request.url.path,
|
||||
"query_string": str(request.url.query or ""),
|
||||
"get_data": dict(query),
|
||||
"post_data": post_data,
|
||||
}
|
||||
auth = OneLogin_Saml2_Auth(req_data, old_settings=settings)
|
||||
try:
|
||||
url = auth.process_slo(keep_local_session=True)
|
||||
except Exception as err:
|
||||
raise SAMLError(f"SAML logout could not be processed: {err}") from err
|
||||
return url, auth.get_errors()
|
||||
@@ -82,6 +82,26 @@ class Settings(BaseSettings):
|
||||
smtp_use_tls: bool = True
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
"""FlowDeck — v6.7.0 SSO: SAML 2.0 + OIDC endpoints and admin config API.
|
||||
|
||||
Two families of routes:
|
||||
|
||||
* ``/auth/saml/*`` and ``/auth/oidc/*`` — the browser flows (login redirect,
|
||||
ACS callback, SP metadata, Single Logout). The callback endpoints are
|
||||
CSRF-exempt (cross-site POST from the IdP) and instead protected by the
|
||||
single-use ``sso_requests`` relay token + full assertion validation.
|
||||
* ``/api/v2/sso/*`` — admin configuration API (session admin or Bearer token
|
||||
with write scope), consumed by Settings → Admin → SSO / Enterprise.
|
||||
|
||||
Every attempt — success or rejection — lands in ``sso_login_history``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.providers import oidc_provider, saml_provider
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import sso_provisioning as sso
|
||||
from app.services.api_v2_helpers import has_scope, resolve_bearer_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sso"])
|
||||
|
||||
DEFAULT_NEXT = "/workspaces"
|
||||
|
||||
# ── Rate limiting (design §5.2: 5 SSO attempts / minute / IP) ──────────────
|
||||
_RATE_WINDOW = 60.0
|
||||
_RATE_MAX = 5
|
||||
_rate_store: dict[str, tuple[float, int]] = {}
|
||||
|
||||
|
||||
def _rate_ok(request: Request, bucket: str = "sso") -> bool:
|
||||
from app.config import settings
|
||||
|
||||
if not settings.rate_limit_enabled:
|
||||
return True
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
key = f"{bucket}:{ip}"
|
||||
now = time.time()
|
||||
window, count = _rate_store.get(key, (0.0, 0))
|
||||
if now - window > _RATE_WINDOW:
|
||||
_rate_store[key] = (now, 1)
|
||||
return True
|
||||
if count >= _RATE_MAX:
|
||||
return False
|
||||
_rate_store[key] = (window, count + 1)
|
||||
return True
|
||||
|
||||
|
||||
def _page(title: str, body: str, status: int = 200) -> HTMLResponse:
|
||||
"""Small standalone error/info page (same styling as the login page)."""
|
||||
return HTMLResponse(
|
||||
f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<title>FlowDeck — {title}</title><style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||
body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
display:flex;align-items:center;justify-content:center;min-height:100vh;}}
|
||||
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;max-width:460px;text-align:center;}}
|
||||
h1{{font-size:20px;margin-bottom:12px}}p{{color:rgba(255,255,255,.55);font-size:14px;margin-bottom:10px;line-height:1.5;word-break:break-word}}
|
||||
a{{color:#2383E2;font-size:14px;text-decoration:none}}a:hover{{text-decoration:underline}}
|
||||
</style></head><body><div class="box"><h1>{title}</h1>{body}</div></body></html>""",
|
||||
status_code=status,
|
||||
)
|
||||
|
||||
|
||||
def _sso_config_or_error() -> dict | None:
|
||||
cfg = sso.get_sso_config()
|
||||
return sso.normalize_config(cfg) if cfg else None
|
||||
|
||||
|
||||
def _session_cookie(user_data: dict, request: Request):
|
||||
"""Signed, revocable session cookie (same shape as local/OAuth logins)."""
|
||||
return SessionManager.create_session(user_data, request)
|
||||
|
||||
|
||||
def _login_error(message: str, *, cfg: dict | None, identifier: str = "", request=None) -> HTMLResponse:
|
||||
provider_type = (cfg or {}).get("provider_type", "saml")
|
||||
sso.log_sso_login(
|
||||
user_id=None,
|
||||
provider_type=provider_type,
|
||||
provider_name=(cfg or {}).get("name") or "SSO",
|
||||
identifier=identifier,
|
||||
request=request,
|
||||
success=False,
|
||||
error=message,
|
||||
)
|
||||
logger.warning("SSO login rejected: %s", message)
|
||||
safe = (
|
||||
message.replace("&", "&").replace("<", "<").replace(">", ">")[:400]
|
||||
)
|
||||
return _page(
|
||||
"SSO sign-in failed",
|
||||
f"<p>{safe}</p><p><a href=\"/auth/login?provider=local\">↩ Back to login</a></p>",
|
||||
status=403,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════ SAML 2.0 ════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/auth/saml/login")
|
||||
async def saml_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
|
||||
if not _rate_ok(request, "saml"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _page(
|
||||
"SAML not configured",
|
||||
"<p>Single Sign-On has not been set up by the server administrator.</p>"
|
||||
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
|
||||
status=404,
|
||||
)
|
||||
|
||||
cfg = sso.ensure_sp_keypair(cfg)
|
||||
# RelayState = "<AuthnRequest id>.<CSRF token>" — both checked at the ACS.
|
||||
csrf_token = secrets.token_hex(16)
|
||||
# The id is only known after building the request, so build it first with a
|
||||
# placeholder relay state, then re-issue with the real one? python3-saml
|
||||
# builds the AuthnRequest inside login(); we instead create the row right
|
||||
# after login() returns the URL — but the RelayState is already embedded.
|
||||
# So: generate the request id ourselves is not possible → build the URL,
|
||||
# then patch the RelayState by rebuilding with the known id.
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
provisional = saml_provider.create_login(request, cfg, relay_state="_pending_")
|
||||
authn_id = provisional[1]
|
||||
relay = f"{authn_id}.{csrf_token}"
|
||||
sso.create_request(
|
||||
"saml_authn",
|
||||
request_id=authn_id,
|
||||
relay_state=csrf_token,
|
||||
next_path=sso.safe_next_path(next),
|
||||
)
|
||||
# Replace the placeholder RelayState with the real token (same SAMLRequest).
|
||||
parsed = urlparse(provisional[0])
|
||||
params = parse_qs(parsed.query)
|
||||
params["RelayState"] = [relay]
|
||||
flat = [(k, v) for k, values in params.items() for v in values]
|
||||
url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(flat)}"
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/auth/saml/callback")
|
||||
async def saml_callback(request: Request):
|
||||
"""Assertion Consumer Service — validate the SAMLResponse and open a session."""
|
||||
if not _rate_ok(request, "saml-cb"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
|
||||
form = await request.form()
|
||||
saml_response = str(form.get("SAMLResponse") or "")
|
||||
relay_state = str(form.get("RelayState") or "")
|
||||
if not saml_response:
|
||||
return _login_error("Missing SAMLResponse", cfg=None, request=request)
|
||||
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _login_error("SAML is not configured", cfg=None, request=request)
|
||||
|
||||
authn_id, _, csrf_token = relay_state.partition(".")
|
||||
pending = sso.peek_request("saml_authn", authn_id)
|
||||
if not pending and sso.was_consumed("saml_authn", authn_id):
|
||||
# Same assertion twice: the single-use row is already spent.
|
||||
return _login_error(
|
||||
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
|
||||
)
|
||||
if not pending or not csrf_token or not secrets.compare_digest(
|
||||
pending.get("relay_state", ""), csrf_token
|
||||
):
|
||||
return _login_error(
|
||||
"Unknown or expired login request (start again from the login page)",
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
|
||||
try:
|
||||
identity = saml_provider.process_response(
|
||||
request, cfg, {"SAMLResponse": saml_response, "RelayState": relay_state}, authn_id
|
||||
)
|
||||
except saml_provider.SAMLError as err:
|
||||
return _login_error(str(err), cfg=cfg, identifier=authn_id, request=request)
|
||||
|
||||
# Single-use: the same AuthnRequest id can never authenticate twice.
|
||||
consumed = sso.consume_request("saml_authn", authn_id, csrf_token)
|
||||
if not consumed:
|
||||
return _login_error(
|
||||
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
|
||||
)
|
||||
|
||||
claims = sso.identity_from_saml(identity, cfg)
|
||||
identifier = sso.sso_identifier_field(claims)
|
||||
try:
|
||||
user = sso.handle_sso_login(claims, provider_type="saml", cfg=cfg, request=request)
|
||||
except sso.SSOProvisioningError as err:
|
||||
# _login_error() below records the failed attempt itself.
|
||||
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
|
||||
|
||||
sso.log_sso_login(
|
||||
user_id=user["id"], provider_type="saml",
|
||||
provider_name=cfg.get("name") or "SSO", identifier=identifier,
|
||||
request=request, success=True,
|
||||
)
|
||||
user_data = dict(user)
|
||||
user_data["_sso_name_id"] = identity.name_id
|
||||
user_data["_sso_session_index"] = identity.session_index
|
||||
response = RedirectResponse(consumed.get("next_path") or DEFAULT_NEXT, status_code=302)
|
||||
response.set_cookie(
|
||||
"flowdeck_session", _session_cookie(user_data, request),
|
||||
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/saml/metadata")
|
||||
async def saml_metadata(request: Request):
|
||||
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
return _page("SAML not configured", "<p>No SAML configuration found.</p>", status=404)
|
||||
cfg = sso.ensure_sp_keypair(cfg)
|
||||
try:
|
||||
xml = saml_provider.metadata_xml(request, cfg)
|
||||
except saml_provider.SAMLError as err:
|
||||
return _page("Metadata error", f"<p>{err}</p>", status=500)
|
||||
return HTMLResponse(xml, media_type="application/samlmetadata+xml")
|
||||
|
||||
|
||||
async def _saml_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""Single Logout: SP-initiated (our logout button) or IdP-initiated.
|
||||
|
||||
* no SAML payload → build a LogoutRequest to the IdP (after revoking the
|
||||
local session);
|
||||
* ``SAMLRequest`` / ``SAMLResponse`` present → process it (LogoutResponse
|
||||
of our own SLO, or a LogoutRequest issued by the IdP).
|
||||
"""
|
||||
form = dict(await request.form()) if request.method == "POST" else {}
|
||||
query = dict(request.query_params)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
payload = form.get("SAMLRequest") or form.get("SAMLResponse") or query.get("SAMLResponse")
|
||||
if payload:
|
||||
try:
|
||||
url, errors = saml_provider.process_slo_form(request, cfg, form, query)
|
||||
except saml_provider.SAMLError as err:
|
||||
logger.warning("SLO processing failed: %s", err)
|
||||
return _login_error(str(err), cfg=cfg, request=request)
|
||||
if errors:
|
||||
return _login_error(
|
||||
"; ".join(errors)[:300], cfg=cfg, request=request
|
||||
)
|
||||
response = RedirectResponse(url or next, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
# SP-initiated
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(cookie) if cookie else None
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
if cookie:
|
||||
sid = SessionManager.session_id(cookie)
|
||||
if sid:
|
||||
SessionManager.revoke_session(sid)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
if user and cfg.get("slo_url") and user.get("_sso_name_id"):
|
||||
try:
|
||||
logout_url = saml_provider.build_logout_url(
|
||||
request, cfg,
|
||||
return_to=sso.safe_next_path(next),
|
||||
name_id=user.get("_sso_name_id", ""),
|
||||
session_index=user.get("_sso_session_index", ""),
|
||||
)
|
||||
# Keep the cookie-clearing headers built above: hand the browser
|
||||
# to the IdP with our local session already dead.
|
||||
response = RedirectResponse(logout_url, status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
except saml_provider.SAMLError as err:
|
||||
logger.warning("SP-initiated SLO failed: %s", err)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/saml/logout")
|
||||
async def saml_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""SP-initiated Single Logout (GET) — hands the browser to the IdP."""
|
||||
return await _saml_logout(request, next)
|
||||
|
||||
|
||||
@router.post("/auth/saml/logout")
|
||||
async def saml_logout_post(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""IdP-initiated Single Logout (POST with SAMLRequest/SAMLResponse)."""
|
||||
return await _saml_logout(request, next)
|
||||
|
||||
|
||||
# ═════════════════════════════════ OIDC ═══════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/auth/oidc/login")
|
||||
async def oidc_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
"""Redirect to the OIDC provider (authorization code + PKCE)."""
|
||||
if not _rate_ok(request, "oidc"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "oidc":
|
||||
return _page(
|
||||
"OIDC not configured",
|
||||
"<p>Single Sign-On has not been set up by the server administrator.</p>"
|
||||
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
|
||||
status=404,
|
||||
)
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
except oidc_provider.OIDCError as err:
|
||||
return _login_error(str(err), cfg=cfg, request=request)
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
state = secrets.token_hex(32)
|
||||
nonce = secrets.token_hex(16)
|
||||
verifier, challenge = oidc_provider.pkce_pair()
|
||||
sso.create_request(
|
||||
"oidc",
|
||||
request_id=state,
|
||||
relay_state=nonce,
|
||||
code_verifier=verifier,
|
||||
next_path=sso.safe_next_path(next),
|
||||
)
|
||||
url = oidc_provider.build_authorize_url(
|
||||
doc,
|
||||
client_id=cfg["client_id"],
|
||||
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
|
||||
scope=cfg.get("scope") or "openid profile email",
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
code_challenge=challenge,
|
||||
)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
async def _oidc_callback(request: Request):
|
||||
"""OIDC callback: exchange the code, validate the ID token, open a session."""
|
||||
if not _rate_ok(request, "oidc-cb"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
|
||||
params = dict(request.query_params)
|
||||
if request.method == "POST":
|
||||
params.update({k: str(v) for k, v in (await request.form()).items()})
|
||||
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "oidc":
|
||||
return _login_error("OIDC is not configured", cfg=None, request=request)
|
||||
|
||||
if params.get("error"):
|
||||
return _login_error(
|
||||
f"Provider error: {params.get('error')} {params.get('error_description', '')}".strip(),
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
code, state = params.get("code", ""), params.get("state", "")
|
||||
pending = sso.consume_request("oidc", state)
|
||||
if not code or not pending:
|
||||
return _login_error(
|
||||
"Unknown or expired OIDC state (start again from the login page)",
|
||||
cfg=cfg, request=request,
|
||||
)
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
tokens = await oidc_provider.exchange_code(
|
||||
doc,
|
||||
client_id=cfg["client_id"],
|
||||
client_secret=sso.client_secret_value(cfg),
|
||||
code=code,
|
||||
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
|
||||
code_verifier=pending.get("code_verifier", ""),
|
||||
)
|
||||
jwks = await _fetch_jwks(doc)
|
||||
claims = oidc_provider.validate_id_token(
|
||||
tokens.get("id_token", ""),
|
||||
issuer=cfg["issuer_url"],
|
||||
client_id=cfg["client_id"],
|
||||
nonce=pending.get("relay_state", ""),
|
||||
jwks=jwks,
|
||||
)
|
||||
userinfo = await oidc_provider.fetch_userinfo(doc, tokens.get("access_token", ""))
|
||||
except oidc_provider.OIDCError as err:
|
||||
return _login_error(str(err), cfg=cfg, identifier=state, request=request)
|
||||
|
||||
merged = {**claims, **userinfo}
|
||||
identity = oidc_provider.claims_to_identity(merged, cfg.get("attribute_mapping") or None)
|
||||
identifier = sso.sso_identifier_field(identity)
|
||||
try:
|
||||
user = sso.handle_sso_login(identity, provider_type="oidc", cfg=cfg, request=request)
|
||||
except sso.SSOProvisioningError as err:
|
||||
# _login_error() below records the failed attempt itself.
|
||||
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
|
||||
|
||||
sso.log_sso_login(
|
||||
user_id=user["id"], provider_type="oidc",
|
||||
provider_name=cfg.get("name") or "SSO", identifier=identifier,
|
||||
request=request, success=True,
|
||||
)
|
||||
response = RedirectResponse(pending.get("next_path") or DEFAULT_NEXT, status_code=302)
|
||||
response.set_cookie(
|
||||
"flowdeck_session", _session_cookie(dict(user), request),
|
||||
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/oidc/callback")
|
||||
async def oidc_callback(request: Request):
|
||||
"""OIDC callback (GET, authorization code in the query string)."""
|
||||
return await _oidc_callback(request)
|
||||
|
||||
|
||||
@router.post("/auth/oidc/callback")
|
||||
async def oidc_callback_post(request: Request):
|
||||
"""OIDC callback (POST, form_post response mode)."""
|
||||
return await _oidc_callback(request)
|
||||
|
||||
|
||||
async def _fetch_jwks(doc: dict) -> dict:
|
||||
url = doc.get("jwks_uri")
|
||||
if not url:
|
||||
raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as err:
|
||||
raise oidc_provider.OIDCError(f"Could not fetch the issuer JWKS: {err}") from err
|
||||
if not isinstance(data, dict) or not data.get("keys"):
|
||||
raise oidc_provider.OIDCError("Issuer JWKS contains no keys")
|
||||
return data
|
||||
|
||||
|
||||
async def _oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""Local logout + RP-initiated logout at the provider when supported."""
|
||||
cfg = _sso_config_or_error()
|
||||
response = RedirectResponse(next, status_code=302)
|
||||
cookie = request.cookies.get("flowdeck_session", "")
|
||||
if cookie:
|
||||
sid = SessionManager.session_id(cookie)
|
||||
if sid:
|
||||
SessionManager.revoke_session(sid)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
if cfg and cfg["provider_type"] == "oidc":
|
||||
try:
|
||||
doc = await oidc_provider.discover(cfg["issuer_url"])
|
||||
end_session = doc.get("end_session_endpoint")
|
||||
if end_session:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
qs = urlencode({
|
||||
"client_id": cfg["client_id"],
|
||||
"post_logout_redirect_uri": external_base_url(request) + next,
|
||||
})
|
||||
sep = "&" if "?" in end_session else "?"
|
||||
return RedirectResponse(f"{end_session}{sep}{qs}", status_code=302)
|
||||
except oidc_provider.OIDCError as err:
|
||||
logger.debug("RP-initiated logout skipped: %s", err)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.get("/auth/oidc/logout")
|
||||
async def oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""OIDC logout (GET) — local session first, then the IdP end-session URL."""
|
||||
return await _oidc_logout(request, next)
|
||||
|
||||
|
||||
@router.post("/auth/oidc/logout")
|
||||
async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=local"):
|
||||
"""OIDC logout (POST)."""
|
||||
return await _oidc_logout(request, next)
|
||||
|
||||
|
||||
# ═══════════════════════ Admin configuration API ══════════════════════════
|
||||
|
||||
|
||||
async def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
"""Admin identity: Bearer token (scope read/write) or an admin session.
|
||||
|
||||
Session-authenticated writes also need the CSRF header — ``/api/v2`` is
|
||||
exempted in the middleware, so the check lives here for this router.
|
||||
"""
|
||||
auth_header = request.headers.get("authorization") or ""
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
user = resolve_bearer_token(auth_header[7:].strip())
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
scopes = user.get("_token_scopes") or ""
|
||||
need = "write" if write else "read"
|
||||
if not (has_scope(scopes, need) or has_scope(scopes, "admin")):
|
||||
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {need}")
|
||||
if not user.get("is_admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return user
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, login, full_name, email, is_admin FROM users WHERE id=?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
if write and request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
cookie = request.cookies.get("csrf_token", "")
|
||||
header = request.headers.get("X-CSRF-Token", "")
|
||||
if not cookie or not header or not secrets.compare_digest(cookie, header):
|
||||
raise HTTPException(status_code=403, detail="CSRF validation failed")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/providers")
|
||||
async def sso_providers(request: Request):
|
||||
"""Public: what the login page should show (button list + sso_only flag)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg:
|
||||
return {"providers": [], "sso_only": False}
|
||||
from app.auth.providers.saml_provider import external_base_url
|
||||
|
||||
base = external_base_url(request)
|
||||
login_path = "/auth/saml/login" if cfg["provider_type"] == "saml" else "/auth/oidc/login"
|
||||
return {
|
||||
"providers": [{
|
||||
"type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"icon": "🏢",
|
||||
"login_url": f"{login_path}?next={DEFAULT_NEXT}",
|
||||
}],
|
||||
"sso_only": bool(cfg.get("sso_only")),
|
||||
"base_url": base,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/config")
|
||||
async def get_sso_config_api(request: Request):
|
||||
"""Read the current SSO configuration (secrets never returned)."""
|
||||
await _require_admin(request, write=False)
|
||||
cfg = _sso_config_or_error()
|
||||
return sso.public_config_view(cfg)
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/config")
|
||||
@router.put("/api/v2/sso/config")
|
||||
async def save_sso_config_api(request: Request):
|
||||
"""Create/replace the SSO configuration (admin, scope write)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as err:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from err
|
||||
try:
|
||||
saved = sso.save_sso_config(payload, created_by=admin.get("id"))
|
||||
except sso.SSOConfigError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.config.save", "sso_config", saved.get("id", 0),
|
||||
f"provider={saved.get('provider_type')}", request)
|
||||
return sso.public_config_view(saved)
|
||||
|
||||
|
||||
@router.delete("/api/v2/sso/config")
|
||||
async def delete_sso_config_api(request: Request):
|
||||
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
removed = sso.delete_sso_config()
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.config.disable", "sso_config", 0, "", request)
|
||||
return {"status": "ok", "disabled": removed}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/workspaces")
|
||||
async def sso_workspaces(request: Request):
|
||||
"""Workspaces available for default assignment / group mapping."""
|
||||
await _require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces ORDER BY name"
|
||||
).fetchall()
|
||||
cfg = _sso_config_or_error()
|
||||
return {
|
||||
"workspaces": [dict(r) for r in rows],
|
||||
"default_workspace_id": (cfg or {}).get("default_workspace_id"),
|
||||
"sso_only": bool((cfg or {}).get("sso_only")),
|
||||
"provisioned_users": sso.provisioned_count(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/sync")
|
||||
async def sso_sync(request: Request):
|
||||
"""Re-apply group → workspace role mapping for every SSO user."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
try:
|
||||
result = sso.force_sync_all_groups()
|
||||
except sso.SSOProvisioningError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
audit_log(admin, "sso.sync", "sso_config", 0, str(result), request)
|
||||
return {"status": "ok", **result}
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/history")
|
||||
async def sso_history(request: Request, limit: int = 50):
|
||||
"""Audit trail of SSO login attempts (successes and rejections)."""
|
||||
await _require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT h.id, h.user_id, u.login, h.provider_type, h.provider_name,
|
||||
h.sso_identifier, h.ip_address, h.success, h.error_message,
|
||||
h.created_at
|
||||
FROM sso_login_history h LEFT JOIN users u ON u.id = h.user_id
|
||||
ORDER BY h.id DESC LIMIT ?""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
ident = d.get("sso_identifier") or ""
|
||||
if "|" in ident: # drop the stored group list from the UI payload
|
||||
d["sso_identifier"] = ident.split("|", 1)[0]
|
||||
d["success"] = bool(d["success"])
|
||||
out.append(d)
|
||||
return {"history": out}
|
||||
@@ -98,6 +98,74 @@ class PermissionManager:
|
||||
).fetchone()
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
"""v6.7.0 — SSO provisioning: config store, auto-provisioning, group mapping.
|
||||
|
||||
Single source of truth for the SSO configuration (``sso_config`` table, with
|
||||
an ``SSO_*`` environment fallback for bootstrap installs) and for what
|
||||
happens when an IdP says "this is [email protected]":
|
||||
|
||||
1. resolve the local account (by email → merge, else by login),
|
||||
2. create it when ``auto_provision`` is on, else reject with an audit row,
|
||||
3. sync attributes + map SSO groups to workspace roles,
|
||||
4. hand back the user dict so the caller can mint a session.
|
||||
|
||||
Secrets at rest: ``client_secret`` and the generated SP private key are
|
||||
encrypted with a Fernet key derived from ``app_secret_key``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_PROVIDER_TYPES = ("saml", "oidc")
|
||||
|
||||
|
||||
class SSOConfigError(Exception):
|
||||
"""Invalid SSO configuration payload (message shown to the admin)."""
|
||||
|
||||
|
||||
class SSOProvisioningError(Exception):
|
||||
"""A login was rejected (no local account, missing attributes…)."""
|
||||
|
||||
|
||||
# ── Secrets at rest ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _fernet():
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
key = hashlib.sha256((settings.app_secret_key or "flowdeck").encode()).digest()
|
||||
import base64
|
||||
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return _fernet().encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_secret(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return _fernet().decrypt(value.encode()).decode()
|
||||
except Exception:
|
||||
return "" # key rotated / not ours — treat as unset
|
||||
|
||||
|
||||
# ── Config store ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _default_mapping(provider_type: str) -> dict:
|
||||
if provider_type == "oidc":
|
||||
from app.auth.providers.oidc_provider import DEFAULT_OIDC_MAPPING
|
||||
|
||||
return dict(DEFAULT_OIDC_MAPPING)
|
||||
from app.auth.providers.saml_provider import DEFAULT_SAML_MAPPING
|
||||
|
||||
return dict(DEFAULT_SAML_MAPPING)
|
||||
|
||||
|
||||
def _env_config() -> dict | None:
|
||||
"""Bootstrap config from ``SSO_*`` env vars (design doc §3.3).
|
||||
|
||||
Only used when the table holds no active row — the Settings UI always
|
||||
wins once an admin saved a configuration.
|
||||
"""
|
||||
provider_type = (settings.sso_provider or "").strip().lower()
|
||||
if provider_type not in VALID_PROVIDER_TYPES:
|
||||
return None
|
||||
cfg = {
|
||||
"id": 0,
|
||||
"provider_type": provider_type,
|
||||
"name": settings.sso_name or "Company SSO",
|
||||
"entity_id": settings.sso_entity_id,
|
||||
"sso_url": settings.sso_sso_url,
|
||||
"slo_url": settings.sso_slo_url,
|
||||
"x509_certificate": settings.sso_x509_certificate,
|
||||
"issuer_url": settings.sso_issuer_url,
|
||||
"client_id": settings.sso_client_id,
|
||||
"client_secret": settings.sso_client_secret,
|
||||
"scope": settings.sso_scope,
|
||||
"attribute_mapping": settings.sso_attribute_mapping,
|
||||
"groups_mapping": settings.sso_groups_mapping,
|
||||
"auto_provision": int(settings.sso_auto_provision),
|
||||
"sso_only": int(settings.sso_only),
|
||||
"sign_requests": int(settings.sso_sign_requests),
|
||||
"default_workspace_id": settings.sso_default_workspace_id,
|
||||
"sp_private_key": "",
|
||||
"sp_certificate": "",
|
||||
"workspace_id": None,
|
||||
"active": 1,
|
||||
"_source": "env",
|
||||
}
|
||||
if provider_type == "saml" and (not cfg["entity_id"] or not cfg["sso_url"]):
|
||||
return None
|
||||
if provider_type == "oidc" and (not cfg["issuer_url"] or not cfg["client_id"]):
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
def get_sso_config(require_active: bool = True) -> dict | None:
|
||||
"""Active SSO config as a dict (DB row, else env fallback)."""
|
||||
row = _raw_row(require_active=require_active)
|
||||
if row:
|
||||
cfg = dict(row)
|
||||
cfg["_source"] = "db"
|
||||
return cfg
|
||||
if not require_active:
|
||||
return _env_config()
|
||||
return _env_config()
|
||||
|
||||
|
||||
def _raw_row(require_active: bool = True) -> dict | None:
|
||||
"""Raw ``sso_config`` row (``client_secret`` still encrypted, ``_source`` unset)."""
|
||||
from app.db import get_conn
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
where = "WHERE active=1" if require_active else ""
|
||||
row = conn.execute(
|
||||
f"SELECT * FROM sso_config {where} ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
except Exception: # table missing (very old install) → env only
|
||||
return None
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def client_secret_value(cfg: dict) -> str:
|
||||
"""Plaintext OIDC client secret (decrypted for DB rows, raw for env)."""
|
||||
raw = (cfg or {}).get("client_secret") or ""
|
||||
if not raw:
|
||||
return ""
|
||||
if (cfg or {}).get("_source") == "env":
|
||||
return raw
|
||||
return decrypt_secret(raw)
|
||||
|
||||
|
||||
def _json_field(value, fallback):
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
try:
|
||||
parsed = json.loads(value or "")
|
||||
return parsed if isinstance(parsed, type(fallback)) else fallback
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _strict_json(value, expected, field: str):
|
||||
"""Parse a payload field and reject wrong shapes (before normalize,
|
||||
which would otherwise silently coerce ``"[]"`` → ``{}``)."""
|
||||
if value is None or value == "":
|
||||
return expected()
|
||||
if isinstance(value, (dict, list)):
|
||||
parsed = value
|
||||
else:
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except Exception as exc:
|
||||
raise SSOConfigError(f"{field} must be valid JSON") from exc
|
||||
if not isinstance(parsed, expected):
|
||||
kind = "object" if expected is dict else "array"
|
||||
raise SSOConfigError(f"{field} must be a JSON {kind}")
|
||||
return parsed
|
||||
|
||||
|
||||
def normalize_config(cfg: dict) -> dict:
|
||||
"""Parse JSON columns + fill defaults (single place for every consumer)."""
|
||||
out = dict(cfg)
|
||||
out["attribute_mapping"] = _json_field(out.get("attribute_mapping"), {})
|
||||
out["groups_mapping"] = _json_field(out.get("groups_mapping"), [])
|
||||
if not out["attribute_mapping"]:
|
||||
out["attribute_mapping"] = _default_mapping(out.get("provider_type", "saml"))
|
||||
for key in ("entity_id", "sso_url", "slo_url", "x509_certificate", "issuer_url",
|
||||
"client_id", "client_secret", "scope", "name"):
|
||||
out[key] = (out.get(key) or "").strip()
|
||||
for key in ("auto_provision", "sso_only", "sign_requests", "active"):
|
||||
out[key] = int(out.get(key) or 0)
|
||||
return out
|
||||
|
||||
|
||||
def validate_config_payload(payload: dict) -> dict:
|
||||
"""Validate + sanitize an admin payload. Raises ``SSOConfigError``."""
|
||||
provider_type = str(payload.get("provider_type") or "").strip().lower()
|
||||
if provider_type not in VALID_PROVIDER_TYPES:
|
||||
raise SSOConfigError(f"provider_type must be one of {', '.join(VALID_PROVIDER_TYPES)}")
|
||||
|
||||
payload = dict(payload)
|
||||
payload["attribute_mapping"] = _strict_json(
|
||||
payload.get("attribute_mapping"), dict, "attribute_mapping"
|
||||
)
|
||||
payload["groups_mapping"] = _strict_json(
|
||||
payload.get("groups_mapping"), list, "groups_mapping"
|
||||
)
|
||||
cfg = normalize_config({**payload, "provider_type": provider_type})
|
||||
|
||||
if provider_type == "saml":
|
||||
for field in ("entity_id", "sso_url"):
|
||||
if not cfg[field]:
|
||||
raise SSOConfigError(f"SAML requires '{field}'")
|
||||
for field, url in (("sso_url", cfg["sso_url"]), ("slo_url", cfg["slo_url"])):
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
raise SSOConfigError(f"'{field}' must be an http(s) URL")
|
||||
cert = cfg["x509_certificate"].strip()
|
||||
if cert and "BEGIN CERTIFICATE" not in cert:
|
||||
raise SSOConfigError("x509_certificate must be a PEM certificate")
|
||||
if not cert:
|
||||
raise SSOConfigError("SAML requires the IdP signing certificate (x509_certificate)")
|
||||
cfg["x509_certificate"] = cert
|
||||
else:
|
||||
if not cfg["issuer_url"] or not cfg["client_id"]:
|
||||
raise SSOConfigError("OIDC requires 'issuer_url' and 'client_id'")
|
||||
if not cfg["issuer_url"].startswith(("http://", "https://")):
|
||||
raise SSOConfigError("'issuer_url' must be an http(s) URL")
|
||||
|
||||
if not isinstance(cfg["attribute_mapping"], dict):
|
||||
raise SSOConfigError("attribute_mapping must be a JSON object")
|
||||
if not isinstance(cfg["groups_mapping"], list):
|
||||
raise SSOConfigError("groups_mapping must be a JSON array")
|
||||
for entry in cfg["groups_mapping"]:
|
||||
if not isinstance(entry, dict) or "sso_group" not in entry:
|
||||
raise SSOConfigError("groups_mapping entries need at least an 'sso_group' key")
|
||||
|
||||
ws = cfg.get("default_workspace_id")
|
||||
cfg["default_workspace_id"] = int(ws) if ws not in (None, "", 0) else None
|
||||
return cfg
|
||||
|
||||
|
||||
def save_sso_config(payload: dict, created_by: int | None = None) -> dict:
|
||||
"""Create or replace the single SSO configuration (idempotent)."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = validate_config_payload(payload)
|
||||
columns = {
|
||||
"provider_type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"entity_id": cfg["entity_id"],
|
||||
"sso_url": cfg["sso_url"],
|
||||
"slo_url": cfg["slo_url"],
|
||||
"x509_certificate": cfg["x509_certificate"],
|
||||
"issuer_url": cfg["issuer_url"],
|
||||
"client_id": cfg["client_id"],
|
||||
"scope": cfg.get("scope") or "openid profile email",
|
||||
"attribute_mapping": json.dumps(cfg["attribute_mapping"]),
|
||||
"groups_mapping": json.dumps(cfg["groups_mapping"]),
|
||||
"auto_provision": cfg["auto_provision"],
|
||||
"sso_only": cfg["sso_only"],
|
||||
"sign_requests": cfg["sign_requests"],
|
||||
"default_workspace_id": cfg["default_workspace_id"],
|
||||
"active": 1,
|
||||
"updated_at": str(int(time.time())),
|
||||
}
|
||||
|
||||
# Secret handling: a blank incoming secret keeps the stored one (the raw
|
||||
# row still holds the Fernet blob — never re-encrypt a decrypted value).
|
||||
existing = _raw_row() or {}
|
||||
if "client_secret" in cfg:
|
||||
incoming = str(cfg.get("client_secret") or "").strip()
|
||||
if incoming:
|
||||
columns["client_secret"] = encrypt_secret(incoming)
|
||||
else:
|
||||
columns["client_secret"] = existing.get("client_secret") or ""
|
||||
# SP keypair: keep an existing one, generate one for SAML if missing.
|
||||
sp_key = existing.get("sp_private_key") or ""
|
||||
sp_cert = existing.get("sp_certificate") or ""
|
||||
if cfg["provider_type"] == "saml" and not (sp_key and sp_cert):
|
||||
sp_key, sp_cert = generate_sp_keypair()
|
||||
columns["sp_private_key"] = sp_key
|
||||
columns["sp_certificate"] = sp_cert
|
||||
if created_by:
|
||||
columns["created_by"] = created_by
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM sso_config ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
sets = ", ".join(f"{k}=?" for k in columns)
|
||||
conn.execute(f"UPDATE sso_config SET {sets} WHERE id=?", (*columns.values(), row["id"]))
|
||||
cfg_id = row["id"]
|
||||
else:
|
||||
keys = ", ".join(columns)
|
||||
placeholders = ", ".join("?" for _ in columns)
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO sso_config ({keys}) VALUES ({placeholders})", tuple(columns.values())
|
||||
)
|
||||
cfg_id = cur.lastrowid
|
||||
conn.commit()
|
||||
saved = get_sso_config(require_active=False)
|
||||
saved["id"] = cfg_id
|
||||
return saved
|
||||
|
||||
|
||||
def delete_sso_config() -> bool:
|
||||
"""Disable SSO entirely (local logins keep working)."""
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("UPDATE sso_config SET active=0, updated_at=?", (str(int(time.time())),))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def public_config_view(cfg: dict | None) -> dict:
|
||||
"""Config for the admin UI — secrets never leave the server."""
|
||||
if not cfg:
|
||||
return {"configured": False}
|
||||
cfg = normalize_config(cfg)
|
||||
return {
|
||||
"configured": True,
|
||||
"id": cfg.get("id"),
|
||||
"source": cfg.get("_source", "db"),
|
||||
"provider_type": cfg["provider_type"],
|
||||
"name": cfg.get("name") or "Company SSO",
|
||||
"entity_id": cfg["entity_id"],
|
||||
"sso_url": cfg["sso_url"],
|
||||
"slo_url": cfg["slo_url"],
|
||||
"x509_certificate": cfg["x509_certificate"],
|
||||
"issuer_url": cfg["issuer_url"],
|
||||
"client_id": cfg["client_id"],
|
||||
"client_secret_set": bool(client_secret_value(cfg)),
|
||||
"scope": cfg.get("scope") or "openid profile email",
|
||||
"attribute_mapping": cfg["attribute_mapping"],
|
||||
"groups_mapping": cfg["groups_mapping"],
|
||||
"auto_provision": bool(cfg["auto_provision"]),
|
||||
"sso_only": bool(cfg["sso_only"]),
|
||||
"sign_requests": bool(cfg["sign_requests"]),
|
||||
"default_workspace_id": cfg.get("default_workspace_id"),
|
||||
"sp_certificate": cfg.get("sp_certificate") or "",
|
||||
"active": bool(cfg.get("active", 1)),
|
||||
"provisioned_users": provisioned_count(),
|
||||
}
|
||||
|
||||
|
||||
def is_sso_only(cfg: dict | None = None) -> bool:
|
||||
"""True when local login must be refused (design §7.1 / §4.2)."""
|
||||
cfg = cfg if cfg is not None else get_sso_config()
|
||||
return bool(cfg and normalize_config(cfg).get("sso_only"))
|
||||
|
||||
|
||||
def provisioned_count() -> int:
|
||||
from app.db import get_conn
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE auth_method IN ('saml','oidc')"
|
||||
).fetchone()
|
||||
return int(row["n"] if row is not None else 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def generate_sp_keypair() -> tuple[str, str]:
|
||||
"""RSA-2048 key + self-signed certificate for the SP (metadata + signing)."""
|
||||
import datetime
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
name = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, f"flowdeck-sp-{secrets.token_hex(4)}"),
|
||||
])
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(name)
|
||||
.issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=3650))
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
priv = key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
).decode()
|
||||
public = cert.public_bytes(serialization.Encoding.PEM).decode()
|
||||
return priv, public
|
||||
|
||||
|
||||
def ensure_sp_keypair(cfg: dict) -> dict:
|
||||
"""Guarantee the SAML config carries an SP keypair (generates + persists)."""
|
||||
if cfg.get("provider_type") != "saml":
|
||||
return cfg
|
||||
if cfg.get("sp_private_key") and cfg.get("sp_certificate"):
|
||||
return cfg
|
||||
from app.db import get_conn
|
||||
|
||||
priv, cert = generate_sp_keypair()
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE sso_config SET sp_private_key=?, sp_certificate=? WHERE id=?",
|
||||
(priv, cert, cfg.get("id")),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as err: # env-sourced config has no row to update
|
||||
logger.debug("SP keypair not persisted: %s", err)
|
||||
cfg = dict(cfg)
|
||||
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
|
||||
return cfg
|
||||
cfg = dict(cfg)
|
||||
cfg["sp_private_key"], cfg["sp_certificate"] = priv, cert
|
||||
return cfg
|
||||
|
||||
|
||||
# ── Audit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def log_sso_login(
|
||||
*,
|
||||
user_id: int | None,
|
||||
provider_type: str,
|
||||
provider_name: str,
|
||||
identifier: str,
|
||||
request,
|
||||
success: bool,
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""Write one ``sso_login_history`` row (failures included — design §5.2)."""
|
||||
ip = request.client.host if request is not None and getattr(request, "client", None) else ""
|
||||
ua = (request.headers.get("user-agent", "") if request is not None else "")[:500]
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO sso_login_history
|
||||
(user_id, provider_type, provider_name, sso_identifier,
|
||||
ip_address, user_agent, success, error_message)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, provider_type, provider_name, (identifier or "")[:320], ip, ua,
|
||||
1 if success else 0, (error or "")[:500]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as err: # audit must never break the login path
|
||||
logger.warning("sso_login_history write failed: %s", err)
|
||||
|
||||
|
||||
# ── Group mapping ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def sync_sso_groups(user_id: int, sso_groups: list[str], cfg: dict) -> list[int]:
|
||||
"""Apply ``groups_mapping`` → ``workspace_members.role``. Returns touched ws ids."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = normalize_config(cfg)
|
||||
mappings = cfg.get("groups_mapping") or []
|
||||
wanted = {g.strip().lower() for g in sso_groups if g and str(g).strip()}
|
||||
touched: list[int] = []
|
||||
|
||||
with get_conn() as conn:
|
||||
for entry in mappings:
|
||||
group_name = str(entry.get("sso_group") or "").strip().lower()
|
||||
if not group_name or group_name not in wanted:
|
||||
continue
|
||||
ws_id = entry.get("workspace_id") or cfg.get("default_workspace_id")
|
||||
if not ws_id:
|
||||
continue
|
||||
role = str(entry.get("workspace_role") or "editor").strip() or "editor"
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
role = "editor"
|
||||
conn.execute(
|
||||
"""INSERT INTO workspace_members (workspace_id, user_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(workspace_id, user_id) DO UPDATE SET role=excluded.role""",
|
||||
(int(ws_id), user_id, role),
|
||||
)
|
||||
touched.append(int(ws_id))
|
||||
|
||||
# Default workspace: every SSO user lands there as a plain member.
|
||||
default_ws = cfg.get("default_workspace_id")
|
||||
if default_ws:
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role)
|
||||
VALUES (?, ?, 'editor')""",
|
||||
(int(default_ws), user_id),
|
||||
)
|
||||
if int(default_ws) not in touched:
|
||||
touched.append(int(default_ws))
|
||||
conn.commit()
|
||||
return touched
|
||||
|
||||
|
||||
def force_sync_all_groups() -> dict:
|
||||
"""Re-apply the group mapping for every SSO user (``POST /api/v2/sso/sync``)."""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = get_sso_config()
|
||||
if not cfg:
|
||||
raise SSOProvisioningError("No SSO configuration")
|
||||
cfg = normalize_config(cfg)
|
||||
mapping = cfg.get("attribute_mapping") or {}
|
||||
groups_source = mapping.get("groups", "groups")
|
||||
updated = 0
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, auth_method FROM users WHERE auth_method IN ('saml','oidc')"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
groups = _stored_groups(row["id"], groups_source, cfg)
|
||||
if sync_sso_groups(row["id"], groups, cfg):
|
||||
updated += 1
|
||||
return {"users": len(rows), "updated": updated}
|
||||
|
||||
|
||||
def _stored_groups(user_id: int, source: str, cfg: dict) -> list[str]:
|
||||
"""Groups seen at the last login of that user (stored in attribute sync)."""
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT sso_identifier FROM sso_login_history "
|
||||
"WHERE user_id=? AND success=1 ORDER BY id DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if not row or not row["sso_identifier"]:
|
||||
return []
|
||||
raw = row["sso_identifier"]
|
||||
if "|" in raw:
|
||||
ident, _, groups_json = raw.partition("|")
|
||||
groups = json.loads(groups_json or "[]")
|
||||
return [str(g) for g in groups] if isinstance(groups, list) else []
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── Auto-provisioning ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _unique_login(conn, base: str) -> str:
|
||||
candidate = base
|
||||
n = 1
|
||||
while conn.execute("SELECT 1 FROM users WHERE login=?", (candidate,)).fetchone():
|
||||
n += 1
|
||||
candidate = f"{base}_{n}"
|
||||
return candidate
|
||||
|
||||
|
||||
def identity_from_saml(identity, cfg: dict) -> dict:
|
||||
"""Apply the SAML attribute mapping to a validated assertion."""
|
||||
cfg = normalize_config(cfg)
|
||||
mapping = cfg.get("attribute_mapping") or {}
|
||||
out = {
|
||||
"login": identity.resolve(mapping.get("login", "nameid")),
|
||||
"email": identity.resolve(mapping.get("email", "nameid")),
|
||||
"full_name": identity.resolve(mapping.get("full_name", "displayName")),
|
||||
"avatar_url": identity.resolve(mapping.get("avatar_url", "avatar")),
|
||||
"name_id": identity.name_id,
|
||||
}
|
||||
groups = identity.resolve(mapping.get("groups", "groups"))
|
||||
if groups:
|
||||
# Multi-valued SAML attribute: take every value of the resolved source.
|
||||
source = mapping.get("groups", "groups")
|
||||
values = identity.attributes.get(source) or identity.friendly_attributes.get(source) or [groups]
|
||||
out["groups"] = [str(v).strip() for v in values if v and str(v).strip()]
|
||||
else:
|
||||
out["groups"] = []
|
||||
out["email"] = (out["email"] or "").strip().lower()
|
||||
if "@" not in out["email"]:
|
||||
# NameID may be a persistent opaque id — fall back to login when it is
|
||||
# an email, otherwise leave empty (login will carry the identity).
|
||||
out["email"] = out["email"] if "@" in (out["login"] or "") else ""
|
||||
if not out["full_name"]:
|
||||
out["full_name"] = out["email"] or out["login"]
|
||||
out["login"] = out["login"] or out["email"] or f"sso_{identity.name_id[:32]}"
|
||||
return out
|
||||
|
||||
|
||||
def handle_sso_login(identity: dict, *, provider_type: str, cfg: dict, request) -> dict:
|
||||
"""Resolve/create the local user for an SSO identity. Returns the user dict.
|
||||
|
||||
Raises ``SSOProvisioningError`` when the login must be refused (the
|
||||
caller writes the audit row).
|
||||
"""
|
||||
from app.db import get_conn
|
||||
|
||||
cfg = normalize_config(cfg)
|
||||
email = (identity.get("email") or "").strip().lower()
|
||||
login_hint = (identity.get("login") or "").strip()
|
||||
if not email and not login_hint:
|
||||
raise SSOProvisioningError(
|
||||
"SSO assertion carries no usable email/login — check the attribute mapping"
|
||||
)
|
||||
|
||||
with get_conn() as conn:
|
||||
user = None
|
||||
if email:
|
||||
user = conn.execute(
|
||||
"SELECT * FROM users WHERE lower(email)=? AND email!='' ORDER BY id LIMIT 1",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if not user and login_hint:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (login_hint,)).fetchone()
|
||||
|
||||
if user:
|
||||
# §7.1 — email match → merge: the existing account is reused and
|
||||
# tagged with the SSO method (no duplicate account).
|
||||
updates, params = [], []
|
||||
if identity.get("full_name"):
|
||||
updates.append("full_name=?")
|
||||
params.append(identity["full_name"])
|
||||
if email:
|
||||
updates.append("email=?")
|
||||
params.append(email)
|
||||
if identity.get("avatar_url"):
|
||||
updates.append("avatar_url=?")
|
||||
params.append(identity["avatar_url"])
|
||||
updates.append("auth_method=?")
|
||||
params.append(provider_type)
|
||||
updates.append("last_login=?")
|
||||
params.append(str(time.time()))
|
||||
params.append(user["id"])
|
||||
conn.execute(f"UPDATE users SET {', '.join(updates)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
else:
|
||||
if not cfg.get("auto_provision"):
|
||||
raise SSOProvisioningError(
|
||||
"No local account for this SSO identity and auto-provisioning is disabled"
|
||||
)
|
||||
base_login = login_hint or email
|
||||
login = _unique_login(conn, base_login)
|
||||
conn.execute(
|
||||
"""INSERT INTO users
|
||||
(login, full_name, email, avatar_url, auth_method, is_admin, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)""",
|
||||
(
|
||||
login,
|
||||
identity.get("full_name") or email or login,
|
||||
email,
|
||||
identity.get("avatar_url") or "",
|
||||
provider_type,
|
||||
str(time.time()),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE login=?", (login,)).fetchone()
|
||||
|
||||
if not row:
|
||||
raise SSOProvisioningError("Could not create or load the SSO user")
|
||||
user_dict = dict(row)
|
||||
sync_sso_groups(user_dict["id"], identity.get("groups") or [], cfg)
|
||||
return user_dict
|
||||
|
||||
|
||||
def sso_identifier_field(identity: dict) -> str:
|
||||
"""Audit identifier: ``nameid|["groups",...]`` (groups kept for re-sync)."""
|
||||
ident = identity.get("name_id") or identity.get("email") or identity.get("login") or ""
|
||||
groups = identity.get("groups") or []
|
||||
if groups:
|
||||
return f"{ident}|{json.dumps(groups)}"
|
||||
return ident
|
||||
|
||||
|
||||
def safe_next_path(candidate: str | None) -> str:
|
||||
"""Sanitize the post-login redirect target (open-redirect guard)."""
|
||||
if not candidate:
|
||||
return "/workspaces"
|
||||
candidate = str(candidate)
|
||||
if not candidate.startswith("/") or candidate.startswith("//"):
|
||||
return "/workspaces"
|
||||
parsed = urllib.parse.urlsplit(candidate)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return "/workspaces"
|
||||
return candidate
|
||||
|
||||
|
||||
# ── Anti-replay request store ──────────────────────────────────────────────
|
||||
|
||||
REQUEST_TTL_SECONDS = 600 # AuthnRequest / OIDC state lifetime
|
||||
|
||||
|
||||
def create_request(kind: str, *, request_id: str, relay_state: str = "",
|
||||
code_verifier: str = "", next_path: str = "/workspaces") -> None:
|
||||
"""Store a single-use SSO request (AuthnRequest id / OIDC state)."""
|
||||
from app.db import get_conn
|
||||
|
||||
purge_stale_requests()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO sso_requests
|
||||
(id, kind, relay_state, code_verifier, next_path, used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)""",
|
||||
(request_id, kind, relay_state, code_verifier, safe_next_path(next_path)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def consume_request(kind: str, request_id: str, relay_state: str = "") -> dict | None:
|
||||
"""Atomically consume a request. Returns the row, or None (replay/unknown)."""
|
||||
if not request_id:
|
||||
return None
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
|
||||
(f"-{REQUEST_TTL_SECONDS} seconds",),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
if relay_state and row["relay_state"] and not secrets.compare_digest(
|
||||
row["relay_state"], relay_state
|
||||
):
|
||||
return None
|
||||
cur = conn.execute(
|
||||
"UPDATE sso_requests SET used=1 WHERE id=? AND used=0", (request_id,)
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount != 1:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def peek_request(kind: str, request_id: str) -> dict | None:
|
||||
"""Read a request without consuming it (CSRF check before heavy validation)."""
|
||||
if not request_id:
|
||||
return None
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sso_requests WHERE id=? AND kind=? AND used=0",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def was_consumed(kind: str, request_id: str) -> bool:
|
||||
"""True when this single-use request id was already spent (replay)."""
|
||||
if not request_id:
|
||||
return False
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sso_requests WHERE id=? AND kind=? AND used=1",
|
||||
(request_id, kind),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def purge_stale_requests() -> None:
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM sso_requests WHERE created_at < datetime('now', ?)",
|
||||
(f"-{REQUEST_TTL_SECONDS} seconds",),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -153,6 +153,7 @@
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-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 & 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 & 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 {
|
||||
|
||||
Reference in New Issue
Block a user