Files
Sascha WolfandClaude Opus 5 c8eaaa1947 feat(auth): Client-Secret in den Einstellungen pflegen, Warnung vor Ablauf
Das Secret der App-Registrierung laeuft in Entra ab. Bisher hiess das: jemand
mit SSH-Zugang aendert die .env und startet den Dienst neu - und niemand sieht
vorher, wann es soweit ist. Laeuft es aus, stehen Anmeldung UND
Postfachabgleich still, denn beide haengen an derselben Registrierung.

Neu unter Einstellungen > Anmeldung: Secret eintragen, Ablaufdatum hinterlegen,
Restlaufzeit ablesen. Ab 30 Tagen vor dem Ablauf bekommen Administratoren ueber
jeder Ansicht ein Hinweisband, das direkt dorthin fuehrt; nach dem Ablauf wird
es rot. Der Hinweis reist mit /api/badges mit, das die App ohnehin bei jedem
Laden abruft.

Bewusst so entschieden:
  - Das Secret muss dafuer in die Datenbank, entgegen der bisherigen Regel
    "keine Geheimnisse in crm_settings". Es liegt deshalb AES-256-GCM-
    verschluesselt dort; der Schluessel kommt aus CRM_ENCRYPTION_KEY, ersatz-
    weise JWT_SECRET, und steht damit nie im selben Dump.
  - Vor dem Speichern holt das CRM mit dem eingegebenen Wert ein Token bei
    Microsoft. Ein Tippfehler wird gar nicht erst uebernommen.
  - Gelesen wird Datenbank vor .env; der .env-Wert bleibt Rueckfallebene, und
    "Hinterlegtes Secret verwerfen" schaltet dorthin zurueck.
  - Passt der Schluessel nicht mehr, meldet die Oberflaeche das ausdruecklich,
    statt still auf die .env zurueckzufallen - sonst sucht man den Fehler an
    der falschen Stelle.
  - Das Ablaufdatum wird von Hand eingetragen. Aus Entra lesen braeuchte
    Application.Read.All, die die App bewusst nicht hat.

MAIL_CONFIGURED und SSO_CONFIGURED sind dadurch von Konstanten zu den
Funktionen mailConfigured() und ssoConfigured() geworden - die Pruefung muss
jetzt in die Datenbank sehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 08:38:28 +02:00

137 lines
5.5 KiB
JavaScript

/*
* 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 { domainAllowed, missingSsoConfig, ssoConfigured } = 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');
// Ohne erreichbare Datenbank faellt das Secret auf die .env zurueck - genau
// dieser Weg wird hier mitgeprueft.
const missing = await missingSsoConfig();
check('vollstaendige Konfiguration gilt als betriebsbereit', (await ssoConfigured()) === true);
check('nichts fehlt', missing.length === 0, missing.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);