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

Operational tidy work and extension of the logout functions for future use
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ OAuth support is intentionally split across the same trust boundary as the rest
- the adapter stores only the resulting SeamlessAuth cookies

Provider access tokens are not stored by the adapter, returned to the frontend, or placed in cookies.
The adapter also does not handle provider client secrets; those remain on the Seamless Auth API host.

Mounted Express routes include:

Expand Down Expand Up @@ -166,6 +167,18 @@ Configure providers on the Seamless Auth API using `oauth_providers` and `LOGIN_
Each provider references its client secret by environment variable name, for example
`clientSecretEnv: "GOOGLE_CLIENT_SECRET"`.

## Admin Hardening Routes

The Express adapter exposes the v1 admin recovery and session hygiene routes under the mounted auth
path:

- `DELETE /auth/admin/sessions/by-id/:id`
- `DELETE /auth/admin/sessions/:userId/revoke-all`
- `POST /auth/admin/users/:userId/recovery/device-replacement`

The device-replacement route is enforced by the Seamless Auth API and requires a fresh step-up
session before sessions, passkeys, or TOTP credentials are reset.

---

## Extensibility
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface EnsureCookiesResult {
user?: {
sub: string;
sessionId?: string;
token?: string;
roles?: string[];
};
setCookies?: CookieInstruction[];
Expand Down Expand Up @@ -107,6 +108,7 @@ const COOKIE_REQUIREMENTS: Record<
name: "preAuthCookieName",
required: true,
},
"/logout/all": { name: "accessCookieName", required: true },
"/logout": { name: "accessCookieName", required: true },
"/users/me": { name: "accessCookieName", required: true },
"/organizations": { name: "accessCookieName", required: true },
Expand Down Expand Up @@ -223,6 +225,7 @@ export async function ensureCookies(
...(refreshed.sessionId === undefined
? {}
: { sessionId: refreshed.sessionId }),
token: refreshed.token,
roles: refreshed.roles,
},
setCookies: [
Expand All @@ -233,6 +236,7 @@ export async function ensureCookies(
...(refreshed.sessionId === undefined
? {}
: { sessionId: refreshed.sessionId }),
token: refreshed.token,
roles: refreshed.roles,
email: refreshed.email,
phone: refreshed.phone,
Expand Down Expand Up @@ -271,6 +275,7 @@ export async function ensureCookies(
...(typeof payload.sessionId === "string"
? { sessionId: payload.sessionId }
: {}),
...(typeof payload.token === "string" ? { token: payload.token } : {}),
roles: payload.roles as string[] | undefined,
},
};
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/handlers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,14 @@ export const listAllSessionsHandler = (opts: WithQuery) =>
export const listUserSessionsHandler = (userId: string, opts: BaseOpts) =>
request("GET", `/admin/sessions/${userId}`, opts);

export const revokeUserSessionHandler = (id: string, opts: BaseOpts) =>
request("DELETE", `/admin/sessions/by-id/${id}`, opts);

export const revokeAllUserSessionsHandler = (userId: string, opts: BaseOpts) =>
request("DELETE", `/admin/sessions/${userId}/revoke-all`, opts);

export const recoverUserForDeviceReplacementHandler = (
userId: string,
opts: WithBody,
) =>
request("POST", `/admin/users/${userId}/recovery/device-replacement`, opts);
1 change: 1 addition & 0 deletions packages/core/src/handlers/finishLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export async function finishLoginHandler(
value: {
sub: data.sub,
...(sessionId === undefined ? {} : { sessionId }),
token: data.token,
roles: data.roles,
email: data.email,
phone: data.phone,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/handlers/finishRegister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export async function finishRegisterHandler(
value: {
sub: data.sub,
...(sessionId === undefined ? {} : { sessionId }),
token: data.token,
roles: data.roles,
email: data.email,
phone: data.phone,
Expand Down
28 changes: 22 additions & 6 deletions packages/core/src/handlers/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,44 @@ export interface LogoutOptions {
accessCookieName: string;
registrationCookieName: string;
refreshCookieName: string;
authorization?: string;
forwardedClientIp?: string;
scope?: LogoutScope;
}

export interface LogoutResult {
status: number;
clearCookies: string[];
}

export async function logoutHandler(
opts: LogoutOptions,
): Promise<LogoutResult> {
await authFetch(`${opts.authServerUrl}/logout`, {
method: "GET",
export type LogoutScope = "current_session" | "all_sessions";

function getLogoutPath(scope: LogoutScope) {
return scope === "all_sessions" ? "/logout/all" : "/logout";
}

export async function logoutHandler(opts: LogoutOptions): Promise<LogoutResult> {
const scope = opts.scope ?? "all_sessions";
const upstream = await authFetch(`${opts.authServerUrl}${getLogoutPath(scope)}`, {
method: "DELETE",
authorization: opts.authorization,
forwardedClientIp: opts.forwardedClientIp,
});

return {
status: 204,
status: upstream.ok ? 204 : upstream.status,
clearCookies: [
opts.accessCookieName,
opts.registrationCookieName,
opts.refreshCookieName,
],
};
}

export function logoutCurrentSessionHandler(opts: Omit<LogoutOptions, "scope">) {
return logoutHandler({ ...opts, scope: "current_session" });
}

export function logoutAllSessionsHandler(opts: Omit<LogoutOptions, "scope">) {
return logoutHandler({ ...opts, scope: "all_sessions" });
}
1 change: 1 addition & 0 deletions packages/core/src/handlers/oauthHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export async function finishOAuthLoginHandler(
value: {
sub: data.sub,
...(sessionId === undefined ? {} : { sessionId }),
token: data.token,
roles: data.roles,
email: data.email,
phone: data.phone,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export async function pollMagicLinkConfirmationHandler(
value: {
sub: data.sub,
...(sessionId === undefined ? {} : { sessionId }),
token: data.token,
roles: data.roles,
email: data.email,
phone: data.phone,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/handlers/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export async function registerHandler(
setCookies: [
{
name: opts.registrationCookieName,
value: { sub: data.sub },
value: { sub: data.sub, token: data.token },
ttl: data.ttl,
domain: opts.cookieDomain,
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/handlers/switchOrganizationHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export async function switchOrganizationHandler(
value: {
sub: data.sub,
...(sessionId === undefined ? {} : { sessionId }),
token: data.token,
roles: data.roles,
email: data.email,
phone: data.phone,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/handlers/verifyLoginOtpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export async function verifyLoginOtpHandler(
value: {
sub: data.sub,
...(sessionId === undefined ? {} : { sessionId }),
token: data.token,
roles: data.roles,
email: data.email,
phone: data.phone,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/tests/ensureCookes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ describe("ensureCookies", () => {
expect(result.type).toBe("ok");
expect(result.user?.sub).toBe("user-123");
expect(result.user?.sessionId).toBe("session-123");
expect(result.user?.token).toBe("new-access");

expect(result.setCookies).toHaveLength(2);

Expand All @@ -115,6 +116,7 @@ describe("ensureCookies", () => {
expect(accessCookie.value).toEqual({
sub: "user-123",
sessionId: "session-123",
token: "new-access",
roles: ["user"],
email: "test@example.com",
phone: "+14155552671",
Expand Down Expand Up @@ -209,6 +211,7 @@ describe("ensureCookies", () => {

verifyCookieJwtMock.mockReturnValue({
sub: "user-123",
token: "access-token",
sessionId: "session-123",
roles: ["user"],
});
Expand All @@ -225,6 +228,7 @@ describe("ensureCookies", () => {
expect(result.user).toEqual({
sub: "user-123",
sessionId: "session-123",
token: "access-token",
roles: ["user"],
});
});
Expand All @@ -234,6 +238,7 @@ describe("ensureCookies", () => {

verifyCookieJwtMock.mockReturnValue({
sub: "user-123",
token: "access-token",
sessionId: "session-123",
roles: ["user"],
});
Expand All @@ -250,6 +255,7 @@ describe("ensureCookies", () => {
expect(result.user).toEqual({
sub: "user-123",
sessionId: "session-123",
token: "access-token",
roles: ["user"],
});
});
Expand Down
59 changes: 59 additions & 0 deletions packages/core/tests/logoutHandler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { jest } from "@jest/globals";

const authFetchMock = jest.fn();

jest.unstable_mockModule("../dist/authFetch.js", () => ({
authFetch: authFetchMock,
}));

const baseOptions = {
authServerUrl: "https://auth.example.com",
accessCookieName: "access",
registrationCookieName: "registration",
refreshCookieName: "refresh",
authorization: "Bearer service-token",
forwardedClientIp: "203.0.113.44",
};

describe("logoutHandler", () => {
beforeEach(() => authFetchMock.mockReset());

it("logs out all sessions by default for backward compatibility", async () => {
const { logoutHandler } = await import("../dist/handlers/logout.js");

authFetchMock.mockResolvedValue({ ok: true, status: 200 });

const result = await logoutHandler(baseOptions);

expect(authFetchMock).toHaveBeenCalledWith(
"https://auth.example.com/logout/all",
{
method: "DELETE",
authorization: "Bearer service-token",
forwardedClientIp: "203.0.113.44",
},
);
expect(result).toEqual({
status: 204,
clearCookies: ["access", "registration", "refresh"],
});
});

it("can log out only the current session", async () => {
const { logoutCurrentSessionHandler } = await import(
"../dist/handlers/logout.js"
);

authFetchMock.mockResolvedValue({ ok: true, status: 200 });

await logoutCurrentSessionHandler(baseOptions);

expect(authFetchMock).toHaveBeenCalledWith(
"https://auth.example.com/logout",
expect.objectContaining({
method: "DELETE",
authorization: "Bearer service-token",
}),
);
});
});
1 change: 1 addition & 0 deletions packages/core/tests/oauthHandlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ describe("oauthHandlers", () => {
value: expect.objectContaining({
sub: "user-123",
sessionId: "session-123",
token: "access-token",
organizationId: "org-123",
}),
}),
Expand Down
54 changes: 54 additions & 0 deletions packages/core/tests/registerHandler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { jest } from "@jest/globals";

function createJsonResponse(status, body) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}

describe("registerHandler", () => {
const originalFetch = global.fetch;

beforeEach(() => {
global.fetch = jest.fn();
});

afterEach(() => {
global.fetch = originalFetch;
});

it("stores the upstream ephemeral token in the registration cookie", async () => {
const { registerHandler } = await import("../dist/handlers/register.js");

global.fetch.mockResolvedValue(
createJsonResponse(200, {
message: "Success",
sub: "user-123",
token: "ephemeral-token",
ttl: 300,
}),
);

const result = await registerHandler(
{
body: { email: "user@example.com", phone: "+14155552671" },
},
{
authServerUrl: "https://auth.example.com",
registrationCookieName: "registration",
},
);

expect(result.status).toBe(200);
expect(result.setCookies).toEqual([
{
name: "registration",
value: { sub: "user-123", token: "ephemeral-token" },
ttl: 300,
domain: undefined,
},
]);
});
});
18 changes: 17 additions & 1 deletion packages/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ Routes include:
- `/auth/step-up/*`
- `/auth/registration/*`
- `/auth/users/me`
- `/auth/logout`
- `DELETE /auth/logout` for the current session
- `DELETE /auth/logout/all` for every session owned by the current user
- `GET /auth/logout` as a deprecated all-session compatibility route

**Options**

Expand Down Expand Up @@ -230,6 +232,20 @@ Provider access tokens are never stored in adapter cookies or returned to the fr

---

### Admin Hardening Routes

When mounted under `/auth`, the adapter proxies the admin hardening endpoints used by the
Seamless Auth dashboard:

- `DELETE /auth/admin/sessions/by-id/:id`
- `DELETE /auth/admin/sessions/:userId/revoke-all`
- `POST /auth/admin/users/:userId/recovery/device-replacement`

The device-replacement endpoint requires the current admin session to have fresh step-up
authentication in the Seamless Auth API.

---

### `requireAuth(options?)`

Express middleware that verifies a signed access cookie and attaches the decoded user payload to `req.user`.
Expand Down
Loading
Loading