fix: fehlende Dateien aus vorherigem Commit nachreichen

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 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-03 20:10:50 +02:00
1 parent 4d3100e9ba
commit b6db9254a9
10 files changed
+1205

No files matched your search

+134
View File
@@ -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<typeof harvestSchema>;
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<string> {
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<Map<string, string>> {
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<ApiCoreHarvestWithApiaryName[]> {
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,
}));
}
+30
View File
@@ -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 <form enctype="text/plain"> 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;
}
}
+348
View File
@@ -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<string> {
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<string> {
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<string, string> = {
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<DhlCreateShipmentResult> {
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<Record<string, unknown> | undefined> {
try {
return await response.json();
} catch {
return undefined;
}
}
/** Extrahiert eine lesbare Fehlermeldung aus gaengigen DHL-Fehlerformaten (RFC7807-aehnlich). */
function extractErrorDetail(payload: Record<string, unknown> | 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;
}
+259
View File
@@ -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<TokenResponse> {
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<string, unknown> | 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<typeof createRemoteJWKSet> | null = null;
let cachedJwksTenantId: string | null = null;
function getJwks(tenantId: string): ReturnType<typeof createRemoteJWKSet> {
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<EntraIdTokenClaims> {
const config = getEntraConfig();
try {
const { payload } = await jwtVerify<EntraIdTokenClaims>(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;
}
+24
View File
@@ -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<SocialLinks> {
const v = await getContentValues([...SOCIAL_KEYS], "de");
return {
tiktok: v["social.tiktok"],
youtube: v["social.youtube"],
instagram: v["social.instagram"],
};
}