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:
Scarriffle
2026-09-09 18:00:46 +02:00
co-authored by Claude Opus 5
parent a30375aa63
commit 31baf4b18c
17 changed files with 2008 additions and 5 deletions
+15
View File
@@ -564,6 +564,21 @@ a { color: var(--primary); text-decoration: none; }
.main-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
#view-container { flex: 1; display: flex; flex-direction: column; min-height: 0; }
/* ── SSO on the login screen ─────────────────────────────── */
#sso-block { margin-top: 18px; }
.auth-divider {
display: flex; align-items: center; gap: 10px;
margin: 0 0 14px; font-size: 12px; color: var(--text-3);
text-transform: uppercase; letter-spacing: .5px;
}
.auth-divider::before, .auth-divider::after {
content: ''; flex: 1; height: 1px; background: var(--border);
}
#sso-buttons { display: flex; flex-direction: column; gap: 8px; }
/* The SSO buttons are <a> elements (a top-level navigation is required to
follow the 302 and set the flow cookie), so undo the link defaults. */
.sso-btn { text-decoration: none; }
/* ── Mini Calendar ──────────────────────────────────────── */
.mini-cal { padding: 12px 16px; }
.mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
+6
View File
@@ -80,6 +80,12 @@
<div id="login-error" class="form-error hidden"></div>
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form>
<!-- Single sign-on: filled in by renderSsoButtons() when a provider is
configured, otherwise stays hidden and the login screen is unchanged. -->
<div id="sso-block" class="hidden">
<div class="auth-divider">oder</div>
<div id="sso-buttons"></div>
</div>
</div>
<button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices</button>
</div>
+11 -1
View File
@@ -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'),
};
+88
View File
@@ -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) {
+32
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
export const APP_VERSION = 'v90';
export const APP_VERSION = 'v91';
+7 -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-v36';
const CACHE_VERSION = 'calendarr-v37';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
@@ -34,6 +34,12 @@ self.addEventListener('fetch', event => {
const url = new URL(req.url);
// SSO: let the browser handle these itself. /start and /callback are
// redirects that must be followed as a top-level navigation and carry the
// flow cookie; wrapping them in fetch() would risk rendering the JSON
// offline fallback in the address bar.
if (url.pathname.startsWith('/api/auth/oidc/')) return;
// API routes: always go to the network, no offline fallback (we'd just
// be returning stale account/event data otherwise).
if (url.pathname.startsWith('/api/')) {