Auth fix
CI / ci (push) Canceled after 0s

Implemented
- Root cause fixed in [src/proxy.ts]: Auth.js is now initialized lazily and never invoked when MANAGER_MODE=true.
- Manager requests call handleProxyRequest directly, preserving redirects and CSP.
- Business requests continue through Auth.js with existing protected-route behavior.
- Added coverage in [proxy-manager-mode.test.ts]
This commit is contained in:
Stefan Lazic committed 2026-09-04 22:28:49 +01:00
1 parent 2da916a4c6
commit 1c281c9f5d
2 files changed
+40 -11

No files matched your search

+27 -3
View File
@@ -2,6 +2,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
const isInstalledForRequest = vi.fn(); const isInstalledForRequest = vi.fn();
// Keep the Auth.js wrapper observable. Manager requests must not construct or
// invoke it; business requests still must use it for req.auth handling.
const { nextAuth, handleProxyRequestForTest } = vi.hoisted(() => {
const handleProxyRequestForTest = vi.fn(async (_request: NextRequest) => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => ({
auth: (request: NextRequest) => handleProxyRequestForTest(request),
}));
return { nextAuth, handleProxyRequestForTest };
});
// If proxy.ts or its Manager request path statically imports Prisma, this // If proxy.ts or its Manager request path statically imports Prisma, this
// mock throws while the test module is loading. Manager mode must not reach it. // mock throws while the test module is loading. Manager mode must not reach it.
@@ -11,7 +21,9 @@ vi.mock("@/lib/db/prisma", () => {
vi.mock("@/lib/installation/installation-state", () => ({ isInstalledForRequest })); vi.mock("@/lib/installation/installation-state", () => ({ isInstalledForRequest }));
import { handleProxyRequest } from "@/proxy"; vi.mock("next-auth", () => ({ default: nextAuth }));
import proxy, { handleProxyRequest } from "@/proxy";
function request(pathname: string) { function request(pathname: string) {
return new NextRequest(`https://manager.example.com${pathname}`); return new NextRequest(`https://manager.example.com${pathname}`);
@@ -24,23 +36,26 @@ describe("proxy deployment separation", () => {
if (originalManagerMode === undefined) delete process.env.MANAGER_MODE; if (originalManagerMode === undefined) delete process.env.MANAGER_MODE;
else process.env.MANAGER_MODE = originalManagerMode; else process.env.MANAGER_MODE = originalManagerMode;
isInstalledForRequest.mockReset(); isInstalledForRequest.mockReset();
nextAuth.mockClear();
handleProxyRequestForTest.mockClear();
}); });
it("routes the Manager root before loading or checking business installation state", async () => { it("routes the Manager root before loading or checking business installation state", async () => {
process.env.MANAGER_MODE = "true"; process.env.MANAGER_MODE = "true";
const response = await handleProxyRequest(request("/")); const response = await proxy(request("/"));
expect(response.status).toBe(307); expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("https://manager.example.com/manager"); expect(response.headers.get("location")).toBe("https://manager.example.com/manager");
expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
expect(isInstalledForRequest).not.toHaveBeenCalled(); expect(isInstalledForRequest).not.toHaveBeenCalled();
expect(nextAuth).not.toHaveBeenCalled();
}); });
it("serves Manager routes without business installation state", async () => { it("serves Manager routes without business installation state", async () => {
process.env.MANAGER_MODE = "true"; process.env.MANAGER_MODE = "true";
const response = await handleProxyRequest(request("/manager")); const response = await proxy(request("/manager"));
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(isInstalledForRequest).not.toHaveBeenCalled(); expect(isInstalledForRequest).not.toHaveBeenCalled();
@@ -57,6 +72,15 @@ describe("proxy deployment separation", () => {
expect(isInstalledForRequest).toHaveBeenCalledOnce(); expect(isInstalledForRequest).toHaveBeenCalledOnce();
}); });
it("uses Auth.js for normal business requests", async () => {
process.env.MANAGER_MODE = "false";
await proxy(request("/"));
expect(nextAuth).toHaveBeenCalledOnce();
expect(handleProxyRequestForTest).toHaveBeenCalledOnce();
});
it("preserves protected business route redirects after installation", async () => { it("preserves protected business route redirects after installation", async () => {
process.env.MANAGER_MODE = "false"; process.env.MANAGER_MODE = "false";
isInstalledForRequest.mockResolvedValue(true); isInstalledForRequest.mockResolvedValue(true);
+13 -8
View File
@@ -29,13 +29,15 @@ const INSTALL_EXEMPT_PREFIXES = [
"/sw.js", "/sw.js",
]; ];
// Lightweight instance built from the DB-free config — see auth.config.ts. // Build the business Auth.js middleware only when a business request needs
// Next.js 16 renamed this file convention from `middleware` to `proxy` // it. Manager requests must not construct or invoke this wrapper at all.
// (https://nextjs.org/docs/messages/middleware-to-proxy); it now always type BusinessProxy = (req: NextRequest) => Response | Promise<Response>;
// runs on the Node.js runtime rather than Edge, but this stays split from let authenticatedProxy: BusinessProxy | undefined;
// the full `@/auth` regardless, so checking "is there a session" doesn't
// spin up a Postgres connection pool on every request. function getAuthenticatedProxy() {
const { auth } = NextAuth(authConfig); authenticatedProxy ??= NextAuth(authConfig).auth as unknown as BusinessProxy;
return authenticatedProxy;
}
/** /**
* Nonce-gated 'strict-dynamic' script-src (Next's own documented pattern) * Nonce-gated 'strict-dynamic' script-src (Next's own documented pattern)
@@ -139,7 +141,10 @@ export async function handleProxyRequest(req: AuthenticatedNextRequest) {
return response; return response;
} }
export default auth(handleProxyRequest); export default function proxy(req: NextRequest) {
if (process.env.MANAGER_MODE === "true") return handleProxyRequest(req);
return getAuthenticatedProxy()(req);
}
export const config = { export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],