Files
Wabenhain/src/lib/session.test.ts
T
Nick SzaunigandClaude Opus 5 3e81896194 fix: Session-Pruefung akzeptierte Token anderer Zwecke
Alle Token in lib/session.ts sind mit DEMSELBEN Geheimnis signiert und
tragen denselben Aussteller und dieselbe Zielgruppe. Unterschieden werden
sie allein ueber ein "type"-Feld — und genau das prueft
verifySessionToken nicht. Es verlangte nur eine vorhandene "sub".

Damit bestand ein 2FA-Challenge-Token die Session-Pruefung und galt als
vollwertige Anmeldung. Dieses Cookie wird ausgestellt, NACHDEM das
Passwort stimmt, aber BEVOR der zweite Faktor geprueft ist: wer es im
Browser in das Session-Cookie kopiert, umgeht die Zwei-Faktor-Pruefung.
Praktisch nachgestellt:

  signTwoFactorChallenge("customer_123")
  -> verifySessionToken(...) ergab
     { customerId: "customer_123", role: "KUNDE", sessionVersion: 0 }

Dasselbe galt fuer den 2FA-Einrichtungstoken und den Token der
Backup-Code-Anzeige. Der Entra-OAuth-State trug keine "sub" und fiel
schon vorher durch.

Aufgehalten wurde das bisher nur zufaellig: diese Token fuehren keine
sessionVersion (also 0), und getCurrentCustomer vergleicht sie gegen die
Datenbank — wer 2FA aktiviert, steht dort bereits bei mindestens 1
(konto/actions/two-factor.ts zaehlt hoch). Das ist eine Sperre, die aus
einem voellig anderen Grund existiert und lautlos wegfaellt, sobald sich
an Zaehler oder Token etwas aendert.

verifySessionToken lehnt jetzt jeden Token mit "type"-Feld ab. Geprueft
wird auf ABWESENHEIT, nicht auf einen bestimmten Wert: das Session-Token
setzt selbst kein "type", bestehende Cookies bleiben also gueltig und
niemand wird durch das Ausrollen abgemeldet.

Dazu die erste Testabdeckung fuer die Token-Schicht ueberhaupt
(lib/session.test.ts, 12 Faelle): Rundlauf, manipulierter Token,
unbekannte Rolle wird auf KUNDE heruntergestuft, und jede Kombination von
Token-Verwechslung in beide Richtungen. Die Datei laeuft bewusst in der
node-Umgebung — jose prueft mit "instanceof Uint8Array", und jsdoms
TextEncoder liefert ein Uint8Array aus einem anderen Realm.

Nebenbei in honig/actions.ts: toggleWishlistAction nahm die productId
ungeprueft aus dem Formular. Ein erfundener Wert lief in eine
Fremdschluesselverletzung und damit in die allgemeine Fehlerseite — ein
500er als Antwort auf eine schlicht ungueltige Eingabe. Jetzt wird die
Existenz geprueft, und ein deaktiviertes Produkt kommt nicht neu auf eine
Wunschliste.

tsc: 0 Fehler. vitest: 328/328 gruen (zuvor 316). eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 02:30:47 +02:00

165 lines
6.0 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,
});
// Letztes Zeichen der Signatur veraendern.
const tampered = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A");
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",
});
});
});