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:
co-authored by
Claude Opus 5
parent
6fda2a579e
commit
9946e7c308
@@ -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 ?? '',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user