From a4d25d4251238d73b28823e37e9d2fc67c76d34c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 7 Jun 2026 21:30:58 -0400 Subject: [PATCH 1/2] feat: make phone registration optional --- .env.example | 2 +- docs/production-operations.md | 4 +- resources/coverage-badge.svg | 8 +- src/controllers/admin.ts | 2 +- src/controllers/jwks.ts | 2 +- src/controllers/otp.ts | 26 ++- src/controllers/registration.ts | 204 ++++++++++++++++-- src/middleware/jwksRateLimit.ts | 29 ++- src/middleware/rateLimit.ts | 67 +++--- src/middleware/slowDown.ts | 28 +-- ...0260607120000-make-user-phone-optional.cjs | 18 ++ src/models/users.ts | 6 +- src/routes/registration.routes.ts | 56 ++++- src/schemas/admin.requests.ts | 2 +- src/schemas/me.response.ts | 2 +- src/schemas/organization.responses.ts | 2 +- src/schemas/registration.requests.ts | 4 + src/schemas/registration.responses.ts | 6 + src/schemas/user.schema.ts | 2 +- src/services/apiResponseSerializers.ts | 2 +- src/services/organizationService.ts | 2 +- src/utils/otp.ts | 8 +- tests/e2e/authFlow.spec.ts | 12 +- tests/factories/requestFactory.ts | 1 - .../integration/registration/register.spec.ts | 110 ++++++++-- tests/unit/middleware/rateLimit.spec.ts | 78 +++++-- .../services/apiResponseSerializers.spec.ts | 17 ++ tests/unit/utils/otp.spec.ts | 4 + 28 files changed, 542 insertions(+), 162 deletions(-) create mode 100644 src/migrations/20260607120000-make-user-phone-optional.cjs diff --git a/.env.example b/.env.example index 36fa844..ef82ca3 100644 --- a/.env.example +++ b/.env.example @@ -91,4 +91,4 @@ MESSAGING_TWILIO_AUTH_TOKEN= # Required when NODE_ENV=production. # SEAMLESS_JWKS_ACTIVE_KID=main_2026_04 # SEAMLESS_JWKS_KEY_main_2026_04_PRIVATE="-----BEGIN PRIVATE KEY-----..." -# SEAMLESS_JWKS_PUBLIC_KEYS={"keys":[{"kid":"main_2026_04","pem":"-----BEGIN PUBLIC KEY-----...","createdAt":"2026-04-22T00:00:00.000Z"}]} +# JWKS_PUBLIC_KEYS={"keys":[{"kid":"main_2026_04","pem":"-----BEGIN PUBLIC KEY-----...","createdAt":"2026-04-22T00:00:00.000Z"}]} diff --git a/docs/production-operations.md b/docs/production-operations.md index d88f25c..8f3a3e3 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -24,7 +24,7 @@ Production deployments should define: - `OAUTH_STATE_SECRET` - `SEAMLESS_JWKS_ACTIVE_KID` - `SEAMLESS_JWKS_KEY__PRIVATE` -- `SEAMLESS_JWKS_PUBLIC_KEYS` +- `JWKS_PUBLIC_KEYS` - OAuth client-secret environment variables referenced by provider `clientSecretEnv` - Messaging provider credentials when direct delivery is enabled @@ -35,7 +35,7 @@ Do not store raw secrets in `system_config`. Access tokens are signed with configured JWKS signing keys. A typical rotation is: 1. Generate a new key pair. -2. Publish the new public key in `SEAMLESS_JWKS_PUBLIC_KEYS`. +2. Publish the new public key in `JWKS_PUBLIC_KEYS`. 3. Deploy with both old and new public keys available. 4. Switch `SEAMLESS_JWKS_ACTIVE_KID` to the new key id. 5. Keep retired public keys until all tokens signed with them expire. diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index ab2d251..cd72161 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 81.8% + + coverage: 81.4% @@ -17,7 +17,7 @@ coverage coverage - 81.8% - 81.8% + 81.4% + 81.4% diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts index d1ef980..62f9931 100644 --- a/src/controllers/admin.ts +++ b/src/controllers/admin.ts @@ -94,7 +94,7 @@ export const createUser = async (req: Request, res: Response) => { const user = await User.create({ email, - phone: phone, + phone: phone ?? null, roles: roles ?? [], }); diff --git a/src/controllers/jwks.ts b/src/controllers/jwks.ts index aaad194..076a700 100644 --- a/src/controllers/jwks.ts +++ b/src/controllers/jwks.ts @@ -29,7 +29,7 @@ export function __resetJwksCache() { async function loadJwksFromSecrets(): Promise { logger.info('Loading JWKS from Secrets Manager'); - const raw = await getSecret('SEAMLESS_JWKS_PUBLIC_KEYS'); + const raw = await getSecret('JWKS_PUBLIC_KEYS'); const parsed = JSON.parse(raw); const jwks: JWK[] = []; diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index 7bb17a9..60dbc3a 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -56,7 +56,6 @@ export const sendPhoneOTP = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; const user = authReq.user; const phone = user.phone; - const normalizedPhone = normalizePhoneNumber(phone); const useExternalDelivery = await canReturnExternalDelivery(req); if (!phone) { @@ -73,6 +72,8 @@ export const sendPhoneOTP = async (req: Request, res: Response) => { logger.info('Sending phone OTP'); try { + const normalizedPhone = normalizePhoneNumber(phone); + if (!isValidPhoneNumber(phone) || !normalizedPhone) { logger.warn('Invalid phone provided'); AuthEventService.log({ @@ -231,7 +232,6 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; let user = authReq.user; - const email = user.email; const phone = user.phone; logger.info('Verifying phone number'); @@ -248,7 +248,7 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { } try { - if (!verificationToken || !phone || !email) { + if (!verificationToken || !phone) { logger.warn(`Missing data from verify phone numnber request.`); await AuthEventService.log({ userId: user.id, @@ -300,7 +300,6 @@ export const verifyEmail = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; let user = authReq.user; const email = user.email; - const phone = user.phone; logger.info('Verifying email'); @@ -326,8 +325,8 @@ export const verifyEmail = async (req: Request, res: Response) => { return res.status(401).json({ error: 'Invalid data' }); } - if (!email || !phone) { - logger.warn(`Missing email or phone`); + if (!email) { + logger.warn(`Missing email`); await AuthEventService.log({ userId: user.id, type: 'verify_otp_suspicious', @@ -348,17 +347,17 @@ export const verifyEmail = async (req: Request, res: Response) => { userId: user.id, type: 'verify_otp_success', req, - metadata: { reason: 'User verified their email number' }, + metadata: { reason: 'User verified their email' }, }); - if (user.phoneVerified && user.emailVerified && user.verified) { + if (user.emailVerified && user.verified) { logger.info('User is fully verified. Logging in...'); await AuthEventService.log({ userId: user.id, type: 'verify_otp_success', req, - metadata: { reason: 'User completed verification of phone and email' }, + metadata: { reason: 'User completed email verification' }, }); await issueSessionAndRespond({ @@ -488,7 +487,6 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; let user = authReq.user; const email = user.email; - const phone = user.phone; if (await rejectDisabledLoginMethod('email_otp', req, res)) { return; @@ -522,8 +520,8 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { return res.status(401).json({ error: 'Not allowed' }); } - if (!email || !phone) { - logger.warn(`Missing email or phone`); + if (!email) { + logger.warn(`Missing email`); await AuthEventService.log({ userId: user.id, type: 'verify_otp_suspicious', @@ -546,14 +544,14 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { req, }); - if (user.phoneVerified && user.emailVerified && user.verified) { + if (user.emailVerified && user.verified) { logger.info('User is fully verified. Logging in...'); await AuthEventService.log({ userId: user.id, type: 'verify_otp_success', req, - metadata: { reason: 'User completed verification of phone and email' }, + metadata: { reason: 'User completed email verification' }, }); await issueSessionAndRespond({ diff --git a/src/controllers/registration.ts b/src/controllers/registration.ts index 1e7df83..420e75a 100644 --- a/src/controllers/registration.ts +++ b/src/controllers/registration.ts @@ -15,8 +15,9 @@ import { BOOTSTRAP_INVITE_TOKEN_HASH_CONTEXT_KEY, createBootstrapInviteTokenHash, } from '../services/bootstrapPromotionService.js'; +import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; -import { generatePhoneOTP } from '../utils/otp.js'; +import { generateEmailOTP, generatePhoneOTP, verifyPhoneOTP } from '../utils/otp.js'; import { isValidEmail, isValidPhoneNumber, normalizePhoneNumber } from '../utils/utils.js'; const logger = getLogger('registration'); @@ -25,18 +26,19 @@ export const register = async (req: Request, res: Response) => { const { email, phone, bootstrapToken } = req.body; const useExternalDelivery = await canReturnExternalDelivery(req); const normalizedEmail = email?.toLowerCase(); - const normalizedPhone = typeof phone === 'string' ? normalizePhoneNumber(phone) : null; + const phoneProvided = typeof phone === 'string' && phone.trim().length > 0; + const normalizedPhone = phoneProvided ? normalizePhoneNumber(phone) : null; const bootstrapInviteTokenHash = typeof bootstrapToken === 'string' && bootstrapToken.length > 10 ? createBootstrapInviteTokenHash(bootstrapToken) : null; const systemConfig = await getSystemConfig(); - logger.info(`Registering phone and email account`); + logger.info(`Registering email account`); try { - if (!isValidEmail(email) || !isValidPhoneNumber(phone) || !normalizedPhone) { - logger.error('Invalid email or phone provided during registration'); + if (!isValidEmail(email)) { + logger.error('Invalid email provided during registration'); await AuthEventService.log({ userId: null, type: 'registration_suspicious', @@ -44,18 +46,30 @@ export const register = async (req: Request, res: Response) => { metadata: { reason: 'Bad data submitted.' }, }); - return res.status(400).json({ message: 'Invalid data.' }); + return res.status(400).json({ error: 'Invalid data.', message: 'Invalid data.' }); + } + + if (phoneProvided && (!isValidPhoneNumber(phone) || !normalizedPhone)) { + logger.error('Invalid optional phone provided during registration'); + await AuthEventService.log({ + userId: null, + type: 'registration_suspicious', + req, + metadata: { reason: 'Bad phone submitted.' }, + }); + + return res.status(400).json({ error: 'Invalid data.', message: 'Invalid data.' }); } const [existingEmailUser, existingPhoneUser] = await Promise.all([ User.findOne({ where: { email: normalizedEmail } }), - User.findOne({ where: { phone: normalizedPhone } }), + normalizedPhone ? User.findOne({ where: { phone: normalizedPhone } }) : Promise.resolve(null), ]); const hasExactExistingUser = existingEmailUser && existingPhoneUser && existingEmailUser.id === existingPhoneUser.id; const hasIdentifierConflict = - (existingEmailUser && !existingPhoneUser) || + (Boolean(existingEmailUser) && phoneProvided && !existingPhoneUser) || (!existingEmailUser && existingPhoneUser) || (existingEmailUser && existingPhoneUser && existingEmailUser.id !== existingPhoneUser.id); @@ -75,18 +89,18 @@ export const register = async (req: Request, res: Response) => { return res.status(409).json({ error: 'Registration conflict', message: - 'The provided email and phone do not belong to the same account. Try signing in with your existing account details or use a different email and phone.', + 'The provided identifiers do not belong to the same account. Try signing in with your existing account details or use a different email or phone.', }); } - let user = hasExactExistingUser ? existingEmailUser : null; + let user = hasExactExistingUser || !phoneProvided ? existingEmailUser : null; let token; - let phoneOtp: number | null = null; + let emailOtp: string | null = null; if (user) { logger.info(`Registration attempt for a user that already exisited`); - logger.info(`Sending OTP`); + logger.info(`Sending email OTP`); await AuthEventService.log({ userId: user.id, type: 'informational', @@ -106,7 +120,7 @@ export const register = async (req: Request, res: Response) => { logger.info('Bootstrap token hash stored for registration flow'); } - phoneOtp = await generatePhoneOTP(user, { + emailOtp = await generateEmailOTP(user, { sendMessage: !useExternalDelivery, }); } else { @@ -138,8 +152,8 @@ export const register = async (req: Request, res: Response) => { reason: 'Owner notified of new user registration', }); - logger.info('Sending phone OTP for registration'); - phoneOtp = await generatePhoneOTP(user, { + logger.info('Sending email OTP for registration'); + emailOtp = await generateEmailOTP(user, { sendMessage: !useExternalDelivery, }); @@ -152,11 +166,11 @@ export const register = async (req: Request, res: Response) => { } const delivery = - useExternalDelivery && phoneOtp !== null + useExternalDelivery && emailOtp !== null ? { - kind: 'otp_sms', - to: normalizePhoneNumber(user.phone) ?? user.phone, - token: phoneOtp, + kind: 'otp_email' as const, + to: user.email, + token: emailOtp, } : undefined; @@ -183,3 +197,155 @@ export const register = async (req: Request, res: Response) => { return res.status(500).json({ error: 'Internal server error' }); } }; + +export const registerPhone = async (req: Request, res: Response) => { + const authReq = req as AuthenticatedRequest; + const user = authReq.user; + const { phone } = req.body; + const normalizedPhone = typeof phone === 'string' ? normalizePhoneNumber(phone) : null; + const useExternalDelivery = await canReturnExternalDelivery(req); + + if (!user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + if (!phone || !isValidPhoneNumber(phone) || !normalizedPhone) { + logger.warn('Invalid phone provided for phone registration'); + await AuthEventService.log({ + userId: user.id, + type: 'registration_suspicious', + req, + metadata: { reason: 'Invalid phone number.' }, + }); + + return res.status(400).json({ error: 'Invalid data' }); + } + + const existingPhoneUser = await User.findOne({ where: { phone: normalizedPhone } }); + + if (existingPhoneUser && existingPhoneUser.id !== user.id) { + await AuthEventService.log({ + userId: user.id, + type: 'registration_suspicious', + req, + metadata: { reason: 'Phone registration attempted with an in-use phone number.' }, + }); + + return res.status(409).json({ + error: 'Phone number in use', + message: 'The provided phone number is already registered to another account.', + }); + } + + const phoneChanged = user.phone !== normalizedPhone; + + await user.update({ + phone: normalizedPhone, + phoneVerified: phoneChanged ? false : user.phoneVerified, + phoneVerificationToken: null, + phoneVerificationTokenExpiry: null, + }); + + user.phone = normalizedPhone; + if (phoneChanged) { + user.phoneVerified = false; + } + + const shouldSendVerification = phoneChanged || !user.phoneVerified; + const phoneOtp = shouldSendVerification + ? await generatePhoneOTP(user, { sendMessage: !useExternalDelivery }) + : null; + + await AuthEventService.log({ + userId: user.id, + type: 'registration_success', + req, + metadata: { reason: 'User registered a phone number.' }, + }); + + const delivery = + useExternalDelivery && phoneOtp !== null + ? { + kind: 'otp_sms' as const, + to: normalizedPhone, + token: phoneOtp, + } + : undefined; + + return res.status(200).json({ + message: 'Success', + phone: normalizedPhone, + ...(delivery ? { delivery } : {}), + }); + } catch (error: unknown) { + logger.error(`Error during phone registration: ${String(error)}`); + await AuthEventService.log({ + userId: user.id, + type: 'registration_failed', + req, + metadata: { reason: 'Failed to register phone number.' }, + }); + + return res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const verifyRegisteredPhone = async (req: Request, res: Response) => { + const authReq = req as AuthenticatedRequest; + const user = authReq.user; + const { verificationToken } = req.body; + + if (!user) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + if (!user.phone || !user.phoneVerificationTokenExpiry || !user.phoneVerificationToken) { + await AuthEventService.log({ + userId: user.id, + type: 'verify_otp_suspicious', + req, + metadata: { reason: 'Missing phone verification data.' }, + }); + + return res.status(401).json({ error: 'Failed to verify OTP' }); + } + + if (!verificationToken) { + await AuthEventService.log({ + userId: user.id, + type: 'verify_otp_suspicious', + req, + metadata: { reason: 'Missing verification token.' }, + }); + + return res.status(401).json({ error: 'Not allowed' }); + } + + try { + const verificationResult = await verifyPhoneOTP(user, verificationToken); + + if (!verificationResult.verified) { + await AuthEventService.log({ + userId: user.id, + type: 'verify_otp_failed', + req, + metadata: { reason: 'User verification failed for phone.' }, + }); + + return res.status(401).json({ error: 'Not allowed' }); + } + + await AuthEventService.log({ + userId: user.id, + type: 'verify_otp_success', + req, + metadata: { reason: 'User verified their phone number.' }, + }); + + return res.status(200).json({ message: 'Success' }); + } catch (error: unknown) { + logger.error(`Failed to verify registered phone: ${String(error)}`); + return res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/src/middleware/jwksRateLimit.ts b/src/middleware/jwksRateLimit.ts index e3bf8c9..f9c68bb 100644 --- a/src/middleware/jwksRateLimit.ts +++ b/src/middleware/jwksRateLimit.ts @@ -9,25 +9,20 @@ import rateLimit from 'express-rate-limit'; import { getSystemConfig } from '../config/getSystemConfig.js'; -let cachedLimiter: ReturnType | null = null; -let cachedLimit: number | null = null; - -export async function dynamicJWKSRateLimit(req: Request, res: Response, next: NextFunction) { +async function getConfiguredRateLimit() { const { rate_limit } = await getSystemConfig(); - const limit = rate_limit ?? 50; - - if (!cachedLimiter || cachedLimit !== limit) { - cachedLimit = limit; + return rate_limit ?? 50; +} - cachedLimiter = rateLimit({ - windowMs: 1 * 60 * 1000, - max: limit, - standardHeaders: true, - legacyHeaders: false, - message: 'Too many requests, please try again later', - }); - } +const jwksLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, + limit: getConfiguredRateLimit, + standardHeaders: true, + legacyHeaders: false, + message: 'Too many requests, please try again later', +}); - return next(); +export function dynamicJWKSRateLimit(req: Request, res: Response, next: NextFunction) { + return jwksLimiter(req, res, next); } diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index 2f4130f..6ee5c78 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -10,10 +10,11 @@ import rateLimit from 'express-rate-limit'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { AuthenticatedRequest } from '../types/types.js'; -let dynamicLimiter: ReturnType | null = null; -let dynamicLimit: number | null = null; -let magicLinkIpCachedLimiter: ReturnType | null = null; -let magicLinkIdentityCachedLimiter: ReturnType | null = null; +async function getConfiguredRateLimit() { + const { rate_limit } = await getSystemConfig(); + + return rate_limit ?? 50; +} function getMagicLinkIdentityKey(req: Request) { const authReq = req as AuthenticatedRequest; @@ -31,49 +32,37 @@ function getMagicLinkIdentityKey(req: Request) { return `ip:${req.ip ?? req.socket.remoteAddress ?? 'unknown'}`; } -export async function dynamicRateLimit(req: Request, res: Response, next: NextFunction) { - const { rate_limit } = await getSystemConfig(); - - const limit = rate_limit ?? 50; +const dynamicLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, + limit: getConfiguredRateLimit, + standardHeaders: true, + legacyHeaders: false, + message: 'Too many requests, please try again later', +}); - if (!dynamicLimiter || dynamicLimit !== limit) { - dynamicLimit = limit; +const magicLinkIpCachedLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 20, + standardHeaders: true, + legacyHeaders: false, +}); - dynamicLimiter = rateLimit({ - windowMs: 1 * 60 * 1000, - max: limit, - standardHeaders: true, - legacyHeaders: false, - message: 'Too many requests, please try again later', - }); - } +const magicLinkIdentityCachedLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 5, + keyGenerator: getMagicLinkIdentityKey, + standardHeaders: true, + legacyHeaders: false, +}); +export function dynamicRateLimit(req: Request, res: Response, next: NextFunction) { return dynamicLimiter(req, res, next); } -export async function magicLinkIpLimiter(req: Request, res: Response, next: NextFunction) { - if (!magicLinkIpCachedLimiter) { - magicLinkIpCachedLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 20, - standardHeaders: true, - legacyHeaders: false, - }); - } - +export function magicLinkIpLimiter(req: Request, res: Response, next: NextFunction) { return magicLinkIpCachedLimiter(req, res, next); } -export async function magicLinkEmailLimiter(req: Request, res: Response, next: NextFunction) { - if (!magicLinkIdentityCachedLimiter) { - magicLinkIdentityCachedLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 5, - keyGenerator: getMagicLinkIdentityKey, - standardHeaders: true, - legacyHeaders: false, - }); - } - +export function magicLinkEmailLimiter(req: Request, res: Response, next: NextFunction) { return magicLinkIdentityCachedLimiter(req, res, next); } diff --git a/src/middleware/slowDown.ts b/src/middleware/slowDown.ts index 05791e8..02af5a1 100644 --- a/src/middleware/slowDown.ts +++ b/src/middleware/slowDown.ts @@ -5,30 +5,24 @@ */ import { NextFunction, Request, Response } from 'express'; -import rateLimit from 'express-rate-limit'; import slowDown from 'express-slow-down'; import { getSystemConfig } from '../config/getSystemConfig.js'; -let cachedLimiter: ReturnType | null = null; -let cachedLimit: number | null = null; - -export async function dynamicSlowDown(req: Request, res: Response, next: NextFunction) { +async function getConfiguredDelayAfter() { const { delay_after } = await getSystemConfig(); - const limit = delay_after ?? 25; - - if (!cachedLimiter || cachedLimit !== limit) { - cachedLimit = limit; + return delay_after ?? 25; +} - cachedLimiter = slowDown({ - windowMs: 1 * 60 * 1000, - delayAfter: cachedLimit, - legacyHeaders: false, - delayMs: (hits) => hits * 1000, - message: 'Too many requests, please try again later', - }); - } +const cachedLimiter: ReturnType = slowDown({ + windowMs: 1 * 60 * 1000, + delayAfter: getConfiguredDelayAfter, + legacyHeaders: false, + delayMs: (hits) => hits * 1000, + message: 'Too many requests, please try again later', +}); +export function dynamicSlowDown(req: Request, res: Response, next: NextFunction) { return cachedLimiter(req, res, next); } diff --git a/src/migrations/20260607120000-make-user-phone-optional.cjs b/src/migrations/20260607120000-make-user-phone-optional.cjs new file mode 100644 index 0000000..9f6a44e --- /dev/null +++ b/src/migrations/20260607120000-make-user-phone-optional.cjs @@ -0,0 +1,18 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.changeColumn('users', 'phone', { + type: Sequelize.STRING, + allowNull: true, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.changeColumn('users', 'phone', { + type: Sequelize.STRING, + allowNull: false, + }); + }, +}; diff --git a/src/models/users.ts b/src/models/users.ts index 191f4a6..d023c3b 100644 --- a/src/models/users.ts +++ b/src/models/users.ts @@ -14,7 +14,7 @@ import type { TotpCredential } from './totpCredentials.js'; export interface UserAttributes { id?: string; email: string; - phone: string; + phone: string | null; roles?: string[]; revoked?: boolean; emailVerificationToken?: string | null; @@ -38,7 +38,7 @@ export interface UserAttributes { export class User extends Model implements UserAttributes { declare id: string; declare email: string; - declare phone: string; + declare phone: string | null; declare revoked: boolean; declare emailVerificationToken: string | null; declare emailVerificationTokenExpiry: number | null; @@ -106,7 +106,7 @@ const initializeUserModel = (sequelize: Sequelize) => { }, phone: { type: DataTypes.STRING, - allowNull: false, + allowNull: true, unique: true, }, roles: { diff --git a/src/routes/registration.routes.ts b/src/routes/registration.routes.ts index d0befe4..f523ff3 100644 --- a/src/routes/registration.routes.ts +++ b/src/routes/registration.routes.ts @@ -4,11 +4,19 @@ * See LICENSE file in the project root for full license information */ -import { register } from '../controllers/registration.js'; +import { register, registerPhone, verifyRegisteredPhone } from '../controllers/registration.js'; import { createRouter } from '../lib/createRouter.js'; import { ErrorSchema } from '../schemas/generic.responses.js'; -import { RegistrationRequestSchema } from '../schemas/registration.requests.js'; -import { RegistrationSuccessSchema } from '../schemas/registration.responses.js'; +import { VerifyOTPRequestSchema } from '../schemas/otp.requests.js'; +import { OTPVerifyTokenSuccessSchema } from '../schemas/otp.responses.js'; +import { + RegisterPhoneRequestSchema, + RegistrationRequestSchema, +} from '../schemas/registration.requests.js'; +import { + RegisterPhoneSuccessSchema, + RegistrationSuccessSchema, +} from '../schemas/registration.responses.js'; const registrationRouter = createRouter('/registration'); @@ -32,4 +40,46 @@ registrationRouter.post( register, ); +registrationRouter.post( + '/phone', + { + auth: 'access', + summary: 'Register a phone number for the authenticated user', + tags: ['Registration'], + + schemas: { + body: RegisterPhoneRequestSchema, + + response: { + 200: RegisterPhoneSuccessSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 409: ErrorSchema, + 500: ErrorSchema, + }, + }, + }, + registerPhone, +); + +registrationRouter.post( + '/phone/verify', + { + auth: 'access', + summary: 'Verify the authenticated user phone number', + tags: ['Registration'], + + schemas: { + body: VerifyOTPRequestSchema, + + response: { + 200: OTPVerifyTokenSuccessSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + }, + verifyRegisteredPhone, +); + export default registrationRouter.router; diff --git a/src/schemas/admin.requests.ts b/src/schemas/admin.requests.ts index 4441ffb..0104ed3 100644 --- a/src/schemas/admin.requests.ts +++ b/src/schemas/admin.requests.ts @@ -10,7 +10,7 @@ import { RoleNameSchema } from './roles.schema.js'; export const CreateUserSchema = z.object({ email: z.email(), - phone: z.string(), + phone: z.string().nullish(), roles: z.array(RoleNameSchema).min(1), }); diff --git a/src/schemas/me.response.ts b/src/schemas/me.response.ts index c7ab027..616dea0 100644 --- a/src/schemas/me.response.ts +++ b/src/schemas/me.response.ts @@ -13,7 +13,7 @@ import { RoleNameSchema } from './roles.schema.js'; const MeUserSchema = z.object({ id: z.string(), email: z.email(), - phone: z.string(), + phone: z.string().nullable(), roles: z.array(RoleNameSchema), lastLogin: z.any().optional(), activeOrganizationId: z.string().nullable().optional(), diff --git a/src/schemas/organization.responses.ts b/src/schemas/organization.responses.ts index 5a8f56e..6f29f98 100644 --- a/src/schemas/organization.responses.ts +++ b/src/schemas/organization.responses.ts @@ -9,7 +9,7 @@ import { z } from 'zod'; const OrganizationMembershipUserSchema = z.object({ id: z.string(), email: z.email(), - phone: z.string(), + phone: z.string().nullable(), roles: z.array(z.string()), }); diff --git a/src/schemas/registration.requests.ts b/src/schemas/registration.requests.ts index e83a646..f26d408 100644 --- a/src/schemas/registration.requests.ts +++ b/src/schemas/registration.requests.ts @@ -9,5 +9,9 @@ import { z } from 'zod'; export const RegistrationRequestSchema = z.object({ bootstrapToken: z.string().optional(), email: z.email(), + phone: z.string().nullish(), +}); + +export const RegisterPhoneRequestSchema = z.object({ phone: z.string(), }); diff --git a/src/schemas/registration.responses.ts b/src/schemas/registration.responses.ts index cb3e73d..3e913de 100644 --- a/src/schemas/registration.responses.ts +++ b/src/schemas/registration.responses.ts @@ -15,3 +15,9 @@ export const RegistrationSuccessSchema = z.object({ ttl: z.string().optional(), delivery: AuthDeliverySchema.optional(), }); + +export const RegisterPhoneSuccessSchema = z.object({ + message: z.string(), + phone: z.string(), + delivery: AuthDeliverySchema.optional(), +}); diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index 54b5894..d4fd83a 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -14,7 +14,7 @@ export const ApiUserSchema = z .object({ id: z.string(), email: z.email(), - phone: z.string(), + phone: z.string().nullable(), roles: z.array(RoleNameSchema).default([]), revoked: z.boolean().optional(), emailVerified: z.boolean().optional(), diff --git a/src/services/apiResponseSerializers.ts b/src/services/apiResponseSerializers.ts index d4ce13e..757668f 100644 --- a/src/services/apiResponseSerializers.ts +++ b/src/services/apiResponseSerializers.ts @@ -113,7 +113,7 @@ export function serializeApiUser(user: unknown) { return omitUndefined({ id: stringField(user, 'id'), email: stringField(user, 'email'), - phone: stringField(user, 'phone'), + phone: nullableStringField(user, 'phone') ?? null, roles: stringArrayField(user, 'roles'), revoked: booleanField(user, 'revoked'), emailVerified: booleanField(user, 'emailVerified'), diff --git a/src/services/organizationService.ts b/src/services/organizationService.ts index dd5a481..5c2fa70 100644 --- a/src/services/organizationService.ts +++ b/src/services/organizationService.ts @@ -24,7 +24,7 @@ export interface SerializedOrganizationMembership { user?: { id: string; email: string; - phone: string; + phone: string | null; roles: string[]; }; } diff --git a/src/utils/otp.ts b/src/utils/otp.ts index 359bb8b..3124d99 100644 --- a/src/utils/otp.ts +++ b/src/utils/otp.ts @@ -67,6 +67,10 @@ export const generatePhoneOTP = async ( throw new Error('Cannot generate phone OTP for non-exsistent user'); } + if (!user.phone) { + throw new Error('Cannot generate phone OTP without a registered phone number'); + } + try { // Set the token and the expiry time (ALWAYS 5 mins) const now = new Date(); @@ -107,7 +111,7 @@ export const verifyPhoneOTP = async ( user.phoneVerificationToken = null; user.phoneVerificationTokenExpiry = null; - if (user.phoneVerified && user.emailVerified && !user.verified) { + if (user.emailVerified && !user.verified) { user.verified = true; } @@ -140,7 +144,7 @@ export const verifyEmailOTP = async ( user.emailVerificationToken = null; user.emailVerificationTokenExpiry = null; - if (user.phoneVerified && user.emailVerified && !user.verified) { + if (user.emailVerified && !user.verified) { user.verified = true; } diff --git a/tests/e2e/authFlow.spec.ts b/tests/e2e/authFlow.spec.ts index 669985e..a7bd07e 100644 --- a/tests/e2e/authFlow.spec.ts +++ b/tests/e2e/authFlow.spec.ts @@ -27,7 +27,7 @@ import { hashRefreshToken, } from '../../src/lib/token.js'; -import { generatePhoneOTP, verifyPhoneOTP } from '../../src/utils/otp.js'; +import { generateEmailOTP, verifyEmailOTP } from '../../src/utils/otp.js'; import { findRefreshSessionByToken, @@ -73,17 +73,17 @@ describe('E2E Auth Flow', () => { expect(registerRes.status).toBe(200); const otpRes = await request(app) - .get('/otp/generate-phone-otp') + .get('/otp/generate-email-otp') .set('Authorization', 'Bearer ephemeral-token'); expect(otpRes.status).toBe(200); - expect(generatePhoneOTP).toHaveBeenCalled(); + expect(generateEmailOTP).toHaveBeenCalled(); - (verifyPhoneOTP as any).mockResolvedValue({ + (verifyEmailOTP as any).mockResolvedValue({ user: { id: 'user-1', emailVerified: true, - phoneVerified: true, + phoneVerified: false, verified: true, roles: ['user'], }, @@ -95,7 +95,7 @@ describe('E2E Auth Flow', () => { }); const verifyRes = await request(app) - .post('/otp/verify-phone-otp') + .post('/otp/verify-email-otp') .set('Authorization', 'Bearer ephemeral-token') .send({ verificationToken: '123456' }); diff --git a/tests/factories/requestFactory.ts b/tests/factories/requestFactory.ts index 819617e..6186248 100644 --- a/tests/factories/requestFactory.ts +++ b/tests/factories/requestFactory.ts @@ -1,7 +1,6 @@ export function buildRegistrationRequest(overrides = {}) { return { email: 'test@example.com', - phone: '+14155552671', ...overrides, }; } diff --git a/tests/integration/registration/register.spec.ts b/tests/integration/registration/register.spec.ts index 61d729a..60e3a18 100644 --- a/tests/integration/registration/register.spec.ts +++ b/tests/integration/registration/register.spec.ts @@ -4,7 +4,7 @@ import { createApp } from '../../../src/app'; import { Application } from 'express'; import { buildUser } from '../../factories/userFactory.js'; -import { generatePhoneOTP } from '../../../src/utils/otp.js'; +import { generateEmailOTP, generatePhoneOTP, verifyPhoneOTP } from '../../../src/utils/otp.js'; vi.mock('../../../src/models/users.js', () => ({ User: { @@ -14,7 +14,9 @@ vi.mock('../../../src/models/users.js', () => ({ })); vi.mock('../../../src/utils/otp.js', () => ({ + generateEmailOTP: vi.fn(), generatePhoneOTP: vi.fn(), + verifyPhoneOTP: vi.fn(), })); vi.mock('../../../src/config/getSystemConfig.js', () => ({ @@ -50,14 +52,16 @@ beforeEach(() => { }); (signEphemeralToken as any).mockResolvedValue('mock-token'); + (generateEmailOTP as any).mockResolvedValue('EMAILME'); (generatePhoneOTP as any).mockResolvedValue(123456); + (verifyPhoneOTP as any).mockResolvedValue({ user: buildUser(), verified: true }); }); describe('POST /registration/register', () => { - it('creates a new user', async () => { + it('creates a new email-only user and sends an email OTP', async () => { (User.findOne as any).mockResolvedValue(null); - const user = buildUser(); + const user = buildUser({ phone: null }); (User.create as any).mockResolvedValue(user); @@ -68,9 +72,11 @@ describe('POST /registration/register', () => { expect(User.create).toHaveBeenCalled(); expect(signEphemeralToken).toHaveBeenCalledWith(user.id); + expect(generateEmailOTP).toHaveBeenCalledWith(user, { sendMessage: true }); + expect(generatePhoneOTP).not.toHaveBeenCalled(); }); - it('handles existing user', async () => { + it('handles existing user by sending an email OTP', async () => { const user = buildUser(); (User.findOne as any).mockResolvedValue(user); @@ -81,6 +87,45 @@ describe('POST /registration/register', () => { expect(User.create).not.toHaveBeenCalled(); expect(signEphemeralToken).toHaveBeenCalledWith(user.id); + expect(generateEmailOTP).toHaveBeenCalledWith(user, { sendMessage: true }); + expect(generatePhoneOTP).not.toHaveBeenCalled(); + }); + + it('returns external email OTP delivery payload', async () => { + (User.findOne as any).mockResolvedValue(null); + + const user = buildUser({ phone: null }); + + (User.create as any).mockResolvedValue(user); + + const res = await request(app) + .post('/registration/register') + .set('x-seamless-auth-delivery-mode', 'external') + .send(buildRegistrationRequest()); + + expect(res.status).toBe(200); + expect(res.body.delivery).toEqual({ + kind: 'otp_email', + to: user.email, + token: 'EMAILME', + }); + expect(generateEmailOTP).toHaveBeenCalledWith(user, { sendMessage: false }); + }); + + it('treats null phone as omitted', async () => { + (User.findOne as any).mockResolvedValue(null); + + const user = buildUser({ phone: null }); + + (User.create as any).mockResolvedValue(user); + + const res = await request(app) + .post('/registration/register') + .send(buildRegistrationRequest({ phone: null })); + + expect(res.status).toBe(200); + expect(User.create).toHaveBeenCalledWith(expect.objectContaining({ phone: null })); + expect(generateEmailOTP).toHaveBeenCalled(); }); it('rejects when email belongs to one user and phone is new', async () => { @@ -97,6 +142,7 @@ describe('POST /registration/register', () => { expect(User.create).not.toHaveBeenCalled(); expect(signEphemeralToken).not.toHaveBeenCalled(); expect(generatePhoneOTP).not.toHaveBeenCalled(); + expect(generateEmailOTP).not.toHaveBeenCalled(); }); it('rejects when phone belongs to one user and email is new', async () => { @@ -106,13 +152,14 @@ describe('POST /registration/register', () => { const res = await request(app) .post('/registration/register') - .send(buildRegistrationRequest({ email: 'other@example.com' })); + .send(buildRegistrationRequest({ email: 'other@example.com', phone: '+14155552671' })); expect(res.status).toBe(409); expect(res.body.error).toBe('Registration conflict'); expect(User.create).not.toHaveBeenCalled(); expect(signEphemeralToken).not.toHaveBeenCalled(); expect(generatePhoneOTP).not.toHaveBeenCalled(); + expect(generateEmailOTP).not.toHaveBeenCalled(); }); it('rejects when email and phone belong to different existing users', async () => { @@ -120,13 +167,16 @@ describe('POST /registration/register', () => { .mockResolvedValueOnce(buildUser({ id: 'user-1', email: 'test@example.com' })) .mockResolvedValueOnce(buildUser({ id: 'user-2', phone: '+14155552671' })); - const res = await request(app).post('/registration/register').send(buildRegistrationRequest()); + const res = await request(app) + .post('/registration/register') + .send(buildRegistrationRequest({ phone: '+14155552671' })); expect(res.status).toBe(409); expect(res.body.error).toBe('Registration conflict'); expect(User.create).not.toHaveBeenCalled(); expect(signEphemeralToken).not.toHaveBeenCalled(); expect(generatePhoneOTP).not.toHaveBeenCalled(); + expect(generateEmailOTP).not.toHaveBeenCalled(); }); it('fails without email', async () => { @@ -135,14 +185,6 @@ describe('POST /registration/register', () => { expect(res.status).toBe(400); }); - it('fails without phone', async () => { - const res = await request(app) - .post('/registration/register') - .send({ email: 'test@example.com' }); - - expect(res.status).toBe(400); - }); - it('fails invalid email', async () => { const res = await request(app) .post('/registration/register') @@ -159,3 +201,43 @@ describe('POST /registration/register', () => { expect(res.status).toBe(500); }); }); + +describe('POST /registration/phone', () => { + it('registers a phone number and sends a phone OTP', async () => { + (User.findOne as any).mockResolvedValue(null); + + const res = await request(app) + .post('/registration/phone') + .set('x-seamless-auth-delivery-mode', 'external') + .send({ phone: '+14155550000' }); + + expect(res.status).toBe(200); + expect(res.body.phone).toBe('+14155550000'); + expect(res.body.delivery).toEqual({ + kind: 'otp_sms', + to: '+14155550000', + token: 123456, + }); + expect(generatePhoneOTP).toHaveBeenCalled(); + }); + + it('rejects an in-use phone number', async () => { + (User.findOne as any).mockResolvedValue(buildUser({ id: 'other-user' })); + + const res = await request(app).post('/registration/phone').send({ phone: '+14155550000' }); + + expect(res.status).toBe(409); + expect(generatePhoneOTP).not.toHaveBeenCalled(); + }); +}); + +describe('POST /registration/phone/verify', () => { + it('verifies a registered phone number', async () => { + const res = await request(app) + .post('/registration/phone/verify') + .send({ verificationToken: '123456' }); + + expect(res.status).toBe(200); + expect(verifyPhoneOTP).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/middleware/rateLimit.spec.ts b/tests/unit/middleware/rateLimit.spec.ts index 41f749e..11e3b3c 100644 --- a/tests/unit/middleware/rateLimit.spec.ts +++ b/tests/unit/middleware/rateLimit.spec.ts @@ -7,13 +7,29 @@ vi.mock('../../../src/config/getSystemConfig', () => ({ vi.mock('express-rate-limit', () => { return { - default: vi.fn(() => vi.fn((req, _res, next) => next())), + default: vi.fn((options = {}) => + vi.fn(async (req, res, next) => { + if (typeof (options as any).limit === 'function') { + await (options as any).limit(req, res); + } + + next(); + }), + ), }; }); vi.mock('express-slow-down', () => { return { - default: vi.fn(() => vi.fn((req, _res, next) => next())), + default: vi.fn((options = {}) => + vi.fn(async (req, res, next) => { + if (typeof (options as any).delayAfter === 'function') { + await (options as any).delayAfter(req, res); + } + + next(); + }), + ), }; }); @@ -46,10 +62,11 @@ describe('dynamicSlowDown', () => { expect(slowDown.default).toHaveBeenCalledWith( expect.objectContaining({ - delayAfter: 10, + delayAfter: expect.any(Function), }), ); + expect(getSystemConfig).toHaveBeenCalledTimes(1); expect(next).toHaveBeenCalled(); }); @@ -104,14 +121,15 @@ describe('dynamicRateLimit', () => { expect(rateLimit.default).toHaveBeenCalledWith( expect.objectContaining({ - max: 100, + limit: expect.any(Function), }), ); + expect(getSystemConfig).toHaveBeenCalledTimes(1); expect(next).toHaveBeenCalled(); }); - it('caches limiter', async () => { + it('creates limiter instances once at module initialization', async () => { const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); const rateLimit = await import('express-rate-limit'); @@ -122,12 +140,12 @@ describe('dynamicRateLimit', () => { await dynamicRateLimit(req, res, next); await dynamicRateLimit(req, res, next); - expect(rateLimit.default).toHaveBeenCalledTimes(1); + expect(rateLimit.default).toHaveBeenCalledTimes(3); }); }); describe('magicLinkIpLimiter', () => { - it('uses fixed max of 20', async () => { + it('uses fixed limit of 20', async () => { const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); const rateLimit = await import('express-rate-limit'); @@ -142,7 +160,7 @@ describe('magicLinkIpLimiter', () => { expect(rateLimit.default).toHaveBeenCalledWith( expect.objectContaining({ - max: 20, + limit: 20, }), ); }); @@ -171,19 +189,48 @@ describe('magicLinkEmailLimiter', () => { expect.objectContaining({ keyGenerator: expect.any(Function), legacyHeaders: false, - max: 5, + limit: 5, standardHeaders: true, windowMs: 15 * 60 * 1000, }), ); - const options = (rateLimit.default as any).mock.calls[0][0]; + const options = (rateLimit.default as any).mock.calls.find( + ([options]: any[]) => options.keyGenerator, + )[0]; expect(options.keyGenerator(req)).toBe('email:test@example.com'); expect(options.keyGenerator({ ip: '127.0.0.1' })).toBe('ip:127.0.0.1'); }); }); +describe('dynamicJWKSRateLimit', () => { + it('uses config rate_limit and invokes the cached limiter', async () => { + const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); + const rateLimit = await import('express-rate-limit'); + + (getSystemConfig as any).mockResolvedValue({ rate_limit: 100 }); + + const { dynamicJWKSRateLimit } = await import('../../../src/middleware/jwksRateLimit'); + const limiter = (rateLimit.default as any).mock.results[0].value; + const next = vi.fn(); + const req = {}; + const res = {}; + + // @ts-ignore + await dynamicJWKSRateLimit(req, res, next); + + expect(rateLimit.default).toHaveBeenCalledWith( + expect.objectContaining({ + limit: expect.any(Function), + }), + ); + expect(limiter).toHaveBeenCalledWith(req, res, next); + expect(getSystemConfig).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalled(); + }); +}); + describe('rate limiter caches', () => { it('keeps dynamic and magic link limiter instances isolated', async () => { const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); @@ -204,8 +251,15 @@ describe('rate limiter caches', () => { await magicLinkEmailLimiter({}, {}, next); expect(rateLimit.default).toHaveBeenCalledTimes(3); - expect((rateLimit.default as any).mock.calls.map(([options]: any[]) => options.max)).toEqual([ - 100, 20, 5, + expect((rateLimit.default as any).mock.calls[0][0]).toEqual( + expect.objectContaining({ + limit: expect.any(Function), + }), + ); + expect((rateLimit.default as any).mock.calls.map(([options]: any[]) => options.limit)).toEqual([ + expect.any(Function), + 20, + 5, ]); }); }); diff --git a/tests/unit/services/apiResponseSerializers.spec.ts b/tests/unit/services/apiResponseSerializers.spec.ts index 3f0847b..9fc673c 100644 --- a/tests/unit/services/apiResponseSerializers.spec.ts +++ b/tests/unit/services/apiResponseSerializers.spec.ts @@ -36,6 +36,23 @@ describe('api response serializers', () => { expect(user).not.toHaveProperty('phoneVerificationToken'); }); + it('preserves null phone values in user responses', () => { + const user = serializeApiUser({ + id: 'user-1', + email: 'test@example.com', + phone: null, + roles: ['user'], + }); + + expect(user).toEqual( + expect.objectContaining({ + id: 'user-1', + email: 'test@example.com', + phone: null, + }), + ); + }); + it('minimizes credential responses without public key material', () => { const credential = serializeCredential({ id: 'credential-1', diff --git a/tests/unit/utils/otp.spec.ts b/tests/unit/utils/otp.spec.ts index 45ed6b4..cc740fe 100644 --- a/tests/unit/utils/otp.spec.ts +++ b/tests/unit/utils/otp.spec.ts @@ -110,12 +110,14 @@ describe('OTP utils', () => { const user = buildUser({ phoneVerificationToken: '123456', phoneVerificationTokenExpiry: Date.now() + 10000, + emailVerified: true, }); const result = await verifyPhoneOTP(user as any, '123456'); expect(result.verified).toBe(true); expect(user.phoneVerified).toBe(true); + expect(user.verified).toBe(true); expect(user.save).toHaveBeenCalled(); }); @@ -156,12 +158,14 @@ describe('OTP utils', () => { const user = buildUser({ emailVerificationToken: 'ABCDEF', emailVerificationTokenExpiry: Date.now() + 10000, + phone: null, }); const result = await verifyEmailOTP(user as any, 'abcdef'); expect(result.verified).toBe(true); expect(user.emailVerified).toBe(true); + expect(user.verified).toBe(true); expect(user.save).toHaveBeenCalled(); }); From 9d045c40d3ecc22c5f7b8841a63c411a3c38fcbe Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 7 Jun 2026 21:31:30 -0400 Subject: [PATCH 2/2] chore: add test --- tests/integration/jwks/jwks.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/jwks/jwks.spec.ts b/tests/integration/jwks/jwks.spec.ts index b84d576..9b763ab 100644 --- a/tests/integration/jwks/jwks.spec.ts +++ b/tests/integration/jwks/jwks.spec.ts @@ -101,7 +101,7 @@ describe('JWKS - Production Mode', () => { expect(res.status).toBe(200); expect(res.body.keys[0].kid).toBe('key-1'); - expect(getSecret).toHaveBeenCalledWith('SEAMLESS_JWKS_PUBLIC_KEYS'); + expect(getSecret).toHaveBeenCalledWith('JWKS_PUBLIC_KEYS'); expect(res.headers['cache-control']).toContain('max-age=300'); }); });