Commit Graph
7 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 a67602dbc2 Configure the invitation mailbox in the UI, not the environment
Putting IMAP settings in environment variables was my call to make and it was
the wrong one: it meant editing a file on the server and restarting to change a
folder name, and it was never offered as a choice.

The mailbox is now a row in mailbox_accounts, edited under Settings → Admin:
host, user, password, port, folder, interval, on/off. Administrators only — it
is a shared forwarding target, not a per-user account. The password is never
returned by any endpoint, the same as the CalDAV account routes, and leaving
the field empty on edit keeps the stored one so changing a port does not mean
retyping it. The panel shows when the mailbox was last read and what went wrong
if it did, because a mailbox that quietly stopped working looks exactly like
one that has no mail.

Worth stating plainly: the password now lives in the database, in clear text,
like the CalDAV, Google and Home Assistant credentials beside it. Unlike those
three, an IMAP password opens an entire mailbox.

The scheduler job is registered unconditionally now — whether a mailbox exists
can change without a restart, and fetch_invites returns immediately when none
does. test_scheduler knows about it.

Also adds the requests list to the sidebar with accept and decline, 26
translation keys across all ten languages, and tests covering that the password
is never returned, that an edit without one keeps it, and that a disabled
mailbox counts as unconfigured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-29 17:31:56 +01:00
ScarriffleandClaude Opus 5 9bf7d3797d Take in invitations that arrive by mail
A watched mailbox is a trust boundary this app did not have: anyone who knows
the address can hand the server an iCalendar file. So a mail can do exactly one
thing — create a row in event_invites. No calendar read path touches that
table, and an invitation becomes an event only when a person accepts it.

Routing is by the ATTENDEE address, never the From header, which a sender can
claim freely. Addresses are matched against users.email plus a dedicated table,
so forwarded mail still finds its owner: the invitation names the original
address, not the mailbox it landed in.

An accepted invitation gets a fresh UID; the organiser's goes to external_uid.
LocalEvent.uid is globally unique, so reusing it would let a stranger's mail
collide with an event already there. That case has a test, because it is the
one that would be a real problem.

parse_ics now reads METHOD, SEQUENCE, the organiser address, the ATTENDEE list
and RECURRENCE-ID. All additive — existing callers read the keys they knew.
METHOD sits on the VCALENDAR, so it is read outside the component loop; that is
the only reliable way to tell an invitation from a plain calendar file. A VEVENT
carrying RECURRENCE-ID is skipped rather than applied, since treating one
occurrence as the series would move every other date.

The mailbox job registers only when IMAP_HOST is set: an unconditional job would
log every few minutes that it has nothing to do, and test_scheduler asserts the
exact job set. Credentials come from the environment, which keeps the one secret
that opens a whole mailbox out of a table an endpoint could return.

Replies to the organiser are the next step; accepting and declining work without
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-29 17:16:25 +01:00
ScarriffleandClaude Opus 5 cd8aba74b6 docs: Anhaenge, Hintergrund-Jobs und neue Variablen im README
Dokumentiert auch den ehrlichen Haken der ATTACH-Links: sie muessen ohne
Login funktionieren, weil externe Kalender-Clients keinen mitschicken
koennen, und sind damit unbefristet gueltige Capability-URLs. Mit dem
Abschalter, falls das fuer eine Installation nicht tragbar ist.

Nebenbei die seit dem Monorepo-Merge falsche Aussage korrigiert, die
mobilen Apps laegen in eigenen Repos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:28:08 +02:00
ScarriffleandClaude Opus 5 cc4daf4a84 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>
2026-09-09 20:46:34 +02:00
ScarriffleandClaude Opus 5 25fde49cee docs: echte Redirect-URIs der Mobile-Clients in der SSO-Anleitung
Die Platzhalter-URI calendarr://oauth/callback durch die tatsaechlichen
Reverse-DNS-Schemata der beiden Apps ersetzt und vermerkt, dass sie als
Public Clients ohne Secret laufen und ohne MOBILE_CLIENT_ID keinen
SSO-Button zeigen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:14:33 +02:00
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
Scarriffle 33c1f0ab73 README hinzufügen 2026-03-27 14:52:33 +01:00