/** * Meta Embedded Signup — the Tech Provider onboarding handshake. * * Every multi-tenant WhatsApp app rewrites the same five calls to turn a * one-time `code` into a usable tenant: exchange the code for a token, * find the WABA, find its phone number, subscribe the app to the WABA's * webhooks, and (optionally) register the number with the Cloud API. This * class is those five calls with a typed error taxonomy, an injectable * `fetch`, and no opinion about where you persist the result. * * Two flows are supported, because Meta ships two: * * **JS SDK flow (current).** Your frontend runs `FB.login` with the ES * config, receives `code` plus a `sessionInfoResponse` carrying `waba_id` * and `phone_number_id`, and posts all three to your backend. Pass them * straight to `complete()` — no discovery calls, no `state`. * * const signup = new MetaEmbeddedSignup({ appId, appSecret, redirectUri }); * const result = await signup.complete({ code, wabaId, phoneNumberId }); * await saveTenant(tenantId, result.wabaId, result.phoneNumberId); * * **OAuth redirect flow.** You send the user to `authUrl(tenantId)` and * handle the callback. Configure `stateSecret` and the class signs the * state (HS256 + expiry) on the way out and verifies it on the way back; * `complete()` then discovers the WABA and phone number itself. * * const signup = new MetaEmbeddedSignup({ appId, appSecret, redirectUri, stateSecret }); * // GET /connect → redirect to await signup.authUrl(tenantId) * // GET /callback → const r = await signup.complete({ code, state }); * // r.statePayload is the tenantId you signed * * Persistence, cache invalidation, and the post-signup redirect stay with * the app — the framework doesn't know your tenant table. */ import { signJwt, verifyJwt } from '../util/jwt.js'; /** Default Graph API version used for every call. Override with `graphBase`. */ export const DEFAULT_GRAPH_BASE = 'https://graph.facebook.com/v22.0'; // hardcoded:allow — framework-level Meta API default /** OAuth scopes Embedded Signup needs. */ export const DEFAULT_SIGNUP_SCOPES = [ 'whatsapp_business_management', 'whatsapp_business_messaging', 'business_management', ] as const; /** Default lifetime of a signed `state` value. */ export const DEFAULT_STATE_TTL_SECONDS = 10 * 60; export type EmbeddedSignupErrorCode = | 'invalid_state' | 'token_exchange_failed' | 'waba_lookup_failed' | 'no_waba_found' | 'phone_lookup_failed' | 'no_phone_number' | 'asset_lookup_failed' | 'no_authorized_assets' | 'asset_not_authorized' | 'subscribe_failed' | 'register_failed'; /** * Every failure path throws this. Branch on `code` — the strings are stable * API, the messages are not. */ export class EmbeddedSignupError extends Error { readonly code: EmbeddedSignupErrorCode; /** HTTP status from Graph, when the failure came from a call. */ readonly status: number | null; /** Parsed Graph error body, when there was one. */ readonly detail: unknown; constructor(code: EmbeddedSignupErrorCode, message: string, status: number | null = null, detail: unknown = null) { super(message); this.name = 'EmbeddedSignupError'; this.code = code; this.status = status; this.detail = detail; } } export interface Waba { id: string; name: string | null; } export interface SignupPhoneNumber { id: string; displayPhoneNumber: string | null; verifiedName: string | null; qualityRating: string | null; } /** * A (WABA, phone number) pair the **exchanged token** actually authorizes. * This is the allowlist `complete()` validates caller-supplied ids against — * see `listAuthorizedAssets`. */ export interface AuthorizedAsset { wabaId: string; wabaName: string | null; phoneNumberId: string; displayPhoneNumber: string | null; verifiedName: string | null; } export interface MetaEmbeddedSignupOptions { appId: string; appSecret: string; /** * Must match the redirect URI registered on the Meta app. Required for * the OAuth flow; the JS SDK flow still sends it on the code exchange, * so pass the same value you configured there (or `''` when unused). */ redirectUri?: string; /** * HS256 secret for signing the `state` round-trip. Set it and `complete()` * REQUIRES a valid state (CSRF protection for the redirect flow). Leave * it unset for the JS SDK flow, where the SDK owns the session. */ stateSecret?: string; /** * Token used for WABA-scoped calls (subscribe, phone lookup, register). * Tech Providers typically use a System User token here. Defaults to the * token returned by the code exchange. */ systemUserToken?: string; /** Graph API base including version. Default `DEFAULT_GRAPH_BASE`. */ graphBase?: string; /** OAuth scopes. Default `DEFAULT_SIGNUP_SCOPES`. */ scopes?: readonly string[]; /** Signed-state lifetime in seconds. Default 600. */ stateTtlSeconds?: number; /** Injectable fetch for tests. */ fetchImpl?: typeof fetch; /** Injectable clock for tests. */ now?: () => number; } export interface CompleteSignupParams { /** One-time code from `FB.login` or the OAuth redirect. */ code: string; /** Signed state from the redirect. Required when `stateSecret` is configured. */ state?: string; /** * WABA id hint from `sessionInfoResponse`. **Verified** against what the * exchanged token authorizes — a hint, never a grant. */ wabaId?: string; /** * Phone number id hint from `sessionInfoResponse`. **Verified** against what * the exchanged token authorizes — a hint, never a grant. */ phoneNumberId?: string; /** * Skip the authorization check on `wabaId` / `phoneNumberId` (v0.19.1). * * **Leave this false.** The ids reach your backend from a browser; trusting * them lets any authenticated user of your app point their tenant at another * business's number and take over its inbox. Set it only when the ids come * from a source you control end-to-end and you've accepted that risk. */ trustSuppliedAssets?: boolean; /** Skip the `POST /{waba}/subscribed_apps` call. Default false. */ skipSubscribe?: boolean; /** * 6-digit PIN for two-step verification. When set, the number is * registered with the Cloud API (`POST /{phone}/register`). Numbers * migrating from the WhatsApp Business app usually need this. */ registerPin?: string; } export interface EmbeddedSignupResult { /** Payload you signed into `authUrl`, or null on the JS SDK flow. */ statePayload: string | null; accessToken: string; wabaId: string; phoneNumberId: string; displayPhoneNumber: string | null; verifiedName: string | null; subscribed: boolean; registered: boolean; /** WABAs seen during discovery. Empty when `wabaId` was supplied. */ wabas: Waba[]; /** Phone numbers seen during discovery. Empty when `phoneNumberId` was supplied. */ phoneNumbers: SignupPhoneNumber[]; /** * Everything the exchanged token authorized (v0.19.1). Empty only when * `trustSuppliedAssets` skipped the check. Persist-worthy: it's the proof * the connecting user actually owns the number you're about to route to * their tenant. */ authorizedAssets: AuthorizedAsset[]; } interface StateClaims { /** Consumer payload — typically a tenantId. */ s: string; /** Expiry, epoch seconds. */ exp: number; /** Nonce, so two states minted in the same second differ. */ jti: string; [k: string]: unknown; } export class MetaEmbeddedSignup { readonly appId: string; readonly graphBase: string; readonly scopes: readonly string[]; readonly stateTtlSeconds: number; private readonly appSecret: string; private readonly redirectUri: string; private readonly stateSecret: string | null; private readonly systemUserToken: string | null; private readonly fetchImpl: typeof fetch; private readonly now: () => number; constructor(opts: MetaEmbeddedSignupOptions) { if (!opts?.appId) throw new Error('MetaEmbeddedSignup: appId required'); if (!opts.appSecret) throw new Error('MetaEmbeddedSignup: appSecret required'); this.appId = opts.appId; this.appSecret = opts.appSecret; this.redirectUri = opts.redirectUri ?? ''; this.stateSecret = opts.stateSecret ?? null; this.systemUserToken = opts.systemUserToken ?? null; this.graphBase = (opts.graphBase ?? DEFAULT_GRAPH_BASE).replace(/\/+$/, ''); this.scopes = opts.scopes ?? DEFAULT_SIGNUP_SCOPES; this.stateTtlSeconds = opts.stateTtlSeconds ?? DEFAULT_STATE_TTL_SECONDS; this.fetchImpl = opts.fetchImpl ?? ((...args: Parameters) => fetch(...args)); this.now = opts.now ?? (() => Date.now()); } /** * Facebook OAuth dialog URL for the redirect flow, with `statePayload` * signed into the `state` parameter. Throws when no `stateSecret` was * configured — an unsigned state is a CSRF hole, not a convenience. */ async authUrl(statePayload: string): Promise { const state = await this.signState(statePayload); const params = new URLSearchParams({ client_id: this.appId, redirect_uri: this.redirectUri, state, scope: this.scopes.join(','), response_type: 'code', }); // The dialog lives on facebook.com, not graph.facebook.com — derive it // from graphBase so a custom base (a test double, a proxy) still works. const dialogBase = this.graphBase.replace('//graph.', '//www.'); return `${dialogBase}/dialog/oauth?${params.toString()}`; } /** Sign a state payload (HS256 + expiry + nonce). */ async signState(payload: string): Promise { if (!this.stateSecret) throw new Error('MetaEmbeddedSignup.signState: stateSecret required'); const claims: StateClaims = { s: payload, exp: Math.floor(this.now() / 1000) + this.stateTtlSeconds, jti: randomNonce(), }; return await signJwt(claims, this.stateSecret); } /** * Verify a state produced by `signState`. Returns the payload, or `null` * when the signature is wrong, the token is malformed, or it expired. */ async verifyState(state: string | undefined | null): Promise { if (!this.stateSecret || !state) return null; const claims = await verifyJwt(state, this.stateSecret); if (!claims || typeof claims.s !== 'string') return null; if (typeof claims.exp !== 'number' || claims.exp * 1000 <= this.now()) return null; return claims.s; } /** Exchange the one-time code for an access token. */ async exchangeCode(code: string): Promise<{ accessToken: string; tokenType: string | null; expiresIn: number | null }> { const params = new URLSearchParams({ client_id: this.appId, client_secret: this.appSecret, code, }); if (this.redirectUri) params.set('redirect_uri', this.redirectUri); const body = await this.graphJson<{ access_token?: string; token_type?: string; expires_in?: number }>( `/oauth/access_token?${params.toString()}`, { method: 'GET' }, null, 'token_exchange_failed', ); if (!body.access_token) { throw new EmbeddedSignupError('token_exchange_failed', 'Graph returned no access_token', null, body); } return { accessToken: body.access_token, tokenType: body.token_type ?? null, expiresIn: typeof body.expires_in === 'number' ? body.expires_in : null, }; } /** * Enumerate the (WABA, phone number) pairs **this exact token** authorizes * (v0.19.1). This is the allowlist `complete()` validates caller-supplied * ids against. * * Pass the token from the code exchange, and only that one. A System User * token sees every number in the app, so enumerating with it proves nothing * about who is connecting — which is exactly the hole this closes. The * method therefore never falls back to `systemUserToken`. */ async listAuthorizedAssets(exchangedToken: string): Promise { if (!exchangedToken) { throw new EmbeddedSignupError('asset_lookup_failed', 'listAuthorizedAssets requires the exchanged token'); } const body = await this.graphJson<{ data?: Array<{ owned_whatsapp_business_accounts?: { data?: Array<{ id?: string; name?: string; phone_numbers?: { data?: Array<{ id?: string; display_phone_number?: string; verified_name?: string }> }; }>; }; }>; }>( '/me/businesses?fields=owned_whatsapp_business_accounts{id,name,phone_numbers{id,display_phone_number,verified_name}}', { method: 'GET' }, exchangedToken, 'asset_lookup_failed', ); const out: AuthorizedAsset[] = []; for (const business of body.data ?? []) { for (const waba of business.owned_whatsapp_business_accounts?.data ?? []) { if (!waba?.id) continue; for (const phone of waba.phone_numbers?.data ?? []) { if (!phone?.id) continue; out.push({ wabaId: waba.id, wabaName: waba.name ?? null, phoneNumberId: phone.id, displayPhoneNumber: phone.display_phone_number ?? null, verifiedName: phone.verified_name ?? null, }); } } } return out; } /** WABAs owned by the businesses the token can see. */ async listWabas(token: string): Promise { const body = await this.graphJson<{ data?: Array<{ owned_whatsapp_business_accounts?: { data?: Array<{ id?: string; name?: string }> } }>; }>('/me/businesses?fields=name,owned_whatsapp_business_accounts{id,name}', { method: 'GET' }, token, 'waba_lookup_failed'); const out: Waba[] = []; for (const business of body.data ?? []) { for (const waba of business.owned_whatsapp_business_accounts?.data ?? []) { if (waba?.id) out.push({ id: waba.id, name: waba.name ?? null }); } } return out; } /** Phone numbers registered under a WABA. */ async listPhoneNumbers(wabaId: string, token?: string): Promise { const body = await this.graphJson<{ data?: Array<{ id?: string; display_phone_number?: string; verified_name?: string; quality_rating?: string }>; }>( `/${encodeURIComponent(wabaId)}/phone_numbers?fields=id,display_phone_number,verified_name,quality_rating`, { method: 'GET' }, this.tokenFor(token), 'phone_lookup_failed', ); return (body.data ?? []) .filter((p): p is { id: string } & typeof p => typeof p?.id === 'string') .map((p) => ({ id: p.id, displayPhoneNumber: p.display_phone_number ?? null, verifiedName: p.verified_name ?? null, qualityRating: p.quality_rating ?? null, })); } /** * Subscribe the app to the WABA's webhooks. Without this the tenant is * connected but no message ever reaches your webhook. */ async subscribeApp(wabaId: string, token?: string): Promise { const body = await this.graphJson<{ success?: boolean }>( `/${encodeURIComponent(wabaId)}/subscribed_apps`, { method: 'POST' }, this.tokenFor(token), 'subscribe_failed', ); return body.success !== false; } /** * Register the number with the Cloud API. Required for numbers migrating * off the WhatsApp Business app; `pin` is the two-step verification code. */ async registerPhoneNumber(phoneNumberId: string, pin: string, token?: string): Promise { const body = await this.graphJson<{ success?: boolean }>( `/${encodeURIComponent(phoneNumberId)}/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messaging_product: 'whatsapp', pin }), }, this.tokenFor(token), 'register_failed', ); return body.success !== false; } /** * Run the whole handshake. Supply `wabaId` / `phoneNumberId` from the JS * SDK's session info to skip discovery; omit them and they're looked up. * * Throws `EmbeddedSignupError` on every failure path — see the `code` * taxonomy. Nothing is persisted; the result is yours to store. */ async complete(params: CompleteSignupParams): Promise { let statePayload: string | null = null; if (this.stateSecret) { statePayload = await this.verifyState(params.state); if (statePayload === null) { throw new EmbeddedSignupError('invalid_state', 'state missing, tampered, or expired'); } } const { accessToken } = await this.exchangeCode(params.code); let wabas: Waba[] = []; let phoneNumbers: SignupPhoneNumber[] = []; let authorizedAssets: AuthorizedAsset[] = []; let wabaId = params.wabaId ?? ''; let phoneNumberId = params.phoneNumberId ?? ''; let displayPhoneNumber: string | null = null; let verifiedName: string | null = null; if (params.trustSuppliedAssets && wabaId && phoneNumberId) { // Explicit opt-out. Documented as unsafe; honored because some apps // source the ids from a channel the framework can't see. } else { // Both paths — discovery and verification — go through the assets the // EXCHANGED token authorizes. That's what ties the ids to the person // who just completed signup instead of to whatever the browser posted. authorizedAssets = await this.listAuthorizedAssets(accessToken); if (authorizedAssets.length === 0) { throw new EmbeddedSignupError( 'no_authorized_assets', 'the exchanged token authorizes no (WABA, phone number) pair', ); } const match = selectAuthorizedAsset(authorizedAssets, { wabaId, phoneNumberId }); if (!match) { throw new EmbeddedSignupError( 'asset_not_authorized', `the exchanged token does not authorize waba=${wabaId || '(any)'} phone=${phoneNumberId || '(any)'}`, null, { requested: { wabaId, phoneNumberId }, authorized: authorizedAssets }, ); } wabaId = match.wabaId; phoneNumberId = match.phoneNumberId; displayPhoneNumber = match.displayPhoneNumber; verifiedName = match.verifiedName; wabas = dedupeWabas(authorizedAssets); phoneNumbers = authorizedAssets .filter((a) => a.wabaId === wabaId) .map((a) => ({ id: a.phoneNumberId, displayPhoneNumber: a.displayPhoneNumber, verifiedName: a.verifiedName, qualityRating: null, })); } const subscribed = params.skipSubscribe ? false : await this.subscribeApp(wabaId, accessToken); const registered = params.registerPin ? await this.registerPhoneNumber(phoneNumberId, params.registerPin, accessToken) : false; return { statePayload, accessToken, wabaId, phoneNumberId, displayPhoneNumber, verifiedName, subscribed, registered, wabas, phoneNumbers, authorizedAssets, }; } /** * Token for operational calls (subscribe, register). The System User token * wins when configured — it's long-lived and scoped to your app, which is * what Tech Providers want for calls made after authorization is settled. * * Never use this for authorization checks: see `listAuthorizedAssets`. */ private tokenFor(token: string | undefined): string | null { return this.systemUserToken ?? token ?? null; } private async graphJson( path: string, init: RequestInit, token: string | null, errorCode: EmbeddedSignupErrorCode, ): Promise { const headers = new Headers(init.headers); // Exactly the token the caller passed. Callers decide whether the System // User token may stand in (`tokenFor`); this method must not, or an // authorization check could silently be answered by a token that sees // every number in the app. if (token) headers.set('Authorization', `Bearer ${token}`); let res: Response; try { res = await this.fetchImpl(`${this.graphBase}${path}`, { ...init, headers }); } catch (e) { throw new EmbeddedSignupError(errorCode, `network error: ${e instanceof Error ? e.message : String(e)}`); } const text = await res.text(); let body: unknown = null; try { body = text ? JSON.parse(text) : null; } catch { body = text; } if (!res.ok) { const message = readGraphErrorMessage(body) ?? `HTTP ${res.status}`; throw new EmbeddedSignupError(errorCode, message, res.status, body); } return (body ?? {}) as T; } } /** * Resolve a hint against the authorized set (v0.19.1). Pure logic, exported so * apps that run their own connect endpoint get the same matching rules. * * - both ids given → the exact pair must be authorized * - phone only → that number must be authorized, under any WABA * - WABA only → first authorized number under that WABA * - neither → first authorized pair (the discovery path) * * Returns `null` when nothing matches — the caller turns that into * `asset_not_authorized`. */ export function selectAuthorizedAsset( assets: readonly AuthorizedAsset[], hint: { wabaId?: string; phoneNumberId?: string } = {}, ): AuthorizedAsset | null { const wabaId = hint.wabaId || ''; const phoneNumberId = hint.phoneNumberId || ''; for (const asset of assets) { if (wabaId && asset.wabaId !== wabaId) continue; if (phoneNumberId && asset.phoneNumberId !== phoneNumberId) continue; return asset; } return null; } function dedupeWabas(assets: readonly AuthorizedAsset[]): Waba[] { const seen = new Set(); const out: Waba[] = []; for (const asset of assets) { if (seen.has(asset.wabaId)) continue; seen.add(asset.wabaId); out.push({ id: asset.wabaId, name: asset.wabaName }); } return out; } function readGraphErrorMessage(body: unknown): string | null { if (typeof body !== 'object' || body === null) return null; const error = (body as { error?: { message?: unknown } }).error; return typeof error?.message === 'string' ? error.message : null; } function randomNonce(): string { const buf = new Uint8Array(8); crypto.getRandomValues(buf); let out = ''; for (let i = 0; i < buf.length; i++) out += buf[i]!.toString(16).padStart(2, '0'); return out; }