Files
Calendarr/frontend/js/app.js
T
ScarriffleandClaude Opus 5 31baf4b18c 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>
2026-09-09 18:00:46 +02:00

317 lines
11 KiB
JavaScript

import { api } from './api.js';
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 {
const res = await api.setupRequired();
setupRequired = res.required;
} catch (e) {
showScreen('login');
return;
}
if (setupRequired) {
showScreen('setup');
bindSetupForm();
return;
}
// Check if already logged in
const token = localStorage.getItem('token');
if (token) {
let authed = false;
try {
await api.get('/auth/me'); // validate the TOKEN only
authed = true;
} catch (_) {
// The token is genuinely invalid/expired — clear it and show login.
localStorage.removeItem('token');
localStorage.removeItem('user');
}
if (authed) {
// Token is valid → stay logged in. launchApp() runs OUTSIDE the auth
// try/catch on purpose: a later data/render error inside app init must
// never bounce a validly-authenticated user back to the login screen
// (that was the "logged out on every reload" bug).
await launchApp();
return;
}
}
showScreen('login');
bindLoginForm();
renderSsoButtons();
}
function showScreen(name) {
document.getElementById('screen-setup').classList.add('hidden');
document.getElementById('screen-login').classList.add('hidden');
document.getElementById('app').classList.add('hidden');
if (name === 'setup') document.getElementById('screen-setup').classList.remove('hidden');
else if (name === 'login') document.getElementById('screen-login').classList.remove('hidden');
else if (name === 'app') document.getElementById('app').classList.remove('hidden');
}
async function launchApp() {
showScreen('app');
// Set user avatar initials
const user = JSON.parse(localStorage.getItem('user') || '{}');
const avatar = document.getElementById('user-avatar');
if (user.username) {
avatar.textContent = user.username[0].toUpperCase();
avatar.title = user.username;
}
// User dropdown menu
const dropdown = document.getElementById('user-dropdown');
document.getElementById('dropdown-username').textContent = user.display_name || user.username || 'Benutzer';
avatar.addEventListener('click', e => {
e.stopPropagation();
dropdown.classList.toggle('hidden');
});
document.addEventListener('click', e => {
if (!dropdown.contains(e.target) && !avatar.contains(e.target)) {
dropdown.classList.add('hidden');
}
});
document.getElementById('btn-profile').addEventListener('click', () => {
dropdown.classList.add('hidden');
openProfileModal();
});
document.getElementById('btn-logout').addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
});
// Load avatar image if available
try {
const me = await api.get('/auth/me');
// Store extended user info
localStorage.setItem('user', JSON.stringify({ ...user, ...me }));
if (me.has_avatar) {
loadAvatarImage(avatar, user.username);
}
} catch (_) {}
await initCalendar();
}
// ── Setup Form ────────────────────────────────────────────
function bindSetupForm() {
document.getElementById('setup-form').addEventListener('submit', async e => {
e.preventDefault();
const username = document.getElementById('setup-username').value.trim();
const email = document.getElementById('setup-email').value.trim() || null;
const pw1 = document.getElementById('setup-password').value;
const pw2 = document.getElementById('setup-password2').value;
const errEl = document.getElementById('setup-error');
errEl.classList.add('hidden');
if (pw1 !== pw2) {
errEl.textContent = t('setup_pw_mismatch');
errEl.classList.remove('hidden');
return;
}
if (pw1.length < 6) {
errEl.textContent = t('setup_pw_short');
errEl.classList.remove('hidden');
return;
}
try {
const res = await api.setup({ username, email, password: pw1 });
localStorage.setItem('token', res.access_token);
localStorage.setItem('user', JSON.stringify(res.user));
await launchApp();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
}
// ── Login Form ────────────────────────────────────────────
function bindLoginForm() {
const totpRow = document.getElementById('login-totp-row');
document.getElementById('login-form').addEventListener('submit', async e => {
e.preventDefault();
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
const totpCode = document.getElementById('login-totp')?.value.trim() || null;
const remember = document.getElementById('login-remember')?.checked || false;
const errEl = document.getElementById('login-error');
errEl.classList.add('hidden');
try {
const res = await api.login(username, password, totpCode, remember);
localStorage.setItem('token', res.access_token);
localStorage.setItem('user', JSON.stringify(res.user));
await launchApp();
} catch (err) {
if (err.message === '2fa_required') {
totpRow.classList.remove('hidden');
document.getElementById('login-totp').focus();
} else {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
}
});
}
// ── Avatar Helper ────────────────────────────────────────
function loadAvatarImage(avatarEl, username) {
const token = localStorage.getItem('token');
fetch(`/api/profile/avatar?t=${Date.now()}`, {
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
})
.then(res => {
if (!res.ok) throw new Error('No avatar');
return res.blob();
})
.then(blob => {
const img = new Image();
img.onload = () => {
avatarEl.innerHTML = '';
img.style.cssText = 'width:100%;height:100%;object-fit:cover;position:absolute;inset:0';
avatarEl.appendChild(img);
};
img.src = URL.createObjectURL(blob);
})
.catch(() => {
avatarEl.innerHTML = '';
avatarEl.textContent = (username || '?')[0].toUpperCase();
});
}
// ── Start ─────────────────────────────────────────────────
boot();
// ── Service Worker registration (PWA) ─────────────────────
if ('serviceWorker' in navigator) {
// Auto-update: when a new service worker takes control, reload once so the
// page runs the fresh assets. Guarded so it never loops and never fires on
// the very first install (when there was no previous controller).
let refreshing = false;
const hadController = !!navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing || !hadController) return;
refreshing = true;
window.location.reload();
});
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).then(reg => {
// Check for a new SW now and hourly, so long-open tabs pick up releases.
reg.update();
setInterval(() => reg.update(), 60 * 60 * 1000);
}).catch(err => {
console.warn('SW registration failed:', err);
});
});
}