diff --git a/.changeset/verified-phone-for-sign-in.md b/.changeset/verified-phone-for-sign-in.md new file mode 100644 index 00000000..be724bff --- /dev/null +++ b/.changeset/verified-phone-for-sign-in.md @@ -0,0 +1,10 @@ +--- +'seamless-auth-api': patch +--- + +Only a verified phone number signs in by phone OTP. + +- `POST /login` finds an account by phone only when that phone is verified, and offers `phone_otp` only for a verified phone. +- `/otp/generate-login-phone-otp` answers an unverified phone as it answers an account with no phone (`400`), and `/otp/verify-login-phone-otp` refuses it (`401`). +- The registration phone OTP routes (`/otp/generate-phone-otp`, `/otp/verify-phone-otp`) no longer verify a phone on an account that is already verified. Sending answers `200` and sends nothing; verifying answers `401`. Add or verify a phone on a verified account with an access session through `/registration/phone`. +- The policy dry run counts an unverified phone as no phone. diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index c1d019bc..96cf9d1c 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -270,7 +270,7 @@ export const login = async (req: Request, res: Response) => { } else if (isValidPhoneNumber(identifier) && normalizedIdentifier) { try { user = await User.findOne({ - where: { phone: normalizedIdentifier }, + where: { phone: normalizedIdentifier, phoneVerified: true }, }); identifierType = 'phone'; decoyIdentifier = normalizedIdentifier; diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index 5a771b39..4fb23bfc 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -226,6 +226,43 @@ export const sendLoginPhoneOTP = async (req: Request, res: Response) => { return; } + const user = (req as AuthenticatedRequest).user; + + // Answered as for an account with no phone, which is what `/login` offered it. + if (user.phone && !user.phoneVerified) { + await AuthEventService.log({ + userId: user.id, + type: 'otp_suspicious', + req, + metadata: { reason: 'Login phone OTP requested for an unverified phone.' }, + }); + return res.status(400).json({ error: 'Invalid data' }); + } + + return sendPhoneOTP(req, res); +}; + +/** + * Proving a phone on an account that already signs in changes what it can sign in with, + * which takes an access session (`/registration/phone`). The pre-auth token here can come + * from an email address alone, so for a verified account nothing is sent and the answer + * is the one a decoy gives. + */ +export const sendRegistrationPhoneOTP = async (req: Request, res: Response) => { + const authReq = req as AuthenticatedRequest; + const user = authReq.user; + + if (user.verified && user.phone) { + await AuthEventService.log({ + userId: user.id, + type: 'otp_suspicious', + req, + metadata: { reason: 'Registration phone OTP requested for a verified account.' }, + }); + const token = await signEphemeralToken(user.id, authReq.attemptId); + return res.status(200).json({ message: 'success', token }); + } + return sendPhoneOTP(req, res); }; @@ -256,6 +293,18 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { logger.info('Verifying phone number'); + // See sendRegistrationPhoneOTP. A code sent before the account was claimed must not + // verify a phone on it afterwards. + if (user.verified) { + await AuthEventService.log({ + userId: user.id, + type: 'verify_otp_suspicious', + req, + metadata: { reason: 'Registration phone verification on a verified account.' }, + }); + return res.status(401).json({ error: 'Not allowed' }); + } + if (!user || !user.phoneVerificationTokenExpiry || !user.phoneVerificationToken) { logger.warn('Failed to find a user for this phone verification token'); await AuthEventService.log({ @@ -463,6 +512,18 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { logger.info('Verifying login phone number'); + // A code sent to an unverified phone, through registration before the account was + // claimed, is not a sign-in. + if (!user.phoneVerified) { + await AuthEventService.log({ + userId: user.id, + type: 'verify_otp_suspicious', + req, + metadata: { reason: 'Login phone verification for an unverified phone.' }, + }); + return res.status(401).json({ error: 'Not allowed' }); + } + if (!user || !user.phoneVerificationTokenExpiry || !user.phoneVerificationToken) { logger.warn('Failed to find a user for this phone verification token'); await AuthEventService.log({ diff --git a/src/routes/otp.routes.ts b/src/routes/otp.routes.ts index bd31f281..365bb20e 100644 --- a/src/routes/otp.routes.ts +++ b/src/routes/otp.routes.ts @@ -17,8 +17,8 @@ import { import { sendLoginEmailOTP, sendLoginPhoneOTP, - sendPhoneOTP, sendRegistrationEmailOTP, + sendRegistrationPhoneOTP, verifyEmail, verifyLoginEmail, verifyLoginPhoneNumber, @@ -97,7 +97,7 @@ otpRouter.post( }, }, }, - sendPhoneOTP, + sendRegistrationPhoneOTP, ); otpRouter.get( @@ -121,7 +121,7 @@ otpRouter.get( }, }, }, - sendPhoneOTP, + sendRegistrationPhoneOTP, ); otpRouter.post( diff --git a/src/services/loginPolicyService.ts b/src/services/loginPolicyService.ts index e0399a55..09b24f14 100644 --- a/src/services/loginPolicyService.ts +++ b/src/services/loginPolicyService.ts @@ -20,6 +20,7 @@ export interface LoginPolicy { type LoginMethodUser = { email?: string | null; phone?: string | null; + phoneVerified?: boolean | null; }; const LOGIN_METHOD_ORDER: LoginMethod[] = [ @@ -126,7 +127,9 @@ export function resolveAvailableLoginMethods({ return false; } - return hasValue(user.phone); + // An unverified phone may have been planted by whoever registered the address + // first, so it is never a way in. + return hasValue(user.phone) && user.phoneVerified === true; }); } diff --git a/src/services/policyDryRun.ts b/src/services/policyDryRun.ts index c2d2dfee..2dd462b5 100644 --- a/src/services/policyDryRun.ts +++ b/src/services/policyDryRun.ts @@ -109,7 +109,7 @@ export function hasUsableFactor( ) { const methods = resolveAvailableLoginMethods({ policy, - user: { email: account.email, phone: account.phone }, + user: { email: account.email, phone: account.phone, phoneVerified: account.phone !== null }, hasPasskeyCredential: account.hasPasskey, }); @@ -158,7 +158,7 @@ async function signInsInWindow(from: Date, to: Date, cap: number) { ) ), scoped AS ( - SELECT u.id, u.email, u.phone + SELECT u.id, u.email, CASE WHEN u.phone_verified THEN u.phone END AS phone FROM users u WHERE u.revoked = false AND u.id IN (SELECT user_id FROM sign_ins) diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index 1025a889..7b77397a 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -216,7 +216,9 @@ describe('POST /login', () => { expect(res.status).toBe(200); expect(res.body.identifierType).toBe('phone'); - expect(User.findOne).toHaveBeenCalledWith({ where: { phone: expect.any(String) } }); + expect(User.findOne).toHaveBeenCalledWith({ + where: { phone: expect.any(String), phoneVerified: true }, + }); }); it('reports a server error when the email lookup throws', async () => { diff --git a/tests/integration/otp/otp.spec.ts b/tests/integration/otp/otp.spec.ts index eeeeacfd..57e2870f 100644 --- a/tests/integration/otp/otp.spec.ts +++ b/tests/integration/otp/otp.spec.ts @@ -49,11 +49,11 @@ beforeEach(() => { }); describe('OTP - Generate', () => { - it('generates phone OTP', async () => { + it('sends no registration phone OTP to a verified account', async () => { const res = await request(app).get('/otp/generate-phone-otp'); expect(res.status).toBe(200); - expect(generatePhoneOTP).toHaveBeenCalled(); + expect(generatePhoneOTP).not.toHaveBeenCalled(); expect(res.body.message).toBe('success'); }); @@ -126,23 +126,13 @@ describe('OTP - Verify Phone', () => { expect(res.status).toBe(401); }); - it('succeeds when OTP valid', async () => { - (verifyPhoneOTP as any).mockResolvedValue({ - user: { - id: 'user-1', - emailVerified: true, - phoneVerified: true, - verified: true, - roles: ['user'], - }, - verified: true, - }); - + it('refuses a registration phone code on a verified account', async () => { const res = await request(app) .post('/otp/verify-phone-otp') .send({ verificationToken: '123456' }); - expect(res.status).toBe(200); + expect(res.status).toBe(401); + expect(verifyPhoneOTP).not.toHaveBeenCalled(); }); }); diff --git a/tests/postgres/unverifiedPhoneClaim.spec.ts b/tests/postgres/unverifiedPhoneClaim.spec.ts new file mode 100644 index 00000000..547e606c --- /dev/null +++ b/tests/postgres/unverifiedPhoneClaim.spec.ts @@ -0,0 +1,217 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { invalidateSystemConfigCache } from '../../src/config/getSystemConfig.js'; +import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { SystemConfig } from '../../src/models/systemConfig.js'; +import { User } from '../../src/models/users.js'; +import { buildSystemConfig } from '../factories/systemConfigFactory.js'; + +const EXTERNAL = { 'x-seamless-auth-delivery-mode': 'external' }; + +let app: Awaited>; +let storedLoginMethods: unknown; + +function codeFrom(res: request.Response) { + return String(res.body.delivery?.token); +} + +beforeAll(async () => { + vi.stubEnv('ISSUER', 'http://localhost:5312'); + vi.stubEnv('ALLOW_UNCREDENTIALED_DELIVERY_SECRETS', 'true'); + vi.stubEnv('DISABLE_AUTH_RATE_LIMITS', 'true'); + vi.stubEnv('SEAMLESS_DEV_KEYS_DIR', fs.mkdtempSync(path.join(os.tmpdir(), 'seamless-keys-'))); + + await initializeModels(); + + for (const [key, value] of Object.entries(buildSystemConfig())) { + await getSequelize().query( + `INSERT INTO system_config (key, value, "createdAt", "updatedAt") + VALUES (:key, :value, now(), now()) + ON CONFLICT (key) DO NOTHING`, + { replacements: { key, value: JSON.stringify(value) } }, + ); + } + + storedLoginMethods = (await SystemConfig.findByPk('login_methods'))?.value; + await SystemConfig.update( + { value: ['passkey', 'magic_link', 'email_otp', 'phone_otp'] }, + { where: { key: 'login_methods' } }, + ); + invalidateSystemConfigCache(); + + const { initDevSigningKey } = await import('../../src/utils/signingKeyStore.js'); + const { createApp } = await import('../../src/app.js'); + initDevSigningKey(); + app = await createApp(); +}); + +afterAll(async () => { + // Every file shares the one database, and phone is unique. + await getSequelize().query(`DELETE FROM users WHERE email LIKE :pattern`, { + replacements: { pattern: '%@unverified-phone.test' }, + }); + await SystemConfig.update({ value: storedLoginMethods }, { where: { key: 'login_methods' } }); + invalidateSystemConfigCache(); + vi.unstubAllEnvs(); + await getSequelize().close(); +}); + +/** Registers `email` with `phone` attached, then has the owner claim it by email code. */ +async function plantPhoneThenClaim(email: string, phone: string) { + const planted = await request(app) + .post('/registration/register') + .set(EXTERNAL) + .send({ email, phone }); + expect(planted.status).toBe(200); + + const owner = await request(app).post('/registration/register').set(EXTERNAL).send({ email }); + expect(owner.status).toBe(200); + + const claimed = await request(app) + .post('/otp/verify-email-otp') + .set('Authorization', `Bearer ${owner.body.token}`) + .send({ verificationToken: codeFrom(owner) }); + expect(claimed.status).toBe(200); + + const user = await User.findOne({ where: { email } }); + expect(user).toEqual(expect.objectContaining({ verified: true, phone, phoneVerified: false })); + + return { user: user! }; +} + +// Whoever registers an address first can attach a phone to the account it creates. The +// owner later claims the account by proving the address, and the phone, never verified, +// stays on it. It must not become a way into the owner's account. +describe('a phone planted before the owner claims the account', () => { + it('does not sign in by phone OTP', async () => { + const phone = '+14155552681'; + const { user } = await plantPhoneThenClaim('planted-login@unverified-phone.test', phone); + + const login = await request(app).post('/login').send({ identifier: phone }); + expect(login.status).toBe(200); + expect(login.body.sub).not.toBe(user.id); + + const byEmail = await request(app) + .post('/login') + .send({ identifier: 'planted-login@unverified-phone.test' }); + expect(byEmail.body.sub).toBe(user.id); + expect(byEmail.body.loginMethods).not.toContain('phone_otp'); + + const send = await request(app) + .get('/otp/generate-login-phone-otp') + .set(EXTERNAL) + .set('Authorization', `Bearer ${byEmail.body.token}`); + expect(send.status).toBe(400); + + await user.reload(); + expect(user.phoneVerificationToken).toBeNull(); + }); + + it('does not accept a code sent before the claim', async () => { + const email = 'planted-early@unverified-phone.test'; + const planted = await request(app) + .post('/registration/register') + .set(EXTERNAL) + .send({ email, phone: '+14155552682' }); + + // Sent while the account is still unclaimed, so it really goes to the planted phone. + const early = await request(app) + .get('/otp/generate-phone-otp') + .set(EXTERNAL) + .set('Authorization', `Bearer ${planted.body.token}`); + expect(early.body.delivery?.kind).toBe('otp_sms'); + + const owner = await request(app).post('/registration/register').set(EXTERNAL).send({ email }); + await request(app) + .post('/otp/verify-email-otp') + .set('Authorization', `Bearer ${owner.body.token}`) + .send({ verificationToken: codeFrom(owner) }); + + const byEmail = await request(app).post('/login').send({ identifier: email }); + + for (const [route, token] of [ + ['/otp/verify-login-phone-otp', byEmail.body.token], + ['/otp/verify-phone-otp', byEmail.body.token], + ['/otp/verify-phone-otp', planted.body.token], + ]) { + const verify = await request(app) + .post(route) + .set('Authorization', `Bearer ${token}`) + .send({ verificationToken: codeFrom(early) }); + expect(verify.status).toBe(401); + expect(verify.body.token).toBeUndefined(); + } + + const user = await User.findOne({ where: { email } }); + expect(user).toEqual(expect.objectContaining({ verified: true, phoneVerified: false })); + }); + + it('cannot be verified through registration once the account is claimed', async () => { + const phone = '+14155552683'; + const { user } = await plantPhoneThenClaim('planted-verify@unverified-phone.test', phone); + + const again = await request(app) + .post('/registration/register') + .set(EXTERNAL) + .send({ email: 'planted-verify@unverified-phone.test' }); + + const send = await request(app) + .get('/otp/generate-phone-otp') + .set(EXTERNAL) + .set('Authorization', `Bearer ${again.body.token}`); + expect(send.status).toBe(200); + expect(send.body.delivery).toBeUndefined(); + + await user.reload(); + expect(user.phoneVerificationToken).toBeNull(); + expect(user.phoneVerified).toBe(false); + }); +}); + +describe('phone-first sign up', () => { + it('still signs in by phone OTP', async () => { + const email = 'phone-first@unverified-phone.test'; + const phone = '+14155552684'; + + const registered = await request(app) + .post('/registration/register') + .set(EXTERNAL) + .send({ email, phone }); + const token = registered.body.token; + + const sms = await request(app) + .get('/otp/generate-phone-otp') + .set(EXTERNAL) + .set('Authorization', `Bearer ${token}`); + const phoneVerified = await request(app) + .post('/otp/verify-phone-otp') + .set('Authorization', `Bearer ${token}`) + .send({ verificationToken: codeFrom(sms) }); + expect(phoneVerified.status).toBe(200); + + const emailVerified = await request(app) + .post('/otp/verify-email-otp') + .set('Authorization', `Bearer ${token}`) + .send({ verificationToken: codeFrom(registered) }); + expect(emailVerified.status).toBe(200); + + const login = await request(app).post('/login').send({ identifier: phone }); + expect(login.body.loginMethods).toContain('phone_otp'); + + const send = await request(app) + .get('/otp/generate-login-phone-otp') + .set(EXTERNAL) + .set('Authorization', `Bearer ${login.body.token}`); + const signedIn = await request(app) + .post('/otp/verify-login-phone-otp') + .set('Authorization', `Bearer ${login.body.token}`) + .send({ verificationToken: codeFrom(send) }); + + expect(signedIn.status).toBe(200); + expect(signedIn.body.email).toBe(email); + }); +}); diff --git a/tests/unit/controllers/otp.spec.ts b/tests/unit/controllers/otp.spec.ts index 61c81cad..5a2501a8 100644 --- a/tests/unit/controllers/otp.spec.ts +++ b/tests/unit/controllers/otp.spec.ts @@ -96,6 +96,11 @@ function buildUser(overrides: Partial = {}): MockUser { }; } +/** An account registration has created but not yet claimed. */ +function unclaimedUser(overrides: Partial = {}): MockUser { + return buildUser({ verified: false, emailVerified: false, phoneVerified: false, ...overrides }); +} + function buildReq(user: MockUser, overrides: Record = {}) { const headers: Record = { ...(overrides.headers as Record | undefined), @@ -247,6 +252,7 @@ describe('otp controller', () => { it('returns 401 when phone verification data is missing', async () => { const { verifyPhoneNumber } = await loadOtpController(); const user = buildUser({ + verified: false, phoneVerificationToken: undefined, phoneVerificationTokenExpiry: null, }); @@ -268,7 +274,7 @@ describe('otp controller', () => { emailVerified: false, verified: false, }); - const req = buildReq(buildUser(), { + const req = buildReq(buildUser({ verified: false, phoneVerified: false }), { body: { verificationToken: '123456' }, }); const res = buildRes(); @@ -627,6 +633,67 @@ describe('otp controller', () => { expect(res.status).toHaveBeenCalledWith(200); }); + it('answers a login phone OTP for an unverified phone as for no phone', async () => { + const { sendLoginPhoneOTP } = await loadOtpController(); + const res = buildRes(); + + await sendLoginPhoneOTP(buildReq(buildUser({ phoneVerified: false })), res); + + expect(generatePhoneOTPMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid data' }); + }); + + it('refuses login phone verification for an unverified phone', async () => { + const { verifyLoginPhoneNumber } = await loadOtpController(); + const res = buildRes(); + + await verifyLoginPhoneNumber( + buildReq(buildUser({ phoneVerified: false }), { body: { verificationToken: '123456' } }), + res, + ); + + expect(verifyPhoneOTPMock).not.toHaveBeenCalled(); + expect(issueSessionAndRespondMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Not allowed' }); + }); + + it('sends the registration phone OTP for an account not yet claimed', async () => { + const { sendRegistrationPhoneOTP } = await loadOtpController(); + const res = buildRes(); + + await sendRegistrationPhoneOTP(buildReq(unclaimedUser()), res); + + expect(generatePhoneOTPMock).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('sends no registration phone OTP to a verified account', async () => { + const { sendRegistrationPhoneOTP } = await loadOtpController(); + const res = buildRes(); + + await sendRegistrationPhoneOTP(buildReq(buildUser({ phoneVerified: false })), res); + + expect(generatePhoneOTPMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ message: 'success', token: 'ephemeral-token' }); + }); + + it('refuses registration phone verification on a verified account', async () => { + const { verifyPhoneNumber } = await loadOtpController(); + const res = buildRes(); + + await verifyPhoneNumber( + buildReq(buildUser({ phoneVerified: false }), { body: { verificationToken: '123456' } }), + res, + ); + + expect(verifyPhoneOTPMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Not allowed' }); + }); + it('sends the login email OTP when email OTP login is enabled', async () => { const { sendLoginEmailOTP } = await loadOtpController(); const res = buildRes(); @@ -666,7 +733,7 @@ describe('otp controller', () => { const { verifyPhoneNumber } = await loadOtpController(); const res = buildRes(); - await verifyPhoneNumber(buildReq(buildUser(), { body: {} }), res); + await verifyPhoneNumber(buildReq(unclaimedUser(), { body: {} }), res); expect(verifyPhoneOTPMock).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(401); @@ -678,7 +745,7 @@ describe('otp controller', () => { const res = buildRes(); await verifyPhoneNumber( - buildReq(buildUser({ phone: null }), { body: { verificationToken: '123456' } }), + buildReq(unclaimedUser({ phone: null }), { body: { verificationToken: '123456' } }), res, ); @@ -692,7 +759,10 @@ describe('otp controller', () => { const verifiedUser = buildUser({ phoneVerified: true, emailVerified: true, verified: true }); verifyPhoneOTPMock.mockResolvedValue({ user: verifiedUser, verified: true }); - await verifyPhoneNumber(buildReq(buildUser(), { body: { verificationToken: '123456' } }), res); + await verifyPhoneNumber( + buildReq(unclaimedUser(), { body: { verificationToken: '123456' } }), + res, + ); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ message: 'Success' }); @@ -703,7 +773,10 @@ describe('otp controller', () => { const res = buildRes(); verifyPhoneOTPMock.mockResolvedValue({ user: buildUser(), verified: false }); - await verifyPhoneNumber(buildReq(buildUser(), { body: { verificationToken: 'wrong' } }), res); + await verifyPhoneNumber( + buildReq(unclaimedUser(), { body: { verificationToken: 'wrong' } }), + res, + ); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Not allowed' }); @@ -714,7 +787,10 @@ describe('otp controller', () => { const res = buildRes(); verifyPhoneOTPMock.mockRejectedValue(new Error('db down')); - await verifyPhoneNumber(buildReq(buildUser(), { body: { verificationToken: '123456' } }), res); + await verifyPhoneNumber( + buildReq(unclaimedUser(), { body: { verificationToken: '123456' } }), + res, + ); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }); diff --git a/tests/unit/services/loginPolicyService.spec.ts b/tests/unit/services/loginPolicyService.spec.ts index d0e99c64..26391444 100644 --- a/tests/unit/services/loginPolicyService.spec.ts +++ b/tests/unit/services/loginPolicyService.spec.ts @@ -77,7 +77,7 @@ describe('loginPolicyService', () => { expect( resolveAvailableLoginMethods({ policy, - user: { email: 'test@example.com', phone: '+14155552671' }, + user: { email: 'test@example.com', phone: '+14155552671', phoneVerified: true }, hasPasskeyCredential: true, passkeyAvailable: true, }), @@ -94,7 +94,7 @@ describe('loginPolicyService', () => { const available = resolveAvailableLoginMethods({ policy, - user: { email: 'test@example.com', phone: '+14155552671' }, + user: { email: 'test@example.com', phone: '+14155552671', phoneVerified: true }, hasPasskeyCredential: false, passkeyAvailable: true, }); @@ -103,6 +103,21 @@ describe('loginPolicyService', () => { expect(available).not.toContain('oauth'); }); + it('does not offer phone OTP for an unverified phone', () => { + const policy = normalizeLoginPolicy({ + login_methods: ['magic_link', 'phone_otp'], + passkey_login_fallback_enabled: true, + }); + + expect( + resolveAvailableLoginMethods({ + policy, + user: { email: 'test@example.com', phone: '+14155552671', phoneVerified: false }, + hasPasskeyCredential: false, + }), + ).toEqual(['magic_link']); + }); + it('allows configured fallback methods when passkey is unavailable on the client', () => { const policy = normalizeLoginPolicy({ login_methods: ['passkey', 'magic_link', 'phone_otp'], @@ -112,7 +127,7 @@ describe('loginPolicyService', () => { expect( resolveAvailableLoginMethods({ policy, - user: { email: 'test@example.com', phone: '+14155552671' }, + user: { email: 'test@example.com', phone: '+14155552671', phoneVerified: true }, hasPasskeyCredential: true, passkeyAvailable: false, }), @@ -130,7 +145,7 @@ describe('loginPolicyService', () => { expect( resolveAvailableLoginMethods({ policy: passkeyOnly(), - user: { email: 'test@example.com', phone: '+14155552671' }, + user: { email: 'test@example.com', phone: '+14155552671', phoneVerified: true }, hasPasskeyCredential: true, passkeyAvailable: false, }), @@ -141,7 +156,7 @@ describe('loginPolicyService', () => { expect( resolveAvailableLoginMethods({ policy: passkeyOnly(), - user: { email: 'test@example.com', phone: '+14155552671' }, + user: { email: 'test@example.com', phone: '+14155552671', phoneVerified: true }, hasPasskeyCredential: true, passkeyAvailable: true, }), @@ -152,7 +167,7 @@ describe('loginPolicyService', () => { expect( resolveAvailableLoginMethods({ policy: passkeyOnly(), - user: { email: 'test@example.com', phone: '+14155552671' }, + user: { email: 'test@example.com', phone: '+14155552671', phoneVerified: true }, hasPasskeyCredential: true, }), ).toEqual(['passkey']); @@ -164,7 +179,7 @@ describe('loginPolicyService', () => { expect( resolveAvailableLoginMethods({ policy: passkeyOnly(), - user: { email: 'test@example.com', phone: '+14155552671' }, + user: { email: 'test@example.com', phone: '+14155552671', phoneVerified: true }, hasPasskeyCredential: false, passkeyAvailable: false, }), @@ -178,7 +193,7 @@ describe('loginPolicyService', () => { login_methods: ['magic_link', 'phone_otp'], passkey_login_fallback_enabled: false, }), - user: { email: 'test@example.com', phone: '+14155552671' }, + user: { email: 'test@example.com', phone: '+14155552671', phoneVerified: true }, hasPasskeyCredential: true, passkeyAvailable: false, }), @@ -219,7 +234,7 @@ describe('loginPolicyService', () => { expect( resolveAvailableLoginMethods({ policy, - user: { email: 'a@example.com', phone: '+15555555555' }, + user: { email: 'a@example.com', phone: '+15555555555', phoneVerified: true }, hasPasskeyCredential: false, }), ).toEqual([]);