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

This commit is contained in:
2026-09-24 13:32:17 -04:00
parent 9562f30366
commit d074689b18
25 changed files with 4507 additions and 31 deletions
+74 -5
View File
@@ -63,6 +63,7 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
.oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;}
.oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;}
.oauth-btn:hover{background:#333;}
.sso-btn{border-color:rgba(35,131,226,.5);}
</style>
</head>
<body>
@@ -88,11 +89,17 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
<div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div>
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
</form>
<div class="oauth-section">
<div class="oauth-section" id="oauth-section">
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
</div>
<!-- SSO / SAML + OIDC (v6.7.0) — buttons injected by loadSsoProviders() -->
<div class="oauth-section" id="sso-section" style="display:none">
<p id="sso-divider" style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
<div id="sso-buttons"></div>
<p id="sso-only-note" style="display:none;font-size:12px;color:rgba(255,255,255,.45);margin-top:12px;line-height:1.5;">This instance only accepts your organization account — local login is disabled.</p>
</div>
</div>
<script>
// Show session expired banner if ?expired=1 in URL
@@ -101,6 +108,38 @@ let mode='login';
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;}
function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}}
async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/workspaces';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}}
// SSO buttons (v6.7.0) — rendered from /api/v2/sso/providers
(async function loadSsoProviders(){
try{
const r = await fetch('/api/v2/sso/providers');
if(!r.ok) return;
const d = await r.json();
const providers = d.providers || [];
if(!providers.length) return;
const wrap = document.getElementById('sso-buttons');
providers.forEach(function(p){
const b = document.createElement('button');
b.className = 'oauth-btn sso-btn';
b.style.marginBottom = '8px';
b.title = 'Sign in with ' + (p.name || 'SSO');
b.onclick = function(){ window.location = p.login_url; };
const icon = document.createElement('span'); icon.textContent = p.icon || '🏢';
const label = document.createElement('span');
label.textContent = (mode === 'register' ? 'Sign up' : 'Login') + ' with ' + (p.name || 'SSO');
b.appendChild(icon); b.appendChild(label);
wrap.appendChild(b);
});
document.getElementById('sso-section').style.display = 'block';
if(d.sso_only){
// Local auth is refused server-side too — don't show a dead form.
const form = document.getElementById('login-form'); if(form) form.style.display = 'none';
const tabs = document.querySelector('.tabs'); if(tabs) tabs.style.display = 'none';
const oauth = document.getElementById('oauth-section'); if(oauth) oauth.style.display = 'none';
const note = document.getElementById('sso-only-note'); if(note) note.style.display = 'block';
const intro = document.querySelector('.login-box p'); if(intro) intro.textContent = 'Sign in with your organization account to continue';
}
}catch(e){}
})();
</script>
</body>
</html>"""
@@ -191,6 +230,16 @@ async def register(request: Request):
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
# SSO-only instance (v6.7.0): local registration is refused — accounts are
# auto-provisioned by the IdP instead (admins still come from Settings).
from app.services.sso_provisioning import is_sso_only
if is_sso_only():
from fastapi.responses import JSONResponse
return JSONResponse(
{"error": "Registration is disabled — sign in with your organization SSO"},
status_code=403,
)
with get_conn() as conn:
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
if existing:
@@ -260,7 +309,15 @@ async def local_login(request: Request):
conn.commit()
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
# Successful login
# Successful local login — SSO-only instances keep a way in for admins
# only (every other account must use the IdP, design §7.1).
from app.services.sso_provisioning import is_sso_only
if is_sso_only() and not ud.get("is_admin"):
return JSONResponse(
{"error": "Local login is disabled on this instance — sign in with SSO"},
status_code=403,
)
with get_conn() as conn:
conn.execute(
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
@@ -373,9 +430,21 @@ async def callback(
@router.get("/logout")
async def logout():
"""Clear session and redirect to login page."""
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
async def logout(request: Request):
"""Clear session and redirect to login page.
SAML sessions additionally hand over to the IdP's Single Logout when one
is configured (the actual cookie clearing happens on the SLO route).
"""
cookie = request.cookies.get("flowdeck_session", "")
user = SessionManager.decode_session(cookie) if cookie else None
local_target = "/auth/login?provider=local"
if user and user.get("_sso_name_id"):
# SSO session → let /auth/saml/logout revoke locally + notify the IdP.
return RedirectResponse(url=f"/auth/saml/logout?next={local_target}", status_code=302)
response = RedirectResponse(url=local_target, status_code=302)
response.delete_cookie("flowdeck_session")
return response
+11
View File
@@ -489,6 +489,7 @@ async def help_page(request: Request):
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style>
<div class="help-page">
<div class="help-hero">
@@ -590,6 +591,16 @@ FlowDeck supports three authentication methods:<br>
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
</p>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
<b>Settings → Admin → SSO / Enterprise</b> (login history).
</p>
</div>
<div class="help-section">
+656
View File
@@ -0,0 +1,656 @@
"""FlowDeck — v6.7.0 SSO: SAML 2.0 + OIDC endpoints and admin config API.
Two families of routes:
* ``/auth/saml/*`` and ``/auth/oidc/*`` — the browser flows (login redirect,
ACS callback, SP metadata, Single Logout). The callback endpoints are
CSRF-exempt (cross-site POST from the IdP) and instead protected by the
single-use ``sso_requests`` relay token + full assertion validation.
* ``/api/v2/sso/*`` — admin configuration API (session admin or Bearer token
with write scope), consumed by Settings → Admin → SSO / Enterprise.
Every attempt — success or rejection — lands in ``sso_login_history``.
"""
from __future__ import annotations
import logging
import secrets
import time
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.providers import oidc_provider, saml_provider
from app.auth.session import SessionManager
from app.services import sso_provisioning as sso
from app.services.api_v2_helpers import has_scope, resolve_bearer_token
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sso"])
DEFAULT_NEXT = "/workspaces"
# ── Rate limiting (design §5.2: 5 SSO attempts / minute / IP) ──────────────
_RATE_WINDOW = 60.0
_RATE_MAX = 5
_rate_store: dict[str, tuple[float, int]] = {}
def _rate_ok(request: Request, bucket: str = "sso") -> bool:
from app.config import settings
if not settings.rate_limit_enabled:
return True
ip = request.client.host if request.client else "unknown"
key = f"{bucket}:{ip}"
now = time.time()
window, count = _rate_store.get(key, (0.0, 0))
if now - window > _RATE_WINDOW:
_rate_store[key] = (now, 1)
return True
if count >= _RATE_MAX:
return False
_rate_store[key] = (window, count + 1)
return True
def _page(title: str, body: str, status: int = 200) -> HTMLResponse:
"""Small standalone error/info page (same styling as the login page)."""
return HTMLResponse(
f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<title>FlowDeck — {title}</title><style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
display:flex;align-items:center;justify-content:center;min-height:100vh;}}
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;max-width:460px;text-align:center;}}
h1{{font-size:20px;margin-bottom:12px}}p{{color:rgba(255,255,255,.55);font-size:14px;margin-bottom:10px;line-height:1.5;word-break:break-word}}
a{{color:#2383E2;font-size:14px;text-decoration:none}}a:hover{{text-decoration:underline}}
</style></head><body><div class="box"><h1>{title}</h1>{body}</div></body></html>""",
status_code=status,
)
def _sso_config_or_error() -> dict | None:
cfg = sso.get_sso_config()
return sso.normalize_config(cfg) if cfg else None
def _session_cookie(user_data: dict, request: Request):
"""Signed, revocable session cookie (same shape as local/OAuth logins)."""
return SessionManager.create_session(user_data, request)
def _login_error(message: str, *, cfg: dict | None, identifier: str = "", request=None) -> HTMLResponse:
provider_type = (cfg or {}).get("provider_type", "saml")
sso.log_sso_login(
user_id=None,
provider_type=provider_type,
provider_name=(cfg or {}).get("name") or "SSO",
identifier=identifier,
request=request,
success=False,
error=message,
)
logger.warning("SSO login rejected: %s", message)
safe = (
message.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")[:400]
)
return _page(
"SSO sign-in failed",
f"<p>{safe}</p><p><a href=\"/auth/login?provider=local\">↩ Back to login</a></p>",
status=403,
)
# ═══════════════════════════════ SAML 2.0 ════════════════════════════════
@router.get("/auth/saml/login")
async def saml_login(request: Request, next: str = DEFAULT_NEXT):
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
if not _rate_ok(request, "saml"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
return _page(
"SAML not configured",
"<p>Single Sign-On has not been set up by the server administrator.</p>"
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
status=404,
)
cfg = sso.ensure_sp_keypair(cfg)
# RelayState = "<AuthnRequest id>.<CSRF token>" — both checked at the ACS.
csrf_token = secrets.token_hex(16)
# The id is only known after building the request, so build it first with a
# placeholder relay state, then re-issue with the real one? python3-saml
# builds the AuthnRequest inside login(); we instead create the row right
# after login() returns the URL — but the RelayState is already embedded.
# So: generate the request id ourselves is not possible → build the URL,
# then patch the RelayState by rebuilding with the known id.
from urllib.parse import parse_qs, urlencode, urlparse
provisional = saml_provider.create_login(request, cfg, relay_state="_pending_")
authn_id = provisional[1]
relay = f"{authn_id}.{csrf_token}"
sso.create_request(
"saml_authn",
request_id=authn_id,
relay_state=csrf_token,
next_path=sso.safe_next_path(next),
)
# Replace the placeholder RelayState with the real token (same SAMLRequest).
parsed = urlparse(provisional[0])
params = parse_qs(parsed.query)
params["RelayState"] = [relay]
flat = [(k, v) for k, values in params.items() for v in values]
url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(flat)}"
return RedirectResponse(url, status_code=302)
@router.post("/auth/saml/callback")
async def saml_callback(request: Request):
"""Assertion Consumer Service — validate the SAMLResponse and open a session."""
if not _rate_ok(request, "saml-cb"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
form = await request.form()
saml_response = str(form.get("SAMLResponse") or "")
relay_state = str(form.get("RelayState") or "")
if not saml_response:
return _login_error("Missing SAMLResponse", cfg=None, request=request)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
return _login_error("SAML is not configured", cfg=None, request=request)
authn_id, _, csrf_token = relay_state.partition(".")
pending = sso.peek_request("saml_authn", authn_id)
if not pending and sso.was_consumed("saml_authn", authn_id):
# Same assertion twice: the single-use row is already spent.
return _login_error(
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
)
if not pending or not csrf_token or not secrets.compare_digest(
pending.get("relay_state", ""), csrf_token
):
return _login_error(
"Unknown or expired login request (start again from the login page)",
cfg=cfg, request=request,
)
try:
identity = saml_provider.process_response(
request, cfg, {"SAMLResponse": saml_response, "RelayState": relay_state}, authn_id
)
except saml_provider.SAMLError as err:
return _login_error(str(err), cfg=cfg, identifier=authn_id, request=request)
# Single-use: the same AuthnRequest id can never authenticate twice.
consumed = sso.consume_request("saml_authn", authn_id, csrf_token)
if not consumed:
return _login_error(
"Replayed SAML response rejected", cfg=cfg, identifier=authn_id, request=request
)
claims = sso.identity_from_saml(identity, cfg)
identifier = sso.sso_identifier_field(claims)
try:
user = sso.handle_sso_login(claims, provider_type="saml", cfg=cfg, request=request)
except sso.SSOProvisioningError as err:
# _login_error() below records the failed attempt itself.
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
sso.log_sso_login(
user_id=user["id"], provider_type="saml",
provider_name=cfg.get("name") or "SSO", identifier=identifier,
request=request, success=True,
)
user_data = dict(user)
user_data["_sso_name_id"] = identity.name_id
user_data["_sso_session_index"] = identity.session_index
response = RedirectResponse(consumed.get("next_path") or DEFAULT_NEXT, status_code=302)
response.set_cookie(
"flowdeck_session", _session_cookie(user_data, request),
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
)
return response
@router.get("/auth/saml/metadata")
async def saml_metadata(request: Request):
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
return _page("SAML not configured", "<p>No SAML configuration found.</p>", status=404)
cfg = sso.ensure_sp_keypair(cfg)
try:
xml = saml_provider.metadata_xml(request, cfg)
except saml_provider.SAMLError as err:
return _page("Metadata error", f"<p>{err}</p>", status=500)
return HTMLResponse(xml, media_type="application/samlmetadata+xml")
async def _saml_logout(request: Request, next: str = "/auth/login?provider=local"):
"""Single Logout: SP-initiated (our logout button) or IdP-initiated.
* no SAML payload → build a LogoutRequest to the IdP (after revoking the
local session);
* ``SAMLRequest`` / ``SAMLResponse`` present → process it (LogoutResponse
of our own SLO, or a LogoutRequest issued by the IdP).
"""
form = dict(await request.form()) if request.method == "POST" else {}
query = dict(request.query_params)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
response = RedirectResponse(next, status_code=302)
response.delete_cookie("flowdeck_session")
return response
payload = form.get("SAMLRequest") or form.get("SAMLResponse") or query.get("SAMLResponse")
if payload:
try:
url, errors = saml_provider.process_slo_form(request, cfg, form, query)
except saml_provider.SAMLError as err:
logger.warning("SLO processing failed: %s", err)
return _login_error(str(err), cfg=cfg, request=request)
if errors:
return _login_error(
"; ".join(errors)[:300], cfg=cfg, request=request
)
response = RedirectResponse(url or next, status_code=302)
response.delete_cookie("flowdeck_session")
return response
# SP-initiated
cookie = request.cookies.get("flowdeck_session", "")
user = SessionManager.decode_session(cookie) if cookie else None
response = RedirectResponse(next, status_code=302)
if cookie:
sid = SessionManager.session_id(cookie)
if sid:
SessionManager.revoke_session(sid)
response.delete_cookie("flowdeck_session")
if user and cfg.get("slo_url") and user.get("_sso_name_id"):
try:
logout_url = saml_provider.build_logout_url(
request, cfg,
return_to=sso.safe_next_path(next),
name_id=user.get("_sso_name_id", ""),
session_index=user.get("_sso_session_index", ""),
)
# Keep the cookie-clearing headers built above: hand the browser
# to the IdP with our local session already dead.
response = RedirectResponse(logout_url, status_code=302)
response.delete_cookie("flowdeck_session")
return response
except saml_provider.SAMLError as err:
logger.warning("SP-initiated SLO failed: %s", err)
return response
@router.get("/auth/saml/logout")
async def saml_logout(request: Request, next: str = "/auth/login?provider=local"):
"""SP-initiated Single Logout (GET) — hands the browser to the IdP."""
return await _saml_logout(request, next)
@router.post("/auth/saml/logout")
async def saml_logout_post(request: Request, next: str = "/auth/login?provider=local"):
"""IdP-initiated Single Logout (POST with SAMLRequest/SAMLResponse)."""
return await _saml_logout(request, next)
# ═════════════════════════════════ OIDC ═══════════════════════════════════
@router.get("/auth/oidc/login")
async def oidc_login(request: Request, next: str = DEFAULT_NEXT):
"""Redirect to the OIDC provider (authorization code + PKCE)."""
if not _rate_ok(request, "oidc"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "oidc":
return _page(
"OIDC not configured",
"<p>Single Sign-On has not been set up by the server administrator.</p>"
"<p><a href=\"/auth/login?provider=local\">↩ Use local login</a></p>",
status=404,
)
try:
doc = await oidc_provider.discover(cfg["issuer_url"])
except oidc_provider.OIDCError as err:
return _login_error(str(err), cfg=cfg, request=request)
from app.auth.providers.saml_provider import external_base_url
state = secrets.token_hex(32)
nonce = secrets.token_hex(16)
verifier, challenge = oidc_provider.pkce_pair()
sso.create_request(
"oidc",
request_id=state,
relay_state=nonce,
code_verifier=verifier,
next_path=sso.safe_next_path(next),
)
url = oidc_provider.build_authorize_url(
doc,
client_id=cfg["client_id"],
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
scope=cfg.get("scope") or "openid profile email",
state=state,
nonce=nonce,
code_challenge=challenge,
)
return RedirectResponse(url, status_code=302)
async def _oidc_callback(request: Request):
"""OIDC callback: exchange the code, validate the ID token, open a session."""
if not _rate_ok(request, "oidc-cb"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
params = dict(request.query_params)
if request.method == "POST":
params.update({k: str(v) for k, v in (await request.form()).items()})
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "oidc":
return _login_error("OIDC is not configured", cfg=None, request=request)
if params.get("error"):
return _login_error(
f"Provider error: {params.get('error')} {params.get('error_description', '')}".strip(),
cfg=cfg, request=request,
)
code, state = params.get("code", ""), params.get("state", "")
pending = sso.consume_request("oidc", state)
if not code or not pending:
return _login_error(
"Unknown or expired OIDC state (start again from the login page)",
cfg=cfg, request=request,
)
from app.auth.providers.saml_provider import external_base_url
try:
doc = await oidc_provider.discover(cfg["issuer_url"])
tokens = await oidc_provider.exchange_code(
doc,
client_id=cfg["client_id"],
client_secret=sso.client_secret_value(cfg),
code=code,
redirect_uri=f"{external_base_url(request)}/auth/oidc/callback",
code_verifier=pending.get("code_verifier", ""),
)
jwks = await _fetch_jwks(doc)
claims = oidc_provider.validate_id_token(
tokens.get("id_token", ""),
issuer=cfg["issuer_url"],
client_id=cfg["client_id"],
nonce=pending.get("relay_state", ""),
jwks=jwks,
)
userinfo = await oidc_provider.fetch_userinfo(doc, tokens.get("access_token", ""))
except oidc_provider.OIDCError as err:
return _login_error(str(err), cfg=cfg, identifier=state, request=request)
merged = {**claims, **userinfo}
identity = oidc_provider.claims_to_identity(merged, cfg.get("attribute_mapping") or None)
identifier = sso.sso_identifier_field(identity)
try:
user = sso.handle_sso_login(identity, provider_type="oidc", cfg=cfg, request=request)
except sso.SSOProvisioningError as err:
# _login_error() below records the failed attempt itself.
return _login_error(str(err), cfg=cfg, identifier=identifier, request=request)
sso.log_sso_login(
user_id=user["id"], provider_type="oidc",
provider_name=cfg.get("name") or "SSO", identifier=identifier,
request=request, success=True,
)
response = RedirectResponse(pending.get("next_path") or DEFAULT_NEXT, status_code=302)
response.set_cookie(
"flowdeck_session", _session_cookie(dict(user), request),
httponly=True, max_age=86400 * 7, samesite="lax", path="/",
)
return response
@router.get("/auth/oidc/callback")
async def oidc_callback(request: Request):
"""OIDC callback (GET, authorization code in the query string)."""
return await _oidc_callback(request)
@router.post("/auth/oidc/callback")
async def oidc_callback_post(request: Request):
"""OIDC callback (POST, form_post response mode)."""
return await _oidc_callback(request)
async def _fetch_jwks(doc: dict) -> dict:
url = doc.get("jwks_uri")
if not url:
raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
import httpx
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url)
r.raise_for_status()
data = r.json()
except Exception as err:
raise oidc_provider.OIDCError(f"Could not fetch the issuer JWKS: {err}") from err
if not isinstance(data, dict) or not data.get("keys"):
raise oidc_provider.OIDCError("Issuer JWKS contains no keys")
return data
async def _oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
"""Local logout + RP-initiated logout at the provider when supported."""
cfg = _sso_config_or_error()
response = RedirectResponse(next, status_code=302)
cookie = request.cookies.get("flowdeck_session", "")
if cookie:
sid = SessionManager.session_id(cookie)
if sid:
SessionManager.revoke_session(sid)
response.delete_cookie("flowdeck_session")
if cfg and cfg["provider_type"] == "oidc":
try:
doc = await oidc_provider.discover(cfg["issuer_url"])
end_session = doc.get("end_session_endpoint")
if end_session:
from urllib.parse import urlencode
from app.auth.providers.saml_provider import external_base_url
qs = urlencode({
"client_id": cfg["client_id"],
"post_logout_redirect_uri": external_base_url(request) + next,
})
sep = "&" if "?" in end_session else "?"
return RedirectResponse(f"{end_session}{sep}{qs}", status_code=302)
except oidc_provider.OIDCError as err:
logger.debug("RP-initiated logout skipped: %s", err)
return response
@router.get("/auth/oidc/logout")
async def oidc_logout(request: Request, next: str = "/auth/login?provider=local"):
"""OIDC logout (GET) — local session first, then the IdP end-session URL."""
return await _oidc_logout(request, next)
@router.post("/auth/oidc/logout")
async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=local"):
"""OIDC logout (POST)."""
return await _oidc_logout(request, next)
# ═══════════════════════ Admin configuration API ══════════════════════════
async def _require_admin(request: Request, *, write: bool) -> dict:
"""Admin identity: Bearer token (scope read/write) or an admin session.
Session-authenticated writes also need the CSRF header — ``/api/v2`` is
exempted in the middleware, so the check lives here for this router.
"""
auth_header = request.headers.get("authorization") or ""
if auth_header.lower().startswith("bearer "):
user = resolve_bearer_token(auth_header[7:].strip())
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
scopes = user.get("_token_scopes") or ""
need = "write" if write else "read"
if not (has_scope(scopes, need) or has_scope(scopes, "admin")):
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {need}")
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin access required")
return user
from app.db import get_conn
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Not authenticated")
with get_conn() as conn:
row = conn.execute(
"SELECT id, login, full_name, email, is_admin FROM users WHERE id=?",
(user["id"],),
).fetchone()
if not row or not row["is_admin"]:
raise HTTPException(status_code=403, detail="Admin access required")
if write and request.method in ("POST", "PUT", "PATCH", "DELETE"):
cookie = request.cookies.get("csrf_token", "")
header = request.headers.get("X-CSRF-Token", "")
if not cookie or not header or not secrets.compare_digest(cookie, header):
raise HTTPException(status_code=403, detail="CSRF validation failed")
return dict(row)
@router.get("/api/v2/sso/providers")
async def sso_providers(request: Request):
"""Public: what the login page should show (button list + sso_only flag)."""
cfg = _sso_config_or_error()
if not cfg:
return {"providers": [], "sso_only": False}
from app.auth.providers.saml_provider import external_base_url
base = external_base_url(request)
login_path = "/auth/saml/login" if cfg["provider_type"] == "saml" else "/auth/oidc/login"
return {
"providers": [{
"type": cfg["provider_type"],
"name": cfg.get("name") or "Company SSO",
"icon": "🏢",
"login_url": f"{login_path}?next={DEFAULT_NEXT}",
}],
"sso_only": bool(cfg.get("sso_only")),
"base_url": base,
}
@router.get("/api/v2/sso/config")
async def get_sso_config_api(request: Request):
"""Read the current SSO configuration (secrets never returned)."""
await _require_admin(request, write=False)
cfg = _sso_config_or_error()
return sso.public_config_view(cfg)
@router.post("/api/v2/sso/config")
@router.put("/api/v2/sso/config")
async def save_sso_config_api(request: Request):
"""Create/replace the SSO configuration (admin, scope write)."""
admin = await _require_admin(request, write=True)
try:
payload = await request.json()
except Exception as err:
raise HTTPException(status_code=400, detail="Invalid JSON body") from err
try:
saved = sso.save_sso_config(payload, created_by=admin.get("id"))
except sso.SSOConfigError as err:
raise HTTPException(status_code=400, detail=str(err)) from err
from app.services.api_v2_helpers import audit_log
audit_log(admin, "sso.config.save", "sso_config", saved.get("id", 0),
f"provider={saved.get('provider_type')}", request)
return sso.public_config_view(saved)
@router.delete("/api/v2/sso/config")
async def delete_sso_config_api(request: Request):
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
admin = await _require_admin(request, write=True)
removed = sso.delete_sso_config()
from app.services.api_v2_helpers import audit_log
audit_log(admin, "sso.config.disable", "sso_config", 0, "", request)
return {"status": "ok", "disabled": removed}
@router.get("/api/v2/sso/workspaces")
async def sso_workspaces(request: Request):
"""Workspaces available for default assignment / group mapping."""
await _require_admin(request, write=False)
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, owner_id FROM workspaces ORDER BY name"
).fetchall()
cfg = _sso_config_or_error()
return {
"workspaces": [dict(r) for r in rows],
"default_workspace_id": (cfg or {}).get("default_workspace_id"),
"sso_only": bool((cfg or {}).get("sso_only")),
"provisioned_users": sso.provisioned_count(),
}
@router.post("/api/v2/sso/sync")
async def sso_sync(request: Request):
"""Re-apply group → workspace role mapping for every SSO user."""
admin = await _require_admin(request, write=True)
try:
result = sso.force_sync_all_groups()
except sso.SSOProvisioningError as err:
raise HTTPException(status_code=400, detail=str(err)) from err
from app.services.api_v2_helpers import audit_log
audit_log(admin, "sso.sync", "sso_config", 0, str(result), request)
return {"status": "ok", **result}
@router.get("/api/v2/sso/history")
async def sso_history(request: Request, limit: int = 50):
"""Audit trail of SSO login attempts (successes and rejections)."""
await _require_admin(request, write=False)
from app.db import get_conn
limit = max(1, min(int(limit or 50), 200))
with get_conn() as conn:
rows = conn.execute(
"""SELECT h.id, h.user_id, u.login, h.provider_type, h.provider_name,
h.sso_identifier, h.ip_address, h.success, h.error_message,
h.created_at
FROM sso_login_history h LEFT JOIN users u ON u.id = h.user_id
ORDER BY h.id DESC LIMIT ?""",
(limit,),
).fetchall()
out = []
for r in rows:
d = dict(r)
ident = d.get("sso_identifier") or ""
if "|" in ident: # drop the stored group list from the UI payload
d["sso_identifier"] = ident.split("|", 1)[0]
d["success"] = bool(d["success"])
out.append(d)
return {"history": out}