Authorization Code mit PKCE, State und Nonce über openid-client 6.8.8; die Rolle kommt bei jeder Anmeldung aus dem Gruppen-Claim. Lokale Konten werden nie über den Namen übernommen, verknüpfen geht nur angemeldet. Getestet gegen einen Mock-IdP inklusive gefälschtem State, falscher Nonce und fremder Audience. Closes #016 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
71 lines
2.9 KiB
TypeScript
71 lines
2.9 KiB
TypeScript
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||
import { join, relative } from "node:path";
|
||
import { describe, expect, it } from "vitest";
|
||
import { istOeffentlich, OEFFENTLICH } from "@/proxy";
|
||
|
||
/**
|
||
* Strukturelle Prüfung: jede Seite und jeder Route Handler prüft sein Recht selbst.
|
||
* Ausnahmen stehen hier namentlich mit Begründung – ein neuer Handler ohne Prüfung soll
|
||
* jemanden zwingen, sie aufzuschreiben.
|
||
*/
|
||
const AUSNAHMEN: Record<string, string> = {
|
||
"src/app/api/v1/auth/anmelden/route.ts": "Anmeldung selbst",
|
||
"src/app/api/v1/auth/token/route.ts":
|
||
"proxdeploy login: Passwort gegen Token – prüft das Passwort selbst (mit Sperre)",
|
||
"src/app/api/v1/auth/abmelden/route.ts": "Abmelden muss ohne gültige Sitzung gehen",
|
||
"src/app/api/v1/auth/oidc/start/route.ts":
|
||
"SSO-Start ist der Weg zur Anmeldung; Verknüpfen prüft die Sitzung selbst",
|
||
"src/app/api/v1/auth/oidc/rueckruf/route.ts":
|
||
"Rückkehr vom IdP; prüft State, PKCE, Nonce und ID-Token selbst",
|
||
"src/app/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
|
||
"src/app/anmelden/page.tsx": "Anmeldeseite",
|
||
"src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis",
|
||
};
|
||
|
||
function dateien(verzeichnis: string): string[] {
|
||
return readdirSync(verzeichnis).flatMap((name) => {
|
||
const pfad = join(verzeichnis, name);
|
||
return statSync(pfad).isDirectory() ? dateien(pfad) : [pfad];
|
||
});
|
||
}
|
||
|
||
const wurzel = process.cwd();
|
||
const kandidaten = dateien(join(wurzel, "src/app"))
|
||
.map((pfad) => relative(wurzel, pfad))
|
||
.filter((pfad) => pfad.endsWith("/page.tsx") || pfad.endsWith("/route.ts"));
|
||
|
||
describe("Zugangsprüfung", () => {
|
||
it.each(kandidaten.filter((pfad) => !(pfad in AUSNAHMEN)))("%s prüft sein Recht", (pfad) => {
|
||
const quelltext = readFileSync(join(wurzel, pfad), "utf8");
|
||
expect(quelltext).toMatch(/seiteSchuetzen\(|rechtErforderlich\(/);
|
||
});
|
||
|
||
it("jede Ausnahme gibt es noch", () => {
|
||
for (const pfad of Object.keys(AUSNAHMEN)) expect(kandidaten).toContain(pfad);
|
||
});
|
||
|
||
it("öffentliche Pfade sind genau die bekannten", () => {
|
||
expect(OEFFENTLICH).toEqual([
|
||
"/anmelden",
|
||
"/api/v1/auth",
|
||
"/api/v1/gesundheit",
|
||
"/brand",
|
||
"/manifest.webmanifest",
|
||
]);
|
||
expect(istOeffentlich("/api/v1/auth/anmelden")).toBe(true);
|
||
expect(istOeffentlich("/api/v1/authentifizieren")).toBe(false);
|
||
expect(istOeffentlich("/server")).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("Client-Komponenten", () => {
|
||
const clientDateien = dateien(join(wurzel, "src"))
|
||
.filter((pfad) => /\.tsx?$/.test(pfad) && !pfad.includes("/generated/"))
|
||
.filter((pfad) => /^["']use client["']/m.test(readFileSync(pfad, "utf8")));
|
||
|
||
it.each(clientDateien.map((p) => relative(wurzel, p)))("%s importiert nichts aus src/server", (pfad) => {
|
||
// Auch reine Typimporte nicht: sonst wandert beim nächsten Umbau doch Servercode ins Browser-Bündel.
|
||
expect(readFileSync(join(wurzel, pfad), "utf8")).not.toMatch(/from ["']@\/server\//);
|
||
});
|
||
});
|