session-fixation-rotation.test.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import { NextRequest } from "next/server";
  3. import type { NextResponse } from "next/server";
  4. const {
  5. mockClearAuthCookie,
  6. mockGetAuthCookie,
  7. mockGetSessionTokenMode,
  8. mockRevoke,
  9. mockRotate,
  10. mockRedisSessionStoreCtor,
  11. mockLogger,
  12. } = vi.hoisted(() => {
  13. const mockRevoke = vi.fn();
  14. const mockRotate = vi.fn();
  15. return {
  16. mockClearAuthCookie: vi.fn(),
  17. mockGetAuthCookie: vi.fn(),
  18. mockGetSessionTokenMode: vi.fn(),
  19. mockRevoke,
  20. mockRotate,
  21. mockRedisSessionStoreCtor: vi.fn().mockImplementation(function RedisSessionStoreMock() {
  22. return {
  23. revoke: mockRevoke,
  24. rotate: mockRotate,
  25. };
  26. }),
  27. mockLogger: {
  28. warn: vi.fn(),
  29. error: vi.fn(),
  30. info: vi.fn(),
  31. debug: vi.fn(),
  32. trace: vi.fn(),
  33. },
  34. };
  35. });
  36. const realWithNoStoreHeaders = vi.hoisted(() => {
  37. return <T extends InstanceType<typeof NextResponse>>(response: T): T => {
  38. response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
  39. response.headers.set("Pragma", "no-cache");
  40. return response;
  41. };
  42. });
  43. vi.mock("@/lib/auth", () => ({
  44. clearAuthCookie: mockClearAuthCookie,
  45. getAuthCookie: mockGetAuthCookie,
  46. getSessionTokenMode: mockGetSessionTokenMode,
  47. withNoStoreHeaders: realWithNoStoreHeaders,
  48. }));
  49. vi.mock("@/lib/auth-session-store/redis-session-store", () => ({
  50. RedisSessionStore: mockRedisSessionStoreCtor,
  51. }));
  52. vi.mock("@/lib/logger", () => ({
  53. logger: mockLogger,
  54. }));
  55. vi.mock("@/lib/config/env.schema", () => ({
  56. getEnvConfig: vi.fn().mockReturnValue({ ENABLE_SECURE_COOKIES: false }),
  57. }));
  58. vi.mock("@/lib/security/auth-response-headers", () => ({
  59. withAuthResponseHeaders: realWithNoStoreHeaders,
  60. }));
  61. function makeLogoutRequest(): NextRequest {
  62. return new NextRequest("http://localhost/api/auth/logout", {
  63. method: "POST",
  64. headers: {
  65. "sec-fetch-site": "same-origin",
  66. },
  67. });
  68. }
  69. async function loadLogoutPost(): Promise<(request: NextRequest) => Promise<Response>> {
  70. const mod = await import("@/app/api/auth/logout/route");
  71. return mod.POST;
  72. }
  73. async function simulatePostLoginSessionRotation(
  74. oldSessionId: string,
  75. rotate: (sessionId: string) => Promise<{ sessionId: string } | null>
  76. ): Promise<string | null> {
  77. const rotated = await rotate(oldSessionId);
  78. return rotated?.sessionId ?? null;
  79. }
  80. describe("session fixation rotation and logout revocation", () => {
  81. beforeEach(() => {
  82. vi.resetModules();
  83. vi.clearAllMocks();
  84. mockRedisSessionStoreCtor.mockImplementation(function RedisSessionStoreMock() {
  85. return {
  86. revoke: mockRevoke,
  87. rotate: mockRotate,
  88. };
  89. });
  90. mockClearAuthCookie.mockResolvedValue(undefined);
  91. mockGetAuthCookie.mockResolvedValue(undefined);
  92. mockGetSessionTokenMode.mockReturnValue("legacy");
  93. mockRevoke.mockResolvedValue(true);
  94. mockRotate.mockResolvedValue(null);
  95. });
  96. it("legacy mode logout only clears cookie without session store revocation", async () => {
  97. mockGetSessionTokenMode.mockReturnValue("legacy");
  98. const POST = await loadLogoutPost();
  99. const response = await POST(makeLogoutRequest());
  100. expect(response.status).toBe(200);
  101. expect(mockRedisSessionStoreCtor).not.toHaveBeenCalled();
  102. expect(mockRevoke).not.toHaveBeenCalled();
  103. expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
  104. });
  105. it("dual mode logout revokes session and clears cookie", async () => {
  106. mockGetSessionTokenMode.mockReturnValue("dual");
  107. mockGetAuthCookie.mockResolvedValue("sid_dual_session");
  108. const POST = await loadLogoutPost();
  109. const response = await POST(makeLogoutRequest());
  110. expect(response.status).toBe(200);
  111. expect(mockRedisSessionStoreCtor).toHaveBeenCalledTimes(1);
  112. expect(mockRevoke).toHaveBeenCalledWith("sid_dual_session");
  113. expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
  114. });
  115. it("opaque mode logout revokes session and clears cookie", async () => {
  116. mockGetSessionTokenMode.mockReturnValue("opaque");
  117. mockGetAuthCookie.mockResolvedValue("sid_opaque_session");
  118. const POST = await loadLogoutPost();
  119. const response = await POST(makeLogoutRequest());
  120. expect(response.status).toBe(200);
  121. expect(mockRedisSessionStoreCtor).toHaveBeenCalledTimes(1);
  122. expect(mockRevoke).toHaveBeenCalledWith("sid_opaque_session");
  123. expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
  124. });
  125. it("logout still clears cookie when session revocation fails", async () => {
  126. mockGetSessionTokenMode.mockReturnValue("opaque");
  127. mockGetAuthCookie.mockResolvedValue("sid_revocation_failure");
  128. mockRevoke.mockRejectedValue(new Error("redis down"));
  129. const POST = await loadLogoutPost();
  130. const response = await POST(makeLogoutRequest());
  131. expect(response.status).toBe(200);
  132. expect(mockRevoke).toHaveBeenCalledWith("sid_revocation_failure");
  133. expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
  134. expect(mockLogger.warn).toHaveBeenCalledTimes(1);
  135. });
  136. it("post-login rotation returns a different session id", async () => {
  137. const oldSessionId = "sid_existing_session";
  138. mockRotate.mockResolvedValue({
  139. sessionId: "sid_rotated_session",
  140. keyFingerprint: "fp-login",
  141. userId: 7,
  142. userRole: "user",
  143. createdAt: 1_700_000_000_000,
  144. expiresAt: 1_700_000_300_000,
  145. });
  146. const rotatedSessionId = await simulatePostLoginSessionRotation(oldSessionId, mockRotate);
  147. expect(mockRotate).toHaveBeenCalledWith(oldSessionId);
  148. expect(rotatedSessionId).toBe("sid_rotated_session");
  149. expect(rotatedSessionId).not.toBe(oldSessionId);
  150. });
  151. });