/* * Prueft die Anmeldung ueber Microsoft Entra, soweit sie ohne Netz und ohne * Datenbank pruefbar ist: Konfiguration, Aufbau der Authorize-URL, Einmaligkeit * von state und Ticket sowie die Abwehr eines untergeschobenen ID-Tokens. * * Die Umgebungsvariablen muessen vor dem Import stehen - sso/config.js liest * sie beim Laden des Moduls, genau wie im Betrieb beim Start des Backends. */ import { readFileSync } from 'node:fs'; process.env.MS_SSO_ENABLED = 'true'; process.env.MS_TENANT_ID = 'tenant-fuer-den-test'; process.env.MS_CLIENT_ID = 'client-fuer-den-test'; process.env.MS_CLIENT_SECRET = 'geheim-fuer-den-test'; process.env.MS_SSO_REDIRECT_URI = 'https://crm.perinco.de/api/auth/sso/callback'; process.env.MS_SSO_ALLOWED_DOMAINS = 'perinco.de'; const { SSO_CONFIGURED, domainAllowed, missingSsoConfig } = await import('../src/sso/config.js'); const { SsoError, beginLogin, createTicket, redeemTicket, resetSsoState, takePendingLogin, verifyIdToken, } = await import('../src/sso/flow.js'); let failures = 0; function check(name, condition, detail = '') { if (condition) { console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`); } else { console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`); failures += 1; } } function throws(name, fn) { try { fn(); check(name, false, 'kein Fehler geworfen'); } catch (err) { check(name, err instanceof SsoError, err instanceof SsoError ? err.message : String(err)); } } /** Baut ein ID-Token, wie Entra es liefert - Signatur ist hier ohne Belang. */ function idToken(claims) { const part = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); return `${part({ alg: 'RS256' })}.${part(claims)}.signatur`; } const validClaims = { aud: 'client-fuer-den-test', tid: 'tenant-fuer-den-test', nonce: 'nonce-1', exp: Math.floor(Date.now() / 1000) + 3600, }; console.log('\nAnmeldung ueber Microsoft (SSO)'); console.log('\n Konfiguration'); check('vollstaendige Konfiguration gilt als betriebsbereit', SSO_CONFIGURED === true); check('nichts fehlt', missingSsoConfig().length === 0, missingSsoConfig().join(', ')); check('eigene Domain erlaubt', domainAllowed('sascha.wolf@perinco.de') === true); check('fremde Domain abgewiesen', domainAllowed('jemand@example.com') === false); check('Adresse ohne Domain abgewiesen', domainAllowed('kaputt') === false); console.log('\n Start der Anmeldung'); resetSsoState(); const url = new URL(beginLogin()); const params = url.searchParams; check('Ziel ist der Tenant', url.origin === 'https://login.microsoftonline.com'); check('Pfad enthaelt die Tenant-ID', url.pathname.includes('tenant-fuer-den-test'), url.pathname); check('Code Flow', params.get('response_type') === 'code'); check('PKCE mit S256', params.get('code_challenge_method') === 'S256'); check('Code-Challenge gesetzt', (params.get('code_challenge') || '').length >= 43); check('Umleitungsadresse unveraendert', params.get('redirect_uri') === process.env.MS_SSO_REDIRECT_URI); check('state und nonce gesetzt', Boolean(params.get('state')) && Boolean(params.get('nonce'))); check('Kontoauswahl erzwungen', params.get('prompt') === 'select_account'); check('kein Secret in der URL', !url.toString().includes('geheim-fuer-den-test')); console.log('\n state gilt genau einmal'); const state = params.get('state'); const pending = takePendingLogin(state); check('Vorgang gefunden', Boolean(pending?.verifier && pending?.nonce)); check('nonce passt zur URL', pending?.nonce === params.get('nonce')); check('zweiter Versuch mit demselben state scheitert', takePendingLogin(state) === null); check('unbekannter state scheitert', takePendingLogin('erfunden') === null); console.log('\n Pruefung des ID-Tokens'); check( 'gueltiges Token wird angenommen', verifyIdToken(idToken(validClaims), 'nonce-1').aud === 'client-fuer-den-test' ); throws('fremdes Verzeichnis abgewiesen', () => verifyIdToken(idToken({ ...validClaims, tid: 'fremder-tenant' }), 'nonce-1') ); throws('fremde Anwendung abgewiesen', () => verifyIdToken(idToken({ ...validClaims, aud: 'andere-app' }), 'nonce-1') ); throws('falsche nonce abgewiesen', () => verifyIdToken(idToken(validClaims), 'nonce-2')); throws('abgelaufenes Token abgewiesen', () => verifyIdToken(idToken({ ...validClaims, exp: Math.floor(Date.now() / 1000) - 3600 }), 'nonce-1') ); throws('unlesbares Token abgewiesen', () => verifyIdToken('kein-jwt', 'nonce-1')); console.log('\n Einmal-Ticket'); const ticket = createTicket({ token: 'crm-token', user: { id: 1 } }); check('Ticket ist kein Klartext des Tokens', !ticket.includes('crm-token')); check('Einloesen liefert die Nutzlast', redeemTicket(ticket)?.token === 'crm-token'); check('zweites Einloesen scheitert', redeemTicket(ticket) === null); check('erfundenes Ticket scheitert', redeemTicket('erfunden') === null); console.log('\n Konten werden nicht angelegt'); const routes = readFileSync(new URL('../src/routes/auth.js', import.meta.url), 'utf8'); check( 'routes/auth.js legt keinen Benutzer an', !/INSERT\s+INTO\s+crm_users/i.test(routes), 'die Anmeldung setzt ein bestehendes Konto voraus' ); check( 'Zuordnung ueber entra_oid vorhanden', routes.includes('entra_oid = ?'), 'stabile Verknuepfung statt nur E-Mail' ); resetSsoState(); console.log(failures === 0 ? '\nAlle Pruefungen bestanden.\n' : `\n${failures} Pruefung(en) fehlgeschlagen.\n`); process.exit(failures === 0 ? 0 : 1);