From f26aa32f9475d508d8cd44bbc2defe66a2ce332f Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:05:05 +0700 Subject: [PATCH] fix(config): resolve Meta app credentials per product instead of sharing one secret Messenger, Instagram and WhatsApp all read a single META_APP_SECRET. That only works when one Meta app serves all three products. A deployment that registered a separate app per product has two or three different secrets, one variable can only hold one, and whichever product it does not belong to fails: - the inbound webhook verifies X-Hub-Signature-256 against the wrong secret, so every delivery is rejected; - the WhatsApp OAuth exchange sent the Messenger app id and secret for a code issued by the WhatsApp app, which Meta refuses outright. Neither symptom points at the actual cause, so both look like a broken integration rather than a mis-set variable. Credentials now resolve in one order, used by both the webhook path and the OAuth path: the channel's own value, then the product's deployment-level value, then the shared Messenger value. The last step is what keeps a single-app deployment working without any configuration change. Per-product variables follow the naming already used by Crove Post, so the two products read the same way: FACEBOOK_APP_ID / FACEBOOK_APP_SECRET / FACEBOOK_VERIFY_TOKEN INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET / INSTAGRAM_VERIFY_TOKEN WHATSAPP_APP_ID / WHATSAPP_APP_SECRET / WHATSAPP_VERIFY_TOKEN META_APP_ID, META_APP_SECRET, FB_APP_ID, FB_APP_SECRET and MESSENGER_* remain bound, to the Messenger app only. The three WHATSAPP_* names were already documented in .env.example but nothing read them; they are real bindings now. The WhatsApp channel form gains Meta App ID and Meta App Secret fields. The backend already honoured a per-channel appSecret, but no field existed to set one, so a channel on a second Meta app could only be configured by editing the database. The OAuth URL endpoint takes the channel id and resolves the app id through the channel first, for the same reason. The three Meta authorization URL handlers are folded into one helper. Instagram and WhatsApp both read the Messenger app id regardless of product, and all three fell back to a fabricated "123456789012345" that produced a Meta error page indistinguishable from an application bug. They now report which variable to set, and Messenger and Instagram gain response_type=code, which WhatsApp already needed and without which Meta returns a token fragment the server never sees. The OAuth connect no longer copies a resolved app secret onto the channel. Doing so would freeze a fallback in place, where it keeps winning over the deployment-level credential even after that credential is corrected. Messenger and Instagram verification is left conditional in this change. Closing it the way WhatsApp was closed would start rejecting live traffic on any deployment whose secret is not yet correct, and that has to be sequenced after this split is deployed and the right secret is in place. --- .env.example | 49 ++++-- CHANGELOG.md | 45 +++-- .../dashboard/channel_oauth_handler.go | 166 ++++++++---------- internal/pkg/config/config.go | 102 ++++++++++- internal/pkg/config/config_test.go | 121 +++++++++++++ internal/pkg/config/runtime.go | 37 ++++ internal/pkg/dto/dto.go | 3 + internal/pkg/i18nx/locales/en-US.yml | 5 +- internal/pkg/i18nx/locales/zh-CN.yml | 5 +- internal/services/channel_service.go | 3 + .../services/instagram_inbound_service.go | 17 +- .../services/messenger_inbound_service.go | 17 +- internal/services/whatsapp_inbound_service.go | 21 +-- internal/services/whatsapp_oauth_service.go | 75 ++++---- .../services/whatsapp_oauth_service_test.go | 99 ++++++++++- .../dashboard/channels/_components/edit.tsx | 49 +++++- web/lib/api/admin.ts | 9 +- web/messages/en-US.json | 3 + web/messages/vi-VN.json | 3 + web/messages/zh-CN.json | 3 + 20 files changed, 612 insertions(+), 220 deletions(-) diff --git a/.env.example b/.env.example index aed25352..6127653f 100644 --- a/.env.example +++ b/.env.example @@ -101,21 +101,46 @@ BREVO_API_KEY=xkeysib-your-brevo-api-key # DISCORD_BOT_TOKEN=your-discord-bot-token # DISCORD_PUBLIC_KEY=your-discord-public-key -# Facebook Messenger Channel Integration (Meta Graph API) -# META_APP_ID=your-meta-app-id -# META_APP_SECRET=your-meta-app-secret -# MESSENGER_VERIFY_TOKEN=your-webhook-verify-token +# ============================================================================== +# Meta Channels (Facebook Messenger / Instagram / WhatsApp) +# +# Each product can use its own Meta app. Set the per-product variables when you +# registered more than one app; the app secret signs that product's webhooks and +# a secret from the wrong app makes every delivery fail verification. +# +# If a single Meta app serves all three products, setting only FACEBOOK_APP_ID +# and FACEBOOK_APP_SECRET is enough: Instagram and WhatsApp fall back to them. +# +# Channel-level credentials entered in Dashboard -> Channels always win over the +# values below, so a single deployment can serve channels from different apps. +# ============================================================================== -# WhatsApp Cloud API Integration (Meta Graph API) -# WhatsApp credentials (Phone Number ID, WABA ID, System User Access Token and -# the per-channel webhook verify token) are stored on the channel itself, in -# Dashboard -> Channels, not in the environment. The two variables below are the -# Meta app credentials and are shared with Messenger and Instagram. -# META_APP_ID and META_APP_SECRET are required: the WhatsApp webhook rejects any -# delivery whose X-Hub-Signature-256 it cannot verify, and the OAuth connect flow -# cannot exchange a code without them. +# Facebook Messenger +# FACEBOOK_APP_ID=your-messenger-meta-app-id +# FACEBOOK_APP_SECRET=your-messenger-meta-app-secret +# FACEBOOK_VERIFY_TOKEN=your-messenger-webhook-verify-token + +# Instagram Direct +# INSTAGRAM_APP_ID=your-instagram-meta-app-id +# INSTAGRAM_APP_SECRET=your-instagram-meta-app-secret +# INSTAGRAM_VERIFY_TOKEN=your-instagram-webhook-verify-token + +# WhatsApp Cloud API +# Required: the WhatsApp webhook rejects any delivery whose X-Hub-Signature-256 +# it cannot verify, and the OAuth connect flow cannot exchange a code without the +# app id and secret. +# WHATSAPP_APP_ID=your-whatsapp-meta-app-id +# WHATSAPP_APP_SECRET=your-whatsapp-meta-app-secret +# WHATSAPP_VERIFY_TOKEN=your-whatsapp-webhook-verify-token +# +# Phone Number ID, WABA ID and System User Access Token are not environment +# variables. They are stored per channel in Dashboard -> Channels. + +# Legacy aliases, still read for the Messenger app only. Prefer FACEBOOK_APP_ID +# and FACEBOOK_APP_SECRET. # META_APP_ID=your-meta-app-id # META_APP_SECRET=your-meta-app-secret +# MESSENGER_VERIFY_TOKEN=your-webhook-verify-token # Slack Bot Integration (Slack Web API & Events API) # SLACK_CLIENT_ID=your-slack-client-id diff --git a/CHANGELOG.md b/CHANGELOG.md index 74279cd4..a8a1ba97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the `sha256=` prefix all passed, so anyone who learned a webhook URL could write into a customer conversation, trigger AI replies and burn paid message quota. Rejections now return `401` without echoing the reason. **Breaking for - deployments that never set `META_APP_SECRET` or a per-channel `appSecret`: the - WhatsApp webhook stops accepting messages until one is configured.** -- `ChannelGetWhatsAppOAuthURL` no longer falls back to a fabricated app id, which - sent operators to a Meta error page that looked like an application bug. The - authorization URL now includes `response_type=code`; without it Meta returns a - token fragment the server never sees. + deployments that never set `WHATSAPP_APP_SECRET` or a per-channel `appSecret`: + the WhatsApp webhook stops accepting messages until one is configured.** +- **Each Meta product now resolves its own app credentials.** Messenger, Instagram + and WhatsApp all read a single `META_APP_SECRET`, which cannot work for a + deployment that registered a separate Meta app per product: one variable holds + one secret, and a signature verified against the wrong app's secret always + fails. Credentials now resolve channel-level first, then the product's own + `FACEBOOK_APP_*` / `INSTAGRAM_APP_*` / `WHATSAPP_APP_*`, then the shared + Messenger values, so a single-app deployment keeps working unchanged. The + WhatsApp OAuth exchange previously sent the Messenger app id and secret for a + code issued by the WhatsApp app, which Meta rejects outright. +- `ChannelGetWhatsAppOAuthURL`, `ChannelGetMessengerOAuthURL` and + `ChannelGetInstagramOAuthURL` no longer fall back to a fabricated app id, which + sent operators to a Meta error page that looked like an application bug, and now + report which variable to set. The authorization URL includes + `response_type=code`; without it Meta returns a token fragment the server never + sees. - The Channels dialog advertised `/api/third/whatsapp/webhook` as the webhook URL, but verification requires a bound channel id, so the documented URL always failed. It now shows the real per-channel URL with a copy action. @@ -43,8 +54,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 discovers the reachable WABAs and sender numbers, and saves the credentials onto the target channel while preserving its existing webhook verify token. The Channels dialog opens Meta in a popup and prefills the form from the result. +- The WhatsApp channel form gains Meta App ID and Meta App Secret fields. The + backend already read a per-channel `appSecret` but no field existed to set one, + so a channel belonging to a second Meta app could only be configured by editing + the database. - Focused tests for signature rejection, structured inbound types, media storage, - media-failure fallback, and the OAuth connect flow including discovery failure. + media-failure fallback, the OAuth connect flow including discovery failure, and + per-product Meta credential resolution. ### Changed @@ -55,9 +71,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - In-app brand strings in `en-US` and `zh-CN` now read `Crove Desk`; `vi-VN` already did. The widget SDK's public `AgentDesk*` globals are unchanged, because renaming them would break every site that has already integrated it. -- `.env.example` no longer documents four `WHATSAPP_*` variables that nothing in - the codebase reads. It points at the Meta app credentials and at the channel - form instead. +- `.env.example` groups the Meta variables per product — `FACEBOOK_APP_*`, + `INSTAGRAM_APP_*`, `WHATSAPP_APP_*` — matching the naming already used by Crove + Post, and keeps `META_APP_*` documented as a Messenger-only legacy alias. The + three `WHATSAPP_*` names it listed before were never read by anything; they are + real bindings now. - The product backlog marks the WhatsApp integration as shipped, with the template-message and delivery-receipt gaps listed explicitly. @@ -71,6 +89,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 conversations outside the 24-hour customer service window are not possible, and the webhook `statuses` field is not consumed, so delivery and read receipts are not reflected. +- Messenger and Instagram webhook verification is still conditional: it only runs + when an app secret is configured *and* a signature header arrived, and + `verifyMessengerSignature` returns `true` for a header without the `sha256=` + prefix. WhatsApp was closed in this release; the same fix for those two was + deliberately held back because it would start rejecting live traffic on any + deployment whose secret is not yet correct, and that needs to be sequenced + against the credential split above. - `pnpm lint` fails on pre-existing `react-hooks/set-state-in-effect` and ref-access errors across the dashboard. The WhatsApp files added here are lint-clean. diff --git a/internal/handlers/dashboard/channel_oauth_handler.go b/internal/handlers/dashboard/channel_oauth_handler.go index 65629424..e0ce97d4 100644 --- a/internal/handlers/dashboard/channel_oauth_handler.go +++ b/internal/handlers/dashboard/channel_oauth_handler.go @@ -4,11 +4,13 @@ import ( "fmt" "net/url" "os" + "strconv" "strings" "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/httpx" "agent-desk/internal/pkg/httpx/params" @@ -62,94 +64,85 @@ func ChannelGetDiscordOAuthURL(ctx *gin.Context) { })) } -// ChannelGetMessengerOAuthURL returns the 1-Click OAuth authorization URL for Meta Messenger. -func ChannelGetMessengerOAuthURL(ctx *gin.Context) { - if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { - httpx.WriteJSON(ctx, err) - return - } +// metaOAuthDialogURL is the Facebook Login authorization endpoint. The Graph API +// version is pinned so an authorization URL and the code exchange that follows it +// cannot drift onto different versions. +const metaOAuthDialogURL = "https://www.facebook.com/v21.0/dialog/oauth" - appID := "" - if cfg := config.GetCurrent(); cfg != nil { - appID = strings.TrimSpace(cfg.Messenger.AppID) - } - if appID == "" { - appID = strings.TrimSpace(os.Getenv("META_APP_ID")) - } - if appID == "" { - appID = strings.TrimSpace(os.Getenv("FB_APP_ID")) - } - if appID == "" { - appID = strings.TrimSpace(ctx.Query("app_id")) - } - redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) +// Scopes each Meta product needs to send and receive support messages. +const ( + messengerOAuthScope = "pages_show_list,pages_messaging,pages_manage_metadata" + instagramOAuthScope = "instagram_basic,instagram_manage_messages,pages_show_list,pages_manage_metadata" + whatsAppOAuthScope = "whatsapp_business_management,whatsapp_business_messaging" +) +// writeMetaOAuthURL builds an authorization URL for one Meta product. +// +// appID must already be resolved for that specific product: a deployment may +// register a separate Meta app per product, and a code issued by one app cannot +// be exchanged with another app's secret. +func writeMetaOAuthURL(ctx *gin.Context, appID, appIDEnvName, redirectURI, state, scope string) { + appID = strings.TrimSpace(appID) + redirectURI = strings.TrimSpace(redirectURI) + + // A fabricated app id would send the operator to a Meta error page that looks + // like our bug, so an unconfigured deployment says so instead. if appID == "" { - appID = "123456789012345" + httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.e0353", appIDEnvName)) + return } - - state := strings.TrimSpace(ctx.Query("state")) - if state == "" { - state = "crove_messenger_connect" + if redirectURI == "" { + httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri")) + return + } + if state = strings.TrimSpace(state); state == "" { + state = "crove_meta_connect" } - authURL := fmt.Sprintf( - "https://www.facebook.com/v21.0/dialog/oauth?client_id=%s&redirect_uri=%s&scope=pages_show_list,pages_messaging,pages_manage_metadata&state=%s", - url.QueryEscape(appID), - url.QueryEscape(redirectURI), - url.QueryEscape(state), - ) + query := url.Values{} + query.Set("client_id", appID) + query.Set("redirect_uri", redirectURI) + query.Set("state", state) + query.Set("scope", scope) + // response_type=code is what makes Meta redirect back with an authorization + // code; without it the dialog returns a token fragment the server never sees. + query.Set("response_type", "code") httpx.WriteJSON(ctx, web.JsonData(gin.H{ - "authUrl": authURL, + "authUrl": metaOAuthDialogURL + "?" + query.Encode(), "appId": appID, "redirectUri": redirectURI, })) } -// ChannelGetInstagramOAuthURL returns the 1-Click OAuth authorization URL for Instagram Messaging. -func ChannelGetInstagramOAuthURL(ctx *gin.Context) { +// ChannelGetMessengerOAuthURL returns the 1-Click OAuth authorization URL for Meta Messenger. +func ChannelGetMessengerOAuthURL(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { httpx.WriteJSON(ctx, err) return } - appID := "" - if cfg := config.GetCurrent(); cfg != nil { - appID = strings.TrimSpace(cfg.Messenger.AppID) - } - if appID == "" { - appID = strings.TrimSpace(os.Getenv("META_APP_ID")) - } - if appID == "" { - appID = strings.TrimSpace(os.Getenv("FB_APP_ID")) - } - if appID == "" { - appID = strings.TrimSpace(ctx.Query("app_id")) + appID := config.ResolveMessengerApp(ctx.Query("app_id"), "").AppID + state := strings.TrimSpace(ctx.Query("state")) + if state == "" { + state = "crove_messenger_connect" } - redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) + writeMetaOAuthURL(ctx, appID, "FACEBOOK_APP_ID", ctx.Query("redirect_uri"), state, messengerOAuthScope) +} - if appID == "" { - appID = "123456789012345" +// ChannelGetInstagramOAuthURL returns the 1-Click OAuth authorization URL for Instagram Messaging. +func ChannelGetInstagramOAuthURL(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { + httpx.WriteJSON(ctx, err) + return } + appID := config.ResolveInstagramApp(ctx.Query("app_id"), "").AppID state := strings.TrimSpace(ctx.Query("state")) if state == "" { state = "crove_instagram_connect" } - - authURL := fmt.Sprintf( - "https://www.facebook.com/v21.0/dialog/oauth?client_id=%s&redirect_uri=%s&scope=instagram_basic,instagram_manage_messages,pages_show_list,pages_manage_metadata&state=%s", - url.QueryEscape(appID), - url.QueryEscape(redirectURI), - url.QueryEscape(state), - ) - - httpx.WriteJSON(ctx, web.JsonData(gin.H{ - "authUrl": authURL, - "appId": appID, - "redirectUri": redirectURI, - })) + writeMetaOAuthURL(ctx, appID, "INSTAGRAM_APP_ID", ctx.Query("redirect_uri"), state, instagramOAuthScope) } // ChannelGetWhatsAppOAuthURL returns the 1-Click Embedded Signup / OAuth URL for WhatsApp Cloud API. @@ -159,48 +152,27 @@ func ChannelGetWhatsAppOAuthURL(ctx *gin.Context) { return } - appID := "" - if cfg := config.GetCurrent(); cfg != nil { - appID = strings.TrimSpace(cfg.Messenger.AppID) - } - if appID == "" { - appID = strings.TrimSpace(os.Getenv("META_APP_ID")) - } - if appID == "" { - appID = strings.TrimSpace(ctx.Query("app_id")) - } - redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) - - // A fabricated app id would send the operator to a Meta error page that - // looks like our bug, so an unconfigured deployment says so instead. - if appID == "" { - httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.e0351")) - return + // An existing channel may belong to a different Meta app than the deployment + // default, and the authorization code can only be exchanged by the app that + // issued it, so the channel's own app id wins. + channelAppID := "" + if channelID, parseErr := strconv.ParseInt(strings.TrimSpace(ctx.Query("channel_id")), 10, 64); parseErr == nil && channelID > 0 { + if channel := services.ChannelService.Get(channelID); channel != nil && channel.Status != enums.StatusDeleted { + if cfg, cfgErr := services.ChannelService.ParseWhatsAppChannelConfig(channel.ConfigJSON); cfgErr == nil && cfg != nil { + channelAppID = cfg.AppID + } + } } - if redirectURI == "" { - httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri")) - return + if channelAppID == "" { + channelAppID = strings.TrimSpace(ctx.Query("app_id")) } + appID := config.ResolveWhatsAppApp(channelAppID, "").AppID state := strings.TrimSpace(ctx.Query("state")) if state == "" { state = "crove_whatsapp_connect" } - - query := url.Values{} - query.Set("client_id", appID) - query.Set("redirect_uri", redirectURI) - query.Set("state", state) - // response_type=code is what makes Meta redirect back with an authorization - // code; without it the dialog returns a token fragment the server never sees. - query.Set("response_type", "code") - query.Set("scope", "whatsapp_business_management,whatsapp_business_messaging") - - httpx.WriteJSON(ctx, web.JsonData(gin.H{ - "authUrl": "https://www.facebook.com/v21.0/dialog/oauth?" + query.Encode(), - "appId": appID, - "redirectUri": redirectURI, - })) + writeMetaOAuthURL(ctx, appID, "WHATSAPP_APP_ID", ctx.Query("redirect_uri"), state, whatsAppOAuthScope) } // ChannelPostWhatsAppOAuthCallback exchanges the authorization code Meta diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 5c5b2cf8..bd5c3902 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -31,6 +31,8 @@ type Config struct { Email EmailConfig `yaml:"email"` Discord DiscordConfig `yaml:"discord"` Messenger MessengerConfig `yaml:"messenger"` + Instagram InstagramConfig `yaml:"instagram"` + WhatsApp WhatsAppConfig `yaml:"whatsApp"` } func (c Config) LanguageOrDefault() string { @@ -289,6 +291,84 @@ type MessengerConfig struct { VerifyToken string `yaml:"verifyToken"` } +// InstagramConfig and WhatsAppConfig are separate from MessengerConfig because a +// deployment may register a distinct Meta app per product. One app secret cannot +// verify webhooks for two apps, so sharing a single credential silently breaks +// whichever product it does not belong to. +type InstagramConfig struct { + AppID string `yaml:"appId"` + AppSecret string `yaml:"appSecret"` + VerifyToken string `yaml:"verifyToken"` +} + +type WhatsAppConfig struct { + AppID string `yaml:"appId"` + AppSecret string `yaml:"appSecret"` + VerifyToken string `yaml:"verifyToken"` +} + +// MetaAppCredentials is the resolved credential set for one Meta product. +type MetaAppCredentials struct { + AppID string + AppSecret string + VerifyToken string +} + +// mergeMetaApp applies the resolution order for one Meta product: a channel-level +// credential wins, then the product's own deployment-level value, then the shared +// Messenger credentials. +// +// Messenger is the last fallback on purpose. A deployment that registered a single +// Meta app for Messenger, Instagram and WhatsApp set only META_APP_ID and +// META_APP_SECRET, and those keep working. A deployment with one app per product +// sets the per-product variables and stops inheriting. +func mergeMetaApp(product MetaAppCredentials, shared MessengerConfig, channelAppID, channelAppSecret string) MetaAppCredentials { + return MetaAppCredentials{ + AppID: firstNonBlankMeta(channelAppID, product.AppID, shared.AppID), + AppSecret: firstNonBlankMeta(channelAppSecret, product.AppSecret, shared.AppSecret), + VerifyToken: firstNonBlankMeta(product.VerifyToken, shared.VerifyToken), + } +} + +func firstNonBlankMeta(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +// MessengerApp resolves the Meta app credentials used by Facebook Messenger. +func (c Config) MessengerApp(channelAppID, channelAppSecret string) MetaAppCredentials { + return mergeMetaApp( + MetaAppCredentials{AppID: c.Messenger.AppID, AppSecret: c.Messenger.AppSecret, VerifyToken: c.Messenger.VerifyToken}, + c.Messenger, + channelAppID, + channelAppSecret, + ) +} + +// InstagramApp resolves the Meta app credentials used by Instagram Direct. +func (c Config) InstagramApp(channelAppID, channelAppSecret string) MetaAppCredentials { + return mergeMetaApp( + MetaAppCredentials{AppID: c.Instagram.AppID, AppSecret: c.Instagram.AppSecret, VerifyToken: c.Instagram.VerifyToken}, + c.Messenger, + channelAppID, + channelAppSecret, + ) +} + +// WhatsAppApp resolves the Meta app credentials used by the WhatsApp Cloud API. +func (c Config) WhatsAppApp(channelAppID, channelAppSecret string) MetaAppCredentials { + return mergeMetaApp( + MetaAppCredentials{AppID: c.WhatsApp.AppID, AppSecret: c.WhatsApp.AppSecret, VerifyToken: c.WhatsApp.VerifyToken}, + c.Messenger, + channelAppID, + channelAppSecret, + ) +} + func Load(path string) (*Config, error) { loadDotEnv(path) @@ -398,6 +478,12 @@ func bindConfigDefaults(v *viper.Viper) { v.SetDefault("messenger.appId", "") v.SetDefault("messenger.appSecret", "") v.SetDefault("messenger.verifyToken", "") + v.SetDefault("instagram.appId", "") + v.SetDefault("instagram.appSecret", "") + v.SetDefault("instagram.verifyToken", "") + v.SetDefault("whatsApp.appId", "") + v.SetDefault("whatsApp.appSecret", "") + v.SetDefault("whatsApp.verifyToken", "") } func bindEnvironmentAliases(v *viper.Viper) { @@ -456,9 +542,19 @@ func bindEnvironmentAliases(v *viper.Viper) { _ = v.BindEnv("discord.clientSecret", "AGENT_DESK_DISCORD_CLIENTSECRET", "DISCORD_CLIENT_SECRET") _ = v.BindEnv("discord.botToken", "AGENT_DESK_DISCORD_BOTTOKEN", "DISCORD_BOT_TOKEN") _ = v.BindEnv("discord.publicKey", "AGENT_DESK_DISCORD_PUBLICKEY", "DISCORD_PUBLIC_KEY") - _ = v.BindEnv("messenger.appId", "AGENT_DESK_MESSENGER_APPID", "META_APP_ID", "FB_APP_ID", "MESSENGER_APP_ID") - _ = v.BindEnv("messenger.appSecret", "AGENT_DESK_MESSENGER_APPSECRET", "META_APP_SECRET", "FB_APP_SECRET", "MESSENGER_APP_SECRET") - _ = v.BindEnv("messenger.verifyToken", "AGENT_DESK_MESSENGER_VERIFYTOKEN", "MESSENGER_VERIFY_TOKEN", "META_VERIFY_TOKEN", "FB_VERIFY_TOKEN") + _ = v.BindEnv("messenger.appId", "AGENT_DESK_MESSENGER_APPID", "FACEBOOK_APP_ID", "META_APP_ID", "FB_APP_ID", "MESSENGER_APP_ID") + _ = v.BindEnv("messenger.appSecret", "AGENT_DESK_MESSENGER_APPSECRET", "FACEBOOK_APP_SECRET", "META_APP_SECRET", "FB_APP_SECRET", "MESSENGER_APP_SECRET") + _ = v.BindEnv("messenger.verifyToken", "AGENT_DESK_MESSENGER_VERIFYTOKEN", "FACEBOOK_VERIFY_TOKEN", "MESSENGER_VERIFY_TOKEN", "META_VERIFY_TOKEN", "FB_VERIFY_TOKEN") + + // Instagram and WhatsApp are bound to their own variables rather than sharing + // the Meta ones above: a deployment that registers a separate app per product + // has two different secrets, and a shared variable can only hold one. + _ = v.BindEnv("instagram.appId", "AGENT_DESK_INSTAGRAM_APPID", "INSTAGRAM_APP_ID") + _ = v.BindEnv("instagram.appSecret", "AGENT_DESK_INSTAGRAM_APPSECRET", "INSTAGRAM_APP_SECRET") + _ = v.BindEnv("instagram.verifyToken", "AGENT_DESK_INSTAGRAM_VERIFYTOKEN", "INSTAGRAM_VERIFY_TOKEN") + _ = v.BindEnv("whatsApp.appId", "AGENT_DESK_WHATSAPP_APPID", "WHATSAPP_APP_ID") + _ = v.BindEnv("whatsApp.appSecret", "AGENT_DESK_WHATSAPP_APPSECRET", "WHATSAPP_APP_SECRET") + _ = v.BindEnv("whatsApp.verifyToken", "AGENT_DESK_WHATSAPP_VERIFYTOKEN", "WHATSAPP_VERIFY_TOKEN") } func normalizeLoadedConfig(cfg *Config) { diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index 421fc7a5..b05068b3 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -305,3 +305,124 @@ db: t.Errorf("DB.DSN=%q want legacy-dsn", cfg.DB.DSN) } } + +// metaAppEnvVars are every variable that can supply a Meta app credential. Each +// phase of the test sets exactly the ones it means to, so the rest have to be +// cleared or a value left over from an earlier phase would look like a fallback. +var metaAppEnvVars = []string{ + "AGENT_DESK_MESSENGER_APPID", "AGENT_DESK_MESSENGER_APPSECRET", + "AGENT_DESK_INSTAGRAM_APPID", "AGENT_DESK_INSTAGRAM_APPSECRET", + "AGENT_DESK_WHATSAPP_APPID", "AGENT_DESK_WHATSAPP_APPSECRET", + "FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET", + "INSTAGRAM_APP_ID", "INSTAGRAM_APP_SECRET", + "WHATSAPP_APP_ID", "WHATSAPP_APP_SECRET", + "META_APP_ID", "META_APP_SECRET", "FB_APP_ID", "FB_APP_SECRET", + "MESSENGER_APP_ID", "MESSENGER_APP_SECRET", +} + +func loadMetaConfig(t *testing.T) Config { + t.Helper() + t.Setenv("ENV_FILE", os.DevNull) + t.Setenv("AGENT_DESK_ENV_FILE", os.DevNull) + for _, name := range metaAppEnvVars { + t.Setenv(name, "") + } + t.Setenv("FACEBOOK_APP_ID", "app-messenger") + t.Setenv("FACEBOOK_APP_SECRET", "secret-messenger") + t.Setenv("INSTAGRAM_APP_ID", "app-instagram") + t.Setenv("INSTAGRAM_APP_SECRET", "secret-instagram") + t.Setenv("WHATSAPP_APP_ID", "app-whatsapp") + t.Setenv("WHATSAPP_APP_SECRET", "secret-whatsapp") + + cfg, err := Load(filepath.Join(t.TempDir(), "absent-config.yaml")) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + return *cfg +} + +// A deployment that registered one Meta app per product has more than one app +// secret. Each product must verify its own webhooks with its own secret; sharing +// one variable silently breaks whichever product it does not belong to. +func TestMetaAppCredentialsResolvePerProduct(t *testing.T) { + cfg := loadMetaConfig(t) + + messenger := cfg.MessengerApp("", "") + if messenger.AppID != "app-messenger" || messenger.AppSecret != "secret-messenger" { + t.Errorf("MessengerApp() = %+v, want the Facebook app credentials", messenger) + } + + instagram := cfg.InstagramApp("", "") + if instagram.AppID != "app-instagram" || instagram.AppSecret != "secret-instagram" { + t.Errorf("InstagramApp() = %+v, want the Instagram app credentials", instagram) + } + + whatsApp := cfg.WhatsAppApp("", "") + if whatsApp.AppID != "app-whatsapp" || whatsApp.AppSecret != "secret-whatsapp" { + t.Errorf("WhatsAppApp() = %+v, want the WhatsApp app credentials", whatsApp) + } + + if whatsApp.AppSecret == messenger.AppSecret { + t.Errorf("WhatsApp inherited the Messenger app secret; a two-app deployment would reject every WhatsApp webhook") + } +} + +// A channel can belong to a Meta app that is not the deployment default, so its +// own credentials have to win over the environment. +func TestMetaAppCredentialsPreferChannelValues(t *testing.T) { + cfg := loadMetaConfig(t) + + creds := cfg.WhatsAppApp("app-channel", "secret-channel") + if creds.AppID != "app-channel" || creds.AppSecret != "secret-channel" { + t.Errorf("WhatsAppApp() = %+v, want the channel-level credentials", creds) + } + + // A channel that sets only one of the two still inherits the other. + partial := cfg.WhatsAppApp("", "secret-channel") + if partial.AppID != "app-whatsapp" { + t.Errorf("WhatsAppApp() app id = %q, want app-whatsapp", partial.AppID) + } + if partial.AppSecret != "secret-channel" { + t.Errorf("WhatsAppApp() app secret = %q, want secret-channel", partial.AppSecret) + } +} + +// A single-app deployment sets only the shared Meta variables. Every product has +// to keep resolving to them, or upgrading would break a working installation. +func TestMetaAppCredentialsFallBackToSharedMessengerApp(t *testing.T) { + t.Setenv("ENV_FILE", os.DevNull) + t.Setenv("AGENT_DESK_ENV_FILE", os.DevNull) + for _, name := range metaAppEnvVars { + t.Setenv(name, "") + } + t.Setenv("META_APP_ID", "app-shared") + t.Setenv("META_APP_SECRET", "secret-shared") + + cfg, err := Load(filepath.Join(t.TempDir(), "absent-config.yaml")) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + for name, creds := range map[string]MetaAppCredentials{ + "MessengerApp": cfg.MessengerApp("", ""), + "InstagramApp": cfg.InstagramApp("", ""), + "WhatsAppApp": cfg.WhatsAppApp("", ""), + } { + if creds.AppID != "app-shared" || creds.AppSecret != "secret-shared" { + t.Errorf("%s() = %+v, want the shared META_APP_* credentials", name, creds) + } + } +} + +// The resolvers are called on webhook paths where configuration may not have been +// loaded yet. They must return the channel-level values instead of panicking. +func TestResolveMetaAppWithoutLoadedConfig(t *testing.T) { + previous := current + current = nil + defer func() { current = previous }() + + creds := ResolveWhatsAppApp("app-channel", "secret-channel") + if creds.AppID != "app-channel" || creds.AppSecret != "secret-channel" { + t.Errorf("ResolveWhatsAppApp() = %+v, want the channel-level credentials", creds) + } +} diff --git a/internal/pkg/config/runtime.go b/internal/pkg/config/runtime.go index 6badbf58..33f4d677 100644 --- a/internal/pkg/config/runtime.go +++ b/internal/pkg/config/runtime.go @@ -1,5 +1,7 @@ package config +import "strings" + var current *Config func SetCurrent(cfg *Config) { @@ -16,3 +18,38 @@ func Current() Config { } return *current } + +// The resolvers below return the channel-level values unchanged when no +// configuration has been loaded, so callers on webhook paths do not have to +// nil-check before authenticating a delivery. + +// ResolveMessengerApp resolves the Meta app credentials for Facebook Messenger. +func ResolveMessengerApp(channelAppID, channelAppSecret string) MetaAppCredentials { + if current == nil { + return unresolvedMetaApp(channelAppID, channelAppSecret) + } + return current.MessengerApp(channelAppID, channelAppSecret) +} + +// ResolveInstagramApp resolves the Meta app credentials for Instagram Direct. +func ResolveInstagramApp(channelAppID, channelAppSecret string) MetaAppCredentials { + if current == nil { + return unresolvedMetaApp(channelAppID, channelAppSecret) + } + return current.InstagramApp(channelAppID, channelAppSecret) +} + +// ResolveWhatsAppApp resolves the Meta app credentials for the WhatsApp Cloud API. +func ResolveWhatsAppApp(channelAppID, channelAppSecret string) MetaAppCredentials { + if current == nil { + return unresolvedMetaApp(channelAppID, channelAppSecret) + } + return current.WhatsAppApp(channelAppID, channelAppSecret) +} + +func unresolvedMetaApp(channelAppID, channelAppSecret string) MetaAppCredentials { + return MetaAppCredentials{ + AppID: strings.TrimSpace(channelAppID), + AppSecret: strings.TrimSpace(channelAppSecret), + } +} diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index e8d6d608..5b2f732e 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -79,6 +79,7 @@ type MessengerChannelConfig struct { PageName string `json:"pageName,omitempty"` PageAccessToken string `json:"pageAccessToken,omitempty"` WebhookVerifyToken string `json:"webhookVerifyToken,omitempty"` + AppID string `json:"appId,omitempty"` // Meta App ID this channel belongs to AppSecret string `json:"appSecret,omitempty"` // Meta App Secret WelcomeMessage string `json:"welcomeMessage,omitempty"` } @@ -89,6 +90,7 @@ type InstagramChannelConfig struct { PageID string `json:"pageId,omitempty"` // Linked Facebook Page ID PageAccessToken string `json:"pageAccessToken,omitempty"` // Page Access Token WebhookVerifyToken string `json:"webhookVerifyToken,omitempty"` // Webhook verify token + AppID string `json:"appId,omitempty"` // Meta App ID this channel belongs to AppSecret string `json:"appSecret,omitempty"` // Meta App Secret WelcomeMessage string `json:"welcomeMessage,omitempty"` } @@ -98,6 +100,7 @@ type WhatsAppChannelConfig struct { WABAID string `json:"wabaId,omitempty"` // WhatsApp Business Account ID AccessToken string `json:"accessToken,omitempty"` // System User Access Token WebhookVerifyToken string `json:"webhookVerifyToken,omitempty"` // Webhook verification token + AppID string `json:"appId,omitempty"` // Meta App ID this channel belongs to AppSecret string `json:"appSecret,omitempty"` // Meta App Secret WelcomeMessage string `json:"welcomeMessage,omitempty"` } diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index e7cd89b5..b74ee78d 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -347,10 +347,11 @@ error.e0345: "Attachment message is missing assetId." error.e0346: "Attachment message is missing payload." error.e0347: "Default team queue mode requires at least one agent team." error.e0348: "This file type cannot be uploaded for security reasons." -error.e0349: "WhatsApp webhook rejected: no Meta App Secret is configured. Set it on the WhatsApp channel or in META_APP_SECRET." +error.e0349: "WhatsApp webhook rejected: no Meta App Secret is configured. Set WHATSAPP_APP_SECRET, or appSecret on the WhatsApp channel." error.e0350: "WhatsApp webhook rejected: the X-Hub-Signature-256 header is missing." -error.e0351: "META_APP_ID and META_APP_SECRET must be configured before connecting WhatsApp." +error.e0351: "WhatsApp authorization needs a Meta App ID and App Secret. Set WHATSAPP_APP_ID and WHATSAPP_APP_SECRET, or appId and appSecret on the channel." error.e0352: "WhatsApp authorization failed: %s" +error.e0353: "No Meta App ID is configured for this channel. Set %s, or the app id on the channel." error.whatsapp.oauth.tokenInspectFailed: "Could not inspect the access token: %s" error.whatsapp.oauth.tokenInvalid: "Meta reports this access token is not valid." error.whatsapp.oauth.scopeMissing: "The granted token is missing the \"%s\" permission." diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml index 8c84e53c..dc038fe9 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -347,10 +347,11 @@ error.e0345: "附件消息缺少 assetId" error.e0346: "附件消息缺少 payload" error.e0347: "默认客服组待接入池模式必须至少选择一个客服组" error.e0348: "出于安全考虑,此文件类型不支持上传" -error.e0349: "WhatsApp 回调已被拒绝:未配置 Meta App Secret。请在 WhatsApp 渠道或 META_APP_SECRET 环境变量中配置。" +error.e0349: "WhatsApp 回调已被拒绝:未配置 Meta App Secret。请设置 WHATSAPP_APP_SECRET,或在 WhatsApp 渠道中填写 appSecret。" error.e0350: "WhatsApp 回调已被拒绝:缺少 X-Hub-Signature-256 请求头。" -error.e0351: "连接 WhatsApp 前必须先配置 META_APP_ID 和 META_APP_SECRET。" +error.e0351: "WhatsApp 授权需要 Meta App ID 和 App Secret。请设置 WHATSAPP_APP_ID 和 WHATSAPP_APP_SECRET,或在渠道中填写 appId 和 appSecret。" error.e0352: "WhatsApp 授权失败:%s" +error.e0353: "该渠道未配置 Meta App ID。请设置 %s,或在渠道中填写 app id。" error.whatsapp.oauth.tokenInspectFailed: "无法校验 Access Token:%s" error.whatsapp.oauth.tokenInvalid: "Meta 返回该 Access Token 无效。" error.whatsapp.oauth.scopeMissing: "授权令牌缺少 \"%s\" 权限。" diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index e3b01966..0db9bac3 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -486,6 +486,7 @@ func (s *channelService) ParseMessengerChannelConfig(raw string) (*dto.Messenger cfg.PageName = strings.TrimSpace(cfg.PageName) cfg.PageAccessToken = strings.TrimSpace(cfg.PageAccessToken) cfg.WebhookVerifyToken = strings.TrimSpace(cfg.WebhookVerifyToken) + cfg.AppID = strings.TrimSpace(cfg.AppID) cfg.AppSecret = strings.TrimSpace(cfg.AppSecret) cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) return cfg, nil @@ -504,6 +505,7 @@ func (s *channelService) ParseInstagramChannelConfig(raw string) (*dto.Instagram cfg.PageID = strings.TrimSpace(cfg.PageID) cfg.PageAccessToken = strings.TrimSpace(cfg.PageAccessToken) cfg.WebhookVerifyToken = strings.TrimSpace(cfg.WebhookVerifyToken) + cfg.AppID = strings.TrimSpace(cfg.AppID) cfg.AppSecret = strings.TrimSpace(cfg.AppSecret) cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) return cfg, nil @@ -521,6 +523,7 @@ func (s *channelService) ParseWhatsAppChannelConfig(raw string) (*dto.WhatsAppCh cfg.WABAID = strings.TrimSpace(cfg.WABAID) cfg.AccessToken = strings.TrimSpace(cfg.AccessToken) cfg.WebhookVerifyToken = strings.TrimSpace(cfg.WebhookVerifyToken) + cfg.AppID = strings.TrimSpace(cfg.AppID) cfg.AppSecret = strings.TrimSpace(cfg.AppSecret) cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) return cfg, nil diff --git a/internal/services/instagram_inbound_service.go b/internal/services/instagram_inbound_service.go index 4de06b98..104fc214 100644 --- a/internal/services/instagram_inbound_service.go +++ b/internal/services/instagram_inbound_service.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "os" "strings" "agent-desk/internal/messenger" @@ -62,21 +61,7 @@ func (s *instagramInboundService) HandleWebhook(ctx context.Context, channelID s } // Optional signature verification if appSecret is configured - appSecret := "" - if cfg != nil { - appSecret = strings.TrimSpace(cfg.AppSecret) - } - if appSecret == "" { - if serverCfg := config.GetCurrent(); serverCfg != nil { - appSecret = strings.TrimSpace(serverCfg.Messenger.AppSecret) - } - } - if appSecret == "" { - appSecret = strings.TrimSpace(os.Getenv("META_APP_SECRET")) - } - if appSecret == "" { - appSecret = strings.TrimSpace(os.Getenv("FB_APP_SECRET")) - } + appSecret := config.ResolveInstagramApp(cfg.AppID, cfg.AppSecret).AppSecret if appSecret != "" && strings.TrimSpace(signatureHeader) != "" { if !verifyMessengerSignature(appSecret, signatureHeader, rawPayload) { diff --git a/internal/services/messenger_inbound_service.go b/internal/services/messenger_inbound_service.go index b5e31036..60911f92 100644 --- a/internal/services/messenger_inbound_service.go +++ b/internal/services/messenger_inbound_service.go @@ -15,7 +15,6 @@ import ( "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/openidentity" - "os" ) var MessengerInboundService = newMessengerInboundService() @@ -63,21 +62,7 @@ func (s *messengerInboundService) HandleWebhook(ctx context.Context, channelID s } // Optional signature verification if appSecret is configured - appSecret := "" - if cfg != nil { - appSecret = strings.TrimSpace(cfg.AppSecret) - } - if appSecret == "" { - if serverCfg := config.GetCurrent(); serverCfg != nil { - appSecret = strings.TrimSpace(serverCfg.Messenger.AppSecret) - } - } - if appSecret == "" { - appSecret = strings.TrimSpace(os.Getenv("META_APP_SECRET")) - } - if appSecret == "" { - appSecret = strings.TrimSpace(os.Getenv("FB_APP_SECRET")) - } + appSecret := config.ResolveMessengerApp(cfg.AppID, cfg.AppSecret).AppSecret if appSecret != "" && strings.TrimSpace(signatureHeader) != "" { if !verifyMessengerSignature(appSecret, signatureHeader, rawPayload) { diff --git a/internal/services/whatsapp_inbound_service.go b/internal/services/whatsapp_inbound_service.go index ebd3dcc3..7f31e8d3 100644 --- a/internal/services/whatsapp_inbound_service.go +++ b/internal/services/whatsapp_inbound_service.go @@ -8,7 +8,6 @@ import ( "encoding/json" "fmt" "log/slog" - "os" "strings" "time" "unicode" @@ -661,10 +660,10 @@ func (s *whatsappInboundService) unsupportedContent(message *whatsapp.InboundMes // anyone who learns a webhook URL write into a customer conversation, trigger AI // replies and burn paid message quota. func verifyWhatsAppWebhook(cfg *dto.WhatsAppChannelConfig, signatureHeader string, rawPayload []byte) error { - appSecret := resolveMetaAppSecret(cfg.AppSecret) + appSecret := config.ResolveWhatsAppApp(cfg.AppID, cfg.AppSecret).AppSecret if appSecret == "" { slog.Error("whatsapp webhook rejected, no meta app secret configured", - "hint", "set appSecret on the WhatsApp channel or META_APP_SECRET in the environment", + "hint", "set WHATSAPP_APP_SECRET, or appSecret on the WhatsApp channel; FACEBOOK_APP_SECRET is only correct when one Meta app serves every product", ) return errorsx.UnauthorizedI18n("error.e0349") } @@ -677,22 +676,6 @@ func verifyWhatsAppWebhook(cfg *dto.WhatsAppChannelConfig, signatureHeader strin return nil } -// resolveMetaAppSecret finds the Meta App Secret used to sign webhook payloads, -// preferring the channel's own credential over the deployment-wide one. -func resolveMetaAppSecret(channelAppSecret string) string { - if secret := strings.TrimSpace(channelAppSecret); secret != "" { - return secret - } - if serverCfg := config.GetCurrent(); serverCfg != nil { - if secret := strings.TrimSpace(serverCfg.Messenger.AppSecret); secret != "" { - return secret - } - } - // config already binds META_APP_SECRET, but a deployment may export it after - // configuration was loaded. - return strings.TrimSpace(os.Getenv("META_APP_SECRET")) -} - func verifyWhatsAppSignature(appSecret string, signatureHeader string, payload []byte) bool { signature := strings.TrimSpace(signatureHeader) const prefix = "sha256=" diff --git a/internal/services/whatsapp_oauth_service.go b/internal/services/whatsapp_oauth_service.go index a704702c..a9e4b790 100644 --- a/internal/services/whatsapp_oauth_service.go +++ b/internal/services/whatsapp_oauth_service.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "agent-desk/internal/models" "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" @@ -59,17 +60,26 @@ func (s *whatsappOAuthService) Connect(req request.WhatsAppOAuthCallbackRequest, return nil, errorsx.InvalidParamI18n("error.param.required", "code") } - appID, appSecret := s.resolveAppCredentials() - if appID == "" || appSecret == "" { + // Load the target channel first. Its own Meta app decides which app the + // authorization code was issued for, and a code cannot be exchanged against a + // different app, so the credentials have to be resolved per channel rather + // than globally. + channel, channelCfg, err := s.loadTargetChannel(req.ChannelID) + if err != nil { + return nil, err + } + + creds := config.ResolveWhatsAppApp(channelCfg.AppID, channelCfg.AppSecret) + if creds.AppID == "" || creds.AppSecret == "" { return nil, errorsx.InvalidParamI18n("error.e0351") } ctx, cancel := context.WithTimeout(context.Background(), whatsappOAuthTimeout) defer cancel() - token, err := newWhatsAppOAuthClient("").ExchangeCodeForToken(ctx, appID, appSecret, code, req.RedirectURI) + token, err := newWhatsAppOAuthClient("").ExchangeCodeForToken(ctx, creds.AppID, creds.AppSecret, code, req.RedirectURI) if err != nil { - slog.Warn("whatsapp oauth code exchange failed", "error", err) + slog.Warn("whatsapp oauth code exchange failed", "app_id", creds.AppID, "error", err) return nil, errorsx.InvalidParamI18n("error.e0352", err.Error()) } @@ -84,21 +94,37 @@ func (s *whatsappOAuthService) Connect(req request.WhatsAppOAuthCallbackRequest, s.discoverAccounts(ctx, token.AccessToken, locale, result) s.applySingleCandidate(result) - if req.ChannelID > 0 { - if err := s.persist(req, result, operator); err != nil { + if channel != nil { + if err := s.persist(channel, channelCfg, req, result, operator); err != nil { return nil, err } } return result, nil } -func (s *whatsappOAuthService) resolveAppCredentials() (string, string) { - var appID, appSecret string - if cfg := config.GetCurrent(); cfg != nil { - appID = strings.TrimSpace(cfg.Messenger.AppID) - appSecret = strings.TrimSpace(cfg.Messenger.AppSecret) +// loadTargetChannel resolves the channel the credentials should be saved onto. A +// zero id means the operator is still creating one, which is not an error: the +// exchanged values are returned so the form can be filled in. +func (s *whatsappOAuthService) loadTargetChannel(channelID int64) (*models.Channel, *dto.WhatsAppChannelConfig, error) { + cfg := &dto.WhatsAppChannelConfig{} + if channelID <= 0 { + return nil, cfg, nil + } + channel := ChannelService.Get(channelID) + if channel == nil || channel.Status == enums.StatusDeleted { + return nil, nil, errorsx.InvalidParamI18n("error.e0208") + } + if strings.TrimSpace(channel.ChannelType) != enums.ChannelTypeWhatsApp { + return nil, nil, errorsx.InvalidParamI18n("error.e0250") + } + parsed, err := ChannelService.ParseWhatsAppChannelConfig(channel.ConfigJSON) + if err != nil { + return nil, nil, errorsx.InvalidParam("invalid whatsapp configuration") + } + if parsed != nil { + cfg = parsed } - return appID, appSecret + return channel, cfg, nil } // inspectToken records expiry and granted scopes so the operator can see whether @@ -215,19 +241,7 @@ func (s *whatsappOAuthService) applySingleCandidate(result *response.WhatsAppOAu // persist writes the exchanged credentials onto an existing WhatsApp channel, // preserving the webhook verify token and welcome message it already has. -func (s *whatsappOAuthService) persist(req request.WhatsAppOAuthCallbackRequest, result *response.WhatsAppOAuthConnectResponse, operator *dto.AuthPrincipal) error { - channel := ChannelService.Get(req.ChannelID) - if channel == nil || channel.Status == enums.StatusDeleted { - return errorsx.InvalidParamI18n("error.e0208") - } - if strings.TrimSpace(channel.ChannelType) != enums.ChannelTypeWhatsApp { - return errorsx.InvalidParamI18n("error.e0250") - } - - cfg, err := ChannelService.ParseWhatsAppChannelConfig(channel.ConfigJSON) - if err != nil { - return errorsx.InvalidParam("invalid whatsapp configuration") - } +func (s *whatsappOAuthService) persist(channel *models.Channel, cfg *dto.WhatsAppChannelConfig, req request.WhatsAppOAuthCallbackRequest, result *response.WhatsAppOAuthConnectResponse, operator *dto.AuthPrincipal) error { if cfg == nil { cfg = &dto.WhatsAppChannelConfig{} } @@ -239,13 +253,10 @@ func (s *whatsappOAuthService) persist(req request.WhatsAppOAuthCallbackRequest, if phoneNumberID := s.pickPhoneNumberID(req, result, cfg.WABAID); phoneNumberID != "" { cfg.PhoneNumberID = phoneNumberID } - // Without an app secret the inbound webhook cannot be authenticated, so the - // deployment-wide secret is recorded on the channel when it has none. - if cfg.AppSecret == "" { - if cfgSecret := config.GetCurrent(); cfgSecret != nil { - cfg.AppSecret = strings.TrimSpace(cfgSecret.Messenger.AppSecret) - } - } + // The app id and secret are deliberately not copied onto the channel here. + // They resolve through the same channel -> product -> Messenger chain at + // webhook time, so writing the resolved value would freeze a fallback in + // place and keep winning after the deployment-level credential is corrected. configBytes, err := json.Marshal(cfg) if err != nil { diff --git a/internal/services/whatsapp_oauth_service_test.go b/internal/services/whatsapp_oauth_service_test.go index 8d4011a0..231562f1 100644 --- a/internal/services/whatsapp_oauth_service_test.go +++ b/internal/services/whatsapp_oauth_service_test.go @@ -1,6 +1,10 @@ package services import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "net/http" "net/http/httptest" @@ -16,8 +20,11 @@ import ( ) // newGraphStub returns an httptest server that answers the WhatsApp connect flow -// with a single business, a single WABA and a single sender number. -func newGraphStub(t *testing.T, accessToken string) *httptest.Server { +// with a single business, a single WABA and a single sender number. It asserts the +// code exchange was authenticated with the expected app, because exchanging a code +// against the wrong Meta app is the failure this whole resolution order exists to +// prevent. +func newGraphStub(t *testing.T, accessToken, wantClientID, wantClientSecret string) *httptest.Server { t.Helper() mux := http.NewServeMux() @@ -25,11 +32,11 @@ func newGraphStub(t *testing.T, accessToken string) *httptest.Server { if got := r.URL.Query().Get("code"); got != "auth-code-1" { t.Errorf("code = %q, want auth-code-1", got) } - if got := r.URL.Query().Get("client_id"); got != "app-1" { - t.Errorf("client_id = %q, want app-1", got) + if got := r.URL.Query().Get("client_id"); got != wantClientID { + t.Errorf("client_id = %q, want %q", got, wantClientID) } - if got := r.URL.Query().Get("client_secret"); got != "secret-1" { - t.Errorf("client_secret = %q, want secret-1", got) + if got := r.URL.Query().Get("client_secret"); got != wantClientSecret { + t.Errorf("client_secret = %q, want %q", got, wantClientSecret) } writeJSONStub(w, map[string]any{ "access_token": accessToken, @@ -111,12 +118,86 @@ func setMetaAppCredentials(t *testing.T, appID, appSecret string) { t.Cleanup(func() { config.SetCurrent(previous) }) } +// setSplitMetaAppCredentials configures a different Meta app for Messenger and +// for WhatsApp, which is how a deployment that registered one app per product is +// set up. +func setSplitMetaAppCredentials(t *testing.T) { + t.Helper() + previous := config.GetCurrent() + cfg := &config.Config{} + if previous != nil { + *cfg = *previous + } + cfg.Messenger.AppID = "app-messenger" + cfg.Messenger.AppSecret = "secret-messenger" + cfg.WhatsApp.AppID = "app-whatsapp" + cfg.WhatsApp.AppSecret = "secret-whatsapp" + config.SetCurrent(cfg) + t.Cleanup(func() { config.SetCurrent(previous) }) +} + +// A deployment with one Meta app per product has two different secrets. WhatsApp +// must be authorized with its own app: a code issued by the WhatsApp app cannot be +// exchanged with the Messenger app's secret, and Meta rejects it. +func TestWhatsAppOAuthConnectUsesTheWhatsAppAppNotMessenger(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, "") + setSplitMetaAppCredentials(t) + + server := newGraphStub(t, "EAAtest-business-token", "app-whatsapp", "secret-whatsapp") + defer server.Close() + defer stubWhatsAppOAuthClient(server)() + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + result, err := WhatsAppOAuthService.Connect(request.WhatsAppOAuthCallbackRequest{ + Code: "auth-code-1", + ChannelID: channel.ID, + }, "en-US", operator) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Connected { + t.Errorf("Connected = false, want the credentials to be saved") + } +} + +// The inbound webhook has to verify signatures with the same app the channel +// belongs to, so it resolves credentials the same way the connect flow does. +func TestWhatsAppWebhookUsesTheWhatsAppAppSecret(t *testing.T) { + db := setupWhatsAppTestDB(t) + channel := seedWhatsAppChannel(t, db, "") + setSplitMetaAppCredentials(t) + + payload := []byte(whatsAppWebhookPayload(`{ + "from": "84901234567", "id": "wamid.SPLIT01", "timestamp": "1725260000", "type": "text", + "text": { "body": "hello" } + }`)) + + // Signed with the Messenger secret: must be rejected. + wrongMac := hmac.New(sha256.New, []byte("secret-messenger")) + wrongMac.Write(payload) + wrongSignature := "sha256=" + hex.EncodeToString(wrongMac.Sum(nil)) + err := WhatsAppInboundService.HandleWebhook(context.Background(), channel.ChannelID, wrongSignature, payload) + assertUnauthorized(t, err) + + // Signed with the WhatsApp secret: must be accepted. + rightSignature := signWhatsAppPayload(t, "secret-whatsapp", payload) + if err := WhatsAppInboundService.HandleWebhook(context.Background(), channel.ChannelID, rightSignature, payload); err != nil { + t.Fatalf("HandleWebhook with the WhatsApp app secret failed: %v", err) + } + if countWhatsAppMessages(t, db, "wamid.SPLIT01") != 1 { + t.Fatalf("expected the correctly signed message to be stored") + } +} + func TestWhatsAppOAuthConnectPersistsCredentials(t *testing.T) { db := setupWhatsAppTestDB(t) channel := seedWhatsAppChannel(t, db, whatsAppTestAppSecret) setMetaAppCredentials(t, "app-1", "secret-1") - server := newGraphStub(t, "EAAtest-business-token") + // The channel carries its own app secret but no app id, so the exchange has to + // use the channel secret with the deployment-level app id. + server := newGraphStub(t, "EAAtest-business-token", "app-1", whatsAppTestAppSecret) defer server.Close() defer stubWhatsAppOAuthClient(server)() @@ -181,7 +262,7 @@ func TestWhatsAppOAuthConnectPersistsCredentials(t *testing.T) { func TestWhatsAppOAuthConnectRequiresAppCredentials(t *testing.T) { db := setupWhatsAppTestDB(t) - channel := seedWhatsAppChannel(t, db, whatsAppTestAppSecret) + channel := seedWhatsAppChannel(t, db, "") setMetaAppCredentials(t, "", "") operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} @@ -320,7 +401,7 @@ func TestWhatsAppOAuthConnectRejectsNonWhatsAppChannel(t *testing.T) { t.Fatalf("create telegram channel: %v", err) } - server := newGraphStub(t, "EAAtest-business-token") + server := newGraphStub(t, "EAAtest-business-token", "app-1", whatsAppTestAppSecret) defer server.Close() defer stubWhatsAppOAuthClient(server)() diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index c8dda47f..96ac6a15 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -124,6 +124,7 @@ type WhatsAppChannelConfig = { wabaId?: string accessToken?: string webhookVerifyToken?: string + appId?: string appSecret?: string } @@ -223,6 +224,8 @@ function createSchema(t: Translate) { whatsAppWabaId: z.string().trim(), whatsAppAccessToken: z.string().trim(), whatsAppWebhookVerifyToken: z.string().trim(), + whatsAppAppId: z.string().trim(), + whatsAppAppSecret: z.string().trim(), slackBotToken: z.string().trim(), slackSigningSecret: z.string().trim(), slackAppId: z.string().trim(), @@ -371,6 +374,8 @@ type EditForm = { whatsAppWabaId: string whatsAppAccessToken: string whatsAppWebhookVerifyToken: string + whatsAppAppId: string + whatsAppAppSecret: string slackBotToken: string slackSigningSecret: string slackAppId: string @@ -455,6 +460,8 @@ function createEmptyForm(t: Translate): EditForm { whatsAppWabaId: "", whatsAppAccessToken: "", whatsAppWebhookVerifyToken: "", + whatsAppAppId: "", + whatsAppAppSecret: "", slackBotToken: "", slackSigningSecret: "", slackAppId: "", @@ -668,6 +675,7 @@ function parseWhatsAppChannelConfig(configJson: string): WhatsAppChannelConfig { wabaId: parsed.wabaId?.trim() || "", accessToken: parsed.accessToken?.trim() || "", webhookVerifyToken: parsed.webhookVerifyToken?.trim() || "", + appId: parsed.appId?.trim() || "", appSecret: parsed.appSecret?.trim() || "", } } catch { @@ -898,6 +906,8 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { whatsAppWabaId: whatsAppConfig?.wabaId ?? "", whatsAppAccessToken: whatsAppConfig?.accessToken ?? "", whatsAppWebhookVerifyToken: whatsAppConfig?.webhookVerifyToken ?? "", + whatsAppAppId: whatsAppConfig?.appId ?? "", + whatsAppAppSecret: whatsAppConfig?.appSecret ?? "", slackBotToken: slackConfig?.botToken ?? "", slackSigningSecret: slackConfig?.signingSecret ?? "", slackAppId: slackConfig?.appId ?? "", @@ -1014,6 +1024,8 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin wabaId: form.whatsAppWabaId.trim(), accessToken: form.whatsAppAccessToken.trim(), webhookVerifyToken: form.whatsAppWebhookVerifyToken.trim(), + appId: form.whatsAppAppId.trim(), + appSecret: form.whatsAppAppSecret.trim(), }) : channelType === "slack" ? JSON.stringify({ @@ -1405,7 +1417,11 @@ function ChannelFormBody({ const state = itemId ? `${WHATSAPP_OAUTH_STATE_PREFIX}:${itemId}` : WHATSAPP_OAUTH_STATE_PREFIX - const { authUrl } = await fetchWhatsAppOAuthURL(redirectUri, state) + const { authUrl } = await fetchWhatsAppOAuthURL( + redirectUri, + state, + itemId ?? undefined + ) // No noopener: the landing page needs window.opener to hand the // credentials back to this form. const popup = window.open( @@ -2032,6 +2048,37 @@ function ChannelFormBody({ + +
+ + {t("channel.whatsappAppId")} + + + + + + + + {t("channel.whatsappAppSecret")} + + + + + +
+ +

+ {t("channel.whatsappAppCredentialsHint")} +

) : null} diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 800618a5..f8bc9848 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -1045,11 +1045,18 @@ export function deleteChannel(id: number) { }) } -export function fetchWhatsAppOAuthURL(redirectUri: string, state?: string) { +export function fetchWhatsAppOAuthURL( + redirectUri: string, + state?: string, + channelId?: number +) { return request( `/api/dashboard/channel/whatsapp_oauth_url${toQueryString({ redirect_uri: redirectUri, state, + // The channel's own Meta app issues the code, so the server needs to know + // which channel is being connected to pick the right app id. + channel_id: channelId, })}` ) } diff --git a/web/messages/en-US.json b/web/messages/en-US.json index c89738bd..6291a5bf 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -703,6 +703,9 @@ "whatsappPhoneId": "Phone Number ID", "whatsappWabaId": "WABA ID (Business Account ID)", "whatsappAccessToken": "System User Access Token", + "whatsappAppId": "Meta App ID", + "whatsappAppSecret": "Meta App Secret", + "whatsappAppCredentialsHint": "Only needed when this channel uses a different Meta app from the deployment default. Leave both empty to use WHATSAPP_APP_ID and WHATSAPP_APP_SECRET. The app secret is required to verify inbound webhooks; without it every delivery is rejected.", "whatsappConnecting": "Opening Meta authorization…", "whatsappConnectFailed": "Could not start WhatsApp authorization: {error}", "whatsappPopupBlocked": "Your browser blocked the Meta sign-in window. Allow pop-ups for this site and try again.", diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json index 5519de15..ff8d9dbb 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -704,6 +704,9 @@ "whatsappPhoneId": "Phone Number ID", "whatsappWabaId": "WABA ID (Mã tài khoản doanh nghiệp)", "whatsappAccessToken": "System User Access Token", + "whatsappAppId": "Meta App ID", + "whatsappAppSecret": "Meta App Secret", + "whatsappAppCredentialsHint": "Chỉ cần điền khi kênh này dùng app Meta khác với mặc định của hệ thống. Để trống cả hai thì sẽ dùng WHATSAPP_APP_ID và WHATSAPP_APP_SECRET. App Secret là bắt buộc để xác minh webhook đến; nếu thiếu, mọi webhook đều bị từ chối.", "whatsappConnecting": "Đang mở trang ủy quyền Meta…", "whatsappConnectFailed": "Không thể bắt đầu ủy quyền WhatsApp: {error}", "whatsappPopupBlocked": "Trình duyệt đã chặn cửa sổ đăng nhập Meta. Vui lòng cho phép cửa sổ bật lên cho trang này rồi thử lại.", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 7d517565..daafddab 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -703,6 +703,9 @@ "whatsappPhoneId": "Phone Number ID", "whatsappWabaId": "WABA ID (商业账号 ID)", "whatsappAccessToken": "System User Access Token", + "whatsappAppId": "Meta App ID", + "whatsappAppSecret": "Meta App Secret", + "whatsappAppCredentialsHint": "仅当该渠道使用与部署默认值不同的 Meta 应用时才需要填写。两项都留空则使用 WHATSAPP_APP_ID 和 WHATSAPP_APP_SECRET。校验入站回调必须使用 App Secret,否则所有回调都会被拒绝。", "whatsappConnecting": "正在打开 Meta 授权页面…", "whatsappConnectFailed": "无法发起 WhatsApp 授权:{error}", "whatsappPopupBlocked": "浏览器拦截了 Meta 登录窗口。请允许本站弹出窗口后重试。",