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>
This commit is contained in:
2026-04-02 00:20:47 +02:00
parent 435c871e1f
commit e5ecea81c5
8 changed files with 341 additions and 459 deletions
+17 -3
View File
@@ -4,12 +4,26 @@ import { createAuditEntry } from "@capakraken/api/lib/audit";
import { logger } from "@capakraken/api/lib/logger";
import NextAuth, { type NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { CredentialsSignin } from "next-auth";
import { verify } from "@node-rs/argon2";
import { z } from "zod";
import { assertSecureRuntimeEnv } from "./runtime-env";
assertSecureRuntimeEnv();
// Auth.js v5: throw CredentialsSignin subclasses so the `code` is forwarded
// to the client via SignInResponse.code — plain Error throws become
// CallbackRouteError and the message is never visible to the client.
export class MfaRequiredError extends CredentialsSignin {
code = "MFA_REQUIRED" as const;
}
export class MfaRequiredSetupError extends CredentialsSignin {
code = "MFA_REQUIRED_SETUP" as const;
}
export class InvalidTotpError extends CredentialsSignin {
code = "INVALID_TOTP" as const;
}
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
@@ -88,7 +102,7 @@ const authConfig = {
if (user.totpEnabled && user.totpSecret) {
if (!totp) {
// Signal to the client that MFA is required (include userId for re-submission)
throw new Error("MFA_REQUIRED:" + user.id);
throw new MfaRequiredError();
}
const { TOTP, Secret } = await import("otpauth");
@@ -114,7 +128,7 @@ const authConfig = {
summary: "Login failed — invalid TOTP token",
source: "ui",
});
throw new Error("INVALID_TOTP");
throw new InvalidTotpError();
}
}
@@ -127,7 +141,7 @@ const authConfig = {
});
const requireMfaForRoles = (settings?.requireMfaForRoles as string[] | null) ?? [];
if (requireMfaForRoles.includes(user.systemRole)) {
throw new Error("MFA_REQUIRED_SETUP:" + user.id);
throw new MfaRequiredSetupError();
}
}