Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/audit-and-coverage-passthrough.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
27 changes: 26 additions & 1 deletion packages/core/src/applyResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
body: ReadableStream<Uint8Array> | null;
}

export interface SessionCookie {
Expand All @@ -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[];
}
Expand Down Expand Up @@ -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)}`,
);
Expand Down Expand Up @@ -239,5 +259,10 @@ export function applyResult(
return;
}

if (result.raw) {
adapter.sendRaw(result.status, result.raw);
return;
}

adapter.send(result.status, result.body);
}
4 changes: 4 additions & 0 deletions packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ const COOKIE_REQUIREMENTS: Record<
name: "accessCookieName",
required: true,
},
"/admin/reports": {
name: "accessCookieName",
required: true,
},
"/admin/enrollment": {
name: "accessCookieName",
required: true,
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/proxyRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const picked: Record<string, string> = {};

for (const name of RAW_HEADERS) {
const value = headers.get(name);
if (value !== null) picked[name] = value;
}

return picked;
}

/**
Expand All @@ -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() };
}
43 changes: 39 additions & 4 deletions packages/core/tests/applyResult.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
};
}

Expand Down Expand Up @@ -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 },
);
Expand Down Expand Up @@ -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([]);
});
});
45 changes: 45 additions & 0 deletions packages/core/tests/proxyRequest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
});
});
23 changes: 23 additions & 0 deletions packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -269,6 +270,7 @@ export function createSeamlessAuthServer(
forwardedUserAgent: buildForwardedUserAgent(req),
query: req.query,
body: req.body,
raw,
});

respond(res, result, resolvedOpts);
Expand Down Expand Up @@ -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),
Expand Down
18 changes: 18 additions & 0 deletions packages/express/src/internal/respond.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { Readable } from "node:stream";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";

import { Response } from "express";
import {
applyResult,
Expand Down Expand Up @@ -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<Uint8Array>)
.on("error", () => res.destroy())
.pipe(res);
},
};
}

Expand Down
33 changes: 26 additions & 7 deletions packages/express/tests/queryForwarding.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
);
Expand Down Expand Up @@ -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"],
Expand All @@ -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(() => {
Expand Down
14 changes: 14 additions & 0 deletions packages/fastify/src/internal/respond.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Uint8Array>));
},
};
}

Expand Down
Loading
Loading