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 ?? '',
),
)
}
}
+356
View File
@@ -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
}
+209
View File
@@ -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,
}
}
+91
View File
@@ -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
}
}
+16
View File
@@ -30,6 +30,17 @@ const schema = z.object({
WEBAUTHN_RP_NAME: z.string().min(1).default('Planer'), WEBAUTHN_RP_NAME: z.string().min(1).default('Planer'),
WEBAUTHN_ORIGIN: z.url(), 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), DEMO_MODUS: jaNein.default(false),
}) })
@@ -37,6 +48,9 @@ export type Umgebung = z.infer<typeof schema> & {
instanz: Instanz instanz: Instanz
/** OIDC ist nur eingerichtet, wenn alle vier Werte gesetzt sind. */ /** OIDC ist nur eingerichtet, wenn alle vier Werte gesetzt sind. */
oidcAktiv: boolean oidcAktiv: boolean
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
graphAktiv: boolean
googleAktiv: boolean
} }
let zwischengespeichert: Umgebung | null = null let zwischengespeichert: Umgebung | null = null
@@ -56,6 +70,8 @@ function lesen(): Umgebung {
oidcAktiv: Boolean( oidcAktiv: Boolean(
werte.OIDC_ISSUER && werte.OIDC_CLIENT_ID && werte.OIDC_CLIENT_SECRET, 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),
} }
} }