diff --git a/.changeset/ranged-metrics-review-accounts.md b/.changeset/ranged-metrics-review-accounts.md new file mode 100644 index 0000000..0b11471 --- /dev/null +++ b/.changeset/ranged-metrics-review-accounts.md @@ -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). diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index 08c35b4..e7e0fea 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/review-accounts": { + name: "accessCookieName", + required: true, + }, "/admin/reports": { name: "accessCookieName", required: true, diff --git a/packages/core/src/handlers/internalMetrics.ts b/packages/core/src/handlers/internalMetrics.ts index 7df2916..5c28218 100644 --- a/packages/core/src/handlers/internalMetrics.ts +++ b/packages/core/src/handlers/internalMetrics.ts @@ -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) => diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 6e815c9..b0a684b 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -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), diff --git a/packages/express/src/handlers/internalMetrics.ts b/packages/express/src/handlers/internalMetrics.ts index 2e295bd..b24a14f 100644 --- a/packages/express/src/handlers/internalMetrics.ts +++ b/packages/express/src/handlers/internalMetrics.ts @@ -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); @@ -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); diff --git a/packages/express/tests/queryForwarding.test.js b/packages/express/tests/queryForwarding.test.js index 1434040..03ef6c8 100644 --- a/packages/express/tests/queryForwarding.test.js +++ b/packages/express/tests/queryForwarding.test.js @@ -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", () => { diff --git a/packages/fastify/src/routes/adminRoutes.ts b/packages/fastify/src/routes/adminRoutes.ts index 2c92a85..51f947d 100644 --- a/packages/fastify/src/routes/adminRoutes.ts +++ b/packages/fastify/src/routes/adminRoutes.ts @@ -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 }), + (c, r) => + getUsersHandler({ ...c, query: r.query as Record }), ], ["POST", "/admin/users", (c, r) => createUserHandler({ ...c, body: r.body })], [ @@ -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, + }), + ], + [ + "GET", + "/internal/metrics/dashboard", + (c, r) => + getDashboardMetricsHandler({ + ...c, + query: r.query as Record, + }), ], - ["GET", "/internal/metrics/dashboard", (c) => getDashboardMetricsHandler(c)], [ "GET", "/internal/metrics/funnel", diff --git a/packages/fastify/src/routes/proxyRoutes.ts b/packages/fastify/src/routes/proxyRoutes.ts index b9441c9..e1ee8f2 100644 --- a/packages/fastify/src/routes/proxyRoutes.ts +++ b/packages/fastify/src/routes/proxyRoutes.ts @@ -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", + }, ]; /** diff --git a/packages/fastify/tests/parity.test.js b/packages/fastify/tests/parity.test.js index ac47aa1..2fdedc2 100644 --- a/packages/fastify/tests/parity.test.js +++ b/packages/fastify/tests/parity.test.js @@ -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", { diff --git a/packages/fastify/tests/queryForwarding.test.js b/packages/fastify/tests/queryForwarding.test.js index 94246fb..c768fcc 100644 --- a/packages/fastify/tests/queryForwarding.test.js +++ b/packages/fastify/tests/queryForwarding.test.js @@ -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", () => { diff --git a/packages/nextjs/src/routes/adminRoutes.ts b/packages/nextjs/src/routes/adminRoutes.ts index 9866dbb..8dc37c1 100644 --- a/packages/nextjs/src/routes/adminRoutes.ts +++ b/packages/nextjs/src/routes/adminRoutes.ts @@ -53,11 +53,7 @@ type Call = (ctx: CallContext, req: AuthContext) => Promise; 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", @@ -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)], @@ -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", @@ -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", diff --git a/packages/nextjs/src/routes/proxyRoutes.ts b/packages/nextjs/src/routes/proxyRoutes.ts index d19f4ab..5ac44f1 100644 --- a/packages/nextjs/src/routes/proxyRoutes.ts +++ b/packages/nextjs/src/routes/proxyRoutes.ts @@ -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", + }, ]; /** diff --git a/packages/nextjs/tests/parity.test.js b/packages/nextjs/tests/parity.test.js index 863c7fb..ebf7aec 100644 --- a/packages/nextjs/tests/parity.test.js +++ b/packages/nextjs/tests/parity.test.js @@ -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", { @@ -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); + } + }); +});