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 ?? '',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import 'server-only'
|
||||
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||
import { graphSeiteLesen } from '@/lib/kalender/graph'
|
||||
import { googleSeiteLesen } from '@/lib/kalender/google'
|
||||
import {
|
||||
VerweisAbgelaufen,
|
||||
type AbgleichSeite,
|
||||
type RohTermin,
|
||||
} from '@/lib/kalender/typen'
|
||||
import { tagesBeginn } from '@/lib/zeit'
|
||||
import { db } from '@/server/db'
|
||||
import { entschluesseln, verschluesseln } from '@/server/krypto'
|
||||
import { tokenErneuern } from '@/server/kalender/oauth'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
/**
|
||||
* Abgleich der Kalender.
|
||||
*
|
||||
* Der Ablauf ist bei beiden Anbietern derselbe: Zugriffstoken auffrischen,
|
||||
* Seiten abrufen, bis ein Verweis für die nächste Runde kommt, und die
|
||||
* gelesenen Termine in den örtlichen Spiegel schreiben.
|
||||
*
|
||||
* Beide Anbieter kennen einen Verweis, der abläuft (HTTP 410). Dann wird er
|
||||
* verworfen und von vorn abgeglichen — deshalb ist der Abgleich so gebaut,
|
||||
* dass ein vollständiger Durchlauf jederzeit möglich ist.
|
||||
*/
|
||||
|
||||
/** Wie weit in die Zukunft und Vergangenheit gelesen wird. */
|
||||
export const FENSTER_TAGE_ZURUECK = 7
|
||||
export const FENSTER_TAGE_VORAUS = 90
|
||||
|
||||
/** Höchstzahl Seiten je Runde – schützt vor einer Endlosschleife. */
|
||||
const SEITEN_GRENZE = 50
|
||||
|
||||
export type AbgleichBericht = {
|
||||
kalenderkontoId: string
|
||||
anzeigename: string
|
||||
geaendert: number
|
||||
entfernt: number
|
||||
ok: boolean
|
||||
fehler?: string
|
||||
}
|
||||
|
||||
type Konto = {
|
||||
id: string
|
||||
anbieter: KalenderAnbieter
|
||||
anzeigename: string
|
||||
kalenderId: string
|
||||
zugriffstoken: string | null
|
||||
aktualisierungstoken: string | null
|
||||
tokenGueltigBis: Date | null
|
||||
deltaVerweis: string | null
|
||||
}
|
||||
|
||||
/** Zeitfenster, das abgeglichen wird. */
|
||||
export function abgleichFenster(zeitpunkt: Date): { von: Date; bis: Date } {
|
||||
const heute = tagesBeginn(zeitpunkt)
|
||||
return {
|
||||
von: new Date(heute.getTime() - FENSTER_TAGE_ZURUECK * 86_400_000),
|
||||
bis: new Date(heute.getTime() + FENSTER_TAGE_VORAUS * 86_400_000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt ein gültiges Zugriffstoken zurück und erneuert es, wenn es bald
|
||||
* abläuft. Eine Minute Vorlauf, damit ein Abruf nicht mitten im Ablauf
|
||||
* scheitert.
|
||||
*/
|
||||
async function gueltigesToken(konto: Konto, zeitpunkt: Date): Promise<string> {
|
||||
const vorhanden = entschluesseln(konto.zugriffstoken)
|
||||
const laeuftBald =
|
||||
!konto.tokenGueltigBis ||
|
||||
konto.tokenGueltigBis.getTime() - zeitpunkt.getTime() < 60_000
|
||||
|
||||
if (vorhanden && !laeuftBald) return vorhanden
|
||||
|
||||
const aktualisierung = entschluesseln(konto.aktualisierungstoken)
|
||||
if (!aktualisierung) {
|
||||
throw new Error(
|
||||
'Kein Aktualisierungstoken hinterlegt. Das Konto muss neu verbunden werden.',
|
||||
)
|
||||
}
|
||||
|
||||
const satz = await tokenErneuern(konto.anbieter, aktualisierung, zeitpunkt)
|
||||
|
||||
await db.kalenderkonto.update({
|
||||
where: { id: konto.id },
|
||||
data: {
|
||||
zugriffstoken: verschluesseln(satz.zugriffstoken),
|
||||
...(satz.aktualisierungstoken
|
||||
? { aktualisierungstoken: verschluesseln(satz.aktualisierungstoken) }
|
||||
: {}),
|
||||
tokenGueltigBis: satz.gueltigBis,
|
||||
},
|
||||
})
|
||||
|
||||
return satz.zugriffstoken
|
||||
}
|
||||
|
||||
/** Adresse der ersten Seite einer Runde. */
|
||||
function ersteAdresse(konto: Konto, fenster: { von: Date; bis: Date }): string {
|
||||
if (konto.deltaVerweis) return konto.deltaVerweis
|
||||
|
||||
if (konto.anbieter === 'GRAPH') {
|
||||
// "standard" heißt: der Standardkalender des Postfachs.
|
||||
const pfad =
|
||||
konto.kalenderId === 'standard'
|
||||
? '/me/calendarView/delta'
|
||||
: `/me/calendars/${encodeURIComponent(konto.kalenderId)}/calendarView/delta`
|
||||
const parameter = new URLSearchParams({
|
||||
startDateTime: fenster.von.toISOString(),
|
||||
endDateTime: fenster.bis.toISOString(),
|
||||
})
|
||||
return `https://graph.microsoft.com/v1.0${pfad}?${parameter.toString()}`
|
||||
}
|
||||
|
||||
const kalender = encodeURIComponent(
|
||||
konto.kalenderId === 'standard' ? 'primary' : konto.kalenderId,
|
||||
)
|
||||
const parameter = new URLSearchParams({
|
||||
timeMin: fenster.von.toISOString(),
|
||||
timeMax: fenster.bis.toISOString(),
|
||||
// Serien werden aufgelöst; uns interessieren die einzelnen Vorkommen.
|
||||
singleEvents: 'true',
|
||||
showDeleted: 'true',
|
||||
maxResults: '250',
|
||||
})
|
||||
return `https://www.googleapis.com/calendar/v3/calendars/${kalender}/events?${parameter.toString()}`
|
||||
}
|
||||
|
||||
/** Adresse der nächsten Seite; Google liefert nur einen Token, keine URL. */
|
||||
function naechsteAdresse(konto: Konto, vorher: string, weiter: string): string {
|
||||
if (konto.anbieter === 'GRAPH') return weiter
|
||||
|
||||
const adresse = new URL(vorher)
|
||||
adresse.searchParams.set('pageToken', weiter)
|
||||
return adresse.toString()
|
||||
}
|
||||
|
||||
/** Verweis für die nächste Runde; Google gibt nur einen Token zurück. */
|
||||
function verweisFuerNaechsteRunde(
|
||||
konto: Konto,
|
||||
vorher: string,
|
||||
deltaVerweis: string,
|
||||
): string {
|
||||
if (konto.anbieter === 'GRAPH') return deltaVerweis
|
||||
|
||||
// Der syncToken verträgt sich nicht mit timeMin/timeMax – die müssen raus.
|
||||
const adresse = new URL(vorher)
|
||||
adresse.searchParams.delete('timeMin')
|
||||
adresse.searchParams.delete('timeMax')
|
||||
adresse.searchParams.delete('pageToken')
|
||||
adresse.searchParams.set('syncToken', deltaVerweis)
|
||||
return adresse.toString()
|
||||
}
|
||||
|
||||
async function seiteAbrufen(
|
||||
konto: Konto,
|
||||
adresse: string,
|
||||
token: string,
|
||||
): Promise<AbgleichSeite> {
|
||||
const kopfzeilen: Record<string, string> = { Authorization: `Bearer ${token}` }
|
||||
if (konto.anbieter === 'GRAPH') {
|
||||
// Ohne das liefert Graph Zeiten in der Zeitzone des Postfachs, und der
|
||||
// Text trägt keinen Versatz – die Umrechnung wäre geraten.
|
||||
kopfzeilen.Prefer = 'outlook.timezone="UTC", odata.maxpagesize=100'
|
||||
}
|
||||
|
||||
const antwort = await fetch(adresse, { headers: kopfzeilen })
|
||||
|
||||
if (antwort.status === 410) {
|
||||
throw new VerweisAbgelaufen(konto.anbieter)
|
||||
}
|
||||
if (!antwort.ok) {
|
||||
throw new Error(`Der Anbieter antwortete mit ${antwort.status}.`)
|
||||
}
|
||||
|
||||
const inhalt: unknown = await antwort.json()
|
||||
return konto.anbieter === 'GRAPH' ? graphSeiteLesen(inhalt) : googleSeiteLesen(inhalt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die gelesenen Termine in den örtlichen Spiegel.
|
||||
*
|
||||
* Googles Folgeabgleiche liefern auch Änderungen außerhalb unseres Fensters,
|
||||
* weil der syncToken die Zeitgrenzen nicht mitträgt. Was daneben liegt, wird
|
||||
* hier verworfen — sonst wüchse der Spiegel unbegrenzt.
|
||||
*/
|
||||
async function spiegeln(
|
||||
kalenderkontoId: string,
|
||||
seite: AbgleichSeite,
|
||||
fenster: { von: Date; bis: Date },
|
||||
): Promise<{ geaendert: number; entfernt: number }> {
|
||||
const imFenster = (termin: RohTermin): boolean =>
|
||||
termin.ende.getTime() > fenster.von.getTime() &&
|
||||
termin.beginn.getTime() < fenster.bis.getTime()
|
||||
|
||||
const zuSchreiben = seite.geaendert.filter(imFenster)
|
||||
const ausserhalb = seite.geaendert.filter((t) => !imFenster(t)).map((t) => t.extId)
|
||||
|
||||
for (const termin of zuSchreiben) {
|
||||
await db.termin.upsert({
|
||||
where: { kalenderkontoId_extId: { kalenderkontoId, extId: termin.extId } },
|
||||
update: {
|
||||
etag: termin.etag,
|
||||
betreff: termin.betreff,
|
||||
beginn: termin.beginn,
|
||||
ende: termin.ende,
|
||||
ganztaegig: termin.ganztaegig,
|
||||
zeigtAls: termin.zeigtAls,
|
||||
},
|
||||
create: {
|
||||
kalenderkontoId,
|
||||
extId: termin.extId,
|
||||
etag: termin.etag,
|
||||
betreff: termin.betreff,
|
||||
beginn: termin.beginn,
|
||||
ende: termin.ende,
|
||||
ganztaegig: termin.ganztaegig,
|
||||
zeigtAls: termin.zeigtAls,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const zuEntfernen = [...seite.entfernt, ...ausserhalb]
|
||||
const entfernt =
|
||||
zuEntfernen.length > 0
|
||||
? (
|
||||
await db.termin.deleteMany({
|
||||
where: { kalenderkontoId, extId: { in: zuEntfernen } },
|
||||
})
|
||||
).count
|
||||
: 0
|
||||
|
||||
return { geaendert: zuSchreiben.length, entfernt }
|
||||
}
|
||||
|
||||
/** Gleicht ein einzelnes Konto ab. */
|
||||
export async function kontoAbgleichen(
|
||||
kalenderkontoId: string,
|
||||
zeitpunkt: Date = jetzt(),
|
||||
): Promise<AbgleichBericht> {
|
||||
const konto = await db.kalenderkonto.findUnique({
|
||||
where: { id: kalenderkontoId },
|
||||
select: {
|
||||
id: true,
|
||||
anbieter: true,
|
||||
anzeigename: true,
|
||||
kalenderId: true,
|
||||
zugriffstoken: true,
|
||||
aktualisierungstoken: true,
|
||||
tokenGueltigBis: true,
|
||||
deltaVerweis: true,
|
||||
},
|
||||
})
|
||||
if (!konto) {
|
||||
return {
|
||||
kalenderkontoId,
|
||||
anzeigename: '(unbekannt)',
|
||||
geaendert: 0,
|
||||
entfernt: 0,
|
||||
ok: false,
|
||||
fehler: 'Dieses Kalenderkonto gibt es nicht mehr.',
|
||||
}
|
||||
}
|
||||
|
||||
const fenster = abgleichFenster(zeitpunkt)
|
||||
let geaendert = 0
|
||||
let entfernt = 0
|
||||
|
||||
try {
|
||||
const token = await gueltigesToken(konto, zeitpunkt)
|
||||
let adresse = ersteAdresse(konto, fenster)
|
||||
let neuerVerweis: string | null = null
|
||||
|
||||
for (let seitenzahl = 0; seitenzahl < SEITEN_GRENZE; seitenzahl++) {
|
||||
let seite: AbgleichSeite
|
||||
try {
|
||||
seite = await seiteAbrufen(konto, adresse, token)
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof VerweisAbgelaufen && konto.deltaVerweis) {
|
||||
// Von vorn: Verweis verwerfen und den vollen Zeitraum neu lesen.
|
||||
await db.kalenderkonto.update({
|
||||
where: { id: konto.id },
|
||||
data: { deltaVerweis: null },
|
||||
})
|
||||
await db.termin.deleteMany({ where: { kalenderkontoId: konto.id } })
|
||||
return kontoAbgleichen(kalenderkontoId, zeitpunkt)
|
||||
}
|
||||
throw fehler
|
||||
}
|
||||
|
||||
const bilanz = await spiegeln(konto.id, seite, fenster)
|
||||
geaendert += bilanz.geaendert
|
||||
entfernt += bilanz.entfernt
|
||||
|
||||
if (seite.deltaVerweis) {
|
||||
neuerVerweis = verweisFuerNaechsteRunde(konto, adresse, seite.deltaVerweis)
|
||||
break
|
||||
}
|
||||
if (!seite.weiter) break
|
||||
adresse = naechsteAdresse(konto, adresse, seite.weiter)
|
||||
}
|
||||
|
||||
await db.kalenderkonto.update({
|
||||
where: { id: konto.id },
|
||||
data: {
|
||||
...(neuerVerweis ? { deltaVerweis: neuerVerweis } : {}),
|
||||
letzterAbgleich: zeitpunkt,
|
||||
letzterFehler: null,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
kalenderkontoId: konto.id,
|
||||
anzeigename: konto.anzeigename,
|
||||
geaendert,
|
||||
entfernt,
|
||||
ok: true,
|
||||
}
|
||||
} catch (fehler) {
|
||||
const meldung =
|
||||
fehler instanceof Error ? fehler.message : 'Unbekannter Fehler beim Abgleich.'
|
||||
|
||||
await db.kalenderkonto.update({
|
||||
where: { id: konto.id },
|
||||
data: { letzterAbgleich: zeitpunkt, letzterFehler: meldung },
|
||||
})
|
||||
|
||||
return {
|
||||
kalenderkontoId: konto.id,
|
||||
anzeigename: konto.anzeigename,
|
||||
geaendert,
|
||||
entfernt,
|
||||
ok: false,
|
||||
fehler: meldung,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Gleicht alle aktiven Konten eines Benutzers ab. */
|
||||
export async function alleKontenAbgleichen(
|
||||
benutzerId: string,
|
||||
zeitpunkt: Date = jetzt(),
|
||||
): Promise<AbgleichBericht[]> {
|
||||
const konten = await db.kalenderkonto.findMany({
|
||||
where: { benutzerId, aktiv: true },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const berichte: AbgleichBericht[] = []
|
||||
for (const konto of konten) {
|
||||
berichte.push(await kontoAbgleichen(konto.id, zeitpunkt))
|
||||
}
|
||||
return berichte
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'server-only'
|
||||
import { z } from 'zod'
|
||||
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||
import { umgebung } from '@/server/umgebung'
|
||||
|
||||
/**
|
||||
* OAuth für die Kalenderanbieter.
|
||||
*
|
||||
* Bewusst mit blankem fetch statt mit einem SDK: es geht um drei Aufrufe je
|
||||
* Anbieter, und die Antworten werden ohnehin mit zod geprüft. Ein SDK würde
|
||||
* hier vor allem Abhängigkeiten und blinde Flecken hinzufügen.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const GRAPH_BEREICHE = ['offline_access', 'openid', 'email', 'Calendars.ReadWrite']
|
||||
const GOOGLE_BEREICHE = [
|
||||
'https://www.googleapis.com/auth/calendar.events',
|
||||
'https://www.googleapis.com/auth/calendar.readonly',
|
||||
'openid',
|
||||
'email',
|
||||
]
|
||||
|
||||
export type AnbieterEinstellung = {
|
||||
anbieter: KalenderAnbieter
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
autorisierungsUrl: string
|
||||
tokenUrl: string
|
||||
bereiche: string[]
|
||||
}
|
||||
|
||||
export function anbieterEinstellung(anbieter: KalenderAnbieter): AnbieterEinstellung {
|
||||
const einst = umgebung()
|
||||
|
||||
if (anbieter === 'GRAPH') {
|
||||
if (!einst.graphAktiv) {
|
||||
throw new Error('Microsoft Graph ist nicht eingerichtet (GRAPH_CLIENT_ID fehlt).')
|
||||
}
|
||||
const tenant = einst.GRAPH_TENANT_ID || 'organizations'
|
||||
return {
|
||||
anbieter,
|
||||
clientId: einst.GRAPH_CLIENT_ID,
|
||||
clientSecret: einst.GRAPH_CLIENT_SECRET,
|
||||
autorisierungsUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
|
||||
tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
|
||||
bereiche: GRAPH_BEREICHE,
|
||||
}
|
||||
}
|
||||
|
||||
if (!einst.googleAktiv) {
|
||||
throw new Error('Google ist nicht eingerichtet (GOOGLE_CLIENT_ID fehlt).')
|
||||
}
|
||||
return {
|
||||
anbieter,
|
||||
clientId: einst.GOOGLE_CLIENT_ID,
|
||||
clientSecret: einst.GOOGLE_CLIENT_SECRET,
|
||||
autorisierungsUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
tokenUrl: 'https://oauth2.googleapis.com/token',
|
||||
bereiche: GOOGLE_BEREICHE,
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Rückleit-URI, den es beim Anbieter zu hinterlegen gilt. */
|
||||
export function rueckleitUrl(anbieter: KalenderAnbieter): string {
|
||||
return `${umgebung().APP_URL}/api/kalender/rueckleitung/${anbieter.toLowerCase()}`
|
||||
}
|
||||
|
||||
/** Adresse, zu der der Browser für die Zustimmung geschickt wird. */
|
||||
export function autorisierungsAdresse(
|
||||
anbieter: KalenderAnbieter,
|
||||
zustand: string,
|
||||
): string {
|
||||
const einst = anbieterEinstellung(anbieter)
|
||||
const parameter = new URLSearchParams({
|
||||
client_id: einst.clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: rueckleitUrl(anbieter),
|
||||
scope: einst.bereiche.join(' '),
|
||||
state: zustand,
|
||||
})
|
||||
|
||||
if (anbieter === 'GOOGLE') {
|
||||
// Ohne beides gibt Google kein Aktualisierungstoken heraus – und ohne das
|
||||
// wäre die Verbindung nach einer Stunde wieder tot.
|
||||
parameter.set('access_type', 'offline')
|
||||
parameter.set('prompt', 'consent')
|
||||
} else {
|
||||
parameter.set('response_mode', 'query')
|
||||
}
|
||||
|
||||
return `${einst.autorisierungsUrl}?${parameter.toString()}`
|
||||
}
|
||||
|
||||
const tokenAntwort = z.looseObject({
|
||||
access_token: z.string().min(1),
|
||||
refresh_token: z.string().min(1).optional(),
|
||||
expires_in: z.coerce.number().int().positive().optional(),
|
||||
scope: z.string().optional(),
|
||||
id_token: z.string().optional(),
|
||||
token_type: z.string().optional(),
|
||||
})
|
||||
|
||||
export type Tokensatz = {
|
||||
zugriffstoken: string
|
||||
aktualisierungstoken: string | null
|
||||
gueltigBis: Date | null
|
||||
bereiche: string | null
|
||||
/** Aus dem id_token gelesen, wenn vorhanden – dient dem Wiedererkennen. */
|
||||
kennung: string | null
|
||||
}
|
||||
|
||||
/** Liest die E-Mail aus einem id_token, ohne die Signatur zu prüfen. */
|
||||
function kennungAus(idToken: string | undefined): string | null {
|
||||
if (!idToken) return null
|
||||
const teile = idToken.split('.')
|
||||
if (teile.length < 2) return null
|
||||
try {
|
||||
const nutzlast = JSON.parse(
|
||||
Buffer.from(teile[1] ?? '', 'base64url').toString('utf8'),
|
||||
) as Record<string, unknown>
|
||||
// Die Signatur wird bewusst nicht geprüft: das Token kommt gerade eben
|
||||
// über eine TLS-Verbindung direkt vom Anbieter, und der Wert dient nur
|
||||
// der Anzeige und dem Wiedererkennen – nie einer Berechtigung.
|
||||
const wert = nutzlast.email ?? nutzlast.preferred_username ?? nutzlast.upn
|
||||
return typeof wert === 'string' ? wert : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function tokenAbrufen(
|
||||
anbieter: KalenderAnbieter,
|
||||
koerper: URLSearchParams,
|
||||
jetzt: Date,
|
||||
): Promise<Tokensatz> {
|
||||
const einst = anbieterEinstellung(anbieter)
|
||||
koerper.set('client_id', einst.clientId)
|
||||
koerper.set('client_secret', einst.clientSecret)
|
||||
|
||||
const antwort = await fetch(einst.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: koerper,
|
||||
})
|
||||
|
||||
if (!antwort.ok) {
|
||||
// Der Antwortkörper enthält oft das Client-Secret im Klartext-Echo –
|
||||
// deshalb kommt hier nur der Status heraus.
|
||||
throw new Error(`Der Anbieter hat den Token-Aufruf mit ${antwort.status} abgelehnt.`)
|
||||
}
|
||||
|
||||
const gelesen = tokenAntwort.safeParse(await antwort.json())
|
||||
if (!gelesen.success) {
|
||||
throw new Error('Die Token-Antwort des Anbieters war nicht verwertbar.')
|
||||
}
|
||||
|
||||
return {
|
||||
zugriffstoken: gelesen.data.access_token,
|
||||
aktualisierungstoken: gelesen.data.refresh_token ?? null,
|
||||
gueltigBis: gelesen.data.expires_in
|
||||
? new Date(jetzt.getTime() + gelesen.data.expires_in * 1000)
|
||||
: null,
|
||||
bereiche: gelesen.data.scope ?? null,
|
||||
kennung: kennungAus(gelesen.data.id_token),
|
||||
}
|
||||
}
|
||||
|
||||
/** Tauscht den Code aus der Rückleitung gegen Tokens. */
|
||||
export async function codeEinloesen(
|
||||
anbieter: KalenderAnbieter,
|
||||
code: string,
|
||||
jetzt: Date = new Date(),
|
||||
): Promise<Tokensatz> {
|
||||
return tokenAbrufen(
|
||||
anbieter,
|
||||
new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: rueckleitUrl(anbieter),
|
||||
}),
|
||||
jetzt,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt ein frisches Zugriffstoken. Google schickt dabei kein neues
|
||||
* Aktualisierungstoken mit – das alte bleibt gültig und wird behalten.
|
||||
*/
|
||||
export async function tokenErneuern(
|
||||
anbieter: KalenderAnbieter,
|
||||
aktualisierungstoken: string,
|
||||
jetzt: Date = new Date(),
|
||||
): Promise<Tokensatz> {
|
||||
const satz = await tokenAbrufen(
|
||||
anbieter,
|
||||
new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: aktualisierungstoken,
|
||||
}),
|
||||
jetzt,
|
||||
)
|
||||
return {
|
||||
...satz,
|
||||
aktualisierungstoken: satz.aktualisierungstoken ?? aktualisierungstoken,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'server-only'
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||
import type { Faehigkeit } from '@/lib/instanz'
|
||||
import { umgebung } from '@/server/umgebung'
|
||||
|
||||
/**
|
||||
* Zustandswert für den OAuth-Ablauf.
|
||||
*
|
||||
* Er trägt Anbieter und Benutzer und ist mit AUTH_SECRET signiert. Ohne das
|
||||
* könnte jemand einen Rückleit-Aufruf unterschieben und ein fremdes
|
||||
* Kalenderkonto an das eigene Konto hängen.
|
||||
*/
|
||||
|
||||
const TRENNER = '.'
|
||||
|
||||
export function faehigkeitFuer(anbieter: KalenderAnbieter): Faehigkeit {
|
||||
return anbieter === 'GRAPH' ? 'kalender:graph' : 'kalender:google'
|
||||
}
|
||||
|
||||
export function istAnbieter(wert: string): wert is Lowercase<KalenderAnbieter> {
|
||||
return wert === 'graph' || wert === 'google'
|
||||
}
|
||||
|
||||
export function alsAnbieter(wert: string): KalenderAnbieter {
|
||||
return wert.toUpperCase() as KalenderAnbieter
|
||||
}
|
||||
|
||||
function signieren(nutzlast: string): string {
|
||||
return createHmac('sha256', umgebung().AUTH_SECRET).update(nutzlast).digest('base64url')
|
||||
}
|
||||
|
||||
/** Erzeugt einen signierten Zustandswert. */
|
||||
export function zustandErzeugen(
|
||||
anbieter: KalenderAnbieter,
|
||||
benutzerId: string,
|
||||
jetzt: Date = new Date(),
|
||||
): string {
|
||||
const nutzlast = Buffer.from(
|
||||
JSON.stringify({
|
||||
a: anbieter,
|
||||
b: benutzerId,
|
||||
z: jetzt.getTime(),
|
||||
n: randomBytes(9).toString('base64url'),
|
||||
}),
|
||||
'utf8',
|
||||
).toString('base64url')
|
||||
|
||||
return `${nutzlast}${TRENNER}${signieren(nutzlast)}`
|
||||
}
|
||||
|
||||
export type Zustand = { anbieter: KalenderAnbieter; benutzerId: string }
|
||||
|
||||
/** Gültigkeit des Zustandswerts – die Zustimmung dauert keine Stunde. */
|
||||
const ZUSTAND_GUELTIG_MINUTEN = 30
|
||||
|
||||
/**
|
||||
* Prüft den Zustandswert. Gibt null zurück, wenn er nicht stimmt, verändert
|
||||
* wurde oder zu alt ist.
|
||||
*/
|
||||
export function zustandLesen(
|
||||
wert: string | null,
|
||||
jetzt: Date = new Date(),
|
||||
): Zustand | null {
|
||||
if (!wert) return null
|
||||
|
||||
const [nutzlast, unterschrift] = wert.split(TRENNER)
|
||||
if (!nutzlast || !unterschrift) return null
|
||||
|
||||
const erwartet = Buffer.from(signieren(nutzlast), 'utf8')
|
||||
const erhalten = Buffer.from(unterschrift, 'utf8')
|
||||
if (erwartet.length !== erhalten.length) return null
|
||||
if (!timingSafeEqual(erwartet, erhalten)) return null
|
||||
|
||||
try {
|
||||
const inhalt = JSON.parse(Buffer.from(nutzlast, 'base64url').toString('utf8')) as {
|
||||
a?: unknown
|
||||
b?: unknown
|
||||
z?: unknown
|
||||
}
|
||||
|
||||
if (typeof inhalt.a !== 'string' || typeof inhalt.b !== 'string') return null
|
||||
if (typeof inhalt.z !== 'number') return null
|
||||
if (jetzt.getTime() - inhalt.z > ZUSTAND_GUELTIG_MINUTEN * 60_000) return null
|
||||
if (inhalt.a !== 'GRAPH' && inhalt.a !== 'GOOGLE') return null
|
||||
|
||||
return { anbieter: inhalt.a, benutzerId: inhalt.b }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user