From b6db9254a9ca2bc771d8b4ad9deedcac30ec8e8f Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 3 Sep 2026 20:10:50 +0200 Subject: [PATCH] fix: fehlende Dateien aus vorherigem Commit nachreichen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSRF-Schutz, DHL-Versandmarken, Entra-ID-SSO, ApiCore-Sync und die Harvest-isPublished-Migration waren gestaged aber nicht im letzten Commit enthalten — Gitea-Actions-Build brach mit "Module not found" fuer genau diese Dateien ab. Co-Authored-By: Claude Sonnet 5 --- .../migration.sql | 8 + src/app/admin/actions/apicore-sync.ts | 127 +++++++ src/app/admin/actions/dhl.ts | 104 ++++++ src/app/api/auth/entra/callback/route.ts | 145 ++++++++ src/app/api/auth/entra/login/route.ts | 26 ++ src/lib/apicore-client.ts | 134 +++++++ src/lib/csrf.ts | 30 ++ src/lib/dhl.ts | 348 ++++++++++++++++++ src/lib/entra.ts | 259 +++++++++++++ src/lib/social-links.ts | 24 ++ 10 files changed, 1205 insertions(+) create mode 100644 prisma/migrations/20260903130000_add_harvest_is_published/migration.sql create mode 100644 src/app/admin/actions/apicore-sync.ts create mode 100644 src/app/admin/actions/dhl.ts create mode 100644 src/app/api/auth/entra/callback/route.ts create mode 100644 src/app/api/auth/entra/login/route.ts create mode 100644 src/lib/apicore-client.ts create mode 100644 src/lib/csrf.ts create mode 100644 src/lib/dhl.ts create mode 100644 src/lib/entra.ts create mode 100644 src/lib/social-links.ts diff --git a/prisma/migrations/20260903130000_add_harvest_is_published/migration.sql b/prisma/migrations/20260903130000_add_harvest_is_published/migration.sql new file mode 100644 index 0000000..7b464a1 --- /dev/null +++ b/prisma/migrations/20260903130000_add_harvest_is_published/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +-- Draft/Publish-Workflow fuer Ernten: Default true, damit alle VOR dieser +-- Migration angelegten Ernten (bislang implizit oeffentlich) sichtbar +-- bleiben. Neu aus ApiCore synchronisierte Ernten werden von der +-- Sync-Action explizit mit isPublished = false angelegt (siehe +-- admin/actions/apicore-sync.ts) und muessen erst von einem Admin ueber +-- "Veroeffentlichen" freigegeben werden. +ALTER TABLE "harvests" ADD COLUMN "isPublished" BOOLEAN NOT NULL DEFAULT true; diff --git a/src/app/admin/actions/apicore-sync.ts b/src/app/admin/actions/apicore-sync.ts new file mode 100644 index 0000000..5813f2f --- /dev/null +++ b/src/app/admin/actions/apicore-sync.ts @@ -0,0 +1,127 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { logAdminAction } from "@/lib/audit-log"; +import { + fetchApiCoreHarvests, + ApiCoreConfigError, + ApiCoreRequestError, + type ApiCoreHarvestWithApiaryName, +} from "@/lib/apicore-client"; +import { requireAdmin } from "./_shared"; + +/** + * Stabiler, eindeutiger Slug aus der ApiCore-Ernte-ID statt aus Typ/Jahr + * abgeleitet (wie beim manuellen createHarvestAction) — dient hier zugleich + * als Idempotenz-Schluessel: ein wiederholter Sync erkennt an diesem Slug, + * ob eine ApiCore-Ernte schon einmal importiert wurde, statt bei jedem Lauf + * Duplikate anzulegen. + */ +function slugFor(sourceId: string): string { + return `apicore-${sourceId}`; +} + +/** + * Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer + * kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den + * vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert. + */ +function buildDescription(source: ApiCoreHarvestWithApiaryName): string { + const parts: string[] = []; + if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`); + if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`); + if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`); + if (source.notes?.trim()) parts.push(source.notes.trim()); + const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen."; + return `Automatisch aus ApiCore importiert. ${detail}`; +} + +/** + * Zieht Ernten aus ApiCore und legt sie als unveroeffentlichte Entwuerfe an + * (isPublished: false) bzw. aktualisiert bereits vorhandene, noch + * unveroeffentlichte Entwuerfe. Bereits vom Admin veroeffentlichte, + * frueher synchronisierte Ernten werden NICHT angefasst — sonst wuerde ein + * erneuter Sync eine bewusste Freigabe- und Bearbeitungsentscheidung des + * Admins stillschweigend zuruecksetzen. + * + * Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B. + * "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig") + * sind KEINE aequivalenten Taxonomien — es findet bewusst keine automatische + * Uebersetzung statt, der Rohwert wird uebernommen und muss vom Admin beim + * Review korrigiert werden. + */ +export async function syncHarvestsFromApiCoreAction(): Promise { + const admin = await requireAdmin(); + + let sourceHarvests: ApiCoreHarvestWithApiaryName[]; + try { + sourceHarvests = await fetchApiCoreHarvests(); + } catch (error: unknown) { + const message = + error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError + ? error.message + : error instanceof Error + ? error.message + : "Unbekannter Fehler"; + redirect(`/admin/ernten?syncError=${encodeURIComponent(message)}`); + } + + let created = 0; + let updated = 0; + let skipped = 0; + let failed = 0; + + for (const source of sourceHarvests) { + const slug = slugFor(source.id); + const date = new Date(source.date); + if (Number.isNaN(date.getTime())) { + failed++; + console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`); + continue; + } + const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`; + + try { + const existing = await prisma.harvest.findUnique({ where: { slug } }); + + if (existing?.isPublished) { + skipped++; + continue; + } + + const data = { + year: date.getFullYear(), + type, + harvestDate: date, + location: source.apiaryName ?? "Altenmünster", + description: buildDescription(source), + }; + + if (existing) { + await prisma.harvest.update({ where: { slug }, data }); + updated++; + } else { + await prisma.harvest.create({ data: { ...data, slug, isPublished: false } }); + created++; + } + } catch (error: unknown) { + failed++; + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + // z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten + // im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer + // den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision. + console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${message}`); + } + } + + await logAdminAction(admin, { + action: "harvest.sync_apicore", + entityType: "Harvest", + detail: `${created} neu, ${updated} aktualisiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`, + }); + + revalidatePath("/admin/ernten"); + redirect(`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}`); +} diff --git a/src/app/admin/actions/dhl.ts b/src/app/admin/actions/dhl.ts new file mode 100644 index 0000000..ece5bf3 --- /dev/null +++ b/src/app/admin/actions/dhl.ts @@ -0,0 +1,104 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { logAdminAction } from "@/lib/audit-log"; +import { getCompanyInfo } from "@/lib/company-info"; +import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl"; +import { requireAdmin, fail, str } from "./_shared"; + +/** + * Erstellt per DHL Parcel DE Shipping API eine Versandmarke fuer eine + * bezahlte Bestellung und speichert die zurueckgegebene Sendungsnummer im + * Shipment-Datensatz (carrier="DHL"). Idempotent: ist bereits eine + * Sendungsnummer erfasst, wird KEINE zweite Marke erzeugt (das wuerde bei + * DHL echte Kosten verursachen) — die Aktion bricht in dem Fall mit einer + * klaren Meldung ab, statt stillschweigend zu ueberschreiben. + */ +export async function createDhlLabelAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const orderId = str(formData, "orderId"); + const detailPath = `/admin/bestellungen/${orderId}`; + + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { + items: { select: { weightGrams: true, quantity: true } }, + payment: { select: { status: true } }, + shipment: { select: { trackingNumber: true } }, + }, + }); + if (!order) { + fail("/admin/bestellungen", "Bestellung nicht gefunden."); + } + + if (order.fulfillmentMethod !== "VERSAND") { + fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig."); + } + if (order.payment?.status !== "SUCCEEDED") { + fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden."); + } + if (order.shipment?.trackingNumber) { + fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst."); + } + + const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0); + const company = await getCompanyInfo(); + + let result: DhlCreateShipmentResult; + try { + result = await createDhlShipmentLabel({ + reference: order.orderNumber, + weightGrams: totalWeightGrams, + shipper: { + companyName: company.name, + firstName: "", + lastName: "", + street: company.address.street, + zip: company.address.zip, + city: company.address.city, + // Firmensitz ist Deutschland (siehe lib/site-config.ts) — bewusst + // fest verdrahtet statt aus dem lokalisierten Anzeigetext + // ("Deutschland") geparst. + country: "DE", + phone: company.phone, + email: company.email, + }, + receiver: { + firstName: order.firstName, + lastName: order.lastName, + street: order.street, + zip: order.zip, + city: order.city, + country: order.country, + phone: order.phone, + email: order.email, + }, + }); + } catch (error: unknown) { + if (error instanceof DhlNotConfiguredError) { + fail(detailPath, error.message); + } + if (error instanceof DhlApiError) { + fail(detailPath, error.message); + } + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`); + } + + await prisma.shipment.upsert({ + where: { orderId }, + create: { orderId, carrier: "DHL", trackingNumber: result.trackingNumber }, + update: { carrier: "DHL", trackingNumber: result.trackingNumber }, + }); + + await logAdminAction(admin, { + action: "order.dhl_label_created", + entityType: "Order", + entityId: orderId, + detail: `${order.orderNumber}: DHL-Sendungsnummer ${result.trackingNumber}`, + }); + + revalidatePath("/admin/bestellungen"); + revalidatePath(detailPath); +} diff --git a/src/app/api/auth/entra/callback/route.ts b/src/app/api/auth/entra/callback/route.ts new file mode 100644 index 0000000..751fdcd --- /dev/null +++ b/src/app/api/auth/entra/callback/route.ts @@ -0,0 +1,145 @@ +import { randomBytes } from "node:crypto"; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getBaseUrl } from "@/lib/url"; +import { createSession, hashPassword, getEntraOAuthCookie, clearEntraOAuthCookie } from "@/lib/auth"; +import { + isEntraConfigured, + exchangeEntraAuthorizationCode, + verifyEntraIdToken, + getVerifiedEmail, + EntraAuthError, + EntraNotConfiguredError, + type EntraIdTokenClaims, +} from "@/lib/entra"; +import { rateLimit, getClientIp } from "@/lib/rate-limit"; + +const CALLBACK_ATTEMPTS_PER_WINDOW = 20; +const CALLBACK_WINDOW_MS = 15 * 60 * 1000; + +function firstNameFromClaims(claims: EntraIdTokenClaims): string { + const value = claims.given_name?.trim() || claims.name?.trim().split(/\s+/)[0] || "Kundin/Kunde"; + return value.slice(0, 100); +} + +function lastNameFromClaims(claims: EntraIdTokenClaims): string { + const fromName = claims.name?.trim().split(/\s+/).slice(1).join(" "); + const value = claims.family_name?.trim() || (fromName && fromName.length > 0 ? fromName : "—"); + return value.slice(0, 100); +} + +/** + * Callback fuer "Mit Microsoft anmelden" — tauscht den Autorisierungscode + * gegen Tokens, verifiziert das ID-Token serverseitig (Signatur/issuer/ + * audience, siehe lib/entra.ts) und meldet die Kundin/den Kunden an. Legt + * bei erstmaliger Anmeldung automatisch ein Customer-Konto (Rolle KUNDE) an. + * + * Sicherheitskritisch: Es wird AUSSCHLIESSLICH die verifizierte E-Mail aus + * dem signierten ID-Token verwendet, nie ein Query-Parameter oder anderer + * Client-Wert — sonst koennte sich jemand als beliebige E-Mail-Adresse + * ausgeben und ein fremdes Konto uebernehmen/anlegen. + */ +export async function GET(request: Request) { + const baseUrl = getBaseUrl(); + + function redirectWithError(message: string): NextResponse { + const url = new URL("/konto", baseUrl); + url.searchParams.set("error", message); + return NextResponse.redirect(url); + } + + if (!isEntraConfigured()) { + return redirectWithError("SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an."); + } + + const ip = await getClientIp(); + if (!rateLimit(`entra-callback:${ip}`, CALLBACK_ATTEMPTS_PER_WINDOW, CALLBACK_WINDOW_MS)) { + return redirectWithError("Zu viele Anmeldeversuche. Bitte versuche es später erneut."); + } + + const requestUrl = new URL(request.url); + const providerError = requestUrl.searchParams.get("error"); + const code = requestUrl.searchParams.get("code"); + const returnedState = requestUrl.searchParams.get("state"); + + // Das state/code_verifier-Cookie ist Einmalgebrauch — sofort loeschen, + // unabhaengig davon, ob der Rest der Pruefung gleich erfolgreich ist. + const stored = await getEntraOAuthCookie(); + await clearEntraOAuthCookie(); + + if (providerError) { + return redirectWithError("Die Microsoft-Anmeldung wurde abgebrochen."); + } + if (!code || !returnedState || !stored || stored.state !== returnedState) { + return redirectWithError("Die Anmeldung ist abgelaufen oder ungültig. Bitte versuche es erneut."); + } + + let claims: EntraIdTokenClaims; + try { + const tokens = await exchangeEntraAuthorizationCode({ code, codeVerifier: stored.codeVerifier }); + claims = await verifyEntraIdToken(tokens.id_token); + } catch (error: unknown) { + const message = + error instanceof EntraAuthError || error instanceof EntraNotConfiguredError + ? error.message + : "Microsoft-Anmeldung fehlgeschlagen."; + return redirectWithError(message); + } + + const email = getVerifiedEmail(claims); + if (!email) { + return redirectWithError( + "Dein Microsoft-Konto hat keine verifizierte E-Mail-Adresse hinterlegt. Bitte melde dich mit E-Mail und Passwort an oder wende dich an uns." + ); + } + + let customer = await prisma.customer.findUnique({ where: { email } }); + + if (customer?.blockedAt) { + return redirectWithError("Dieses Konto wurde gesperrt. Bitte kontaktiere uns."); + } + + if (!customer) { + // SSO-Konten haben kein von uns gesetztes Passwort. Statt passwordHash + // per Migration nullable zu machen (und damit ueberall im Code, wo + // Customer.passwordHash als garantiert vorhanden gilt — Login, + // Passwort-Reset, Datenexport, 2FA — Nullable-Handling nachzuruesten), + // erhaelt das Konto denselben "unbenutzbaren Zufalls-Hash" wie von + // Admins ohne Passwort angelegte Konten (siehe + // admin/actions/customers.ts createCustomerAction): ein bcrypt-Hash + // ueber 32 zufaellige Bytes, die nirgends gespeichert werden. Ein Login + // per Passwort ist damit praktisch unmoeglich; Zugang bleibt + // ausschliesslich ueber "Mit Microsoft anmelden" (oder "Passwort + // vergessen", falls spaeter ein echtes Passwort gewuenscht ist). + const unusablePassword = randomBytes(32).toString("hex"); + customer = await prisma.customer.create({ + data: { + email, + firstName: firstNameFromClaims(claims), + lastName: lastNameFromClaims(claims), + passwordHash: await hashPassword(unusablePassword), + // Microsoft hat die Adresse bereits verifiziert (signierter + // "email"-Claim) — eine zusaetzliche eigene Bestaetigungsmail waere + // redundant. + emailVerifiedAt: new Date(), + }, + }); + } else if (!customer.emailVerifiedAt) { + // Bestehendes Konto (z.B. aus einer fruehen Registrierung oder + // Gastbestellung mit spaeter angelegtem Konto) meldet sich erstmals per + // SSO an — Microsoft hat die Adresse soeben verifiziert, das muss nicht + // zusaetzlich per E-Mail-Link bestaetigt werden. + customer = await prisma.customer.update({ + where: { id: customer.id }, + data: { emailVerifiedAt: new Date() }, + }); + } + + // Anmeldung per SSO wird bewusst als vollwertig authentifiziert behandelt + // und ueberspringt die App-eigene 2FA-Abfrage (falls fuer das Konto + // aktiviert): Microsoft ist hier die pruefende Authentifizierungsstelle, + // deren eigene Absicherung (bis hin zu Microsofts eigener MFA) die Rolle + // unseres TOTP-Schritts uebernimmt. + await createSession(customer.id, customer.role, customer.sessionVersion); + return NextResponse.redirect(new URL("/konto", baseUrl)); +} diff --git a/src/app/api/auth/entra/login/route.ts b/src/app/api/auth/entra/login/route.ts new file mode 100644 index 0000000..b6daa63 --- /dev/null +++ b/src/app/api/auth/entra/login/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { getBaseUrl } from "@/lib/url"; +import { setEntraOAuthCookie } from "@/lib/auth"; +import { signEntraOAuthState } from "@/lib/session"; +import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOAuthState } from "@/lib/entra"; + +/** + * Startet die Microsoft-Anmeldung ("Mit Microsoft anmelden" auf /konto): + * erzeugt PKCE-Paar + CSRF-state, hinterlegt beides in einem kurzlebigen, + * signierten Cookie (siehe lib/session.ts ENTRA_OAUTH_COOKIE) und leitet zu + * Microsofts /authorize-Endpunkt weiter. + */ +export async function GET() { + if (!isEntraConfigured()) { + const url = new URL("/konto", getBaseUrl()); + url.searchParams.set("error", "SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an."); + return NextResponse.redirect(url); + } + + const { codeVerifier, codeChallenge } = generatePkcePair(); + const state = generateOAuthState(); + const cookieToken = await signEntraOAuthState(state, codeVerifier); + await setEntraOAuthCookie(cookieToken); + + return NextResponse.redirect(buildEntraAuthorizeUrl({ state, codeChallenge })); +} diff --git a/src/lib/apicore-client.ts b/src/lib/apicore-client.ts new file mode 100644 index 0000000..ba636ab --- /dev/null +++ b/src/lib/apicore-client.ts @@ -0,0 +1,134 @@ +import { z } from "zod"; + +/** + * HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter + * .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB), + * die die "Ernten"-Rohdaten fuer den Admin-Sync bereitstellt + * (admin/actions/apicore-sync.ts). + * + * Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT + * (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen + * ("harvests:read" fuer GET /harvests). Es gibt dort AKTUELL KEINEN + * API-Key-/Service-Account-Mechanismus fuer maschinelle Zugriffe — nur den + * interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt + * (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen + * automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto + * mit ausschliesslich den Berechtigungen "harvests:read" (und optional + * "apiaries:read", fuer Standortnamen) empfohlen statt eines echten + * Admin-Zugangs. + * + * Benoetigte Umgebungsvariablen (siehe .env.example): + * APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix + * "api" laut ApiCore-Backend/src/app.config.ts) + * APICORE_SYNC_EMAIL E-Mail des Service-Kontos + * APICORE_SYNC_PASSWORD Passwort des Service-Kontos + * + * Ohne diese drei Variablen wirft fetchApiCoreHarvests() sofort einen + * ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message + * dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen. + */ + +export class ApiCoreConfigError extends Error {} +export class ApiCoreRequestError extends Error {} + +const loginResponseSchema = z.union([ + z.object({ accessToken: z.string().min(1) }), + z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }), +]); + +const apiarySchema = z.object({ id: z.string(), name: z.string() }); + +const harvestSchema = z.object({ + id: z.string(), + apiaryId: z.string(), + date: z.string(), + honeyType: z.string().nullable().optional(), + honeySuperCount: z.number().nullable().optional(), + grossWeight: z.number().nullable().optional(), + netWeight: z.number().nullable().optional(), + notes: z.string().nullable().optional(), +}); + +export type ApiCoreHarvest = z.infer; + +export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest { + apiaryName: string | null; +} + +function getConfig(): { baseUrl: string; email: string; password: string } { + const baseUrl = process.env.APICORE_API_URL; + const email = process.env.APICORE_SYNC_EMAIL; + const password = process.env.APICORE_SYNC_PASSWORD; + if (!baseUrl || !email || !password) { + throw new ApiCoreConfigError( + "ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL, APICORE_SYNC_EMAIL und APICORE_SYNC_PASSWORD setzen (siehe .env.example)." + ); + } + return { baseUrl: baseUrl.replace(/\/$/, ""), email, password }; +} + +async function login(baseUrl: string, email: string, password: string): Promise { + let res: Response; + try { + res = await fetch(`${baseUrl}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`); + } + if (!res.ok) { + throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`); + } + const parsed = loginResponseSchema.safeParse(await res.json()); + if (!parsed.success) { + throw new ApiCoreRequestError("ApiCore-Login: unerwartete Antwortstruktur."); + } + if ("mfaRequired" in parsed.data) { + throw new ApiCoreConfigError( + "Das konfigurierte ApiCore-Sync-Konto hat MFA aktiviert. Fuer den automatisierten Sync wird ein MFA-freies Service-Konto benoetigt (siehe Kommentar in apicore-client.ts)." + ); + } + return parsed.data.accessToken; +} + +/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */ +async function fetchApiaryNames(baseUrl: string, token: string): Promise> { + try { + const res = await fetch(`${baseUrl}/apiaries`, { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) return new Map(); + const parsed = z.array(apiarySchema).safeParse(await res.json()); + if (!parsed.success) return new Map(); + return new Map(parsed.data.map((a) => [a.id, a.name])); + } catch { + return new Map(); + } +} + +export async function fetchApiCoreHarvests(): Promise { + const { baseUrl, email, password } = getConfig(); + const token = await login(baseUrl, email, password); + + let res: Response; + try { + res = await fetch(`${baseUrl}/harvests`, { headers: { Authorization: `Bearer ${token}` } }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`); + } + if (!res.ok) { + throw new ApiCoreRequestError(`ApiCore: Ernten konnten nicht geladen werden (HTTP ${res.status}).`); + } + const parsed = z.array(harvestSchema).safeParse(await res.json()); + if (!parsed.success) { + throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Ernten."); + } + + const apiaryNames = await fetchApiaryNames(baseUrl, token); + return parsed.data.map((harvest) => ({ + ...harvest, + apiaryName: apiaryNames.get(harvest.apiaryId) ?? null, + })); +} diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts new file mode 100644 index 0000000..2c95ab5 --- /dev/null +++ b/src/lib/csrf.ts @@ -0,0 +1,30 @@ +/** + * Same-Origin-Pruefung fuer zustandsaendernde app/api/.../route.ts-Handler. + * + * Next.js' eingebauter CSRF-Schutz (Origin-Header gegen Host geprueft) gilt + * nur fuer Server Actions ("use server") — eigene Route-Handler wie + * /api/checkout sind normale POST-Endpunkte ohne diesen automatischen Schutz + * und muessen ihn sich selbst holen. Ohne diese Pruefung liesse sich ein + * state-aendernder POST z.B. per
als "einfacher" + * (nicht preflight-pflichtiger) Cross-Origin-Request faelschen (bekannte + * JSON-CSRF-Umgehung) — bei /api/checkout koennte das, sofern der Besuch von + * einer eingeloggten Kundschaft ausgeht, Bestellungen unter deren Konto + * anlegen und Lagerbestand reservieren, ohne dass die Kundschaft das wollte. + */ +export function isTrustedOrigin(request: Request): boolean { + const origin = request.headers.get("origin"); + // Kein Origin-Header: moderne Browser senden ihn bei POST/PUT/DELETE sowohl + // fuer Cross-Origin- als auch fuer Same-Origin-Anfragen mit, ein fehlender + // Header stammt also nicht von einem regulaeren browserbasierten + // Cross-Site-Angriff. Wird hier bewusst durchgelassen, um serverseitige/ + // Tool-Aufrufe (z.B. Health-Checks) nicht zu blockieren. + if (!origin) return true; + + try { + const requestHost = new URL(request.url).host; + const originHost = new URL(origin).host; + return requestHost === originHost; + } catch { + return false; + } +} diff --git a/src/lib/dhl.ts b/src/lib/dhl.ts new file mode 100644 index 0000000..eba90c6 --- /dev/null +++ b/src/lib/dhl.ts @@ -0,0 +1,348 @@ +/** + * Client fuer die DHL Parcel DE Shipping API v2 ("Post & Parcel Germany", + * developer.dhl.com) — erstellt Versandmarken (Labels) fuer nationale + * DHL-Paketsendungen. + * + * Authentifizierung laut aktueller DHL-Dokumentation (Stand: Recherche + * September 2026, developer.dhl.com/api-reference/parcel-de-shipping-post-parcel-germany-v2): + * Zwei Faktoren sind noetig, nicht nur ein API-Key: + * 1. Ein OAuth2-Access-Token ueber den ROPC-Grant (Resource Owner Password + * Credentials) unter POST /parcel/de/account/auth/ropc/v1/token, mit + * App-Client-ID/-Secret UND den Zugangsdaten des DHL-Geschaeftskunden- + * portals (GKP-Benutzername/-Passwort) im Body. + * 2. Der App-API-Key zusaetzlich als "dhl-api-key"-Header auf jedem + * eigentlichen API-Aufruf (identifiziert die registrierte App/Quota). + * DHL kuendigt an, das aeltere reine Basic-Auth-Schema mittelfristig + * abzuschaffen — dieser Client verwendet daher bewusst den OAuth2/ROPC-Weg. + * + * Sandbox-Basis-URL: https://api-sandbox.dhl.com + * Produktions-Basis-URL: https://api-eu.dhl.com + * + * WICHTIG: Die genauen Feldnamen/Endpunkte wurden anhand der oeffentlichen + * API-Referenz und des zugehoerigen OpenAPI-Schemas recherchiert, konnten in + * dieser Umgebung aber nicht gegen einen echten Sandbox-Account getestet + * werden (keine DHL-Zugangsdaten vorhanden). Vor dem produktiven Einsatz: + * einmal mit echten Sandbox-Zugangsdaten (siehe .env.example) gegen + * https://developer.dhl.com testen und bei Abweichungen die Request-/ + * Response-Shape unten anpassen — DHL liefert bei Validierungsfehlern + * sprechende Fehlermeldungen, die per DhlApiError durchgereicht werden. + */ + +// --------------------------------------------------------------------------- +// Fehlerklassen +// --------------------------------------------------------------------------- + +/** Wird geworfen, wenn eine/mehrere der benoetigten DHL_*-Umgebungsvariablen fehlen. */ +export class DhlNotConfiguredError extends Error { + constructor() { + super( + "DHL ist noch nicht konfiguriert. Bitte DHL_API_KEY, DHL_API_SECRET, DHL_GKP_USERNAME, " + + "DHL_GKP_PASSWORD und DHL_ACCOUNT_NUMBER in der .env-Datei setzen (siehe .env.example)." + ); + this.name = "DhlNotConfiguredError"; + } +} + +/** Wird bei einem fehlgeschlagenen Aufruf der DHL-API geworfen (Auth, Validierung, Netzwerk, ...). */ +export class DhlApiError extends Error { + constructor(message: string) { + super(message); + this.name = "DhlApiError"; + } +} + +// --------------------------------------------------------------------------- +// Konfiguration +// --------------------------------------------------------------------------- + +interface DhlConfig { + apiKey: string; + apiSecret: string; + gkpUsername: string; + gkpPassword: string; + /** + * "billingNumber" — 14-stellig: 10-stellige EKP (Kundennummer) + 2-stelliges + * Verfahren + 2-stellige Teilnahme, z.B. "33333333330101" fuer V01PAK. + * Wird vom DHL-Vertrieb im Rahmen des Geschaeftskundenvertrags vergeben. + */ + accountNumber: string; + baseUrl: string; +} + +function getDhlConfig(): DhlConfig { + const apiKey = process.env.DHL_API_KEY; + const apiSecret = process.env.DHL_API_SECRET; + const gkpUsername = process.env.DHL_GKP_USERNAME; + const gkpPassword = process.env.DHL_GKP_PASSWORD; + const accountNumber = process.env.DHL_ACCOUNT_NUMBER; + + if (!apiKey || !apiSecret || !gkpUsername || !gkpPassword || !accountNumber) { + throw new DhlNotConfiguredError(); + } + + const environment = (process.env.DHL_ENVIRONMENT ?? "sandbox").trim().toLowerCase(); + const baseUrl = environment === "production" ? "https://api-eu.dhl.com" : "https://api-sandbox.dhl.com"; + + return { apiKey, apiSecret, gkpUsername, gkpPassword, accountNumber, baseUrl }; +} + +/** Fuer die bedingte Anzeige/Deaktivierung des "Versandmarke erstellen"-Buttons im Admin-UI. */ +export function isDhlConfigured(): boolean { + try { + getDhlConfig(); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// OAuth2-Access-Token (ROPC), pro Serverprozess zwischengespeichert +// --------------------------------------------------------------------------- + +interface CachedToken { + accessToken: string; + expiresAt: number; +} + +// Modul-weiter In-Memory-Cache: analog zu lib/rate-limit.ts ausreichend fuer +// den Single-Container-Betrieb dieser Seite. Bei horizontaler Skalierung +// wuerde jede Instanz ihr eigenes Token ziehen, was fuer DHL unproblematisch +// ist (kein geteilter Zustand noetig). +let cachedToken: CachedToken | null = null; + +async function fetchAccessToken(config: DhlConfig): Promise { + let response: Response; + try { + response = await fetch(`${config.baseUrl}/parcel/de/account/auth/ropc/v1/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams({ + grant_type: "password", + username: config.gkpUsername, + password: config.gkpPassword, + client_id: config.apiKey, + client_secret: config.apiSecret, + }), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler"; + throw new DhlApiError(`DHL-Authentifizierung nicht erreichbar: ${message}`); + } + + const payload = await safeReadJson(response); + if (!response.ok) { + throw new DhlApiError( + `DHL-Authentifizierung fehlgeschlagen (HTTP ${response.status}): ${extractErrorDetail(payload) ?? "unbekannter Fehler"}` + ); + } + + const accessToken = typeof payload?.access_token === "string" ? payload.access_token : null; + if (!accessToken) { + throw new DhlApiError("DHL-Authentifizierung lieferte kein Access Token zurück."); + } + + const expiresInSeconds = typeof payload?.expires_in === "number" ? payload.expires_in : 3600; + // 60 Sekunden Sicherheitsabstand vor dem tatsaechlichen Ablauf, damit kein + // knapp abgelaufenes Token noch fuer einen Request verwendet wird. + cachedToken = { accessToken, expiresAt: Date.now() + Math.max(expiresInSeconds - 60, 30) * 1000 }; + return accessToken; +} + +async function getAccessToken(config: DhlConfig): Promise { + if (cachedToken && cachedToken.expiresAt > Date.now()) return cachedToken.accessToken; + return fetchAccessToken(config); +} + +// --------------------------------------------------------------------------- +// Adress-Mapping +// --------------------------------------------------------------------------- + +// DHL erwartet Laendercodes nach ISO 3166-1 ALPHA-3, waehrend Order.country +// in diesem Projekt ALPHA-2 speichert (siehe prisma/schema.prisma). Bewusst +// nur die in einem deutschen Honig-Versandhandel realistischen Zielmaerkte +// gelistet — ein nicht gelistetes Land liefert einen klaren Fehler statt +// eines falsch geratenen Codes. +const COUNTRY_ISO2_TO_ISO3: Record = { + DE: "DEU", + AT: "AUT", + CH: "CHE", + FR: "FRA", + NL: "NLD", + BE: "BEL", + LU: "LUX", + IT: "ITA", + ES: "ESP", + PL: "POL", + DK: "DNK", + CZ: "CZE", +}; + +function toIso3Country(iso2: string): string { + const code = COUNTRY_ISO2_TO_ISO3[iso2.trim().toUpperCase()]; + if (!code) { + throw new DhlApiError( + `Land "${iso2}" wird vom DHL-Client aktuell nicht unterstützt (siehe COUNTRY_ISO2_TO_ISO3 in lib/dhl.ts).` + ); + } + return code; +} + +/** + * Trennt eine deutsche Adresszeile ("Musterstraße 12a") in Straßenname und + * Hausnummer fuer die getrennten DHL-Adressfelder (addressStreet/addressHouse). + * Ohne erkennbare Hausnummer am Ende landet die komplette Zeile im + * Straßenfeld — das Hausnummernfeld ist bei DHL optional, ein falscher + * Trennversuch waere schlechter als gar keine Trennung. + */ +function splitStreet(street: string): { addressStreet: string; addressHouse?: string } { + const trimmed = street.trim(); + const match = trimmed.match(/^(.*\S)\s+(\d+\s*[a-zA-Z]?)$/); + if (!match) return { addressStreet: trimmed }; + return { addressStreet: match[1], addressHouse: match[2].replace(/\s+/g, "") }; +} + +// --------------------------------------------------------------------------- +// Versandmarke erstellen +// --------------------------------------------------------------------------- + +export interface DhlAddressInput { + firstName: string; + lastName: string; + street: string; + zip: string; + city: string; + /** ISO 3166-1 ALPHA-2, z.B. "DE" — wie Order.country in prisma/schema.prisma. */ + country: string; + phone?: string | null; + email?: string | null; +} + +export interface DhlShipperInput extends DhlAddressInput { + companyName: string; +} + +export interface DhlCreateShipmentInput { + /** Wird als DHL-Referenz (refNo) mitgeschickt, z.B. die Wabenhain-Bestellnummer. */ + reference: string; + /** Gesamtgewicht der Sendung in Gramm (Summe aller Positionen inkl. Menge). */ + weightGrams: number; + shipper: DhlShipperInput; + receiver: DhlAddressInput; +} + +export interface DhlCreateShipmentResult { + trackingNumber: string; + /** Base64-kodiertes PDF-Label — wird aktuell nicht persistiert (siehe README/Bericht). */ + labelPdfBase64: string; +} + +// Deutsches Standard-Inlandspaket. Andere Produkte (z.B. Warenpost/Kleinpaket +// fuer kleine, leichte Sendungen) sind bewusst nicht abgebildet — YAGNI, +// bis der Bedarf konkret entsteht. +const DHL_PRODUCT_NATIONAL_PARCEL = "V01PAK"; + +function buildDhlContact(address: DhlAddressInput, name1: string) { + const { addressStreet, addressHouse } = splitStreet(address.street); + return { + name1, + phone: address.phone || undefined, + email: address.email || undefined, + address: { + addressStreet, + addressHouse, + postalCode: address.zip, + city: address.city, + country: toIso3Country(address.country), + }, + }; +} + +export async function createDhlShipmentLabel( + input: DhlCreateShipmentInput +): Promise { + const config = getDhlConfig(); + const accessToken = await getAccessToken(config); + + const requestBody = { + shipments: [ + { + product: DHL_PRODUCT_NATIONAL_PARCEL, + billingNumber: config.accountNumber, + // DHL erlaubt max. 35 Zeichen fuer refNo. + refNo: input.reference.slice(0, 35), + shipper: buildDhlContact(input.shipper, input.shipper.companyName), + consignee: buildDhlContact(input.receiver, `${input.receiver.firstName} ${input.receiver.lastName}`.trim()), + details: { + weight: { uom: "g", value: Math.max(Math.round(input.weightGrams), 1) }, + }, + }, + ], + }; + + let response: Response; + try { + response = await fetch(`${config.baseUrl}/parcel/de/shipping/v2/orders`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + "dhl-api-key": config.apiKey, + }, + body: JSON.stringify(requestBody), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler"; + throw new DhlApiError(`DHL-API nicht erreichbar: ${message}`); + } + + const payload = await safeReadJson(response); + + if (!response.ok) { + throw new DhlApiError( + `DHL-Versandmarke konnte nicht erstellt werden (HTTP ${response.status}): ${extractErrorDetail(payload) ?? "unbekannter Fehler"}` + ); + } + + const item = Array.isArray(payload?.items) ? payload.items[0] : undefined; + const validationError = Array.isArray(item?.validationMessages) + ? item.validationMessages.find((entry: { validationState?: string }) => entry?.validationState === "Error") + : undefined; + if (validationError) { + throw new DhlApiError(`DHL meldet einen Fehler in den Sendungsdaten: ${validationError.validationMessage ?? "unbekannt"}`); + } + + const trackingNumber = typeof item?.shipmentNo === "string" ? item.shipmentNo : null; + const labelPdfBase64 = typeof item?.label?.b64 === "string" ? item.label.b64 : null; + if (!trackingNumber || !labelPdfBase64) { + throw new DhlApiError("DHL-Antwort enthielt keine Sendungsnummer oder kein Label — bitte Sandbox-Response pruefen."); + } + + return { trackingNumber, labelPdfBase64 }; +} + +// --------------------------------------------------------------------------- +// Hilfsfunktionen +// --------------------------------------------------------------------------- + +async function safeReadJson(response: Response): Promise | undefined> { + try { + return await response.json(); + } catch { + return undefined; + } +} + +/** Extrahiert eine lesbare Fehlermeldung aus gaengigen DHL-Fehlerformaten (RFC7807-aehnlich). */ +function extractErrorDetail(payload: Record | undefined): string | undefined { + if (!payload) return undefined; + if (typeof payload.detail === "string") return payload.detail; + if (typeof payload.title === "string") return payload.title; + if (typeof payload.error_description === "string") return payload.error_description; + if (typeof payload.message === "string") return payload.message; + const status = payload.status as { detail?: string; title?: string } | undefined; + if (status?.detail) return status.detail; + if (status?.title) return status.title; + return undefined; +} diff --git a/src/lib/entra.ts b/src/lib/entra.ts new file mode 100644 index 0000000..f91a3dd --- /dev/null +++ b/src/lib/entra.ts @@ -0,0 +1,259 @@ +/** + * OIDC-Client fuer "Mit Microsoft anmelden" (Entra ID / Azure AD) — + * OAuth2 Authorization Code Flow mit PKCE gegen die Microsoft Identity + * Platform, siehe learn.microsoft.com/entra/identity-platform/ + * v2-oauth2-auth-code-flow und .../v2-protocols-oidc (Stand der Recherche: + * September 2026). + * + * Endpunkte (single-tenant, {tenant} = ENTRA_TENANT_ID): + * Autorisierung: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize + * Token: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token + * JWKS: https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys + * Issuer (iss-Claim im ID-Token): https://login.microsoftonline.com/{tenant}/v2.0 + * + * response_type=code (reiner Authorization-Code-Flow, kein Hybrid-Flow) — + * das ID-Token kommt ausschliesslich ueber den serverseitigen Token- + * Endpunkt zurueck (Backchannel, per client_secret authentifiziert), nicht + * ueber die Redirect-URL. Ein "nonce"-Parameter ist daher nicht noetig + * (der schuetzt beim Hybrid-/Implicit-Flow vor Replay eines im Front- + * channel/URL-Fragment sichtbaren ID-Tokens). + */ + +import { randomBytes, createHash } from "node:crypto"; +import { jwtVerify, createRemoteJWKSet, type JWTPayload } from "jose"; + +export class EntraNotConfiguredError extends Error { + constructor() { + super( + "Microsoft-Anmeldung (SSO) ist noch nicht konfiguriert. Bitte ENTRA_TENANT_ID, ENTRA_CLIENT_ID, " + + "ENTRA_CLIENT_SECRET und ENTRA_REDIRECT_URI in der .env-Datei setzen (siehe .env.example)." + ); + this.name = "EntraNotConfiguredError"; + } +} + +export class EntraAuthError extends Error { + constructor(message: string) { + super(message); + this.name = "EntraAuthError"; + } +} + +interface EntraConfig { + tenantId: string; + clientId: string; + clientSecret: string; + redirectUri: string; +} + +function getEntraConfig(): EntraConfig { + const tenantId = process.env.ENTRA_TENANT_ID; + const clientId = process.env.ENTRA_CLIENT_ID; + const clientSecret = process.env.ENTRA_CLIENT_SECRET; + const redirectUri = process.env.ENTRA_REDIRECT_URI; + if (!tenantId || !clientId || !clientSecret || !redirectUri) { + throw new EntraNotConfiguredError(); + } + return { tenantId, clientId, clientSecret, redirectUri }; +} + +/** Fuer die bedingte Anzeige des "Mit Microsoft anmelden"-Buttons im Login-UI. */ +export function isEntraConfigured(): boolean { + try { + getEntraConfig(); + return true; + } catch { + return false; + } +} + +function authorizeEndpoint(tenantId: string): string { + return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`; +} +function tokenEndpoint(tenantId: string): string { + return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; +} +function issuer(tenantId: string): string { + return `https://login.microsoftonline.com/${tenantId}/v2.0`; +} +function jwksUri(tenantId: string): string { + return `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`; +} + +// --------------------------------------------------------------------------- +// PKCE +// --------------------------------------------------------------------------- + +export interface PkcePair { + codeVerifier: string; + codeChallenge: string; +} + +/** + * code_verifier: 43-128 Zeichen aus dem PKCE-erlaubten Zeichensatz (RFC 7636). + * base64url-Kodierung von 32 Zufallsbytes ergibt 43 Zeichen aus exakt diesem + * Zeichensatz ([A-Za-z0-9_-]) — kein zusaetzlicher Zeichensatz-Filter noetig. + * code_challenge = base64url(SHA256(code_verifier)), Methode "S256". + */ +export function generatePkcePair(): PkcePair { + const codeVerifier = randomBytes(32).toString("base64url"); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + return { codeVerifier, codeChallenge }; +} + +export function generateOAuthState(): string { + return randomBytes(24).toString("base64url"); +} + +// --------------------------------------------------------------------------- +// 1. Redirect zu Microsoft +// --------------------------------------------------------------------------- + +export function buildEntraAuthorizeUrl(params: { state: string; codeChallenge: string }): string { + const config = getEntraConfig(); + const url = new URL(authorizeEndpoint(config.tenantId)); + url.searchParams.set("client_id", config.clientId); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", config.redirectUri); + url.searchParams.set("response_mode", "query"); + // "email" liefert den verifizierten E-Mail-Claim, "profile" Vor-/Nachname + // fuer die Konto-Erstellung. Kein "offline_access" — wir brauchen keinen + // Refresh-Token, es findet kein weiterer API-Zugriff im Namen des Nutzers statt. + url.searchParams.set("scope", "openid profile email"); + url.searchParams.set("state", params.state); + url.searchParams.set("code_challenge", params.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + // Zeigt immer den Konto-Auswahldialog statt automatischem Single-Sign-On + // auf ein evtl. im Browser noch aktives fremdes Microsoft-Konto. + url.searchParams.set("prompt", "select_account"); + return url.toString(); +} + +// --------------------------------------------------------------------------- +// 2. Code gegen Tokens tauschen +// --------------------------------------------------------------------------- + +interface TokenResponse { + id_token: string; + access_token?: string; + token_type?: string; + expires_in?: number; +} + +export async function exchangeEntraAuthorizationCode(params: { + code: string; + codeVerifier: string; +}): Promise { + const config = getEntraConfig(); + + let response: Response; + try { + response = await fetch(tokenEndpoint(config.tenantId), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + grant_type: "authorization_code", + code: params.code, + redirect_uri: config.redirectUri, + code_verifier: params.codeVerifier, + scope: "openid profile email", + }), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler"; + throw new EntraAuthError(`Microsoft-Token-Endpunkt nicht erreichbar: ${message}`); + } + + let payload: Record | undefined; + try { + payload = await response.json(); + } catch { + payload = undefined; + } + + if (!response.ok) { + const detail = + (typeof payload?.error_description === "string" && payload.error_description) || + (typeof payload?.error === "string" && payload.error) || + `HTTP ${response.status}`; + throw new EntraAuthError(`Microsoft-Anmeldung fehlgeschlagen: ${detail}`); + } + + if (!payload || typeof payload.id_token !== "string") { + throw new EntraAuthError("Microsoft-Antwort enthielt kein ID-Token."); + } + + return { + id_token: payload.id_token, + access_token: typeof payload.access_token === "string" ? payload.access_token : undefined, + token_type: typeof payload.token_type === "string" ? payload.token_type : undefined, + expires_in: typeof payload.expires_in === "number" ? payload.expires_in : undefined, + }; +} + +// --------------------------------------------------------------------------- +// 3. ID-Token verifizieren (Signatur + Claims) — NIE ungeprueft vertrauen +// --------------------------------------------------------------------------- + +export interface EntraIdTokenClaims extends JWTPayload { + email?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + name?: string; + oid?: string; + tid?: string; +} + +// Ein JWKSet pro Tenant reicht (dieses Projekt bindet genau einen Tenant an) — +// jose cached/erneuert die Schluessel intern automatisch (Key-Rollover). +let cachedJwks: ReturnType | null = null; +let cachedJwksTenantId: string | null = null; + +function getJwks(tenantId: string): ReturnType { + if (cachedJwks && cachedJwksTenantId === tenantId) return cachedJwks; + cachedJwks = createRemoteJWKSet(new URL(jwksUri(tenantId))); + cachedJwksTenantId = tenantId; + return cachedJwks; +} + +/** + * Verifiziert Signatur, issuer, audience und Ablauf des ID-Tokens gegen + * Microsofts oeffentliche Signaturschluessel (JWKS) — niemals ein Token + * ungeprueft dekodieren und seinen Claims vertrauen, sonst koennte jeder ein + * beliebiges Konto durch ein selbstgebasteltes JWT vortaeuschen. Gibt bei + * Erfolg die geprueften Claims zurueck, wirft sonst EntraAuthError. + */ +export async function verifyEntraIdToken(idToken: string): Promise { + const config = getEntraConfig(); + + try { + const { payload } = await jwtVerify(idToken, getJwks(config.tenantId), { + issuer: issuer(config.tenantId), + audience: config.clientId, + algorithms: ["RS256"], + }); + return payload; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + throw new EntraAuthError(`ID-Token-Prüfung fehlgeschlagen: ${message}`); + } +} + +/** + * Extrahiert die verifizierte E-Mail-Adresse aus den ID-Token-Claims. + * Bewusst NUR der "email"-Claim (aus dem signierten Token, nie ein vom + * Client mitgeschicktes Feld) — "preferred_username" ist bei Microsoft- + * Konten zwar meist ebenfalls die E-Mail/UPN, aber laut Microsofts eigener + * Dokumentation nicht garantiert ident/verifiziert und kann sich zudem + * aendern. Fehlt der email-Claim (z.B. Konto ohne hinterlegte Mailbox), + * wird die Anmeldung bewusst abgelehnt statt zu raten. + */ +export function getVerifiedEmail(claims: EntraIdTokenClaims): string | null { + if (typeof claims.email === "string" && claims.email.includes("@")) { + return claims.email.trim().toLowerCase(); + } + return null; +} diff --git a/src/lib/social-links.ts b/src/lib/social-links.ts new file mode 100644 index 0000000..e8e003c --- /dev/null +++ b/src/lib/social-links.ts @@ -0,0 +1,24 @@ +import { getContentValues } from "@/lib/content"; + +const SOCIAL_KEYS = ["social.tiktok", "social.youtube", "social.instagram"] as const; + +export interface SocialLinks { + tiktok: string; + youtube: string; + instagram: string; +} + +/** + * Admin-gepflegte Social-Media-Links (siehe admin/inhalte, Gruppe "Social + * Media") — nicht lokalisiert, wie Firmendaten (company-info.ts). Ein leerer + * String bedeutet "kein Profil hinterlegt"; der Footer rendert dann kein + * Icon fuer diese Plattform (siehe components/site-chrome.tsx Footer()). + */ +export async function getSocialLinks(): Promise { + const v = await getContentValues([...SOCIAL_KEYS], "de"); + return { + tiktok: v["social.tiktok"], + youtube: v["social.youtube"], + instagram: v["social.instagram"], + }; +}