Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
The last base64url character of a 32-byte HMAC signature partly carries padding bits that decoders ignore, so the tampered token was sometimes still valid and the test failed at random. Changing a character in the middle of the signature always alters the signature bytes. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
171 lines
6.4 KiB
TypeScript
171 lines
6.4 KiB
TypeScript
// @vitest-environment node
|
|
//
|
|
// Abweichend von der globalen jsdom-Umgebung (vitest.config.ts): "jose" prueft
|
|
// seine Eingabe mit "instanceof Uint8Array", und das von jsdom bereitgestellte
|
|
// TextEncoder liefert ein Uint8Array aus einem anderen JS-Realm — die Pruefung
|
|
// schlaegt dann fehl, obwohl der Code stimmt. Im Betrieb laeuft dieses Modul
|
|
// ohnehin nie in einem DOM.
|
|
|
|
/**
|
|
* Tests fuer die Token-Schicht der Anmeldung.
|
|
*
|
|
* Alle Token in session.ts sind mit DEMSELBEN Geheimnis signiert und tragen
|
|
* denselben Aussteller und dieselbe Zielgruppe. Was sie unterscheidet, ist
|
|
* allein das "type"-Feld. Damit ist die zentrale Frage nicht, ob ein gueltiger
|
|
* Token angenommen wird, sondern ob ein Token FUER EINEN ANDEREN ZWECK
|
|
* abgelehnt wird — sonst laesst sich der zweite Faktor umgehen, indem man das
|
|
* Challenge-Cookie in das Session-Cookie kopiert.
|
|
*/
|
|
import { describe, test, expect } from "vitest";
|
|
|
|
// Vor dem Import setzen: getSecretKey wirft ohne diesen Wert.
|
|
process.env.SESSION_SECRET = "test-secret-mit-mindestens-32-zeichen-laenge";
|
|
|
|
const {
|
|
signSessionToken,
|
|
verifySessionToken,
|
|
signTwoFactorChallenge,
|
|
verifyTwoFactorChallenge,
|
|
signTwoFactorSetup,
|
|
verifyTwoFactorSetup,
|
|
signTwoFactorBackupCodesDisplay,
|
|
verifyTwoFactorBackupCodesDisplay,
|
|
signEntraOAuthState,
|
|
verifyEntraOAuthState,
|
|
} = await import("./session");
|
|
|
|
const CUSTOMER_ID = "customer_123";
|
|
|
|
describe("Session-Token", () => {
|
|
test("gibt Kunde, Rolle und Sitzungsversion unveraendert zurueck", async () => {
|
|
const token = await signSessionToken({
|
|
customerId: CUSTOMER_ID,
|
|
role: "ADMIN",
|
|
sessionVersion: 3,
|
|
});
|
|
|
|
await expect(verifySessionToken(token)).resolves.toEqual({
|
|
customerId: CUSTOMER_ID,
|
|
role: "ADMIN",
|
|
sessionVersion: 3,
|
|
});
|
|
});
|
|
|
|
test("stuft eine unbekannte Rolle auf KUNDE herunter statt sie zu uebernehmen", async () => {
|
|
// Die Rolle im Token ist nur der schnelle Vorfilter fuer den Proxy;
|
|
// autoritativ ist die Datenbank. Ein unerwarteter Wert darf trotzdem nie
|
|
// als etwas anderes als die geringste Berechtigung durchgehen.
|
|
const token = await signSessionToken({
|
|
customerId: CUSTOMER_ID,
|
|
role: "SUPERADMIN" as never,
|
|
sessionVersion: 0,
|
|
});
|
|
|
|
await expect(verifySessionToken(token)).resolves.toMatchObject({ role: "KUNDE" });
|
|
});
|
|
|
|
test("lehnt einen manipulierten Token ab", async () => {
|
|
const token = await signSessionToken({
|
|
customerId: CUSTOMER_ID,
|
|
role: "KUNDE",
|
|
sessionVersion: 0,
|
|
});
|
|
// Ein Zeichen MITTEN in der Signatur veraendern. Das letzte Zeichen taugt
|
|
// nicht: bei 32 Signaturbytes traegt es in Base64url teils nur Fuellbits,
|
|
// die der Decoder ignoriert — der Test schlug dann zufaellig fehl, obwohl
|
|
// die Signatur korrekt geprueft wurde.
|
|
const signatureStart = token.lastIndexOf(".") + 1;
|
|
const position = signatureStart + 10;
|
|
const replacement = token[position] === "A" ? "B" : "A";
|
|
const tampered = token.slice(0, position) + replacement + token.slice(position + 1);
|
|
|
|
await expect(verifySessionToken(tampered)).resolves.toBeNull();
|
|
});
|
|
|
|
test("lehnt Unsinn ab, statt zu werfen", async () => {
|
|
await expect(verifySessionToken("kein.jwt.hier")).resolves.toBeNull();
|
|
await expect(verifySessionToken("")).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Token-Verwechslung", () => {
|
|
/**
|
|
* Der wichtigste Test dieser Datei.
|
|
*
|
|
* Das Challenge-Cookie wird ausgestellt, NACHDEM das Passwort stimmt, aber
|
|
* BEVOR der zweite Faktor geprueft ist. Gilt es als Session, ist die
|
|
* Zwei-Faktor-Pruefung eine Formalie, die sich mit einem Kopiervorgang im
|
|
* Browser ueberspringen laesst.
|
|
*/
|
|
test("ein 2FA-Challenge-Token ist KEINE gueltige Anmeldung", async () => {
|
|
const challenge = await signTwoFactorChallenge(CUSTOMER_ID);
|
|
|
|
await expect(verifySessionToken(challenge)).resolves.toBeNull();
|
|
});
|
|
|
|
test("ein 2FA-Einrichtungstoken ist KEINE gueltige Anmeldung", async () => {
|
|
const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP");
|
|
|
|
await expect(verifySessionToken(setup)).resolves.toBeNull();
|
|
});
|
|
|
|
test("ein Backup-Code-Anzeigetoken ist KEINE gueltige Anmeldung", async () => {
|
|
const display = await signTwoFactorBackupCodesDisplay(CUSTOMER_ID, ["aaaa-bbbb"]);
|
|
|
|
await expect(verifySessionToken(display)).resolves.toBeNull();
|
|
});
|
|
|
|
test("ein Entra-OAuth-State ist KEINE gueltige Anmeldung", async () => {
|
|
const state = await signEntraOAuthState("zufallswert", "code-verifier");
|
|
|
|
await expect(verifySessionToken(state)).resolves.toBeNull();
|
|
});
|
|
|
|
test("ein Session-Token taugt umgekehrt fuer keinen der Sonderzwecke", async () => {
|
|
const session = await signSessionToken({
|
|
customerId: CUSTOMER_ID,
|
|
role: "ADMIN",
|
|
sessionVersion: 0,
|
|
});
|
|
|
|
await expect(verifyTwoFactorChallenge(session)).resolves.toBeNull();
|
|
await expect(verifyTwoFactorSetup(session)).resolves.toBeNull();
|
|
await expect(verifyTwoFactorBackupCodesDisplay(session)).resolves.toBeNull();
|
|
await expect(verifyEntraOAuthState(session)).resolves.toBeNull();
|
|
});
|
|
|
|
test("die Sondertoken lassen sich auch untereinander nicht tauschen", async () => {
|
|
const challenge = await signTwoFactorChallenge(CUSTOMER_ID);
|
|
const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP");
|
|
|
|
// Ein Einrichtungstoken darf keinen Anmeldeschritt abschliessen, und ein
|
|
// Challenge-Token traegt kein Secret, mit dem sich 2FA einrichten liesse.
|
|
await expect(verifyTwoFactorChallenge(setup)).resolves.toBeNull();
|
|
await expect(verifyTwoFactorSetup(challenge)).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Sondertoken im Normalfall", () => {
|
|
test("Challenge und Einrichtung geben ihren Inhalt zurueck", async () => {
|
|
const challenge = await signTwoFactorChallenge(CUSTOMER_ID);
|
|
await expect(verifyTwoFactorChallenge(challenge)).resolves.toEqual({
|
|
customerId: CUSTOMER_ID,
|
|
});
|
|
|
|
const setup = await signTwoFactorSetup(CUSTOMER_ID, "JBSWY3DPEHPK3PXP");
|
|
await expect(verifyTwoFactorSetup(setup)).resolves.toEqual({
|
|
customerId: CUSTOMER_ID,
|
|
secret: "JBSWY3DPEHPK3PXP",
|
|
});
|
|
});
|
|
|
|
test("der Entra-State haelt den PKCE-Verifier, der nie in die URL gehoert", async () => {
|
|
const state = await signEntraOAuthState("zufallswert", "code-verifier");
|
|
|
|
await expect(verifyEntraOAuthState(state)).resolves.toEqual({
|
|
state: "zufallswert",
|
|
codeVerifier: "code-verifier",
|
|
});
|
|
});
|
|
});
|