From e290f3a9d7582a151481042a74713acb4704a278 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 6 Oct 2026 20:38:44 -0400 Subject: [PATCH] feat(proxy): pass audit and coverage report routes through, with raw downloads Adds passthroughs for the audit integrity check, the audit event export and the authentication coverage report. The export and the report answer with files, so proxied routes can now forward the upstream body and its content headers unparsed through a new raw mode in core and every adapter. Refs fells-code/seamless-auth-api#173, #174, #178. --- .changeset/audit-and-coverage-passthrough.md | 14 ++ packages/core/package.json | 2 +- packages/core/src/applyResult.ts | 27 ++- packages/core/src/ensureCookies.ts | 4 + packages/core/src/proxyRequest.ts | 27 +++ packages/core/tests/applyResult.test.js | 43 ++++- packages/core/tests/proxyRequest.test.js | 45 +++++ packages/express/src/createServer.ts | 23 +++ packages/express/src/internal/respond.ts | 18 ++ .../express/tests/queryForwarding.test.js | 33 +++- packages/fastify/src/internal/respond.ts | 14 ++ packages/fastify/src/plugin.ts | 4 + packages/fastify/src/routes/proxyRoutes.ts | 22 +++ packages/fastify/tests/parity.test.js | 168 ++++++++++++++---- .../fastify/tests/queryForwarding.test.js | 33 +++- packages/nextjs/package.json | 2 +- packages/nextjs/src/handler.ts | 11 +- packages/nextjs/src/internal/respond.ts | 18 ++ packages/nextjs/src/routes/proxyRoutes.ts | 22 +++ packages/nextjs/tests/parity.test.js | 166 +++++++++++++---- pnpm-lock.yaml | 16 +- 21 files changed, 619 insertions(+), 93 deletions(-) create mode 100644 .changeset/audit-and-coverage-passthrough.md diff --git a/.changeset/audit-and-coverage-passthrough.md b/.changeset/audit-and-coverage-passthrough.md new file mode 100644 index 0000000..00e7793 --- /dev/null +++ b/.changeset/audit-and-coverage-passthrough.md @@ -0,0 +1,14 @@ +--- +"@seamless-auth/core": minor +"@seamless-auth/express": minor +"@seamless-auth/fastify": minor +"@seamless-auth/nextjs": minor +--- + +Pass the auth API's audit and reporting routes through with the caller's access identity: `GET /admin/auth-events/integrity` (fells-code/seamless-auth-api#174), `GET /admin/auth-events/export` (fells-code/seamless-auth-api#173) and `GET /admin/reports/authentication-coverage` (fells-code/seamless-auth-api#178), each with its query. + +The export and the coverage report answer with a file (NDJSON, or CSV when `format=csv`), so proxied routes can now forward an upstream response unparsed. `proxyRequest` takes `raw: true` and returns `raw: { headers, body }`, holding the body stream and its `content-type`, `content-disposition` and `cache-control`. Each adapter streams it through as is, so the download keeps its type and filename rather than arriving wrapped in `{ message }`. + +`ResponseAdapter` gains a required `sendRaw(status, raw)`. A custom adapter that implements `ResponseAdapter` itself has to add it. The adapters in this repository already do. + +`@seamless-auth/types` is now `^0.27.0`. diff --git a/packages/core/package.json b/packages/core/package.json index 8d63035..fc8ddf6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -51,7 +51,7 @@ "test:watch": "pnpm run build && NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@seamless-auth/types": "^0.26.0", + "@seamless-auth/types": "^0.27.0", "jose": "^6.1.3", "jsonwebtoken": "^9.0.2" }, diff --git a/packages/core/src/applyResult.ts b/packages/core/src/applyResult.ts index eccffb6..7e1aa38 100644 --- a/packages/core/src/applyResult.ts +++ b/packages/core/src/applyResult.ts @@ -85,6 +85,20 @@ export interface ResponseAdapter { clearCookie(command: ClearCookieCommand): void; /** `body` is `undefined` when the response carries none. */ send(status: number, body: unknown): void; + /** Sends an upstream response through unparsed, see {@link RawBody}. */ + sendRaw(status: number, raw: RawBody): void; +} + +/** + * An upstream body forwarded as bytes rather than parsed as JSON. + * + * For routes whose answer is not a JSON document, such as a CSV report or an NDJSON + * export, which a JSON round trip would wrap in `{ message }` and strip of the headers + * that make it a download. `headers` holds only the ones that describe the body. + */ +export interface RawBody { + headers: Record; + body: ReadableStream | null; } export interface SessionCookie { @@ -97,6 +111,8 @@ export interface SessionCookie { export interface AppliableResult extends ResultFailure { status: number; body?: unknown; + /** Set instead of `body` when the upstream response is passed through unparsed. */ + raw?: RawBody; setCookies?: SessionCookie[]; clearCookies?: string[]; } @@ -136,7 +152,11 @@ export function signSessionCookie( function toTtlSeconds(ttl: unknown): number { const seconds = typeof ttl === "string" ? Number(ttl) : ttl; - if (typeof seconds !== "number" || !Number.isInteger(seconds) || seconds <= 0) { + if ( + typeof seconds !== "number" || + !Number.isInteger(seconds) || + seconds <= 0 + ) { throw new Error( `Upstream returned an unusable cookie ttl: ${JSON.stringify(ttl)}`, ); @@ -239,5 +259,10 @@ export function applyResult( return; } + if (result.raw) { + adapter.sendRaw(result.status, result.raw); + return; + } + adapter.send(result.status, result.body); } diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index dca9c56..08c35b4 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -181,6 +181,10 @@ const COOKIE_REQUIREMENTS: Record< name: "accessCookieName", required: true, }, + "/admin/reports": { + name: "accessCookieName", + required: true, + }, "/admin/enrollment": { name: "accessCookieName", required: true, diff --git a/packages/core/src/proxyRequest.ts b/packages/core/src/proxyRequest.ts index 915f364..f04bcb5 100644 --- a/packages/core/src/proxyRequest.ts +++ b/packages/core/src/proxyRequest.ts @@ -136,6 +136,26 @@ export interface ProxyRequestOptions { forwardedUserAgent?: string; query?: QueryInput; body?: unknown; + /** + * Pass the response through as bytes, with the headers that describe it, instead of + * parsing it as JSON. For routes that answer with a file, such as CSV or NDJSON. + */ + raw?: boolean; +} + +// Only what describes the body. Hop-by-hop and length headers are left to the adapter's +// framework, which re-chunks the stream anyway. +const RAW_HEADERS = ["content-type", "content-disposition", "cache-control"]; + +function pickRawHeaders(headers: Headers): Record { + const picked: Record = {}; + + for (const name of RAW_HEADERS) { + const value = headers.get(name); + if (value !== null) picked[name] = value; + } + + return picked; } /** @@ -161,5 +181,12 @@ export async function proxyRequest( }, ); + if (opts.raw) { + return { + status: upstream.status, + raw: { headers: pickRawHeaders(upstream.headers), body: upstream.body }, + }; + } + return { status: upstream.status, body: await upstream.json() }; } diff --git a/packages/core/tests/applyResult.test.js b/packages/core/tests/applyResult.test.js index 70cc63e..6237e7f 100644 --- a/packages/core/tests/applyResult.test.js +++ b/packages/core/tests/applyResult.test.js @@ -8,13 +8,14 @@ const { applyCookies, applyResult, resolveCookieSameSite, signSessionCookie } = const SECRET = "cookie-secret-cookie-secret-cookie-secret"; function recorder() { - const calls = { set: [], cleared: [], sent: [] }; + const calls = { set: [], cleared: [], sent: [], raw: [] }; return { calls, setCookie: (c) => calls.set.push(c), clearCookie: (c) => calls.cleared.push(c), send: (status, body) => calls.sent.push({ status, body }), + sendRaw: (status, raw) => calls.raw.push({ status, raw }), }; } @@ -282,7 +283,9 @@ describe("cookie ttl arriving from an untyped upstream body", () => { const adapter = recorder(); applyCookies( - { setCookies: [{ name: "seamless-ephemeral", value: { sub: "u1" }, ttl }] }, + { + setCookies: [{ name: "seamless-ephemeral", value: { sub: "u1" }, ttl }], + }, adapter, { cookieSecret: SECRET }, ); @@ -324,7 +327,39 @@ describe("cookie ttl arriving from an untyped upstream body", () => { ["a negative", -300], ["null", null], ["undefined", undefined], - ])("refuses %s rather than issuing a cookie nobody can vouch for", (_l, ttl) => { - expect(() => setCookie(ttl)).toThrow(/unusable cookie ttl/); + ])( + "refuses %s rather than issuing a cookie nobody can vouch for", + (_l, ttl) => { + expect(() => setCookie(ttl)).toThrow(/unusable cookie ttl/); + }, + ); +}); + +describe("applyResult raw bodies", () => { + it("hands a raw upstream response to the adapter untouched", () => { + const adapter = recorder(); + const raw = { headers: { "content-type": "text/csv" }, body: null }; + + applyResult({ status: 200, raw }, adapter, { cookieSecret: "s" }); + + expect(adapter.calls.raw).toEqual([{ status: 200, raw }]); + expect(adapter.calls.sent).toEqual([]); + }); + + it("still answers its own rejection as JSON on a raw route", () => { + const adapter = recorder(); + + applyResult( + { status: 401, errorCode: "access session required" }, + adapter, + { + cookieSecret: "s", + }, + ); + + expect(adapter.calls.sent).toEqual([ + { status: 401, body: { error: "access session required" } }, + ]); + expect(adapter.calls.raw).toEqual([]); }); }); diff --git a/packages/core/tests/proxyRequest.test.js b/packages/core/tests/proxyRequest.test.js index 160a6e7..9997677 100644 --- a/packages/core/tests/proxyRequest.test.js +++ b/packages/core/tests/proxyRequest.test.js @@ -182,3 +182,48 @@ describe("proxyRequest", () => { ).toEqual({ status: 403, body: { error: "forbidden" } }); }); }); + +describe("proxyRequest raw passthrough", () => { + it("returns the body unparsed with only the headers that describe it", async () => { + authFetchMock.mockResolvedValueOnce( + new Response("a,b\r\n1,2\r\n", { + status: 200, + headers: { + "content-type": "text/csv; charset=utf-8", + "content-disposition": 'attachment; filename="r.csv"', + "x-powered-by": "Express", + }, + }), + ); + + const result = await proxyRequest({ + authServerUrl: "https://auth.example.com", + path: "admin/reports/authentication-coverage", + method: "GET", + query: { format: "csv" }, + raw: true, + }); + + expect(result.status).toBe(200); + expect(result.body).toBeUndefined(); + expect(result.raw.headers).toEqual({ + "content-type": "text/csv; charset=utf-8", + "content-disposition": 'attachment; filename="r.csv"', + }); + expect(await new Response(result.raw.body).text()).toBe("a,b\r\n1,2\r\n"); + }); + + it("still parses JSON when raw is not asked for", async () => { + authFetchMock.mockResolvedValueOnce( + new Response('{"ok":true}', { status: 200 }), + ); + + const result = await proxyRequest({ + authServerUrl: "https://auth.example.com", + path: "admin/auth-events/integrity", + method: "GET", + }); + + expect(result).toEqual({ status: 200, body: { ok: true } }); + }); +}); diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 2d1edaa..6e815c9 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -232,6 +232,7 @@ export function createSeamlessAuthServer( path: string | ((req: Request) => string), identity: "preAuth" | "access" | "register", method: AuthFetchOptions["method"] = "POST", + { raw = false }: { raw?: boolean } = {}, ) => async (req: Request & { cookiePayload?: any }, res: Response) => { const rejection = checkProxyIdentity({ @@ -269,6 +270,7 @@ export function createSeamlessAuthServer( forwardedUserAgent: buildForwardedUserAgent(req), query: req.query, body: req.body, + raw, }); respond(res, result, resolvedOpts); @@ -687,6 +689,27 @@ export function createSeamlessAuthServer( "/admin/enrollment/invites", proxyWithIdentity("admin/enrollment/invites", "access"), ); + r.get( + "/admin/auth-events/integrity", + proxyWithIdentity("admin/auth-events/integrity", "access", "GET"), + ); + r.get( + "/admin/auth-events/export", + proxyWithIdentity("admin/auth-events/export", "access", "GET", { + raw: true, + }), + ); + r.get( + "/admin/reports/authentication-coverage", + proxyWithIdentity( + "admin/reports/authentication-coverage", + "access", + "GET", + { + raw: true, + }, + ), + ); r.get("/admin/sessions", (req, res) => admin.listAllSessions(req, res, resolvedOpts), diff --git a/packages/express/src/internal/respond.ts b/packages/express/src/internal/respond.ts index 12917f1..10b8024 100644 --- a/packages/express/src/internal/respond.ts +++ b/packages/express/src/internal/respond.ts @@ -1,3 +1,6 @@ +import { Readable } from "node:stream"; +import type { ReadableStream as NodeReadableStream } from "node:stream/web"; + import { Response } from "express"; import { applyResult, @@ -47,6 +50,21 @@ export function expressResponseAdapter(res: Response): ResponseAdapter { res.status(status).json(body); }, + + sendRaw(status, raw) { + res.status(status).set(raw.headers); + + if (!raw.body) { + res.end(); + return; + } + + // A failure partway through cannot change a status already sent, so the + // connection is cut and the client sees a truncated download, not a hang. + Readable.fromWeb(raw.body as NodeReadableStream) + .on("error", () => res.destroy()) + .pipe(res); + }, }; } diff --git a/packages/express/tests/queryForwarding.test.js b/packages/express/tests/queryForwarding.test.js index e432fd6..1434040 100644 --- a/packages/express/tests/queryForwarding.test.js +++ b/packages/express/tests/queryForwarding.test.js @@ -9,7 +9,12 @@ const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; function accessCookie() { const token = jwt.sign( - { sub: "user-123", roles: ["admin"], sessionId: "s-1", token: "access-token" }, + { + sub: "user-123", + roles: ["admin"], + sessionId: "s-1", + token: "access-token", + }, COOKIE_SECRET, { algorithm: "HS256", expiresIn: "300s" }, ); @@ -49,7 +54,18 @@ const QUERY_ROUTES = [ ["/admin/sessions", "limit=10&offset=20"], ["/admin/auth-events", "type=login_success&limit=10"], ["/admin/organizations", "search=acme&limit=10&offset=20"], - ["/admin/enrollment", "organizationId=org-1&status=none&imported=true&limit=10"], + [ + "/admin/enrollment", + "organizationId=org-1&status=none&imported=true&limit=10", + ], + [ + "/admin/auth-events/export", + "from=2026-01-01T00%3A00%3A00Z&to=2026-02-01T00%3A00%3A00Z", + ], + [ + "/admin/reports/authentication-coverage", + "from=2026-01-01&to=2026-03-31&bucket=week&format=csv", + ], ["/internal/auth-events/summary", "from=2026-01-01&interval=day"], ["/internal/auth-events/timeseries", "from=2026-01-01&interval=day"], ["/internal/auth-events/grouped", "from=2026-01-01&interval=day"], @@ -62,11 +78,14 @@ describe("query forwarding", () => { const originalFetch = global.fetch; beforeEach(() => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({}), - }); + // A fresh real Response per call: the download routes read its headers and body. + global.fetch = jest.fn( + async () => + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); }); afterEach(() => { diff --git a/packages/fastify/src/internal/respond.ts b/packages/fastify/src/internal/respond.ts index b81cfed..d58e629 100644 --- a/packages/fastify/src/internal/respond.ts +++ b/packages/fastify/src/internal/respond.ts @@ -1,3 +1,6 @@ +import { Readable } from "node:stream"; +import type { ReadableStream as NodeReadableStream } from "node:stream/web"; + import type { FastifyReply } from "fastify"; import { applyResult, @@ -50,6 +53,17 @@ export function fastifyResponseAdapter(reply: FastifyReply): ResponseAdapter { reply.status(status).send(body); }, + + sendRaw(status, raw) { + reply.status(status).headers(raw.headers); + + if (!raw.body) { + reply.send(); + return; + } + + reply.send(Readable.fromWeb(raw.body as NodeReadableStream)); + }, }; } diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index 5b35a2f..fa41756 100644 --- a/packages/fastify/src/plugin.ts +++ b/packages/fastify/src/plugin.ts @@ -166,9 +166,13 @@ function registerProxyRoutes( forwardedUserAgent: buildForwardedUserAgent(req), query: req.query as Record, body: req.body, + raw: route.raw, }); respond(reply, result, opts); + // Returned so Fastify waits for a streamed download rather than finishing the + // reply when this handler resolves. + return reply; }, }); } diff --git a/packages/fastify/src/routes/proxyRoutes.ts b/packages/fastify/src/routes/proxyRoutes.ts index d9bce5e..b9441c9 100644 --- a/packages/fastify/src/routes/proxyRoutes.ts +++ b/packages/fastify/src/routes/proxyRoutes.ts @@ -10,6 +10,8 @@ export interface ProxyRouteDefinition { */ upstream: string; identity: ProxyIdentity; + /** Forward the upstream body and its content headers unparsed, for file downloads. */ + raw?: boolean; } /** @@ -256,6 +258,26 @@ export const PROXY_ROUTES: ProxyRouteDefinition[] = [ upstream: "admin/enrollment/invites", identity: "access", }, + { + method: "GET", + path: "/admin/auth-events/integrity", + upstream: "admin/auth-events/integrity", + identity: "access", + }, + { + method: "GET", + path: "/admin/auth-events/export", + upstream: "admin/auth-events/export", + identity: "access", + raw: true, + }, + { + method: "GET", + path: "/admin/reports/authentication-coverage", + upstream: "admin/reports/authentication-coverage", + identity: "access", + raw: true, + }, ]; /** diff --git a/packages/fastify/tests/parity.test.js b/packages/fastify/tests/parity.test.js index 401439f..ac47aa1 100644 --- a/packages/fastify/tests/parity.test.js +++ b/packages/fastify/tests/parity.test.js @@ -79,14 +79,7 @@ function parseBody(text) { } } -async function viaFastify({ - method, - path, - cookie, - headers, - payload, - options, -}) { +async function viaFastify({ method, path, cookie, headers, payload, options }) { const app = await buildFastify(options); try { const res = await app.inject({ @@ -109,14 +102,7 @@ async function viaFastify({ } } -async function viaExpress({ - method, - path, - cookie, - headers, - payload, - options, -}) { +async function viaExpress({ method, path, cookie, headers, payload, options }) { let req = request(buildExpress(options))[method](`/auth${path}`); if (headers) req = req.set(headers); if (cookie) req = req.set("Cookie", cookie); @@ -250,6 +236,15 @@ describe("fastify and express adapters agree", () => { { method: "get", path: "/organizations", cookie: accessCookie() }, upstream(403, { error: "forbidden" }), ], + [ + "admin audit integrity forwards the report", + { + method: "get", + path: "/admin/auth-events/integrity", + cookie: accessCookie(), + }, + upstream(200, { verified: true, rowsChecked: 3, firstFailure: null }), + ], [ "admin enrollment invite forwards the body", { @@ -404,24 +399,24 @@ describe("fastify and express adapters agree", () => { cookie: preAuthCookie(), }, ], - ])("answers 401 on %s, and asks upstream nothing", async ( - _label, - scenario, - ) => { - const upstreamResponse = upstream(200, {}); + ])( + "answers 401 on %s, and asks upstream nothing", + async (_label, scenario) => { + const upstreamResponse = upstream(200, {}); - global.fetch = jest.fn(async () => upstreamResponse); - const fastifyResult = await viaFastify(scenario); - const fastifyCalls = global.fetch.mock.calls.length; + global.fetch = jest.fn(async () => upstreamResponse); + const fastifyResult = await viaFastify(scenario); + const fastifyCalls = global.fetch.mock.calls.length; - global.fetch = jest.fn(async () => upstreamResponse); - const expressResult = await viaExpress(scenario); + global.fetch = jest.fn(async () => upstreamResponse); + const expressResult = await viaExpress(scenario); - expect(fastifyResult.status).toBe(401); - expect(expressResult.status).toBe(401); - expect(fastifyCalls).toBe(0); - expect(global.fetch.mock.calls.length).toBe(0); - }); + expect(fastifyResult.status).toBe(401); + expect(expressResult.status).toBe(401); + expect(fastifyCalls).toBe(0); + expect(global.fetch.mock.calls.length).toBe(0); + }, + ); // The sign-in screens call this with no session at all. Forwarding an identity // would be pointless on a route upstream serves publicly, and it would put a @@ -443,7 +438,9 @@ describe("fastify and express adapters agree", () => { for (const result of [fastify, expressResult]) { expect(result.status).toBe(401); expect( - result.cookies.some((cookie) => cookie.startsWith("seamless-ephemeral=")), + result.cookies.some((cookie) => + cookie.startsWith("seamless-ephemeral="), + ), ).toBe(false); } expect(global.fetch).not.toHaveBeenCalled(); @@ -877,3 +874,110 @@ describe("both adapters forward a magic link destination", () => { expect(viaF).toBe("https://auth.example.com/magic-link"); }); }); + +async function viaFastifyRaw({ method, path, cookie }) { + const app = await buildFastify(); + try { + const res = await app.inject({ + method: method.toUpperCase(), + url: `/auth${path}`, + headers: cookie ? { cookie } : {}, + }); + return { + status: res.statusCode, + text: res.body, + contentType: res.headers["content-type"], + disposition: res.headers["content-disposition"], + }; + } finally { + await app.close(); + } +} + +async function viaExpressRaw({ method, path, cookie }) { + let req = request(buildExpress())[method](`/auth${path}`).buffer(true); + req = req.parse((res, done) => { + let text = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => (text += chunk)); + res.on("end", () => done(null, text)); + }); + if (cookie) req = req.set("Cookie", cookie); + const res = await req; + return { + status: res.status, + text: res.body, + contentType: res.headers["content-type"], + disposition: res.headers["content-disposition"], + }; +} + +// Downloads are forwarded as bytes with the headers that make them a file. A JSON +// round trip would wrap the body in { message } and drop the attachment header. +describe("fastify and express forward downloads unparsed", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + const NDJSON = '{"seq":1,"hash":"a"}\n{"type":"manifest","count":1}\n'; + const CSV = 'Section,Field,Value\r\nCoverage,"Users, active",3\r\n'; + + function download(status, text, headers) { + return () => new Response(text, { status, headers }); + } + + async function rawVia(adapter, scenario, respond) { + global.fetch = jest.fn(async () => respond()); + return adapter(scenario); + } + + it.each([ + [ + "NDJSON audit export", + "/admin/auth-events/export?from=2026-01-01T00:00:00Z", + download(200, NDJSON, { + "content-type": "application/x-ndjson; charset=utf-8", + "content-disposition": 'attachment; filename="auth-events.ndjson"', + "cache-control": "no-store", + }), + "application/x-ndjson", + NDJSON, + ], + [ + "CSV coverage report", + "/admin/reports/authentication-coverage?format=csv", + download(200, CSV, { + "content-type": "text/csv; charset=utf-8", + "content-disposition": 'attachment; filename="coverage.csv"', + }), + "text/csv", + CSV, + ], + [ + "JSON error from a download route", + "/admin/auth-events/export", + download(403, '{"error":"step_up_required"}', { + "content-type": "application/json; charset=utf-8", + }), + "application/json", + '{"error":"step_up_required"}', + ], + ])("%s", async (_name, path, respond, contentType, text) => { + const scenario = { method: "get", path, cookie: accessCookie() }; + + const other = await rawVia(viaFastifyRaw, scenario, respond); + const expressResult = await rawVia(viaExpressRaw, scenario, respond); + + for (const result of [other, expressResult]) { + expect(result.text).toBe(text); + expect(result.contentType).toContain(contentType); + } + expect(other.status).toBe(expressResult.status); + expect(other.disposition).toBe(expressResult.disposition); + const [upstreamUrl] = global.fetch.mock.calls[0]; + expect(upstreamUrl).toMatch( + new RegExp(`^https://auth\\.example\\.com${path.split("?")[0]}`), + ); + }); +}); diff --git a/packages/fastify/tests/queryForwarding.test.js b/packages/fastify/tests/queryForwarding.test.js index d82023d..94246fb 100644 --- a/packages/fastify/tests/queryForwarding.test.js +++ b/packages/fastify/tests/queryForwarding.test.js @@ -8,7 +8,12 @@ const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; function accessCookie() { const token = jwt.sign( - { sub: "user-123", roles: ["admin"], sessionId: "s-1", token: "access-token" }, + { + sub: "user-123", + roles: ["admin"], + sessionId: "s-1", + token: "access-token", + }, COOKIE_SECRET, { algorithm: "HS256", expiresIn: "300s" }, ); @@ -38,7 +43,18 @@ const QUERY_ROUTES = [ ["/admin/sessions", "limit=10&offset=20"], ["/admin/auth-events", "type=login_success&limit=10"], ["/admin/organizations", "search=acme&limit=10&offset=20"], - ["/admin/enrollment", "organizationId=org-1&status=none&imported=true&limit=10"], + [ + "/admin/enrollment", + "organizationId=org-1&status=none&imported=true&limit=10", + ], + [ + "/admin/auth-events/export", + "from=2026-01-01T00%3A00%3A00Z&to=2026-02-01T00%3A00%3A00Z", + ], + [ + "/admin/reports/authentication-coverage", + "from=2026-01-01&to=2026-03-31&bucket=week&format=csv", + ], ["/internal/auth-events/summary", "from=2026-01-01&interval=day"], ["/internal/auth-events/timeseries", "from=2026-01-01&interval=day"], ["/internal/auth-events/grouped", "from=2026-01-01&interval=day"], @@ -51,11 +67,14 @@ describe("query forwarding", () => { const originalFetch = global.fetch; beforeEach(() => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({}), - }); + // A fresh real Response per call: the download routes read its headers and body. + global.fetch = jest.fn( + async () => + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); }); afterEach(() => { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 38c2f91..946809e 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -53,7 +53,7 @@ }, "dependencies": { "@seamless-auth/core": "workspace:^", - "@seamless-auth/types": "^0.26.0", + "@seamless-auth/types": "^0.27.0", "cookie": "^1.1.1" }, "devDependencies": { diff --git a/packages/nextjs/src/handler.ts b/packages/nextjs/src/handler.ts index ebfdaf6..1e9bf22 100644 --- a/packages/nextjs/src/handler.ts +++ b/packages/nextjs/src/handler.ts @@ -92,13 +92,18 @@ const PASSTHROUGH_ROUTES: Route[] = PROXY_ROUTES.map((definition) => ({ forwardedUserAgent: forwardedUserAgent(ctx.request), query: ctx.query, body: ctx.body, + raw: definition.raw, }); }, })); // Mount order of the other adapters: handler-backed routes first, so a // passthrough can never shadow one. -const ROUTES: Route[] = [...AUTH_ROUTES, ...ADMIN_ROUTES, ...PASSTHROUGH_ROUTES]; +const ROUTES: Route[] = [ + ...AUTH_ROUTES, + ...ADMIN_ROUTES, + ...PASSTHROUGH_ROUTES, +]; function warnOnDevJwksKid(jwksKid: string | undefined): void { if (!jwksKid || jwksKid === DEV_JWKS_KID) { @@ -147,7 +152,9 @@ export function createSeamlessAuthHandler( console.error( "[SEAMLESS-AUTH-NEXTJS] - Unhandled route error.", redactSensitiveText( - error instanceof Error ? (error.stack ?? error.message) : String(error), + error instanceof Error + ? (error.stack ?? error.message) + : String(error), ), ); // The same collector, so a refresh that ensureCookies already rotated diff --git a/packages/nextjs/src/internal/respond.ts b/packages/nextjs/src/internal/respond.ts index c13520f..04c7af2 100644 --- a/packages/nextjs/src/internal/respond.ts +++ b/packages/nextjs/src/internal/respond.ts @@ -3,6 +3,7 @@ import { applyResult, type AppliableResult, type CookieSecurityOptions, + type RawBody, type ResponseAdapter, } from "@seamless-auth/core"; @@ -26,6 +27,7 @@ export class ResponseCollector { private readonly setCookies: string[] = []; private status = 200; private body: unknown; + private raw: RawBody | undefined; readonly adapter: ResponseAdapter = { setCookie: (command) => { @@ -58,6 +60,11 @@ export class ResponseCollector { this.status = status; this.body = body; }, + + sendRaw: (status, raw) => { + this.status = status; + this.raw = raw; + }, }; json(status: number, body: unknown): Response { @@ -72,6 +79,17 @@ export class ResponseCollector { headers.append("set-cookie", cookie); } + if (this.raw) { + for (const [name, value] of Object.entries(this.raw.headers)) { + headers.set(name, value); + } + + return new Response( + NULL_BODY_STATUSES.has(this.status) ? null : this.raw.body, + { status: this.status, headers }, + ); + } + if (this.body === undefined || NULL_BODY_STATUSES.has(this.status)) { return new Response(null, { status: this.status, headers }); } diff --git a/packages/nextjs/src/routes/proxyRoutes.ts b/packages/nextjs/src/routes/proxyRoutes.ts index a561214..d19f4ab 100644 --- a/packages/nextjs/src/routes/proxyRoutes.ts +++ b/packages/nextjs/src/routes/proxyRoutes.ts @@ -10,6 +10,8 @@ export interface ProxyRouteDefinition { */ upstream: string; identity: ProxyIdentity; + /** Forward the upstream body and its content headers unparsed, for file downloads. */ + raw?: boolean; } /** @@ -242,6 +244,26 @@ export const PROXY_ROUTES: ProxyRouteDefinition[] = [ upstream: "admin/enrollment/invites", identity: "access", }, + { + method: "GET", + path: "/admin/auth-events/integrity", + upstream: "admin/auth-events/integrity", + identity: "access", + }, + { + method: "GET", + path: "/admin/auth-events/export", + upstream: "admin/auth-events/export", + identity: "access", + raw: true, + }, + { + method: "GET", + path: "/admin/reports/authentication-coverage", + upstream: "admin/reports/authentication-coverage", + identity: "access", + raw: true, + }, ]; /** diff --git a/packages/nextjs/tests/parity.test.js b/packages/nextjs/tests/parity.test.js index 95ba218..863c7fb 100644 --- a/packages/nextjs/tests/parity.test.js +++ b/packages/nextjs/tests/parity.test.js @@ -7,15 +7,12 @@ import express from "express"; import jwt from "jsonwebtoken"; import request from "supertest"; -const { createSeamlessAuthHandler, createSeamlessConsoleProxy } = await import( - "../dist/index.js" -); +const { createSeamlessAuthHandler, createSeamlessConsoleProxy } = + await import("../dist/index.js"); const { default: createSeamlessAuthServer, createSeamlessConsoleProxy: createExpressConsoleProxy, -} = await import( - "../../express/dist/index.js" -); +} = await import("../../express/dist/index.js"); const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; const SERVICE_SECRET = "service-secret-service-secret-service-secret"; @@ -101,14 +98,7 @@ async function viaNext({ method, path, cookie, headers, payload, options }) { }; } -async function viaExpress({ - method, - path, - cookie, - headers, - payload, - options, -}) { +async function viaExpress({ method, path, cookie, headers, payload, options }) { let req = request(buildExpress(options))[method](`/auth${path}`); if (headers) req = req.set(headers); if (cookie) req = req.set("Cookie", cookie); @@ -242,6 +232,15 @@ describe("next.js and express adapters agree", () => { { method: "get", path: "/organizations", cookie: accessCookie() }, upstream(403, { error: "forbidden" }), ], + [ + "admin audit integrity forwards the report", + { + method: "get", + path: "/admin/auth-events/integrity", + cookie: accessCookie(), + }, + upstream(200, { verified: true, rowsChecked: 3, firstFailure: null }), + ], [ "admin enrollment invite forwards the body", { @@ -390,24 +389,24 @@ describe("next.js and express adapters agree", () => { cookie: preAuthCookie(), }, ], - ])("answers 401 on %s, and asks upstream nothing", async ( - _label, - scenario, - ) => { - const upstreamResponse = upstream(200, {}); + ])( + "answers 401 on %s, and asks upstream nothing", + async (_label, scenario) => { + const upstreamResponse = upstream(200, {}); - global.fetch = jest.fn(async () => upstreamResponse); - const nextResult = await viaNext(scenario); - const nextCalls = global.fetch.mock.calls.length; + global.fetch = jest.fn(async () => upstreamResponse); + const nextResult = await viaNext(scenario); + const nextCalls = global.fetch.mock.calls.length; - global.fetch = jest.fn(async () => upstreamResponse); - const expressResult = await viaExpress(scenario); + global.fetch = jest.fn(async () => upstreamResponse); + const expressResult = await viaExpress(scenario); - expect(nextResult.status).toBe(401); - expect(expressResult.status).toBe(401); - expect(nextCalls).toBe(0); - expect(global.fetch.mock.calls.length).toBe(0); - }); + expect(nextResult.status).toBe(401); + expect(expressResult.status).toBe(401); + expect(nextCalls).toBe(0); + expect(global.fetch.mock.calls.length).toBe(0); + }, + ); // The sign-in screens call this with no session at all. Forwarding an identity // would be pointless on a route upstream serves publicly, and it would put a @@ -454,7 +453,9 @@ describe("next.js and express adapters agree", () => { Object.keys(headers) .filter((key) => key !== "x-seamless-client-ip") .sort(); - expect(withoutClientIp(nextHeaders)).toEqual(withoutClientIp(expressHeaders)); + expect(withoutClientIp(nextHeaders)).toEqual( + withoutClientIp(expressHeaders), + ); }); // The auth API records the user agent on every audit row and folds it into a @@ -809,3 +810,108 @@ describe("next.js and express console proxies agree", () => { expect(expressResult.status).toBeGreaterThanOrEqual(400); }); }); + +async function viaNextRaw({ method, path, cookie }) { + const handlers = createSeamlessAuthHandler(OPTIONS); + const verb = method.toUpperCase(); + const res = await handlers[verb]( + new Request(`http://localhost/auth${path}`, { + method: verb, + headers: cookie ? { cookie } : {}, + }), + ); + return { + status: res.status, + text: await res.text(), + contentType: res.headers.get("content-type"), + disposition: res.headers.get("content-disposition") ?? undefined, + }; +} + +async function viaExpressRaw({ method, path, cookie }) { + let req = request(buildExpress())[method](`/auth${path}`).buffer(true); + req = req.parse((res, done) => { + let text = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => (text += chunk)); + res.on("end", () => done(null, text)); + }); + if (cookie) req = req.set("Cookie", cookie); + const res = await req; + return { + status: res.status, + text: res.body, + contentType: res.headers["content-type"], + disposition: res.headers["content-disposition"], + }; +} + +// Downloads are forwarded as bytes with the headers that make them a file. A JSON +// round trip would wrap the body in { message } and drop the attachment header. +describe("next.js and express forward downloads unparsed", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + const NDJSON = '{"seq":1,"hash":"a"}\n{"type":"manifest","count":1}\n'; + const CSV = 'Section,Field,Value\r\nCoverage,"Users, active",3\r\n'; + + function download(status, text, headers) { + return () => new Response(text, { status, headers }); + } + + async function rawVia(adapter, scenario, respond) { + global.fetch = jest.fn(async () => respond()); + return adapter(scenario); + } + + it.each([ + [ + "NDJSON audit export", + "/admin/auth-events/export?from=2026-01-01T00:00:00Z", + download(200, NDJSON, { + "content-type": "application/x-ndjson; charset=utf-8", + "content-disposition": 'attachment; filename="auth-events.ndjson"', + "cache-control": "no-store", + }), + "application/x-ndjson", + NDJSON, + ], + [ + "CSV coverage report", + "/admin/reports/authentication-coverage?format=csv", + download(200, CSV, { + "content-type": "text/csv; charset=utf-8", + "content-disposition": 'attachment; filename="coverage.csv"', + }), + "text/csv", + CSV, + ], + [ + "JSON error from a download route", + "/admin/auth-events/export", + download(403, '{"error":"step_up_required"}', { + "content-type": "application/json; charset=utf-8", + }), + "application/json", + '{"error":"step_up_required"}', + ], + ])("%s", async (_name, path, respond, contentType, text) => { + const scenario = { method: "get", path, cookie: accessCookie() }; + + const other = await rawVia(viaNextRaw, scenario, respond); + const expressResult = await rawVia(viaExpressRaw, scenario, respond); + + for (const result of [other, expressResult]) { + expect(result.text).toBe(text); + expect(result.contentType).toContain(contentType); + } + expect(other.status).toBe(expressResult.status); + expect(other.disposition).toBe(expressResult.disposition); + const [upstreamUrl] = global.fetch.mock.calls[0]; + expect(upstreamUrl).toMatch( + new RegExp(`^https://auth\\.example\\.com${path.split("?")[0]}`), + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c66901..be8de44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: packages/core: dependencies: '@seamless-auth/types': - specifier: ^0.26.0 - version: 0.26.0 + specifier: ^0.27.0 + version: 0.27.0 jose: specifier: ^6.1.3 version: 6.2.3 @@ -144,8 +144,8 @@ importers: specifier: workspace:^ version: link:../core '@seamless-auth/types': - specifier: ^0.26.0 - version: 0.26.0 + specifier: ^0.27.0 + version: 0.27.0 cookie: specifier: ^1.1.1 version: 1.1.1 @@ -882,9 +882,9 @@ packages: cpu: [x64] os: [win32] - '@seamless-auth/types@0.26.0': - resolution: {integrity: sha512-O1a6LO/NGE8IYQEc5fTg+54PLzOvmqMmqnKe20OQ0/++0kLjvH7a9xodghzXCMnxiR/76UV2zOvNcy7r0kQ9ZQ==} - engines: {node: '>=24 <25'} + '@seamless-auth/types@0.27.0': + resolution: {integrity: sha512-kIrPtdnSOxhDKdxI7NfoRO7pHT23jgpT3pgB8Aa2asGMnasU6pPeBceN1CngUgKpU8ZkOv10mRN44bfgVZGNSA==} + engines: {node: '>=22'} '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} @@ -3433,7 +3433,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true - '@seamless-auth/types@0.26.0': + '@seamless-auth/types@0.27.0': dependencies: zod: 4.4.3