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
9 changes: 9 additions & 0 deletions .changeset/ranged-metrics-review-accounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": minor
"@seamless-auth/fastify": minor
"@seamless-auth/nextjs": minor
---

- `GET /internal/metrics/dashboard` and `GET /internal/security/anomalies` now forward their query string, so the time range and paging the auth API accepts on them reach it (fells-code/seamless-auth-api#132). Before, both handlers were built without a query, and a range from the dashboard was silently dropped. `getDashboardMetricsHandler` and `getSecurityAnomaliesHandler` accept `query`.
- Pass `GET /admin/review-accounts` (with its `days` query) through to the auth API with the caller's access identity (fells-code/seamless-auth-api#331).
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/review-accounts": {
name: "accessCookieName",
required: true,
},
"/admin/reports": {
name: "accessCookieName",
required: true,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/handlers/internalMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ export const getAuthEventTimeseriesHandler = (opts: WithQuery) =>
export const getLoginStatsHandler = (opts: WithQuery) =>
get("/internal/auth-events/login-stats", opts);

export const getSecurityAnomaliesHandler = (opts: BaseOpts) =>
export const getSecurityAnomaliesHandler = (opts: WithQuery) =>
get("/internal/security/anomalies", opts);

export const getDashboardMetricsHandler = (opts: BaseOpts) =>
export const getDashboardMetricsHandler = (opts: WithQuery) =>
get("/internal/metrics/dashboard", opts);

export const getGroupedEventSummaryHandler = (opts: WithQuery) =>
Expand Down
4 changes: 4 additions & 0 deletions packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,10 @@ export function createSeamlessAuthServer(
},
),
);
r.get(
"/admin/review-accounts",
proxyWithIdentity("admin/review-accounts", "access", "GET"),
);

r.get("/admin/sessions", (req, res) =>
admin.listAllSessions(req, res, resolvedOpts),
Expand Down
2 changes: 2 additions & 0 deletions packages/express/src/handlers/internalMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export async function getSecurityAnomalies(
serviceAuthorization: buildProxyServiceAuthorization(opts),
forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp),
forwardedUserAgent: buildForwardedUserAgent(req),
query: req.query,
});

return handle(res, result, opts);
Expand All @@ -128,6 +129,7 @@ export async function getDashboardMetrics(
serviceAuthorization: buildProxyServiceAuthorization(opts),
forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp),
forwardedUserAgent: buildForwardedUserAgent(req),
query: req.query,
});

return handle(res, result, opts);
Expand Down
6 changes: 6 additions & 0 deletions packages/express/tests/queryForwarding.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ const QUERY_ROUTES = [
["/internal/auth-events/login-stats", "from=2026-01-01&to=2026-02-01"],
["/internal/metrics/funnel", "from=2026-01-01&to=2026-02-01"],
["/internal/metrics/sign-ins", "from=2026-01-01&to=2026-02-01"],
["/internal/metrics/dashboard", "from=2026-01-01&to=2026-02-01"],
["/admin/review-accounts", "days=60"],
[
"/internal/security/anomalies",
"from=2026-01-01&to=2026-02-01&limit=50&offset=100",
],
];

describe("query forwarding", () => {
Expand Down
19 changes: 16 additions & 3 deletions packages/fastify/src/routes/adminRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ const ROUTES: Array<["GET" | "POST" | "PATCH" | "DELETE", string, Call]> = [
[
"GET",
"/admin/users",
(c, r) => getUsersHandler({ ...c, query: r.query as Record<string, unknown> }),
(c, r) =>
getUsersHandler({ ...c, query: r.query as Record<string, unknown> }),
],
["POST", "/admin/users", (c, r) => createUserHandler({ ...c, body: r.body })],
[
Expand Down Expand Up @@ -186,9 +187,21 @@ const ROUTES: Array<["GET" | "POST" | "PATCH" | "DELETE", string, Call]> = [
[
"GET",
"/internal/security/anomalies",
(c) => getSecurityAnomaliesHandler(c),
(c, r) =>
getSecurityAnomaliesHandler({
...c,
query: r.query as Record<string, unknown>,
}),
],
[
"GET",
"/internal/metrics/dashboard",
(c, r) =>
getDashboardMetricsHandler({
...c,
query: r.query as Record<string, unknown>,
}),
],
["GET", "/internal/metrics/dashboard", (c) => getDashboardMetricsHandler(c)],
[
"GET",
"/internal/metrics/funnel",
Expand Down
6 changes: 6 additions & 0 deletions packages/fastify/src/routes/proxyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,12 @@ export const PROXY_ROUTES: ProxyRouteDefinition[] = [
identity: "access",
raw: true,
},
{
method: "GET",
path: "/admin/review-accounts",
upstream: "admin/review-accounts",
identity: "access",
},
];

/**
Expand Down
19 changes: 19 additions & 0 deletions packages/fastify/tests/parity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,25 @@ describe("fastify and express adapters agree", () => {
{ method: "get", path: "/organizations", cookie: accessCookie() },
upstream(403, { error: "forbidden" }),
],
[
"admin review accounts forwards the report",
{
method: "get",
path: "/admin/review-accounts",
cookie: accessCookie(),
},
upstream(200, {
enabled: true,
emails: ["review@example.com"],
codeConfigured: true,
recentSignIns: {
days: 30,
count: 2,
failedVerifications: 0,
lastSignInAt: null,
},
}),
],
[
"admin audit integrity forwards the report",
{
Expand Down
6 changes: 6 additions & 0 deletions packages/fastify/tests/queryForwarding.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ const QUERY_ROUTES = [
["/internal/auth-events/login-stats", "from=2026-01-01&to=2026-02-01"],
["/internal/metrics/funnel", "from=2026-01-01&to=2026-02-01"],
["/internal/metrics/sign-ins", "from=2026-01-01&to=2026-02-01"],
["/internal/metrics/dashboard", "from=2026-01-01&to=2026-02-01"],
["/admin/review-accounts", "days=60"],
[
"/internal/security/anomalies",
"from=2026-01-01&to=2026-02-01&limit=50&offset=100",
],
];

describe("query forwarding", () => {
Expand Down
20 changes: 9 additions & 11 deletions packages/nextjs/src/routes/adminRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,7 @@ type Call = (ctx: CallContext, req: AuthContext) => Promise<AppliableResult>;

const TABLE: Array<[RouteMethod, string, Call]> = [
// Users
[
"GET",
"/admin/users",
(c, r) => getUsersHandler({ ...c, query: r.query }),
],
["GET", "/admin/users", (c, r) => getUsersHandler({ ...c, query: r.query })],
["POST", "/admin/users", (c, r) => createUserHandler({ ...c, body: r.body })],
[
"DELETE",
Expand Down Expand Up @@ -93,8 +89,7 @@ const TABLE: Array<[RouteMethod, string, Call]> = [
[
"GET",
"/admin/auth-events",
(c, r) =>
getAuthEventsHandler({ ...c, query: r.query }),
(c, r) => getAuthEventsHandler({ ...c, query: r.query }),
],
["GET", "/admin/credential-count", (c) => getCredentialCountHandler(c)],

Expand Down Expand Up @@ -155,8 +150,7 @@ const TABLE: Array<[RouteMethod, string, Call]> = [
[
"GET",
"/internal/auth-events/login-stats",
(c, r) =>
getLoginStatsHandler({ ...c, query: r.query }),
(c, r) => getLoginStatsHandler({ ...c, query: r.query }),
],
[
"GET",
Expand All @@ -170,9 +164,13 @@ const TABLE: Array<[RouteMethod, string, Call]> = [
[
"GET",
"/internal/security/anomalies",
(c) => getSecurityAnomaliesHandler(c),
(c, r) => getSecurityAnomaliesHandler({ ...c, query: r.query }),
],
[
"GET",
"/internal/metrics/dashboard",
(c, r) => getDashboardMetricsHandler({ ...c, query: r.query }),
],
["GET", "/internal/metrics/dashboard", (c) => getDashboardMetricsHandler(c)],
[
"GET",
"/internal/metrics/funnel",
Expand Down
6 changes: 6 additions & 0 deletions packages/nextjs/src/routes/proxyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@ export const PROXY_ROUTES: ProxyRouteDefinition[] = [
identity: "access",
raw: true,
},
{
method: "GET",
path: "/admin/review-accounts",
upstream: "admin/review-accounts",
identity: "access",
},
];

/**
Expand Down
50 changes: 50 additions & 0 deletions packages/nextjs/tests/parity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,25 @@ describe("next.js and express adapters agree", () => {
{ method: "get", path: "/organizations", cookie: accessCookie() },
upstream(403, { error: "forbidden" }),
],
[
"admin review accounts forwards the report",
{
method: "get",
path: "/admin/review-accounts",
cookie: accessCookie(),
},
upstream(200, {
enabled: true,
emails: ["review@example.com"],
codeConfigured: true,
recentSignIns: {
days: 30,
count: 2,
failedVerifications: 0,
lastSignInAt: null,
},
}),
],
[
"admin audit integrity forwards the report",
{
Expand Down Expand Up @@ -915,3 +934,34 @@ describe("next.js and express forward downloads unparsed", () => {
);
});
});

// A handler built without the query forwards a bare path and answers 200 for the
// default window, which nothing else would notice.
describe("next.js forwards the range on ranged internal metrics", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});

it.each([
["/internal/metrics/dashboard", "from=2026-01-01&to=2026-02-01"],
[
"/internal/security/anomalies",
"from=2026-01-01&to=2026-02-01&limit=50&offset=100",
],
])("GET %s", async (path, query) => {
global.fetch = jest.fn(async () => upstream(200, {}));

await viaNext({
method: "get",
path: `${path}?${query}`,
cookie: accessCookie(),
});

const [url] = global.fetch.mock.calls[0];
const forwarded = new URL(url).searchParams;
for (const [name, value] of new URLSearchParams(query)) {
expect(forwarded.get(name)).toBe(value);
}
});
});
Loading