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
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
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,
},
]);
});
});
4 changes: 3 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
2 changes: 1 addition & 1 deletion packages/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,4 @@
"publishConfig": {
"access": "public"
}
}
}
10 changes: 9 additions & 1 deletion packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,15 @@ export function createSeamlessAuthServer(
);

r.get("/users/me", (req, res) => me(req, res, resolvedOpts));
r.get("/logout", (req, res) => logout(req, res, resolvedOpts));
r.get("/logout", (req, res) =>
logout(req, res, resolvedOpts, "all_sessions"),
);
r.delete("/logout", (req, res) =>
logout(req, res, resolvedOpts, "current_session"),
);
r.delete("/logout/all", (req, res) =>
logout(req, res, resolvedOpts, "all_sessions"),
);

r.get("/organizations", proxyWithIdentity("organizations", "access", "GET"));
r.post("/organizations", proxyWithIdentity("organizations", "access"));
Expand Down
7 changes: 6 additions & 1 deletion packages/express/src/handlers/logout.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import { Request, Response } from "express";
import { logoutHandler } from "@seamless-auth/core/handlers/logout";
import type { LogoutScope } from "@seamless-auth/core/handlers/logout";
import { clearAllCookies } from "../internal/cookie";
import { buildForwardedClientIp } from "../internal/buildForwardedClientIp";
import { SeamlessAuthServerOptions } from "../createServer";
import { buildServiceAuthorization } from "../internal/buildAuthorization";

export async function logout(
req: Request,
res: Response,
opts: SeamlessAuthServerOptions,
scope: LogoutScope = "current_session",
) {
const result = await logoutHandler({
authServerUrl: opts.authServerUrl,
accessCookieName: opts.accessCookieName!,
registrationCookieName: opts.registrationCookieName!,
refreshCookieName: opts.refreshCookieName!,
authorization: buildServiceAuthorization(req, opts),
forwardedClientIp: buildForwardedClientIp(req),
} as any);
scope,
});

clearAllCookies(res, opts.cookieDomain || "", ...result.clearCookies);

Expand Down
Loading
Loading