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:
1 parent
4d3100e9ba
commit
b6db9254a9
10 files changed
+1205
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user