From ceb085675ee5019e2eb48b200ae2c460f78bb33c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 17 May 2026 15:20:39 -0400 Subject: [PATCH 1/2] 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 2/2] 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",