#41 (critical): Replace plain Error throws in authorize() with CredentialsSignin subclasses (MfaRequiredError / MfaRequiredSetupError / InvalidTotpError). Auth.js v5 forwards CredentialsSignin.code to the client via SignInResponse.code; plain throws become CallbackRouteError and the message is never visible. Signin page now checks result.code ?? result.error for exact code matching. #38: MfaPromptBanner converted to fully client-side component via trpc.user.getMfaStatus.useQuery() — disappears immediately after MFA enable without requiring page reload. Snooze key remains userId-scoped via useSession(). Server-side prisma.user.findUnique call removed from (app)/layout.tsx. #40: NEXTAUTH_URL default fallback removed from docker-compose.yml. The variable is now required (:?) — docker compose up fails with a descriptive error if the value is missing, preventing silent localhost redirect bugs. Tests: auth.test.ts (5), MfaPromptBanner.test.ts (7), reset-password.test.ts (6) All new tests green. pnpm --filter @capakraken/web exec tsc --noEmit clean. Co-Authored-By: claude-flow <ruv@ruv.net>
9.5 KiB
CapaKraken — Umsetzungsplan
Gitea-Repo: https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY
Stand: 2026-04-02 | Issues: #38–#42 (MFA Post-Activation Bugs)
Plan: MFA Post-Activation Bugs #38–#42
Anforderungsanalyse
Nach der Implementierung der MFA-Tickets (#28–#35) wurden 5 Folgefehler identifiziert, die den Login-Flow für MFA-Nutzer vollständig blockieren. Betroffen sind apps/web und packages/api. Kein Prisma-Schema-Change nötig.
Kritischer Pfad: #41 muss zuerst rein — solange Auth.js v5 alle throws aus authorize() als CallbackRouteError wrappt, ist der MFA-Login für jeden Nutzer gebrochen. Alle anderen Bugs sind unabhängig davon lösbar, aber #41 ist Voraussetzung für testbare Akzeptanzkriterien von #38 und #39.
Betroffene Pakete & Dateien
| Paket | Datei | Art |
|---|---|---|
apps/web |
src/server/auth.ts |
edit — Custom CredentialsSignin-Subklassen |
apps/web |
src/app/auth/signin/page.tsx |
edit — Error-Code-Prüfung anpassen |
apps/web |
src/app/(app)/layout.tsx |
edit — server-seitiges showMfaPrompt entfernen |
apps/web |
src/components/security/MfaPromptBanner.tsx |
edit — client-seitig via tRPC |
apps/web |
src/app/api/perf/route.test.ts |
edit — neuer Test für Auth-Fehler-Signale |
apps/web |
docker-compose.yml |
edit — NEXTAUTH_URL required machen |
packages/api |
src/router/user.ts |
edit — resetUserPassword Admin-Mutation |
packages/api |
src/router/user-procedure-support.ts |
edit — resetUserPassword-Support-Fn |
packages/api |
src/__tests__/reset-password.test.ts |
create — Unit-Tests |
Task-Liste
Block A — #41: Auth.js v5 MFA-Signal-Fix (KRITISCH)
-
Task A1: Custom Error-Klassen in
apps/web/src/server/auth.tsdefinieren. ImportiereCredentialsSigninaus"next-auth"und erstelle 3 Subklassen direkt in der Datei:class MfaRequiredError extends CredentialsSignin { code = "MFA_REQUIRED" as const; } class MfaRequiredSetupError extends CredentialsSignin { code = "MFA_REQUIRED_SETUP" as const; } class InvalidTotpError extends CredentialsSignin { code = "INVALID_TOTP" as const; }Ersetze alle 3
throw new Error(...)inauthorize():throw new Error("MFA_REQUIRED:...")→throw new MfaRequiredError()throw new Error("MFA_REQUIRED_SETUP:...")→throw new MfaRequiredSetupError()throw new Error("INVALID_TOTP")→throw new InvalidTotpError()throw new Error("Too many login attempts...")→ bleibt als normalerthrow(nicht durch MFA verursacht,return nullwäre die sauberere Variante — aber Audit-Eintrag muss davor kommen) → Datei:apps/web/src/server/auth.ts
-
Task A2: Signin-Page: Error-Code-Prüfung anpassen. In Auth.js v5 beta mit
redirect: falseliefertsignIn()beiCredentialsSignin-Subklassen:result.error = "<code>"(also"MFA_REQUIRED","MFA_REQUIRED_SETUP","INVALID_TOTP") oderresult.error = "CredentialsSignin"+result.code = "<code>"— je nach beta-Version. Strategie für beta.25: Exakte string-Gleichheit statt.includes()verwenden:if (result.error === "MFA_REQUIRED_SETUP" || result.code === "MFA_REQUIRED_SETUP") { ... } if (result.error === "MFA_REQUIRED" || result.code === "MFA_REQUIRED") { ... } if (result.error === "INVALID_TOTP" || result.code === "INVALID_TOTP") { ... }Rate-Limit-Fall:
return nullstattthrowmacht inauthorize()aus.includes("Too many")-Prüfung ein separates Problem — hier stattdessen im!isValid-Pfad bleiben und für Rate-Limitresult.error="CredentialsSignin"annehmen (der Nutzer sieht keinen spezifischen Text mehr, was ok ist — Rate-Limit-Feedback ist absichtlich vage). → Datei:apps/web/src/app/auth/signin/page.tsx -
Task A3: Unit-Test für Auth-Fehler-Signale. Teste
authorize()direkt mit einem gemockten prisma: MFA-aktivierter User ohnetotp-Feld → wirftMfaRequiredError. Falsches TOTP → wirftInvalidTotpError. Richtiger TOTP-Code → gibt User-Objekt zurück. → Datei:apps/web/src/server/auth.test.ts(neu)
Block B — #38: MFA-Banner als Client-Component
-
Task B1:
MfaPromptBanner.tsxzu vollständig client-seitigem Component umbauen. StattuserId-Prop: eigene tRPC-Querytrpc.user.getMfaStatus.useQuery(). Der Banner rendert sich selbst unsichtbar (return null) solange der Query lädt odertotpEnabled === true. KeinuserId-Prop mehr nötig. → Datei:apps/web/src/components/security/MfaPromptBanner.tsx -
Task B2:
(app)/layout.tsxbereinigen. Denprisma.user.findUnique-Call fürshowMfaPromptentfernen. Denprisma-Import entfernen.MfaPromptBannerohne Props rendern — immer eingebunden fürMFA_PROMPT_ROLES(oder direkt bedingungslos, da der Banner selbst den Status kennt). → Datei:apps/web/src/app/(app)/layout.tsx
Block C — #39: Dashboard-Widgets nach MFA-Aktivierung (Investigation)
- Task C1: Nach #41-Fix: Login mit MFA-Nutzer testen und prüfen, ob Dashboard-Widgets laden.
Hypothese: Die Widgets schlugen fehl weil die Session nach MFA-Aktivierung als ungültig galt (der active-session-Check in
auth.tskönnte nach einem tRPC-Call eine veraltete Session sehen). Nach #41-Fix und erneutem Login sollte die Session gültig sein. Falls das Problem dann noch besteht: Untersuche obenableTotpdie laufende Session invalidiert (fehlende Session-Neuausstellung nach MFA-Aktivierung). → Kein Code-Change bis C1-Prüfung abgeschlossen. Falls nötig:user.enableTotpgibt nach Erfolg{ requireRelogin: true }zurück und das Frontend leitet auf/auth/signinweiter.
Block D — #40: NEXTAUTH_URL Required
-
Task D1:
NEXTAUTH_URL-Fallback indocker-compose.ymlentfernen.# vorher: NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3100} # nachher: NEXTAUTH_URL: ${NEXTAUTH_URL:?NEXTAUTH_URL must be set in .env}→ Datei:
docker-compose.yml -
Task D2:
.env.exampleaktualisieren —NEXTAUTH_URL=https://your-domain.comdokumentieren. → Datei:.env.example(falls vorhanden, sonst erstellen)
Block E — #42: Admin-Password-Reset-Mutation
-
Task E1:
resetUserPassword-Funktion inuser-procedure-support.tshinzufügen. Die vorhandenesetUserPasswordsetzt das Passwort beim User-Create. Eine neue FunktionresetUserPassword(ctx, input: { userId, newPassword })hashtnewPasswordmit argon2id und schreibtpasswordHash. Nur erreichbar viaadminProcedure. → Datei:packages/api/src/router/user-procedure-support.ts -
Task E2: Mutation in
user.tsregistrieren.resetPassword: adminProcedure .input(z.object({ userId: z.string(), newPassword: z.string().min(8) })) .mutation(({ ctx, input }) => resetUserPassword(ctx, input)),→ Datei:
packages/api/src/router/user.ts -
Task E3: Unit-Test für
resetUserPassword. Happy path: Mutation updatedpasswordHash. Negative cases: non-admin schlägt fehl, leeres Passwort schlägt fehl. → Datei:packages/api/src/__tests__/reset-password.test.ts -
Task E4: Admin-UI — Password-Reset-Button in der User-Verwaltung. Im Admin-Bereich (vermutlich
apps/web/src/app/(app)/admin/users/) Button "Reset Password" mit einem Modal, das ein neues Passwort verlangt (min. 8 Zeichen). Kein separates Ticket nötig — gehört zu #42. → Datei: Admin-User-List-Page (Pfad nach aktuellem Stand ermitteln)
Abhängigkeiten
A1 → A2 → A3 (Sequential: A2 braucht den finalen Code aus A1)
B1 → B2 (Sequential: B2 entfernt was B1 ersetzt)
A3, B2, D1, E3 (Können parallel laufen, sobald A1 fertig)
C1 nach A1+A2 (Test)
E4 nach E1+E2
Reihenfolge:
- A1 (kritisch, zuerst — entsperrt alle anderen)
- A2 (direkt danach, am selben Batch)
- Parallel: B1+B2, D1+D2, E1+E2
- A3, E3, E4
- C1 (manuelle Verifikation nach Deploy)
Akzeptanzkriterien
pnpm test:unitläuft grünpnpm --filter @capakraken/web exec tsc --noEmit— keine neuen Fehler- Login mit
hn@hartmut-noerenberg.com+Admin12345!zeigt TOTP-Eingabe (nicht "Invalid email or password") - Korrekter TOTP-Code → erfolgreicher Login
- Nach MFA-Aktivierung via UI verschwindet der Banner sofort ohne Reload
- Nach Logout von
capakraken.hartmut-noerenberg.comlandet man auf der Login-Seite der gleichen Domain docker compose upohneNEXTAUTH_URLschlägt mit lesebarer Fehlermeldung fehl- Admin kann Passwort eines anderen Users über die UI zurücksetzen
Risiken & offene Fragen
-
Auth.js beta.25 vs beta.30
result.errorvsresult.code: Die genaue Rückgabestruktur vonsignIn({ redirect: false })variiert zwischen beta-Versionen. Task A2 muss nach A1 kurz manuell verifiziert werden — ggf. beide Felder (result.errorUNDresult.code) prüfen. -
#39 Dashboard-Bug: Ursache noch unklar. Nach #41-Fix ist zu prüfen, ob der Fehler von selbst verschwindet (Session-Neustart beim Re-Login). Falls nicht: Session-Re-Issue nach
enableTotpnötig (Breaking Change in der MFA-Setup-UX). -
MFA_REQUIRED enthält
userIdim aktuellen throw: Die userId war im"MFA_REQUIRED:userId"-String für eine geplante Re-Auth-Mechanik. MitCredentialsSigninentfällt dieses Encoding — die userId wird in der TOTP-Verifikation direkt aus der Session gelesen (was korrekt ist, da die Session beim ersten erfolgreichen Password-Check bereits existiert). Kein Datenverlust.