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

feat(auth): switch session token mode to opaque by default

Changes session authentication from legacy API key cookies to opaque session identifiers with Redis backing. The opaque mode prevents API key exposure in cookies and enables proper session lifecycle management.

Key changes:
- Default SESSION_TOKEN_MODE changed from "legacy" to "opaque"
- Session IDs now prefixed with "sid_" for clear identification
- Unified authentication through validateAuthToken() wrapper
- Redis session creation now throws on failure instead of silent fallback
- Session rotation preserves new session even if old revocation fails
- Extracted auth response headers to dedicated module
- Fixed redirect sanitization bypass in login flow
- Updated all tests to reflect opaque-first behavior

BREAKING CHANGE: SESSION_TOKEN_MODE now defaults to "opaque". Existing deployments using legacy mode must explicitly set SESSION_TOKEN_MODE=legacy in environment variables to maintain current behavior. The "dual" mode remains available for gradual migration.
ding113 3 дней назад
Родитель
Сommit
bea43b44cc

+ 0 - 5
.env.example

@@ -69,11 +69,6 @@ API_KEY_AUTH_CACHE_TTL_SECONDS="60"      # 鉴权缓存 TTL(秒,默认 60,
 ENABLE_API_KEY_REDIS_CACHE="true"        # 是否启用 API Key Redis 缓存(默认:true)
 
 # Session 配置
-# Session Token 迁移模式(auth-token Cookie 载荷)
-# - legacy (默认):仅接受 legacy(Cookie 值为原始 API Key),用于紧急回滚
-# - dual:迁移窗口,同时接受 legacy 与 opaque(Cookie 值为 sid_ 前缀 sessionId)
-# - opaque:硬切换,仅接受 opaque(Cookie 不再承载 API Key)
-SESSION_TOKEN_MODE=legacy
 SESSION_TTL=300                         # Session 过期时间(秒,默认 300 = 5 分钟)
 STORE_SESSION_MESSAGES=false            # 会话消息存储模式(默认:false)
                                         # - false:存储请求/响应体但对 message 内容脱敏 [REDACTED]

+ 1 - 1
src/app/[locale]/login/redirect-safety.ts

@@ -30,7 +30,7 @@ export function sanitizeRedirectPath(from: string): string {
 
 export function resolveLoginRedirectTarget(redirectTo: unknown, from: string): string {
   if (typeof redirectTo === "string" && redirectTo.trim().length > 0) {
-    return redirectTo;
+    return sanitizeRedirectPath(redirectTo);
   }
 
   return sanitizeRedirectPath(from);

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

@@ -1,4 +1,3 @@
-import crypto from "node:crypto";
 import { type NextRequest, NextResponse } from "next/server";
 import { getTranslations } from "next-intl/server";
 import { defaultLocale, type Locale, locales } from "@/i18n/config";
@@ -7,15 +6,15 @@ import {
   getLoginRedirectTarget,
   getSessionTokenMode,
   setAuthCookie,
+  toKeyFingerprint,
   validateKey,
-  withNoStoreHeaders,
 } from "@/lib/auth";
 import { RedisSessionStore } from "@/lib/auth-session-store/redis-session-store";
 import { getEnvConfig } from "@/lib/config/env.schema";
 import { logger } from "@/lib/logger";
+import { withAuthResponseHeaders } from "@/lib/security/auth-response-headers";
 import { createCsrfOriginGuard } from "@/lib/security/csrf-origin-guard";
 import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy";
-import { buildSecurityHeaders } from "@/lib/security/security-headers";
 
 // 需要数据库连接
 export const runtime = "nodejs";
@@ -28,24 +27,6 @@ const csrfGuard = createCsrfOriginGuard({
 
 const loginPolicy = new LoginAbusePolicy();
 
-function applySecurityHeaders(response: NextResponse): NextResponse {
-  const env = getEnvConfig();
-  const headers = buildSecurityHeaders({
-    enableHsts: env.ENABLE_SECURE_COOKIES,
-    cspMode: "report-only",
-  });
-
-  for (const [key, value] of Object.entries(headers)) {
-    response.headers.set(key, value);
-  }
-
-  return response;
-}
-
-function withAuthResponseHeaders(response: NextResponse): NextResponse {
-  return applySecurityHeaders(withNoStoreHeaders(response));
-}
-
 /**
  * Get locale from request (cookie or Accept-Language header)
  */
@@ -130,14 +111,10 @@ function getClientIp(request: NextRequest): string {
   );
 }
 
-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),
+    keyFingerprint: await toKeyFingerprint(key),
     userId: session.user.id,
     userRole: session.user.role,
   });

+ 10 - 25
src/app/api/auth/logout/route.ts

@@ -4,13 +4,11 @@ import {
   getAuthCookie,
   getSessionTokenMode,
   type SessionTokenMode,
-  withNoStoreHeaders,
 } from "@/lib/auth";
 import { RedisSessionStore } from "@/lib/auth-session-store/redis-session-store";
-import { getEnvConfig } from "@/lib/config/env.schema";
 import { logger } from "@/lib/logger";
+import { withAuthResponseHeaders } from "@/lib/security/auth-response-headers";
 import { createCsrfOriginGuard } from "@/lib/security/csrf-origin-guard";
-import { buildSecurityHeaders } from "@/lib/security/security-headers";
 
 const csrfGuard = createCsrfOriginGuard({
   allowedOrigins: [],
@@ -19,32 +17,19 @@ const csrfGuard = createCsrfOriginGuard({
 });
 
 function resolveSessionTokenMode(): SessionTokenMode {
-  const resolver = getSessionTokenMode as unknown as (() => SessionTokenMode) | undefined;
-  return resolver?.() ?? "legacy";
+  try {
+    return getSessionTokenMode();
+  } catch {
+    return "legacy";
+  }
 }
 
 async function resolveAuthCookieToken(): Promise<string | undefined> {
-  const reader = getAuthCookie as unknown as (() => Promise<string | undefined>) | undefined;
-  if (!reader) return undefined;
-  return reader();
-}
-
-function applySecurityHeaders(response: NextResponse): NextResponse {
-  const env = getEnvConfig();
-  const headers = buildSecurityHeaders({
-    enableHsts: env.ENABLE_SECURE_COOKIES,
-    cspMode: "report-only",
-  });
-
-  for (const [key, value] of Object.entries(headers)) {
-    response.headers.set(key, value);
+  try {
+    return await getAuthCookie();
+  } catch {
+    return undefined;
   }
-
-  return response;
-}
-
-function withAuthResponseHeaders(response: NextResponse): NextResponse {
-  return applySecurityHeaders(withNoStoreHeaders(response));
 }
 
 export async function POST(request: NextRequest) {

+ 3 - 3
src/lib/api/action-adapter-openapi.ts

@@ -12,7 +12,7 @@ import { createRoute, z } from "@hono/zod-openapi";
 import type { Context } from "hono";
 import { getCookie } from "hono/cookie";
 import type { ActionResult } from "@/actions/types";
-import { AUTH_COOKIE_NAME, runWithAuthSession, validateKey } from "@/lib/auth";
+import { AUTH_COOKIE_NAME, runWithAuthSession, validateAuthToken } from "@/lib/auth";
 import { logger } from "@/lib/logger";
 
 function getBearerTokenFromAuthHeader(raw: string | undefined): string | undefined {
@@ -300,7 +300,7 @@ export function createActionRoute(
     const fullPath = `${module}.${actionName}`;
 
     try {
-      let authSession: Awaited<ReturnType<typeof validateKey>> | null = null;
+      let authSession: Awaited<ReturnType<typeof validateAuthToken>> | null = null;
 
       // 0. 认证检查 (如果需要)
       if (requiresAuth) {
@@ -312,7 +312,7 @@ export function createActionRoute(
           return c.json({ ok: false, error: "未认证" }, 401);
         }
 
-        const session = await validateKey(authToken, { allowReadOnlyAccess });
+        const session = await validateAuthToken(authToken, { allowReadOnlyAccess });
         if (!session) {
           logger.warn(`[ActionAPI] ${fullPath} 认证失败: 无效的 ${AUTH_COOKIE_NAME}`);
           return c.json({ ok: false, error: "认证无效或已过期" }, 401);

+ 21 - 16
src/lib/auth-session-store/redis-session-store.ts

@@ -102,7 +102,7 @@ export class RedisSessionStore implements SessionStore {
     const ttl = normalizeTtlSeconds(ttlSeconds);
     const createdAt = Date.now();
     const sessionData: SessionData = {
-      sessionId: crypto.randomUUID(),
+      sessionId: `sid_${crypto.randomUUID()}`,
       keyFingerprint: data.keyFingerprint,
       userId: data.userId,
       userRole: data.userRole,
@@ -112,10 +112,7 @@ export class RedisSessionStore implements SessionStore {
 
     const redis = this.getReadyRedis();
     if (!redis) {
-      logger.warn("[AuthSessionStore] Redis not ready during create", {
-        sessionId: sessionData.sessionId,
-      });
-      return sessionData;
+      throw new Error("Redis not ready: session not persisted");
     }
 
     try {
@@ -125,6 +122,7 @@ export class RedisSessionStore implements SessionStore {
         error: toLogError(error),
         sessionId: sessionData.sessionId,
       });
+      throw error;
     }
 
     return sessionData;
@@ -183,14 +181,23 @@ export class RedisSessionStore implements SessionStore {
     }
 
     const ttlSeconds = resolveRotateTtlSeconds(oldSession.expiresAt);
-    const nextSession = await this.create(
-      {
-        keyFingerprint: oldSession.keyFingerprint,
-        userId: oldSession.userId,
-        userRole: oldSession.userRole,
-      },
-      ttlSeconds
-    );
+    let nextSession: SessionData;
+    try {
+      nextSession = await this.create(
+        {
+          keyFingerprint: oldSession.keyFingerprint,
+          userId: oldSession.userId,
+          userRole: oldSession.userRole,
+        },
+        ttlSeconds
+      );
+    } catch (error) {
+      logger.error("[AuthSessionStore] Failed to create rotated session", {
+        error: toLogError(error),
+        oldSessionId,
+      });
+      return null;
+    }
 
     const persisted = await this.read(nextSession.sessionId);
     if (!persisted) {
@@ -203,12 +210,10 @@ export class RedisSessionStore implements SessionStore {
 
     const revoked = await this.revoke(oldSessionId);
     if (!revoked) {
-      logger.error("[AuthSessionStore] Failed to revoke old session during rotate", {
+      logger.warn("[AuthSessionStore] Failed to revoke old session during rotate; old session will expire naturally", {
         oldSessionId,
         newSessionId: persisted.sessionId,
       });
-      await this.revoke(persisted.sessionId);
-      return null;
     }
 
     return persisted;

+ 30 - 29
src/lib/auth.ts

@@ -259,6 +259,33 @@ export async function clearAuthCookie() {
   cookieStore.delete(AUTH_COOKIE_NAME);
 }
 
+export async function validateAuthToken(
+  token: string,
+  options?: { allowReadOnlyAccess?: boolean }
+): Promise<AuthSession | null> {
+  const mode = getSessionTokenMode();
+
+  if (mode !== "legacy") {
+    try {
+      const sessionStore = await getSessionStore();
+      const sessionData = await sessionStore.read(token);
+      if (sessionData) {
+        return convertToAuthSession(sessionData, options);
+      }
+    } catch (error) {
+      logger.warn("Opaque session read failed", {
+        error: error instanceof Error ? error.message : String(error),
+      });
+    }
+  }
+
+  if (mode === "legacy" || mode === "dual") {
+    return validateKey(token, options);
+  }
+
+  return null;
+}
+
 export async function getSession(options?: {
   /**
    * 允许仅访问只读页面(如 my-usage),跳过 canLoginWebUi 校验
@@ -282,7 +309,7 @@ export async function getSession(options?: {
     return null;
   }
 
-  return validateKey(keyString, options);
+  return validateAuthToken(keyString, options);
 }
 
 type SessionStoreReader = {
@@ -301,7 +328,7 @@ async function getSessionStore(): Promise<SessionStoreReader> {
   return sessionStorePromise;
 }
 
-async function toKeyFingerprint(keyString: string): Promise<string> {
+export 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(
     ""
@@ -333,33 +360,7 @@ async function convertToAuthSession(
 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;
+  return getSession(options);
 }
 
 export async function validateSession(options?: {

+ 1 - 1
src/lib/config/env.schema.ts

@@ -93,7 +93,7 @@ export const EnvSchema = z.object({
   REDIS_TLS_REJECT_UNAUTHORIZED: z.string().default("true").transform(booleanTransform),
   ENABLE_RATE_LIMIT: z.string().default("true").transform(booleanTransform),
   ENABLE_SECURE_COOKIES: z.string().default("true").transform(booleanTransform),
-  SESSION_TOKEN_MODE: z.enum(["legacy", "dual", "opaque"]).default("legacy"),
+  SESSION_TOKEN_MODE: z.enum(["legacy", "dual", "opaque"]).default("opaque"),
   SESSION_TTL: z.coerce.number().default(300),
   // 会话消息存储控制
   // - false (默认):存储请求/响应体但对 message 内容脱敏 [REDACTED]

+ 22 - 0
src/lib/security/auth-response-headers.ts

@@ -0,0 +1,22 @@
+import type { NextResponse } from "next/server";
+import { withNoStoreHeaders } from "@/lib/auth";
+import { getEnvConfig } from "@/lib/config/env.schema";
+import { buildSecurityHeaders } from "@/lib/security/security-headers";
+
+export function applySecurityHeaders(response: NextResponse): NextResponse {
+  const env = getEnvConfig();
+  const headers = buildSecurityHeaders({
+    enableHsts: env.ENABLE_SECURE_COOKIES,
+    cspMode: "report-only",
+  });
+
+  for (const [key, value] of Object.entries(headers)) {
+    response.headers.set(key, value);
+  }
+
+  return response;
+}
+
+export function withAuthResponseHeaders(response: NextResponse): NextResponse {
+  return applySecurityHeaders(withNoStoreHeaders(response));
+}

+ 2 - 2
src/proxy.ts

@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
 import createMiddleware from "next-intl/middleware";
 import type { Locale } from "@/i18n/config";
 import { routing } from "@/i18n/routing";
-import { AUTH_COOKIE_NAME, validateKey } from "@/lib/auth";
+import { AUTH_COOKIE_NAME, validateAuthToken } from "@/lib/auth";
 import { isDevelopment } from "@/lib/config/env.schema";
 import { logger } from "@/lib/logger";
 
@@ -80,7 +80,7 @@ async function proxyHandler(request: NextRequest) {
   }
 
   // Validate key permissions (canLoginWebUi, isEnabled, expiresAt, etc.)
-  const session = await validateKey(authToken.value, { allowReadOnlyAccess: isReadOnlyPath });
+  const session = await validateAuthToken(authToken.value, { allowReadOnlyAccess: isReadOnlyPath });
   if (!session) {
     // Invalid key or insufficient permissions, clear cookie and redirect to login
     const url = request.nextUrl.clone();

+ 3 - 2
tests/api/action-adapter-auth-session.unit.test.ts

@@ -76,11 +76,12 @@ describe("Action Adapter:会话透传", () => {
       return {
         ...actual,
         validateKey: vi.fn(async () => mockSession),
+        validateAuthToken: vi.fn(async () => mockSession),
       };
     });
 
     const { createActionRoute } = await import("@/lib/api/action-adapter-openapi");
-    const { getSession, validateKey } = await import("@/lib/auth");
+    const { getSession, validateAuthToken } = await import("@/lib/auth");
 
     const action = vi.fn(async () => {
       const session = await getSession();
@@ -115,7 +116,7 @@ describe("Action Adapter:会话透传", () => {
         }),
     } as any)) as Response;
 
-    expect(validateKey).toHaveBeenCalledTimes(1);
+    expect(validateAuthToken).toHaveBeenCalledTimes(1);
     expect(action).toHaveBeenCalledTimes(1);
     expect(response.status).toBe(200);
     await expect(response.json()).resolves.toEqual({

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

@@ -6,6 +6,7 @@ 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 mockGetEnvConfig = vi.hoisted(() => vi.fn());
 const mockLogger = vi.hoisted(() => ({
   warn: vi.fn(),
   error: vi.fn(),
@@ -33,6 +34,18 @@ vi.mock("@/lib/logger", () => ({
   logger: mockLogger,
 }));
 
+vi.mock("@/lib/config/env.schema", () => ({
+  getEnvConfig: mockGetEnvConfig,
+}));
+
+vi.mock("@/lib/security/auth-response-headers", () => ({
+  withAuthResponseHeaders: <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;
+  },
+}));
+
 function makeRequest(body: unknown, ip: string): NextRequest {
   return new NextRequest("http://localhost/api/auth/login", {
     method: "POST",

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

@@ -42,6 +42,14 @@ vi.mock("@/lib/logger", () => ({
   logger: mockLogger,
 }));
 
+vi.mock("@/lib/security/auth-response-headers", () => ({
+  withAuthResponseHeaders: <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;
+  },
+}));
+
 type LoginPostHandler = (request: NextRequest) => Promise<Response>;
 type LogoutPostHandler = (request: NextRequest) => Promise<Response>;
 

+ 3 - 3
tests/security/full-security-regression.test.ts

@@ -89,13 +89,13 @@ describe("Full Security Regression Suite", () => {
   });
 
   describe("Session Contract", () => {
-    it("SESSION_TOKEN_MODE defaults to legacy", async () => {
+    it("SESSION_TOKEN_MODE defaults to opaque", async () => {
       delete process.env.SESSION_TOKEN_MODE;
 
       vi.resetModules();
       const { getSessionTokenMode } = await import("../../src/lib/auth");
 
-      expect(getSessionTokenMode()).toBe("legacy");
+      expect(getSessionTokenMode()).toBe("opaque");
     });
 
     it("OpaqueSessionContract has required fields", async () => {
@@ -135,7 +135,7 @@ describe("Full Security Regression Suite", () => {
         userRole: "user",
       });
 
-      expect(created.sessionId).toMatch(/^[0-9a-f-]{36}$/i);
+      expect(created.sessionId).toMatch(/^sid_[0-9a-f-]{36}$/i);
       expect(created.keyFingerprint).toBe("sha256:fp-1");
       expect(created.userId).toBe(101);
       expect(created.userRole).toBe("user");

+ 2 - 2
tests/security/session-contract.test.ts

@@ -16,13 +16,13 @@ describe("session token contract and migration flags", () => {
     vi.resetModules();
   });
 
-  it("SESSION_TOKEN_MODE defaults to legacy", async () => {
+  it("SESSION_TOKEN_MODE defaults to opaque", async () => {
     delete process.env.SESSION_TOKEN_MODE;
 
     vi.resetModules();
     const { getSessionTokenMode } = await import("../../src/lib/auth");
 
-    expect(getSessionTokenMode()).toBe("legacy");
+    expect(getSessionTokenMode()).toBe("opaque");
   });
 
   it("getSessionTokenMode returns configured mode values", async () => {

+ 4 - 0
tests/security/session-cookie-hardening.test.ts

@@ -46,6 +46,10 @@ vi.mock("@/lib/config/env.schema", () => ({
   getEnvConfig: mockGetEnvConfig,
 }));
 
+vi.mock("@/lib/security/auth-response-headers", () => ({
+  withAuthResponseHeaders: realWithNoStoreHeaders,
+}));
+
 vi.mock("@/lib/config/config", () => ({ config: { auth: { adminToken: "test" } } }));
 vi.mock("@/repository/key", () => ({ validateApiKeyAndGetUser: vi.fn() }));
 

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

@@ -59,6 +59,14 @@ vi.mock("@/lib/logger", () => ({
   logger: mockLogger,
 }));
 
+vi.mock("@/lib/config/env.schema", () => ({
+  getEnvConfig: vi.fn().mockReturnValue({ ENABLE_SECURE_COOKIES: false }),
+}));
+
+vi.mock("@/lib/security/auth-response-headers", () => ({
+  withAuthResponseHeaders: realWithNoStoreHeaders,
+}));
+
 function makeLogoutRequest(): NextRequest {
   return new NextRequest("http://localhost/api/auth/logout", {
     method: "POST",

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

@@ -27,6 +27,7 @@ vi.mock("@/lib/auth", () => ({
   setAuthCookie: mockSetAuthCookie,
   getSessionTokenMode: mockGetSessionTokenMode,
   getLoginRedirectTarget: mockGetLoginRedirectTarget,
+  toKeyFingerprint: vi.fn().mockResolvedValue("sha256:fake"),
   withNoStoreHeaders: realWithNoStoreHeaders,
 }));
 
@@ -44,6 +45,14 @@ vi.mock("@/lib/logger", () => ({
   logger: mockLogger,
 }));
 
+vi.mock("@/lib/config/env.schema", () => ({
+  getEnvConfig: vi.fn().mockReturnValue({ ENABLE_SECURE_COOKIES: false }),
+}));
+
+vi.mock("@/lib/security/auth-response-headers", () => ({
+  withAuthResponseHeaders: realWithNoStoreHeaders,
+}));
+
 function makeRequest(body: unknown): NextRequest {
   return new NextRequest("http://localhost/api/auth/login", {
     method: "POST",

+ 47 - 2
tests/security/session-store.test.ts

@@ -71,7 +71,7 @@ describe("RedisSessionStore", () => {
     const store = new RedisSessionStore();
     const created = await store.create({ keyFingerprint: "fp-1", userId: 101, userRole: "user" });
 
-    expect(created.sessionId).toMatch(/^[0-9a-f-]{36}$/i);
+    expect(created.sessionId).toMatch(/^sid_[0-9a-f-]{36}$/i);
     expect(created.keyFingerprint).toBe("fp-1");
     expect(created.userId).toBe(101);
     expect(created.userRole).toBe("user");
@@ -170,7 +170,30 @@ describe("RedisSessionStore", () => {
     expect(created.expiresAt - created.createdAt).toBe(120_000);
   });
 
-  it("Redis failure returns null gracefully", async () => {
+  it("create() throws when Redis setex fails", async () => {
+    const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
+
+    redis.throwOnSetex = true;
+    const store = new RedisSessionStore();
+
+    await expect(
+      store.create({ keyFingerprint: "fp-fail", userId: 3, userRole: "user" })
+    ).rejects.toThrow("redis setex failed");
+    expect(loggerMock.error).toHaveBeenCalled();
+  });
+
+  it("create() throws when Redis is not ready", async () => {
+    const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
+
+    redis.status = "end";
+    const store = new RedisSessionStore();
+
+    await expect(
+      store.create({ keyFingerprint: "fp-noredis", userId: 4, userRole: "user" })
+    ).rejects.toThrow("Redis not ready");
+  });
+
+  it("rotate() returns null when Redis setex fails during create", async () => {
     const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
 
     const oldSession = {
@@ -191,4 +214,26 @@ describe("RedisSessionStore", () => {
     expect(redis.store.has(`cch:session:${oldSession.sessionId}`)).toBe(true);
     expect(loggerMock.error).toHaveBeenCalled();
   });
+
+  it("rotate() keeps new session when old session revocation fails", async () => {
+    const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
+
+    const oldSession = {
+      sessionId: "aaa-old-session",
+      keyFingerprint: "fp-revoke-fail",
+      userId: 5,
+      userRole: "user",
+      createdAt: Date.now() - 10_000,
+      expiresAt: Date.now() + 120_000,
+    };
+    redis.store.set(`cch:session:${oldSession.sessionId}`, JSON.stringify(oldSession));
+    redis.throwOnDel = true;
+
+    const store = new RedisSessionStore();
+    const rotated = await store.rotate(oldSession.sessionId);
+
+    expect(rotated).not.toBeNull();
+    expect(rotated?.keyFingerprint).toBe(oldSession.keyFingerprint);
+    expect(loggerMock.warn).toHaveBeenCalled();
+  });
 });

+ 8 - 0
tests/unit/api/auth-login-failure-taxonomy.test.ts

@@ -38,6 +38,14 @@ vi.mock("@/lib/config/env.schema", () => ({
   getEnvConfig: mockGetEnvConfig,
 }));
 
+vi.mock("@/lib/security/auth-response-headers", () => ({
+  withAuthResponseHeaders: <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;
+  },
+}));
+
 function makeRequest(
   body: unknown,
   opts?: { locale?: string; acceptLanguage?: string; xForwardedProto?: string }

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

@@ -18,6 +18,7 @@ vi.mock("@/lib/auth", () => ({
   setAuthCookie: mockSetAuthCookie,
   getSessionTokenMode: mockGetSessionTokenMode,
   getLoginRedirectTarget: mockGetLoginRedirectTarget,
+  toKeyFingerprint: vi.fn().mockResolvedValue("sha256:fake"),
   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");
@@ -33,6 +34,18 @@ vi.mock("@/lib/logger", () => ({
   logger: mockLogger,
 }));
 
+vi.mock("@/lib/config/env.schema", () => ({
+  getEnvConfig: vi.fn().mockReturnValue({ ENABLE_SECURE_COOKIES: false }),
+}));
+
+vi.mock("@/lib/security/auth-response-headers", () => ({
+  withAuthResponseHeaders: <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;
+  },
+}));
+
 function makeRequest(
   body: unknown,
   opts?: { locale?: string; acceptLanguage?: string }
@@ -90,12 +103,13 @@ describe("POST /api/auth/login", () => {
   let POST: (request: NextRequest) => Promise<Response>;
 
   beforeEach(async () => {
+    vi.resetModules();
     const mockT = vi.fn((key: string) => `translated:${key}`);
     mockGetTranslations.mockResolvedValue(mockT);
     mockSetAuthCookie.mockResolvedValue(undefined);
     mockGetSessionTokenMode.mockReturnValue("legacy");
 
-    const mod = await import("../../../src/app/api/auth/login/route");
+    const mod = await import("@/app/api/auth/login/route");
     POST = mod.POST;
   });
 

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

@@ -38,6 +38,14 @@ vi.mock("@/lib/logger", () => ({
   logger: mockLogger,
 }));
 
+vi.mock("@/lib/security/auth-response-headers", () => ({
+  withAuthResponseHeaders: (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;
+  },
+}));
+
 function makeRequest(body: unknown, xForwardedProto = "https"): NextRequest {
   return new NextRequest("http://localhost/api/auth/login", {
     method: "POST",

+ 1 - 1
vitest.config.ts

@@ -12,7 +12,7 @@ export default defineConfig({
           environment: "happy-dom",
           include: [
             "tests/unit/**/*.{test,spec}.tsx",
-            "tests/security/**/*.{test,spec}.tsx",
+            "tests/security/**/*.{test,spec}.{ts,tsx}",
             "tests/api/**/*.{test,spec}.tsx",
             "src/**/*.{test,spec}.tsx",
           ],