diff --git a/src/app/api/kalender/rueckleitung/[anbieter]/route.ts b/src/app/api/kalender/rueckleitung/[anbieter]/route.ts new file mode 100644 index 0000000..fd4a449 --- /dev/null +++ b/src/app/api/kalender/rueckleitung/[anbieter]/route.ts @@ -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') + } +} diff --git a/src/app/api/kalender/verbinden/[anbieter]/route.ts b/src/app/api/kalender/verbinden/[anbieter]/route.ts new file mode 100644 index 0000000..1e85795 --- /dev/null +++ b/src/app/api/kalender/verbinden/[anbieter]/route.ts @@ -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 ?? '', + ), + ) + } +} diff --git a/src/server/kalender/abgleich.ts b/src/server/kalender/abgleich.ts new file mode 100644 index 0000000..26fe4e7 --- /dev/null +++ b/src/server/kalender/abgleich.ts @@ -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 { + 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 { + const kopfzeilen: Record = { 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 { + 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 { + 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 +} diff --git a/src/server/kalender/oauth.ts b/src/server/kalender/oauth.ts new file mode 100644 index 0000000..81c524e --- /dev/null +++ b/src/server/kalender/oauth.ts @@ -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 + // 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 { + 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 { + 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 { + const satz = await tokenAbrufen( + anbieter, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: aktualisierungstoken, + }), + jetzt, + ) + return { + ...satz, + aktualisierungstoken: satz.aktualisierungstoken ?? aktualisierungstoken, + } +} diff --git a/src/server/kalender/verbinden.ts b/src/server/kalender/verbinden.ts new file mode 100644 index 0000000..4ac68e6 --- /dev/null +++ b/src/server/kalender/verbinden.ts @@ -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 { + 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 + } +} diff --git a/src/server/umgebung.ts b/src/server/umgebung.ts index 5900dfb..533c9a4 100644 --- a/src/server/umgebung.ts +++ b/src/server/umgebung.ts @@ -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 & { 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), } }