feat(oidc): SSO-Identitaet bewusst mit einem Konto verknuepfen
Bisher war eine SSO-Anmeldung ohne passendes Konto eine Sackgasse: rote Fehlermeldung, kein Weg weiter. Die Verknuepfung ging nur ueber LINK_BY_EMAIL, das der E-Mail-Verwaltung des Providers vertrauen muss. Jetzt endet der Callback bei einer besitzerlosen Identitaet nicht im Fehler, sondern parkt sie in einem kurzlebigen signierten HttpOnly-Cookie und leitet auf /?sso_link=1. Der Login-Screen bittet daraufhin einmalig um das Calendarr-Passwort; nach erfolgreichem Login loest der Client POST /api/auth/oidc/link-pending ein und die Identitaet haengt an genau dem Konto, das sich gerade ausgewiesen hat. Beide Haelften sind damit bewiesen — das ID-Token haben wir selbst geprueft, das Konto durch das Passwort — und die Vertrauensannahme ueber die E-Mail entfaellt. Das greift fuer alle drei Faelle, in denen eine gueltige Identitaet keinen Besitzer hat: kein Konto vorhanden, Selbstregistrierung aus, oder E-Mail bereits vergeben. Ausserdem: unbekannte Fehler-Slugs werden jetzt mit angezeigt statt hinter "Anmeldung fehlgeschlagen" zu verschwinden — genau die Information, die zur Diagnose fehlte. Und oidc_token_exchange_failed hat eine eigene Meldung, die auf Client-Typ und Redirect-URI hinweist, weil das der haeufigste Konfigurationsfehler ist. Sechs Tests dazu; Ablauf zusaetzlich im Browser gegen einen echten OIDC-Stub-Provider durchgespielt: Hinweis statt Fehler, Verknuepfung nach Passwort-Login, danach Anmeldung allein per SSO. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b560ee260c
commit
cc4daf4a84
+38
-4
@@ -6,6 +6,9 @@ import { loadInstance } from './instance.js';
|
||||
// ── Single Sign-On ────────────────────────────────────────
|
||||
// Error slug from a failed SSO round-trip, shown once the login screen is up.
|
||||
let ssoErrorSlug = null;
|
||||
// True when an SSO identity is parked server-side, waiting to be linked to
|
||||
// whichever account the user signs into next.
|
||||
let ssoLinkPending = false;
|
||||
|
||||
/**
|
||||
* Handle the return leg of an OIDC login.
|
||||
@@ -16,14 +19,16 @@ let ssoErrorSlug = null;
|
||||
*/
|
||||
async function consumeSsoHandoff() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ok = params.has('sso');
|
||||
const err = params.get('sso_error');
|
||||
if (!ok && !err) return false; // normal load — zero cost
|
||||
const ok = params.has('sso');
|
||||
const err = params.get('sso_error');
|
||||
const link = params.has('sso_link');
|
||||
if (!ok && !err && !link) return false; // normal load — zero cost
|
||||
|
||||
// Strip the marker before awaiting anything: a reload mid-flight must not
|
||||
// try to redeem a cookie that has already been spent.
|
||||
window.history.replaceState({}, '', window.location.pathname + window.location.hash);
|
||||
|
||||
if (link) { ssoLinkPending = true; return false; }
|
||||
if (err) { ssoErrorSlug = err; return false; }
|
||||
|
||||
try {
|
||||
@@ -44,6 +49,14 @@ async function renderSsoButtons() {
|
||||
const list = document.getElementById('sso-buttons');
|
||||
if (!block || !list) return;
|
||||
|
||||
if (ssoLinkPending) {
|
||||
const hintEl = document.getElementById('login-hint');
|
||||
if (hintEl) {
|
||||
hintEl.textContent = t('sso_link_prompt');
|
||||
hintEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
if (ssoErrorSlug) {
|
||||
const errEl = document.getElementById('login-error');
|
||||
if (errEl) {
|
||||
@@ -53,7 +66,10 @@ async function renderSsoButtons() {
|
||||
const msg = [`sso_err_${ssoErrorSlug}`, `sso_err_${bare}`]
|
||||
.map(k => [k, t(k)])
|
||||
.find(([k, v]) => v !== k);
|
||||
errEl.textContent = msg ? msg[1] : t('sso_err_generic');
|
||||
// An unmapped slug still has to be diagnosable — showing only the
|
||||
// generic text hides the one piece of information that identifies the
|
||||
// fault.
|
||||
errEl.textContent = msg ? msg[1] : `${t('sso_err_generic')} (${ssoErrorSlug})`;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
ssoErrorSlug = null;
|
||||
@@ -151,6 +167,13 @@ function showScreen(name) {
|
||||
async function launchApp() {
|
||||
showScreen('app');
|
||||
|
||||
// Confirm a linking that happened during this login.
|
||||
const linkedName = sessionStorage.getItem('ssoLinkedName');
|
||||
if (linkedName !== null) {
|
||||
sessionStorage.removeItem('ssoLinkedName');
|
||||
showToast(t('sso_linked_toast', { name: linkedName }));
|
||||
}
|
||||
|
||||
// Set user avatar initials
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const avatar = document.getElementById('user-avatar');
|
||||
@@ -250,6 +273,17 @@ function bindLoginForm() {
|
||||
const res = await api.login(username, password, totpCode, remember);
|
||||
localStorage.setItem('token', res.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(res.user));
|
||||
// A parked SSO identity is attached to exactly the account that just
|
||||
// proved itself with a password — no email guesswork involved.
|
||||
if (ssoLinkPending) {
|
||||
ssoLinkPending = false;
|
||||
try {
|
||||
const linked = await api.oidcLinkPending();
|
||||
if (linked && linked.linked) {
|
||||
sessionStorage.setItem('ssoLinkedName', linked.name || '');
|
||||
}
|
||||
} catch (e) { /* linking is optional; the login itself succeeded */ }
|
||||
}
|
||||
await launchApp();
|
||||
} catch (err) {
|
||||
if (err.message === '2fa_required') {
|
||||
|
||||
Reference in New Issue
Block a user