Files
Calendarr/frontend/sw.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

83 lines
2.9 KiB
JavaScript

// Calendarr Service Worker — minimal-cache strategy
//
// Strategy: network-first for everything. The cache is only used as a
// last-resort fallback when offline (so the app shell still opens). This
// means every online request hits the network and respects the
// server's Cache-Control headers (≤ 2h for static assets, no-cache for
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
const CACHE_VERSION = 'calendarr-v37';
const OFFLINE_SHELL = ['/', '/index.html'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_VERSION).then(cache =>
Promise.all(OFFLINE_SHELL.map(url =>
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
))
).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k)))
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', event => {
const req = event.request;
if (req.method !== 'GET') return;
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/')) {
event.respondWith(
fetch(req).catch(() =>
new Response(JSON.stringify({ offline: true }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
})
)
);
return;
}
// Everything else: network-first. The browser's HTTP cache (driven by
// the server's Cache-Control headers) already throttles re-fetches —
// the SW just makes sure offline still works for the entry HTML.
event.respondWith(
fetch(req).then(resp => {
// Keep a fresh copy of navigation requests / index.html for offline
const isNavigation = req.mode === 'navigate'
|| url.pathname === '/'
|| url.pathname === '/index.html';
if (isNavigation && resp && resp.status === 200) {
const clone = resp.clone();
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
}
return resp;
}).catch(() => {
// Offline fallback: only the HTML shell is served from cache, so the
// app at least renders and can show its own offline UI.
if (req.mode === 'navigate'
|| url.pathname === '/'
|| url.pathname === '/index.html') {
return caches.match(req).then(c => c || caches.match('/index.html'));
}
return new Response('', { status: 503 });
})
);
});