feat(kalender): OAuth und Abgleich für Graph und Google

Verbinden, Token erneuern und seitenweise abrufen — mit blankem fetch,
ohne SDK. Es geht um drei Aufrufe je Anbieter, und die Antworten werden
ohnehin mit zod geprüft; ein SDK brächte vor allem Abhängigkeiten und
blinde Flecken.

Entscheidungen:
- Der Zustandswert im OAuth-Ablauf ist mit AUTH_SECRET signiert und trägt
  die Benutzerkennung. Ohne das ließe sich ein fremdes Kalenderkonto an
  ein beliebiges Konto hängen.
- Die Berechtigungen schließen das Schreiben ein, obwohl M3 nur liest.
  Sonst müsste ab M4 jedes Konto neu zustimmen — und die Zustimmung ist
  genau der Schritt, an dem Einrichtung scheitert.
- Läuft der Abgleichverweis ab (beide Anbieter antworten mit 410), wird er
  verworfen und von vorn gelesen. Der Ablauf ist so gebaut, dass ein
  vollständiger Durchlauf jederzeit möglich ist.
- Googles syncToken trägt die Zeitgrenzen nicht mit; Folgeabgleiche
  liefern also auch Änderungen außerhalb unseres Fensters. Was daneben
  liegt, wird verworfen — sonst wüchse der Spiegel unbegrenzt.

Die Instanzregel greift serverseitig: Google gibt es nur daheim, in der
Firmeninstanz liefert die Route 404 statt eines Hinweises.

Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der
enthält das Client-Secret oft im Echo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 18:05:06 +02:00
co-authored by Claude Opus 5
parent 6fda2a579e
commit 9946e7c308
6 changed files with 860 additions and 0 deletions
+16
View File
@@ -30,6 +30,17 @@ const schema = z.object({
WEBAUTHN_RP_NAME: z.string().min(1).default('Planer'),
WEBAUTHN_ORIGIN: z.url(),
// --- Kalenderanbieter (M3) ---
// Graph gibt es in beiden Instanzen, Google nur daheim. Welche Werte
// tatsaechlich benutzt werden duerfen, entscheidet src/lib/instanz.ts.
GRAPH_CLIENT_ID: z.string().trim().default(''),
GRAPH_CLIENT_SECRET: z.string().trim().default(''),
/// Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant.
GRAPH_TENANT_ID: z.string().trim().default('organizations'),
GOOGLE_CLIENT_ID: z.string().trim().default(''),
GOOGLE_CLIENT_SECRET: z.string().trim().default(''),
DEMO_MODUS: jaNein.default(false),
})
@@ -37,6 +48,9 @@ export type Umgebung = z.infer<typeof schema> & {
instanz: Instanz
/** OIDC ist nur eingerichtet, wenn alle vier Werte gesetzt sind. */
oidcAktiv: boolean
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
graphAktiv: boolean
googleAktiv: boolean
}
let zwischengespeichert: Umgebung | null = null
@@ -56,6 +70,8 @@ function lesen(): Umgebung {
oidcAktiv: Boolean(
werte.OIDC_ISSUER && werte.OIDC_CLIENT_ID && werte.OIDC_CLIENT_SECRET,
),
graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET),
googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET),
}
}