Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
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]>
280 lines
11 KiB
TypeScript
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;
|
|
}
|