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/core/package-lock.json b/packages/core/package-lock.json index dad3125..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.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/core", - "version": "0.4.0", + "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 7befdee..27fb346 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.5.0", "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 +} 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/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 0dae9a3..55a3bfb 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -8,11 +8,12 @@ export interface EnsureCookiesInput { export interface CookiePayload { sub: string; + sessionId?: string; token?: string; refreshToken?: string; roles?: string[]; email?: string; - phone?: string; + phone?: string | null; } export interface CookieInstruction { @@ -28,6 +29,7 @@ export interface EnsureCookiesResult { error?: string; user?: { sub: string; + sessionId?: string; roles?: string[]; }; setCookies?: CookieInstruction[]; @@ -46,6 +48,7 @@ export interface EnsureCookiesOptions { issuer: string; audience: string; keyId: string; + forwardedClientIp?: string; } const COOKIE_REQUIREMENTS: Record< @@ -78,8 +81,35 @@ 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, + }, + "/magic-link/check": { + name: "preAuthCookieName", + required: true, + }, "/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", @@ -163,6 +193,7 @@ export async function ensureCookies( issuer: opts.issuer, audience: opts.audience, keyId: opts.keyId, + forwardedClientIp: opts.forwardedClientIp, }); if (!refreshed?.token) { @@ -182,6 +213,9 @@ export async function ensureCookies( type: "ok", user: { sub: refreshed.sub, + ...(refreshed.sessionId === undefined + ? {} + : { sessionId: refreshed.sessionId }), roles: refreshed.roles, }, setCookies: [ @@ -189,7 +223,12 @@ 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, }, ttl: refreshed.ttl, domain: opts.cookieDomain, @@ -221,6 +260,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/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..72df3f0 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(); @@ -58,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, @@ -66,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 667b5f3..6ce9a35 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(); @@ -59,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: [ @@ -66,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/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..147812e 100644 --- a/packages/core/src/handlers/login.ts +++ b/packages/core/src/handlers/login.ts @@ -10,11 +10,17 @@ export interface LoginOptions { authServerUrl: string; cookieDomain?: string; preAuthCookieName: string; + forwardedClientIp?: string; } export interface LoginResult { status: number; - error?: string; + body?: { + message?: string; + identifierType?: string; + loginMethods?: string[]; + }; + error?: unknown; setCookies?: { name: string; value: CookiePayload; @@ -30,6 +36,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(); @@ -54,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/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..5a6fb66 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) @@ -73,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, @@ -81,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/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..939a2bb 100644 --- a/packages/core/src/handlers/requestOtpHandler.ts +++ b/packages/core/src/handlers/requestOtpHandler.ts @@ -2,12 +2,14 @@ import { authFetch } from "../authFetch.js"; export interface RequestOtpInput { authorization?: string; + flow?: "registration" | "login"; kind: "email" | "phone"; } export interface RequestOtpOptions { authServerUrl: string; externalDelivery?: boolean; + forwardedClientIp?: string; } export interface RequestOtpResult { @@ -20,14 +22,20 @@ 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", 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/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/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/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/src/refreshAccessToken.ts b/packages/core/src/refreshAccessToken.ts index ae12446..974d2ab 100644 --- a/packages/core/src/refreshAccessToken.ts +++ b/packages/core/src/refreshAccessToken.ts @@ -1,6 +1,6 @@ import { authFetch } from "./authFetch.js"; -import { verifyRefreshCookie } from "./verifyRefreshCookie.js"; import { createServiceToken } from "./createServiceToken.js"; +import { verifyRefreshCookie } from "./verifyRefreshCookie.js"; export interface RefreshAccessTokenOptions { authServerUrl: string; @@ -9,39 +9,82 @@ export interface RefreshAccessTokenOptions { issuer: string; audience: string; keyId: string; + forwardedClientIp?: string; } -export async function refreshAccessToken( - refreshCookie: string, - opts: RefreshAccessTokenOptions, -): Promise<{ +type RefreshAccessTokenResult = { sub: string; + sessionId?: string; token: string; refreshToken: string; - roles: string[]; + roles?: string[]; + email?: string; + phone?: string | null; ttl: number; refreshTtl: number; -} | null> { - 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: "GET", - headers: { - Authorization: `Bearer ${serviceToken}`, - }, - }); - - if (!response.ok) return null; - - return response.json(); +}; + +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 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", + authorization: `Bearer ${payload.refreshToken}`, + serviceAuthorization: `Bearer ${serviceToken}`, + forwardedClientIp: opts.forwardedClientIp, + }); + + if (!response.ok) return null; + + return response.json(); + })(); + + inFlightRefreshes.set(refreshCookie, refreshPromise); + + try { + 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/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/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index 283b577..c867b76 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -85,9 +85,12 @@ describe("ensureCookies", () => { refreshAccessTokenMock.mockResolvedValue({ sub: "user-123", + sessionId: "session-123", token: "new-access", refreshToken: "new-refresh", roles: ["user"], + email: "test@example.com", + phone: "+14155552671", ttl: 300, refreshTtl: 3600, }); @@ -102,11 +105,19 @@ 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); const [accessCookie, refreshCookie] = result.setCookies; expect(accessCookie.name).toBe("access"); + expect(accessCookie.value).toEqual({ + sub: "user-123", + sessionId: "session-123", + roles: ["user"], + email: "test@example.com", + phone: "+14155552671", + }); expect(refreshCookie.name).toBe("refresh"); }); @@ -144,4 +155,75 @@ 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"], + }); + }); + + 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"); + + 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/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/refreshAccessToken.test.js b/packages/core/tests/refreshAccessToken.test.js index 426b423..d4c4df0 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,12 +75,111 @@ 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: "GET", - headers: { Authorization: "Bearer service.jwt" }, + method: "POST", + authorization: "Bearer refresh-token", + serviceAuthorization: "Bearer service.jwt", }), ); }); + + 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-concurrent.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-concurrent.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); + }); + + 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); + }); }); 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/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/package-lock.json b/packages/express/package-lock.json index 5a09c65..a55c6ad 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.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/express", - "version": "0.3.0", + "version": "0.4.0", "license": "AGPL-3.0-only", "dependencies": { - "@seamless-auth/core": "^0.4.0", + "@seamless-auth/core": "^0.5.0", "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.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", @@ -6538,4 +6538,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/express/package.json b/packages/express/package.json index 33ac41a..685c60e 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.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.0", + "@seamless-auth/core": "^0.5.0", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, @@ -56,4 +56,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index c8c52b2..1bf6c57 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -8,18 +8,16 @@ 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"; 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"; import { getAvailableRoles, @@ -79,6 +77,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. * @@ -194,14 +214,16 @@ 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 queryString = buildProxyQueryString(req.query); const upstream = await authFetch( - `${resolvedOpts.authServerUrl}/${path}`, - options, + `${resolvedOpts.authServerUrl}/${path}${queryString ? `?${queryString}` : ""}`, + options as any, ); const data = await upstream.json(); @@ -221,7 +243,8 @@ export function createSeamlessAuthServer( issuer: resolvedOpts.issuer, audience: resolvedOpts.authServerUrl, keyId: resolvedOpts.jwksKid, - } as EnsureCookiesOptions), + forwardedClientIp: undefined, + } as any), ); r.post( @@ -248,14 +271,24 @@ 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"), + 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.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)); @@ -266,6 +299,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", @@ -275,13 +321,14 @@ 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}`, - { 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..94f49bc 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) { @@ -49,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/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..d858b45 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"; @@ -9,16 +10,19 @@ 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), }, { 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/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/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/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/tests/messagingDelivery.test.js b/packages/express/tests/messagingDelivery.test.js index 07a86a5..51e0622 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,12 @@ describe("messaging delivery routes", () => { headers: expect.objectContaining({ "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), }), }), ); - expect(global.fetch.mock.calls[0][1].headers.Authorization).toBeUndefined(); expect(emailTransport.send).toHaveBeenCalledWith( expect.objectContaining({ @@ -145,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", }), }), diff --git a/packages/express/tests/stepUpProxy.test.js b/packages/express/tests/stepUpProxy.test.js new file mode 100644 index 0000000..c2e05e7 --- /dev/null +++ b/packages/express/tests/stepUpProxy.test.js @@ -0,0 +1,195 @@ +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 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(); + + 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 /), + }), + }), + ); + }); + + 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", + }), + ); + }); +}); 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,