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 登录窗口。请允许本站弹出窗口后重试。",