feat(auth): OpenID-Connect-SSO gegen Authentik (Backend + Web)
Zusaetzlicher Anmeldeweg per OIDC neben dem bestehenden Passwort-Login. Ohne gesetztes OIDC_PROVIDERS aendert sich nichts: /api/auth/oidc/providers meldet enabled:false und der Login-Screen bleibt unveraendert. Backend - oidc_config.py: Provider-Registry rein aus ENV. Ein zweiter Provider ist reine Konfiguration (OIDC_PROVIDERS + OIDC_<KEY>_* Block), keine Codeaenderung. - oidc_client.py: Discovery, JWKS, Token-Tausch, ID-Token-Pruefung ueber authlib.jose mit fest gepinnter Signatur-Allowlist (nur asymmetrisch, damit alg:none und Algorithmus-Confusion ausgeschlossen sind). - oidc_identity.py: Zuordnung ueber (provider_key, sub). Ein E-Mail-Treffer auf ein bestehendes Konto wird standardmaessig abgelehnt statt verknuepft — sonst erbt jeder, der im IdP eine beliebige E-Mail setzen kann, das passende Konto. Verknuepfen ist eine explizite, authentifizierte Aktion. Auto-Provisioning nur per ENV-Flag, standardmaessig aus, optional mit Domain-Allowlist. - routers/oidc_router.py: /providers, /start, /callback, /complete, /exchange, /link, /identities. PKCE S256; Flow-State als kurzlebiges signiertes JWT in einem HttpOnly-Cookie mit jti==state statt eines prozesslokalen Dicts, damit der Flow Neustarts und mehrere Worker uebersteht. Das Calendarr-JWT wird ueber ein Einmal-Cookie uebergeben, nie ueber URL oder Fragment. - /exchange als Kontrakt fuer die AppAuth-Clients: die App fuehrt den Flow als Public Client selbst aus und tauscht das ID-Token; kein Secret im Request. - models.py: neue Tabelle oidc_identities, users.auth_source; Migration in main.py im dortigen try/except-Stil. Nebenbefund, mitbehoben: auth.verify_password liess ValueError aus bcrypt.checkpw durch. Ein nicht wohlgeformter password_hash wurde damit zu HTTP 500 auf /api/auth/login und bei jedem CalDAV-Basic-Auth. SSO-Nutzer bekommen einen echten bcrypt-Hash ueber ein zufaelliges, nirgends gespeichertes Geheimnis; fuer CalDAV nutzen sie App-Passwoerter wie 2FA-Nutzer. Web - SSO-Block auf dem Login-Screen, aus /providers befuellt; Buttons sind Links, weil /start eine Top-Level-Navigation braucht. - consumeSsoHandoff() laeuft vor den Setup-/Token-Pruefungen in boot() und raeumt die Query per replaceState ab, bevor es awaited. - 401-Auto-Logout um die SSO-Pfade erweitert, sonst dreht ein fehlgeschlagener Handoff eine Reload-Schleife. - i18n-Keys in de + en (Fallback-Kette deckt die uebrigen acht Sprachen). - Service Worker laesst /api/auth/oidc/ unangetastet durch. 37 neue Tests (Gesamtsuite 60 gruen): Signaturpruefung mit echten RSA-Tokens, alg:none, falscher iss/aud/nonce, unbekannte client_id, Provisioning-Policies, Username-Kollision, Einmaligkeit des Handoff-Cookies, unerreichbarer Provider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a30375aa63
commit
31baf4b18c
+11
-1
@@ -1,6 +1,10 @@
|
||||
import { t } from './i18n.js';
|
||||
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'];
|
||||
|
||||
async function request(method, path, body = null, formEncoded = false) {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = {};
|
||||
@@ -20,7 +24,7 @@ async function request(method, path, body = null, formEncoded = false) {
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr });
|
||||
|
||||
if (res.status === 401 && !path.startsWith('/auth/login')) {
|
||||
if (res.status === 401 && !NO_RELOAD_ON_401.some(p => path.startsWith(p))) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.reload();
|
||||
@@ -108,4 +112,10 @@ export const api = {
|
||||
|
||||
setupRequired: () => request('GET', '/auth/setup-required'),
|
||||
setup: (data) => request('POST', '/auth/setup', data),
|
||||
|
||||
// Single sign-on. oidcComplete() trades the one-time HttpOnly handoff cookie
|
||||
// set by the OIDC callback for the access token.
|
||||
oidcProviders: () => request('GET', '/auth/oidc/providers'),
|
||||
oidcComplete: () => request('POST', '/auth/oidc/complete', {}),
|
||||
oidcIdentities: () => request('GET', '/auth/oidc/identities'),
|
||||
};
|
||||
|
||||
@@ -3,11 +3,98 @@ import { initCalendar, showToast, openProfileModal } from './calendar.js';
|
||||
import { t } from './i18n.js';
|
||||
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;
|
||||
|
||||
/**
|
||||
* Handle the return leg of an OIDC login.
|
||||
*
|
||||
* The callback redirected us to /?sso=1 with the access token in a one-time
|
||||
* HttpOnly cookie; trade it for the token here. Returns true when the user is
|
||||
* now logged in. Must run before the setup/token checks in boot().
|
||||
*/
|
||||
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
|
||||
|
||||
// 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 (err) { ssoErrorSlug = err; return false; }
|
||||
|
||||
try {
|
||||
const res = await api.oidcComplete();
|
||||
if (!res || !res.access_token) { ssoErrorSlug = 'generic'; return false; }
|
||||
localStorage.setItem('token', res.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(res.user));
|
||||
return true;
|
||||
} catch (e) {
|
||||
ssoErrorSlug = 'generic';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one button per configured provider on the login screen. */
|
||||
async function renderSsoButtons() {
|
||||
const block = document.getElementById('sso-block');
|
||||
const list = document.getElementById('sso-buttons');
|
||||
if (!block || !list) return;
|
||||
|
||||
if (ssoErrorSlug) {
|
||||
const errEl = document.getElementById('login-error');
|
||||
if (errEl) {
|
||||
// Backend slugs come in both shapes: policy refusals are prefixed
|
||||
// ("oidc_account_not_linked"), flow errors are not ("state_mismatch").
|
||||
const bare = ssoErrorSlug.replace(/^oidc_/, '');
|
||||
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');
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
ssoErrorSlug = null;
|
||||
}
|
||||
|
||||
let cfg;
|
||||
try {
|
||||
cfg = await api.oidcProviders();
|
||||
} catch (e) {
|
||||
return; // SSO unreachable → password only
|
||||
}
|
||||
if (!cfg || !cfg.enabled || !cfg.providers.length) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
for (const p of cfg.providers) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'btn btn-secondary btn-full sso-btn';
|
||||
// A real link, not fetch(): /start answers 302 to the identity provider and
|
||||
// needs a top-level navigation to follow it and to set the flow cookie.
|
||||
a.href = p.start_url;
|
||||
a.textContent = `${p.icon ? p.icon + ' ' : ''}${t('sso_login_with', { name: p.name })}`;
|
||||
list.appendChild(a);
|
||||
}
|
||||
const divider = block.querySelector('.auth-divider');
|
||||
if (divider) divider.textContent = t('sso_or');
|
||||
block.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────
|
||||
async function boot() {
|
||||
// Apply instance branding (logo/favicon/default theme) ASAP so the login and
|
||||
// setup screens are already branded. Public endpoint — no token needed.
|
||||
loadInstance();
|
||||
|
||||
// Returning from an SSO login? That has to be settled before anything else,
|
||||
// because we arrive unauthenticated with a one-time cookie in hand.
|
||||
if (await consumeSsoHandoff()) {
|
||||
await launchApp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if setup is required
|
||||
let setupRequired = false;
|
||||
try {
|
||||
@@ -48,6 +135,7 @@ async function boot() {
|
||||
|
||||
showScreen('login');
|
||||
bindLoginForm();
|
||||
renderSsoButtons();
|
||||
}
|
||||
|
||||
function showScreen(name) {
|
||||
|
||||
@@ -21,6 +21,22 @@ const translations = {
|
||||
login_tagline: 'das minimalistische Kalender Frontend',
|
||||
login_submit: 'Anmelden',
|
||||
login_2fa_ph: '6-stelliger Code',
|
||||
// Single sign-on (OpenID Connect)
|
||||
sso_or: 'oder',
|
||||
sso_login_with: 'Mit {name} anmelden',
|
||||
sso_err_generic: 'Anmeldung fehlgeschlagen',
|
||||
sso_err_account_not_linked: 'Kein verknüpftes Calendarr-Konto. Bitte lokal anmelden und in den Einstellungen verknüpfen.',
|
||||
sso_err_signup_disabled: 'Automatische Kontoerstellung ist deaktiviert',
|
||||
sso_err_domain_not_allowed: 'Diese E-Mail-Domain ist nicht zugelassen',
|
||||
sso_err_email_conflict: 'Diese E-Mail-Adresse gehört bereits zu einem anderen Konto',
|
||||
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_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',
|
||||
sso_link_title: 'Single Sign-On',
|
||||
sso_unlink: 'Verknüpfung aufheben',
|
||||
|
||||
// Topbar
|
||||
btn_today: 'Heute',
|
||||
@@ -809,6 +825,22 @@ const translations = {
|
||||
login_tagline: 'the minimalist calendar frontend',
|
||||
login_submit: 'Sign in',
|
||||
login_2fa_ph: '6-digit code',
|
||||
// Single sign-on (OpenID Connect)
|
||||
sso_or: 'or',
|
||||
sso_login_with: 'Sign in with {name}',
|
||||
sso_err_generic: 'Sign-in failed',
|
||||
sso_err_account_not_linked: 'No linked Calendarr account. Sign in locally and link it in Settings.',
|
||||
sso_err_signup_disabled: 'Automatic account creation is disabled',
|
||||
sso_err_domain_not_allowed: 'This email domain is not allowed',
|
||||
sso_err_email_conflict: 'That email address already belongs to another account',
|
||||
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_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',
|
||||
sso_link_title: 'Single sign-on',
|
||||
sso_unlink: 'Unlink',
|
||||
|
||||
// Topbar
|
||||
btn_today: 'Today',
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v90';
|
||||
export const APP_VERSION = 'v91';
|
||||
|
||||
Reference in New Issue
Block a user