diff --git a/src/app/api/auth/verify-sms/route.js b/src/app/api/auth/verify-sms/route.js index bd79e8e..3cb90c6 100644 --- a/src/app/api/auth/verify-sms/route.js +++ b/src/app/api/auth/verify-sms/route.js @@ -125,7 +125,25 @@ export async function POST(request, { params } = {}) { userId: user.id, userPhone: user.phone }); - + + // Bind the requested phone number to the JWT-verified phone. + // The session branch skips OTP, so without this a caller holding + // ANY valid session (their own verified number, or an anonymous + // session) could complete signup claiming a phone they never + // verified — that value is persisted as `phone_number` below. + // Compare bare digits so a leading '+' difference doesn't matter. + const verifiedPhone = (user.phone || '').replace(/\D/g, ''); + const requestedPhone = phoneNumber.replace(/\D/g, ''); + if (!verifiedPhone || verifiedPhone !== requestedPhone) { + logger.error('Session phone does not match verified phone', { + hasVerifiedPhone: !!user.phone + }); + return NextResponse.json( + { error: 'Phone number does not match verified session', code: 'PHONE_SESSION_MISMATCH' }, + { status: 403 } + ); + } + // Use the authenticated user data verifyData = { user, diff --git a/src/app/api/auth/verify-sms/route.test.js b/src/app/api/auth/verify-sms/route.test.js new file mode 100644 index 0000000..4e52fe2 --- /dev/null +++ b/src/app/api/auth/verify-sms/route.test.js @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the server + service Supabase clients. The phone-binding guard runs +// right after getUser(), before any DB work, so only getUser needs behaviour. +const mocks = vi.hoisted(() => ({ + getUser: vi.fn(), + serverClient: vi.fn(), + createClient: vi.fn(() => ({})) +})); + +vi.mock('@/lib/supabase.js', () => ({ + createSupabaseServerClient: mocks.serverClient +})); + +vi.mock('@supabase/supabase-js', () => ({ + createClient: mocks.createClient +})); + +function sessionRequest(body) { + return new Request('https://example.com/api/auth/verify-sms', { + method: 'POST', + headers: { + authorization: 'Bearer test-session-token', + 'content-type': 'application/json' + }, + body: JSON.stringify(body) + }); +} + +describe('verify-sms session phone binding', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://example.supabase.co'; + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key'; + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key'; + mocks.serverClient.mockResolvedValue({ + auth: { getUser: mocks.getUser } + }); + }); + + it('rejects session signup when the requested phone != JWT-verified phone', async () => { + // The JWT belongs to +15550000000, but the client claims +15559999999. + mocks.getUser.mockResolvedValue({ + data: { user: { id: 'user-1', phone: '+15550000000' } }, + error: null + }); + + const { POST } = await import('./route.js'); + const res = await POST(sessionRequest({ + useSession: true, + phoneNumber: '+15559999999', + username: 'alice' + })); + const body = await res.json(); + + expect(res.status).toBe(403); + expect(body.code).toBe('PHONE_SESSION_MISMATCH'); + expect(mocks.getUser).toHaveBeenCalledWith('test-session-token'); + }); + + it('rejects anonymous / phoneless sessions', async () => { + mocks.getUser.mockResolvedValue({ + data: { user: { id: 'anon', phone: null } }, + error: null + }); + + const { POST } = await import('./route.js'); + const res = await POST(sessionRequest({ + useSession: true, + phoneNumber: '+15559999999', + username: 'bob' + })); + + expect(res.status).toBe(403); + }); +});