Files
stack/apps/api/src/common/controllers/csrf.controller.spec.ts
Jason Woltje 7390cac2cc fix(#338): Bind CSRF token to user session with HMAC
- Token now includes HMAC binding to session ID
- Validates session binding on verification
- Adds CSRF_SECRET configuration requirement
- Requires authentication for CSRF token endpoint
- 51 new tests covering session binding security

Security: CSRF tokens are now cryptographically tied to user sessions,
preventing token reuse across sessions and mitigating session fixation
attacks.

Token format: {random_part}:{hmac(random_part + user_id, secret)}

Refs #338

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:33:22 -06:00

178 lines
5.6 KiB
TypeScript

/**
* CSRF Controller Tests
*
* Tests CSRF token generation endpoint with session binding.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Request, Response } from "express";
import { CsrfController } from "./csrf.controller";
import { CsrfService } from "../services/csrf.service";
import type { AuthenticatedUser } from "../types/user.types";
interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser;
}
describe("CsrfController", () => {
let controller: CsrfController;
let csrfService: CsrfService;
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
process.env.CSRF_SECRET = "test-secret-0123456789abcdef0123456789abcdef";
csrfService = new CsrfService();
csrfService.onModuleInit();
controller = new CsrfController(csrfService);
});
afterEach(() => {
process.env = originalEnv;
});
const createMockRequest = (userId?: string): AuthenticatedRequest => {
return {
user: userId ? { id: userId, email: "test@example.com", name: "Test User" } : undefined,
} as AuthenticatedRequest;
};
const createMockResponse = (): Response => {
return {
cookie: vi.fn(),
} as unknown as Response;
};
describe("getCsrfToken", () => {
it("should generate and return a CSRF token with session binding", () => {
const mockRequest = createMockRequest("user-123");
const mockResponse = createMockResponse();
const result = controller.getCsrfToken(mockRequest, mockResponse);
expect(result).toHaveProperty("token");
expect(typeof result.token).toBe("string");
// Token format: random:hmac (64 hex chars : 64 hex chars)
expect(result.token).toContain(":");
const parts = result.token.split(":");
expect(parts[0]).toHaveLength(64);
expect(parts[1]).toHaveLength(64);
});
it("should set CSRF token in httpOnly cookie", () => {
const mockRequest = createMockRequest("user-123");
const mockResponse = createMockResponse();
const result = controller.getCsrfToken(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
result.token,
expect.objectContaining({
httpOnly: true,
sameSite: "strict",
})
);
});
it("should set secure flag in production", () => {
process.env.NODE_ENV = "production";
const mockRequest = createMockRequest("user-123");
const mockResponse = createMockResponse();
controller.getCsrfToken(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
expect.any(String),
expect.objectContaining({
secure: true,
})
);
});
it("should not set secure flag in development", () => {
process.env.NODE_ENV = "development";
const mockRequest = createMockRequest("user-123");
const mockResponse = createMockResponse();
controller.getCsrfToken(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
expect.any(String),
expect.objectContaining({
secure: false,
})
);
});
it("should generate unique tokens on each call", () => {
const mockRequest = createMockRequest("user-123");
const mockResponse = createMockResponse();
const result1 = controller.getCsrfToken(mockRequest, mockResponse);
const result2 = controller.getCsrfToken(mockRequest, mockResponse);
expect(result1.token).not.toBe(result2.token);
});
it("should set cookie with 24 hour expiry", () => {
const mockRequest = createMockRequest("user-123");
const mockResponse = createMockResponse();
controller.getCsrfToken(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledWith(
"csrf-token",
expect.any(String),
expect.objectContaining({
maxAge: 24 * 60 * 60 * 1000, // 24 hours
})
);
});
it("should throw error when user is not authenticated", () => {
const mockRequest = createMockRequest(); // No user ID
const mockResponse = createMockResponse();
expect(() => controller.getCsrfToken(mockRequest, mockResponse)).toThrow(
"User ID not available after authentication"
);
});
it("should generate token bound to specific user session", () => {
const mockRequest = createMockRequest("user-123");
const mockResponse = createMockResponse();
const result = controller.getCsrfToken(mockRequest, mockResponse);
// Token should be valid for user-123
expect(csrfService.validateToken(result.token, "user-123")).toBe(true);
// Token should be invalid for different user
expect(csrfService.validateToken(result.token, "user-456")).toBe(false);
});
it("should generate different tokens for different users", () => {
const mockResponse = createMockResponse();
const request1 = createMockRequest("user-A");
const request2 = createMockRequest("user-B");
const result1 = controller.getCsrfToken(request1, mockResponse);
const result2 = controller.getCsrfToken(request2, mockResponse);
expect(result1.token).not.toBe(result2.token);
// Each token only valid for its user
expect(csrfService.validateToken(result1.token, "user-A")).toBe(true);
expect(csrfService.validateToken(result1.token, "user-B")).toBe(false);
expect(csrfService.validateToken(result2.token, "user-B")).toBe(true);
expect(csrfService.validateToken(result2.token, "user-A")).toBe(false);
});
});
});