diff --git a/apps/web/middleware.test.ts b/apps/web/middleware.test.ts new file mode 100644 index 000000000..65e310e1c --- /dev/null +++ b/apps/web/middleware.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" + +const getSession = mock() + +mock.module("@lib/auth.middleware", () => ({ + middlewareAuthClient: { getSession }, +})) + +const { default: proxy } = await import("./middleware") + +beforeEach(() => { + getSession.mockReset() +}) + +describe("auth proxy", () => { + it("rejects API requests with a forged session cookie", async () => { + getSession.mockResolvedValue(null) + + const response = await proxy( + new Request("https://app.supermemory.ai/api/onboarding/research", { + headers: { + cookie: "better-auth.session_token=forged", + }, + }), + ) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: "Unauthorized" }) + expect(getSession).toHaveBeenCalledWith({ + fetchOptions: { + headers: { + cookie: "better-auth.session_token=forged", + }, + }, + }) + }) + + it("does not validate API requests without a session cookie", async () => { + const response = await proxy( + new Request("https://app.supermemory.ai/api/onboarding/research"), + ) + + expect(response.status).toBe(401) + expect(getSession).not.toHaveBeenCalled() + }) + + it("redirects page requests with an expired session cookie", async () => { + getSession.mockResolvedValue(null) + + const response = await proxy( + new Request("https://app.supermemory.ai/settings", { + headers: { + cookie: "better-auth.session_token=expired", + }, + }), + ) + + expect(response.status).toBe(307) + expect(response.headers.get("location")).toBe( + "https://app.supermemory.ai/login?redirect=https%3A%2F%2Fapp.supermemory.ai%2Fsettings", + ) + }) + + it("allows API requests with a valid session", async () => { + getSession.mockResolvedValue({ + session: {}, + user: {}, + }) + + const response = await proxy( + new Request("https://app.supermemory.ai/api/onboarding/research", { + headers: { + cookie: "better-auth.session_token=valid", + }, + }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get("x-middleware-next")).toBe("1") + }) + + it("fails closed when session validation is unavailable", async () => { + getSession.mockRejectedValue(new Error("backend unavailable")) + + const response = await proxy( + new Request("https://app.supermemory.ai/api/onboarding/research", { + headers: { + cookie: "better-auth.session_token=unknown", + }, + }), + ) + + expect(response.status).toBe(401) + }) +}) diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index ea7b1ec1c..284a46b93 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,5 +1,6 @@ import { getSessionCookie } from "better-auth/cookies" import { NextResponse } from "next/server" +import { middlewareAuthClient } from "@lib/auth.middleware" import { getPublicRequestUrl } from "@/lib/url-helpers" const LOCAL_DEV_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]) @@ -11,6 +12,22 @@ function getAuthSessionCookie(request: Request): string | null { ) } +async function hasValidSession(request: Request): Promise { + try { + const session = await middlewareAuthClient.getSession({ + fetchOptions: { + headers: { + cookie: request.headers.get("cookie") ?? "", + }, + }, + }) + return Boolean(session?.session && session.user) + } catch (error) { + console.error("[PROXY] Failed to validate session", error) + return false + } +} + export default async function proxy(request: Request) { console.debug("[PROXY] === PROXY START ===") const url = getPublicRequestUrl(request) @@ -58,22 +75,26 @@ export default async function proxy(request: Request) { return NextResponse.next() } + const validSession = sessionCookie ? await hasValidSession(request) : false + if (url.pathname.startsWith("/api/")) { - if (!sessionCookie) { - console.debug("[MIDDLEWARE] API route without session, returning 401") + if (!validSession) { + console.debug( + "[MIDDLEWARE] API route without valid session, returning 401", + ) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" }, }) } - console.debug("[MIDDLEWARE] API route with session, allowing access") + console.debug("[MIDDLEWARE] API route with valid session, allowing access") return NextResponse.next() } - // If no session cookie and not on a public path, redirect to login - if (!sessionCookie) { + // If no valid session and not on a public path, redirect to login + if (!validSession) { console.debug( - "[PROXY] No session cookie and not on public path, redirecting to /login", + "[PROXY] No valid session and not on public path, redirecting to /login", ) const loginUrl = new URL("/login", url.origin) loginUrl.searchParams.set("redirect", url.toString())