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
6 changes: 3 additions & 3 deletions src/app/api/auth/hooks/send-sms/route.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { NextResponse } from 'next/server';
import { e164, sendViaTelnyx, smsText, verifyHook } from '@/lib/auth/sms-hook.js';
import { e164, sendCode, verifyHook } from '@/lib/auth/sms-hook.js';

/**
* POST /api/auth/hooks/send-sms: Supabase Auth's Send SMS hook.
* GoTrue calls this with { user, sms: { otp } } for every phone code it issues;
* we deliver it through Telnyx. Errors use the hook error shape so GoTrue
* we deliver it through Telnyx Verify (see sms-hook.js for why not a plain SMS). Errors use the hook error shape so GoTrue
* passes a useful message back to the sign-in form.
*/
const fail = (status, message) => NextResponse.json({ error: { http_code: status, message } }, { status });
Expand Down Expand Up @@ -36,7 +36,7 @@ export async function POST(request) {
if (!to || !otp) return fail(400, 'Missing phone or code');

try {
await sendViaTelnyx({ to, text: smsText(otp) });
await sendCode({ to, otp });
return NextResponse.json({});
} catch (err) {
console.error('[send-sms hook]', err?.message);
Expand Down
35 changes: 33 additions & 2 deletions src/lib/auth/sms-hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
*
* Supabase Auth on dev2 has no working SMS provider (the Twilio account it
* was set up with now answers 401), so GoTrue hands each code to
* POST /api/auth/hooks/send-sms instead and we send it. The request is signed
* POST /api/auth/hooks/send-sms instead and we send it.
*
* Delivery is Telnyx **Verify** with `custom_code` (GoTrue's own code), sent
* from Telnyx's registered verification senders. A plain message from one of
* our long codes is refused by US carriers (error 40010, "not 10DLC
* registered"): it reaches a Telnyx number but never an AT&T phone. The plain
* message path stays only as a fallback when no Verify profile is configured. The request is signed
* with the Standard Webhooks scheme: `webhook-id`, `webhook-timestamp` and
* `webhook-signature: v1,<base64 HMAC-SHA256 of "id.timestamp.body">`, keyed
* by the secret GoTrue holds as `v1,whsec_<base64>`.
Expand Down Expand Up @@ -44,7 +50,32 @@ export function smsText(otp) {
return `Your qrypt.chat code is ${otp}. It expires in 5 minutes. Never share it.`;
}

/** Send one SMS through Telnyx. Throws with the provider's message on failure. */
/**
* Send a sign-in code. Uses Telnyx Verify when TELNYX_VERIFY_PROFILE_ID is set
* (the path that reaches real phones), else a plain message.
*/
export async function sendCode({ to, otp }, { profileId = process.env.TELNYX_VERIFY_PROFILE_ID, ...opts } = {}) {
if (profileId) return sendViaTelnyxVerify({ to, otp }, { profileId, ...opts });
return sendViaTelnyx({ to, text: smsText(otp) }, opts);
}

/** Telnyx Verify with our own code: Telnyx picks a registered sender and the template. */
export async function sendViaTelnyxVerify({ to, otp }, { apiKey = process.env.TELNYX_API_KEY, profileId, fetchImpl = fetch } = {}) {
if (!apiKey) throw new Error('TELNYX_API_KEY is not set');
const res = await fetchImpl('https://api.telnyx.com/v2/verifications/sms', {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ phone_number: to, verify_profile_id: profileId, custom_code: String(otp) }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data?.errors?.[0]?.detail || data?.errors?.[0]?.title || `HTTP ${res.status}`;
throw new Error(`Telnyx Verify: ${detail}`);
}
return data?.data?.id ?? null;
}

/** Send one plain SMS through Telnyx. Throws with the provider's message on failure. */
export async function sendViaTelnyx({ to, text }, { apiKey = process.env.TELNYX_API_KEY, from = process.env.TELNYX_SMS_FROM, profile = process.env.TELNYX_MESSAGING_PROFILE_ID, fetchImpl = fetch } = {}) {
if (!apiKey) throw new Error('TELNYX_API_KEY is not set');
if (!from && !profile) throw new Error('TELNYX_SMS_FROM is not set');
Expand Down
21 changes: 19 additions & 2 deletions tests/auth/sms-hook.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createHmac, randomBytes } from 'node:crypto';
import { verifyHook, e164, sendViaTelnyx, smsText } from '../../src/lib/auth/sms-hook.js';
import { verifyHook, e164, sendViaTelnyx, sendCode, smsText } from '../../src/lib/auth/sms-hook.js';

const key = randomBytes(32);
const SECRET = `v1,whsec_${key.toString('base64')}`;
Expand Down Expand Up @@ -46,6 +46,21 @@ describe('Telnyx delivery', () => {
expect(JSON.parse(init.body)).toEqual({ to: '+15550001111', from: '+14084269127', text: smsText('424242') });
});

it('sends through Telnyx Verify with our own code when a profile is set', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ data: { id: 'v1' } }), { status: 200 }));
const id = await sendCode({ to: '+15550001111', otp: '424242' }, { apiKey: 'k', profileId: 'prof', fetchImpl });
expect(id).toBe('v1');
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('https://api.telnyx.com/v2/verifications/sms');
expect(JSON.parse(init.body)).toEqual({ phone_number: '+15550001111', verify_profile_id: 'prof', custom_code: '424242' });
});

it('falls back to a plain message only without a Verify profile', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ data: { id: 'm' } }), { status: 200 }));
await sendCode({ to: '+15550001111', otp: '1' }, { apiKey: 'k', profileId: '', from: '+14084269127', fetchImpl });
expect(fetchImpl.mock.calls[0][0]).toBe('https://api.telnyx.com/v2/messages');
});

it("surfaces Telnyx's error detail", async () => {
const fetchImpl = async () => new Response(JSON.stringify({ errors: [{ detail: 'Invalid destination' }] }), { status: 422 });
await expect(sendViaTelnyx({ to: '+1', text: 'x' }, { apiKey: 'k', from: '+1', fetchImpl })).rejects.toThrow('Telnyx: Invalid destination');
Expand All @@ -58,6 +73,7 @@ describe('POST /api/auth/hooks/send-sms', () => {
process.env.SEND_SMS_HOOK_SECRET = SECRET;
process.env.TELNYX_API_KEY = 'k';
process.env.TELNYX_SMS_FROM = '+14084269127';
process.env.TELNYX_VERIFY_PROFILE_ID = 'prof';
});
afterEach(() => {
process.env = { ...env };
Expand All @@ -84,7 +100,8 @@ describe('POST /api/auth/hooks/send-sms', () => {
const s = sign(body);
const res = await call(body, { 'webhook-id': s.id, 'webhook-timestamp': s.timestamp, 'webhook-signature': s.signature });
expect(res.status).toBe(200);
expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toMatchObject({ to: '+15550001111', text: smsText('654321') });
expect(fetchSpy.mock.calls[0][0]).toBe('https://api.telnyx.com/v2/verifications/sms');
expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toMatchObject({ phone_number: '+15550001111', custom_code: '654321' });
});

it('reports a Telnyx failure in the hook error shape', async () => {
Expand Down
Loading