Browse Source

refactor(auth): improve error handling and lazy-load session store

ding113 1 week ago
parent
commit
3b362e13bd

+ 38 - 13
src/app/api/auth/login/route.ts

@@ -9,7 +9,6 @@ import {
   toKeyFingerprint,
   validateKey,
 } 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";
@@ -111,8 +110,19 @@ function getClientIp(request: NextRequest): string {
   );
 }
 
+let sessionStoreInstance: import("@/lib/auth-session-store/redis-session-store").RedisSessionStore | null =
+  null;
+
+async function getLoginSessionStore() {
+  if (!sessionStoreInstance) {
+    const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store");
+    sessionStoreInstance = new RedisSessionStore();
+  }
+  return sessionStoreInstance;
+}
+
 async function createOpaqueSession(key: string, session: AuthSession) {
-  const store = new RedisSessionStore();
+  const store = await getLoginSessionStore();
   return store.create({
     keyFingerprint: await toKeyFingerprint(key),
     userId: session.user.id,
@@ -124,7 +134,7 @@ export async function POST(request: NextRequest) {
   const csrfResult = csrfGuard.check(request);
   if (!csrfResult.allowed) {
     return withAuthResponseHeaders(
-      NextResponse.json({ error: "Forbidden", errorCode: "CSRF_REJECTED" }, { status: 403 })
+      NextResponse.json({ errorCode: "CSRF_REJECTED" }, { status: 403 })
     );
   }
 
@@ -137,7 +147,7 @@ export async function POST(request: NextRequest) {
     const response = withAuthResponseHeaders(
       NextResponse.json(
         {
-          error: t?.("loginFailed") ?? t?.("serverError"),
+          error: t?.("loginFailed") ?? t?.("serverError") ?? "Too many attempts",
           errorCode: "RATE_LIMITED",
         },
         { status: 429 }
@@ -157,13 +167,16 @@ export async function POST(request: NextRequest) {
     if (!key) {
       if (!shouldIncludeFailureTaxonomy(request)) {
         return withAuthResponseHeaders(
-          NextResponse.json({ error: t?.("apiKeyRequired") }, { status: 400 })
+          NextResponse.json(
+            { error: t?.("apiKeyRequired") ?? "API key is required" },
+            { status: 400 }
+          )
         );
       }
 
       return withAuthResponseHeaders(
         NextResponse.json(
-          { error: t?.("apiKeyRequired"), errorCode: "KEY_REQUIRED" },
+          { error: t?.("apiKeyRequired") ?? "API key is required", errorCode: "KEY_REQUIRED" },
           { status: 400 }
         )
       );
@@ -175,16 +188,19 @@ export async function POST(request: NextRequest) {
 
       if (!shouldIncludeFailureTaxonomy(request)) {
         return withAuthResponseHeaders(
-          NextResponse.json({ error: t?.("apiKeyInvalidOrExpired") }, { status: 401 })
+          NextResponse.json(
+            { error: t?.("apiKeyInvalidOrExpired") ?? "Authentication failed" },
+            { status: 401 }
+          )
         );
       }
 
       const responseBody: {
-        error: string | undefined;
+        error: string;
         errorCode: "KEY_INVALID";
         httpMismatchGuidance?: string;
       } = {
-        error: t?.("apiKeyInvalidOrExpired"),
+        error: t?.("apiKeyInvalidOrExpired") ?? "Authentication failed",
         errorCode: "KEY_INVALID",
       };
 
@@ -212,8 +228,15 @@ export async function POST(request: NextRequest) {
         });
       }
     } else {
-      const opaqueSession = await createOpaqueSession(key, session);
-      await setAuthCookie(opaqueSession.sessionId);
+      try {
+        const opaqueSession = await createOpaqueSession(key, session);
+        await setAuthCookie(opaqueSession.sessionId);
+      } catch (error) {
+        logger.error("Failed to create opaque session, falling back to legacy cookie", {
+          error: error instanceof Error ? error.message : String(error),
+        });
+        await setAuthCookie(key);
+      }
     }
 
     loginPolicy.recordSuccess(clientIp);
@@ -242,14 +265,16 @@ export async function POST(request: NextRequest) {
   } catch (error) {
     logger.error("Login error:", error);
 
+    const serverError = t?.("serverError") ?? "Internal server error";
+
     if (!shouldIncludeFailureTaxonomy(request)) {
       return withAuthResponseHeaders(
-        NextResponse.json({ error: t?.("serverError") }, { status: 500 })
+        NextResponse.json({ error: serverError }, { status: 500 })
       );
     }
 
     return withAuthResponseHeaders(
-      NextResponse.json({ error: t?.("serverError"), errorCode: "SERVER_ERROR" }, { status: 500 })
+      NextResponse.json({ error: serverError, errorCode: "SERVER_ERROR" }, { status: 500 })
     );
   }
 }

+ 1 - 1
src/app/api/auth/logout/route.ts

@@ -36,7 +36,7 @@ export async function POST(request: NextRequest) {
   const csrfResult = csrfGuard.check(request);
   if (!csrfResult.allowed) {
     return withAuthResponseHeaders(
-      NextResponse.json({ error: "Forbidden", errorCode: "CSRF_REJECTED" }, { status: 403 })
+      NextResponse.json({ errorCode: "CSRF_REJECTED" }, { status: 403 })
     );
   }
 

+ 5 - 4
tests/security/auth-csrf-route-integration.test.ts

@@ -23,6 +23,7 @@ vi.mock("@/lib/auth", () => ({
   getLoginRedirectTarget: mockGetLoginRedirectTarget,
   clearAuthCookie: mockClearAuthCookie,
   getAuthCookie: mockGetAuthCookie,
+  toKeyFingerprint: vi.fn().mockResolvedValue("sha256:mock"),
   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");
@@ -109,10 +110,10 @@ describe("auth route csrf guard integration", () => {
     mockGetAuthCookie.mockResolvedValue(undefined);
     mockGetSessionTokenMode.mockReturnValue("legacy");
 
-    const loginRoute = await import("../../src/app/api/auth/login/route");
+    const loginRoute = await import("@/app/api/auth/login/route");
     loginPost = loginRoute.POST;
 
-    const logoutRoute = await import("../../src/app/api/auth/logout/route");
+    const logoutRoute = await import("@/app/api/auth/logout/route");
     logoutPost = logoutRoute.POST;
   });
 
@@ -132,7 +133,7 @@ describe("auth route csrf guard integration", () => {
     const res = await loginPost(request);
 
     expect(res.status).toBe(403);
-    expect(await res.json()).toEqual({ error: "Forbidden", errorCode: "CSRF_REJECTED" });
+    expect(await res.json()).toEqual({ errorCode: "CSRF_REJECTED" });
     expect(mockValidateKey).not.toHaveBeenCalled();
   });
 
@@ -160,7 +161,7 @@ describe("auth route csrf guard integration", () => {
     const res = await logoutPost(request);
 
     expect(res.status).toBe(403);
-    expect(await res.json()).toEqual({ error: "Forbidden", errorCode: "CSRF_REJECTED" });
+    expect(await res.json()).toEqual({ errorCode: "CSRF_REJECTED" });
     expect(mockClearAuthCookie).not.toHaveBeenCalled();
   });