657 lines
26 KiB
Python
657 lines
26 KiB
Python
"""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}
|