diff --git a/.changeset/soft-chefs-retire.md b/.changeset/soft-chefs-retire.md new file mode 100644 index 0000000..9083d56 --- /dev/null +++ b/.changeset/soft-chefs-retire.md @@ -0,0 +1,6 @@ +--- +"@seamless-auth/express": patch +"@seamless-auth/core": patch +--- + +Operational tidy work and extension of the logout functions for future use diff --git a/README.md b/README.md index a140611..fa7d5d5 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index 8761342..a68f540 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -32,6 +32,7 @@ export interface EnsureCookiesResult { user?: { sub: string; sessionId?: string; + token?: string; roles?: string[]; }; setCookies?: CookieInstruction[]; @@ -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 }, @@ -223,6 +225,7 @@ export async function ensureCookies( ...(refreshed.sessionId === undefined ? {} : { sessionId: refreshed.sessionId }), + token: refreshed.token, roles: refreshed.roles, }, setCookies: [ @@ -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, @@ -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, }, }; diff --git a/packages/core/src/handlers/admin.ts b/packages/core/src/handlers/admin.ts index 5b97b39..cf54d30 100644 --- a/packages/core/src/handlers/admin.ts +++ b/packages/core/src/handlers/admin.ts @@ -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); diff --git a/packages/core/src/handlers/finishLogin.ts b/packages/core/src/handlers/finishLogin.ts index 168f510..6e77a92 100644 --- a/packages/core/src/handlers/finishLogin.ts +++ b/packages/core/src/handlers/finishLogin.ts @@ -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, diff --git a/packages/core/src/handlers/finishRegister.ts b/packages/core/src/handlers/finishRegister.ts index 6ce9a35..721382f 100644 --- a/packages/core/src/handlers/finishRegister.ts +++ b/packages/core/src/handlers/finishRegister.ts @@ -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, diff --git a/packages/core/src/handlers/logout.ts b/packages/core/src/handlers/logout.ts index 55c2764..b7cb4ad 100644 --- a/packages/core/src/handlers/logout.ts +++ b/packages/core/src/handlers/logout.ts @@ -5,7 +5,9 @@ export interface LogoutOptions { accessCookieName: string; registrationCookieName: string; refreshCookieName: string; + authorization?: string; forwardedClientIp?: string; + scope?: LogoutScope; } export interface LogoutResult { @@ -13,16 +15,22 @@ export interface LogoutResult { clearCookies: string[]; } -export async function logoutHandler( - opts: LogoutOptions, -): Promise { - 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 { + 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, @@ -30,3 +38,11 @@ export async function logoutHandler( ], }; } + +export function logoutCurrentSessionHandler(opts: Omit) { + return logoutHandler({ ...opts, scope: "current_session" }); +} + +export function logoutAllSessionsHandler(opts: Omit) { + return logoutHandler({ ...opts, scope: "all_sessions" }); +} diff --git a/packages/core/src/handlers/oauthHandlers.ts b/packages/core/src/handlers/oauthHandlers.ts index bac3164..203c8ca 100644 --- a/packages/core/src/handlers/oauthHandlers.ts +++ b/packages/core/src/handlers/oauthHandlers.ts @@ -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, diff --git a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts index 2497b2f..4e1d765 100644 --- a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts +++ b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts @@ -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, diff --git a/packages/core/src/handlers/register.ts b/packages/core/src/handlers/register.ts index f0007a0..dc780e8 100644 --- a/packages/core/src/handlers/register.ts +++ b/packages/core/src/handlers/register.ts @@ -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, }, diff --git a/packages/core/src/handlers/switchOrganizationHandler.ts b/packages/core/src/handlers/switchOrganizationHandler.ts index 27e7fdd..ba38d44 100644 --- a/packages/core/src/handlers/switchOrganizationHandler.ts +++ b/packages/core/src/handlers/switchOrganizationHandler.ts @@ -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, diff --git a/packages/core/src/handlers/verifyLoginOtpHandler.ts b/packages/core/src/handlers/verifyLoginOtpHandler.ts index ef69426..1dc257c 100644 --- a/packages/core/src/handlers/verifyLoginOtpHandler.ts +++ b/packages/core/src/handlers/verifyLoginOtpHandler.ts @@ -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, diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index b079fe9..bd338f8 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -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); @@ -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", @@ -209,6 +211,7 @@ describe("ensureCookies", () => { verifyCookieJwtMock.mockReturnValue({ sub: "user-123", + token: "access-token", sessionId: "session-123", roles: ["user"], }); @@ -225,6 +228,7 @@ describe("ensureCookies", () => { expect(result.user).toEqual({ sub: "user-123", sessionId: "session-123", + token: "access-token", roles: ["user"], }); }); @@ -234,6 +238,7 @@ describe("ensureCookies", () => { verifyCookieJwtMock.mockReturnValue({ sub: "user-123", + token: "access-token", sessionId: "session-123", roles: ["user"], }); @@ -250,6 +255,7 @@ describe("ensureCookies", () => { expect(result.user).toEqual({ sub: "user-123", sessionId: "session-123", + token: "access-token", roles: ["user"], }); }); diff --git a/packages/core/tests/logoutHandler.test.js b/packages/core/tests/logoutHandler.test.js new file mode 100644 index 0000000..8225e03 --- /dev/null +++ b/packages/core/tests/logoutHandler.test.js @@ -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", + }), + ); + }); +}); diff --git a/packages/core/tests/oauthHandlers.test.js b/packages/core/tests/oauthHandlers.test.js index eba503f..0466d6d 100644 --- a/packages/core/tests/oauthHandlers.test.js +++ b/packages/core/tests/oauthHandlers.test.js @@ -104,6 +104,7 @@ describe("oauthHandlers", () => { value: expect.objectContaining({ sub: "user-123", sessionId: "session-123", + token: "access-token", organizationId: "org-123", }), }), diff --git a/packages/core/tests/registerHandler.test.js b/packages/core/tests/registerHandler.test.js new file mode 100644 index 0000000..c1402b7 --- /dev/null +++ b/packages/core/tests/registerHandler.test.js @@ -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, + }, + ]); + }); +}); diff --git a/packages/express/README.md b/packages/express/README.md index 8457e00..1ecbe87 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -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** @@ -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`. diff --git a/packages/express/package.json b/packages/express/package.json index 3670d27..8912130 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -60,4 +60,4 @@ "publishConfig": { "access": "public" } -} +} \ No newline at end of file diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 22c8929..62deeae 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -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")); @@ -461,6 +469,9 @@ export function createSeamlessAuthServer( r.patch("/admin/users/:userId", (req, res) => admin.updateUser(req, res, resolvedOpts), ); + r.post("/admin/users/:userId/recovery/device-replacement", (req, res) => + admin.recoverUserForDeviceReplacement(req, res, resolvedOpts), + ); r.get("/admin/users/:userId", (req, res) => admin.getUserDetail(req, res, resolvedOpts), ); @@ -541,6 +552,9 @@ export function createSeamlessAuthServer( r.get("/admin/sessions/:userId", (req, res) => admin.listUserSessions(req, res, resolvedOpts), ); + r.delete("/admin/sessions/by-id/:id", (req, res) => + admin.revokeUserSession(req, res, resolvedOpts), + ); r.delete("/admin/sessions/:userId/revoke-all", (req, res) => admin.revokeAllUserSessions(req, res, resolvedOpts), ); diff --git a/packages/express/src/handlers/admin.ts b/packages/express/src/handlers/admin.ts index ac082aa..81837e8 100644 --- a/packages/express/src/handlers/admin.ts +++ b/packages/express/src/handlers/admin.ts @@ -10,7 +10,9 @@ import { getCredentialCountHandler, listAllSessionsHandler, listUserSessionsHandler, + recoverUserForDeviceReplacementHandler, revokeAllUserSessionsHandler, + revokeUserSessionHandler, } from "@seamless-auth/core/handlers/admin"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; @@ -168,6 +170,20 @@ export const listUserSessions = async ( } as any), ); +export const revokeUserSession = async ( + req: Request, + res: Response, + opts: SeamlessAuthServerOptions, +) => + handle( + res, + await revokeUserSessionHandler(req.params.id as string, { + authServerUrl: opts.authServerUrl, + authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), + } as any), + ); + export const revokeAllUserSessions = async ( req: Request, res: Response, @@ -181,3 +197,18 @@ export const revokeAllUserSessions = async ( forwardedClientIp: buildForwardedClientIp(req), } as any), ); + +export const recoverUserForDeviceReplacement = async ( + req: Request, + res: Response, + opts: SeamlessAuthServerOptions, +) => + handle( + res, + await recoverUserForDeviceReplacementHandler(req.params.userId as string, { + authServerUrl: opts.authServerUrl, + authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), + body: req.body, + } as any), + ); diff --git a/packages/express/src/handlers/logout.ts b/packages/express/src/handlers/logout.ts index 93b665a..5558d98 100644 --- a/packages/express/src/handlers/logout.ts +++ b/packages/express/src/handlers/logout.ts @@ -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); diff --git a/packages/express/src/internal/buildAuthorization.ts b/packages/express/src/internal/buildAuthorization.ts index bf44866..0dd851e 100644 --- a/packages/express/src/internal/buildAuthorization.ts +++ b/packages/express/src/internal/buildAuthorization.ts @@ -4,26 +4,16 @@ import { SeamlessAuthServerOptions } from "../createServer"; export function buildServiceAuthorization( req: Request & { cookiePayload?: any }, - opts: SeamlessAuthServerOptions, + _opts?: SeamlessAuthServerOptions, ) { - const subject = req.cookiePayload?.sub || req.user?.sub; - - if (!subject) { - return undefined; - } - - const token = createServiceToken({ - subject, - issuer: opts.issuer, - audience: opts.audience, - serviceSecret: opts.serviceSecret, - keyId: opts.jwksKid || "dev-main", - }); + const token = req.cookiePayload?.token || req.user?.token; - return `Bearer ${token}`; + return typeof token === "string" ? `Bearer ${token}` : undefined; } -export function buildInternalServiceAuthorization(opts: SeamlessAuthServerOptions) { +export function buildInternalServiceAuthorization( + opts: SeamlessAuthServerOptions, +) { const token = createServiceToken({ subject: "seamless-auth-external-delivery", issuer: "seamless-portal-api", diff --git a/packages/express/tests/loginOtpRoutes.test.js b/packages/express/tests/loginOtpRoutes.test.js index 7b34277..814068b 100644 --- a/packages/express/tests/loginOtpRoutes.test.js +++ b/packages/express/tests/loginOtpRoutes.test.js @@ -14,10 +14,14 @@ function createJsonResponse(status, body) { } function createPreAuthCookie(subject = "user-123") { - const token = jwt.sign({ sub: subject }, "cookie-secret", { - algorithm: "HS256", - expiresIn: "300s", - }); + const token = jwt.sign( + { sub: subject, token: "ephemeral-token" }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); return `seamless-ephemeral=${token}`; } @@ -68,8 +72,36 @@ describe("login OTP routes", () => { expect.objectContaining({ method: "GET", headers: expect.objectContaining({ - Authorization: expect.stringMatching(/^Bearer /), - "x-seamless-service-token": expect.stringMatching(/^Bearer /), + Authorization: "Bearer ephemeral-token", + "x-seamless-service-token": "Bearer ephemeral-token", + }), + }), + ); + }); + + it("proxies registration phone OTP verification with the stored ephemeral token", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + message: "Phone verified successfully.", + }), + ); + + const body = { verificationToken: "123456" }; + + const res = await request(createApp()) + .post("/auth/otp/verify-phone-otp") + .set("Cookie", createPreAuthCookie()) + .send(body); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/otp/verify-phone-otp", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(body), + headers: expect.objectContaining({ + Authorization: "Bearer ephemeral-token", + "x-seamless-service-token": "Bearer ephemeral-token", }), }), ); diff --git a/packages/express/tests/logoutRoutes.test.js b/packages/express/tests/logoutRoutes.test.js new file mode 100644 index 0000000..a3c1a31 --- /dev/null +++ b/packages/express/tests/logoutRoutes.test.js @@ -0,0 +1,107 @@ +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 createResponse(status = 200) { + return { + ok: status >= 200 && status < 300, + status, + }; +} + +function createAccessCookie(subject = "user-123") { + const token = jwt.sign( + { + sub: subject, + roles: ["user"], + 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("logout routes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue(createResponse()); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("logs out the current session with DELETE /logout", async () => { + const res = await request(createApp()) + .delete("/auth/logout") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(204); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/logout", + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", + }), + }), + ); + }); + + it("keeps GET /logout as an all-session compatibility route", async () => { + const res = await request(createApp()) + .get("/auth/logout") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(204); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/logout/all", + expect.objectContaining({ + method: "DELETE", + }), + ); + }); + + it("logs out all sessions with DELETE /logout/all", async () => { + const res = await request(createApp()) + .delete("/auth/logout/all") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(204); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/logout/all", + expect.objectContaining({ + method: "DELETE", + }), + ); + }); +}); diff --git a/packages/express/tests/messagingDelivery.test.js b/packages/express/tests/messagingDelivery.test.js index 6f54a40..328be6c 100644 --- a/packages/express/tests/messagingDelivery.test.js +++ b/packages/express/tests/messagingDelivery.test.js @@ -14,10 +14,14 @@ function createJsonResponse(status, body) { } function createPreAuthCookie(subject = "user-123") { - const token = jwt.sign({ sub: subject }, "cookie-secret", { - algorithm: "HS256", - expiresIn: "300s", - }); + const token = jwt.sign( + { sub: subject, token: "ephemeral-token" }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); return `seamless-ephemeral=${token}`; } @@ -96,7 +100,7 @@ describe("messaging delivery routes", () => { headers: expect.objectContaining({ "Content-Type": "application/json", "x-seamless-auth-delivery-mode": "external", - Authorization: expect.stringMatching(/^Bearer /), + Authorization: "Bearer ephemeral-token", "x-seamless-service-token": expect.stringMatching(/^Bearer /), "x-seamless-client-ip": expect.any(String), }), diff --git a/packages/express/tests/organizationRoutes.test.js b/packages/express/tests/organizationRoutes.test.js index c376605..204cf48 100644 --- a/packages/express/tests/organizationRoutes.test.js +++ b/packages/express/tests/organizationRoutes.test.js @@ -15,7 +15,12 @@ function createJsonResponse(status, body) { function createAccessCookie(subject = "user-123") { const token = jwt.sign( - { sub: subject, roles: ["admin"], sessionId: "session-123" }, + { + sub: subject, + roles: ["admin"], + sessionId: "session-123", + token: "access-token", + }, "cookie-secret", { algorithm: "HS256", @@ -77,8 +82,8 @@ describe("organization proxy routes", () => { expect.objectContaining({ method: "GET", headers: expect.objectContaining({ - Authorization: expect.stringMatching(/^Bearer /), - "x-seamless-service-token": expect.stringMatching(/^Bearer /), + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", }), }), ); diff --git a/packages/express/tests/stepUpProxy.test.js b/packages/express/tests/stepUpProxy.test.js index c2e05e7..27488fd 100644 --- a/packages/express/tests/stepUpProxy.test.js +++ b/packages/express/tests/stepUpProxy.test.js @@ -14,19 +14,27 @@ function createJsonResponse(status, body) { } function createAccessCookie(subject = "user-123") { - const token = jwt.sign({ sub: subject, roles: ["user"] }, "cookie-secret", { - algorithm: "HS256", - expiresIn: "300s", - }); + const token = jwt.sign( + { sub: subject, roles: ["user"], token: "access-token" }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); return `seamless-access=${token}`; } function createRegistrationCookie(subject = "user-123") { - const token = jwt.sign({ sub: subject, roles: ["user"] }, "cookie-secret", { - algorithm: "HS256", - expiresIn: "300s", - }); + const token = jwt.sign( + { sub: subject, roles: ["user"], token: "ephemeral-token" }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); return `seamless-ephemeral=${token}`; } @@ -89,8 +97,8 @@ describe("step-up proxy routes", () => { expect.objectContaining({ method: "GET", headers: expect.objectContaining({ - Authorization: expect.stringMatching(/^Bearer /), - "x-seamless-service-token": expect.stringMatching(/^Bearer /), + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", }), }), ); @@ -131,8 +139,8 @@ describe("step-up proxy routes", () => { method: "POST", body: JSON.stringify(body), headers: expect.objectContaining({ - Authorization: expect.stringMatching(/^Bearer /), - "x-seamless-service-token": expect.stringMatching(/^Bearer /), + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", }), }), );