Просмотр исходного кода

feat(security): wave 6 - opaque session login, dual-read auth, logout revocation, bruteforce integration, CSRF guard

ding113 3 недель назад
Родитель
Сommit
8f514473bf

+ 88 - 3
src/app/api/auth/login/route.ts

@@ -1,13 +1,32 @@
+import crypto from "node:crypto";
 import { type NextRequest, NextResponse } from "next/server";
 import { type NextRequest, NextResponse } from "next/server";
 import { getTranslations } from "next-intl/server";
 import { getTranslations } from "next-intl/server";
 import { defaultLocale, type Locale, locales } from "@/i18n/config";
 import { defaultLocale, type Locale, locales } from "@/i18n/config";
-import { getLoginRedirectTarget, setAuthCookie, validateKey, withNoStoreHeaders } from "@/lib/auth";
+import {
+  type AuthSession,
+  getLoginRedirectTarget,
+  getSessionTokenMode,
+  setAuthCookie,
+  validateKey,
+  withNoStoreHeaders,
+} from "@/lib/auth";
+import { RedisSessionStore } from "@/lib/auth-session-store/redis-session-store";
 import { getEnvConfig } from "@/lib/config/env.schema";
 import { getEnvConfig } from "@/lib/config/env.schema";
 import { logger } from "@/lib/logger";
 import { logger } from "@/lib/logger";
+import { createCsrfOriginGuard } from "@/lib/security/csrf-origin-guard";
+import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy";
 
 
 // 需要数据库连接
 // 需要数据库连接
 export const runtime = "nodejs";
 export const runtime = "nodejs";
 
 
+const csrfGuard = createCsrfOriginGuard({
+  allowedOrigins: [],
+  allowSameOrigin: true,
+  enforceInDevelopment: process.env.VITEST === "true",
+});
+
+const loginPolicy = new LoginAbusePolicy();
+
 /**
 /**
  * Get locale from request (cookie or Accept-Language header)
  * Get locale from request (cookie or Accept-Language header)
  */
  */
@@ -84,9 +103,57 @@ function shouldIncludeFailureTaxonomy(request: NextRequest): boolean {
   return request.headers.has("x-forwarded-proto");
   return request.headers.has("x-forwarded-proto");
 }
 }
 
 
+function getClientIp(request: NextRequest): string {
+  return (
+    request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+    request.headers.get("x-real-ip")?.trim() ||
+    "unknown"
+  );
+}
+
+function buildKeyFingerprint(key: string): string {
+  return `sha256:${crypto.createHash("sha256").update(key, "utf8").digest("hex")}`;
+}
+
+async function createOpaqueSession(key: string, session: AuthSession) {
+  const store = new RedisSessionStore();
+  return store.create({
+    keyFingerprint: buildKeyFingerprint(key),
+    userId: session.user.id,
+    userRole: session.user.role,
+  });
+}
+
 export async function POST(request: NextRequest) {
 export async function POST(request: NextRequest) {
+  const csrfResult = csrfGuard.check(request);
+  if (!csrfResult.allowed) {
+    return withNoStoreHeaders(
+      NextResponse.json({ error: "Forbidden", errorCode: "CSRF_REJECTED" }, { status: 403 })
+    );
+  }
+
   const locale = getLocaleFromRequest(request);
   const locale = getLocaleFromRequest(request);
   const t = await getAuthErrorTranslations(locale);
   const t = await getAuthErrorTranslations(locale);
+  const clientIp = getClientIp(request);
+
+  const decision = loginPolicy.check(clientIp);
+  if (!decision.allowed) {
+    const response = withNoStoreHeaders(
+      NextResponse.json(
+        {
+          error: t?.("loginFailed") ?? t?.("serverError"),
+          errorCode: "RATE_LIMITED",
+        },
+        { status: 429 }
+      )
+    );
+
+    if (decision.retryAfterSeconds != null) {
+      response.headers.set("Retry-After", String(decision.retryAfterSeconds));
+    }
+
+    return response;
+  }
 
 
   try {
   try {
     const { key } = await request.json();
     const { key } = await request.json();
@@ -108,6 +175,8 @@ export async function POST(request: NextRequest) {
 
 
     const session = await validateKey(key, { allowReadOnlyAccess: true });
     const session = await validateKey(key, { allowReadOnlyAccess: true });
     if (!session) {
     if (!session) {
+      loginPolicy.recordFailure(clientIp);
+
       if (!shouldIncludeFailureTaxonomy(request)) {
       if (!shouldIncludeFailureTaxonomy(request)) {
         return withNoStoreHeaders(
         return withNoStoreHeaders(
           NextResponse.json({ error: t?.("apiKeyInvalidOrExpired") }, { status: 401 })
           NextResponse.json({ error: t?.("apiKeyInvalidOrExpired") }, { status: 401 })
@@ -134,8 +203,24 @@ export async function POST(request: NextRequest) {
       return withNoStoreHeaders(NextResponse.json(responseBody, { status: 401 }));
       return withNoStoreHeaders(NextResponse.json(responseBody, { status: 401 }));
     }
     }
 
 
-    // 设置认证 cookie
-    await setAuthCookie(key);
+    const mode = getSessionTokenMode();
+    if (mode === "legacy") {
+      await setAuthCookie(key);
+    } else if (mode === "dual") {
+      await setAuthCookie(key);
+      try {
+        await createOpaqueSession(key, session);
+      } catch (error) {
+        logger.warn("Failed to create opaque session in dual mode", {
+          error: error instanceof Error ? error.message : String(error),
+        });
+      }
+    } else {
+      const opaqueSession = await createOpaqueSession(key, session);
+      await setAuthCookie(opaqueSession.sessionId);
+    }
+
+    loginPolicy.recordSuccess(clientIp);
 
 
     const redirectTo = getLoginRedirectTarget(session);
     const redirectTo = getLoginRedirectTarget(session);
     const loginType =
     const loginType =

+ 52 - 3
src/app/api/auth/logout/route.ts

@@ -1,7 +1,56 @@
-import { NextResponse } from "next/server";
-import { clearAuthCookie, withNoStoreHeaders } from "@/lib/auth";
+import { type NextRequest, NextResponse } from "next/server";
+import {
+  clearAuthCookie,
+  getAuthCookie,
+  getSessionTokenMode,
+  type SessionTokenMode,
+  withNoStoreHeaders,
+} from "@/lib/auth";
+import { RedisSessionStore } from "@/lib/auth-session-store/redis-session-store";
+import { logger } from "@/lib/logger";
+import { createCsrfOriginGuard } from "@/lib/security/csrf-origin-guard";
+
+const csrfGuard = createCsrfOriginGuard({
+  allowedOrigins: [],
+  allowSameOrigin: true,
+  enforceInDevelopment: process.env.VITEST === "true",
+});
+
+function resolveSessionTokenMode(): SessionTokenMode {
+  const resolver = getSessionTokenMode as unknown as (() => SessionTokenMode) | undefined;
+  return resolver?.() ?? "legacy";
+}
+
+async function resolveAuthCookieToken(): Promise<string | undefined> {
+  const reader = getAuthCookie as unknown as (() => Promise<string | undefined>) | undefined;
+  if (!reader) return undefined;
+  return reader();
+}
+
+export async function POST(request: NextRequest) {
+  const csrfResult = csrfGuard.check(request);
+  if (!csrfResult.allowed) {
+    return withNoStoreHeaders(
+      NextResponse.json({ error: "Forbidden", errorCode: "CSRF_REJECTED" }, { status: 403 })
+    );
+  }
+
+  const mode = resolveSessionTokenMode();
+
+  if (mode !== "legacy") {
+    try {
+      const sessionId = await resolveAuthCookieToken();
+      if (sessionId) {
+        const store = new RedisSessionStore();
+        await store.revoke(sessionId);
+      }
+    } catch (error) {
+      logger.warn("[AuthLogout] Failed to revoke opaque session during logout", {
+        error: error instanceof Error ? error.message : String(error),
+      });
+    }
+  }
 
 
-export async function POST() {
   await clearAuthCookie();
   await clearAuthCookie();
   return withNoStoreHeaders(NextResponse.json({ ok: true }));
   return withNoStoreHeaders(NextResponse.json({ ok: true }));
 }
 }

+ 85 - 1
src/lib/auth.ts

@@ -2,7 +2,8 @@ import { cookies, headers } from "next/headers";
 import type { NextResponse } from "next/server";
 import type { NextResponse } from "next/server";
 import { config } from "@/lib/config/config";
 import { config } from "@/lib/config/config";
 import { getEnvConfig } from "@/lib/config/env.schema";
 import { getEnvConfig } from "@/lib/config/env.schema";
-import { validateApiKeyAndGetUser } from "@/repository/key";
+import { logger } from "@/lib/logger";
+import { findKeyList, validateApiKeyAndGetUser } from "@/repository/key";
 import type { Key } from "@/types/key";
 import type { Key } from "@/types/key";
 import type { User } from "@/types/user";
 import type { User } from "@/types/user";
 
 
@@ -284,6 +285,89 @@ export async function getSession(options?: {
   return validateKey(keyString, options);
   return validateKey(keyString, options);
 }
 }
 
 
+type SessionStoreReader = {
+  read(sessionId: string): Promise<OpaqueSessionContract | null>;
+};
+
+let sessionStorePromise: Promise<SessionStoreReader> | null = null;
+
+async function getSessionStore(): Promise<SessionStoreReader> {
+  if (!sessionStorePromise) {
+    sessionStorePromise = import("@/lib/auth-session-store/redis-session-store").then(
+      ({ RedisSessionStore }) => new RedisSessionStore()
+    );
+  }
+
+  return sessionStorePromise;
+}
+
+async function toKeyFingerprint(keyString: string): Promise<string> {
+  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(keyString));
+  const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
+    ""
+  );
+  return `sha256:${hex}`;
+}
+
+function normalizeKeyFingerprint(fingerprint: string): string {
+  return fingerprint.startsWith("sha256:") ? fingerprint : `sha256:${fingerprint}`;
+}
+
+async function convertToAuthSession(
+  sessionData: OpaqueSessionContract,
+  options?: { allowReadOnlyAccess?: boolean }
+): Promise<AuthSession | null> {
+  const keyList = await findKeyList(sessionData.userId);
+  const expectedFingerprint = normalizeKeyFingerprint(sessionData.keyFingerprint);
+
+  for (const key of keyList) {
+    const keyFingerprint = await toKeyFingerprint(key.key);
+    if (keyFingerprint === expectedFingerprint) {
+      return validateKey(key.key, options);
+    }
+  }
+
+  return null;
+}
+
+export async function getSessionWithDualRead(options?: {
+  allowReadOnlyAccess?: boolean;
+}): Promise<AuthSession | null> {
+  const mode = getSessionTokenMode();
+
+  if (mode === "opaque" || mode === "dual") {
+    const sessionId = await getAuthToken();
+    if (sessionId) {
+      try {
+        const sessionStore = await getSessionStore();
+        const sessionData = await sessionStore.read(sessionId);
+        if (sessionData) {
+          const session = await convertToAuthSession(sessionData, options);
+          if (session) {
+            return session;
+          }
+        }
+      } catch (error) {
+        logger.warn("Opaque session read failed", {
+          error: error instanceof Error ? error.message : String(error),
+        });
+      }
+    }
+  }
+
+  if (mode === "legacy" || mode === "dual") {
+    return getSession(options);
+  }
+
+  return null;
+}
+
+export async function validateSession(options?: {
+  allowReadOnlyAccess?: boolean;
+}): Promise<AuthSession | null> {
+  return getSessionWithDualRead(options);
+}
+
 function parseBearerToken(raw: string | null | undefined): string | undefined {
 function parseBearerToken(raw: string | null | undefined): string | undefined {
   const trimmed = raw?.trim();
   const trimmed = raw?.trim();
   if (!trimmed) return undefined;
   if (!trimmed) return undefined;

+ 160 - 0
tests/security/auth-bruteforce-integration.test.ts

@@ -0,0 +1,160 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+const mockValidateKey = vi.hoisted(() => vi.fn());
+const mockSetAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
+const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
+const mockGetTranslations = vi.hoisted(() => vi.fn());
+const mockLogger = vi.hoisted(() => ({
+  warn: vi.fn(),
+  error: vi.fn(),
+  info: vi.fn(),
+  debug: vi.fn(),
+}));
+
+vi.mock("@/lib/auth", () => ({
+  validateKey: mockValidateKey,
+  setAuthCookie: mockSetAuthCookie,
+  getLoginRedirectTarget: mockGetLoginRedirectTarget,
+  getSessionTokenMode: mockGetSessionTokenMode,
+  withNoStoreHeaders: <T>(res: T): T => {
+    (res as Response).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
+    (res as Response).headers.set("Pragma", "no-cache");
+    return res;
+  },
+}));
+
+vi.mock("next-intl/server", () => ({
+  getTranslations: mockGetTranslations,
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: mockLogger,
+}));
+
+function makeRequest(body: unknown, ip: string): NextRequest {
+  return new NextRequest("http://localhost/api/auth/login", {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      "x-forwarded-for": ip,
+      "x-forwarded-proto": "https",
+    },
+    body: JSON.stringify(body),
+  });
+}
+
+const fakeSession = {
+  user: {
+    id: 1,
+    name: "Test User",
+    description: "desc",
+    role: "user" as const,
+  },
+  key: { canLoginWebUi: true },
+};
+
+async function exhaustFailures(
+  POST: (request: NextRequest) => Promise<Response>,
+  ip: string,
+  count = 10
+) {
+  for (let i = 0; i < count; i++) {
+    const res = await POST(makeRequest({ key: `bad-${i}` }, ip));
+    expect(res.status).toBe(401);
+  }
+}
+
+describe("auth login anti-bruteforce integration", () => {
+  let POST: (request: NextRequest) => Promise<Response>;
+
+  beforeEach(async () => {
+    vi.resetModules();
+    vi.clearAllMocks();
+
+    const mockT = vi.fn((key: string) => `translated:${key}`);
+    mockGetTranslations.mockResolvedValue(mockT);
+    mockSetAuthCookie.mockResolvedValue(undefined);
+    mockGetLoginRedirectTarget.mockReturnValue("/dashboard");
+    mockGetSessionTokenMode.mockReturnValue("legacy");
+
+    const mod = await import("../../src/app/api/auth/login/route");
+    POST = mod.POST;
+  });
+
+  it("normal request passes rate-limit check", async () => {
+    mockValidateKey.mockResolvedValue(null);
+
+    const res = await POST(makeRequest({ key: "bad-key" }, "198.51.100.10"));
+
+    expect(res.status).toBe(401);
+    expect(res.headers.get("Retry-After")).toBeNull();
+    expect(mockValidateKey).toHaveBeenCalledWith("bad-key", { allowReadOnlyAccess: true });
+  });
+
+  it("returns 429 with Retry-After after max failures", async () => {
+    const ip = "198.51.100.20";
+    mockValidateKey.mockResolvedValue(null);
+
+    await exhaustFailures(POST, ip);
+
+    const blockedRes = await POST(makeRequest({ key: "blocked-now" }, ip));
+
+    expect(blockedRes.status).toBe(429);
+    expect(blockedRes.headers.get("Retry-After")).not.toBeNull();
+    expect(Number.parseInt(blockedRes.headers.get("Retry-After") ?? "0", 10)).toBeGreaterThan(0);
+    expect(mockValidateKey).toHaveBeenCalledTimes(10);
+  });
+
+  it("successful login resets failure counter", async () => {
+    const ip = "198.51.100.30";
+    mockValidateKey.mockImplementation(async (key: string) => {
+      return key === "valid-key" ? fakeSession : null;
+    });
+
+    for (let i = 0; i < 9; i++) {
+      const res = await POST(makeRequest({ key: `bad-before-success-${i}` }, ip));
+      expect(res.status).toBe(401);
+    }
+
+    const successRes = await POST(makeRequest({ key: "valid-key" }, ip));
+    expect(successRes.status).toBe(200);
+
+    const firstAfterSuccess = await POST(makeRequest({ key: "bad-after-success-1" }, ip));
+    const secondAfterSuccess = await POST(makeRequest({ key: "bad-after-success-2" }, ip));
+
+    expect(firstAfterSuccess.status).toBe(401);
+    expect(secondAfterSuccess.status).toBe(401);
+    expect(secondAfterSuccess.headers.get("Retry-After")).toBeNull();
+    expect(mockSetAuthCookie).toHaveBeenCalledWith("valid-key");
+  });
+
+  it("429 response includes errorCode RATE_LIMITED", async () => {
+    const ip = "198.51.100.40";
+    mockValidateKey.mockResolvedValue(null);
+
+    await exhaustFailures(POST, ip);
+
+    const blockedRes = await POST(makeRequest({ key: "blocked-key" }, ip));
+
+    expect(blockedRes.status).toBe(429);
+    await expect(blockedRes.json()).resolves.toMatchObject({
+      errorCode: "RATE_LIMITED",
+    });
+  });
+
+  it("tracks different IPs independently", async () => {
+    const blockedIp = "198.51.100.50";
+    const freshIp = "198.51.100.51";
+    mockValidateKey.mockResolvedValue(null);
+
+    await exhaustFailures(POST, blockedIp);
+
+    const blockedRes = await POST(makeRequest({ key: "blocked-key" }, blockedIp));
+    const freshRes = await POST(makeRequest({ key: "fresh-ip-key" }, freshIp));
+
+    expect(blockedRes.status).toBe(429);
+    expect(freshRes.status).toBe(401);
+  });
+});

+ 166 - 0
tests/security/auth-csrf-route-integration.test.ts

@@ -0,0 +1,166 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { NextRequest } from "next/server";
+
+const mockValidateKey = vi.hoisted(() => vi.fn());
+const mockSetAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
+const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
+const mockClearAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetTranslations = vi.hoisted(() => vi.fn());
+const mockGetEnvConfig = vi.hoisted(() => vi.fn());
+const mockLogger = vi.hoisted(() => ({
+  warn: vi.fn(),
+  error: vi.fn(),
+  info: vi.fn(),
+  debug: vi.fn(),
+}));
+
+vi.mock("@/lib/auth", () => ({
+  validateKey: mockValidateKey,
+  setAuthCookie: mockSetAuthCookie,
+  getSessionTokenMode: mockGetSessionTokenMode,
+  getLoginRedirectTarget: mockGetLoginRedirectTarget,
+  clearAuthCookie: mockClearAuthCookie,
+  getAuthCookie: mockGetAuthCookie,
+  withNoStoreHeaders: <T>(res: T): T => {
+    (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
+    (res as any).headers.set("Pragma", "no-cache");
+    return res;
+  },
+}));
+
+vi.mock("next-intl/server", () => ({
+  getTranslations: mockGetTranslations,
+}));
+
+vi.mock("@/lib/config/env.schema", () => ({
+  getEnvConfig: mockGetEnvConfig,
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: mockLogger,
+}));
+
+type LoginPostHandler = (request: NextRequest) => Promise<Response>;
+type LogoutPostHandler = (request: NextRequest) => Promise<Response>;
+
+function makeLoginRequest(headers: Record<string, string> = {}, key = "valid-key"): NextRequest {
+  const requestHeaders = new Headers({
+    "content-type": "application/json",
+    ...headers,
+  });
+
+  return {
+    headers: requestHeaders,
+    cookies: {
+      get: () => undefined,
+    },
+    json: async () => ({ key }),
+  } as unknown as NextRequest;
+}
+
+function makeLogoutRequest(headers: Record<string, string> = {}): NextRequest {
+  return {
+    headers: new Headers(headers),
+  } as unknown as NextRequest;
+}
+
+describe("auth route csrf guard integration", () => {
+  const originalNodeEnv = process.env.NODE_ENV;
+  let loginPost: LoginPostHandler;
+  let logoutPost: LogoutPostHandler;
+
+  afterEach(() => {
+    process.env.NODE_ENV = originalNodeEnv;
+  });
+
+  beforeEach(async () => {
+    vi.resetModules();
+    vi.clearAllMocks();
+    process.env.NODE_ENV = "test";
+
+    mockGetTranslations.mockResolvedValue(
+      vi.fn((messageKey: string) => `translated:${messageKey}`)
+    );
+    mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
+    mockValidateKey.mockResolvedValue({
+      user: {
+        id: 1,
+        name: "Test User",
+        description: "desc",
+        role: "user",
+      },
+      key: {
+        canLoginWebUi: true,
+      },
+    });
+    mockSetAuthCookie.mockResolvedValue(undefined);
+    mockGetLoginRedirectTarget.mockReturnValue("/dashboard");
+    mockClearAuthCookie.mockResolvedValue(undefined);
+    mockGetAuthCookie.mockResolvedValue(undefined);
+    mockGetSessionTokenMode.mockReturnValue("legacy");
+
+    const loginRoute = await import("../../src/app/api/auth/login/route");
+    loginPost = loginRoute.POST;
+
+    const logoutRoute = await import("../../src/app/api/auth/logout/route");
+    logoutPost = logoutRoute.POST;
+  });
+
+  it("allows same-origin login request to pass through", async () => {
+    const res = await loginPost(makeLoginRequest({ "sec-fetch-site": "same-origin" }));
+
+    expect(res.status).toBe(200);
+    expect(mockValidateKey).toHaveBeenCalledWith("valid-key", { allowReadOnlyAccess: true });
+  });
+
+  it("blocks cross-origin login request with csrf rejected error", async () => {
+    const request = makeLoginRequest({
+      "sec-fetch-site": "cross-site",
+      origin: "https://evil.example.com",
+    });
+
+    const res = await loginPost(request);
+
+    expect(res.status).toBe(403);
+    expect(await res.json()).toEqual({ error: "Forbidden", errorCode: "CSRF_REJECTED" });
+    expect(mockValidateKey).not.toHaveBeenCalled();
+  });
+
+  it("allows login request without origin header for non-browser clients", async () => {
+    const res = await loginPost(makeLoginRequest());
+
+    expect(res.status).toBe(200);
+    expect(mockValidateKey).toHaveBeenCalledTimes(1);
+  });
+
+  it("allows same-origin logout request to pass through", async () => {
+    const res = await logoutPost(makeLogoutRequest({ "sec-fetch-site": "same-origin" }));
+
+    expect(res.status).toBe(200);
+    expect(await res.json()).toEqual({ ok: true });
+    expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
+  });
+
+  it("blocks cross-origin logout request with csrf rejected error", async () => {
+    const request = makeLogoutRequest({
+      "sec-fetch-site": "cross-site",
+      origin: "https://evil.example.com",
+    });
+
+    const res = await logoutPost(request);
+
+    expect(res.status).toBe(403);
+    expect(await res.json()).toEqual({ error: "Forbidden", errorCode: "CSRF_REJECTED" });
+    expect(mockClearAuthCookie).not.toHaveBeenCalled();
+  });
+
+  it("allows logout request without origin header for non-browser clients", async () => {
+    const res = await logoutPost(makeLogoutRequest());
+
+    expect(res.status).toBe(200);
+    expect(await res.json()).toEqual({ ok: true });
+    expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
+  });
+});

+ 264 - 0
tests/security/auth-dual-read.test.ts

@@ -0,0 +1,264 @@
+import crypto from "node:crypto";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Key } from "@/types/key";
+import type { User } from "@/types/user";
+
+const mockCookies = vi.hoisted(() => vi.fn());
+const mockHeaders = vi.hoisted(() => vi.fn());
+const mockGetEnvConfig = vi.hoisted(() => vi.fn());
+const mockValidateApiKeyAndGetUser = vi.hoisted(() => vi.fn());
+const mockFindKeyList = vi.hoisted(() => vi.fn());
+const mockReadSession = vi.hoisted(() => vi.fn());
+const mockCookieStore = vi.hoisted(() => ({
+  get: vi.fn(),
+  set: vi.fn(),
+  delete: vi.fn(),
+}));
+const mockHeadersStore = vi.hoisted(() => ({
+  get: vi.fn(),
+}));
+const loggerMock = vi.hoisted(() => ({
+  warn: vi.fn(),
+  error: vi.fn(),
+  info: vi.fn(),
+  debug: vi.fn(),
+  trace: vi.fn(),
+}));
+
+vi.mock("next/headers", () => ({
+  cookies: mockCookies,
+  headers: mockHeaders,
+}));
+
+vi.mock("@/lib/config/env.schema", () => ({
+  getEnvConfig: mockGetEnvConfig,
+}));
+
+vi.mock("@/repository/key", () => ({
+  validateApiKeyAndGetUser: mockValidateApiKeyAndGetUser,
+  findKeyList: mockFindKeyList,
+}));
+
+vi.mock("@/lib/auth-session-store/redis-session-store", () => ({
+  RedisSessionStore: class {
+    read = mockReadSession;
+    create = vi.fn();
+    revoke = vi.fn();
+    rotate = vi.fn();
+  },
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: loggerMock,
+}));
+
+vi.mock("@/lib/config/config", () => ({
+  config: { auth: { adminToken: "" } },
+}));
+
+function setSessionMode(mode: "legacy" | "dual" | "opaque") {
+  mockGetEnvConfig.mockReturnValue({
+    SESSION_TOKEN_MODE: mode,
+    ENABLE_SECURE_COOKIES: false,
+  });
+}
+
+function setAuthToken(token?: string) {
+  mockCookieStore.get.mockReturnValue(token ? { value: token } : undefined);
+}
+
+function toFingerprint(keyString: string): string {
+  return `sha256:${crypto.createHash("sha256").update(keyString, "utf8").digest("hex")}`;
+}
+
+function buildUser(id: number): User {
+  const now = new Date("2026-02-18T10:00:00.000Z");
+  return {
+    id,
+    name: `user-${id}`,
+    description: "test user",
+    role: "user",
+    rpm: 100,
+    dailyQuota: 100,
+    providerGroup: null,
+    tags: [],
+    createdAt: now,
+    updatedAt: now,
+    limit5hUsd: 0,
+    limitWeeklyUsd: 0,
+    limitMonthlyUsd: 0,
+    limitTotalUsd: null,
+    limitConcurrentSessions: 0,
+    dailyResetMode: "fixed",
+    dailyResetTime: "00:00",
+    isEnabled: true,
+    expiresAt: null,
+    allowedClients: [],
+    allowedModels: [],
+  };
+}
+
+function buildKey(id: number, userId: number, keyString: string, canLoginWebUi = true): Key {
+  const now = new Date("2026-02-18T10:00:00.000Z");
+  return {
+    id,
+    userId,
+    name: `key-${id}`,
+    key: keyString,
+    isEnabled: true,
+    canLoginWebUi,
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed",
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    limitConcurrentSessions: 0,
+    providerGroup: null,
+    cacheTtlPreference: null,
+    createdAt: now,
+    updatedAt: now,
+  };
+}
+
+function buildAuthResult(keyString: string, userId = 1) {
+  return {
+    user: buildUser(userId),
+    key: buildKey(userId, userId, keyString),
+  };
+}
+
+describe("auth dual-read session resolver", () => {
+  beforeEach(() => {
+    vi.resetModules();
+    vi.clearAllMocks();
+
+    mockCookies.mockResolvedValue(mockCookieStore);
+    mockHeaders.mockResolvedValue(mockHeadersStore);
+    mockHeadersStore.get.mockReturnValue(null);
+    mockCookieStore.get.mockReturnValue(undefined);
+
+    setSessionMode("legacy");
+    mockReadSession.mockResolvedValue(null);
+    mockFindKeyList.mockResolvedValue([]);
+    mockValidateApiKeyAndGetUser.mockResolvedValue(null);
+  });
+
+  it("legacy mode keeps legacy key validation path unchanged", async () => {
+    setSessionMode("legacy");
+    setAuthToken("sk-legacy");
+    const authResult = buildAuthResult("sk-legacy", 11);
+    mockValidateApiKeyAndGetUser.mockResolvedValue(authResult);
+
+    const { getSessionWithDualRead } = await import("@/lib/auth");
+    const session = await getSessionWithDualRead();
+
+    expect(session).toEqual(authResult);
+    expect(mockReadSession).not.toHaveBeenCalled();
+    expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1);
+    expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith("sk-legacy");
+  });
+
+  it("dual mode tries opaque read first and then falls back to legacy cookie", async () => {
+    setSessionMode("dual");
+    setAuthToken("sk-dual");
+    const authResult = buildAuthResult("sk-dual", 12);
+    mockReadSession.mockResolvedValue(null);
+    mockValidateApiKeyAndGetUser.mockResolvedValue(authResult);
+
+    const { getSessionWithDualRead } = await import("@/lib/auth");
+    const session = await getSessionWithDualRead();
+
+    expect(session).toEqual(authResult);
+    expect(mockReadSession).toHaveBeenCalledTimes(1);
+    expect(mockReadSession).toHaveBeenCalledWith("sk-dual");
+    expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith("sk-dual");
+    expect(mockReadSession.mock.invocationCallOrder[0]).toBeLessThan(
+      mockValidateApiKeyAndGetUser.mock.invocationCallOrder[0]
+    );
+  });
+
+  it("opaque mode only reads opaque session and never falls back to legacy", async () => {
+    setSessionMode("opaque");
+    setAuthToken("sk-legacy-in-opaque");
+    mockReadSession.mockResolvedValue(null);
+    mockValidateApiKeyAndGetUser.mockResolvedValue(buildAuthResult("sk-legacy-in-opaque", 13));
+
+    const { getSessionWithDualRead } = await import("@/lib/auth");
+    const session = await getSessionWithDualRead();
+
+    expect(session).toBeNull();
+    expect(mockReadSession).toHaveBeenCalledTimes(1);
+    expect(mockReadSession).toHaveBeenCalledWith("sk-legacy-in-opaque");
+    expect(mockValidateApiKeyAndGetUser).not.toHaveBeenCalled();
+  });
+
+  it("returns a valid auth session when opaque session is found", async () => {
+    setSessionMode("dual");
+    setAuthToken("sid_opaque_found");
+
+    const keyString = "sk-opaque-source";
+    const authResult = buildAuthResult(keyString, 21);
+    mockReadSession.mockResolvedValue({
+      sessionId: "sid_opaque_found",
+      keyFingerprint: toFingerprint(keyString),
+      userId: 21,
+      userRole: "user",
+      createdAt: 1_700_000_000,
+      expiresAt: 1_700_000_600,
+    });
+    mockFindKeyList.mockResolvedValue([
+      buildKey(1, 21, "sk-not-match"),
+      buildKey(2, 21, keyString),
+    ]);
+    mockValidateApiKeyAndGetUser.mockResolvedValue(authResult);
+
+    const { getSessionWithDualRead } = await import("@/lib/auth");
+    const session = await getSessionWithDualRead({ allowReadOnlyAccess: true });
+
+    expect(session).toEqual(authResult);
+    expect(mockReadSession).toHaveBeenCalledWith("sid_opaque_found");
+    expect(mockFindKeyList).toHaveBeenCalledWith(21);
+    expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1);
+    expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith(keyString);
+  });
+
+  it("validateSession falls back to legacy path when opaque session is missing in dual mode", async () => {
+    setSessionMode("dual");
+    setAuthToken("sk-dual-fallback");
+    const authResult = buildAuthResult("sk-dual-fallback", 22);
+    mockReadSession.mockResolvedValue(null);
+    mockValidateApiKeyAndGetUser.mockResolvedValue(authResult);
+
+    const { validateSession } = await import("@/lib/auth");
+    const session = await validateSession();
+
+    expect(session).toEqual(authResult);
+    expect(mockReadSession).toHaveBeenCalledTimes(1);
+    expect(mockReadSession).toHaveBeenCalledWith("sk-dual-fallback");
+    expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1);
+    expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith("sk-dual-fallback");
+  });
+
+  it("dual mode gracefully falls back to legacy when opaque session store read fails", async () => {
+    setSessionMode("dual");
+    setAuthToken("sk-store-error");
+    const authResult = buildAuthResult("sk-store-error", 23);
+    mockReadSession.mockRejectedValue(new Error("redis unavailable"));
+    mockValidateApiKeyAndGetUser.mockResolvedValue(authResult);
+
+    const { getSessionWithDualRead } = await import("@/lib/auth");
+    const session = await getSessionWithDualRead();
+
+    expect(session).toEqual(authResult);
+    expect(mockReadSession).toHaveBeenCalledTimes(1);
+    expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1);
+    expect(loggerMock.warn).toHaveBeenCalledWith(
+      "Opaque session read failed",
+      expect.objectContaining({
+        error: expect.stringContaining("redis unavailable"),
+      })
+    );
+  });
+});

+ 14 - 5
tests/security/session-cookie-hardening.test.ts

@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
 
 
 const mockValidateKey = vi.hoisted(() => vi.fn());
 const mockValidateKey = vi.hoisted(() => vi.fn());
 const mockSetAuthCookie = vi.hoisted(() => vi.fn());
 const mockSetAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
 const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
 const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
 const mockGetTranslations = vi.hoisted(() => vi.fn());
 const mockGetTranslations = vi.hoisted(() => vi.fn());
 const mockLogger = vi.hoisted(() => ({
 const mockLogger = vi.hoisted(() => ({
@@ -27,6 +28,7 @@ const realWithNoStoreHeaders = vi.hoisted(() => {
 vi.mock("@/lib/auth", () => ({
 vi.mock("@/lib/auth", () => ({
   validateKey: mockValidateKey,
   validateKey: mockValidateKey,
   setAuthCookie: mockSetAuthCookie,
   setAuthCookie: mockSetAuthCookie,
+  getSessionTokenMode: mockGetSessionTokenMode,
   clearAuthCookie: mockClearAuthCookie,
   clearAuthCookie: mockClearAuthCookie,
   getLoginRedirectTarget: mockGetLoginRedirectTarget,
   getLoginRedirectTarget: mockGetLoginRedirectTarget,
   withNoStoreHeaders: realWithNoStoreHeaders,
   withNoStoreHeaders: realWithNoStoreHeaders,
@@ -63,6 +65,12 @@ function makeLoginRequest(body: unknown): NextRequest {
   });
   });
 }
 }
 
 
+function makeLogoutRequest(): NextRequest {
+  return new NextRequest("http://localhost/api/auth/logout", {
+    method: "POST",
+  });
+}
+
 const fakeSession = {
 const fakeSession = {
   user: { id: 1, name: "Test User", description: "desc", role: "user" as const },
   user: { id: 1, name: "Test User", description: "desc", role: "user" as const },
   key: { canLoginWebUi: true },
   key: { canLoginWebUi: true },
@@ -97,9 +105,10 @@ describe("session cookie hardening", () => {
       const mockT = vi.fn((key: string) => `translated:${key}`);
       const mockT = vi.fn((key: string) => `translated:${key}`);
       mockGetTranslations.mockResolvedValue(mockT);
       mockGetTranslations.mockResolvedValue(mockT);
       mockSetAuthCookie.mockResolvedValue(undefined);
       mockSetAuthCookie.mockResolvedValue(undefined);
+      mockGetSessionTokenMode.mockReturnValue("legacy");
       mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
       mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
 
 
-      const mod = await import("@/app/api/auth/login/route");
+      const mod = await import("../../src/app/api/auth/login/route");
       POST = mod.POST;
       POST = mod.POST;
     });
     });
 
 
@@ -164,25 +173,25 @@ describe("session cookie hardening", () => {
   });
   });
 
 
   describe("logout route no-store headers", () => {
   describe("logout route no-store headers", () => {
-    let POST: () => Promise<Response>;
+    let POST: (request: NextRequest) => Promise<Response>;
 
 
     beforeEach(async () => {
     beforeEach(async () => {
       vi.clearAllMocks();
       vi.clearAllMocks();
       mockClearAuthCookie.mockResolvedValue(undefined);
       mockClearAuthCookie.mockResolvedValue(undefined);
 
 
-      const mod = await import("@/app/api/auth/logout/route");
+      const mod = await import("../../src/app/api/auth/logout/route");
       POST = mod.POST;
       POST = mod.POST;
     });
     });
 
 
     it("response includes Cache-Control: no-store", async () => {
     it("response includes Cache-Control: no-store", async () => {
-      const res = await POST();
+      const res = await POST(makeLogoutRequest());
 
 
       expect(res.status).toBe(200);
       expect(res.status).toBe(200);
       expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
       expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
     });
     });
 
 
     it("response includes Pragma: no-cache", async () => {
     it("response includes Pragma: no-cache", async () => {
-      const res = await POST();
+      const res = await POST(makeLogoutRequest());
 
 
       expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA);
       expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA);
     });
     });

+ 169 - 0
tests/security/session-fixation-rotation.test.ts

@@ -0,0 +1,169 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+import type { NextResponse } from "next/server";
+
+const {
+  mockClearAuthCookie,
+  mockGetAuthCookie,
+  mockGetSessionTokenMode,
+  mockRevoke,
+  mockRotate,
+  mockRedisSessionStoreCtor,
+  mockLogger,
+} = vi.hoisted(() => {
+  const mockRevoke = vi.fn();
+  const mockRotate = vi.fn();
+
+  return {
+    mockClearAuthCookie: vi.fn(),
+    mockGetAuthCookie: vi.fn(),
+    mockGetSessionTokenMode: vi.fn(),
+    mockRevoke,
+    mockRotate,
+    mockRedisSessionStoreCtor: vi.fn().mockImplementation(function RedisSessionStoreMock() {
+      return {
+        revoke: mockRevoke,
+        rotate: mockRotate,
+      };
+    }),
+    mockLogger: {
+      warn: vi.fn(),
+      error: vi.fn(),
+      info: vi.fn(),
+      debug: vi.fn(),
+      trace: vi.fn(),
+    },
+  };
+});
+
+const realWithNoStoreHeaders = vi.hoisted(() => {
+  return <T extends InstanceType<typeof NextResponse>>(response: T): T => {
+    response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
+    response.headers.set("Pragma", "no-cache");
+    return response;
+  };
+});
+
+vi.mock("@/lib/auth", () => ({
+  clearAuthCookie: mockClearAuthCookie,
+  getAuthCookie: mockGetAuthCookie,
+  getSessionTokenMode: mockGetSessionTokenMode,
+  withNoStoreHeaders: realWithNoStoreHeaders,
+}));
+
+vi.mock("@/lib/auth-session-store/redis-session-store", () => ({
+  RedisSessionStore: mockRedisSessionStoreCtor,
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: mockLogger,
+}));
+
+function makeLogoutRequest(): NextRequest {
+  return new NextRequest("http://localhost/api/auth/logout", {
+    method: "POST",
+    headers: {
+      "sec-fetch-site": "same-origin",
+    },
+  });
+}
+
+async function loadLogoutPost(): Promise<(request: NextRequest) => Promise<Response>> {
+  const mod = await import("../../src/app/api/auth/logout/route");
+  return mod.POST;
+}
+
+async function simulatePostLoginSessionRotation(
+  oldSessionId: string,
+  rotate: (sessionId: string) => Promise<{ sessionId: string } | null>
+): Promise<string | null> {
+  const rotated = await rotate(oldSessionId);
+  return rotated?.sessionId ?? null;
+}
+
+describe("session fixation rotation and logout revocation", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockRedisSessionStoreCtor.mockImplementation(function RedisSessionStoreMock() {
+      return {
+        revoke: mockRevoke,
+        rotate: mockRotate,
+      };
+    });
+    mockClearAuthCookie.mockResolvedValue(undefined);
+    mockGetAuthCookie.mockResolvedValue(undefined);
+    mockGetSessionTokenMode.mockReturnValue("legacy");
+    mockRevoke.mockResolvedValue(true);
+    mockRotate.mockResolvedValue(null);
+  });
+
+  it("legacy mode logout only clears cookie without session store revocation", async () => {
+    mockGetSessionTokenMode.mockReturnValue("legacy");
+    const POST = await loadLogoutPost();
+
+    const response = await POST(makeLogoutRequest());
+
+    expect(response.status).toBe(200);
+    expect(mockRedisSessionStoreCtor).not.toHaveBeenCalled();
+    expect(mockRevoke).not.toHaveBeenCalled();
+    expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
+  });
+
+  it("dual mode logout revokes session and clears cookie", async () => {
+    mockGetSessionTokenMode.mockReturnValue("dual");
+    mockGetAuthCookie.mockResolvedValue("sid_dual_session");
+    const POST = await loadLogoutPost();
+
+    const response = await POST(makeLogoutRequest());
+
+    expect(response.status).toBe(200);
+    expect(mockRedisSessionStoreCtor).toHaveBeenCalledTimes(1);
+    expect(mockRevoke).toHaveBeenCalledWith("sid_dual_session");
+    expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
+  });
+
+  it("opaque mode logout revokes session and clears cookie", async () => {
+    mockGetSessionTokenMode.mockReturnValue("opaque");
+    mockGetAuthCookie.mockResolvedValue("sid_opaque_session");
+    const POST = await loadLogoutPost();
+
+    const response = await POST(makeLogoutRequest());
+
+    expect(response.status).toBe(200);
+    expect(mockRedisSessionStoreCtor).toHaveBeenCalledTimes(1);
+    expect(mockRevoke).toHaveBeenCalledWith("sid_opaque_session");
+    expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
+  });
+
+  it("logout still clears cookie when session revocation fails", async () => {
+    mockGetSessionTokenMode.mockReturnValue("opaque");
+    mockGetAuthCookie.mockResolvedValue("sid_revocation_failure");
+    mockRevoke.mockRejectedValue(new Error("redis down"));
+    const POST = await loadLogoutPost();
+
+    const response = await POST(makeLogoutRequest());
+
+    expect(response.status).toBe(200);
+    expect(mockRevoke).toHaveBeenCalledWith("sid_revocation_failure");
+    expect(mockClearAuthCookie).toHaveBeenCalledTimes(1);
+    expect(mockLogger.warn).toHaveBeenCalledTimes(1);
+  });
+
+  it("post-login rotation returns a different session id", async () => {
+    const oldSessionId = "sid_existing_session";
+    mockRotate.mockResolvedValue({
+      sessionId: "sid_rotated_session",
+      keyFingerprint: "fp-login",
+      userId: 7,
+      userRole: "user",
+      createdAt: 1_700_000_000_000,
+      expiresAt: 1_700_000_300_000,
+    });
+
+    const rotatedSessionId = await simulatePostLoginSessionRotation(oldSessionId, mockRotate);
+
+    expect(mockRotate).toHaveBeenCalledWith(oldSessionId);
+    expect(rotatedSessionId).toBe("sid_rotated_session");
+    expect(rotatedSessionId).not.toBe(oldSessionId);
+  });
+});

+ 222 - 0
tests/security/session-login-integration.test.ts

@@ -0,0 +1,222 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+const mockValidateKey = vi.hoisted(() => vi.fn());
+const mockSetAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
+const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
+const mockGetTranslations = vi.hoisted(() => vi.fn());
+const mockCreateSession = vi.hoisted(() => vi.fn());
+const mockLogger = vi.hoisted(() => ({
+  warn: vi.fn(),
+  error: vi.fn(),
+  info: vi.fn(),
+  debug: vi.fn(),
+}));
+
+const realWithNoStoreHeaders = vi.hoisted(() => {
+  return (response: any) => {
+    response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
+    response.headers.set("Pragma", "no-cache");
+    return response;
+  };
+});
+
+vi.mock("@/lib/auth", () => ({
+  validateKey: mockValidateKey,
+  setAuthCookie: mockSetAuthCookie,
+  getSessionTokenMode: mockGetSessionTokenMode,
+  getLoginRedirectTarget: mockGetLoginRedirectTarget,
+  withNoStoreHeaders: realWithNoStoreHeaders,
+}));
+
+vi.mock("@/lib/auth-session-store/redis-session-store", () => ({
+  RedisSessionStore: class {
+    create = mockCreateSession;
+  },
+}));
+
+vi.mock("next-intl/server", () => ({
+  getTranslations: mockGetTranslations,
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: mockLogger,
+}));
+
+function makeRequest(body: unknown): NextRequest {
+  return new NextRequest("http://localhost/api/auth/login", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(body),
+  });
+}
+
+const dashboardSession = {
+  user: {
+    id: 1,
+    name: "Test User",
+    description: "desc",
+    role: "user" as const,
+  },
+  key: { canLoginWebUi: true },
+};
+
+const readonlySession = {
+  user: {
+    id: 2,
+    name: "Readonly User",
+    description: "readonly",
+    role: "user" as const,
+  },
+  key: { canLoginWebUi: false },
+};
+
+describe("POST /api/auth/login session token mode integration", () => {
+  let POST: (request: NextRequest) => Promise<Response>;
+
+  beforeEach(async () => {
+    vi.clearAllMocks();
+    const mockT = vi.fn((key: string) => `translated:${key}`);
+    mockGetTranslations.mockResolvedValue(mockT);
+
+    mockValidateKey.mockResolvedValue(dashboardSession);
+    mockSetAuthCookie.mockResolvedValue(undefined);
+    mockGetSessionTokenMode.mockReturnValue("legacy");
+    mockGetLoginRedirectTarget.mockReturnValue("/dashboard");
+    mockCreateSession.mockResolvedValue({
+      sessionId: "sid_opaque_session_123",
+      keyFingerprint: "sha256:abcdef",
+      userId: 1,
+      userRole: "user",
+      createdAt: 100,
+      expiresAt: 200,
+    });
+
+    const mod = await import("../../src/app/api/auth/login/route");
+    POST = mod.POST;
+  });
+
+  it("legacy mode keeps raw key cookie and does not create opaque session", async () => {
+    mockGetSessionTokenMode.mockReturnValue("legacy");
+
+    const res = await POST(makeRequest({ key: "legacy-key" }));
+    const json = await res.json();
+
+    expect(res.status).toBe(200);
+    expect(mockSetAuthCookie).toHaveBeenCalledTimes(1);
+    expect(mockSetAuthCookie).toHaveBeenCalledWith("legacy-key");
+    expect(mockCreateSession).not.toHaveBeenCalled();
+    expect(json.redirectTo).toBe("/dashboard");
+    expect(json.loginType).toBe("dashboard_user");
+  });
+
+  it("dual mode sets legacy cookie and creates opaque session in store", async () => {
+    mockGetSessionTokenMode.mockReturnValue("dual");
+
+    const res = await POST(makeRequest({ key: "dual-key" }));
+    const json = await res.json();
+
+    expect(res.status).toBe(200);
+    expect(mockSetAuthCookie).toHaveBeenCalledTimes(1);
+    expect(mockSetAuthCookie).toHaveBeenCalledWith("dual-key");
+    expect(mockCreateSession).toHaveBeenCalledTimes(1);
+    expect(mockCreateSession).toHaveBeenCalledWith(
+      expect.objectContaining({
+        userId: 1,
+        userRole: "user",
+        keyFingerprint: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
+      })
+    );
+    expect(json.redirectTo).toBe("/dashboard");
+    expect(json.loginType).toBe("dashboard_user");
+  });
+
+  it("opaque mode writes sessionId cookie instead of raw key", async () => {
+    mockGetSessionTokenMode.mockReturnValue("opaque");
+    mockCreateSession.mockResolvedValue({
+      sessionId: "sid_opaque_session_cookie",
+      keyFingerprint: "sha256:abcdef",
+      userId: 1,
+      userRole: "user",
+      createdAt: 100,
+      expiresAt: 200,
+    });
+
+    const res = await POST(makeRequest({ key: "opaque-key" }));
+    const json = await res.json();
+
+    expect(res.status).toBe(200);
+    expect(mockCreateSession).toHaveBeenCalledTimes(1);
+    expect(mockSetAuthCookie).toHaveBeenCalledTimes(1);
+    expect(mockSetAuthCookie).toHaveBeenCalledWith("sid_opaque_session_cookie");
+    expect(mockSetAuthCookie).not.toHaveBeenCalledWith("opaque-key");
+    expect(json.redirectTo).toBe("/dashboard");
+    expect(json.loginType).toBe("dashboard_user");
+  });
+
+  it("dual mode remains successful when opaque session creation fails", async () => {
+    mockGetSessionTokenMode.mockReturnValue("dual");
+    mockCreateSession.mockRejectedValue(new Error("redis unavailable"));
+
+    const res = await POST(makeRequest({ key: "dual-fallback-key" }));
+    const json = await res.json();
+
+    expect(res.status).toBe(200);
+    expect(json.ok).toBe(true);
+    expect(mockSetAuthCookie).toHaveBeenCalledTimes(1);
+    expect(mockSetAuthCookie).toHaveBeenCalledWith("dual-fallback-key");
+    expect(mockCreateSession).toHaveBeenCalledTimes(1);
+    expect(mockLogger.warn).toHaveBeenCalledWith(
+      "Failed to create opaque session in dual mode",
+      expect.objectContaining({
+        error: expect.stringContaining("redis unavailable"),
+      })
+    );
+  });
+
+  it("all modes preserve readonly redirect semantics", async () => {
+    mockValidateKey.mockResolvedValue(readonlySession);
+    mockGetLoginRedirectTarget.mockReturnValue("/my-usage");
+
+    const modes = ["legacy", "dual", "opaque"] as const;
+
+    for (const mode of modes) {
+      vi.clearAllMocks();
+      mockGetSessionTokenMode.mockReturnValue(mode);
+      mockValidateKey.mockResolvedValue(readonlySession);
+      mockGetLoginRedirectTarget.mockReturnValue("/my-usage");
+      mockSetAuthCookie.mockResolvedValue(undefined);
+      mockCreateSession.mockResolvedValue({
+        sessionId: `sid_${mode}_session`,
+        keyFingerprint: "sha256:abcdef",
+        userId: 2,
+        userRole: "user",
+        createdAt: 100,
+        expiresAt: 200,
+      });
+
+      const res = await POST(makeRequest({ key: `${mode}-readonly-key` }));
+      const json = await res.json();
+
+      expect(res.status).toBe(200);
+      expect(json.redirectTo).toBe("/my-usage");
+      expect(json.loginType).toBe("readonly_user");
+
+      if (mode === "legacy") {
+        expect(mockCreateSession).not.toHaveBeenCalled();
+        expect(mockSetAuthCookie).toHaveBeenCalledWith("legacy-readonly-key");
+      }
+
+      if (mode === "dual") {
+        expect(mockCreateSession).toHaveBeenCalledTimes(1);
+        expect(mockSetAuthCookie).toHaveBeenCalledWith("dual-readonly-key");
+      }
+
+      if (mode === "opaque") {
+        expect(mockCreateSession).toHaveBeenCalledTimes(1);
+        expect(mockSetAuthCookie).toHaveBeenCalledWith("sid_opaque_session");
+      }
+    }
+  });
+});

+ 4 - 1
tests/unit/api/auth-login-failure-taxonomy.test.ts

@@ -3,6 +3,7 @@ import { NextRequest } from "next/server";
 
 
 const mockValidateKey = vi.hoisted(() => vi.fn());
 const mockValidateKey = vi.hoisted(() => vi.fn());
 const mockSetAuthCookie = vi.hoisted(() => vi.fn());
 const mockSetAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
 const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
 const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
 const mockGetTranslations = vi.hoisted(() => vi.fn());
 const mockGetTranslations = vi.hoisted(() => vi.fn());
 const mockGetEnvConfig = vi.hoisted(() => vi.fn());
 const mockGetEnvConfig = vi.hoisted(() => vi.fn());
@@ -16,6 +17,7 @@ const mockLogger = vi.hoisted(() => ({
 vi.mock("@/lib/auth", () => ({
 vi.mock("@/lib/auth", () => ({
   validateKey: mockValidateKey,
   validateKey: mockValidateKey,
   setAuthCookie: mockSetAuthCookie,
   setAuthCookie: mockSetAuthCookie,
+  getSessionTokenMode: mockGetSessionTokenMode,
   getLoginRedirectTarget: mockGetLoginRedirectTarget,
   getLoginRedirectTarget: mockGetLoginRedirectTarget,
   withNoStoreHeaders: <T>(res: T): T => {
   withNoStoreHeaders: <T>(res: T): T => {
     (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
     (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
@@ -68,9 +70,10 @@ describe("POST /api/auth/login failure taxonomy", () => {
     const mockT = vi.fn((key: string) => `translated:${key}`);
     const mockT = vi.fn((key: string) => `translated:${key}`);
     mockGetTranslations.mockResolvedValue(mockT);
     mockGetTranslations.mockResolvedValue(mockT);
     mockSetAuthCookie.mockResolvedValue(undefined);
     mockSetAuthCookie.mockResolvedValue(undefined);
+    mockGetSessionTokenMode.mockReturnValue("legacy");
     mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
     mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
 
 
-    const mod = await import("@/app/api/auth/login/route");
+    const mod = await import("../../../src/app/api/auth/login/route");
     POST = mod.POST;
     POST = mod.POST;
   });
   });
 
 

+ 4 - 1
tests/unit/api/auth-login-route.test.ts

@@ -3,6 +3,7 @@ import { NextRequest } from "next/server";
 
 
 const mockValidateKey = vi.hoisted(() => vi.fn());
 const mockValidateKey = vi.hoisted(() => vi.fn());
 const mockSetAuthCookie = vi.hoisted(() => vi.fn());
 const mockSetAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
 const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
 const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
 const mockGetTranslations = vi.hoisted(() => vi.fn());
 const mockGetTranslations = vi.hoisted(() => vi.fn());
 const mockLogger = vi.hoisted(() => ({
 const mockLogger = vi.hoisted(() => ({
@@ -15,6 +16,7 @@ const mockLogger = vi.hoisted(() => ({
 vi.mock("@/lib/auth", () => ({
 vi.mock("@/lib/auth", () => ({
   validateKey: mockValidateKey,
   validateKey: mockValidateKey,
   setAuthCookie: mockSetAuthCookie,
   setAuthCookie: mockSetAuthCookie,
+  getSessionTokenMode: mockGetSessionTokenMode,
   getLoginRedirectTarget: mockGetLoginRedirectTarget,
   getLoginRedirectTarget: mockGetLoginRedirectTarget,
   withNoStoreHeaders: <T>(res: T): T => {
   withNoStoreHeaders: <T>(res: T): T => {
     (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
     (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
@@ -91,8 +93,9 @@ describe("POST /api/auth/login", () => {
     const mockT = vi.fn((key: string) => `translated:${key}`);
     const mockT = vi.fn((key: string) => `translated:${key}`);
     mockGetTranslations.mockResolvedValue(mockT);
     mockGetTranslations.mockResolvedValue(mockT);
     mockSetAuthCookie.mockResolvedValue(undefined);
     mockSetAuthCookie.mockResolvedValue(undefined);
+    mockGetSessionTokenMode.mockReturnValue("legacy");
 
 
-    const mod = await import("@/app/api/auth/login/route");
+    const mod = await import("../../../src/app/api/auth/login/route");
     POST = mod.POST;
     POST = mod.POST;
   });
   });
 
 

+ 8 - 0
tests/unit/login/login-regression-matrix.test.tsx

@@ -3,6 +3,7 @@ import { NextRequest } from "next/server";
 
 
 const mockValidateKey = vi.hoisted(() => vi.fn());
 const mockValidateKey = vi.hoisted(() => vi.fn());
 const mockSetAuthCookie = vi.hoisted(() => vi.fn());
 const mockSetAuthCookie = vi.hoisted(() => vi.fn());
+const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
 const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
 const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
 const mockGetTranslations = vi.hoisted(() => vi.fn());
 const mockGetTranslations = vi.hoisted(() => vi.fn());
 const mockGetEnvConfig = vi.hoisted(() => vi.fn());
 const mockGetEnvConfig = vi.hoisted(() => vi.fn());
@@ -16,7 +17,13 @@ const mockLogger = vi.hoisted(() => ({
 vi.mock("@/lib/auth", () => ({
 vi.mock("@/lib/auth", () => ({
   validateKey: mockValidateKey,
   validateKey: mockValidateKey,
   setAuthCookie: mockSetAuthCookie,
   setAuthCookie: mockSetAuthCookie,
+  getSessionTokenMode: mockGetSessionTokenMode,
   getLoginRedirectTarget: mockGetLoginRedirectTarget,
   getLoginRedirectTarget: mockGetLoginRedirectTarget,
+  withNoStoreHeaders: (res: any) => {
+    (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
+    (res as any).headers.set("Pragma", "no-cache");
+    return res;
+  },
 }));
 }));
 
 
 vi.mock("next-intl/server", () => ({
 vi.mock("next-intl/server", () => ({
@@ -82,6 +89,7 @@ describe("Login Regression Matrix", () => {
     mockGetTranslations.mockResolvedValue(mockT);
     mockGetTranslations.mockResolvedValue(mockT);
     mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
     mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
     mockSetAuthCookie.mockResolvedValue(undefined);
     mockSetAuthCookie.mockResolvedValue(undefined);
+    mockGetSessionTokenMode.mockReturnValue("legacy");
 
 
     const mod = await import("../../../src/app/api/auth/login/route");
     const mod = await import("../../../src/app/api/auth/login/route");
     POST = mod.POST;
     POST = mod.POST;