Files
Wabenhain/src/lib/entra.ts
T
Nick SzaunigandClaude Opus 5.5 11910dd6dd
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
refactor(mail): send via the SSO app registration, drop SMTP basic auth
Mail now uses the same Entra ID app registration as "Sign in with
Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so
only one enterprise application is needed. The app additionally gets
the SMTP.SendAsApp permission; Exchange still limits it to the mailbox
granted via Add-MailboxPermission.

Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT,
SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The
transport is fixed to smtp.office365.com:587 with required STARTTLS.
SMTP_USER (sending mailbox) and SMTP_FROM remain.

- lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail
- diagnosis drops basic-auth-only causes; hints name the ENTRA_* values
  and warn that an expired secret breaks sign-in and mail together
- admin e-mail test, .env.example, docker-compose, k8s manifests,
  preflight and README updated

Verified against Entra ID with fake values: token error classified,
client secret never exposed, missing ENTRA_CLIENT_SECRET reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 19:23:05 +02:00

280 lines
11 KiB
TypeScript

/**
* 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;
}
export interface EntraAppCredentials {
tenantId: string;
clientId: string;
clientSecret: string;
}
/**
* Zugangsdaten der EINEN App-Registrierung in Entra ID. Sie dient zwei
* Zwecken: der Anmeldung "Mit Microsoft anmelden" (dieses Modul) und dem
* Mailversand ueber Microsoft 365 per OAuth 2.0 (lib/mail.ts, Berechtigung
* SMTP.SendAsApp) — eine Unternehmensanwendung statt zwei. Beim Secret wird
* nur ein Zeilenumbruch am Ende entfernt (typischer Fehler beim Anlegen des
* Kubernetes-Secrets), sonstige Werte werden getrimmt. null, wenn etwas fehlt.
*/
export function readEntraAppCredentials(): EntraAppCredentials | null {
const tenantId = process.env.ENTRA_TENANT_ID?.trim() ?? "";
const clientId = process.env.ENTRA_CLIENT_ID?.trim() ?? "";
const clientSecret = process.env.ENTRA_CLIENT_SECRET?.replace(/[\r\n]+$/, "") ?? "";
if (!tenantId || !clientId || !clientSecret) return null;
return { tenantId, clientId, clientSecret };
}
function getEntraConfig(): EntraConfig {
const credentials = readEntraAppCredentials();
const redirectUri = process.env.ENTRA_REDIRECT_URI;
if (!credentials || !redirectUri) {
throw new EntraNotConfiguredError();
}
return { ...credentials, 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;
}