import crypto from 'crypto'; import { AUTHORITY, CLIENT_ID, GRAPH_ROOT, REDIRECT_URI, SCOPES, TENANT_ID, clientSecret, } from './config.js'; /* * Authorization Code Flow mit PKCE, vollstaendig serverseitig. * * Ablauf: * 1. beginLogin() - state, nonce und PKCE-Verifier merken, Browser zu Entra * 2. Entra - Anmeldung beim Benutzer, Rueckleitung mit code + state * 3. exchangeCode()- code gegen Token tauschen (Backchannel mit Client-Secret) * 4. fetchProfile()- /me bei Graph; das ist der Identitaetsnachweis * * Warum der Graph-Aufruf und nicht nur das ID-Token: das ID-Token kommt ueber * TLS direkt vom Token-Endpunkt und ist damit vertrauenswuerdig (OIDC Core * 3.1.3.7 erlaubt in diesem Fall den Verzicht auf die Signaturpruefung), aber * /me beweist zusaetzlich, dass das Zugriffstoken wirklich gilt, und liefert * die Objekt-ID, an der das CRM-Konto haengt. */ const REQUEST_TIMEOUT_MS = 20_000; /** Wie lange eine angefangene Anmeldung gueltig bleibt. */ const STATE_TTL_MS = 10 * 60 * 1000; /** Wie lange das Ticket gilt, mit dem das Frontend das CRM-Token abholt. */ const TICKET_TTL_MS = 60 * 1000; /* * Beide Speicher liegen im Prozess, nicht in der Datenbank: es sind Werte mit * Sekunden- bis Minutenlebensdauer, und das Backend laeuft als einzelne * Instanz. Ein Neustart verwirft hoechstens gerade laufende Anmeldungen - der * Benutzer klickt dann noch einmal. */ const pendingLogins = new Map(); const tickets = new Map(); function prune(store, ttl) { const now = Date.now(); for (const [key, entry] of store) { if (now - entry.createdAt > ttl) store.delete(key); } } const base64url = (buffer) => buffer.toString('base64url'); const randomToken = () => base64url(crypto.randomBytes(32)); export class SsoError extends Error { constructor(message, { code = null } = {}) { super(message); this.name = 'SsoError'; this.code = code; } } /** * Beginnt eine Anmeldung und liefert die URL, auf die der Browser umgeleitet * wird. prompt=select_account, damit an einem geteilten Rechner nicht * stillschweigend das zuletzt benutzte Konto genommen wird. */ export function beginLogin() { prune(pendingLogins, STATE_TTL_MS); const state = randomToken(); const nonce = randomToken(); const verifier = randomToken(); const challenge = base64url(crypto.createHash('sha256').update(verifier).digest()); pendingLogins.set(state, { nonce, verifier, createdAt: Date.now() }); const params = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: REDIRECT_URI, response_mode: 'query', scope: SCOPES.join(' '), state, nonce, code_challenge: challenge, code_challenge_method: 'S256', prompt: 'select_account', }); return `${AUTHORITY}/oauth2/v2.0/authorize?${params.toString()}`; } /** Holt den gemerkten Vorgang und verbraucht ihn - jeder state gilt genau einmal. */ export function takePendingLogin(state) { prune(pendingLogins, STATE_TTL_MS); if (!state) return null; const entry = pendingLogins.get(state); if (!entry) return null; pendingLogins.delete(state); return entry; } async function postToken(body) { let response; try { response = await fetch(`${AUTHORITY}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (err) { throw new SsoError(`Microsoft ist nicht erreichbar: ${err.message}`); } const data = await response.json().catch(() => null); if (!response.ok || !data?.access_token) { const detail = data?.error_description || data?.error || `HTTP ${response.status}`; throw new SsoError(String(detail).split('\n')[0], { code: data?.error ?? null }); } return data; } /** Tauscht den Code gegen Tokens. Der Verifier weist uns als Starter des Vorgangs aus. */ export async function exchangeCode(code, verifier) { return postToken({ client_id: CLIENT_ID, client_secret: await clientSecret(), grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, code_verifier: verifier, scope: SCOPES.join(' '), }); } /** Nutzlast eines JWT ohne Signaturpruefung - siehe Begruendung oben im Modul. */ function decodePayload(token) { const part = String(token || '').split('.')[1]; if (!part) return null; try { return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); } catch { return null; } } /** * Prueft das ID-Token gegen den erwarteten Tenant, die eigene Client-ID, die * Laufzeit und die zu diesem Vorgang gehoerende nonce. Damit ist eine * untergeschobene Antwort aus einem fremden Verzeichnis ausgeschlossen. */ export function verifyIdToken(idToken, expectedNonce) { const claims = decodePayload(idToken); if (!claims) throw new SsoError('Antwort von Microsoft war unlesbar.'); if (claims.aud !== CLIENT_ID) throw new SsoError('Antwort gehört zu einer anderen Anwendung.'); if (claims.tid !== TENANT_ID) throw new SsoError('Anmeldung kommt aus einem fremden Verzeichnis.'); if (claims.nonce !== expectedNonce) throw new SsoError('Antwort passt nicht zur Anfrage.'); const now = Math.floor(Date.now() / 1000); // 120 s Spielraum fuer auseinanderlaufende Uhren. if (typeof claims.exp === 'number' && claims.exp + 120 < now) { throw new SsoError('Antwort von Microsoft war bereits abgelaufen.'); } return claims; } async function graphGet(path, accessToken, { accept = 'application/json' } = {}) { try { return await fetch(`${GRAPH_ROOT}${path}`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: accept }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (err) { throw new SsoError(`Microsoft Graph ist nicht erreichbar: ${err.message}`); } } /** Das angemeldete Konto: Objekt-ID, Anzeigename, Adresse. */ export async function fetchProfile(accessToken) { const select = 'id,displayName,givenName,surname,mail,userPrincipalName'; const response = await graphGet(`/me?$select=${select}`, accessToken); if (!response.ok) { throw new SsoError(`Profil konnte nicht gelesen werden (HTTP ${response.status}).`); } const data = await response.json(); return { oid: String(data.id || ''), name: String(data.displayName || '').trim(), email: String(data.mail || data.userPrincipalName || '') .trim() .toLowerCase(), upn: String(data.userPrincipalName || '') .trim() .toLowerCase(), }; } /** * Profilfoto in 96x96 als Data-URL, oder null. Fehler sind hier nie fatal: wer * kein Foto hinterlegt hat, bekommt 404, und ohne Foto laesst sich trotzdem * anmelden. */ export async function fetchPhoto(accessToken) { try { const response = await graphGet('/me/photos/96x96/$value', accessToken, { accept: 'image/jpeg' }); if (!response.ok) return null; const type = (response.headers.get('content-type') || 'image/jpeg').split(';')[0]; if (!type.startsWith('image/')) return null; const buffer = Buffer.from(await response.arrayBuffer()); // Sicherheitsnetz gegen unerwartet grosse Bilder - avatar ist zwar LONGTEXT, // soll aber keine halbe Megabyte pro Benutzer aufnehmen. if (buffer.length === 0 || buffer.length > 200_000) return null; return `data:${type};base64,${buffer.toString('base64')}`; } catch { return null; } } /** * Legt ein Einmal-Ticket an. Das CRM-Token wandert damit nicht als * Adressparameter durch Browserverlauf und Server-Logs - der Browser holt es * mit einem POST ab, und das Ticket verfaellt beim ersten Gebrauch. */ export function createTicket(payload) { prune(tickets, TICKET_TTL_MS); const ticket = randomToken(); tickets.set(ticket, { payload, createdAt: Date.now() }); return ticket; } export function redeemTicket(ticket) { prune(tickets, TICKET_TTL_MS); if (!ticket) return null; const entry = tickets.get(ticket); if (!entry) return null; tickets.delete(ticket); return entry.payload; } /** Nur fuer Tests - leert beide Speicher. */ export function resetSsoState() { pendingLogins.clear(); tickets.clear(); }