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>
This commit is contained in:
@@ -14,12 +14,29 @@ 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().mockReturnValue({ handlers: {}, auth: vi.fn() }),
|
||||
default: vi.fn(
|
||||
(cfg: {
|
||||
callbacks?: {
|
||||
jwt?: (...args: unknown[]) => unknown;
|
||||
session?: (...args: unknown[]) => unknown;
|
||||
};
|
||||
}) => {
|
||||
nextAuthCalls.push(cfg);
|
||||
return { handlers: {}, auth: vi.fn() };
|
||||
},
|
||||
),
|
||||
CredentialsSignin,
|
||||
};
|
||||
});
|
||||
@@ -82,6 +99,63 @@ describe("MFA CredentialsSignin error classes — code property", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user