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
6 changes: 6 additions & 0 deletions .changeset/tough-nights-stop.md
Original file line number Diff line number Diff line change
@@ -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
157 changes: 97 additions & 60 deletions packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -157,6 +161,78 @@ const COOKIE_REQUIREMENTS: Record<
},
};

async function refreshRequiredCookie(
cookieName: string,
refreshCookie: string | undefined,
opts: EnsureCookiesOptions,
): Promise<EnsureCookiesResult | null> {
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,
Expand Down Expand Up @@ -187,84 +263,45 @@ 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,
error: `Missing required cookie "${cookieName}"`,
};
}

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],
};
}

Expand All @@ -275,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,
},
};
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/handlers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
52 changes: 52 additions & 0 deletions packages/core/tests/adminHandler.test.js
Original file line number Diff line number Diff line change
@@ -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" },
});
});
});
71 changes: 71 additions & 0 deletions packages/core/tests/ensureCookes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe("ensureCookies", () => {

verifyCookieJwtMock.mockReturnValue({
sub: "user-123",
token: "access-token",
roles: ["user"],
});

Expand All @@ -61,6 +62,7 @@ describe("ensureCookies", () => {
expect(result.type).toBe("ok");
expect(result.user).toEqual({
sub: "user-123",
token: "access-token",
roles: ["user"],
});
});
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -165,6 +205,7 @@ describe("ensureCookies", () => {

verifyCookieJwtMock.mockReturnValue({
sub: "user-123",
token: "ephemeral-token",
roles: ["user"],
});

Expand All @@ -179,6 +220,7 @@ describe("ensureCookies", () => {
expect(result.type).toBe("ok");
expect(result.user).toEqual({
sub: "user-123",
token: "ephemeral-token",
roles: ["user"],
});
});
Expand All @@ -188,6 +230,7 @@ describe("ensureCookies", () => {

verifyCookieJwtMock.mockReturnValue({
sub: "user-123",
token: "ephemeral-token",
roles: ["user"],
});

Expand All @@ -202,6 +245,7 @@ describe("ensureCookies", () => {
expect(result.type).toBe("ok");
expect(result.user).toEqual({
sub: "user-123",
token: "ephemeral-token",
roles: ["user"],
});
});
Expand Down Expand Up @@ -233,6 +277,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");

Expand Down
Loading
Loading