Files
CapaKraken/plan.md
T
Hartmut e5ecea81c5 fix(auth): resolve MFA post-activation login failures — tickets #38 #40 #41
#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>
2026-04-02 00:20:47 +02:00

9.5 KiB
Raw Blame History

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.ts definieren. Importiere CredentialsSignin aus "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(...) in authorize():

    • 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 normaler throw (nicht durch MFA verursacht, return null wä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: false liefert signIn() bei CredentialsSignin-Subklassen: result.error = "<code>" (also "MFA_REQUIRED", "MFA_REQUIRED_SETUP", "INVALID_TOTP") oder result.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 null statt throw macht in authorize() aus .includes("Too many")-Prüfung ein separates Problem — hier stattdessen im !isValid-Pfad bleiben und für Rate-Limit result.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 ohne totp-Feld → wirft MfaRequiredError. Falsches TOTP → wirft InvalidTotpError. 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.tsx zu vollständig client-seitigem Component umbauen. Statt userId-Prop: eigene tRPC-Query trpc.user.getMfaStatus.useQuery(). Der Banner rendert sich selbst unsichtbar (return null) solange der Query lädt oder totpEnabled === true. Kein userId-Prop mehr nötig. → Datei: apps/web/src/components/security/MfaPromptBanner.tsx

  • Task B2: (app)/layout.tsx bereinigen. Den prisma.user.findUnique-Call für showMfaPrompt entfernen. Den prisma-Import entfernen. MfaPromptBanner ohne Props rendern — immer eingebunden für MFA_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.ts kö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 ob enableTotp die laufende Session invalidiert (fehlende Session-Neuausstellung nach MFA-Aktivierung). → Kein Code-Change bis C1-Prüfung abgeschlossen. Falls nötig: user.enableTotp gibt nach Erfolg { requireRelogin: true } zurück und das Frontend leitet auf /auth/signin weiter.

Block D — #40: NEXTAUTH_URL Required

  • Task D1: NEXTAUTH_URL-Fallback in docker-compose.yml entfernen.

    # 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.example aktualisieren — NEXTAUTH_URL=https://your-domain.com dokumentieren. → Datei: .env.example (falls vorhanden, sonst erstellen)


Block E — #42: Admin-Password-Reset-Mutation

  • Task E1: resetUserPassword-Funktion in user-procedure-support.ts hinzufügen. Die vorhandene setUserPassword setzt das Passwort beim User-Create. Eine neue Funktion resetUserPassword(ctx, input: { userId, newPassword }) hasht newPassword mit argon2id und schreibt passwordHash. Nur erreichbar via adminProcedure. → Datei: packages/api/src/router/user-procedure-support.ts

  • Task E2: Mutation in user.ts registrieren.

    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 updated passwordHash. 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:

  1. A1 (kritisch, zuerst — entsperrt alle anderen)
  2. A2 (direkt danach, am selben Batch)
  3. Parallel: B1+B2, D1+D2, E1+E2
  4. A3, E3, E4
  5. C1 (manuelle Verifikation nach Deploy)

Akzeptanzkriterien

  • pnpm test:unit läuft grün
  • pnpm --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.com landet man auf der Login-Seite der gleichen Domain
  • docker compose up ohne NEXTAUTH_URL schlägt mit lesebarer Fehlermeldung fehl
  • Admin kann Passwort eines anderen Users über die UI zurücksetzen

Risiken & offene Fragen

  1. Auth.js beta.25 vs beta.30 result.error vs result.code: Die genaue Rückgabestruktur von signIn({ redirect: false }) variiert zwischen beta-Versionen. Task A2 muss nach A1 kurz manuell verifiziert werden — ggf. beide Felder (result.error UND result.code) prüfen.

  2. #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 enableTotp nötig (Breaking Change in der MFA-Setup-UX).

  3. MFA_REQUIRED enthält userId im aktuellen throw: Die userId war im "MFA_REQUIRED:userId"-String für eine geplante Re-Auth-Mechanik. Mit CredentialsSignin entfä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.