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:
+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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user