From 30c7bc117251d29a8fc5dff737b60d43b21f5b2e Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 22 Apr 2026 19:47:56 -0400 Subject: [PATCH 01/24] fix: change refresh call to a post, not get --- packages/core/src/refreshAccessToken.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/refreshAccessToken.ts b/packages/core/src/refreshAccessToken.ts index ae12446..5729492 100644 --- a/packages/core/src/refreshAccessToken.ts +++ b/packages/core/src/refreshAccessToken.ts @@ -35,7 +35,7 @@ export async function refreshAccessToken( }); const response = await authFetch(`${opts.authServerUrl}/refresh`, { - method: "GET", + method: "POST", headers: { Authorization: `Bearer ${serviceToken}`, }, From b942dad0ad867709d27ba28e396bffa07f9e3a0d Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 22 Apr 2026 19:51:52 -0400 Subject: [PATCH 02/24] chore: version bump --- packages/core/package-lock.json | 4 ++-- packages/core/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index dad3125..d9db856 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/core", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/core", - "version": "0.4.0", + "version": "0.4.1", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/core/package.json b/packages/core/package.json index 7befdee..3144118 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/core", - "version": "0.4.0", + "version": "0.4.1", "description": "Framework-agnostic core authentication logic for SeamlessAuth", "license": "AGPL-3.0-only", "author": "Fells Code, LLC", @@ -53,4 +53,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} From 774f0658e7fb6c0a7a8f042190366827dbf493e0 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 22 Apr 2026 19:53:08 -0400 Subject: [PATCH 03/24] test: fix a test for refresh endpoint --- packages/core/tests/refreshAccessToken.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tests/refreshAccessToken.test.js b/packages/core/tests/refreshAccessToken.test.js index 426b423..c1fa16a 100644 --- a/packages/core/tests/refreshAccessToken.test.js +++ b/packages/core/tests/refreshAccessToken.test.js @@ -76,7 +76,7 @@ describe("refreshAccessToken", () => { expect(authFetchMock).toHaveBeenCalledWith( "https://auth.example.com/refresh", expect.objectContaining({ - method: "GET", + method: "POST", headers: { Authorization: "Bearer service.jwt" }, }), ); From cf6002d3f8948260f26f960cee2243b9da18f705 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 22 Apr 2026 20:00:28 -0400 Subject: [PATCH 04/24] chore: version package and seamless core --- packages/express/package-lock.json | 14 +++++++------- packages/express/package.json | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/express/package-lock.json b/packages/express/package-lock.json index 5a09c65..278326c 100644 --- a/packages/express/package-lock.json +++ b/packages/express/package-lock.json @@ -1,15 +1,15 @@ { "name": "@seamless-auth/express", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/express", - "version": "0.3.0", + "version": "0.3.1", "license": "AGPL-3.0-only", "dependencies": { - "@seamless-auth/core": "^0.4.0", + "@seamless-auth/core": "^0.4.1", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, @@ -1775,9 +1775,9 @@ ] }, "node_modules/@seamless-auth/core": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.0.tgz", - "integrity": "sha512-rP6ERKImV+OD9+WpVM2Gj74ov0EKhyqEdEwiAZXjg7fk4JJFa5r3L6QN7RweaTOGvkXx9PMIQ1/pYmGuhH3leg==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.1.tgz", + "integrity": "sha512-qXDrHcSKTqKPhIlsDuoai7Ei856zIWv29NemQsxhffRvh+JLhdfhsByvVhtJoNNkVHl5ckmsS79yhzpCCyU1DA==", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", @@ -6538,4 +6538,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/express/package.json b/packages/express/package.json index 33ac41a..d843e5b 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/express", - "version": "0.3.0", + "version": "0.3.1", "description": "Express adapter for Seamless Auth passwordless authentication", "license": "AGPL-3.0-only", "type": "module", @@ -39,7 +39,7 @@ "express": ">=4.18.0" }, "dependencies": { - "@seamless-auth/core": "^0.4.0", + "@seamless-auth/core": "^0.4.1", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, @@ -56,4 +56,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} From f9f97918b75e991b51becbfd2cf16082aafa88a9 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 22 Apr 2026 21:00:17 -0400 Subject: [PATCH 05/24] fix: referesh token should pass the refresh token --- packages/core/src/refreshAccessToken.ts | 12 +----------- packages/core/tests/refreshAccessToken.test.js | 2 +- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/core/src/refreshAccessToken.ts b/packages/core/src/refreshAccessToken.ts index 5729492..066f3a6 100644 --- a/packages/core/src/refreshAccessToken.ts +++ b/packages/core/src/refreshAccessToken.ts @@ -1,6 +1,5 @@ import { authFetch } from "./authFetch.js"; import { verifyRefreshCookie } from "./verifyRefreshCookie.js"; -import { createServiceToken } from "./createServiceToken.js"; export interface RefreshAccessTokenOptions { authServerUrl: string; @@ -25,19 +24,10 @@ export async function refreshAccessToken( const payload = verifyRefreshCookie(refreshCookie, opts.cookieSecret); if (!payload) return null; - const serviceToken = createServiceToken({ - issuer: opts.issuer, - audience: opts.audience, - subject: payload.sub, - refreshToken: payload.refreshToken, - serviceSecret: opts.serviceSecret, - keyId: opts.keyId, - }); - const response = await authFetch(`${opts.authServerUrl}/refresh`, { method: "POST", headers: { - Authorization: `Bearer ${serviceToken}`, + Authorization: `Bearer ${payload.refreshToken}`, }, }); diff --git a/packages/core/tests/refreshAccessToken.test.js b/packages/core/tests/refreshAccessToken.test.js index c1fa16a..0ed25e5 100644 --- a/packages/core/tests/refreshAccessToken.test.js +++ b/packages/core/tests/refreshAccessToken.test.js @@ -77,7 +77,7 @@ describe("refreshAccessToken", () => { "https://auth.example.com/refresh", expect.objectContaining({ method: "POST", - headers: { Authorization: "Bearer service.jwt" }, + headers: { Authorization: "Bearer refresh-token" }, }), ); }); From 3d52e7aa9ebfdc3c662d7b120a60f0b35ef142b4 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 22 Apr 2026 21:02:18 -0400 Subject: [PATCH 06/24] chore: version bump --- packages/core/package-lock.json | 4 ++-- packages/core/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index d9db856..4b24f49 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/core", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/core", - "version": "0.4.1", + "version": "0.4.2", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/core/package.json b/packages/core/package.json index 3144118..2e8020b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/core", - "version": "0.4.1", + "version": "0.4.2", "description": "Framework-agnostic core authentication logic for SeamlessAuth", "license": "AGPL-3.0-only", "author": "Fells Code, LLC", From 1fb41ce9b877d97e4af5f2d406ab30bcf6e8313b Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 22 Apr 2026 21:13:27 -0400 Subject: [PATCH 07/24] chore: core upgrade and version bump --- packages/express/package-lock.json | 12 ++++++------ packages/express/package.json | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/express/package-lock.json b/packages/express/package-lock.json index 278326c..c24d988 100644 --- a/packages/express/package-lock.json +++ b/packages/express/package-lock.json @@ -1,15 +1,15 @@ { "name": "@seamless-auth/express", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/express", - "version": "0.3.1", + "version": "0.3.2", "license": "AGPL-3.0-only", "dependencies": { - "@seamless-auth/core": "^0.4.1", + "@seamless-auth/core": "^0.4.2", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, @@ -1775,9 +1775,9 @@ ] }, "node_modules/@seamless-auth/core": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.1.tgz", - "integrity": "sha512-qXDrHcSKTqKPhIlsDuoai7Ei856zIWv29NemQsxhffRvh+JLhdfhsByvVhtJoNNkVHl5ckmsS79yhzpCCyU1DA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.2.tgz", + "integrity": "sha512-uGSxvsyAG4+HwCY5D2CT2sY014TXLQjyH3ccPRr8Sfj3xRyW6OwwMLps94/5z9kccSq1pe/ModFkmooxZ6lJig==", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/express/package.json b/packages/express/package.json index d843e5b..479c43d 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/express", - "version": "0.3.1", + "version": "0.3.2", "description": "Express adapter for Seamless Auth passwordless authentication", "license": "AGPL-3.0-only", "type": "module", @@ -39,7 +39,7 @@ "express": ">=4.18.0" }, "dependencies": { - "@seamless-auth/core": "^0.4.1", + "@seamless-auth/core": "^0.4.2", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, From 8f85d2643611988e01bbe6619fe902127b7b473a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 22 Apr 2026 22:40:59 -0400 Subject: [PATCH 08/24] fix: refresh token race condition --- packages/core/src/ensureCookies.ts | 4 +- packages/core/src/refreshAccessToken.ts | 33 ++++++++++--- packages/core/tests/ensureCookes.test.js | 8 +++ .../core/tests/refreshAccessToken.test.js | 49 +++++++++++++++++++ 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index 0dae9a3..b768afb 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -12,7 +12,7 @@ export interface CookiePayload { refreshToken?: string; roles?: string[]; email?: string; - phone?: string; + phone?: string | null; } export interface CookieInstruction { @@ -190,6 +190,8 @@ export async function ensureCookies( value: { sub: refreshed.sub, roles: refreshed.roles, + email: refreshed.email, + phone: refreshed.phone, }, ttl: refreshed.ttl, domain: opts.cookieDomain, diff --git a/packages/core/src/refreshAccessToken.ts b/packages/core/src/refreshAccessToken.ts index 066f3a6..9d94432 100644 --- a/packages/core/src/refreshAccessToken.ts +++ b/packages/core/src/refreshAccessToken.ts @@ -10,17 +10,29 @@ export interface RefreshAccessTokenOptions { keyId: string; } -export async function refreshAccessToken( - refreshCookie: string, - opts: RefreshAccessTokenOptions, -): Promise<{ +type RefreshAccessTokenResult = { sub: string; token: string; refreshToken: string; - roles: string[]; + roles?: string[]; + email?: string; + phone?: string | null; ttl: number; refreshTtl: number; -} | null> { +}; + +const inFlightRefreshes = new Map>(); + +export async function refreshAccessToken( + refreshCookie: string, + opts: RefreshAccessTokenOptions, +): Promise { + const existingRefresh = inFlightRefreshes.get(refreshCookie); + if (existingRefresh) { + return existingRefresh; + } + + const refreshPromise = (async () => { const payload = verifyRefreshCookie(refreshCookie, opts.cookieSecret); if (!payload) return null; @@ -34,4 +46,13 @@ export async function refreshAccessToken( if (!response.ok) return null; return response.json(); + })(); + + inFlightRefreshes.set(refreshCookie, refreshPromise); + + try { + return await refreshPromise; + } finally { + inFlightRefreshes.delete(refreshCookie); + } } diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index 283b577..6962d1e 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -88,6 +88,8 @@ describe("ensureCookies", () => { token: "new-access", refreshToken: "new-refresh", roles: ["user"], + email: "test@example.com", + phone: "+14155552671", ttl: 300, refreshTtl: 3600, }); @@ -107,6 +109,12 @@ describe("ensureCookies", () => { const [accessCookie, refreshCookie] = result.setCookies; expect(accessCookie.name).toBe("access"); + expect(accessCookie.value).toEqual({ + sub: "user-123", + roles: ["user"], + email: "test@example.com", + phone: "+14155552671", + }); expect(refreshCookie.name).toBe("refresh"); }); diff --git a/packages/core/tests/refreshAccessToken.test.js b/packages/core/tests/refreshAccessToken.test.js index 0ed25e5..e082552 100644 --- a/packages/core/tests/refreshAccessToken.test.js +++ b/packages/core/tests/refreshAccessToken.test.js @@ -58,6 +58,8 @@ describe("refreshAccessToken", () => { token: "new-access", refreshToken: "new-refresh", roles: ["user"], + email: "test@example.com", + phone: "+14155552671", ttl: 300, refreshTtl: 3600, }), @@ -73,6 +75,7 @@ describe("refreshAccessToken", () => { }); expect(result.token).toBe("new-access"); + expect(result.email).toBe("test@example.com"); expect(authFetchMock).toHaveBeenCalledWith( "https://auth.example.com/refresh", expect.objectContaining({ @@ -81,4 +84,50 @@ describe("refreshAccessToken", () => { }), ); }); + + it("deduplicates concurrent refresh calls for the same refresh cookie", async () => { + const { refreshAccessToken } = + await import("../dist/refreshAccessToken.js"); + + verifyRefreshCookieMock.mockReturnValue({ + sub: "user-123", + refreshToken: "refresh-token", + }); + + authFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + sub: "user-123", + token: "new-access", + refreshToken: "new-refresh", + roles: ["user"], + email: "test@example.com", + phone: "+14155552671", + ttl: 300, + refreshTtl: 3600, + }), + }); + + const [first, second] = await Promise.all([ + refreshAccessToken("good.cookie", { + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret", + serviceSecret: "service-secret", + issuer: "https://frontend.example.com", + audience: "https://auth.example.com", + keyId: "dev-main", + }), + refreshAccessToken("good.cookie", { + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret", + serviceSecret: "service-secret", + issuer: "https://frontend.example.com", + audience: "https://auth.example.com", + keyId: "dev-main", + }), + ]); + + expect(first).toEqual(second); + expect(authFetchMock).toHaveBeenCalledTimes(1); + }); }); From 6801422c80dc5ad6b8f178d6848ff7ea7d6a1c49 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 22 Apr 2026 22:47:30 -0400 Subject: [PATCH 09/24] chore: version bump --- packages/core/package-lock.json | 4 ++-- packages/core/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 4b24f49..f6693a3 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/core", - "version": "0.4.2", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/core", - "version": "0.4.2", + "version": "0.4.3", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/core/package.json b/packages/core/package.json index 2e8020b..83fa9f2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/core", - "version": "0.4.2", + "version": "0.4.3", "description": "Framework-agnostic core authentication logic for SeamlessAuth", "license": "AGPL-3.0-only", "author": "Fells Code, LLC", From 3624e217875c4977ec24f6eb27368509db1d0042 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 22 Apr 2026 22:52:07 -0400 Subject: [PATCH 10/24] chore: core upgrade and version bump --- packages/express/package-lock.json | 12 ++++++------ packages/express/package.json | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/express/package-lock.json b/packages/express/package-lock.json index c24d988..5293ce8 100644 --- a/packages/express/package-lock.json +++ b/packages/express/package-lock.json @@ -1,15 +1,15 @@ { "name": "@seamless-auth/express", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/express", - "version": "0.3.2", + "version": "0.3.3", "license": "AGPL-3.0-only", "dependencies": { - "@seamless-auth/core": "^0.4.2", + "@seamless-auth/core": "^0.4.3", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, @@ -1775,9 +1775,9 @@ ] }, "node_modules/@seamless-auth/core": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.2.tgz", - "integrity": "sha512-uGSxvsyAG4+HwCY5D2CT2sY014TXLQjyH3ccPRr8Sfj3xRyW6OwwMLps94/5z9kccSq1pe/ModFkmooxZ6lJig==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.3.tgz", + "integrity": "sha512-o3MWv4WFsQ7tpjgKG2ocNvvzCIrefGDv8pcnN1lIMBPfSSC/SPf0cuAOdV2yTDgu9FXsrrG8rT176gobBFTa+A==", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/express/package.json b/packages/express/package.json index 479c43d..f95ebc4 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/express", - "version": "0.3.2", + "version": "0.3.3", "description": "Express adapter for Seamless Auth passwordless authentication", "license": "AGPL-3.0-only", "type": "module", @@ -39,7 +39,7 @@ "express": ">=4.18.0" }, "dependencies": { - "@seamless-auth/core": "^0.4.2", + "@seamless-auth/core": "^0.4.3", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, From 0a2c41f1ef4663963c7a1f37b3fdb357dd1fe2c8 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 23 Apr 2026 11:08:40 -0400 Subject: [PATCH 11/24] fix: added referesh token cache to stop race conditions --- packages/core/src/refreshAccessToken.ts | 39 +++++++++++---- .../core/tests/refreshAccessToken.test.js | 49 ++++++++++++++++++- 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/packages/core/src/refreshAccessToken.ts b/packages/core/src/refreshAccessToken.ts index 9d94432..9dfeefc 100644 --- a/packages/core/src/refreshAccessToken.ts +++ b/packages/core/src/refreshAccessToken.ts @@ -22,25 +22,39 @@ type RefreshAccessTokenResult = { }; const inFlightRefreshes = new Map>(); +const recentRefreshResults = new Map< + string, + { result: RefreshAccessTokenResult; expiresAt: number } +>(); +const RECENT_REFRESH_RESULT_TTL_MS = 5000; export async function refreshAccessToken( refreshCookie: string, opts: RefreshAccessTokenOptions, ): Promise { + const now = Date.now(); + const recentRefresh = recentRefreshResults.get(refreshCookie); + if (recentRefresh && recentRefresh.expiresAt > now) { + return recentRefresh.result; + } + if (recentRefresh) { + recentRefreshResults.delete(refreshCookie); + } + const existingRefresh = inFlightRefreshes.get(refreshCookie); if (existingRefresh) { return existingRefresh; } const refreshPromise = (async () => { - const payload = verifyRefreshCookie(refreshCookie, opts.cookieSecret); - if (!payload) return null; - - const response = await authFetch(`${opts.authServerUrl}/refresh`, { - method: "POST", - headers: { - Authorization: `Bearer ${payload.refreshToken}`, - }, + const payload = verifyRefreshCookie(refreshCookie, opts.cookieSecret); + if (!payload) return null; + + const response = await authFetch(`${opts.authServerUrl}/refresh`, { + method: "POST", + headers: { + Authorization: `Bearer ${payload.refreshToken}`, + }, }); if (!response.ok) return null; @@ -51,7 +65,14 @@ export async function refreshAccessToken( inFlightRefreshes.set(refreshCookie, refreshPromise); try { - return await refreshPromise; + const result = await refreshPromise; + if (result) { + recentRefreshResults.set(refreshCookie, { + result, + expiresAt: Date.now() + RECENT_REFRESH_RESULT_TTL_MS, + }); + } + return result; } finally { inFlightRefreshes.delete(refreshCookie); } diff --git a/packages/core/tests/refreshAccessToken.test.js b/packages/core/tests/refreshAccessToken.test.js index e082552..275112f 100644 --- a/packages/core/tests/refreshAccessToken.test.js +++ b/packages/core/tests/refreshAccessToken.test.js @@ -109,7 +109,7 @@ describe("refreshAccessToken", () => { }); const [first, second] = await Promise.all([ - refreshAccessToken("good.cookie", { + refreshAccessToken("good-concurrent.cookie", { authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret", serviceSecret: "service-secret", @@ -117,7 +117,7 @@ describe("refreshAccessToken", () => { audience: "https://auth.example.com", keyId: "dev-main", }), - refreshAccessToken("good.cookie", { + refreshAccessToken("good-concurrent.cookie", { authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret", serviceSecret: "service-secret", @@ -130,4 +130,49 @@ describe("refreshAccessToken", () => { expect(first).toEqual(second); expect(authFetchMock).toHaveBeenCalledTimes(1); }); + + it("reuses the freshly-rotated result for an immediate follow-up call with the stale cookie", async () => { + const { refreshAccessToken } = + await import("../dist/refreshAccessToken.js"); + + verifyRefreshCookieMock.mockReturnValue({ + sub: "user-123", + refreshToken: "refresh-token", + }); + + authFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + sub: "user-123", + token: "new-access", + refreshToken: "new-refresh", + roles: ["user"], + email: "test@example.com", + phone: "+14155552671", + ttl: 300, + refreshTtl: 3600, + }), + }); + + const first = await refreshAccessToken("good-sequential.cookie", { + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret", + serviceSecret: "service-secret", + issuer: "https://frontend.example.com", + audience: "https://auth.example.com", + keyId: "dev-main", + }); + + const second = await refreshAccessToken("good-sequential.cookie", { + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret", + serviceSecret: "service-secret", + issuer: "https://frontend.example.com", + audience: "https://auth.example.com", + keyId: "dev-main", + }); + + expect(first).toEqual(second); + expect(authFetchMock).toHaveBeenCalledTimes(1); + }); }); From ec1e35965186c3f9dc22bdd5b33f11502586bc8a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 23 Apr 2026 18:02:44 -0400 Subject: [PATCH 12/24] chore: version bump --- packages/core/package-lock.json | 4 ++-- packages/core/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index f6693a3..191d472 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/core", - "version": "0.4.3", + "version": "0.4.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/core", - "version": "0.4.3", + "version": "0.4.4", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/core/package.json b/packages/core/package.json index 83fa9f2..7d11501 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/core", - "version": "0.4.3", + "version": "0.4.4", "description": "Framework-agnostic core authentication logic for SeamlessAuth", "license": "AGPL-3.0-only", "author": "Fells Code, LLC", From 94ebd97aacc0371deb685bf7861415f8ba4ce8ac Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 23 Apr 2026 18:04:34 -0400 Subject: [PATCH 13/24] chore: version bump --- packages/express/package-lock.json | 12 ++++++------ packages/express/package.json | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/express/package-lock.json b/packages/express/package-lock.json index 5293ce8..3be5f81 100644 --- a/packages/express/package-lock.json +++ b/packages/express/package-lock.json @@ -1,15 +1,15 @@ { "name": "@seamless-auth/express", - "version": "0.3.3", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/express", - "version": "0.3.3", + "version": "0.3.4", "license": "AGPL-3.0-only", "dependencies": { - "@seamless-auth/core": "^0.4.3", + "@seamless-auth/core": "^0.4.4", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, @@ -1775,9 +1775,9 @@ ] }, "node_modules/@seamless-auth/core": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.3.tgz", - "integrity": "sha512-o3MWv4WFsQ7tpjgKG2ocNvvzCIrefGDv8pcnN1lIMBPfSSC/SPf0cuAOdV2yTDgu9FXsrrG8rT176gobBFTa+A==", + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.4.tgz", + "integrity": "sha512-Y1ra5y7Oq/gbKU38q9fTkgf7DPKHONwX+BfpY+t+yVkjN+KWyI0l4JwWDY2jyKUE2LEqScE1gw84zOCOTw0GlQ==", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/express/package.json b/packages/express/package.json index f95ebc4..4c0c851 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/express", - "version": "0.3.3", + "version": "0.3.4", "description": "Express adapter for Seamless Auth passwordless authentication", "license": "AGPL-3.0-only", "type": "module", @@ -39,7 +39,7 @@ "express": ">=4.18.0" }, "dependencies": { - "@seamless-auth/core": "^0.4.3", + "@seamless-auth/core": "^0.4.4", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, From 72704dec00bc47e446617ce91333764c1b2a2423 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 25 Apr 2026 18:32:00 -0400 Subject: [PATCH 14/24] fix(core): add cookie paths to magic links --- packages/core/src/ensureCookies.ts | 8 +++++++ packages/core/tests/ensureCookes.test.js | 23 +++++++++++++++++++ .../express/tests/messagingDelivery.test.js | 18 ++++++++++++--- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index b768afb..6d633b9 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -78,6 +78,14 @@ const COOKIE_REQUIREMENTS: Record< name: "registrationCookieName", required: true, }, + "/magic-link": { + name: "preAuthCookieName", + required: true, + }, + "/magic-link/check": { + name: "preAuthCookieName", + required: true, + }, "/logout": { name: "accessCookieName", required: true }, "/users/me": { name: "accessCookieName", required: true }, "/internal/metrics/dashboard": { name: "accessCookieName", required: true }, diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index 6962d1e..fcd1207 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -152,4 +152,27 @@ describe("ensureCookies", () => { expect(result.type).toBe("error"); expect(result.status).toBe(401); }); + + it("requires the pre-auth cookie for magic-link continuation routes", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + verifyCookieJwtMock.mockReturnValue({ + sub: "user-123", + roles: ["user"], + }); + + const result = await ensureCookies( + { + path: "/magic-link", + cookies: { preauth: "valid.preauth.jwt" }, + }, + BASE_OPTS, + ); + + expect(result.type).toBe("ok"); + expect(result.user).toEqual({ + sub: "user-123", + roles: ["user"], + }); + }); }); diff --git a/packages/express/tests/messagingDelivery.test.js b/packages/express/tests/messagingDelivery.test.js index 07a86a5..2f8b8c1 100644 --- a/packages/express/tests/messagingDelivery.test.js +++ b/packages/express/tests/messagingDelivery.test.js @@ -1,5 +1,6 @@ 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"); @@ -12,6 +13,15 @@ function createJsonResponse(status, body) { }; } +function createPreAuthCookie(subject = "user-123") { + const token = jwt.sign({ sub: subject }, "cookie-secret", { + algorithm: "HS256", + expiresIn: "300s", + }); + + return `seamless-ephemeral=${token}`; +} + function createApp(emailTransport) { const app = express(); @@ -48,7 +58,7 @@ describe("messaging delivery routes", () => { global.fetch = originalFetch; }); - it("delivers magic links through the configured email transport for public requests", async () => { + it("delivers magic links through the configured email transport for pre-authenticated requests", async () => { const emailTransport = { name: "test-email", send: jest.fn().mockResolvedValue({ @@ -70,7 +80,9 @@ describe("messaging delivery routes", () => { }), ); - const res = await request(createApp(emailTransport)).get("/auth/magic-link"); + const res = await request(createApp(emailTransport)) + .get("/auth/magic-link") + .set("Cookie", createPreAuthCookie()); expect(res.status).toBe(200); expect(res.body).toEqual({ @@ -84,10 +96,10 @@ describe("messaging delivery routes", () => { headers: expect.objectContaining({ "Content-Type": "application/json", "x-seamless-auth-delivery-mode": "external", + Authorization: expect.stringMatching(/^Bearer /), }), }), ); - expect(global.fetch.mock.calls[0][1].headers.Authorization).toBeUndefined(); expect(emailTransport.send).toHaveBeenCalledWith( expect.objectContaining({ From 2fc8596b0a3da54e1908ea35a59677248a3c7575 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 25 Apr 2026 20:19:29 -0400 Subject: [PATCH 15/24] chore: bump package version for core --- packages/core/package-lock.json | 4 ++-- packages/core/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 191d472..7f8ab51 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/core", - "version": "0.4.4", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/core", - "version": "0.4.4", + "version": "0.4.5", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/core/package.json b/packages/core/package.json index 7d11501..018c92a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/core", - "version": "0.4.4", + "version": "0.4.5", "description": "Framework-agnostic core authentication logic for SeamlessAuth", "license": "AGPL-3.0-only", "author": "Fells Code, LLC", From e387ddc99618c95c53b96ffe78a4d2bb7cd43125 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 25 Apr 2026 20:26:41 -0400 Subject: [PATCH 16/24] chore: version bump --- packages/express/package-lock.json | 12 ++++++------ packages/express/package.json | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/express/package-lock.json b/packages/express/package-lock.json index 3be5f81..761e686 100644 --- a/packages/express/package-lock.json +++ b/packages/express/package-lock.json @@ -1,15 +1,15 @@ { "name": "@seamless-auth/express", - "version": "0.3.4", + "version": "0.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/express", - "version": "0.3.4", + "version": "0.3.5", "license": "AGPL-3.0-only", "dependencies": { - "@seamless-auth/core": "^0.4.4", + "@seamless-auth/core": "^0.4.5", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, @@ -1775,9 +1775,9 @@ ] }, "node_modules/@seamless-auth/core": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.4.tgz", - "integrity": "sha512-Y1ra5y7Oq/gbKU38q9fTkgf7DPKHONwX+BfpY+t+yVkjN+KWyI0l4JwWDY2jyKUE2LEqScE1gw84zOCOTw0GlQ==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.5.tgz", + "integrity": "sha512-/dgmaaqznW9yijSwCRBR0UrAoG4rlx9hjuJHzuXiiT/X3VAuuSr1INKaBhhWIFMu3+e5HovNLwvw0xCgaCkgLw==", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/express/package.json b/packages/express/package.json index 4c0c851..f032f8b 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/express", - "version": "0.3.4", + "version": "0.3.5", "description": "Express adapter for Seamless Auth passwordless authentication", "license": "AGPL-3.0-only", "type": "module", @@ -39,7 +39,7 @@ "express": ">=4.18.0" }, "dependencies": { - "@seamless-auth/core": "^0.4.4", + "@seamless-auth/core": "^0.4.5", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, From 50fcc25d37c126e51978e5c4b2aadf9251abc86e Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 3 May 2026 00:34:18 -0400 Subject: [PATCH 17/24] feat: forward ips through to auth server --- packages/core/src/authFetch.ts | 11 ++++ packages/core/src/ensureCookies.ts | 2 + packages/core/src/getSeamlessUser.ts | 6 +- packages/core/src/handlers/admin.ts | 2 + .../core/src/handlers/bootstrapAdminInvite.ts | 4 +- packages/core/src/handlers/finishLogin.ts | 2 + packages/core/src/handlers/finishRegister.ts | 2 + packages/core/src/handlers/internalMetrics.ts | 2 + packages/core/src/handlers/login.ts | 2 + packages/core/src/handlers/logout.ts | 2 + packages/core/src/handlers/me.ts | 2 + .../pollMagicLinkConfirmationHandler.ts | 2 + packages/core/src/handlers/register.ts | 2 + .../src/handlers/requestMagicLinkHandler.ts | 2 + .../core/src/handlers/requestOtpHandler.ts | 2 + packages/core/src/handlers/sessions.ts | 2 + packages/core/src/handlers/systemConfig.ts | 4 ++ .../src/handlers/verifyMagicLinkHandler.ts | 2 + packages/core/src/refreshAccessToken.ts | 22 +++++-- packages/core/tests/authFetch.test.js | 58 +++++++++++++++++++ .../core/tests/refreshAccessToken.test.js | 9 ++- packages/express/src/createServer.ts | 16 +++-- packages/express/src/getSeamlessUser.ts | 2 + packages/express/src/handlers/admin.ts | 34 +++++++---- .../src/handlers/bootstrapAdmininvite.ts | 4 +- packages/express/src/handlers/finishLogin.ts | 7 ++- .../express/src/handlers/finishRegister.ts | 4 +- .../express/src/handlers/internalMetrics.ts | 19 ++++-- packages/express/src/handlers/login.ts | 4 +- packages/express/src/handlers/logout.ts | 4 +- packages/express/src/handlers/me.ts | 4 +- .../src/handlers/pollMagicLinkConfirmation.ts | 6 +- packages/express/src/handlers/register.ts | 4 +- .../express/src/handlers/requestMagicLink.ts | 4 +- packages/express/src/handlers/requestOtp.ts | 4 +- packages/express/src/handlers/sessions.ts | 10 +++- packages/express/src/handlers/systemConfig.ts | 10 +++- .../src/internal/buildForwardedClientIp.ts | 5 ++ .../express/src/middleware/ensureCookies.ts | 4 +- .../express/tests/messagingDelivery.test.js | 6 +- 40 files changed, 242 insertions(+), 51 deletions(-) create mode 100644 packages/core/tests/authFetch.test.js create mode 100644 packages/express/src/internal/buildForwardedClientIp.ts diff --git a/packages/core/src/authFetch.ts b/packages/core/src/authFetch.ts index a8d95e7..4421c72 100644 --- a/packages/core/src/authFetch.ts +++ b/packages/core/src/authFetch.ts @@ -7,6 +7,8 @@ export interface AuthFetchOptions { headers?: Record; body?: unknown; authorization?: string; + serviceAuthorization?: string; + forwardedClientIp?: string; } export async function authFetch( @@ -17,6 +19,15 @@ export async function authFetch( "Content-Type": "application/json", ...options.headers, ...(options.authorization ? { Authorization: options.authorization } : {}), + ...((options.serviceAuthorization ?? options.authorization) + ? { + "x-seamless-service-token": + options.serviceAuthorization ?? options.authorization!, + } + : {}), + ...(options.forwardedClientIp + ? { "x-seamless-client-ip": options.forwardedClientIp } + : {}), }; return fetch(url, { diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index 6d633b9..d1f130c 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -46,6 +46,7 @@ export interface EnsureCookiesOptions { issuer: string; audience: string; keyId: string; + forwardedClientIp?: string; } const COOKIE_REQUIREMENTS: Record< @@ -171,6 +172,7 @@ export async function ensureCookies( issuer: opts.issuer, audience: opts.audience, keyId: opts.keyId, + forwardedClientIp: opts.forwardedClientIp, }); if (!refreshed?.token) { diff --git a/packages/core/src/getSeamlessUser.ts b/packages/core/src/getSeamlessUser.ts index 8d4057d..4a80d31 100644 --- a/packages/core/src/getSeamlessUser.ts +++ b/packages/core/src/getSeamlessUser.ts @@ -6,6 +6,7 @@ export interface GetSeamlessUserOptions { cookieSecret: string; authorization: string; cookieName?: string; + forwardedClientIp?: string; } /** @@ -32,9 +33,8 @@ export async function getSeamlessUser( const response = await authFetch(`${opts.authServerUrl}/users/me`, { method: "GET", - headers: { - Authorization: opts.authorization, - }, + authorization: opts.authorization, + forwardedClientIp: opts.forwardedClientIp, }); if (!response.ok) return null; diff --git a/packages/core/src/handlers/admin.ts b/packages/core/src/handlers/admin.ts index 1be1dd9..5b97b39 100644 --- a/packages/core/src/handlers/admin.ts +++ b/packages/core/src/handlers/admin.ts @@ -3,6 +3,7 @@ import { authFetch } from "../authFetch.js"; type BaseOpts = { authServerUrl: string; authorization?: string; + forwardedClientIp?: string; }; type WithQuery = BaseOpts & { @@ -42,6 +43,7 @@ async function request( method, authorization: opts.authorization, body: opts.body, + forwardedClientIp: opts.forwardedClientIp, }, ); diff --git a/packages/core/src/handlers/bootstrapAdminInvite.ts b/packages/core/src/handlers/bootstrapAdminInvite.ts index b127dd0..8aa7d74 100644 --- a/packages/core/src/handlers/bootstrapAdminInvite.ts +++ b/packages/core/src/handlers/bootstrapAdminInvite.ts @@ -5,6 +5,7 @@ export interface BootstrapAdminInviteOptions { email: string; authorization?: string; externalDelivery?: boolean; + forwardedClientIp?: string; } export interface BootstrapAdminInviteResult { @@ -24,8 +25,9 @@ export async function bootstrapAdminInviteHandler( `${opts.authServerUrl}/internal/bootstrap/admin-invite`, { method: "POST", + authorization: opts.authorization, + forwardedClientIp: opts.forwardedClientIp, headers: { - authorization: opts.authorization || "", ...(opts.externalDelivery ? { "x-seamless-auth-delivery-mode": "external", diff --git a/packages/core/src/handlers/finishLogin.ts b/packages/core/src/handlers/finishLogin.ts index 3ab046d..0c73bad 100644 --- a/packages/core/src/handlers/finishLogin.ts +++ b/packages/core/src/handlers/finishLogin.ts @@ -5,6 +5,7 @@ import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; export interface FinishLoginInput { body: unknown; authorization?: string; + forwardedClientIp?: string; } export interface FinishLoginOptions { @@ -34,6 +35,7 @@ export async function finishLoginHandler( method: "POST", body: input.body, authorization: input.authorization, + forwardedClientIp: input.forwardedClientIp, }); const data = await up.json(); diff --git a/packages/core/src/handlers/finishRegister.ts b/packages/core/src/handlers/finishRegister.ts index 667b5f3..caa6a76 100644 --- a/packages/core/src/handlers/finishRegister.ts +++ b/packages/core/src/handlers/finishRegister.ts @@ -6,6 +6,7 @@ export interface FinishRegisterInput { authorization?: string; headers?: Record; body: unknown; + forwardedClientIp?: string; } export interface FinishRegisterOptions { @@ -35,6 +36,7 @@ export async function finishRegisterHandler( authorization: input.authorization, headers: input.headers, body: input.body, + forwardedClientIp: input.forwardedClientIp, }); const data = await up.json(); diff --git a/packages/core/src/handlers/internalMetrics.ts b/packages/core/src/handlers/internalMetrics.ts index 4d95186..ef52a2e 100644 --- a/packages/core/src/handlers/internalMetrics.ts +++ b/packages/core/src/handlers/internalMetrics.ts @@ -3,6 +3,7 @@ import { authFetch } from "../authFetch.js"; type BaseOpts = { authServerUrl: string; authorization?: string; + forwardedClientIp?: string; }; type WithQuery = BaseOpts & { @@ -32,6 +33,7 @@ async function get(path: string, opts: WithQuery): Promise { { method: "GET", authorization: opts.authorization, + forwardedClientIp: opts.forwardedClientIp, }, ); diff --git a/packages/core/src/handlers/login.ts b/packages/core/src/handlers/login.ts index 2437cc8..13fbaba 100644 --- a/packages/core/src/handlers/login.ts +++ b/packages/core/src/handlers/login.ts @@ -10,6 +10,7 @@ export interface LoginOptions { authServerUrl: string; cookieDomain?: string; preAuthCookieName: string; + forwardedClientIp?: string; } export interface LoginResult { @@ -30,6 +31,7 @@ export async function loginHandler( const up = await authFetch(`${opts.authServerUrl}/login`, { method: "POST", body: input.body, + forwardedClientIp: opts.forwardedClientIp, }); const data = await up.json(); diff --git a/packages/core/src/handlers/logout.ts b/packages/core/src/handlers/logout.ts index 93b0fc9..55c2764 100644 --- a/packages/core/src/handlers/logout.ts +++ b/packages/core/src/handlers/logout.ts @@ -5,6 +5,7 @@ export interface LogoutOptions { accessCookieName: string; registrationCookieName: string; refreshCookieName: string; + forwardedClientIp?: string; } export interface LogoutResult { @@ -17,6 +18,7 @@ export async function logoutHandler( ): Promise { await authFetch(`${opts.authServerUrl}/logout`, { method: "GET", + forwardedClientIp: opts.forwardedClientIp, }); return { diff --git a/packages/core/src/handlers/me.ts b/packages/core/src/handlers/me.ts index 96f7e34..1a1e601 100644 --- a/packages/core/src/handlers/me.ts +++ b/packages/core/src/handlers/me.ts @@ -4,6 +4,7 @@ export interface MeOptions { authServerUrl: string; preAuthCookieName: string; authorization?: string; + forwardedClientIp?: string; } export interface MeResult { @@ -20,6 +21,7 @@ export async function meHandler(opts: MeOptions): Promise { const up = await authFetch(`${opts.authServerUrl}/users/me`, { method: "GET", authorization: opts.authorization, + forwardedClientIp: opts.forwardedClientIp, }); const data = await up.json(); diff --git a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts index ee16a5a..442ebe4 100644 --- a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts +++ b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts @@ -4,6 +4,7 @@ import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; export interface PollMagicLinkConfirmationInput { authorization?: string; + forwardedClientIp?: string; } export interface PollMagicLinkConfirmationOptions { @@ -32,6 +33,7 @@ export async function pollMagicLinkConfirmationHandler( const up = await authFetch(`${opts.authServerUrl}/magic-link/check`, { method: "GET", authorization: input.authorization, + forwardedClientIp: input.forwardedClientIp, }); // 👇 Pending state (important for polling UX) diff --git a/packages/core/src/handlers/register.ts b/packages/core/src/handlers/register.ts index 0f5f4b3..ba6c988 100644 --- a/packages/core/src/handlers/register.ts +++ b/packages/core/src/handlers/register.ts @@ -10,6 +10,7 @@ export interface RegisterOptions { cookieDomain?: string; registrationCookieName: string; externalDelivery?: boolean; + forwardedClientIp?: string; } export interface RegisterResult { @@ -31,6 +32,7 @@ export async function registerHandler( const up = await authFetch(`${opts.authServerUrl}/registration/register`, { method: "POST", body: input.body, + forwardedClientIp: opts.forwardedClientIp, ...(opts.externalDelivery ? { headers: { diff --git a/packages/core/src/handlers/requestMagicLinkHandler.ts b/packages/core/src/handlers/requestMagicLinkHandler.ts index 05c8e56..5f7f716 100644 --- a/packages/core/src/handlers/requestMagicLinkHandler.ts +++ b/packages/core/src/handlers/requestMagicLinkHandler.ts @@ -7,6 +7,7 @@ export interface RequestMagicLinkInput { export interface RequestMagicLinkOptions { authServerUrl: string; externalDelivery?: boolean; + forwardedClientIp?: string; } export interface RequestMagicLinkResult { @@ -22,6 +23,7 @@ export async function requestMagicLinkHandler( const up = await authFetch(`${opts.authServerUrl}/magic-link`, { method: "GET", authorization: input.authorization, + forwardedClientIp: opts.forwardedClientIp, ...(opts.externalDelivery ? { headers: { diff --git a/packages/core/src/handlers/requestOtpHandler.ts b/packages/core/src/handlers/requestOtpHandler.ts index 0ddd28f..dba6305 100644 --- a/packages/core/src/handlers/requestOtpHandler.ts +++ b/packages/core/src/handlers/requestOtpHandler.ts @@ -8,6 +8,7 @@ export interface RequestOtpInput { export interface RequestOtpOptions { authServerUrl: string; externalDelivery?: boolean; + forwardedClientIp?: string; } export interface RequestOtpResult { @@ -28,6 +29,7 @@ export async function requestOtpHandler( const up = await authFetch(`${opts.authServerUrl}/${path}`, { method: "GET", authorization: input.authorization, + forwardedClientIp: opts.forwardedClientIp, ...(opts.externalDelivery ? { headers: { diff --git a/packages/core/src/handlers/sessions.ts b/packages/core/src/handlers/sessions.ts index 878222e..253bc15 100644 --- a/packages/core/src/handlers/sessions.ts +++ b/packages/core/src/handlers/sessions.ts @@ -3,6 +3,7 @@ import { authFetch } from "../authFetch.js"; type BaseOpts = { authServerUrl: string; authorization?: string; + forwardedClientIp?: string; }; type Result = { @@ -19,6 +20,7 @@ async function request( const up = await authFetch(`${opts.authServerUrl}${path}`, { method, authorization: opts.authorization, + forwardedClientIp: opts.forwardedClientIp, }); const data = await up.json(); diff --git a/packages/core/src/handlers/systemConfig.ts b/packages/core/src/handlers/systemConfig.ts index ded230c..1c248db 100644 --- a/packages/core/src/handlers/systemConfig.ts +++ b/packages/core/src/handlers/systemConfig.ts @@ -3,6 +3,7 @@ import { authFetch } from "../authFetch.js"; export interface SystemConfigOptions { authServerUrl: string; authorization?: string; + forwardedClientIp?: string; } export interface SystemConfigResult { @@ -17,6 +18,7 @@ export async function getAvailableRolesHandler( const up = await authFetch(`${opts.authServerUrl}/system-config/roles`, { method: "GET", authorization: opts.authorization, + forwardedClientIp: opts.forwardedClientIp, }); const data = await up.json(); @@ -40,6 +42,7 @@ export async function getSystemConfigAdminHandler( const up = await authFetch(`${opts.authServerUrl}/system-config/admin`, { method: "GET", authorization: opts.authorization, + forwardedClientIp: opts.forwardedClientIp, }); const data = await up.json(); @@ -64,6 +67,7 @@ export async function updateSystemConfigHandler( method: "PATCH", authorization: opts.authorization, body: opts.payload, + forwardedClientIp: opts.forwardedClientIp, }); const data = await up.json(); diff --git a/packages/core/src/handlers/verifyMagicLinkHandler.ts b/packages/core/src/handlers/verifyMagicLinkHandler.ts index 0708df7..56e999b 100644 --- a/packages/core/src/handlers/verifyMagicLinkHandler.ts +++ b/packages/core/src/handlers/verifyMagicLinkHandler.ts @@ -6,6 +6,7 @@ export interface VerifyMagicLinkInput { export interface VerifyMagicLinkOptions { authServerUrl: string; + forwardedClientIp?: string; } export interface VerifyMagicLinkResult { @@ -22,6 +23,7 @@ export async function verifyMagicLinkHandler( `${opts.authServerUrl}/magic-link/verify/${input.token}`, { method: "GET", + forwardedClientIp: opts.forwardedClientIp, }, ); diff --git a/packages/core/src/refreshAccessToken.ts b/packages/core/src/refreshAccessToken.ts index 9dfeefc..ce23222 100644 --- a/packages/core/src/refreshAccessToken.ts +++ b/packages/core/src/refreshAccessToken.ts @@ -1,4 +1,5 @@ import { authFetch } from "./authFetch.js"; +import { createServiceToken } from "./createServiceToken.js"; import { verifyRefreshCookie } from "./verifyRefreshCookie.js"; export interface RefreshAccessTokenOptions { @@ -8,6 +9,7 @@ export interface RefreshAccessTokenOptions { issuer: string; audience: string; keyId: string; + forwardedClientIp?: string; } type RefreshAccessTokenResult = { @@ -49,17 +51,25 @@ export async function refreshAccessToken( const refreshPromise = (async () => { const payload = verifyRefreshCookie(refreshCookie, opts.cookieSecret); if (!payload) return null; + const serviceToken = createServiceToken({ + subject: payload.sub, + issuer: opts.issuer, + audience: opts.audience, + serviceSecret: opts.serviceSecret, + keyId: opts.keyId, + refreshToken: payload.refreshToken, + }); const response = await authFetch(`${opts.authServerUrl}/refresh`, { method: "POST", - headers: { - Authorization: `Bearer ${payload.refreshToken}`, - }, - }); + authorization: `Bearer ${payload.refreshToken}`, + serviceAuthorization: `Bearer ${serviceToken}`, + forwardedClientIp: opts.forwardedClientIp, + }); - if (!response.ok) return null; + if (!response.ok) return null; - return response.json(); + return response.json(); })(); inFlightRefreshes.set(refreshCookie, refreshPromise); diff --git a/packages/core/tests/authFetch.test.js b/packages/core/tests/authFetch.test.js new file mode 100644 index 0000000..d1f95c9 --- /dev/null +++ b/packages/core/tests/authFetch.test.js @@ -0,0 +1,58 @@ +import { jest } from "@jest/globals"; + +describe("authFetch", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ ok: true }); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("forwards trusted client IP and mirrors the service token header", async () => { + const { authFetch } = await import("../dist/authFetch.js"); + + await authFetch("https://auth.example.com/users/me", { + method: "GET", + authorization: "Bearer service-token", + forwardedClientIp: "203.0.113.44", + }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/users/me", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer service-token", + "x-seamless-service-token": "Bearer service-token", + "x-seamless-client-ip": "203.0.113.44", + }), + }), + ); + }); + + it("uses an explicit serviceAuthorization override when provided", async () => { + const { authFetch } = await import("../dist/authFetch.js"); + + await authFetch("https://auth.example.com/refresh", { + method: "POST", + authorization: "Bearer refresh-token", + serviceAuthorization: "Bearer service-token", + forwardedClientIp: "203.0.113.44", + }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/refresh", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer refresh-token", + "x-seamless-service-token": "Bearer service-token", + "x-seamless-client-ip": "203.0.113.44", + }), + }), + ); + }); +}); diff --git a/packages/core/tests/refreshAccessToken.test.js b/packages/core/tests/refreshAccessToken.test.js index 275112f..d4c4df0 100644 --- a/packages/core/tests/refreshAccessToken.test.js +++ b/packages/core/tests/refreshAccessToken.test.js @@ -76,11 +76,18 @@ describe("refreshAccessToken", () => { expect(result.token).toBe("new-access"); expect(result.email).toBe("test@example.com"); + expect(createServiceTokenMock).toHaveBeenCalledWith( + expect.objectContaining({ + subject: "user-123", + refreshToken: "refresh-token", + }), + ); expect(authFetchMock).toHaveBeenCalledWith( "https://auth.example.com/refresh", expect.objectContaining({ method: "POST", - headers: { Authorization: "Bearer refresh-token" }, + authorization: "Bearer refresh-token", + serviceAuthorization: "Bearer service.jwt", }), ); }); diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index c8c52b2..d4c9509 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -20,6 +20,7 @@ import { AuthFetchOptions, } from "@seamless-auth/core"; import { buildServiceAuthorization } from "./internal/buildAuthorization"; +import { buildForwardedClientIp } from "./internal/buildForwardedClientIp"; import { bootstrapAdminInvite } from "./handlers/bootstrapAdmininvite"; import { getAvailableRoles, @@ -194,14 +195,15 @@ export function createSeamlessAuthServer( } const authorization = buildServiceAuthorization(req, resolvedOpts); + const forwardedClientIp = buildForwardedClientIp(req); const options = method == "GET" - ? { method, authorization } - : { method, authorization, body: req.body }; + ? { method, authorization, forwardedClientIp } + : { method, authorization, forwardedClientIp, body: req.body }; const upstream = await authFetch( `${resolvedOpts.authServerUrl}/${path}`, - options, + options as any, ); const data = await upstream.json(); @@ -221,7 +223,8 @@ export function createSeamlessAuthServer( issuer: resolvedOpts.issuer, audience: resolvedOpts.authServerUrl, keyId: resolvedOpts.jwksKid, - } as EnsureCookiesOptions), + forwardedClientIp: undefined, + } as any), ); r.post( @@ -281,7 +284,10 @@ export function createSeamlessAuthServer( r.get("/magic-link/verify/:token", async (req, res) => { const upstream = await authFetch( `${resolvedOpts.authServerUrl}/magic-link/verify/${req.params.token}`, - { method: "GET" }, + { + method: "GET", + forwardedClientIp: buildForwardedClientIp(req), + } as any, ); const data = await upstream.json(); diff --git a/packages/express/src/getSeamlessUser.ts b/packages/express/src/getSeamlessUser.ts index 67af415..5718919 100644 --- a/packages/express/src/getSeamlessUser.ts +++ b/packages/express/src/getSeamlessUser.ts @@ -5,6 +5,7 @@ import { } from "@seamless-auth/core"; import { buildServiceAuthorization } from "./internal/buildAuthorization"; import { SeamlessAuthServerOptions } from "./createServer"; +import { buildForwardedClientIp } from "./internal/buildForwardedClientIp"; export async function getSeamlessUser( req: Request, @@ -17,5 +18,6 @@ export async function getSeamlessUser( cookieSecret: opts.cookieSecret, cookieName: opts.accessCookieName ?? "seamless-access", authorization, + forwardedClientIp: buildForwardedClientIp(req), } as GetSeamlessUserOptions); } diff --git a/packages/express/src/handlers/admin.ts b/packages/express/src/handlers/admin.ts index bbdcda7..ac082aa 100644 --- a/packages/express/src/handlers/admin.ts +++ b/packages/express/src/handlers/admin.ts @@ -14,6 +14,7 @@ import { } from "@seamless-auth/core/handlers/admin"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; function handle(res: Response, result: any) { @@ -33,7 +34,8 @@ export const getUsers = async ( await getUsersHandler({ authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), - }), + forwardedClientIp: buildForwardedClientIp(req), + } as any), ); export const createUser = async ( @@ -46,8 +48,9 @@ export const createUser = async ( await createUserHandler({ authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), body: req.body, - }), + } as any), ); export const deleteUser = async ( @@ -60,7 +63,8 @@ export const deleteUser = async ( await deleteUserHandler({ authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), - }), + forwardedClientIp: buildForwardedClientIp(req), + } as any), ); export const updateUser = async ( @@ -73,8 +77,9 @@ export const updateUser = async ( await updateUserHandler(req.params.userId as string, { authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), body: req.body, - }), + } as any), ); export const getUserDetail = async ( @@ -87,7 +92,8 @@ export const getUserDetail = async ( await getUserDetailHandler(req.params.userId as string, { authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), - }), + forwardedClientIp: buildForwardedClientIp(req), + } as any), ); export const getUserAnomalies = async ( @@ -100,7 +106,8 @@ export const getUserAnomalies = async ( await getUserAnomaliesHandler(req.params.userId as string, { authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), - }), + forwardedClientIp: buildForwardedClientIp(req), + } as any), ); export const getAuthEvents = async ( @@ -113,8 +120,9 @@ export const getAuthEvents = async ( await getAuthEventsHandler({ authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), query: req.query, - }), + } as any), ); export const getCredentialCount = async ( @@ -127,7 +135,8 @@ export const getCredentialCount = async ( await getCredentialCountHandler({ authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), - }), + forwardedClientIp: buildForwardedClientIp(req), + } as any), ); export const listAllSessions = async ( @@ -140,8 +149,9 @@ export const listAllSessions = async ( await listAllSessionsHandler({ authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), query: req.query, - }), + } as any), ); export const listUserSessions = async ( @@ -154,7 +164,8 @@ export const listUserSessions = async ( await listUserSessionsHandler(req.params.userId as string, { authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), - }), + forwardedClientIp: buildForwardedClientIp(req), + } as any), ); export const revokeAllUserSessions = async ( @@ -167,5 +178,6 @@ export const revokeAllUserSessions = async ( await revokeAllUserSessionsHandler(req.params.userId as string, { authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), - }), + forwardedClientIp: buildForwardedClientIp(req), + } as any), ); diff --git a/packages/express/src/handlers/bootstrapAdmininvite.ts b/packages/express/src/handlers/bootstrapAdmininvite.ts index 5f1c937..882c69c 100644 --- a/packages/express/src/handlers/bootstrapAdmininvite.ts +++ b/packages/express/src/handlers/bootstrapAdmininvite.ts @@ -1,5 +1,6 @@ import { Request, Response } from "express"; import { bootstrapAdminInviteHandler } from "@seamless-auth/core/handlers/bootstrapAdminInvite"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; @@ -13,7 +14,8 @@ export async function bootstrapAdminInvite( email: req.body.email, authorization: req.headers["authorization"], externalDelivery: Boolean(opts.messaging), - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); if (result.error) { return res.status(result.status).json({ error: result.error }); diff --git a/packages/express/src/handlers/finishLogin.ts b/packages/express/src/handlers/finishLogin.ts index 5e518ef..518ba62 100644 --- a/packages/express/src/handlers/finishLogin.ts +++ b/packages/express/src/handlers/finishLogin.ts @@ -2,6 +2,7 @@ import { Request, Response } from "express"; import { finishLoginHandler } from "@seamless-auth/core/handlers/finishLogin"; import { setSessionCookie } from "../internal/cookie"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; export async function finishLogin( @@ -21,7 +22,11 @@ export async function finishLogin( const authorization = buildServiceAuthorization(req, opts); const result = await finishLoginHandler( - { body: req.body, authorization }, + { + body: req.body, + authorization, + forwardedClientIp: buildForwardedClientIp(req), + } as any, { authServerUrl: opts.authServerUrl, cookieDomain: opts.cookieDomain, diff --git a/packages/express/src/handlers/finishRegister.ts b/packages/express/src/handlers/finishRegister.ts index 0b8c39e..6424b1e 100644 --- a/packages/express/src/handlers/finishRegister.ts +++ b/packages/express/src/handlers/finishRegister.ts @@ -2,6 +2,7 @@ import { Request, Response } from "express"; import { finishRegisterHandler } from "@seamless-auth/core/handlers/finishRegister"; import { setSessionCookie } from "../internal/cookie"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; import { verifyCookieJwt } from "@seamless-auth/core"; @@ -41,7 +42,8 @@ export async function finishRegister( body: req.body, authorization, headers, - }, + forwardedClientIp: buildForwardedClientIp(req), + } as any, { authServerUrl: opts.authServerUrl, cookieDomain: opts.cookieDomain, diff --git a/packages/express/src/handlers/internalMetrics.ts b/packages/express/src/handlers/internalMetrics.ts index 9116c2f..e861cc4 100644 --- a/packages/express/src/handlers/internalMetrics.ts +++ b/packages/express/src/handlers/internalMetrics.ts @@ -9,6 +9,7 @@ import { } from "@seamless-auth/core/handlers/internalMetrics"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; function handle(res: Response, result: any) { @@ -28,8 +29,9 @@ export async function getAuthEventSummary( const result = await getAuthEventSummaryHandler({ authServerUrl: opts.authServerUrl, authorization, + forwardedClientIp: buildForwardedClientIp(req), query: req.query as any, - }); + } as any); return handle(res, result); } @@ -44,8 +46,9 @@ export async function getAuthEventTimeseries( const result = await getAuthEventTimeseriesHandler({ authServerUrl: opts.authServerUrl, authorization, + forwardedClientIp: buildForwardedClientIp(req), query: req.query as any, - }); + } as any); return handle(res, result); } @@ -60,7 +63,8 @@ export async function getLoginStats( const result = await getLoginStatsHandler({ authServerUrl: opts.authServerUrl, authorization, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); return handle(res, result); } @@ -75,7 +79,8 @@ export async function getSecurityAnomalies( const result = await getSecurityAnomaliesHandler({ authServerUrl: opts.authServerUrl, authorization, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); return handle(res, result); } @@ -90,7 +95,8 @@ export async function getDashboardMetrics( const result = await getDashboardMetricsHandler({ authServerUrl: opts.authServerUrl, authorization, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); return handle(res, result); } @@ -105,7 +111,8 @@ export async function getGroupedEventSummary( const result = await getGroupedEventSummaryHandler({ authServerUrl: opts.authServerUrl, authorization, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); return handle(res, result); } diff --git a/packages/express/src/handlers/login.ts b/packages/express/src/handlers/login.ts index 9895d22..fb4c3e7 100644 --- a/packages/express/src/handlers/login.ts +++ b/packages/express/src/handlers/login.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { loginHandler } from "@seamless-auth/core/handlers/login"; import { setSessionCookie } from "../internal/cookie"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; export async function login( @@ -23,7 +24,8 @@ export async function login( authServerUrl: opts.authServerUrl, cookieDomain: opts.cookieDomain, preAuthCookieName: opts.preAuthCookieName!, - }, + forwardedClientIp: buildForwardedClientIp(req), + } as any, ); if (!cookieSigner.secret) { diff --git a/packages/express/src/handlers/logout.ts b/packages/express/src/handlers/logout.ts index 1dab1bd..93b665a 100644 --- a/packages/express/src/handlers/logout.ts +++ b/packages/express/src/handlers/logout.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { logoutHandler } from "@seamless-auth/core/handlers/logout"; import { clearAllCookies } from "../internal/cookie"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; export async function logout( @@ -13,7 +14,8 @@ export async function logout( accessCookieName: opts.accessCookieName!, registrationCookieName: opts.registrationCookieName!, refreshCookieName: opts.refreshCookieName!, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); clearAllCookies(res, opts.cookieDomain || "", ...result.clearCookies); diff --git a/packages/express/src/handlers/me.ts b/packages/express/src/handlers/me.ts index 01567c4..bd1a80a 100644 --- a/packages/express/src/handlers/me.ts +++ b/packages/express/src/handlers/me.ts @@ -2,6 +2,7 @@ import { Request, Response } from "express"; import { meHandler } from "@seamless-auth/core/handlers/me"; import { clearSessionCookie } from "../internal/cookie"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; export async function me( @@ -14,7 +15,8 @@ export async function me( authServerUrl: opts.authServerUrl, preAuthCookieName: opts.preAuthCookieName!, authorization, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); if (result.clearCookies) { for (const name of result.clearCookies) { diff --git a/packages/express/src/handlers/pollMagicLinkConfirmation.ts b/packages/express/src/handlers/pollMagicLinkConfirmation.ts index 4090b4f..d0b0951 100644 --- a/packages/express/src/handlers/pollMagicLinkConfirmation.ts +++ b/packages/express/src/handlers/pollMagicLinkConfirmation.ts @@ -2,6 +2,7 @@ import { Request, Response } from "express"; import { pollMagicLinkConfirmationHandler } from "@seamless-auth/core/handlers/pollMagicLinkConfirmationHandler"; import { setSessionCookie } from "../internal/cookie"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; export async function pollMagicLinkConfirmation( @@ -21,7 +22,10 @@ export async function pollMagicLinkConfirmation( const authorization = buildServiceAuthorization(req, opts); const result = await pollMagicLinkConfirmationHandler( - { authorization }, + { + authorization, + forwardedClientIp: buildForwardedClientIp(req), + } as any, { authServerUrl: opts.authServerUrl, cookieDomain: opts.cookieDomain, diff --git a/packages/express/src/handlers/register.ts b/packages/express/src/handlers/register.ts index 70c4437..a58da8f 100644 --- a/packages/express/src/handlers/register.ts +++ b/packages/express/src/handlers/register.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { registerHandler } from "@seamless-auth/core/handlers/register"; import { setSessionCookie } from "../internal/cookie"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; @@ -25,7 +26,8 @@ export async function register( cookieDomain: opts.cookieDomain, registrationCookieName: opts.registrationCookieName!, externalDelivery: Boolean(opts.messaging), - }, + forwardedClientIp: buildForwardedClientIp(req), + } as any, ); if (!cookieSigner.secret) { diff --git a/packages/express/src/handlers/requestMagicLink.ts b/packages/express/src/handlers/requestMagicLink.ts index bb90933..c48c1d1 100644 --- a/packages/express/src/handlers/requestMagicLink.ts +++ b/packages/express/src/handlers/requestMagicLink.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { requestMagicLinkHandler } from "@seamless-auth/core/handlers/requestMagicLinkHandler"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; @@ -16,7 +17,8 @@ export async function requestMagicLink( { authServerUrl: opts.authServerUrl, externalDelivery: Boolean(opts.messaging), - }, + forwardedClientIp: buildForwardedClientIp(req), + } as any, ); if (result.error) { diff --git a/packages/express/src/handlers/requestOtp.ts b/packages/express/src/handlers/requestOtp.ts index 703275e..4a7a528 100644 --- a/packages/express/src/handlers/requestOtp.ts +++ b/packages/express/src/handlers/requestOtp.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { requestOtpHandler } from "@seamless-auth/core/handlers/requestOtpHandler"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; @@ -18,7 +19,8 @@ export async function requestOtp( { authServerUrl: opts.authServerUrl, externalDelivery: Boolean(opts.messaging), - }, + forwardedClientIp: buildForwardedClientIp(req), + } as any, ); if (result.error) { diff --git a/packages/express/src/handlers/sessions.ts b/packages/express/src/handlers/sessions.ts index ff067d7..91e2ba6 100644 --- a/packages/express/src/handlers/sessions.ts +++ b/packages/express/src/handlers/sessions.ts @@ -6,6 +6,7 @@ import { } from "@seamless-auth/core/handlers/sessions"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; function handle(res: Response, result: any) { @@ -25,7 +26,8 @@ export async function listSessions( const result = await listSessionsHandler({ authServerUrl: opts.authServerUrl, authorization, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); return handle(res, result); } @@ -40,7 +42,8 @@ export async function revokeSession( const result = await revokeSessionHandler(req.params.id as string, { authServerUrl: opts.authServerUrl, authorization, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); return handle(res, result); } @@ -55,7 +58,8 @@ export async function revokeAllSessions( const result = await revokeAllSessionsHandler({ authServerUrl: opts.authServerUrl, authorization, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); return handle(res, result); } diff --git a/packages/express/src/handlers/systemConfig.ts b/packages/express/src/handlers/systemConfig.ts index d71eba9..1cb8ab8 100644 --- a/packages/express/src/handlers/systemConfig.ts +++ b/packages/express/src/handlers/systemConfig.ts @@ -6,6 +6,7 @@ import { } from "@seamless-auth/core/handlers/systemConfig"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; export async function getAvailableRoles( @@ -18,7 +19,8 @@ export async function getAvailableRoles( const result = await getAvailableRolesHandler({ authServerUrl: opts.authServerUrl, authorization, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); if (result.error) { return res.status(result.status).json({ error: result.error }); @@ -37,7 +39,8 @@ export async function getSystemConfigAdmin( const result = await getSystemConfigAdminHandler({ authServerUrl: opts.authServerUrl, authorization, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); if (result.error) { return res.status(result.status).json({ error: result.error }); @@ -57,7 +60,8 @@ export async function updateSystemConfig( authServerUrl: opts.authServerUrl, authorization, payload: req.body, - }); + forwardedClientIp: buildForwardedClientIp(req), + } as any); if (result.error) { return res.status(result.status).json({ error: result.error }); diff --git a/packages/express/src/internal/buildForwardedClientIp.ts b/packages/express/src/internal/buildForwardedClientIp.ts new file mode 100644 index 0000000..2064e75 --- /dev/null +++ b/packages/express/src/internal/buildForwardedClientIp.ts @@ -0,0 +1,5 @@ +import { Request } from "express"; + +export function buildForwardedClientIp(req: Request): string | undefined { + return req.ip || undefined; +} diff --git a/packages/express/src/middleware/ensureCookies.ts b/packages/express/src/middleware/ensureCookies.ts index 7e95956..67b52e0 100644 --- a/packages/express/src/middleware/ensureCookies.ts +++ b/packages/express/src/middleware/ensureCookies.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { ensureCookies, EnsureCookiesResult } from "@seamless-auth/core"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { setSessionCookie, clearAllCookies } from "../internal/cookie"; export interface EnsureCookiesMiddlewareOptions { @@ -59,7 +60,8 @@ export function createEnsureCookiesMiddleware( issuer: opts.issuer, audience: opts.audience, keyId: opts.keyId, - }, + forwardedClientIp: buildForwardedClientIp(req), + } as any, ); applyResult(res, req, result, opts, cookieSigner); diff --git a/packages/express/tests/messagingDelivery.test.js b/packages/express/tests/messagingDelivery.test.js index 2f8b8c1..51e0622 100644 --- a/packages/express/tests/messagingDelivery.test.js +++ b/packages/express/tests/messagingDelivery.test.js @@ -97,6 +97,8 @@ describe("messaging delivery routes", () => { "Content-Type": "application/json", "x-seamless-auth-delivery-mode": "external", Authorization: expect.stringMatching(/^Bearer /), + "x-seamless-service-token": expect.stringMatching(/^Bearer /), + "x-seamless-client-ip": expect.any(String), }), }), ); @@ -157,7 +159,9 @@ describe("messaging delivery routes", () => { method: "POST", headers: expect.objectContaining({ "Content-Type": "application/json", - authorization: "Bearer bootstrap-secret", + Authorization: "Bearer bootstrap-secret", + "x-seamless-service-token": "Bearer bootstrap-secret", + "x-seamless-client-ip": expect.any(String), "x-seamless-auth-delivery-mode": "external", }), }), From cd73aeeb1d5db12a1402d790e8db437b7d0ed94a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 4 May 2026 21:49:45 -0400 Subject: [PATCH 18/24] chore: version bump for core --- packages/core/package-lock.json | 4 ++-- packages/core/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 7f8ab51..61d1a4d 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/core", - "version": "0.4.5", + "version": "0.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/core", - "version": "0.4.5", + "version": "0.4.6", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/core/package.json b/packages/core/package.json index 018c92a..e7966f9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/core", - "version": "0.4.5", + "version": "0.4.6", "description": "Framework-agnostic core authentication logic for SeamlessAuth", "license": "AGPL-3.0-only", "author": "Fells Code, LLC", From b430fc6ba4c57e7a39b5fac950045b6cb207f301 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 4 May 2026 21:52:03 -0400 Subject: [PATCH 19/24] chore: version bump core package in express --- packages/express/package-lock.json | 8 ++++---- packages/express/package.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/express/package-lock.json b/packages/express/package-lock.json index 761e686..51aa50e 100644 --- a/packages/express/package-lock.json +++ b/packages/express/package-lock.json @@ -9,7 +9,7 @@ "version": "0.3.5", "license": "AGPL-3.0-only", "dependencies": { - "@seamless-auth/core": "^0.4.5", + "@seamless-auth/core": "^0.4.6", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, @@ -1775,9 +1775,9 @@ ] }, "node_modules/@seamless-auth/core": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.5.tgz", - "integrity": "sha512-/dgmaaqznW9yijSwCRBR0UrAoG4rlx9hjuJHzuXiiT/X3VAuuSr1INKaBhhWIFMu3+e5HovNLwvw0xCgaCkgLw==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.6.tgz", + "integrity": "sha512-9WiNEgkcV2LCi1RKJoS0S5l9rEk72HQzY0nbDsu2lYKzT6a3imZ1T6od9HRBchXsi/zUaKv/Jw6nEyLidityEw==", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/express/package.json b/packages/express/package.json index f032f8b..5eb4ff3 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -39,7 +39,7 @@ "express": ">=4.18.0" }, "dependencies": { - "@seamless-auth/core": "^0.4.5", + "@seamless-auth/core": "^0.4.6", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, @@ -56,4 +56,4 @@ "publishConfig": { "access": "public" } -} +} \ No newline at end of file From 167d6ae20ac9bf6d93e74d35b78683f8fb553b8c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 4 May 2026 21:52:23 -0400 Subject: [PATCH 20/24] chore: version bump express version --- packages/express/package-lock.json | 4 ++-- packages/express/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/express/package-lock.json b/packages/express/package-lock.json index 51aa50e..8551e17 100644 --- a/packages/express/package-lock.json +++ b/packages/express/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/express", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/express", - "version": "0.3.5", + "version": "0.3.6", "license": "AGPL-3.0-only", "dependencies": { "@seamless-auth/core": "^0.4.6", diff --git a/packages/express/package.json b/packages/express/package.json index 5eb4ff3..30b260b 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/express", - "version": "0.3.5", + "version": "0.3.6", "description": "Express adapter for Seamless Auth passwordless authentication", "license": "AGPL-3.0-only", "type": "module", @@ -56,4 +56,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} From 1b092f585b30678963dfa612f2025aa0d2491e43 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 16 May 2026 11:33:59 -0400 Subject: [PATCH 21/24] feat: implement step up authentication --- packages/core/src/createServiceToken.ts | 2 + packages/core/src/ensureCookies.ts | 14 ++ packages/core/src/handlers/finishLogin.ts | 6 + packages/core/src/handlers/finishRegister.ts | 4 + .../pollMagicLinkConfirmationHandler.ts | 6 + packages/core/src/refreshAccessToken.ts | 1 + packages/core/tests/ensureCookes.test.js | 28 ++++ packages/express/README.md | 1 + packages/express/src/createServer.ts | 33 +++-- packages/express/tests/stepUpProxy.test.js | 131 ++++++++++++++++++ 10 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 packages/express/tests/stepUpProxy.test.js diff --git a/packages/core/src/createServiceToken.ts b/packages/core/src/createServiceToken.ts index 3fedbe8..e0a7905 100644 --- a/packages/core/src/createServiceToken.ts +++ b/packages/core/src/createServiceToken.ts @@ -4,6 +4,7 @@ export interface ServiceTokenOptions { issuer: string; audience: string; subject: string; + sessionId?: string; refreshToken?: string; serviceSecret: string; keyId: string; @@ -15,6 +16,7 @@ export function createServiceToken(opts: ServiceTokenOptions): string { iss: opts.issuer, aud: opts.audience, sub: opts.subject, + ...(opts.sessionId === undefined ? {} : { sid: opts.sessionId }), refreshToken: opts.refreshToken, iat: Math.floor(Date.now() / 1000), }, diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index d1f130c..92198c1 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -8,6 +8,7 @@ export interface EnsureCookiesInput { export interface CookiePayload { sub: string; + sessionId?: string; token?: string; refreshToken?: string; roles?: string[]; @@ -28,6 +29,7 @@ export interface EnsureCookiesResult { error?: string; user?: { sub: string; + sessionId?: string; roles?: string[]; }; setCookies?: CookieInstruction[]; @@ -89,6 +91,9 @@ const COOKIE_REQUIREMENTS: Record< }, "/logout": { name: "accessCookieName", required: true }, "/users/me": { name: "accessCookieName", required: true }, + "/step-up/status": { name: "accessCookieName", required: true }, + "/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/timeseries": { name: "accessCookieName", @@ -192,6 +197,9 @@ export async function ensureCookies( type: "ok", user: { sub: refreshed.sub, + ...(refreshed.sessionId === undefined + ? {} + : { sessionId: refreshed.sessionId }), roles: refreshed.roles, }, setCookies: [ @@ -199,6 +207,9 @@ export async function ensureCookies( name: cookieName, value: { sub: refreshed.sub, + ...(refreshed.sessionId === undefined + ? {} + : { sessionId: refreshed.sessionId }), roles: refreshed.roles, email: refreshed.email, phone: refreshed.phone, @@ -233,6 +244,9 @@ export async function ensureCookies( type: "ok", user: { sub: payload.sub as string, + ...(typeof payload.sessionId === "string" + ? { sessionId: payload.sessionId } + : {}), roles: payload.roles as string[] | undefined, }, }; diff --git a/packages/core/src/handlers/finishLogin.ts b/packages/core/src/handlers/finishLogin.ts index 0c73bad..72df3f0 100644 --- a/packages/core/src/handlers/finishLogin.ts +++ b/packages/core/src/handlers/finishLogin.ts @@ -60,6 +60,11 @@ export async function finishLoginHandler( throw new Error("Signature mismatch with data payload"); } + const sessionId = + typeof verifiedAccessToken.sid === "string" + ? verifiedAccessToken.sid + : undefined; + return { status: 200, body: data, @@ -68,6 +73,7 @@ export async function finishLoginHandler( name: opts.accessCookieName, value: { sub: data.sub, + ...(sessionId === undefined ? {} : { sessionId }), 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 caa6a76..6ce9a35 100644 --- a/packages/core/src/handlers/finishRegister.ts +++ b/packages/core/src/handlers/finishRegister.ts @@ -61,6 +61,9 @@ export async function finishRegisterHandler( throw new Error("Signature mismatch with data payload"); } + const sessionId = + typeof verified.sid === "string" ? verified.sid : undefined; + return { status: 204, setCookies: [ @@ -68,6 +71,7 @@ export async function finishRegisterHandler( name: opts.accessCookieName, value: { sub: data.sub, + ...(sessionId === undefined ? {} : { sessionId }), 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 442ebe4..5a6fb66 100644 --- a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts +++ b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts @@ -75,6 +75,11 @@ export async function pollMagicLinkConfirmationHandler( throw new Error("Signature mismatch with data payload"); } + const sessionId = + typeof verifiedAccessToken.sid === "string" + ? verifiedAccessToken.sid + : undefined; + return { status: 200, body: data, @@ -83,6 +88,7 @@ export async function pollMagicLinkConfirmationHandler( name: opts.accessCookieName, value: { sub: data.sub, + ...(sessionId === undefined ? {} : { sessionId }), roles: data.roles, email: data.email, phone: data.phone, diff --git a/packages/core/src/refreshAccessToken.ts b/packages/core/src/refreshAccessToken.ts index ce23222..974d2ab 100644 --- a/packages/core/src/refreshAccessToken.ts +++ b/packages/core/src/refreshAccessToken.ts @@ -14,6 +14,7 @@ export interface RefreshAccessTokenOptions { type RefreshAccessTokenResult = { sub: string; + sessionId?: string; token: string; refreshToken: string; roles?: string[]; diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index fcd1207..8478fa6 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -85,6 +85,7 @@ describe("ensureCookies", () => { refreshAccessTokenMock.mockResolvedValue({ sub: "user-123", + sessionId: "session-123", token: "new-access", refreshToken: "new-refresh", roles: ["user"], @@ -104,6 +105,7 @@ describe("ensureCookies", () => { expect(result.type).toBe("ok"); expect(result.user?.sub).toBe("user-123"); + expect(result.user?.sessionId).toBe("session-123"); expect(result.setCookies).toHaveLength(2); @@ -111,6 +113,7 @@ describe("ensureCookies", () => { expect(accessCookie.name).toBe("access"); expect(accessCookie.value).toEqual({ sub: "user-123", + sessionId: "session-123", roles: ["user"], email: "test@example.com", phone: "+14155552671", @@ -175,4 +178,29 @@ describe("ensureCookies", () => { roles: ["user"], }); }); + + it("requires the access cookie for step-up routes", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + verifyCookieJwtMock.mockReturnValue({ + sub: "user-123", + sessionId: "session-123", + roles: ["user"], + }); + + const result = await ensureCookies( + { + path: "/step-up/webauthn/start", + cookies: { access: "valid.access.jwt" }, + }, + BASE_OPTS, + ); + + expect(result.type).toBe("ok"); + expect(result.user).toEqual({ + sub: "user-123", + sessionId: "session-123", + roles: ["user"], + }); + }); }); diff --git a/packages/express/README.md b/packages/express/README.md index e335526..c8fb9ad 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -136,6 +136,7 @@ Routes include: - `/auth/login/start` - `/auth/login/finish` - `/auth/webauthn/*` +- `/auth/step-up/*` - `/auth/registration/*` - `/auth/users/me` - `/auth/logout` diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index d4c9509..d4c9a9b 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -14,11 +14,7 @@ import { logout } from "./handlers/logout"; import { pollMagicLinkConfirmation } from "./handlers/pollMagicLinkConfirmation"; import { requestMagicLink } from "./handlers/requestMagicLink"; import * as admin from "./handlers/admin"; -import { - authFetch, - EnsureCookiesOptions, - AuthFetchOptions, -} from "@seamless-auth/core"; +import { authFetch, AuthFetchOptions } from "@seamless-auth/core"; import { buildServiceAuthorization } from "./internal/buildAuthorization"; import { buildForwardedClientIp } from "./internal/buildForwardedClientIp"; import { bootstrapAdminInvite } from "./handlers/bootstrapAdmininvite"; @@ -252,13 +248,11 @@ export function createSeamlessAuthServer( proxyWithIdentity("otp/verify-email-otp", "preAuth"), ); - r.get( - "/otp/generate-phone-otp", - (req, res) => requestOtp(req, res, resolvedOpts, "phone"), + r.get("/otp/generate-phone-otp", (req, res) => + requestOtp(req, res, resolvedOpts, "phone"), ); - r.get( - "/otp/generate-email-otp", - (req, res) => requestOtp(req, res, resolvedOpts, "email"), + r.get("/otp/generate-email-otp", (req, res) => + requestOtp(req, res, resolvedOpts, "email"), ); r.post("/login", (req, res) => login(req, res, resolvedOpts)); @@ -269,6 +263,19 @@ export function createSeamlessAuthServer( r.get("/users/me", (req, res) => me(req, res, resolvedOpts)); r.get("/logout", (req, res) => logout(req, res, resolvedOpts)); + r.get( + "/step-up/status", + proxyWithIdentity("step-up/status", "access", "GET"), + ); + r.post( + "/step-up/webauthn/start", + proxyWithIdentity("step-up/webauthn/start", "access"), + ); + r.post( + "/step-up/webauthn/finish", + proxyWithIdentity("step-up/webauthn/finish", "access"), + ); + r.post("/users/update", proxyWithIdentity("users/update", "access")); r.post( "/users/credentials", @@ -278,9 +285,7 @@ export function createSeamlessAuthServer( "/users/credentials", proxyWithIdentity("users/credentials", "access"), ); - r.get("/magic-link", (req, res) => - requestMagicLink(req, res, resolvedOpts), - ); + r.get("/magic-link", (req, res) => requestMagicLink(req, res, resolvedOpts)); r.get("/magic-link/verify/:token", async (req, res) => { const upstream = await authFetch( `${resolvedOpts.authServerUrl}/magic-link/verify/${req.params.token}`, diff --git a/packages/express/tests/stepUpProxy.test.js b/packages/express/tests/stepUpProxy.test.js new file mode 100644 index 0000000..669ebf6 --- /dev/null +++ b/packages/express/tests/stepUpProxy.test.js @@ -0,0 +1,131 @@ +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 createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function createAccessCookie(subject = "user-123") { + const token = jwt.sign({ sub: subject, roles: ["user"] }, "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("step-up proxy routes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("proxies step-up status with access identity", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + fresh: false, + method: null, + verifiedAt: null, + expiresAt: null, + maxAgeSeconds: 300, + }), + ); + + const res = await request(createApp()) + .get("/auth/step-up/status") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + fresh: false, + method: null, + verifiedAt: null, + expiresAt: null, + maxAgeSeconds: 300, + }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/step-up/status", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Bearer /), + "x-seamless-service-token": expect.stringMatching(/^Bearer /), + }), + }), + ); + }); + + it("proxies step-up finish with the WebAuthn assertion body", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + message: "Success", + fresh: true, + method: "webauthn", + verifiedAt: "2026-05-15T12:00:00.000Z", + expiresAt: "2026-05-15T12:05:00.000Z", + maxAgeSeconds: 300, + }), + ); + + const body = { assertionResponse: { id: "credential-id" } }; + + const res = await request(createApp()) + .post("/auth/step-up/webauthn/finish") + .set("Cookie", createAccessCookie()) + .send(body); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + message: "Success", + fresh: true, + method: "webauthn", + verifiedAt: "2026-05-15T12:00:00.000Z", + expiresAt: "2026-05-15T12:05:00.000Z", + maxAgeSeconds: 300, + }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/step-up/webauthn/finish", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(body), + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Bearer /), + "x-seamless-service-token": expect.stringMatching(/^Bearer /), + }), + }), + ); + }); +}); From 99a6cb78fe23cefa86049e7160643b186550ea23 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 16 May 2026 18:54:18 -0400 Subject: [PATCH 22/24] feat supprt PRF webauthn flows --- README.md | 2 + packages/express/src/createServer.ts | 25 ++++++++- packages/express/tests/stepUpProxy.test.js | 64 ++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 96d58bc..d163d03 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ It is also the natural initializer boundary for adopter-supplied auth messaging - custom auth-message handlers - optional auth template overrides +For WebAuthn PRF flows, the adapter proxies PRF registration query flags and assertion request bodies to the Seamless Auth API. PRF outputs remain browser-only and are never handled by the server adapter. + Location: ``` diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index d4c9a9b..597458c 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -76,6 +76,28 @@ export interface SeamlessAuthUser { iat?: number; exp?: number; } + +function buildProxyQueryString(queryInput: Request["query"]): string { + const query = new URLSearchParams(); + + for (const [key, value] of Object.entries(queryInput)) { + if (typeof value === "string") { + query.append(key, value); + continue; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === "string") { + query.append(key, item); + } + } + } + } + + return query.toString(); +} + /** * Creates an Express Router that proxies all authentication traffic to a Seamless Auth server. * @@ -197,8 +219,9 @@ export function createSeamlessAuthServer( ? { method, authorization, forwardedClientIp } : { method, authorization, forwardedClientIp, body: req.body }; + const queryString = buildProxyQueryString(req.query); const upstream = await authFetch( - `${resolvedOpts.authServerUrl}/${path}`, + `${resolvedOpts.authServerUrl}/${path}${queryString ? `?${queryString}` : ""}`, options as any, ); diff --git a/packages/express/tests/stepUpProxy.test.js b/packages/express/tests/stepUpProxy.test.js index 669ebf6..c2e05e7 100644 --- a/packages/express/tests/stepUpProxy.test.js +++ b/packages/express/tests/stepUpProxy.test.js @@ -22,6 +22,15 @@ function createAccessCookie(subject = "user-123") { return `seamless-access=${token}`; } +function createRegistrationCookie(subject = "user-123") { + const token = jwt.sign({ sub: subject, roles: ["user"] }, "cookie-secret", { + algorithm: "HS256", + expiresIn: "300s", + }); + + return `seamless-ephemeral=${token}`; +} + function createApp() { const app = express(); @@ -128,4 +137,59 @@ describe("step-up proxy routes", () => { }), ); }); + + it("proxies step-up start with PRF request body", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + challenge: "challenge", + extensions: { + prf: { + eval: { + first: "salt", + }, + }, + }, + }), + ); + + const body = { prf: { salt: "salt" }, credentialId: "credential-id" }; + + const res = await request(createApp()) + .post("/auth/step-up/webauthn/start") + .set("Cookie", createAccessCookie()) + .send(body); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/step-up/webauthn/start", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(body), + }), + ); + }); + + it("proxies passkey registration start with PRF query options", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + challenge: "challenge", + extensions: { + prf: {}, + }, + }), + ); + + const res = await request(createApp()) + .get("/auth/webAuthn/register/start") + .query({ requirePrf: "true" }) + .set("Cookie", createRegistrationCookie()); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/webAuthn/register/start?requirePrf=true", + expect.objectContaining({ + method: "GET", + }), + ); + }); }); From ceb085675ee5019e2eb48b200ae2c460f78bb33c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 17 May 2026 15:20:39 -0400 Subject: [PATCH 23/24] feat: otp login and login methods --- packages/core/package-lock.json | 4 +- packages/core/package.json | 2 +- packages/core/src/ensureCookies.ts | 16 +++ packages/core/src/handlers/login.ts | 24 ++++- .../core/src/handlers/requestOtpHandler.ts | 12 ++- .../src/handlers/verifyLoginOtpHandler.ts | 101 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/tests/ensureCookes.test.js | 23 ++++ packages/core/tests/loginHandler.test.js | 83 ++++++++++++++ packages/core/tests/requestOtpHandler.test.js | 58 ++++++++++ packages/express/package-lock.json | 6 +- packages/express/package.json | 4 +- packages/express/src/createServer.ts | 13 +++ packages/express/src/handlers/login.ts | 4 + packages/express/src/handlers/requestOtp.ts | 2 + .../express/src/handlers/verifyLoginOtp.ts | 62 +++++++++++ packages/express/tests/loginOtpRoutes.test.js | 77 +++++++++++++ packages/express/tsconfig.json | 5 + 18 files changed, 484 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/handlers/verifyLoginOtpHandler.ts create mode 100644 packages/core/tests/loginHandler.test.js create mode 100644 packages/core/tests/requestOtpHandler.test.js create mode 100644 packages/express/src/handlers/verifyLoginOtp.ts create mode 100644 packages/express/tests/loginOtpRoutes.test.js diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 61d1a4d..91767e9 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/core", - "version": "0.4.6", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/core", - "version": "0.4.6", + "version": "0.5.0", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3", diff --git a/packages/core/package.json b/packages/core/package.json index e7966f9..27fb346 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/core", - "version": "0.4.6", + "version": "0.5.0", "description": "Framework-agnostic core authentication logic for SeamlessAuth", "license": "AGPL-3.0-only", "author": "Fells Code, LLC", diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index 92198c1..55a3bfb 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -81,6 +81,22 @@ const COOKIE_REQUIREMENTS: Record< name: "registrationCookieName", required: true, }, + "/otp/verify-login-email-otp": { + name: "preAuthCookieName", + required: true, + }, + "/otp/verify-login-phone-otp": { + name: "preAuthCookieName", + required: true, + }, + "/otp/generate-login-email-otp": { + name: "preAuthCookieName", + required: true, + }, + "/otp/generate-login-phone-otp": { + name: "preAuthCookieName", + required: true, + }, "/magic-link": { name: "preAuthCookieName", required: true, diff --git a/packages/core/src/handlers/login.ts b/packages/core/src/handlers/login.ts index 13fbaba..147812e 100644 --- a/packages/core/src/handlers/login.ts +++ b/packages/core/src/handlers/login.ts @@ -15,7 +15,12 @@ export interface LoginOptions { export interface LoginResult { status: number; - error?: string; + body?: { + message?: string; + identifierType?: string; + loginMethods?: string[]; + }; + error?: unknown; setCookies?: { name: string; value: CookiePayload; @@ -56,8 +61,23 @@ export async function loginHandler( throw new Error("Signature mismatch with data payload"); } + const body = { + ...(typeof data.message === "string" ? { message: data.message } : {}), + ...(typeof data.identifierType === "string" + ? { identifierType: data.identifierType } + : {}), + ...(Array.isArray(data.loginMethods) + ? { + loginMethods: data.loginMethods.filter( + (item: unknown) => typeof item === "string", + ), + } + : {}), + }; + return { - status: 204, + status: up.status, + body, setCookies: [ { name: opts.preAuthCookieName, diff --git a/packages/core/src/handlers/requestOtpHandler.ts b/packages/core/src/handlers/requestOtpHandler.ts index dba6305..939a2bb 100644 --- a/packages/core/src/handlers/requestOtpHandler.ts +++ b/packages/core/src/handlers/requestOtpHandler.ts @@ -2,6 +2,7 @@ import { authFetch } from "../authFetch.js"; export interface RequestOtpInput { authorization?: string; + flow?: "registration" | "login"; kind: "email" | "phone"; } @@ -21,10 +22,15 @@ export async function requestOtpHandler( input: RequestOtpInput, opts: RequestOtpOptions, ): Promise { + const flow = input.flow ?? "registration"; const path = - input.kind === "email" - ? "otp/generate-email-otp" - : "otp/generate-phone-otp"; + flow === "login" + ? input.kind === "email" + ? "otp/generate-login-email-otp" + : "otp/generate-login-phone-otp" + : input.kind === "email" + ? "otp/generate-email-otp" + : "otp/generate-phone-otp"; const up = await authFetch(`${opts.authServerUrl}/${path}`, { method: "GET", diff --git a/packages/core/src/handlers/verifyLoginOtpHandler.ts b/packages/core/src/handlers/verifyLoginOtpHandler.ts new file mode 100644 index 0000000..925e8ca --- /dev/null +++ b/packages/core/src/handlers/verifyLoginOtpHandler.ts @@ -0,0 +1,101 @@ +import { authFetch } from "../authFetch.js"; +import type { CookiePayload } from "../ensureCookies.js"; +import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js"; + +export interface VerifyLoginOtpInput { + body: unknown; + authorization?: string; + forwardedClientIp?: string; + kind: "email" | "phone"; +} + +export interface VerifyLoginOtpOptions { + authServerUrl: string; + cookieDomain?: string; + accessCookieName: string; + refreshCookieName: string; +} + +export interface VerifyLoginOtpResult { + status: number; + body?: unknown; + error?: unknown; + setCookies?: { + name: string; + value: CookiePayload; + ttl: number; + domain?: string; + }[]; +} + +export async function verifyLoginOtpHandler( + input: VerifyLoginOtpInput, + opts: VerifyLoginOtpOptions, +): Promise { + const path = + input.kind === "email" + ? "otp/verify-login-email-otp" + : "otp/verify-login-phone-otp"; + + const up = await authFetch(`${opts.authServerUrl}/${path}`, { + method: "POST", + body: input.body, + authorization: input.authorization, + forwardedClientIp: input.forwardedClientIp, + }); + + const data = await up.json(); + + if (!up.ok) { + return { + status: up.status, + error: data, + }; + } + + const verifiedAccessToken = await verifySignedAuthResponse( + data.token, + opts.authServerUrl, + ); + + if (!verifiedAccessToken) { + throw new Error("Invalid signed response from Auth Server"); + } + + if (verifiedAccessToken.sub !== data.sub) { + throw new Error("Signature mismatch with data payload"); + } + + const sessionId = + typeof verifiedAccessToken.sid === "string" + ? verifiedAccessToken.sid + : undefined; + + return { + status: up.status, + body: data, + setCookies: [ + { + name: opts.accessCookieName, + value: { + sub: data.sub, + ...(sessionId === undefined ? {} : { sessionId }), + roles: data.roles, + email: data.email, + phone: data.phone, + }, + ttl: data.ttl, + domain: opts.cookieDomain, + }, + { + name: opts.refreshCookieName, + value: { + sub: data.sub, + refreshToken: data.refreshToken, + }, + ttl: data.refreshTtl, + domain: opts.cookieDomain, + }, + ], + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4e42ed9..05a5f33 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export * from "./handlers/finishRegister.js"; export * from "./handlers/logout.js"; export * from "./handlers/me.js"; export * from "./handlers/requestOtpHandler.js"; +export * from "./handlers/verifyLoginOtpHandler.js"; export * from "./handlers/verifyMagicLinkHandler.js"; export * from "./handlers/requestMagicLinkHandler.js"; export * from "./handlers/pollMagicLinkConfirmationHandler.js"; diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index 8478fa6..c867b76 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -179,6 +179,29 @@ describe("ensureCookies", () => { }); }); + it("requires the pre-auth cookie for login OTP routes", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + verifyCookieJwtMock.mockReturnValue({ + sub: "user-123", + roles: ["user"], + }); + + const result = await ensureCookies( + { + path: "/otp/generate-login-email-otp", + cookies: { preauth: "valid.preauth.jwt" }, + }, + BASE_OPTS, + ); + + expect(result.type).toBe("ok"); + expect(result.user).toEqual({ + sub: "user-123", + roles: ["user"], + }); + }); + it("requires the access cookie for step-up routes", async () => { const { ensureCookies } = await import("../dist/ensureCookies.js"); diff --git a/packages/core/tests/loginHandler.test.js b/packages/core/tests/loginHandler.test.js new file mode 100644 index 0000000..2a693ff --- /dev/null +++ b/packages/core/tests/loginHandler.test.js @@ -0,0 +1,83 @@ +import { jest } from "@jest/globals"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; + +function jsonResponse(status, body) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function createSignedAuthResponse(subject = "user-123") { + const { privateKey, publicKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.alg = "RS256"; + jwk.kid = "test-key"; + jwk.use = "sig"; + + const token = await new SignJWT({ sub: subject }) + .setProtectedHeader({ alg: "RS256", kid: "test-key" }) + .setIssuer("https://auth.example.com") + .setSubject(subject) + .setExpirationTime("5m") + .sign(privateKey); + + return { token, jwk }; +} + +describe("loginHandler", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("returns sanitized login policy metadata and stores the pre-auth token in cookies", async () => { + const { token, jwk } = await createSignedAuthResponse(); + + global.fetch = jest.fn(async (url) => { + if (url === "https://auth.example.com/login") { + return jsonResponse(200, { + message: "Success", + identifierType: "email", + loginMethods: ["passkey", "magic_link", "email_otp"], + sub: "user-123", + token, + ttl: 300, + }); + } + + if (url === "https://auth.example.com/.well-known/jwks.json") { + return jsonResponse(200, { keys: [jwk] }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const { loginHandler } = await import("../dist/handlers/login.js"); + + const result = await loginHandler( + { body: { identifier: "user@example.com", passkeyAvailable: true } }, + { + authServerUrl: "https://auth.example.com", + preAuthCookieName: "preauth", + }, + ); + + expect(result.status).toBe(200); + expect(result.body).toEqual({ + message: "Success", + identifierType: "email", + loginMethods: ["passkey", "magic_link", "email_otp"], + }); + expect(result.body).not.toHaveProperty("token"); + expect(result.setCookies).toEqual([ + { + name: "preauth", + value: { sub: "user-123", token }, + ttl: 300, + domain: undefined, + }, + ]); + }); +}); diff --git a/packages/core/tests/requestOtpHandler.test.js b/packages/core/tests/requestOtpHandler.test.js new file mode 100644 index 0000000..70bf601 --- /dev/null +++ b/packages/core/tests/requestOtpHandler.test.js @@ -0,0 +1,58 @@ +import { jest } from "@jest/globals"; + +function jsonResponse(status, body) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("requestOtpHandler", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest + .fn() + .mockResolvedValue(jsonResponse(200, { message: "sent" })); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("uses registration OTP endpoints by default", async () => { + const { requestOtpHandler } = await import( + "../dist/handlers/requestOtpHandler.js" + ); + + await requestOtpHandler( + { kind: "email", authorization: "Bearer service-token" }, + { authServerUrl: "https://auth.example.com" }, + ); + + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/otp/generate-email-otp", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("uses login OTP endpoints when requested", async () => { + const { requestOtpHandler } = await import( + "../dist/handlers/requestOtpHandler.js" + ); + + await requestOtpHandler( + { + kind: "phone", + flow: "login", + authorization: "Bearer service-token", + }, + { authServerUrl: "https://auth.example.com" }, + ); + + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/otp/generate-login-phone-otp", + expect.objectContaining({ method: "GET" }), + ); + }); +}); diff --git a/packages/express/package-lock.json b/packages/express/package-lock.json index 8551e17..a66bb24 100644 --- a/packages/express/package-lock.json +++ b/packages/express/package-lock.json @@ -1,15 +1,15 @@ { "name": "@seamless-auth/express", - "version": "0.3.6", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/express", - "version": "0.3.6", + "version": "0.4.0", "license": "AGPL-3.0-only", "dependencies": { - "@seamless-auth/core": "^0.4.6", + "@seamless-auth/core": "^0.5.0", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, diff --git a/packages/express/package.json b/packages/express/package.json index 30b260b..685c60e 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/express", - "version": "0.3.6", + "version": "0.4.0", "description": "Express adapter for Seamless Auth passwordless authentication", "license": "AGPL-3.0-only", "type": "module", @@ -39,7 +39,7 @@ "express": ">=4.18.0" }, "dependencies": { - "@seamless-auth/core": "^0.4.6", + "@seamless-auth/core": "^0.5.0", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 597458c..1bf6c57 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -8,6 +8,7 @@ import { login } from "./handlers/login"; import { finishLogin } from "./handlers/finishLogin"; import { register } from "./handlers/register"; import { requestOtp } from "./handlers/requestOtp"; +import { verifyLoginOtp } from "./handlers/verifyLoginOtp"; import { finishRegister } from "./handlers/finishRegister"; import { me } from "./handlers/me"; import { logout } from "./handlers/logout"; @@ -270,6 +271,12 @@ export function createSeamlessAuthServer( "/otp/verify-email-otp", proxyWithIdentity("otp/verify-email-otp", "preAuth"), ); + r.post("/otp/verify-login-phone-otp", (req, res) => + verifyLoginOtp(req, res, resolvedOpts, "phone"), + ); + r.post("/otp/verify-login-email-otp", (req, res) => + verifyLoginOtp(req, res, resolvedOpts, "email"), + ); r.get("/otp/generate-phone-otp", (req, res) => requestOtp(req, res, resolvedOpts, "phone"), @@ -277,6 +284,12 @@ export function createSeamlessAuthServer( r.get("/otp/generate-email-otp", (req, res) => requestOtp(req, res, resolvedOpts, "email"), ); + r.get("/otp/generate-login-phone-otp", (req, res) => + requestOtp(req, res, resolvedOpts, "phone", "login"), + ); + r.get("/otp/generate-login-email-otp", (req, res) => + requestOtp(req, res, resolvedOpts, "email", "login"), + ); r.post("/login", (req, res) => login(req, res, resolvedOpts)); r.post("/registration/register", (req, res) => diff --git a/packages/express/src/handlers/login.ts b/packages/express/src/handlers/login.ts index fb4c3e7..94f49bc 100644 --- a/packages/express/src/handlers/login.ts +++ b/packages/express/src/handlers/login.ts @@ -51,5 +51,9 @@ export async function login( return res.status(result.status).json(result.error); } + if (result.body) { + return res.status(result.status).json(result.body); + } + res.status(result.status).end(); } diff --git a/packages/express/src/handlers/requestOtp.ts b/packages/express/src/handlers/requestOtp.ts index 4a7a528..d858b45 100644 --- a/packages/express/src/handlers/requestOtp.ts +++ b/packages/express/src/handlers/requestOtp.ts @@ -10,10 +10,12 @@ export async function requestOtp( res: Response, opts: SeamlessAuthServerOptions, kind: "email" | "phone", + flow: "registration" | "login" = "registration", ) { const result = await requestOtpHandler( { kind, + flow, authorization: buildServiceAuthorization(req, opts), }, { diff --git a/packages/express/src/handlers/verifyLoginOtp.ts b/packages/express/src/handlers/verifyLoginOtp.ts new file mode 100644 index 0000000..19f361c --- /dev/null +++ b/packages/express/src/handlers/verifyLoginOtp.ts @@ -0,0 +1,62 @@ +import { Request, Response } from "express"; +import { verifyLoginOtpHandler } from "@seamless-auth/core/handlers/verifyLoginOtpHandler"; +import { setSessionCookie } from "../internal/cookie"; +import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; +import { SeamlessAuthServerOptions } from "../createServer"; + +export async function verifyLoginOtp( + req: Request & { cookiePayload?: any }, + res: Response, + opts: SeamlessAuthServerOptions, + kind: "email" | "phone", +) { + const cookieSigner = { + secret: opts.cookieSecret, + secure: process.env.NODE_ENV === "production", + sameSite: + process.env.NODE_ENV === "production" + ? "none" + : ("lax" as "none" | "lax"), + }; + + const result = await verifyLoginOtpHandler( + { + body: req.body, + authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), + kind, + }, + { + authServerUrl: opts.authServerUrl, + cookieDomain: opts.cookieDomain, + accessCookieName: opts.accessCookieName!, + refreshCookieName: opts.refreshCookieName!, + }, + ); + + if (!cookieSigner.secret) { + throw new Error("Missing COOKIE_SIGNING_KEY"); + } + + if (result.setCookies) { + for (const c of result.setCookies) { + setSessionCookie( + res, + { + name: c.name, + payload: c.value, + domain: c.domain, + ttlSeconds: c.ttl, + }, + cookieSigner, + ); + } + } + + if (result.error) { + return res.status(result.status).json(result.error); + } + + return res.status(result.status).json(result.body); +} diff --git a/packages/express/tests/loginOtpRoutes.test.js b/packages/express/tests/loginOtpRoutes.test.js new file mode 100644 index 0000000..7b34277 --- /dev/null +++ b/packages/express/tests/loginOtpRoutes.test.js @@ -0,0 +1,77 @@ +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 createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function createPreAuthCookie(subject = "user-123") { + const token = jwt.sign({ sub: subject }, "cookie-secret", { + algorithm: "HS256", + expiresIn: "300s", + }); + + return `seamless-ephemeral=${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("login OTP routes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("proxies login email OTP requests with pre-auth identity", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + message: "If an account exists, a code has been sent.", + }), + ); + + const res = await request(createApp()) + .get("/auth/otp/generate-login-email-otp") + .set("Cookie", createPreAuthCookie()); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/otp/generate-login-email-otp", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Bearer /), + "x-seamless-service-token": expect.stringMatching(/^Bearer /), + }), + }), + ); + }); +}); diff --git a/packages/express/tsconfig.json b/packages/express/tsconfig.json index 7b4c8a6..71bc028 100644 --- a/packages/express/tsconfig.json +++ b/packages/express/tsconfig.json @@ -7,6 +7,11 @@ "esModuleInterop": true, "strict": true, "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@seamless-auth/core": ["../core/src/index.ts"], + "@seamless-auth/core/*": ["../core/src/*"] + }, "typeRoots": ["src/types/express.d.types"], "declaration": true, "emitDeclarationOnly": false, From 1a02b9c023117eb716be9dfc0568e7853ee51b5c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 17 May 2026 15:28:08 -0400 Subject: [PATCH 24/24] chore: bump and lock core package --- packages/express/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/express/package-lock.json b/packages/express/package-lock.json index a66bb24..a55c6ad 100644 --- a/packages/express/package-lock.json +++ b/packages/express/package-lock.json @@ -1775,9 +1775,9 @@ ] }, "node_modules/@seamless-auth/core": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.4.6.tgz", - "integrity": "sha512-9WiNEgkcV2LCi1RKJoS0S5l9rEk72HQzY0nbDsu2lYKzT6a3imZ1T6od9HRBchXsi/zUaKv/Jw6nEyLidityEw==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/core/-/core-0.5.0.tgz", + "integrity": "sha512-q4bbKBjDvk92Q0d0Is6Jxz7n6Rj1LgCOvGPABSN6dzANyWkscOe7142xMC6P0TDNETOFLk8S6i8zGo3fgwzeDw==", "license": "AGPL-3.0-only", "dependencies": { "jose": "^6.1.3",