#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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user