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.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