d45cc00f2f
Three related fixes: - Cookie secure flag now tracks AUTH_URL scheme (https → Secure), not NODE_ENV — staging over HTTPS with NODE_ENV!=production used to ship Set-Cookie without Secure. Cookie name gains __Host- prefix when Secure is on. - jwt() callback no longer swallows session-registry write failures; concurrent-session cap is now fail-closed. - Session callback no longer copies token.sid onto session.user.jti. The tRPC route handler reads the JTI directly from the encrypted JWT via getToken() so it stays server-side. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
243 lines
8.7 KiB
TypeScript
243 lines
8.7 KiB
TypeScript
/**
|
|
* 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 { beforeEach, 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.
|
|
// Capture the config passed to NextAuth() so callbacks can be invoked.
|
|
const nextAuthCalls: Array<{
|
|
callbacks?: {
|
|
jwt?: (...args: unknown[]) => unknown;
|
|
session?: (...args: unknown[]) => unknown;
|
|
};
|
|
}> = [];
|
|
vi.mock("next-auth", () => {
|
|
class CredentialsSignin extends Error {
|
|
code = "credentials";
|
|
}
|
|
return {
|
|
default: vi.fn(
|
|
(cfg: {
|
|
callbacks?: {
|
|
jwt?: (...args: unknown[]) => unknown;
|
|
session?: (...args: unknown[]) => unknown;
|
|
};
|
|
}) => {
|
|
nextAuthCalls.push(cfg);
|
|
return { handlers: {}, auth: vi.fn() };
|
|
},
|
|
),
|
|
CredentialsSignin,
|
|
};
|
|
});
|
|
|
|
// ── All other side-effectful imports auth.ts pulls in ───────────────────────
|
|
vi.mock("./runtime-env.js", () => ({ assertSecureRuntimeEnv: vi.fn() }));
|
|
|
|
// Capture the config passed to Credentials() so we can call authorize().
|
|
const credentialsCalls: Array<{ authorize: (...args: unknown[]) => unknown }> = [];
|
|
vi.mock("next-auth/providers/credentials", () => ({
|
|
default: vi.fn((cfg: { authorize: (...args: unknown[]) => unknown }) => {
|
|
credentialsCalls.push(cfg);
|
|
return cfg;
|
|
}),
|
|
}));
|
|
|
|
const prismaMock = {
|
|
user: { findUnique: vi.fn(), update: vi.fn() },
|
|
systemSettings: { findUnique: vi.fn() },
|
|
activeSession: { create: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn(), delete: vi.fn() },
|
|
};
|
|
vi.mock("@capakraken/db", () => ({ prisma: prismaMock }));
|
|
vi.mock("@capakraken/api/middleware/rate-limit", () => ({
|
|
authRateLimiter: vi.fn().mockResolvedValue({ allowed: true }),
|
|
}));
|
|
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() },
|
|
}));
|
|
const argonVerifyMock = vi.fn();
|
|
vi.mock("@node-rs/argon2", () => ({ verify: argonVerifyMock }));
|
|
|
|
// ── 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");
|
|
});
|
|
});
|
|
|
|
describe("session() — does not leak JTI to client", () => {
|
|
const sessionCb = nextAuthCalls[0]?.callbacks?.session;
|
|
if (!sessionCb) {
|
|
it.skip("session callback not captured", () => {});
|
|
return;
|
|
}
|
|
|
|
it("never assigns token.sid onto session.user.jti", async () => {
|
|
const session = await sessionCb({
|
|
session: { user: { email: "x@e.com" }, expires: "2030-01-01" },
|
|
token: { sub: "u1", role: "USER", sid: "secret-session-id" },
|
|
});
|
|
const user = (session as { user: Record<string, unknown> }).user;
|
|
expect(user["jti"]).toBeUndefined();
|
|
expect(user["sid"]).toBeUndefined();
|
|
expect(user["id"]).toBe("u1");
|
|
expect(user["role"]).toBe("USER");
|
|
});
|
|
});
|
|
|
|
describe("jwt() — concurrent-session enforcement is fail-closed", () => {
|
|
const jwtCb = nextAuthCalls[0]?.callbacks?.jwt;
|
|
if (!jwtCb) {
|
|
it.skip("jwt callback not captured", () => {});
|
|
return;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
prismaMock.systemSettings.findUnique.mockReset();
|
|
prismaMock.activeSession.create.mockReset();
|
|
prismaMock.activeSession.findMany.mockReset();
|
|
prismaMock.activeSession.deleteMany.mockReset();
|
|
});
|
|
|
|
it("throws if activeSession.create fails", async () => {
|
|
prismaMock.systemSettings.findUnique.mockResolvedValue({ maxConcurrentSessions: 3 });
|
|
prismaMock.activeSession.create.mockRejectedValue(new Error("db down"));
|
|
|
|
await expect(jwtCb({ token: {}, user: { id: "u1", role: "USER" } })).rejects.toThrow(
|
|
/Session registration failed/,
|
|
);
|
|
});
|
|
|
|
it("returns the token when session-registry writes succeed", async () => {
|
|
prismaMock.systemSettings.findUnique.mockResolvedValue({ maxConcurrentSessions: 3 });
|
|
prismaMock.activeSession.create.mockResolvedValue({});
|
|
prismaMock.activeSession.findMany.mockResolvedValue([]);
|
|
|
|
const result = (await jwtCb({ token: {}, user: { id: "u1", role: "USER" } })) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(result["role"]).toBe("USER");
|
|
expect(typeof result["sid"]).toBe("string");
|
|
});
|
|
});
|
|
|
|
describe("authorize() — login timing / enumeration defence", () => {
|
|
const authorize = credentialsCalls[0]?.authorize;
|
|
|
|
if (!authorize) {
|
|
it.skip("authorize was not captured", () => {});
|
|
return;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
argonVerifyMock.mockReset();
|
|
prismaMock.user.findUnique.mockReset();
|
|
prismaMock.user.update.mockReset();
|
|
prismaMock.systemSettings.findUnique.mockReset();
|
|
});
|
|
|
|
it("runs argon2.verify against a dummy hash when the user is not found", async () => {
|
|
prismaMock.user.findUnique.mockResolvedValue(null);
|
|
argonVerifyMock.mockResolvedValue(false);
|
|
|
|
const result = await authorize(
|
|
{ email: "nobody@example.com", password: "s3cret-password" },
|
|
undefined,
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
expect(argonVerifyMock).toHaveBeenCalledTimes(1);
|
|
const [hashArg, passwordArg] = argonVerifyMock.mock.calls[0]!;
|
|
expect(typeof hashArg).toBe("string");
|
|
expect(hashArg).toMatch(/^\$argon2id\$/);
|
|
expect(passwordArg).toBe("s3cret-password");
|
|
});
|
|
|
|
it("runs argon2.verify against a dummy hash when the account is deactivated", async () => {
|
|
prismaMock.user.findUnique.mockResolvedValue({
|
|
id: "u1",
|
|
email: "x@example.com",
|
|
isActive: false,
|
|
passwordHash: "$argon2id$real$hash",
|
|
});
|
|
argonVerifyMock.mockResolvedValue(false);
|
|
|
|
const result = await authorize({ email: "x@example.com", password: "wrong" }, undefined);
|
|
|
|
expect(result).toBeNull();
|
|
expect(argonVerifyMock).toHaveBeenCalledTimes(1);
|
|
expect(argonVerifyMock.mock.calls[0]![0]).toMatch(/^\$argon2id\$/);
|
|
});
|
|
|
|
it("records a uniform 'Login failed' audit summary for every failure branch", async () => {
|
|
const { createAuditEntry } = await import("@capakraken/api/lib/audit");
|
|
const auditMock = createAuditEntry as unknown as ReturnType<typeof vi.fn>;
|
|
auditMock.mockClear();
|
|
|
|
// Branch 1: user not found
|
|
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
|
argonVerifyMock.mockResolvedValueOnce(false);
|
|
await authorize({ email: "a@example.com", password: "p" }, undefined);
|
|
|
|
// Branch 2: deactivated account
|
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
|
id: "u1",
|
|
email: "b@example.com",
|
|
isActive: false,
|
|
passwordHash: "$argon2id$h",
|
|
});
|
|
argonVerifyMock.mockResolvedValueOnce(false);
|
|
await authorize({ email: "b@example.com", password: "p" }, undefined);
|
|
|
|
// Branch 3: wrong password
|
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
|
id: "u2",
|
|
email: "c@example.com",
|
|
isActive: true,
|
|
passwordHash: "$argon2id$h",
|
|
});
|
|
argonVerifyMock.mockResolvedValueOnce(false);
|
|
await authorize({ email: "c@example.com", password: "p" }, undefined);
|
|
|
|
const summaries = auditMock.mock.calls.map(
|
|
(call: unknown[]) => (call[0] as { summary: string }).summary,
|
|
);
|
|
expect(summaries).toEqual(["Login failed", "Login failed", "Login failed"]);
|
|
});
|
|
});
|