Skip to content
Merged

Dev #38

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/sharp-breads-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@seamless-auth/express": patch
"@seamless-auth/core": patch
---

fix: updates core implementation to supply the authorization value during polling for magic links
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface PollMagicLinkConfirmationOptions {
cookieDomain?: string;
accessCookieName: string;
refreshCookieName: string;
serviceAuthorization?: string;
}

export interface PollMagicLinkConfirmationResult {
Expand All @@ -34,9 +35,9 @@ export async function pollMagicLinkConfirmationHandler(
method: "GET",
authorization: input.authorization,
forwardedClientIp: input.forwardedClientIp,
serviceAuthorization: opts.serviceAuthorization,
});

// 👇 Pending state (important for polling UX)
if (up.status === 204) {
return {
status: 204,
Expand All @@ -53,15 +54,13 @@ export async function pollMagicLinkConfirmationHandler(
};
}

// 👇 Web mode: auth server already handled cookies
if (!data?.token || !data?.refreshToken || !data?.sub) {
return {
status: up.status,
body: data,
};
}

// 🔐 Verify signed response (same as WebAuthn flow)
const verifiedAccessToken = await verifySignedAuthResponse(
data.token,
opts.authServerUrl,
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" },
});
});
});
Loading
Loading