diff --git a/App/lib/pdf-export-auth.ts b/App/lib/pdf-export-auth.ts new file mode 100644 index 0000000..e0fa972 --- /dev/null +++ b/App/lib/pdf-export-auth.ts @@ -0,0 +1,24 @@ +// Service-token auth for the PO export route, shared by the auth middleware and +// (conceptually) the export route handler. +// +// PdfService ("Email PO to vendor", issue #14) fetches `/api/po//export` +// WITHOUT a user session, authenticating with a `svc` query param that must equal +// PDF_SERVICE_TOKEN. The route handler validates that token, but the auth +// middleware runs first and would otherwise redirect the unauthenticated request +// to /login — so the middleware uses this to let exactly that one route through +// when the token matches. +// +// Kept dependency-free so it's safe to import into the Edge middleware and easy to +// unit-test. `token` is `process.env.PDF_SERVICE_TOKEN` (undefined when the PDF +// service isn't configured → always denied). +const EXPORT_PATH = /^\/api\/po\/[^/]+\/export\/?$/; + +export function isPdfExportServiceRequest( + pathname: string, + svc: string | null | undefined, + token: string | undefined +): boolean { + if (!token || !svc) return false; + if (svc !== token) return false; + return EXPORT_PATH.test(pathname); +} diff --git a/App/middleware.ts b/App/middleware.ts index fa42626..57edf65 100644 --- a/App/middleware.ts +++ b/App/middleware.ts @@ -1,11 +1,20 @@ import { auth } from "@/auth"; import { NextResponse } from "next/server"; +import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth"; export default auth((req) => { const isAuthenticated = !!req.auth; const pathname = req.nextUrl.pathname; const isLoginPage = pathname === "/login"; + // PdfService fetches the PO export page unauthenticated, using a `svc` token + // that matches PDF_SERVICE_TOKEN (the route handler re-validates it). Let that + // one route through so the service token isn't bounced to /login by the gate + // below. Everything else stays auth-protected. + if (isPdfExportServiceRequest(pathname, req.nextUrl.searchParams.get("svc"), process.env.PDF_SERVICE_TOKEN)) { + return NextResponse.next(); + } + if (!isAuthenticated && !isLoginPage) { const loginUrl = new URL("/login", req.url); loginUrl.searchParams.set("callbackUrl", pathname); diff --git a/App/tests/unit/pdf-export-auth.test.ts b/App/tests/unit/pdf-export-auth.test.ts new file mode 100644 index 0000000..e5c6bb6 --- /dev/null +++ b/App/tests/unit/pdf-export-auth.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth"; + +const TOKEN = "a".repeat(64); + +describe("isPdfExportServiceRequest", () => { + it("allows the export route when the svc token matches", () => { + expect(isPdfExportServiceRequest("/api/po/cmqrug123/export", TOKEN, TOKEN)).toBe(true); + expect(isPdfExportServiceRequest("/api/po/cmqrug123/export/", TOKEN, TOKEN)).toBe(true); // trailing slash + }); + + it("denies when the token is missing, empty, or wrong", () => { + expect(isPdfExportServiceRequest("/api/po/x/export", TOKEN, undefined)).toBe(false); // service not configured + expect(isPdfExportServiceRequest("/api/po/x/export", null, TOKEN)).toBe(false); // no svc on request + expect(isPdfExportServiceRequest("/api/po/x/export", "", TOKEN)).toBe(false); + expect(isPdfExportServiceRequest("/api/po/x/export", "wrong", TOKEN)).toBe(false); + }); + + it("only matches the PO export route, not other paths", () => { + expect(isPdfExportServiceRequest("/api/po/x/export/extra", TOKEN, TOKEN)).toBe(false); + expect(isPdfExportServiceRequest("/api/po/x", TOKEN, TOKEN)).toBe(false); + expect(isPdfExportServiceRequest("/dashboard", TOKEN, TOKEN)).toBe(false); + expect(isPdfExportServiceRequest("/api/reports/spend", TOKEN, TOKEN)).toBe(false); + expect(isPdfExportServiceRequest("/api/po//export", TOKEN, TOKEN)).toBe(false); // empty id + }); +});