Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/app/api/auth/verify-sms/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
77 changes: 77 additions & 0 deletions src/app/api/auth/verify-sms/route.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading