From 1b092f585b30678963dfa612f2025aa0d2491e43 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 16 May 2026 11:33:59 -0400 Subject: [PATCH] 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 /), + }), + }), + ); + }); +});