auth-csrf-route-integration.test.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import type { NextRequest } from "next/server";
  3. const mockValidateKey = vi.hoisted(() => vi.fn());
  4. const mockSetAuthCookie = vi.hoisted(() => vi.fn());
  5. const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
  6. const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
  7. const mockClearAuthCookie = vi.hoisted(() => vi.fn());
  8. const mockGetAuthCookie = vi.hoisted(() => vi.fn());
  9. const mockGetTranslations = vi.hoisted(() => vi.fn());
  10. const mockGetEnvConfig = vi.hoisted(() => vi.fn());
  11. const mockLogger = vi.hoisted(() => ({
  12. warn: vi.fn(),
  13. error: vi.fn(),
  14. info: vi.fn(),
  15. debug: vi.fn(),
  16. }));
  17. vi.mock("@/lib/auth", () => ({
  18. validateKey: mockValidateKey,
  19. setAuthCookie: mockSetAuthCookie,
  20. getSessionTokenMode: mockGetSessionTokenMode,
  21. getLoginRedirectTarget: mockGetLoginRedirectTarget,
  22. clearAuthCookie: mockClearAuthCookie,
  23. getAuthCookie: mockGetAuthCookie,
  24. toKeyFingerprint: vi.fn().mockResolvedValue("sha256:mock"),
  25. withNoStoreHeaders: <T>(res: T): T => {
  26. (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
  27. (res as any).headers.set("Pragma", "no-cache");
  28. return res;
  29. },
  30. }));
  31. vi.mock("next-intl/server", () => ({
  32. getTranslations: mockGetTranslations,
  33. }));
  34. vi.mock("@/lib/config/env.schema", () => ({
  35. getEnvConfig: mockGetEnvConfig,
  36. }));
  37. vi.mock("@/lib/logger", () => ({
  38. logger: mockLogger,
  39. }));
  40. vi.mock("@/lib/security/auth-response-headers", () => ({
  41. withAuthResponseHeaders: <T>(res: T): T => {
  42. (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
  43. (res as any).headers.set("Pragma", "no-cache");
  44. return res;
  45. },
  46. }));
  47. type LoginPostHandler = (request: NextRequest) => Promise<Response>;
  48. type LogoutPostHandler = (request: NextRequest) => Promise<Response>;
  49. function makeLoginRequest(headers: Record<string, string> = {}, key = "valid-key"): NextRequest {
  50. const requestHeaders = new Headers({
  51. "content-type": "application/json",
  52. ...headers,
  53. });
  54. return {
  55. headers: requestHeaders,
  56. cookies: {
  57. get: () => undefined,
  58. },
  59. json: async () => ({ key }),
  60. } as unknown as NextRequest;
  61. }
  62. function makeLogoutRequest(headers: Record<string, string> = {}): NextRequest {
  63. return {
  64. headers: new Headers(headers),
  65. } as unknown as NextRequest;
  66. }
  67. describe("auth route csrf guard integration", () => {
  68. const originalNodeEnv = process.env.NODE_ENV;
  69. let loginPost: LoginPostHandler;
  70. let logoutPost: LogoutPostHandler;
  71. afterEach(() => {
  72. process.env.NODE_ENV = originalNodeEnv;
  73. });
  74. beforeEach(async () => {
  75. vi.resetModules();
  76. vi.clearAllMocks();
  77. process.env.NODE_ENV = "test";
  78. mockGetTranslations.mockResolvedValue(
  79. vi.fn((messageKey: string) => `translated:${messageKey}`)
  80. );
  81. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
  82. mockValidateKey.mockResolvedValue({
  83. user: {
  84. id: 1,
  85. name: "Test User",
  86. description: "desc",
  87. role: "user",
  88. },
  89. key: {
  90. canLoginWebUi: true,
  91. },
  92. });
  93. mockSetAuthCookie.mockResolvedValue(undefined);
  94. mockGetLoginRedirectTarget.mockReturnValue("/dashboard");
  95. mockClearAuthCookie.mockResolvedValue(undefined);
  96. mockGetAuthCookie.mockResolvedValue(undefined);
  97. mockGetSessionTokenMode.mockReturnValue("legacy");
  98. const loginRoute = await import("@/app/api/auth/login/route");
  99. loginPost = loginRoute.POST;
  100. const logoutRoute = await import("@/app/api/auth/logout/route");
  101. logoutPost = logoutRoute.POST;
  102. });
  103. it("allows same-origin login request to pass through", async () => {
  104. const res = await loginPost(makeLoginRequest({ "sec-fetch-site": "same-origin" }));
  105. expect(res.status).toBe(200);
  106. expect(mockValidateKey).toHaveBeenCalledWith("valid-key", { allowReadOnlyAccess: true });
  107. });
  108. it("blocks cross-origin login request with csrf rejected error", async () => {
  109. const request = makeLoginRequest({
  110. "sec-fetch-site": "cross-site",
  111. origin: "https://evil.example.com",
  112. });
  113. const res = await loginPost(request);
  114. expect(res.status).toBe(403);
  115. expect(await res.json()).toEqual({ errorCode: "CSRF_REJECTED" });
  116. expect(mockValidateKey).not.toHaveBeenCalled();
  117. });
  118. it("allows login request without origin header for non-browser clients", async () => {
  119. const res = await loginPost(makeLoginRequest());
  120. expect(res.status).toBe(200);
  121. expect(mockValidateKey).toHaveBeenCalledTimes(1);
  122. });
  123. it("allows same-origin logout request to pass through", async () => {
  124. const res = await logoutPost(makeLogoutRequest({ "sec-fetch-site": "same-origin" }));
  125. expect(res.status).toBe(200);
  126. expect(await res.json()).toEqual({ ok: true });
  127. expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
  128. });
  129. it("blocks cross-origin logout request with csrf rejected error", async () => {
  130. const request = makeLogoutRequest({
  131. "sec-fetch-site": "cross-site",
  132. origin: "https://evil.example.com",
  133. });
  134. const res = await logoutPost(request);
  135. expect(res.status).toBe(403);
  136. expect(await res.json()).toEqual({ errorCode: "CSRF_REJECTED" });
  137. expect(mockClearAuthCookie).not.toHaveBeenCalled();
  138. });
  139. it("allows logout request without origin header for non-browser clients", async () => {
  140. const res = await logoutPost(makeLogoutRequest());
  141. expect(res.status).toBe(200);
  142. expect(await res.json()).toEqual({ ok: true });
  143. expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
  144. });
  145. });