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