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
@@ -0,0 +1,136 @@
import { NextResponse } from 'next/server'
import { notFound } from 'next/navigation'
import { auditSchreiben } from '@/server/audit'
import { db } from '@/server/db'
import { erzwingeFaehigkeit } from '@/server/instanz'
import { kontoAbgleichen } from '@/server/kalender/abgleich'
import { codeEinloesen } from '@/server/kalender/oauth'
import {
alsAnbieter,
faehigkeitFuer,
istAnbieter,
zustandLesen,
} from '@/server/kalender/verbinden'
import { verschluesseln } from '@/server/krypto'
import { umgebung } from '@/server/umgebung'
export const dynamic = 'force-dynamic'
function zurueck(nachricht: string, art: 'ok' | 'fehler'): NextResponse {
const ziel = new URL('/kalenderkonten', umgebung().APP_URL)
ziel.searchParams.set(art, nachricht)
return NextResponse.redirect(ziel)
}
/**
* Nimmt die Rückleitung des Anbieters entgegen und legt das Kalenderkonto an.
*
* Der Zustandswert ist signiert und trägt die Benutzerkennung. Ohne diese
* Prüfung ließe sich ein fremdes Kalenderkonto an ein beliebiges Konto hängen.
*/
export async function GET(
anfrage: Request,
{ params }: { params: Promise<{ anbieter: string }> },
) {
const { anbieter: roh } = await params
if (!istAnbieter(roh)) notFound()
const anbieter = alsAnbieter(roh)
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
const adresse = new URL(anfrage.url)
const fehlerAnbieter = adresse.searchParams.get('error')
if (fehlerAnbieter) {
// Meist "access_denied", weil die Zustimmung abgebrochen wurde.
return zurueck('Die Verbindung wurde abgebrochen.', 'fehler')
}
const zustand = zustandLesen(adresse.searchParams.get('state'))
if (!zustand || zustand.anbieter !== anbieter) {
return zurueck('Die Anfrage war ungültig oder zu alt. Bitte neu beginnen.', 'fehler')
}
const code = adresse.searchParams.get('code')
if (!code) {
return zurueck('Der Anbieter hat keinen Code zurückgegeben.', 'fehler')
}
const benutzer = await db.benutzer.findUnique({
where: { id: zustand.benutzerId },
select: { id: true, email: true, aktiv: true },
})
if (!benutzer?.aktiv) {
return zurueck('Das Konto ist nicht mehr aktiv.', 'fehler')
}
try {
const satz = await codeEinloesen(anbieter, code)
if (!satz.aktualisierungstoken) {
// Ohne das wäre die Verbindung nach einer Stunde tot.
return zurueck(
'Der Anbieter hat kein Aktualisierungstoken herausgegeben. Bitte die Zustimmung erneut erteilen.',
'fehler',
)
}
const extKontoId = satz.kennung ?? `${anbieter.toLowerCase()}-konto`
const kalenderId = anbieter === 'GOOGLE' ? 'primary' : 'standard'
const konto = await db.kalenderkonto.upsert({
where: {
benutzerId_anbieter_extKontoId_kalenderId: {
benutzerId: benutzer.id,
anbieter,
extKontoId,
kalenderId,
},
},
update: {
zugriffstoken: verschluesseln(satz.zugriffstoken),
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
tokenGueltigBis: satz.gueltigBis,
bereiche: satz.bereiche,
aktiv: true,
letzterFehler: null,
// Neu verbunden heißt: von vorn abgleichen.
deltaVerweis: null,
},
create: {
benutzerId: benutzer.id,
anbieter,
anzeigename:
satz.kennung ?? (anbieter === 'GRAPH' ? 'Microsoft 365' : 'Google Kalender'),
extKontoId,
kalenderId,
zugriffstoken: verschluesseln(satz.zugriffstoken),
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
tokenGueltigBis: satz.gueltigBis,
bereiche: satz.bereiche,
},
select: { id: true, anzeigename: true },
})
await auditSchreiben({
akteur: { id: benutzer.id, email: benutzer.email },
aktion: 'kalenderkonto.verbunden',
objektTyp: 'Kalenderkonto',
objektId: konto.id,
// Weder Tokens noch Termininhalte – nur, welches Konto verbunden wurde.
details: { anbieter, anzeigename: konto.anzeigename },
})
const bericht = await kontoAbgleichen(konto.id)
return zurueck(
bericht.ok
? `„${konto.anzeigename}" verbunden, ${bericht.geaendert} Termin(e) gelesen.`
: `„${konto.anzeigename}" verbunden, der erste Abgleich schlug aber fehl.`,
bericht.ok ? 'ok' : 'fehler',
)
} catch (fehler) {
const meldung =
fehler instanceof Error ? fehler.message : 'Die Verbindung ist fehlgeschlagen.'
return zurueck(meldung, 'fehler')
}
}
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server'
import { notFound } from 'next/navigation'
import { autorisierungsAdresse } from '@/server/kalender/oauth'
import {
alsAnbieter,
faehigkeitFuer,
istAnbieter,
zustandErzeugen,
} from '@/server/kalender/verbinden'
import { angemeldet } from '@/server/berechtigung'
import { erzwingeFaehigkeit } from '@/server/instanz'
export const dynamic = 'force-dynamic'
/**
* Startet das Verbinden eines Kalenderkontos und leitet zum Anbieter weiter.
*
* Der Riegel sitzt hier serverseitig: Google gibt es nur daheim. In der
* Firmeninstanz liefert diese Route 404 — nicht etwa einen Hinweis, dass es
* die Möglichkeit anderswo gäbe.
*/
export async function GET(
_anfrage: Request,
{ params }: { params: Promise<{ anbieter: string }> },
) {
const { anbieter: roh } = await params
if (!istAnbieter(roh)) notFound()
const anbieter = alsAnbieter(roh)
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
const benutzer = await angemeldet()
if (!benutzer) {
return NextResponse.redirect(
new URL('/anmelden?weiter=%2Fkalenderkonten', process.env.APP_URL ?? ''),
)
}
try {
const zustand = zustandErzeugen(anbieter, benutzer.id)
return NextResponse.redirect(autorisierungsAdresse(anbieter, zustand))
} catch (fehler) {
const meldung =
fehler instanceof Error ? fehler.message : 'Der Anbieter ist nicht eingerichtet.'
return NextResponse.redirect(
new URL(
`/kalenderkonten?fehler=${encodeURIComponent(meldung)}`,
process.env.APP_URL ?? '',
),
)
}
}