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:
Scarriffle
2026-09-09 20:46:34 +02:00
co-authored by Claude Opus 5
parent b560ee260c
commit cc4daf4a84
10 changed files with 238 additions and 12 deletions
+10
View File
@@ -565,6 +565,16 @@ a { color: var(--primary); text-decoration: none; }
#view-container { flex: 1; display: flex; flex-direction: column; min-height: 0; }
/* ── SSO on the login screen ─────────────────────────────── */
/* Informational, not an error: the SSO identity is fine, it just needs to be
tied to an account. */
.form-hint {
font-size: 13px; line-height: 1.4;
padding: 10px 12px; margin-bottom: 12px;
border-radius: 8px;
color: var(--text-1);
background: var(--primary-dim);
border: 1px solid var(--primary);
}
#sso-block { margin-top: 18px; }
.auth-divider {
display: flex; align-items: center; gap: 10px;
+1
View File
@@ -77,6 +77,7 @@
<label class="toggle-label" style="margin-bottom:14px">
<input type="checkbox" id="login-remember" /> Angemeldet bleiben
</label>
<div id="login-hint" class="form-hint hidden"></div>
<div id="login-error" class="form-error hidden"></div>
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form>
+4 -1
View File
@@ -3,7 +3,8 @@ const BASE = '/api';
// Paths where a 401 is a normal answer rather than an expired session, so the
// auto-logout reload must not fire (it would loop on a failed SSO handoff).
const NO_RELOAD_ON_401 = ['/auth/login', '/auth/oidc/complete', '/auth/oidc/exchange'];
const NO_RELOAD_ON_401 = ['/auth/login', '/auth/oidc/complete', '/auth/oidc/exchange',
'/auth/oidc/link-pending'];
async function request(method, path, body = null, formEncoded = false) {
const token = localStorage.getItem('token');
@@ -118,4 +119,6 @@ export const api = {
oidcProviders: () => request('GET', '/auth/oidc/providers'),
oidcComplete: () => request('POST', '/auth/oidc/complete', {}),
oidcIdentities: () => request('GET', '/auth/oidc/identities'),
oidcLinkPending: () => request('POST', '/auth/oidc/link-pending', {}),
oidcUnlink: (id) => request('DELETE', `/auth/oidc/identities/${id}`),
};
+38 -4
View File
@@ -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') {
+6
View File
@@ -32,6 +32,9 @@ const translations = {
sso_err_email_not_verified: 'Die E-Mail-Adresse ist beim Anbieter nicht bestätigt',
sso_err_no_email: 'Der Anbieter hat keine E-Mail-Adresse übermittelt',
sso_err_state_mismatch: 'Sitzung abgelaufen, bitte erneut versuchen',
sso_link_prompt: 'Dieses Authentik-Konto ist noch nicht verknüpft. Melde dich einmalig mit deinem Calendarr-Passwort an — danach genügt der SSO-Knopf.',
sso_linked_toast: 'Konto mit {name} verknüpft',
sso_err_token_exchange_failed: 'Der Anbieter hat den Anmeldecode abgelehnt — prüfe Client-Typ (Public) und Redirect-URI',
sso_err_provider_unreachable: 'Anmeldedienst nicht erreichbar, bitte später erneut versuchen',
sso_err_discovery_failed: 'Anmeldedienst antwortet nicht korrekt',
sso_err_setup_required: 'Bitte zuerst das Administrator-Konto einrichten',
@@ -836,6 +839,9 @@ const translations = {
sso_err_email_not_verified: 'The email address is not verified at the provider',
sso_err_no_email: 'The provider did not supply an email address',
sso_err_state_mismatch: 'Session expired, please try again',
sso_link_prompt: 'This Authentik account is not linked yet. Sign in once with your Calendarr password — after that the SSO button is enough.',
sso_linked_toast: 'Account linked with {name}',
sso_err_token_exchange_failed: 'The provider rejected the sign-in code — check the client type (public) and the redirect URI',
sso_err_provider_unreachable: 'Sign-in service unreachable, please try again later',
sso_err_discovery_failed: 'Sign-in service returned an invalid response',
sso_err_setup_required: 'Please set up the administrator account first',
+1 -1
View File
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
export const APP_VERSION = 'v91';
export const APP_VERSION = 'v92';
+1 -1
View File
@@ -7,7 +7,7 @@
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
const CACHE_VERSION = 'calendarr-v37';
const CACHE_VERSION = 'calendarr-v38';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {