feat(auth): Anmeldung ueber Microsoft Entra (SSO)
OpenID Connect mit Authorization Code Flow und PKCE, vollstaendig
serverseitig - der Browser sieht weder Tenant- noch Client-ID. Genutzt wird
dieselbe App-Registrierung wie die Postfachanbindung, also kein zweites
Secret; delegiert statt app-only.
Nach dem Token-Tausch beweist ein Aufruf von /me bei Graph, dass das
Zugriffstoken gilt, und liefert die Objekt-ID. Sie landet in der neuen Spalte
crm_users.entra_oid und ist danach die stabile Verknuepfung - die E-Mail-
Adresse eines Menschen kann sich aendern, die Objekt-ID nicht.
Bewusst so entschieden:
- Es werden keine Konten angelegt. Wer kein CRM-Konto mit derselben Adresse
hat, wird abgewiesen; Zugriff vergibt weiter die Nutzerverwaltung.
- Die Anmeldung mit Benutzername und Passwort bleibt fuer alle bestehen -
Notzugang, falls Entra oder das Secret ausfaellt.
- Das CRM-Token kommt als Einmal-Ticket zurueck, nicht in der Adresszeile:
die Rueckleitung ist ein GET und stuende sonst in Browserverlauf und
Apache-Log.
- Keine Signaturpruefung des ID-Tokens und damit keine JWKS-Bibliothek. Es
kommt ueber TLS direkt vom Token-Endpunkt (OIDC Core 3.1.3.7); geprueft
werden aud, tid, nonce und exp, und der Graph-Aufruf belegt den Rest.
Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
bisher.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2d84bac91d
commit
419e57b55d
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* 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);
|
||||
Reference in New Issue
Block a user