diff --git a/src/routes/docs/products/auth/+layout.svelte b/src/routes/docs/products/auth/+layout.svelte index 32621d224d..ea285b206d 100644 --- a/src/routes/docs/products/auth/+layout.svelte +++ b/src/routes/docs/products/auth/+layout.svelte @@ -131,6 +131,11 @@ href: '/docs/products/auth/custom-mfa', new: isNewUntil('30 November 2026') }, + { + label: 'Custom MFA channels', + href: '/docs/products/auth/custom-mfa-channels', + new: isNewUntil('30 November 2026') + }, { label: 'Auth status check', href: '/docs/products/auth/checking-auth-status' diff --git a/src/routes/docs/products/auth/custom-mfa-channels/+page.markdoc b/src/routes/docs/products/auth/custom-mfa-channels/+page.markdoc new file mode 100644 index 0000000000..865713a508 --- /dev/null +++ b/src/routes/docs/products/auth/custom-mfa-channels/+page.markdoc @@ -0,0 +1,368 @@ +--- +layout: article +title: Custom MFA channels +description: Send the custom MFA code through Telegram or WhatsApp. Store the destination on the server, then deliver the code from an Appwrite Function. +--- + +The [custom MFA factor](/docs/products/auth/custom-mfa) generates the code. It sends nothing. This page shows the delivery step for two channels: Telegram and WhatsApp. + +Each channel needs two parts. + +- **A destination.** The chat ID or the telephone number of the user. +- **A delivery call.** One HTTP request to the provider. + +The destination controls the security of the whole factor. Your function must find the destination from the user ID in the `x-appwrite-user-id` header. If your function reads a destination from the request body, an attacker who knows the password can send the code to their own device. + +# Before you start {% #before-you-start %} + +- Read [Custom MFA factor](/docs/products/auth/custom-mfa) first. This page continues from the **Deliver the code** step. +- Create an Appwrite Function with the `users.read` scope. +- Keep each provider token in a function variable. Never put a provider token in your client. + +# Set up the channel {% #set-up-channel %} + +{% tabs %} +{% tabsitem #telegram title="Telegram" %} +A Telegram bot is free, and it needs no approval. + +1. Open [@BotFather](https://t.me/BotFather) in Telegram. +2. Send `/newbot`. Give the bot a name and a username. +3. Copy the token that BotFather returns. + +Add two variables to your function. + +| Variable | Value | +| --- | --- | +| `TELEGRAM_BOT_TOKEN` | The token from BotFather | +| `TELEGRAM_BOT_USERNAME` | The username of the bot, without the `@` | +{% /tabsitem %} + +{% tabsitem #whatsapp title="WhatsApp" %} +WhatsApp needs a Meta app and an approved template. Meta permits no free text in a message that starts a conversation, so an authentication template is necessary. + +1. Create a Meta app and add the WhatsApp product to it. +2. Copy the phone number ID and a permanent access token. +3. Create an authentication template with an OTP button. + +```bash +curl -X POST "https://graph.facebook.com/v25.0//message_templates" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "name": "mfa_code", + "language": "en_US", + "category": "AUTHENTICATION", + "components": [ + { "type": "BODY", "add_security_recommendation": true }, + { "type": "FOOTER", "code_expiration_minutes": 60 }, + { "type": "BUTTONS", "buttons": [ { "type": "OTP", "otp_type": "COPY_CODE" } ] } + ] + }' +``` + +An authentication template holds no text of your own. Meta supplies the wording, and the body has one variable. That variable is the code. + +Add three variables to your function. + +| Variable | Value | +| --- | --- | +| `WHATSAPP_PHONE_NUMBER_ID` | The phone number ID of the sender | +| `WHATSAPP_ACCESS_TOKEN` | A permanent access token | +| `WHATSAPP_TEMPLATE_NAME` | The name of the template, for example `mfa_code` | + +{% info title="Meta reviews each template" %} +A template that is not `APPROVED` sends nothing. Review usually takes minutes, but Meta gives no guarantee. Test the function against the Meta test number before you go to production. +{% /info %} +{% /tabsitem %} +{% /tabs %} + +# Store the destination {% #store-destination %} + +{% tabs %} +{% tabsitem #telegram title="Telegram" %} +Appwrite keeps no Telegram chat ID. You must store it, and you must prove that the chat belongs to the user. + +Never accept a chat ID from the client. Use a one-time link instead. Telegram passes the value after `?start=` to your bot, so the link binds the Appwrite user to the chat. + +Create a table `mfa_telegram` in a database `main` with these columns. + +| Column | Type | Description | +| --- | --- | --- | +| `chatId` | Integer | The Telegram chat of the user. Empty until enrollment completes. | +| `linkToken` | String, 64 | The one-time value in the link. Empty after enrollment completes. | + +Add an index on `linkToken`. Give the table no row permissions. Only a server key reads it. + +**Step 1. Return the link.** The signed-in user calls this function with `createExecution`. The row ID is the user ID, so each user holds one row. + +```server-nodejs +import { Client, TablesDB } from 'node-appwrite'; +import { randomBytes } from 'node:crypto'; + +export default async ({ req, res }) => { + const userId = req.headers['x-appwrite-user-id']; + + if (!userId) { + return res.json({ ok: false }, 401); + } + + const client = new Client() + .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT) + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) + .setKey(req.headers['x-appwrite-key']); + + const tables = new TablesDB(client); + // Telegram permits 64 characters after `?start=`. + const linkToken = randomBytes(24).toString('hex'); + + await tables.upsertRow({ + databaseId: 'main', + tableId: 'mfa_telegram', + rowId: userId, + data: { linkToken } + }); + + return res.json({ + url: `https://t.me/${process.env.TELEGRAM_BOT_USERNAME}?start=${linkToken}` + }); +}; +``` + +This function needs the `rows.write` scope. + +**Step 2. Receive the chat ID.** Telegram calls a second function when the user presses **Start**. Set the execute permission of that function to **Any**, and give it the `rows.read` and `rows.write` scopes. + +Telegram is not a signed-in client, so `x-appwrite-user-id` is empty in this function. The `x-appwrite-key` header is not empty. A call to the function domain also carries a temporary key with the scopes of the function. + +```server-nodejs +import { Client, TablesDB, Query } from 'node-appwrite'; + +export default async ({ req, res }) => { + if (req.headers['x-telegram-bot-api-secret-token'] !== process.env.TELEGRAM_WEBHOOK_SECRET) { + return res.json({ ok: false }, 401); + } + + const update = JSON.parse(req.bodyRaw || '{}'); + const text = update.message?.text ?? ''; + + if (!text.startsWith('/start ')) { + return res.json({ ok: true }); + } + + const linkToken = text.slice('/start '.length).trim(); + + // An empty token matches every row that completed enrollment. + if (!linkToken) { + return res.json({ ok: true }); + } + + const client = new Client() + .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT) + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) + .setKey(req.headers['x-appwrite-key']); + + const tables = new TablesDB(client); + + const found = await tables.listRows({ + databaseId: 'main', + tableId: 'mfa_telegram', + queries: [Query.equal('linkToken', linkToken), Query.limit(1)] + }); + + if (found.rows.length !== 1) { + return res.json({ ok: true }); + } + + await tables.updateRow({ + databaseId: 'main', + tableId: 'mfa_telegram', + rowId: found.rows[0].$id, + data: { chatId: update.message.chat.id, linkToken: '' } + }); + + return res.json({ ok: true }); +}; +``` + +The function clears `linkToken`, so a second use of the same link matches no row. + +**Step 3. Point Telegram at the function.** Use the domain of the second function. Choose your own value for the secret, and put the same value in the `TELEGRAM_WEBHOOK_SECRET` variable. Telegram accepts 1 to 256 characters, and only the characters `A-Z`, `a-z`, `0-9`, `_`, and `-`. + +```bash +curl -X POST "https://api.telegram.org/bot/setWebhook" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://", + "secret_token": "" + }' +``` + +Telegram sends the secret in the `X-Telegram-Bot-Api-Secret-Token` header of each call. The function rejects a call that has no correct secret. +{% /tabsitem %} + +{% tabsitem #whatsapp title="WhatsApp" %} +WhatsApp needs no table. Appwrite already holds a telephone number for each user, and it also holds the verification state of that number. + +Use `phone` from the user record, and send only when `phoneVerification` is `true`. An unverified number is a number that nobody proved, and the code must not go there. + +To set the number and verify it, see [Phone (SMS) login](/docs/products/auth/phone-sms) and [User verification](/docs/products/auth/verify-user). +{% /tabsitem %} +{% /tabs %} + +# Deliver the code {% #deliver-code %} + +This is the function that the client calls after it creates the challenge. It reads the code, finds the destination, and sends one message. + +{% tabs %} +{% tabsitem #telegram title="Telegram" %} +```server-nodejs +import { Client, Users, TablesDB } from 'node-appwrite'; + +export default async ({ req, res, error }) => { + const userId = req.headers['x-appwrite-user-id']; + + if (!userId) { + return res.json({ ok: false }, 401); + } + + const { challengeId } = JSON.parse(req.bodyRaw || '{}'); + + const client = new Client() + .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT) + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) + .setKey(req.headers['x-appwrite-key']); + + const tables = new TablesDB(client); + const users = new Users(client); + + let row; + + try { + row = await tables.getRow({ + databaseId: 'main', + tableId: 'mfa_telegram', + rowId: userId + }); + } catch (err) { + // The user has no row until enrollment starts. + if (err.code === 404) { + return res.json({ ok: false, reason: 'not-enrolled' }, 400); + } + + throw err; + } + + if (!row.chatId) { + return res.json({ ok: false, reason: 'not-enrolled' }, 400); + } + + const challenge = await users.getMFAChallenge({ userId, challengeId }); + + const response = await fetch( + `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: row.chatId, + text: `Your verification code is ${challenge.code}. The code expires in 60 minutes.` + }) + } + ); + + if (!response.ok) { + error(`Telegram refused the message with status ${response.status}.`); + return res.json({ ok: false }, 502); + } + + return res.json({ ok: true }); +}; +``` + +This function needs the `users.read` and `rows.read` scopes. +{% /tabsitem %} + +{% tabsitem #whatsapp title="WhatsApp" %} +```server-nodejs +import { Client, Users } from 'node-appwrite'; + +export default async ({ req, res, error }) => { + const userId = req.headers['x-appwrite-user-id']; + + if (!userId) { + return res.json({ ok: false }, 401); + } + + const { challengeId } = JSON.parse(req.bodyRaw || '{}'); + + const client = new Client() + .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT) + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) + .setKey(req.headers['x-appwrite-key']); + + const users = new Users(client); + const user = await users.get({ userId }); + + if (!user.phoneVerification) { + return res.json({ ok: false, reason: 'phone-not-verified' }, 400); + } + + const challenge = await users.getMFAChallenge({ userId, challengeId }); + + const response = await fetch( + `https://graph.facebook.com/v25.0/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.WHATSAPP_ACCESS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: user.phone, + type: 'template', + template: { + name: process.env.WHATSAPP_TEMPLATE_NAME, + language: { code: 'en_US' }, + components: [ + { + type: 'body', + parameters: [{ type: 'text', text: challenge.code }] + }, + { + type: 'button', + sub_type: 'url', + index: '0', + parameters: [{ type: 'text', text: challenge.code }] + } + ] + } + }) + } + ); + + if (!response.ok) { + error(`Meta refused the message with status ${response.status}.`); + return res.json({ ok: false }, 502); + } + + return res.json({ ok: true }); +}; +``` + +The button component carries the code a second time. It fills the copy button of the authentication template. Set `sub_type` to `url` for both OTP button types. + +This function needs the `users.read` scope. +{% /tabsitem %} +{% /tabs %} + +# Security requirements {% #security %} + +The rules on [Custom MFA factor](/docs/products/auth/custom-mfa) apply here. These rules apply to the channel. + +- **Bind the Telegram chat with a one-time link.** A chat ID from the client proves nothing. The link proves that the same person holds the Appwrite session and the Telegram chat. +- **Check the Telegram webhook secret.** The function domain is public. Without the secret check, any caller can write a chat ID into your table. +- **Send WhatsApp messages only to a verified number.** Read `phoneVerification` before each send. An unverified number can belong to somebody else. +- **Keep provider tokens in function variables.** A bot token or an access token in your client gives full control of the channel to any reader. +- **Never log the code.** Keep the code out of your function logs and out of your response to the client.