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
+1 -13
View File
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { prisma } from "@capakraken/db";
import { AppShell } from "~/components/layout/AppShell.js";
import { MfaPromptBanner } from "~/components/security/MfaPromptBanner.js";
import { auth } from "~/server/auth.js";
@@ -15,21 +14,10 @@ export default async function AppLayout({ children }: { children: React.ReactNod
const sessionUser = session.user as { id?: string; email?: string; role?: string } | undefined;
const userRole = sessionUser?.role ?? "USER";
const userId = sessionUser?.id;
// Show MFA prompt banner for privileged roles that haven't enabled TOTP yet
let showMfaPrompt = false;
if (userId && MFA_PROMPT_ROLES.has(userRole)) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { totpEnabled: true },
});
showMfaPrompt = user != null && !user.totpEnabled;
}
return (
<AppShell userRole={userRole}>
{showMfaPrompt && userId && <MfaPromptBanner userId={userId} />}
{MFA_PROMPT_ROLES.has(userRole) && <MfaPromptBanner />}
{children}
</AppShell>
);
+10 -9
View File
@@ -27,30 +27,31 @@ export default function SignInPage() {
});
if (result?.error) {
// Auth.js wraps authorize() errors in the error field
if (result.error.includes("MFA_REQUIRED_SETUP")) {
// Auth.js v5: CredentialsSignin subclasses forward their `code` via
// SignInResponse.code (and sometimes also as result.error).
// Check both fields for compatibility across beta versions.
const code = result.code ?? result.error;
if (code === "MFA_REQUIRED_SETUP") {
// User's role requires MFA but it hasn't been set up yet — redirect to setup
setLoading(false);
router.push("/account/security?mfa_required=1");
return;
}
if (result.error.includes("MFA_REQUIRED")) {
if (code === "MFA_REQUIRED") {
setMfaRequired(true);
setLoading(false);
// Focus the TOTP input after render
setTimeout(() => totpInputRef.current?.focus(), 100);
return;
}
if (result.error.includes("INVALID_TOTP")) {
if (code === "INVALID_TOTP") {
setError("Invalid verification code. Please try again.");
setTotp("");
setLoading(false);
return;
}
if (result.error.includes("Too many login attempts")) {
setError("Too many login attempts. Please try again later.");
} else {
setError("Invalid email or password");
}
setError("Invalid email or password");
// Reset MFA state on credential error
if (mfaRequired) {
setMfaRequired(false);
@@ -1,34 +1,41 @@
"use client";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
const SNOOZE_KEY = "capakraken_mfa_prompt_snoozed_until";
const SNOOZE_DAYS = 7;
/**
* Banner shown to ADMIN / MANAGER users who have not yet enabled TOTP MFA.
* The user can dismiss it for SNOOZE_DAYS days ("Remind me later").
*
* Props are resolved server-side in the layout, so no client-side DB fetch
* is needed here.
* Fetches MFA status client-side via tRPC so the banner reacts immediately
* after the user enables MFA — no full-page reload required.
* Snooze state is scoped by userId to prevent cross-user leakage on shared browsers.
*/
export function MfaPromptBanner({ userId }: { userId: string }) {
const [visible, setVisible] = useState(false);
export function MfaPromptBanner() {
const { data: mfaStatus } = trpc.user.getMfaStatus.useQuery();
const { data: session } = useSession();
const userId = (session?.user as { id?: string } | undefined)?.id ?? "";
const [snoozed, setSnoozed] = useState<boolean | null>(null);
// Read snooze state from localStorage on mount (keyed by userId)
useEffect(() => {
if (!userId) return;
try {
const raw = localStorage.getItem(`${SNOOZE_KEY}_${userId}`);
if (raw) {
const until = Number(raw);
if (!isNaN(until) && Date.now() < until) {
return; // still snoozed
setSnoozed(true);
return;
}
}
} catch {
// localStorage unavailable (SSR guard — should not happen in a client component)
// localStorage unavailable
}
setVisible(true);
setSnoozed(false);
}, [userId]);
function snooze() {
@@ -38,10 +45,15 @@ export function MfaPromptBanner({ userId }: { userId: string }) {
} catch {
// ignore
}
setVisible(false);
setSnoozed(true);
}
if (!visible) return null;
// Don't render until we know the MFA status and snooze state
if (mfaStatus === undefined || snoozed === null) return null;
// Already enabled — no banner needed
if (mfaStatus.totpEnabled) return null;
// Snoozed
if (snoozed) return null;
return (
<div
+68
View File
@@ -0,0 +1,68 @@
/**
* Unit tests for the MFA error classes exported from auth.ts.
*
* These tests are a regression guard: they verify that the three
* CredentialsSignin subclasses carry the correct `code` value so that
* Auth.js v5 forwards them to the client via SignInResponse.code instead of
* wrapping them as a generic CallbackRouteError.
*
* Testing the full authorize() call graph requires mocking the entire Next.js
* runtime and is covered by E2E tests instead.
*/
import { describe, expect, it, vi } from "vitest";
// ── next-auth imports next/server without .js extension which fails in vitest
// node env. Mock the whole module so the error classes can be imported.
vi.mock("next-auth", () => {
class CredentialsSignin extends Error {
code = "credentials";
}
return {
default: vi.fn().mockReturnValue({ handlers: {}, auth: vi.fn() }),
CredentialsSignin,
};
});
// ── All other side-effectful imports auth.ts pulls in ───────────────────────
vi.mock("./runtime-env.js", () => ({ assertSecureRuntimeEnv: vi.fn() }));
vi.mock("next-auth/providers/credentials", () => ({ default: vi.fn() }));
vi.mock("@capakraken/db", () => ({
prisma: { user: {}, systemSettings: {}, activeSession: {} },
}));
vi.mock("@capakraken/api/middleware/rate-limit", () => ({ authRateLimiter: vi.fn() }));
vi.mock("@capakraken/api/lib/audit", () => ({ createAuditEntry: vi.fn() }));
vi.mock("@capakraken/api/lib/logger", () => ({
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("@node-rs/argon2", () => ({ verify: vi.fn() }));
// ── Import the exported error classes after mocks are in place ───────────────
const { MfaRequiredError, MfaRequiredSetupError, InvalidTotpError } = await import("./auth.js");
describe("MFA CredentialsSignin error classes — code property", () => {
it("MfaRequiredError.code is 'MFA_REQUIRED'", () => {
expect(new MfaRequiredError().code).toBe("MFA_REQUIRED");
});
it("MfaRequiredSetupError.code is 'MFA_REQUIRED_SETUP'", () => {
expect(new MfaRequiredSetupError().code).toBe("MFA_REQUIRED_SETUP");
});
it("InvalidTotpError.code is 'INVALID_TOTP'", () => {
expect(new InvalidTotpError().code).toBe("INVALID_TOTP");
});
it("all three extend the mocked CredentialsSignin base class", async () => {
const { CredentialsSignin } = await import("next-auth");
expect(new MfaRequiredError()).toBeInstanceOf(CredentialsSignin);
expect(new MfaRequiredSetupError()).toBeInstanceOf(CredentialsSignin);
expect(new InvalidTotpError()).toBeInstanceOf(CredentialsSignin);
});
it("error class names are descriptive (not generic 'Error')", () => {
expect(new MfaRequiredError().constructor.name).toBe("MfaRequiredError");
expect(new MfaRequiredSetupError().constructor.name).toBe("MfaRequiredSetupError");
expect(new InvalidTotpError().constructor.name).toBe("InvalidTotpError");
});
});
+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();
}
}