From d5954eb6dd34405c1779b8f842e7da5df56745b9 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 30 May 2026 20:04:25 -0400 Subject: [PATCH 1/4] fix: correct and admin user delete impl, as it needs to preserve the body --- packages/core/src/handlers/admin.ts | 2 +- packages/core/tests/adminHandler.test.js | 52 +++++++++++++ packages/express/src/handlers/admin.ts | 1 + packages/express/tests/adminRoutes.test.js | 89 ++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 packages/core/tests/adminHandler.test.js create mode 100644 packages/express/tests/adminRoutes.test.js diff --git a/packages/core/src/handlers/admin.ts b/packages/core/src/handlers/admin.ts index cf54d30..296bb3b 100644 --- a/packages/core/src/handlers/admin.ts +++ b/packages/core/src/handlers/admin.ts @@ -68,7 +68,7 @@ export const getUsersHandler = (opts: BaseOpts) => export const createUserHandler = (opts: WithBody) => request("POST", "/admin/users", opts); -export const deleteUserHandler = (opts: BaseOpts) => +export const deleteUserHandler = (opts: WithBody) => request("DELETE", "/admin/users", opts); export const updateUserHandler = (userId: string, opts: WithBody) => diff --git a/packages/core/tests/adminHandler.test.js b/packages/core/tests/adminHandler.test.js new file mode 100644 index 0000000..1330fb1 --- /dev/null +++ b/packages/core/tests/adminHandler.test.js @@ -0,0 +1,52 @@ +import { jest } from "@jest/globals"; + +const authFetchMock = jest.fn(); + +jest.unstable_mockModule("../dist/authFetch.js", () => ({ + authFetch: authFetchMock, +})); + +const baseOptions = { + authServerUrl: "https://auth.example.com", + authorization: "Bearer service-token", + forwardedClientIp: "203.0.113.44", +}; + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +describe("admin handlers", () => { + beforeEach(() => authFetchMock.mockReset()); + + it("forwards the delete user request body", async () => { + const { deleteUserHandler } = await import("../dist/handlers/admin.js"); + + authFetchMock.mockResolvedValue( + createJsonResponse(200, { message: "Success" }), + ); + + const result = await deleteUserHandler({ + ...baseOptions, + body: { userId: "user-1" }, + }); + + expect(authFetchMock).toHaveBeenCalledWith( + "https://auth.example.com/admin/users", + { + method: "DELETE", + authorization: "Bearer service-token", + body: { userId: "user-1" }, + forwardedClientIp: "203.0.113.44", + }, + ); + expect(result).toEqual({ + status: 200, + body: { message: "Success" }, + }); + }); +}); diff --git a/packages/express/src/handlers/admin.ts b/packages/express/src/handlers/admin.ts index 81837e8..6754879 100644 --- a/packages/express/src/handlers/admin.ts +++ b/packages/express/src/handlers/admin.ts @@ -66,6 +66,7 @@ export const deleteUser = async ( authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), forwardedClientIp: buildForwardedClientIp(req), + body: req.body, } as any), ); diff --git a/packages/express/tests/adminRoutes.test.js b/packages/express/tests/adminRoutes.test.js new file mode 100644 index 0000000..8d32b59 --- /dev/null +++ b/packages/express/tests/adminRoutes.test.js @@ -0,0 +1,89 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +const { default: createSeamlessAuthServer } = await import("../dist/index.js"); + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function createAccessCookie(subject = "admin-123") { + const token = jwt.sign( + { + sub: subject, + roles: ["admin"], + sessionId: "session-123", + token: "access-token", + }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); + + return `seamless-access=${token}`; +} + +function createApp() { + const app = express(); + + app.use( + "/auth", + createSeamlessAuthServer({ + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret", + serviceSecret: "service-secret", + issuer: "https://api.example.com", + audience: "https://auth.example.com", + jwksKid: "dev-main", + }), + ); + + return app; +} + +describe("admin routes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("forwards the delete user body to the auth API", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { message: "Success" }), + ); + + const body = { userId: "user-1" }; + + const res = await request(createApp()) + .delete("/auth/admin/users") + .set("Cookie", createAccessCookie()) + .send(body); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: "Success" }); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/admin/users", + expect.objectContaining({ + method: "DELETE", + body: JSON.stringify(body), + headers: expect.objectContaining({ + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", + }), + }), + ); + }); +}); From 02a3deebfd498cbc849b8b536cbb00b91231f4d3 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 30 May 2026 23:07:40 -0400 Subject: [PATCH 2/4] fix: add internal auth events to our cookie reqs --- packages/core/src/ensureCookies.ts | 4 + packages/core/tests/ensureCookes.test.js | 27 ++++++ .../tests/internalMetricsRoutes.test.js | 89 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 packages/express/tests/internalMetricsRoutes.test.js diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index a68f540..e10c763 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -116,6 +116,10 @@ const COOKIE_REQUIREMENTS: Record< "/step-up/webauthn/start": { name: "accessCookieName", required: true }, "/step-up/webauthn/finish": { name: "accessCookieName", required: true }, "/internal/metrics/dashboard": { name: "accessCookieName", required: true }, + "/internal/auth-events/summary": { + name: "accessCookieName", + required: true, + }, "/internal/auth-events/timeseries": { name: "accessCookieName", required: true, diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index bd338f8..a3d9b4e 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -233,6 +233,33 @@ describe("ensureCookies", () => { }); }); + it("requires the access cookie for auth event summary metrics", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + verifyCookieJwtMock.mockReturnValue({ + sub: "admin-123", + token: "access-token", + sessionId: "session-123", + roles: ["admin"], + }); + + const result = await ensureCookies( + { + path: "/internal/auth-events/summary", + cookies: { access: "valid.access.jwt" }, + }, + BASE_OPTS, + ); + + expect(result.type).toBe("ok"); + expect(result.user).toEqual({ + sub: "admin-123", + sessionId: "session-123", + token: "access-token", + roles: ["admin"], + }); + }); + it("requires the access cookie for organization routes", async () => { const { ensureCookies } = await import("../dist/ensureCookies.js"); diff --git a/packages/express/tests/internalMetricsRoutes.test.js b/packages/express/tests/internalMetricsRoutes.test.js new file mode 100644 index 0000000..a9edbea --- /dev/null +++ b/packages/express/tests/internalMetricsRoutes.test.js @@ -0,0 +1,89 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +const { default: createSeamlessAuthServer } = await import("../dist/index.js"); + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function createAccessCookie(subject = "admin-123") { + const token = jwt.sign( + { + sub: subject, + roles: ["admin"], + sessionId: "session-123", + token: "access-token", + }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); + + return `seamless-access=${token}`; +} + +function createApp() { + const app = express(); + + app.use( + "/auth", + createSeamlessAuthServer({ + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret", + serviceSecret: "service-secret", + issuer: "https://api.example.com", + audience: "https://auth.example.com", + jwksKid: "dev-main", + }), + ); + + return app; +} + +describe("internal metrics routes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("forwards auth event summary requests with access identity", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + summary: [{ type: "login_success", count: 5 }], + }), + ); + + const res = await request(createApp()) + .get("/auth/internal/auth-events/summary") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + summary: [{ type: "login_success", count: 5 }], + }); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/internal/auth-events/summary", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", + }), + }), + ); + }); +}); From 46ed1bb9a0b0f802f7e00f952d4b82105e0ffa01 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 31 May 2026 08:36:16 -0400 Subject: [PATCH 3/4] fix: update seamless core cookie contract for ensure cookies, fail earlier --- packages/core/src/ensureCookies.ts | 153 ++++++++++++++--------- packages/core/tests/ensureCookes.test.js | 44 +++++++ 2 files changed, 137 insertions(+), 60 deletions(-) diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index e10c763..6b677ef 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -161,6 +161,78 @@ const COOKIE_REQUIREMENTS: Record< }, }; +async function refreshRequiredCookie( + cookieName: string, + refreshCookie: string | undefined, + opts: EnsureCookiesOptions, +): Promise { + if (!refreshCookie) { + return null; + } + + const refreshed = await refreshAccessToken(refreshCookie, { + authServerUrl: opts.authServerUrl, + cookieSecret: opts.cookieSecret, + serviceSecret: opts.serviceSecret, + issuer: opts.issuer, + audience: opts.audience, + keyId: opts.keyId, + forwardedClientIp: opts.forwardedClientIp, + }); + + if (!refreshed?.token) { + return { + type: "error", + status: 401, + error: "Refresh failed", + clearCookies: [ + cookieName, + opts.registrationCookieName, + opts.refreshCookieName, + ], + }; + } + + return { + type: "ok", + user: { + sub: refreshed.sub, + ...(refreshed.sessionId === undefined + ? {} + : { sessionId: refreshed.sessionId }), + token: refreshed.token, + roles: refreshed.roles, + }, + setCookies: [ + { + name: cookieName, + value: { + sub: refreshed.sub, + ...(refreshed.sessionId === undefined + ? {} + : { sessionId: refreshed.sessionId }), + token: refreshed.token, + roles: refreshed.roles, + email: refreshed.email, + phone: refreshed.phone, + organizationId: refreshed.organizationId ?? null, + }, + ttl: refreshed.ttl, + domain: opts.cookieDomain, + }, + { + name: opts.refreshCookieName, + value: { + sub: refreshed.sub, + refreshToken: refreshed.refreshToken, + }, + ttl: refreshed.refreshTtl, + domain: opts.cookieDomain, + }, + ], + }; +} + export async function ensureCookies( input: EnsureCookiesInput, opts: EnsureCookiesOptions, @@ -191,7 +263,9 @@ export async function ensureCookies( const refreshCookie = input.cookies[opts.refreshCookieName]; if (required && !cookieValue) { - if (!refreshCookie) { + const refreshed = await refreshRequiredCookie(cookieName, refreshCookie, opts); + + if (!refreshed) { return { type: "error", status: 400, @@ -199,76 +273,35 @@ export async function ensureCookies( }; } - const refreshed = await refreshAccessToken(refreshCookie, { - authServerUrl: opts.authServerUrl, - cookieSecret: opts.cookieSecret, - serviceSecret: opts.serviceSecret, - issuer: opts.issuer, - audience: opts.audience, - keyId: opts.keyId, - forwardedClientIp: opts.forwardedClientIp, - }); + return refreshed; + } - if (!refreshed?.token) { + if (cookieValue) { + const payload = verifyCookieJwt(cookieValue, opts.cookieSecret); + if (!payload) { return { type: "error", status: 401, - error: "Refresh failed", - clearCookies: [ - cookieName, - opts.registrationCookieName, - opts.refreshCookieName, - ], + error: `Invalid or expired ${cookieName} cookie`, }; } - return { - type: "ok", - user: { - sub: refreshed.sub, - ...(refreshed.sessionId === undefined - ? {} - : { sessionId: refreshed.sessionId }), - token: refreshed.token, - roles: refreshed.roles, - }, - setCookies: [ - { - name: cookieName, - value: { - sub: refreshed.sub, - ...(refreshed.sessionId === undefined - ? {} - : { sessionId: refreshed.sessionId }), - token: refreshed.token, - roles: refreshed.roles, - email: refreshed.email, - phone: refreshed.phone, - organizationId: refreshed.organizationId ?? null, - }, - ttl: refreshed.ttl, - domain: opts.cookieDomain, - }, - { - name: opts.refreshCookieName, - value: { - sub: refreshed.sub, - refreshToken: refreshed.refreshToken, - }, - ttl: refreshed.refreshTtl, - domain: opts.cookieDomain, - }, - ], - }; - } + const token = typeof payload.token === "string" ? payload.token : undefined; - if (cookieValue) { - const payload = verifyCookieJwt(cookieValue, opts.cookieSecret); - if (!payload) { + if (required && !token && cookieName === opts.accessCookieName) { + const refreshed = await refreshRequiredCookie(cookieName, refreshCookie, opts); + + if (refreshed) { + return refreshed; + } + } + + if (required && !token) { return { type: "error", status: 401, error: `Invalid or expired ${cookieName} cookie`, + clearCookies: [cookieName], }; } @@ -279,7 +312,7 @@ export async function ensureCookies( ...(typeof payload.sessionId === "string" ? { sessionId: payload.sessionId } : {}), - ...(typeof payload.token === "string" ? { token: payload.token } : {}), + ...(token === undefined ? {} : { token }), roles: payload.roles as string[] | undefined, }, }; diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index a3d9b4e..72ef5d4 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -47,6 +47,7 @@ describe("ensureCookies", () => { verifyCookieJwtMock.mockReturnValue({ sub: "user-123", + token: "access-token", roles: ["user"], }); @@ -61,6 +62,7 @@ describe("ensureCookies", () => { expect(result.type).toBe("ok"); expect(result.user).toEqual({ sub: "user-123", + token: "access-token", roles: ["user"], }); }); @@ -125,6 +127,44 @@ describe("ensureCookies", () => { expect(refreshCookie.name).toBe("refresh"); }); + it("refreshes old access cookies that do not contain a stored auth token", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + verifyCookieJwtMock.mockReturnValue({ + sub: "user-123", + sessionId: "session-123", + roles: ["user"], + }); + refreshAccessTokenMock.mockResolvedValue({ + sub: "user-123", + sessionId: "session-456", + token: "new-access", + refreshToken: "new-refresh", + roles: ["user"], + email: "test@example.com", + phone: "+14155552671", + organizationId: null, + ttl: 300, + refreshTtl: 3600, + }); + + const result = await ensureCookies( + { + path: "/internal/auth-events/summary", + cookies: { access: "old.access.jwt", refresh: "refresh.jwt" }, + }, + BASE_OPTS, + ); + + expect(result.type).toBe("ok"); + expect(result.user).toEqual({ + sub: "user-123", + sessionId: "session-456", + token: "new-access", + roles: ["user"], + }); + }); + it("returns error and clears cookies when refresh fails", async () => { const { ensureCookies } = await import("../dist/ensureCookies.js"); @@ -165,6 +205,7 @@ describe("ensureCookies", () => { verifyCookieJwtMock.mockReturnValue({ sub: "user-123", + token: "ephemeral-token", roles: ["user"], }); @@ -179,6 +220,7 @@ describe("ensureCookies", () => { expect(result.type).toBe("ok"); expect(result.user).toEqual({ sub: "user-123", + token: "ephemeral-token", roles: ["user"], }); }); @@ -188,6 +230,7 @@ describe("ensureCookies", () => { verifyCookieJwtMock.mockReturnValue({ sub: "user-123", + token: "ephemeral-token", roles: ["user"], }); @@ -202,6 +245,7 @@ describe("ensureCookies", () => { expect(result.type).toBe("ok"); expect(result.user).toEqual({ sub: "user-123", + token: "ephemeral-token", roles: ["user"], }); }); From 0e7b5c890f91ddeac2382bfc1bed7a98cc1fddd0 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 31 May 2026 23:50:00 -0400 Subject: [PATCH 4/4] chore: changset update --- .changeset/tough-nights-stop.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tough-nights-stop.md diff --git a/.changeset/tough-nights-stop.md b/.changeset/tough-nights-stop.md new file mode 100644 index 0000000..d8767e4 --- /dev/null +++ b/.changeset/tough-nights-stop.md @@ -0,0 +1,6 @@ +--- +"@seamless-auth/express": patch +"@seamless-auth/core": patch +--- + +Fixes for deleting users as an admin, and internal auth events summary route token handling