Files
Nexus/apps/web/src/server/auth.test.ts
T
Hartmut d45cc00f2f security: cookie + session hardening (#41)
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>
2026-04-17 09:00:54 +02:00

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"]);
});
});