OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig, Rolle und Aktiv-Status pro Anfrage aus der Datenbank. Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser //fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft verglichen. Dazu acht weitere Befunde, alle behoben. 274 Tests. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
import { SignJWT } from "jose";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
SITZUNG_COOKIE,
|
|
SITZUNG_LAUFZEIT_STUNDEN,
|
|
SitzungsFehler,
|
|
sitzungErzeugen,
|
|
sitzungLesen,
|
|
type Sitzung,
|
|
} from "@/lib/sitzung";
|
|
|
|
// `tests/setup-env.ts` setzt einen Standardwert. Tests, die daran drehen, stellen ihn danach
|
|
// wieder her, damit die Reihenfolge der Tests nichts aendert.
|
|
const APP_SECRET_VORHER = process.env["APP_SECRET"];
|
|
|
|
function appSecretSetzen(wert: string): void {
|
|
process.env["APP_SECRET"] = wert;
|
|
}
|
|
|
|
afterEach(() => {
|
|
if (APP_SECRET_VORHER === undefined) {
|
|
delete process.env["APP_SECRET"];
|
|
} else {
|
|
process.env["APP_SECRET"] = APP_SECRET_VORHER;
|
|
}
|
|
});
|
|
|
|
const DATEN: Omit<Sitzung, "ausgestelltAm"> = {
|
|
benutzerId: "b-0815",
|
|
email: "anna.mueller@example.org",
|
|
name: "Anna Müller",
|
|
herkunft: "passwort",
|
|
};
|
|
|
|
function schluessel(quelle: string): Uint8Array {
|
|
return new TextEncoder().encode(quelle);
|
|
}
|
|
|
|
/** Baut ein Token von Hand, damit sich Ablauf, Aussteller und Zielgruppe einzeln verbiegen lassen. */
|
|
async function tokenBauen(optionen: {
|
|
aussteller?: string;
|
|
zielgruppe?: string;
|
|
ausgestelltAm?: number;
|
|
laeuftAbAm?: number;
|
|
geheimnis?: string;
|
|
inhalt?: Record<string, unknown>;
|
|
}): Promise<string> {
|
|
const jetzt = Math.floor(Date.now() / 1000);
|
|
return await new SignJWT({
|
|
email: DATEN.email,
|
|
name: DATEN.name,
|
|
herkunft: DATEN.herkunft,
|
|
...optionen.inhalt,
|
|
})
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setSubject(DATEN.benutzerId)
|
|
.setIssuer(optionen.aussteller ?? "laufzettel")
|
|
.setAudience(optionen.zielgruppe ?? "laufzettel")
|
|
.setIssuedAt(optionen.ausgestelltAm ?? jetzt)
|
|
.setExpirationTime(optionen.laeuftAbAm ?? jetzt + 3600)
|
|
.sign(schluessel(optionen.geheimnis ?? APP_SECRET_VORHER ?? ""));
|
|
}
|
|
|
|
/** Aendert genau ein Zeichen im angegebenen Abschnitt des Tokens. */
|
|
function zeichenVerfaelschen(jwt: string, abschnitt: number): string {
|
|
const teile = jwt.split(".");
|
|
const teil = teile[abschnitt];
|
|
if (teil === undefined || teil.length === 0) {
|
|
throw new Error(`Abschnitt ${abschnitt} fehlt im Token.`);
|
|
}
|
|
teile[abschnitt] = (teil.startsWith("A") ? "B" : "A") + teil.slice(1);
|
|
return teile.join(".");
|
|
}
|
|
|
|
describe("Sitzung erzeugen und lesen", () => {
|
|
it("ist verlustfrei, auch bei Umlauten im Namen", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
const sitzung = await sitzungLesen(jwt);
|
|
|
|
expect(sitzung).toBeDefined();
|
|
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
|
|
expect(sitzung?.email).toBe(DATEN.email);
|
|
expect(sitzung?.name).toBe("Anna Müller");
|
|
expect(sitzung?.herkunft).toBe("passwort");
|
|
});
|
|
|
|
it.each([
|
|
["Umlaute und Eszett", "Jörg Weiß-Bäcker"],
|
|
["franzoesische Akzente", "Zoé Lefèvre"],
|
|
["langer Doppelname", "Maximilian Freiherr von Hohenstein-Überlingen"],
|
|
])("behaelt %s im Namen", async (_fall, name) => {
|
|
const jwt = await sitzungErzeugen({ ...DATEN, name });
|
|
expect((await sitzungLesen(jwt))?.name).toBe(name);
|
|
});
|
|
|
|
it("setzt ausgestelltAm auf die aktuelle Zeit in Sekunden", async () => {
|
|
const vorher = Math.floor(Date.now() / 1000);
|
|
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
|
|
const nachher = Math.floor(Date.now() / 1000);
|
|
|
|
expect(sitzung?.ausgestelltAm).toBeGreaterThanOrEqual(vorher);
|
|
expect(sitzung?.ausgestelltAm).toBeLessThanOrEqual(nachher);
|
|
});
|
|
|
|
it("uebernimmt beide Herkuenfte", async () => {
|
|
for (const herkunft of ["passwort", "oidc"] as const) {
|
|
const sitzung = await sitzungLesen(await sitzungErzeugen({ ...DATEN, herkunft }));
|
|
expect(sitzung?.herkunft).toBe(herkunft);
|
|
}
|
|
});
|
|
|
|
it("signiert mit HS256 und laeuft nach der festgelegten Laufzeit ab", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
const [kopfTeil, inhaltTeil] = jwt.split(".");
|
|
const kopf = JSON.parse(Buffer.from(kopfTeil ?? "", "base64url").toString("utf8")) as {
|
|
alg: string;
|
|
};
|
|
const inhalt = JSON.parse(Buffer.from(inhaltTeil ?? "", "base64url").toString("utf8")) as {
|
|
iat: number;
|
|
exp: number;
|
|
iss: string;
|
|
aud: string;
|
|
};
|
|
|
|
expect(kopf.alg).toBe("HS256");
|
|
expect(inhalt.exp - inhalt.iat).toBe(SITZUNG_LAUFZEIT_STUNDEN * 3600);
|
|
expect(inhalt.iss).toBe("laufzettel");
|
|
expect(inhalt.aud).toBe("laufzettel");
|
|
});
|
|
|
|
it("nennt den Cookie-Namen und die Laufzeit unveraendert", () => {
|
|
// Der Name steht im Proxy und in der Anmelderoute; eine Umbenennung meldet sich hier.
|
|
expect(SITZUNG_COOKIE).toBe("lz_sitzung");
|
|
expect(SITZUNG_LAUFZEIT_STUNDEN).toBe(12);
|
|
});
|
|
|
|
it("legt den Namen nicht im Klartext offen, aber signiert ihn nachvollziehbar", async () => {
|
|
// Ein JWT ist nur signiert, nicht verschluesselt -- das soll hier niemand fuer
|
|
// Geheimhaltung halten. Der Test haelt fest, dass nichts Vertrauliches hineingehoert.
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
expect(jwt.split(".")).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe("Sitzung lesen bei kaputtem Token", () => {
|
|
it("gibt bei undefined undefined zurueck", async () => {
|
|
await expect(sitzungLesen(undefined)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
["leerer Wert", ""],
|
|
["nur Leerzeichen", " "],
|
|
["Klartext ohne Punkte", "keinTokenSondernText"],
|
|
["zu wenige Abschnitte", "aaa.bbb"],
|
|
["kein base64", "!!!.???.###"],
|
|
])("gibt bei %s undefined zurueck", async (_fall, jwt) => {
|
|
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei einem an einem Zeichen verfaelschten Token undefined zurueck", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
// Die Signatur passt danach nicht mehr -- genau das soll auffallen.
|
|
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 2))).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei verfaelschtem Inhalt undefined zurueck", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 1))).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei verfaelschtem Kopf undefined zurueck", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
await expect(sitzungLesen(zeichenVerfaelschen(jwt, 0))).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei einem abgeschnittenen Token undefined zurueck", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
|
|
await expect(sitzungLesen(jwt.slice(0, jwt.length - 5))).resolves.toBeUndefined();
|
|
await expect(sitzungLesen(jwt.split(".").slice(0, 2).join("."))).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei einem Token ohne Signatur undefined zurueck", async () => {
|
|
// Der klassische alg-none-Versuch: Signatur weglassen und hoffen.
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
await expect(sitzungLesen(`${jwt.split(".").slice(0, 2).join(".")}.`)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei einem mit anderem Schluessel signierten Token undefined zurueck", async () => {
|
|
const fremd = await tokenBauen({ geheimnis: "ein-voellig-anderes-geheimnis-mit-genug-zeichen" });
|
|
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei einem Token undefined zurueck, dessen APP_SECRET inzwischen rotiert wurde", async () => {
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
appSecretSetzen("nach-der-rotation-ein-anderes-geheimnis-mit-laenge");
|
|
|
|
// Haelt fest, dass der Schluessel nicht am Zwischenspeicher vorbeilaeuft.
|
|
await expect(sitzungLesen(jwt)).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("Sitzung lesen bei abgelaufenem oder fremdem Token", () => {
|
|
it("gibt bei einem abgelaufenen Token undefined zurueck", async () => {
|
|
const jetzt = Math.floor(Date.now() / 1000);
|
|
const abgelaufen = await tokenBauen({
|
|
ausgestelltAm: jetzt - 13 * 3600,
|
|
laeuftAbAm: jetzt - 3600,
|
|
});
|
|
|
|
await expect(sitzungLesen(abgelaufen)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("nimmt ein Token an, das gerade noch laeuft", async () => {
|
|
const jetzt = Math.floor(Date.now() / 1000);
|
|
const knapp = await tokenBauen({ laeuftAbAm: jetzt + 60 });
|
|
|
|
expect(await sitzungLesen(knapp)).toBeDefined();
|
|
});
|
|
|
|
it("gibt bei falschem Aussteller undefined zurueck", async () => {
|
|
// Ein mit demselben Geheimnis signiertes Token aus einem anderen Zweck darf nicht als
|
|
// Sitzung durchrutschen.
|
|
const fremd = await tokenBauen({ aussteller: "proxledger" });
|
|
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("gibt bei falscher Zielgruppe undefined zurueck", async () => {
|
|
const fremd = await tokenBauen({ zielgruppe: "einladung" });
|
|
await expect(sitzungLesen(fremd)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
["fehlende E-Mail", { email: undefined }],
|
|
["fehlender Name", { name: undefined }],
|
|
["fehlende Herkunft", { herkunft: undefined }],
|
|
["unbekannte Herkunft", { herkunft: "ldap" }],
|
|
["Name als Zahl", { name: 42 }],
|
|
])("gibt bei %s undefined zurueck", async (_fall, inhalt) => {
|
|
const luecke = await tokenBauen({ inhalt });
|
|
await expect(sitzungLesen(luecke)).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("APP_SECRET pruefen", () => {
|
|
it.each([
|
|
["fehlendes", ""],
|
|
["viel zu kurzes", "kurz"],
|
|
["knapp zu kurzes (31 Zeichen)", "a".repeat(31)],
|
|
])("wirft beim Erzeugen bei %s APP_SECRET mit Hinweis auf openssl", async (_fall, wert) => {
|
|
appSecretSetzen(wert);
|
|
|
|
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(SitzungsFehler);
|
|
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/openssl rand -base64 48/);
|
|
await expect(sitzungErzeugen(DATEN)).rejects.toThrow(/mindestens 32 Zeichen/);
|
|
});
|
|
|
|
it("wirft auch beim Lesen bei zu kurzem APP_SECRET", async () => {
|
|
// Die eine Ausnahme von "sitzungLesen wirft nicht": ein zu kurzes Geheimnis ist ein
|
|
// Konfigurationsfehler und darf nicht als "nicht angemeldet" durchgehen.
|
|
const jwt = await sitzungErzeugen(DATEN);
|
|
appSecretSetzen("kurz");
|
|
|
|
await expect(sitzungLesen(jwt)).rejects.toThrow(SitzungsFehler);
|
|
await expect(sitzungLesen(undefined)).rejects.toThrow(/openssl rand -base64 48/);
|
|
});
|
|
|
|
it("nimmt genau 32 Zeichen an", async () => {
|
|
appSecretSetzen("b".repeat(32));
|
|
|
|
const sitzung = await sitzungLesen(await sitzungErzeugen(DATEN));
|
|
expect(sitzung?.benutzerId).toBe(DATEN.benutzerId);
|
|
});
|
|
|
|
it("stellt den Standardwert aus setup-env wieder her", async () => {
|
|
// Haelt fest, dass die Aufraeumroutine oben greift -- sonst wuerden die Tests je nach
|
|
// Reihenfolge unterschiedlich ausgehen.
|
|
expect(process.env["APP_SECRET"]).toBe(APP_SECRET_VORHER);
|
|
expect(await sitzungLesen(await sitzungErzeugen(DATEN))).toBeDefined();
|
|
});
|
|
});
|