From c25c573fa215740d8c0ae2d1f28a92ea8542a08c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:31:53 +0700 Subject: [PATCH 1/5] fix(channel): make the documented Slack variables real and stop OAuth URLs from inventing credentials Three defects in the same area, all found while answering whether the documented environment variables were complete. The Slack variables in .env.example were never read. SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET were documented as the Slack configuration, but the only one any code touched was SLACK_CLIENT_ID, read in the OAuth URL handler. The bot token and signing secret live on the channel, so setting the documented variables did nothing at all. They are real bindings now, following the same pattern Discord already had: a SlackConfig section with deployment-wide defaults, and a channel credential that wins over it. That matches how the Meta credentials resolve, so a deployment with one Slack app has nothing to change and a deployment with several workspaces can still use per-channel credentials. Four OAuth authorization URLs fabricated a client identifier when none was configured. Discord answered with "123456789012345678", Slack with "123456789012.1234567890123", X with "x_oauth_client_id_placeholder" and TikTok with "tiktok_client_key_placeholder". Each sent the operator to the provider's own error page, which reads as an application bug, and the real cause was never visible. They now answer which variable to set instead. Slack webhook verification also hardened, matching the behaviour already contributed upstream: a delivery with no signature headers is rejected rather than waved through when a signing secret resolves for the channel, and request timestamps outside a five minute window are rejected. Slack signs the timestamp and the body but nothing in the signature expires, so without the check a captured request replays indefinitely. A channel with no signing secret still accepts deliveries, so this is not breaking for an existing installation. Slack's OAuth install flow is implemented: POST /api/dashboard/channel/slack_oauth_callback exchanges an installation code through oauth.v2.access, verifies the resulting token with auth.test, reports the workspace identity and the preselected default channel, and saves everything onto the target channel while preserving its signing secret. The token type and the chat:write scope are checked, because Slack does not treat a user token or a missing scope as an install error and either would leave a bot that cannot reply. Masking of channel credentials is shared between the WhatsApp and Slack flows rather than duplicated. Tests internal/slack/client_test.go oauth.v2.access is form-encoded, ok:false surfaces as an error rather than being treated as success, a blank redirect_uri is omitted, auth.test failure surfaces, threaded replies carry thread_ts and a top-level post omits it internal/services/slack_oauth_service_test.go connect persists the workspace identity and the bot token while preserving the existing signing secret; missing client credentials is an error; a Slack-rejected code carries its reason through; a user token and a missing chat:write scope are warnings rather than silent installs; auth.test failure does not discard a token Slack issued; non-Slack channels rejected internal/services/slack_inbound_service_test.go an unsigned delivery is rejected and stores nothing, a replayed timestamp is rejected at ten minutes, one hour and ten minutes in the future, and a fresh or four-minute-old signature is still accepted internal/pkg/config the resolvers return channel-level values when no configuration is loaded .env.example gains sections for X and TikTok, whose client identifiers are read by the OAuth URL handlers and were undocumented, and notes that LINE and Viber store everything per channel. --- .env.example | 15 + internal/bootstrap/routes.go | 1 + .../dashboard/channel_oauth_handler.go | 72 +++- internal/pkg/config/config.go | 39 ++- internal/pkg/config/runtime.go | 12 + internal/pkg/dto/request/channel_request.go | 9 + .../response/channel_slack_oauth_response.go | 24 ++ internal/pkg/i18nx/locales/en-US.yml | 6 + internal/pkg/i18nx/locales/zh-CN.yml | 6 + internal/services/slack_inbound_service.go | 34 +- .../services/slack_inbound_service_test.go | 27 +- internal/services/slack_oauth_service.go | 223 ++++++++++++ internal/services/slack_oauth_service_test.go | 325 ++++++++++++++++++ internal/services/slack_outbound_service.go | 12 +- internal/services/whatsapp_oauth_service.go | 8 +- internal/slack/client.go | 88 +++++ internal/slack/client_test.go | 236 +++++++++++++ internal/slack/types.go | 45 +++ 18 files changed, 1159 insertions(+), 23 deletions(-) create mode 100644 internal/pkg/dto/response/channel_slack_oauth_response.go create mode 100644 internal/services/slack_oauth_service.go create mode 100644 internal/services/slack_oauth_service_test.go create mode 100644 internal/slack/client_test.go diff --git a/.env.example b/.env.example index 4e9d1af4..928a7632 100644 --- a/.env.example +++ b/.env.example @@ -161,7 +161,22 @@ BREVO_API_KEY=xkeysib-your-brevo-api-key # MESSENGER_VERIFY_TOKEN=your-webhook-verify-token # Slack Bot Integration (Slack Web API & Events API) +# The client id and secret are what the OAuth install flow needs. The bot token +# and signing secret are the deployment-wide fallback; a channel that carries its +# own overrides them. # SLACK_CLIENT_ID=your-slack-client-id # SLACK_CLIENT_SECRET=your-slack-client-secret # SLACK_BOT_TOKEN=xoxb-your-slack-bot-token # SLACK_SIGNING_SECRET=your-slack-signing-secret + +# X (Twitter) OAuth 2.0 client identifier, used to build the authorization URL. +# Requires the dm.read, dm.write and users.read scopes, and a redirect URI +# registered on the X app. +# X_CLIENT_ID=your-x-oauth2-client-id +# TWITTER_CLIENT_ID=legacy-alias-for-X_CLIENT_ID + +# TikTok Business Messaging application client key. +# TIKTOK_CLIENT_KEY=your-tiktok-client-key + +# LINE and Viber store all of their credentials per channel in Dashboard -> +# Channels; they read nothing from the environment. diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 47b5fd45..e4dc1109 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -293,6 +293,7 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) { group.GET("/whatsapp_oauth_url", dashboard.ChannelGetWhatsAppOAuthURL) group.POST("/whatsapp_oauth_callback", dashboard.ChannelPostWhatsAppOAuthCallback) group.GET("/slack_oauth_url", dashboard.ChannelGetSlackOAuthURL) + group.POST("/slack_oauth_callback", dashboard.ChannelPostSlackOAuthCallback) group.GET("/x_oauth_url", dashboard.ChannelGetXOAuthURL) group.GET("/tiktok_oauth_url", dashboard.ChannelGetTikTokOAuthURL) group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts) diff --git a/internal/handlers/dashboard/channel_oauth_handler.go b/internal/handlers/dashboard/channel_oauth_handler.go index e0ce97d4..5e548c6b 100644 --- a/internal/handlers/dashboard/channel_oauth_handler.go +++ b/internal/handlers/dashboard/channel_oauth_handler.go @@ -41,8 +41,12 @@ func ChannelGetDiscordOAuthURL(ctx *gin.Context) { redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) if clientID == "" { - // Provide guidance or sample client id - clientID = "123456789012345678" + writeMissingOAuthCredential(ctx, "DISCORD_CLIENT_ID") + return + } + if redirectURI == "" { + httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri")) + return } state := strings.TrimSpace(ctx.Query("state")) @@ -69,6 +73,14 @@ func ChannelGetDiscordOAuthURL(ctx *gin.Context) { // cannot drift onto different versions. const metaOAuthDialogURL = "https://www.facebook.com/v21.0/dialog/oauth" +// writeMissingOAuthCredential reports an absent OAuth client identifier instead of +// inventing one. A fabricated id sends the operator to the provider's own error +// page, which reads as an application bug rather than a missing configuration +// value, and the real cause is never visible. +func writeMissingOAuthCredential(ctx *gin.Context, envName string) { + httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.channel.oauth.clientIdMissing", envName)) +} + // Scopes each Meta product needs to send and receive support messages. const ( messengerOAuthScope = "pages_show_list,pages_messaging,pages_manage_metadata" @@ -206,6 +218,36 @@ func ChannelPostWhatsAppOAuthCallback(ctx *gin.Context) { httpx.WriteJSON(ctx, result) } +// ChannelPostSlackOAuthCallback exchanges the installation code Slack redirected +// back with for the workspace's bot credentials, and saves them when channelId +// names an existing channel. +func ChannelPostSlackOAuthCallback(ctx *gin.Context) { + req := request.SlackOAuthCallbackRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + // Saving onto an existing channel is an update; exchanging credentials for a + // channel that does not exist yet is part of creating one. + permission := constants.PermissionChannelCreate + if req.ChannelID > 0 { + permission = constants.PermissionChannelUpdate + } + operator, err := services.AuthService.RequirePermission(ctx, permission) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + + result, err := services.SlackOAuthService.Connect(req, i18nx.Locale(ctx), operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, result) +} + // ChannelGetSlackOAuthURL returns the 1-Click OAuth authorization URL for Slack Workspace Bot. func ChannelGetSlackOAuthURL(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil { @@ -213,14 +255,22 @@ func ChannelGetSlackOAuthURL(ctx *gin.Context) { return } - clientID := strings.TrimSpace(os.Getenv("SLACK_CLIENT_ID")) + clientID := config.ResolveSlack("", "").ClientID + if clientID == "" { + clientID = strings.TrimSpace(os.Getenv("SLACK_CLIENT_ID")) + } if clientID == "" { clientID = strings.TrimSpace(ctx.Query("client_id")) } redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) if clientID == "" { - clientID = "123456789012.1234567890123" + writeMissingOAuthCredential(ctx, "SLACK_CLIENT_ID") + return + } + if redirectURI == "" { + httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri")) + return } state := strings.TrimSpace(ctx.Query("state")) @@ -259,7 +309,12 @@ func ChannelGetXOAuthURL(ctx *gin.Context) { redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) if clientID == "" { - clientID = "x_oauth_client_id_placeholder" + writeMissingOAuthCredential(ctx, "X_CLIENT_ID") + return + } + if redirectURI == "" { + httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri")) + return } state := strings.TrimSpace(ctx.Query("state")) @@ -295,7 +350,12 @@ func ChannelGetTikTokOAuthURL(ctx *gin.Context) { redirectURI := strings.TrimSpace(ctx.Query("redirect_uri")) if clientKey == "" { - clientKey = "tiktok_client_key_placeholder" + writeMissingOAuthCredential(ctx, "TIKTOK_CLIENT_KEY") + return + } + if redirectURI == "" { + httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri")) + return } state := strings.TrimSpace(ctx.Query("state")) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index d3549cae..5a9c1fe8 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -30,6 +30,7 @@ type Config struct { Webhook WebhookConfig `yaml:"webhook"` Email EmailConfig `yaml:"email"` Discord DiscordConfig `yaml:"discord"` + Slack SlackConfig `yaml:"slack"` Messenger MessengerConfig `yaml:"messenger"` Instagram InstagramConfig `yaml:"instagram"` WhatsApp WhatsAppConfig `yaml:"whatsApp"` @@ -390,6 +391,16 @@ type DiscordConfig struct { PublicKey string `yaml:"publicKey"` } +// SlackConfig holds deployment-wide Slack app credentials. A channel may carry +// its own bot token and signing secret, which take precedence; these are the +// fallback for a single shared Slack app. +type SlackConfig struct { + ClientID string `yaml:"clientId"` + ClientSecret string `yaml:"clientSecret"` + BotToken string `yaml:"botToken"` + SigningSecret string `yaml:"signingSecret"` +} + type MessengerConfig struct { AppID string `yaml:"appId"` AppSecret string `yaml:"appSecret"` @@ -429,13 +440,13 @@ type MetaAppCredentials struct { // 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), + AppID: firstNonBlank(channelAppID, product.AppID, shared.AppID), + AppSecret: firstNonBlank(channelAppSecret, product.AppSecret, shared.AppSecret), + VerifyToken: firstNonBlank(product.VerifyToken, shared.VerifyToken), } } -func firstNonBlankMeta(values ...string) string { +func firstNonBlank(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed @@ -474,6 +485,18 @@ func (c Config) WhatsAppApp(channelAppID, channelAppSecret string) MetaAppCreden ) } +// SlackApp resolves the Slack app credentials for one channel. A channel value +// wins over the deployment-wide one, so a single deployment can serve several +// workspaces while still having a default app. +func (c Config) SlackApp(channelBotToken, channelSigningSecret string) SlackConfig { + return SlackConfig{ + ClientID: strings.TrimSpace(c.Slack.ClientID), + ClientSecret: strings.TrimSpace(c.Slack.ClientSecret), + BotToken: firstNonBlank(channelBotToken, c.Slack.BotToken), + SigningSecret: firstNonBlank(channelSigningSecret, c.Slack.SigningSecret), + } +} + func Load(path string) (*Config, error) { loadDotEnv(path) @@ -584,6 +607,10 @@ func bindConfigDefaults(v *viper.Viper) { v.SetDefault("discord.clientSecret", "") v.SetDefault("discord.botToken", "") v.SetDefault("discord.publicKey", "") + v.SetDefault("slack.clientId", "") + v.SetDefault("slack.clientSecret", "") + v.SetDefault("slack.botToken", "") + v.SetDefault("slack.signingSecret", "") v.SetDefault("messenger.appId", "") v.SetDefault("messenger.appSecret", "") v.SetDefault("messenger.verifyToken", "") @@ -655,6 +682,10 @@ 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("slack.clientId", "AGENT_DESK_SLACK_CLIENTID", "SLACK_CLIENT_ID") + _ = v.BindEnv("slack.clientSecret", "AGENT_DESK_SLACK_CLIENTSECRET", "SLACK_CLIENT_SECRET") + _ = v.BindEnv("slack.botToken", "AGENT_DESK_SLACK_BOTTOKEN", "SLACK_BOT_TOKEN") + _ = v.BindEnv("slack.signingSecret", "AGENT_DESK_SLACK_SIGNINGSECRET", "SLACK_SIGNING_SECRET") _ = 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") diff --git a/internal/pkg/config/runtime.go b/internal/pkg/config/runtime.go index 33f4d677..fb418bcd 100644 --- a/internal/pkg/config/runtime.go +++ b/internal/pkg/config/runtime.go @@ -53,3 +53,15 @@ func unresolvedMetaApp(channelAppID, channelAppSecret string) MetaAppCredentials AppSecret: strings.TrimSpace(channelAppSecret), } } + +// ResolveSlack resolves the Slack app credentials for one channel without +// requiring the caller to nil-check the loaded configuration. +func ResolveSlack(channelBotToken, channelSigningSecret string) SlackConfig { + if current == nil { + return SlackConfig{ + BotToken: strings.TrimSpace(channelBotToken), + SigningSecret: strings.TrimSpace(channelSigningSecret), + } + } + return current.SlackApp(channelBotToken, channelSigningSecret) +} diff --git a/internal/pkg/dto/request/channel_request.go b/internal/pkg/dto/request/channel_request.go index f2f76a89..812a17ec 100644 --- a/internal/pkg/dto/request/channel_request.go +++ b/internal/pkg/dto/request/channel_request.go @@ -50,3 +50,12 @@ type WhatsAppOAuthCallbackRequest struct { PhoneNumberID string `json:"phoneNumberId"` WabaID string `json:"wabaId"` } + +// SlackOAuthCallbackRequest carries the installation code Slack redirected back +// with. ChannelID is optional and means the same thing as on the WhatsApp one. +type SlackOAuthCallbackRequest struct { + Code string `json:"code"` + State string `json:"state"` + ChannelID int64 `json:"channelId"` + RedirectURI string `json:"redirectUri"` +} diff --git a/internal/pkg/dto/response/channel_slack_oauth_response.go b/internal/pkg/dto/response/channel_slack_oauth_response.go new file mode 100644 index 00000000..c2119e6e --- /dev/null +++ b/internal/pkg/dto/response/channel_slack_oauth_response.go @@ -0,0 +1,24 @@ +package response + +// SlackOAuthConnectResponse reports what a Slack installation code was exchanged +// for. Slack returns the workspace identity and the bot token together, so a +// successful connect fills every field the channel form needs. +type SlackOAuthConnectResponse struct { + // Connected is true when the credentials were persisted onto a channel. + Connected bool `json:"connected"` + ChannelID int64 `json:"channelId,omitempty"` + + BotToken string `json:"botToken"` + TokenMasked string `json:"tokenMasked"` + AppID string `json:"appId,omitempty"` + BotUserID string `json:"botUserId,omitempty"` + TeamID string `json:"teamId,omitempty"` + TeamName string `json:"teamName,omitempty"` + + // DefaultChannelID is the channel Slack preselected during installation. It + // is only present when the installation included an incoming webhook. + DefaultChannelID string `json:"defaultChannelId,omitempty"` + + Scopes []string `json:"scopes,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index 1ba2cbe5..dc8e4e0a 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -353,6 +353,12 @@ error.e0351: "WhatsApp authorization needs a Meta App ID and App Secret. Set WHA 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.e0354: "Too many requests. Please wait a moment and try again." +error.channel.oauth.clientIdMissing: "No OAuth client identifier is configured for this channel. Set %s before connecting it." +error.slack.oauth.clientCredentialsMissing: "Slack installation needs an app client id and client secret. Set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET." +error.slack.oauth.exchangeFailed: "Slack installation failed: %s" +error.slack.oauth.authTestFailed: "The bot token Slack returned could not be verified: %s" +error.slack.oauth.scopeMissing: "The installation is missing the \"%s\" scope, so the bot cannot post replies." +error.slack.oauth.tokenTypeUnexpected: "Slack issued a \"%s\" token instead of a bot token. Reinstall the app and choose the workspace rather than yourself." 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 699be781..931eb49d 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -353,6 +353,12 @@ error.e0351: "WhatsApp 授权需要 Meta App ID 和 App Secret。请设置 WHATS error.e0352: "WhatsApp 授权失败:%s" error.e0353: "该渠道未配置 Meta App ID。请设置 %s,或在渠道中填写 app id。" error.e0354: "请求过于频繁,请稍后再试。" +error.channel.oauth.clientIdMissing: "该渠道未配置 OAuth client id。请先设置 %s 再进行连接。" +error.slack.oauth.clientCredentialsMissing: "Slack 安装需要应用的 client id 和 client secret。请设置 SLACK_CLIENT_ID 和 SLACK_CLIENT_SECRET。" +error.slack.oauth.exchangeFailed: "Slack 安装失败:%s" +error.slack.oauth.authTestFailed: "无法校验 Slack 返回的 Bot Token:%s" +error.slack.oauth.scopeMissing: "本次安装缺少 \"%s\" 权限,机器人将无法发送回复。" +error.slack.oauth.tokenTypeUnexpected: "Slack 返回的是 \"%s\" 令牌而不是 Bot Token。请重新安装应用,并选择工作区而非个人账号。" error.whatsapp.oauth.tokenInspectFailed: "无法校验 Access Token:%s" error.whatsapp.oauth.tokenInvalid: "Meta 返回该 Access Token 无效。" error.whatsapp.oauth.scopeMissing: "授权令牌缺少 \"%s\" 权限。" diff --git a/internal/services/slack_inbound_service.go b/internal/services/slack_inbound_service.go index afa07501..711d214c 100644 --- a/internal/services/slack_inbound_service.go +++ b/internal/services/slack_inbound_service.go @@ -7,9 +7,12 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strconv" "strings" + "time" "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/openidentity" @@ -73,9 +76,16 @@ func (s *slackInboundService) HandleWebhook(ctx context.Context, channelID strin return nil, errorsx.InvalidParam("slack channel config invalid") } - // Verify Slack Signing Secret if configured - if cfg.SigningSecret != "" && strings.TrimSpace(signatureHeader) != "" && strings.TrimSpace(timestampHeader) != "" { - if !verifySlackSignature(cfg.SigningSecret, timestampHeader, signatureHeader, rawPayload) { + // Verify the Slack signing secret whenever one resolves for this channel. A + // delivery with no signature headers is rejected rather than waved through: + // Slack always signs once a signing secret exists, so a missing header means + // the sender is not Slack. + slackCfg := config.ResolveSlack(cfg.BotToken, cfg.SigningSecret) + if slackCfg.SigningSecret != "" { + if strings.TrimSpace(signatureHeader) == "" || strings.TrimSpace(timestampHeader) == "" { + return nil, errorsx.UnauthorizedI18n("error.auth.invalidSignature") + } + if !verifySlackSignature(slackCfg.SigningSecret, timestampHeader, signatureHeader, rawPayload) { return nil, errorsx.UnauthorizedI18n("error.auth.invalidSignature") } } @@ -125,10 +135,26 @@ func (s *slackInboundService) HandleWebhook(ctx context.Context, channelID strin return nil, nil } +// slackTimestampTolerance is how far a request timestamp may drift from now. +// +// Slack's own verification guide requires rejecting anything older than five +// minutes. Without the check a captured request replays indefinitely: the +// signature covers the timestamp and the body, but nothing in it expires. +const slackTimestampTolerance = 5 * time.Minute + func verifySlackSignature(signingSecret, timestampHeader, signatureHeader string, payload []byte) bool { + timestampHeader = strings.TrimSpace(timestampHeader) + timestamp, err := strconv.ParseInt(timestampHeader, 10, 64) + if err != nil || timestamp <= 0 { + return false + } + if drift := time.Since(time.Unix(timestamp, 0)); drift > slackTimestampTolerance || drift < -slackTimestampTolerance { + return false + } + sigBasestring := fmt.Sprintf("v0:%s:%s", timestampHeader, string(payload)) mac := hmac.New(sha256.New, []byte(signingSecret)) mac.Write([]byte(sigBasestring)) expectedSig := "v0=" + hex.EncodeToString(mac.Sum(nil)) - return hmac.Equal([]byte(signatureHeader), []byte(expectedSig)) + return hmac.Equal([]byte(strings.TrimSpace(signatureHeader)), []byte(expectedSig)) } diff --git a/internal/services/slack_inbound_service_test.go b/internal/services/slack_inbound_service_test.go index 34a813b4..b46f890c 100644 --- a/internal/services/slack_inbound_service_test.go +++ b/internal/services/slack_inbound_service_test.go @@ -2,7 +2,11 @@ package services import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" + "strconv" "testing" "time" @@ -17,6 +21,24 @@ import ( "gorm.io/gorm/schema" ) +// slackTestSigningSecret is the signing secret the test channel is configured with. +const slackTestSigningSecret = "test_signing_secret_999" + +// signSlackPayload builds the X-Slack-Request-Timestamp and X-Slack-Signature +// headers Slack would send for this body right now. +func signSlackPayload(t *testing.T, secret string, payload []byte) (string, string) { + t.Helper() + return signSlackPayloadAt(t, secret, payload, time.Now()) +} + +func signSlackPayloadAt(t *testing.T, secret string, payload []byte, at time.Time) (string, string) { + t.Helper() + timestamp := strconv.FormatInt(at.Unix(), 10) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte("v0:" + timestamp + ":" + string(payload))) + return timestamp, "v0=" + hex.EncodeToString(mac.Sum(nil)) +} + func setupSlackTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ @@ -70,7 +92,7 @@ func TestSlackInboundAndOutbound(t *testing.T) { slackConfig := dto.SlackChannelConfig{ BotToken: "xoxb-test-bot-token-12345", - SigningSecret: "test_signing_secret_999", + SigningSecret: slackTestSigningSecret, TeamID: "T0123456789", TeamName: "Acme Corp", DefaultChannel: "C9876543210", @@ -107,7 +129,8 @@ func TestSlackInboundAndOutbound(t *testing.T) { }` ctx := context.Background() - _, err := SlackInboundService.HandleWebhook(ctx, "", "", "", []byte(payload)) + timestamp, signature := signSlackPayload(t, slackTestSigningSecret, []byte(payload)) + _, err := SlackInboundService.HandleWebhook(ctx, "", timestamp, signature, []byte(payload)) if err != nil { t.Fatalf("HandleWebhook failed: %v", err) } diff --git a/internal/services/slack_oauth_service.go b/internal/services/slack_oauth_service.go new file mode 100644 index 00000000..6aeef693 --- /dev/null +++ b/internal/services/slack_oauth_service.go @@ -0,0 +1,223 @@ +package services + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/slack" +) + +var SlackOAuthService = newSlackOAuthService() + +func newSlackOAuthService() *slackOAuthService { + return &slackOAuthService{} +} + +type slackOAuthService struct{} + +// slackExchangeOAuthCode is a variable so tests can point the exchange at a local +// stub instead of Slack. +var slackExchangeOAuthCode = slack.ExchangeOAuthCode + +// slackOAuthTimeout bounds the code exchange and the token check that follows it. +const slackOAuthTimeout = 30 * time.Second + +// slackRequiredScope is the one scope without which the integration cannot work: +// an app that cannot post cannot answer a customer. +const slackRequiredScope = "chat:write" + +// Connect exchanges a Slack installation code for the workspace's bot +// credentials and, when the operator is editing an existing channel, persists +// them onto it. +// +// Slack returns the bot token, the workspace id and name, and the preselected +// default channel in one response, so a successful connect fills every field the +// channel form needs and the operator does not have to copy anything out of the +// Slack admin UI. +func (s *slackOAuthService) Connect(req request.SlackOAuthCallbackRequest, locale string, operator *dto.AuthPrincipal) (*response.SlackOAuthConnectResponse, error) { + if operator == nil { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + code := strings.TrimSpace(req.Code) + if code == "" { + return nil, errorsx.InvalidParamI18n("error.param.required", "code") + } + + channel, cfg, err := s.loadTargetChannel(req.ChannelID) + if err != nil { + return nil, err + } + + // The Slack app is deployment-wide; only the resulting bot token belongs to a + // workspace, so the client credentials never come from the channel. + app := config.ResolveSlack("", "") + if app.ClientID == "" || app.ClientSecret == "" { + return nil, errorsx.InvalidParamI18n("error.slack.oauth.clientCredentialsMissing") + } + + ctx, cancel := context.WithTimeout(context.Background(), slackOAuthTimeout) + defer cancel() + + exchange, err := slackExchangeOAuthCode(ctx, slackOAuthBaseURL, app.ClientID, app.ClientSecret, code, req.RedirectURI) + if err != nil { + slog.Warn("slack oauth code exchange failed", "error", err) + return nil, errorsx.InvalidParamI18n("error.slack.oauth.exchangeFailed", err.Error()) + } + + result := &response.SlackOAuthConnectResponse{ + BotToken: strings.TrimSpace(exchange.AccessToken), + TokenMasked: maskChannelToken(exchange.AccessToken), + AppID: strings.TrimSpace(exchange.AppID), + BotUserID: strings.TrimSpace(exchange.BotUserID), + TeamID: strings.TrimSpace(exchange.Team.ID), + TeamName: strings.TrimSpace(exchange.Team.Name), + Scopes: splitSlackScopes(exchange.Scope), + Warnings: []string{}, + } + if exchange.IncomingWebhook != nil { + result.DefaultChannelID = strings.TrimSpace(exchange.IncomingWebhook.ChannelID) + } + if strings.TrimSpace(exchange.TokenType) != "" && !strings.EqualFold(strings.TrimSpace(exchange.TokenType), "bot") { + result.Warnings = append(result.Warnings, + i18nx.TLocale(locale, "error.slack.oauth.tokenTypeUnexpected", exchange.TokenType)) + } + if !containsSlackScope(result.Scopes, slackRequiredScope) { + result.Warnings = append(result.Warnings, + i18nx.TLocale(locale, "error.slack.oauth.scopeMissing", slackRequiredScope)) + } + + s.verifyToken(ctx, locale, result) + + if channel != nil { + if err := s.persist(channel, cfg, result, operator); err != nil { + return nil, err + } + } + return result, nil +} + +// slackOAuthBaseURL overrides the Slack API base for tests. Empty means the +// production endpoint. +var slackOAuthBaseURL = "" + +// verifyToken calls auth.test with the token Slack just issued. A token that +// cannot authenticate would otherwise be saved and fail later on the first reply, +// which is much harder to diagnose than an error at connect time. +func (s *slackOAuthService) verifyToken(ctx context.Context, locale string, result *response.SlackOAuthConnectResponse) { + client := slack.NewClient(result.BotToken) + if slackOAuthBaseURL != "" { + client.SetBaseURL(slackOAuthBaseURL) + } + auth, err := client.AuthTest(ctx) + if err != nil { + result.Warnings = append(result.Warnings, + i18nx.TLocale(locale, "error.slack.oauth.authTestFailed", err.Error())) + return + } + if result.TeamID == "" { + result.TeamID = strings.TrimSpace(auth.TeamID) + } + if result.TeamName == "" { + result.TeamName = strings.TrimSpace(auth.Team) + } + if result.AppID == "" { + result.AppID = strings.TrimSpace(auth.AppID) + } +} + +func (s *slackOAuthService) loadTargetChannel(channelID int64) (*models.Channel, *dto.SlackChannelConfig, error) { + cfg := &dto.SlackChannelConfig{} + 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.ChannelTypeSlack { + return nil, nil, errorsx.InvalidParamI18n("error.e0250") + } + parsed, err := ChannelService.ParseSlackChannelConfig(channel.ConfigJSON) + if err != nil { + return nil, nil, errorsx.InvalidParam("invalid slack configuration") + } + if parsed != nil { + cfg = parsed + } + return channel, cfg, nil +} + +// persist writes the installed workspace's credentials onto an existing Slack +// channel, preserving the signing secret and welcome message it already has. +func (s *slackOAuthService) persist(channel *models.Channel, cfg *dto.SlackChannelConfig, result *response.SlackOAuthConnectResponse, operator *dto.AuthPrincipal) error { + if cfg == nil { + cfg = &dto.SlackChannelConfig{} + } + + cfg.BotToken = strings.TrimSpace(result.BotToken) + if result.AppID != "" { + cfg.AppID = result.AppID + } + if result.TeamID != "" { + cfg.TeamID = result.TeamID + } + if result.TeamName != "" { + cfg.TeamName = result.TeamName + } + if result.DefaultChannelID != "" && strings.TrimSpace(cfg.DefaultChannel) == "" { + cfg.DefaultChannel = result.DefaultChannelID + } + + configBytes, err := json.Marshal(cfg) + if err != nil { + return err + } + if err := ChannelService.Updates(channel.ID, map[string]any{ + "config_json": string(configBytes), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }); err != nil { + return err + } + + result.Connected = true + result.ChannelID = channel.ID + slog.Info("slack workspace connected to channel", + "channel", channel.ID, + "team_id", cfg.TeamID, + "team_name", cfg.TeamName, + "operator", operator.Username, + ) + return nil +} + +func splitSlackScopes(scope string) []string { + scopes := make([]string, 0, 8) + for _, part := range strings.Split(strings.TrimSpace(scope), ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + scopes = append(scopes, trimmed) + } + } + return scopes +} + +func containsSlackScope(scopes []string, wanted string) bool { + for _, scope := range scopes { + if strings.EqualFold(strings.TrimSpace(scope), wanted) { + return true + } + } + return false +} diff --git a/internal/services/slack_oauth_service_test.go b/internal/services/slack_oauth_service_test.go new file mode 100644 index 00000000..65bc7a3e --- /dev/null +++ b/internal/services/slack_oauth_service_test.go @@ -0,0 +1,325 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/slack" +) + +// setSlackAppCredentials installs the deployment-wide Slack app credentials and +// restores whatever was configured before. +func setSlackAppCredentials(t *testing.T, clientID, clientSecret string) { + t.Helper() + previous := config.GetCurrent() + cfg := &config.Config{} + if previous != nil { + *cfg = *previous + } + cfg.Slack.ClientID = clientID + cfg.Slack.ClientSecret = clientSecret + config.SetCurrent(cfg) + t.Cleanup(func() { config.SetCurrent(previous) }) +} + +// stubSlackExchange replaces the code exchange with a canned result and returns a +// restore func. +func stubSlackExchange(t *testing.T, resp *slack.OAuthAccessResponse, err error) func() { + t.Helper() + previous := slackExchangeOAuthCode + slackExchangeOAuthCode = func(_ context.Context, _, _, _, _, _ string) (*slack.OAuthAccessResponse, error) { + return resp, err + } + return func() { slackExchangeOAuthCode = previous } +} + +// stubSlackAuthTest points the Slack client at a local stub for auth.test and +// returns a restore func. +func stubSlackAuthTest(t *testing.T, status int, body string) func() { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth.test" { + t.Errorf("unexpected slack path %q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + previous := slackOAuthBaseURL + slackOAuthBaseURL = server.URL + return func() { + slackOAuthBaseURL = previous + server.Close() + } +} + +func seedSlackOAuthChannel(t *testing.T, channelType string) int64 { + t.Helper() + db := setupSlackTestDB(t) + now := time.Now() + + agent := &models.AIAgent{ + Name: "Support AI", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(agent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + + cfgBytes, err := json.Marshal(dto.SlackChannelConfig{ + SigningSecret: "existing_signing_secret", + TeamID: "T_OLD", + }) + if err != nil { + t.Fatalf("marshal channel config: %v", err) + } + channel := &models.Channel{ + ChannelType: channelType, + ChannelID: "slack_oauth_channel", + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + Name: "Slack OAuth Target", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create channel: %v", err) + } + return channel.ID +} + +func successfulSlackExchange() *slack.OAuthAccessResponse { + resp := &slack.OAuthAccessResponse{ + OK: true, + AppID: "A01234567", + Scope: "chat:write,channels:history,im:history,im:read", + TokenType: "bot", + AccessToken: "xoxb-installed-workspace-token", + BotUserID: "U_BOT_001", + } + resp.Team.ID = "T_INSTALLED" + resp.Team.Name = "Installed Workspace" + resp.IncomingWebhook = &struct { + ChannelID string `json:"channel_id"` + Channel string `json:"channel"` + URL string `json:"url"` + }{ChannelID: "C_DEFAULT", Channel: "#support"} + return resp +} + +func TestSlackOAuthConnectPersistsWorkspace(t *testing.T) { + channelID := seedSlackOAuthChannel(t, enums.ChannelTypeSlack) + setSlackAppCredentials(t, "slack-client-id", "slack-client-secret") + defer stubSlackExchange(t, successfulSlackExchange(), nil)() + defer stubSlackAuthTest(t, http.StatusOK, `{"ok":true,"team":"Installed Workspace","team_id":"T_INSTALLED","app_id":"A01234567","bot_id":"B001","is_bot":true}`)() + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + result, err := SlackOAuthService.Connect(request.SlackOAuthCallbackRequest{ + Code: "slack-install-code", + ChannelID: channelID, + RedirectURI: "https://desk.example.com/dashboard/channels/oauth-callback", + }, "en-US", operator) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + + if !result.Connected { + t.Errorf("Connected = false, want true") + } + if result.BotToken != "xoxb-installed-workspace-token" { + t.Errorf("BotToken = %q", result.BotToken) + } + if result.TeamID != "T_INSTALLED" || result.TeamName != "Installed Workspace" { + t.Errorf("team = %q/%q, want the installed workspace", result.TeamID, result.TeamName) + } + if result.DefaultChannelID != "C_DEFAULT" { + t.Errorf("DefaultChannelID = %q, want C_DEFAULT", result.DefaultChannelID) + } + if strings.Contains(result.TokenMasked, "installed-workspace-token") { + t.Errorf("TokenMasked = %q leaks the token", result.TokenMasked) + } + if len(result.Warnings) != 0 { + t.Errorf("unexpected warnings: %v", result.Warnings) + } + + saved := ChannelService.Get(channelID) + cfg, err := ChannelService.ParseSlackChannelConfig(saved.ConfigJSON) + if err != nil { + t.Fatalf("parse saved config: %v", err) + } + if cfg.BotToken != "xoxb-installed-workspace-token" { + t.Errorf("saved bot token = %q", cfg.BotToken) + } + if cfg.TeamID != "T_INSTALLED" { + t.Errorf("saved team id = %q, want the installed workspace", cfg.TeamID) + } + // The signing secret was already set and has to survive, or inbound + // verification breaks the moment the operator connects a workspace. + if cfg.SigningSecret != "existing_signing_secret" { + t.Errorf("signing secret = %q, want it preserved", cfg.SigningSecret) + } + if saved.UpdateUserName != "joy" { + t.Errorf("update_user_name = %q, want the operator", saved.UpdateUserName) + } +} + +func TestSlackOAuthConnectRequiresAppCredentials(t *testing.T) { + channelID := seedSlackOAuthChannel(t, enums.ChannelTypeSlack) + setSlackAppCredentials(t, "", "") + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + _, err := SlackOAuthService.Connect(request.SlackOAuthCallbackRequest{ + Code: "slack-install-code", + ChannelID: channelID, + }, "en-US", operator) + if i18nErrorKey(t, err) != "error.slack.oauth.clientCredentialsMissing" { + t.Fatalf("error = %v, want error.slack.oauth.clientCredentialsMissing", err) + } + + saved := ChannelService.Get(channelID) + cfg, parseErr := ChannelService.ParseSlackChannelConfig(saved.ConfigJSON) + if parseErr != nil { + t.Fatalf("parse config: %v", parseErr) + } + if cfg.TeamID != "T_OLD" { + t.Errorf("the channel was modified: team id = %q", cfg.TeamID) + } +} + +func TestSlackOAuthConnectReportsSlackError(t *testing.T) { + channelID := seedSlackOAuthChannel(t, enums.ChannelTypeSlack) + setSlackAppCredentials(t, "slack-client-id", "slack-client-secret") + + // ExchangeOAuthCode turns Slack's ok:false envelope into an error, so that is + // what the stub has to reproduce. + defer stubSlackExchange(t, nil, errors.New("slack oauth error: invalid_grant"))() + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + _, err := SlackOAuthService.Connect(request.SlackOAuthCallbackRequest{ + Code: "slack-install-code", + ChannelID: channelID, + }, "en-US", operator) + if err == nil { + t.Fatalf("expected an error when Slack rejects the code") + } + if i18nErrorKey(t, err) != "error.slack.oauth.exchangeFailed" { + t.Errorf("error key = %q, want error.slack.oauth.exchangeFailed", i18nErrorKey(t, err)) + } + if !strings.Contains(err.Error(), "invalid_grant") { + t.Errorf("error = %v, want Slack's reason carried through", err) + } + + saved := ChannelService.Get(channelID) + cfg, parseErr := ChannelService.ParseSlackChannelConfig(saved.ConfigJSON) + if parseErr != nil { + t.Fatalf("parse config: %v", parseErr) + } + if cfg.BotToken != "" { + t.Errorf("a failed exchange wrote a bot token: %q", cfg.BotToken) + } +} + +// A missing chat:write scope means the bot can receive but never reply, and a +// user token instead of a bot token means the install was done against the wrong +// identity. Both are operator mistakes Slack does not treat as errors, so they +// have to be surfaced as warnings rather than silently saved. +func TestSlackOAuthConnectWarnsAboutUnusableInstallation(t *testing.T) { + channelID := seedSlackOAuthChannel(t, enums.ChannelTypeSlack) + setSlackAppCredentials(t, "slack-client-id", "slack-client-secret") + + cases := []struct { + name string + scope string + tokenType string + wantIn string + }{ + {"missing chat:write", "channels:history,im:history", "bot", "chat:write"}, + {"user token instead of bot", "chat:write", "user", "user"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := successfulSlackExchange() + resp.Scope = tc.scope + resp.TokenType = tc.tokenType + defer stubSlackExchange(t, resp, nil)() + defer stubSlackAuthTest(t, http.StatusOK, `{"ok":true,"team_id":"T_INSTALLED"}`)() + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + result, err := SlackOAuthService.Connect(request.SlackOAuthCallbackRequest{ + Code: "slack-install-code", + ChannelID: channelID, + }, "en-US", operator) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + // Connecting still succeeds: the token is real and the operator may + // intend to fix the scope afterwards. + if !result.Connected { + t.Errorf("Connected = false, want the credentials saved") + } + found := false + for _, warning := range result.Warnings { + if strings.Contains(warning, tc.wantIn) { + found = true + } + } + if !found { + t.Errorf("warnings = %v, want one mentioning %q", result.Warnings, tc.wantIn) + } + }) + } +} + +// auth.test failing must not discard a token Slack genuinely issued; it becomes a +// warning so the operator sees it without losing the installation. +func TestSlackOAuthConnectToleratesAuthTestFailure(t *testing.T) { + channelID := seedSlackOAuthChannel(t, enums.ChannelTypeSlack) + setSlackAppCredentials(t, "slack-client-id", "slack-client-secret") + defer stubSlackExchange(t, successfulSlackExchange(), nil)() + defer stubSlackAuthTest(t, http.StatusOK, `{"ok":false,"error":"account_inactive"}`)() + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + result, err := SlackOAuthService.Connect(request.SlackOAuthCallbackRequest{ + Code: "slack-install-code", + ChannelID: channelID, + }, "en-US", operator) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if !result.Connected { + t.Errorf("Connected = false, want the token saved despite the failed check") + } + if len(result.Warnings) == 0 { + t.Errorf("expected a warning explaining that auth.test failed") + } +} + +func TestSlackOAuthConnectRejectsNonSlackChannel(t *testing.T) { + channelID := seedSlackOAuthChannel(t, enums.ChannelTypeTelegram) + setSlackAppCredentials(t, "slack-client-id", "slack-client-secret") + defer stubSlackExchange(t, successfulSlackExchange(), nil)() + + operator := &dto.AuthPrincipal{UserID: 7, Username: "joy"} + _, err := SlackOAuthService.Connect(request.SlackOAuthCallbackRequest{ + Code: "slack-install-code", + ChannelID: channelID, + }, "en-US", operator) + if i18nErrorKey(t, err) != "error.e0250" { + t.Fatalf("error = %v, want error.e0250", err) + } +} diff --git a/internal/services/slack_outbound_service.go b/internal/services/slack_outbound_service.go index 68951df3..eed7a921 100644 --- a/internal/services/slack_outbound_service.go +++ b/internal/services/slack_outbound_service.go @@ -4,10 +4,10 @@ import ( "context" "encoding/json" "log/slog" - "strings" "time" "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" "agent-desk/internal/services/storage" @@ -92,7 +92,13 @@ func (s *slackOutboundService) processOutbox(outboxID int64) error { return s.markOutboxFailed(outbox, "slack channel not found or disabled") } cfg, err := ChannelService.ParseSlackChannelConfig(channel.ConfigJSON) - if err != nil || cfg == nil || strings.TrimSpace(cfg.BotToken) == "" { + if err != nil || cfg == nil { + return s.markOutboxFailed(outbox, "invalid slack channel config") + } + // The channel's own bot token wins; the deployment-wide SLACK_BOT_TOKEN is the + // fallback for a single shared Slack app. + botToken := config.ResolveSlack(cfg.BotToken, cfg.SigningSecret).BotToken + if botToken == "" { return s.markOutboxFailed(outbox, "slack bot token not configured") } @@ -123,7 +129,7 @@ func (s *slackOutboundService) processOutbox(outboxID int64) error { return s.markOutboxFailed(outbox, "unable to resolve target slack channel") } - client := slack.NewClient(cfg.BotToken) + client := slack.NewClient(botToken) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() diff --git a/internal/services/whatsapp_oauth_service.go b/internal/services/whatsapp_oauth_service.go index a9e4b790..53a0bc3a 100644 --- a/internal/services/whatsapp_oauth_service.go +++ b/internal/services/whatsapp_oauth_service.go @@ -85,7 +85,7 @@ func (s *whatsappOAuthService) Connect(req request.WhatsAppOAuthCallbackRequest, result := &response.WhatsAppOAuthConnectResponse{ AccessToken: token.AccessToken, - TokenMasked: maskWhatsAppToken(token.AccessToken), + TokenMasked: maskChannelToken(token.AccessToken), TokenType: strings.TrimSpace(token.TokenType), Accounts: []response.WhatsAppOAuthAccountResponse{}, } @@ -323,9 +323,9 @@ func (s *whatsappOAuthService) pickPhoneNumberID(req request.WhatsAppOAuthCallba return "" } -// maskWhatsAppToken keeps enough of a token to recognise it in a list without -// making the masked value usable. -func maskWhatsAppToken(token string) string { +// maskChannelToken keeps enough of a channel credential to recognise it in a list +// without making the masked value usable. +func maskChannelToken(token string) string { token = strings.TrimSpace(token) if len(token) <= 8 { return "********" diff --git a/internal/slack/client.go b/internal/slack/client.go index 4ed2fdc7..7d5e11a8 100644 --- a/internal/slack/client.go +++ b/internal/slack/client.go @@ -7,12 +7,17 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) const defaultBaseURL = "https://slack.com/api" +// defaultOAuthHTTPClient is separate from the per-client one because the code +// exchange runs before a bot token exists, so there is no Client to hang it on. +var defaultOAuthHTTPClient = &http.Client{Timeout: 20 * time.Second} + type Client struct { botToken string baseURL string @@ -59,6 +64,89 @@ func (c *Client) PostMessage(ctx context.Context, channel string, text string, t return &resp, nil } +// ExchangeOAuthCode swaps an installation code for the bot credentials of the +// workspace the app was just installed into. +// +// oauth.v2.access is form-encoded rather than JSON, and it answers HTTP 200 with +// ok:false for every application-level failure, so the envelope has to be read +// rather than the status code. redirectURI must be empty or byte-identical to the +// one that built the authorization URL; Slack rejects the exchange otherwise. +func ExchangeOAuthCode(ctx context.Context, baseURL, clientID, clientSecret, code, redirectURI string) (*OAuthAccessResponse, error) { + clientID = strings.TrimSpace(clientID) + clientSecret = strings.TrimSpace(clientSecret) + code = strings.TrimSpace(code) + if clientID == "" || clientSecret == "" { + return nil, fmt.Errorf("slack client id and client secret are required to exchange an oauth code") + } + if code == "" { + return nil, fmt.Errorf("slack oauth code is required") + } + + form := url.Values{} + form.Set("client_id", clientID) + form.Set("client_secret", clientSecret) + form.Set("code", code) + if redirectURI = strings.TrimSpace(redirectURI); redirectURI != "" { + form.Set("redirect_uri", redirectURI) + } + + endpoint := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if endpoint == "" { + endpoint = defaultBaseURL + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/oauth.v2.access", strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("create slack oauth request failed: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + + res, err := defaultOAuthHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("slack oauth request failed: %w", err) + } + defer res.Body.Close() + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("read slack oauth response failed: %w", err) + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("slack oauth http error (%d): %s", res.StatusCode, string(bodyBytes)) + } + + var resp OAuthAccessResponse + if err := json.Unmarshal(bodyBytes, &resp); err != nil { + return nil, fmt.Errorf("unmarshal slack oauth response failed: %w (body: %s)", err, string(bodyBytes)) + } + if !resp.OK { + return nil, fmt.Errorf("slack oauth error: %s", slackErrorOrUnknown(resp.Error)) + } + if strings.TrimSpace(resp.AccessToken) == "" { + return nil, fmt.Errorf("slack oauth exchange returned no access token") + } + return &resp, nil +} + +// AuthTest confirms a bot token is live and reports the workspace it belongs to. +func (c *Client) AuthTest(ctx context.Context) (*AuthTestResponse, error) { + var resp AuthTestResponse + if err := c.doRequest(ctx, "/auth.test", nil, &resp); err != nil { + return nil, err + } + if !resp.OK { + return nil, fmt.Errorf("slack auth.test error: %s", slackErrorOrUnknown(resp.Error)) + } + return &resp, nil +} + +func slackErrorOrUnknown(err string) string { + if err = strings.TrimSpace(err); err != "" { + return err + } + return "unknown_error" +} + func (c *Client) doRequest(ctx context.Context, path string, payload any, result any) error { if c.botToken == "" { return fmt.Errorf("slack bot token is required") diff --git a/internal/slack/client_test.go b/internal/slack/client_test.go new file mode 100644 index 00000000..a1e599ab --- /dev/null +++ b/internal/slack/client_test.go @@ -0,0 +1,236 @@ +package slack + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// Slack answers oauth.v2.access with HTTP 200 and ok:false for every +// application-level failure, so the client has to read the envelope. Returning a +// struct with an empty token instead of an error would let a rejected +// installation look like a successful one. +func TestExchangeOAuthCodeSurfacesSlackError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":false,"error":"invalid_grant"}`)) + })) + defer server.Close() + + _, err := ExchangeOAuthCode(context.Background(), server.URL, "client-id", "client-secret", "code-1", "") + if err == nil { + t.Fatalf("expected an error when Slack answers ok:false") + } + if got := err.Error(); got != "slack oauth error: invalid_grant" { + t.Errorf("error = %q, want Slack's reason carried through", got) + } +} + +func TestExchangeOAuthCodeSendsFormEncodedBody(t *testing.T) { + var ( + gotPath string + gotContentType string + gotForm url.Values + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotContentType = r.Header.Get("Content-Type") + _ = r.ParseForm() + gotForm = r.PostForm + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"access_token":"xoxb-1","token_type":"bot","team":{"id":"T1","name":"Team"}}`)) + })) + defer server.Close() + + resp, err := ExchangeOAuthCode(context.Background(), server.URL, "client-id", "client-secret", "code-1", "https://example.test/cb") + if err != nil { + t.Fatalf("ExchangeOAuthCode failed: %v", err) + } + if resp.AccessToken != "xoxb-1" || resp.Team.ID != "T1" { + t.Fatalf("response = %+v", resp) + } + + if gotPath != "/oauth.v2.access" { + t.Errorf("path = %q, want /oauth.v2.access", gotPath) + } + if gotContentType != "application/x-www-form-urlencoded; charset=utf-8" { + t.Errorf("content type = %q, want form-encoded", gotContentType) + } + for key, want := range map[string]string{ + "client_id": "client-id", + "client_secret": "client-secret", + "code": "code-1", + "redirect_uri": "https://example.test/cb", + } { + if got := gotForm.Get(key); got != want { + t.Errorf("form %s = %q, want %q", key, got, want) + } + } +} + +// redirect_uri is omitted when blank: Slack rejects an exchange whose redirect_uri +// does not match the authorization request byte for byte, and the Embedded +// Signup-style flows do not always carry one. +func TestExchangeOAuthCodeOmitsBlankRedirectURI(t *testing.T) { + var gotRaw string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + gotRaw = string(raw) + _, _ = w.Write([]byte(`{"ok":true,"access_token":"xoxb-1"}`)) + })) + defer server.Close() + + if _, err := ExchangeOAuthCode(context.Background(), server.URL, "client-id", "client-secret", "code-1", " "); err != nil { + t.Fatalf("ExchangeOAuthCode failed: %v", err) + } + + form, err := url.ParseQuery(gotRaw) + if err != nil { + t.Fatalf("parse request body: %v", err) + } + if _, present := form["redirect_uri"]; present { + t.Errorf("request body carried redirect_uri for a blank value: %s", gotRaw) + } +} + +func TestExchangeOAuthCodeValidatesInputs(t *testing.T) { + if _, err := ExchangeOAuthCode(context.Background(), "", "", "client-secret", "code-1", ""); err == nil { + t.Errorf("expected an error without a client id") + } + if _, err := ExchangeOAuthCode(context.Background(), "", "client-id", "", "code-1", ""); err == nil { + t.Errorf("expected an error without a client secret") + } + if _, err := ExchangeOAuthCode(context.Background(), "", "client-id", "client-secret", " ", ""); err == nil { + t.Errorf("expected an error without a code") + } +} + +func TestAuthTestSurfacesSlackError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":false,"error":"account_inactive"}`)) + })) + defer server.Close() + + client := NewClient("xoxb-revoked") + client.SetBaseURL(server.URL) + + if _, err := client.AuthTest(context.Background()); err == nil { + t.Fatalf("expected an error when the token is no longer active") + } +} + +func TestPostMessageSendsThreadedReply(t *testing.T) { + var ( + gotPath string + gotAuth string + gotBody map[string]any + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"channel":"C0123","ts":"1725260000.000100"}`)) + })) + defer server.Close() + + client := NewClient("xoxb-test-token") + client.SetBaseURL(server.URL) + + resp, err := client.PostMessage(context.Background(), "C0123", "hello there", "1725260000.000200") + if err != nil { + t.Fatalf("PostMessage failed: %v", err) + } + if resp == nil || resp.TS != "1725260000.000100" { + t.Fatalf("response = %+v, want the posted message ts", resp) + } + + if gotPath != "/chat.postMessage" { + t.Errorf("path = %q, want /chat.postMessage", gotPath) + } + if gotAuth != "Bearer xoxb-test-token" { + t.Errorf("authorization = %q, want the bot token as a bearer", gotAuth) + } + if gotBody["channel"] != "C0123" { + t.Errorf("channel = %v, want C0123", gotBody["channel"]) + } + if gotBody["text"] != "hello there" { + t.Errorf("text = %v, want 'hello there'", gotBody["text"]) + } + // A reply has to stay in the customer's thread, otherwise the workspace + // timeline fills up with support answers. + if gotBody["thread_ts"] != "1725260000.000200" { + t.Errorf("thread_ts = %v, want the parent ts", gotBody["thread_ts"]) + } +} + +func TestPostMessageOmitsThreadTSForTopLevel(t *testing.T) { + var gotRaw string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + gotRaw = string(raw) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + client := NewClient("xoxb-test-token") + client.SetBaseURL(server.URL) + + if _, err := client.PostMessage(context.Background(), "C0123", "top level", ""); err != nil { + t.Fatalf("PostMessage failed: %v", err) + } + // thread_ts is omitempty on the request struct, so a top-level post must not + // carry an empty one. + form, err := url.ParseQuery(gotRaw) + if err != nil { + t.Fatalf("parse request body: %v", err) + } + if _, present := form["thread_ts"]; present { + t.Errorf("request body carried thread_ts for a top-level message: %s", gotRaw) + } +} + +func TestPostMessageSurfacesSlackAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Slack returns HTTP 200 with ok:false for application-level failures, so + // the client has to read the envelope rather than the status code. + _, _ = w.Write([]byte(`{"ok":false,"error":"channel_not_found"}`)) + })) + defer server.Close() + + client := NewClient("xoxb-test-token") + client.SetBaseURL(server.URL) + + _, err := client.PostMessage(context.Background(), "C_MISSING", "hello", "") + if err == nil { + t.Fatalf("expected an error when Slack answers ok:false") + } + if got := err.Error(); got != "slack api error: channel_not_found" { + t.Errorf("error = %q, want the slack error code to be carried through", got) + } +} + +func TestPostMessageValidatesInputs(t *testing.T) { + client := NewClient("xoxb-test-token") + + if _, err := client.PostMessage(context.Background(), "", "hello", ""); err == nil { + t.Errorf("expected an error for an empty channel") + } + if _, err := client.PostMessage(context.Background(), "C0123", " ", ""); err == nil { + t.Errorf("expected an error for blank text") + } + + noToken := NewClient("") + if _, err := noToken.PostMessage(context.Background(), "C0123", "hello", ""); err == nil { + t.Errorf("expected an error when no bot token is configured") + } +} diff --git a/internal/slack/types.go b/internal/slack/types.go index 270e4e48..f4d8bb19 100644 --- a/internal/slack/types.go +++ b/internal/slack/types.go @@ -16,6 +16,51 @@ type SendMessageResponse struct { Error string `json:"error,omitempty"` } +// OAuthAccessResponse is the response of oauth.v2.access, the endpoint that +// exchanges an installation code for the credentials an app actually runs on. +// +// AccessToken is the bot token when TokenType is "bot", which is the normal case +// for a support app. IncomingWebhook is only populated when the installation +// included an incoming-webhook and picked a default channel. +type OAuthAccessResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + AppID string `json:"app_id,omitempty"` + Scope string `json:"scope,omitempty"` + TokenType string `json:"token_type,omitempty"` + AccessToken string `json:"access_token,omitempty"` + BotUserID string `json:"bot_user_id,omitempty"` + Team struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"team"` + Enterprise *struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"enterprise,omitempty"` + IncomingWebhook *struct { + ChannelID string `json:"channel_id"` + Channel string `json:"channel"` + URL string `json:"url"` + } `json:"incoming_webhook,omitempty"` +} + +// AuthTestResponse is the response of auth.test, used to confirm a token works +// and to read back the workspace it belongs to. +type AuthTestResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + URL string `json:"url,omitempty"` + Team string `json:"team,omitempty"` + TeamID string `json:"team_id,omitempty"` + User string `json:"user,omitempty"` + UserID string `json:"user_id,omitempty"` + BotID string `json:"bot_id,omitempty"` + IsBot bool `json:"is_bot,omitempty"` + AppID string `json:"app_id,omitempty"` + AppName string `json:"app_name,omitempty"` +} + // EventCallback represents incoming Slack Events API payload. type EventCallback struct { Token string `json:"token"` From 569bd01ce0e1e1540d4e61176fda526d30b4b22d Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:06:12 +0700 Subject: [PATCH 2/5] test(channels): sign the Slack webhook handler test and cover the unsigned rejection The Slack webhook handler test still posted unsigned events while the channel carries a signing secret; the tightened inbound gate (an unsigned delivery is rejected whenever a secret resolves for the channel) answers 200 ok=false and provisions nothing, which is exactly what CI saw. Sign the event payload like Slack does and add a negative case asserting an unsigned delivery creates no customer identity. --- .../third/whatsapp_slack_handler_test.go | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/internal/handlers/third/whatsapp_slack_handler_test.go b/internal/handlers/third/whatsapp_slack_handler_test.go index 8c4452f2..73dbb8ff 100644 --- a/internal/handlers/third/whatsapp_slack_handler_test.go +++ b/internal/handlers/third/whatsapp_slack_handler_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "testing" "time" @@ -32,6 +33,19 @@ func signWhatsAppTestPayload(payload []byte) string { return "sha256=" + hex.EncodeToString(mac.Sum(nil)) } +// slackTestSigningSecret is the Slack signing secret the test channel is +// configured with. +const slackTestSigningSecret = "test_signing_secret" + +// signSlackTestPayload builds the X-Slack-Request-Timestamp and +// X-Slack-Signature headers Slack would send for this body right now. +func signSlackTestPayload(payload []byte) (string, string) { + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + mac := hmac.New(sha256.New, []byte(slackTestSigningSecret)) + mac.Write([]byte("v0:" + timestamp + ":" + string(payload))) + return timestamp, "v0=" + hex.EncodeToString(mac.Sum(nil)) +} + func TestWhatsAppWebhook_Handler(t *testing.T) { gin.SetMode(gin.TestMode) db := setupThirdHandlerTestDB(t) @@ -175,7 +189,7 @@ func TestSlackWebhook_Handler(t *testing.T) { slackConfig, _ := json.Marshal(dto.SlackChannelConfig{ BotToken: "xoxb-test-token", - SigningSecret: "test_signing_secret", + SigningSecret: slackTestSigningSecret, TeamID: "T_SLACK_100", DefaultChannel: "C_GENERAL", }) @@ -232,6 +246,9 @@ func TestSlackWebhook_Handler(t *testing.T) { }`) reqEvent, _ := http.NewRequest(http.MethodPost, "/api/third/slack/webhook/"+channel.ChannelID, bytes.NewBuffer(eventPayload)) reqEvent.Header.Set("Content-Type", "application/json") + slackTimestamp, slackSignature := signSlackTestPayload(eventPayload) + reqEvent.Header.Set("X-Slack-Request-Timestamp", slackTimestamp) + reqEvent.Header.Set("X-Slack-Signature", slackSignature) recEvent := httptest.NewRecorder() router.ServeHTTP(recEvent, reqEvent) @@ -246,4 +263,36 @@ func TestSlackWebhook_Handler(t *testing.T) { if identity == nil { t.Fatalf("expected customer identity for U_USER_777") } + + // 3. Unsigned delivery is rejected: once a signing secret resolves for the + // channel, a payload without Slack signature headers must not provision + // anything. The handler still answers 200 ok=false so Slack does not retry. + unsignedPayload := []byte(`{ + "token": "token123", + "team_id": "T_SLACK_100", + "type": "event_callback", + "event": { + "type": "message", + "user": "U_USER_888", + "text": "Unsigned spoof attempt", + "ts": "1725260001.000100", + "channel": "C_GENERAL" + } + }`) + reqUnsigned, _ := http.NewRequest(http.MethodPost, "/api/third/slack/webhook/"+channel.ChannelID, bytes.NewBuffer(unsignedPayload)) + reqUnsigned.Header.Set("Content-Type", "application/json") + recUnsigned := httptest.NewRecorder() + router.ServeHTTP(recUnsigned, reqUnsigned) + + var unsignedResp map[string]any + _ = json.Unmarshal(recUnsigned.Body.Bytes(), &unsignedResp) + if unsignedResp["ok"] != false { + t.Fatalf("expected unsigned delivery to be rejected with ok=false, got: %+v", unsignedResp) + } + unsignedIdentity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd(). + Eq("external_source", enums.ExternalSourceSlack). + Eq("external_id", "U_USER_888")) + if unsignedIdentity != nil { + t.Fatalf("unsigned delivery must not provision an identity for U_USER_888") + } } From e06915bad5cf9c8e99f380fac3ee0cb53b936c95 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:34:00 +0700 Subject: [PATCH 3/5] fix(channels): address review findings on the Slack webhook hardening - lock the five-minute replay window down with a drift table test (fresh and four-minute-old signatures accepted; ten minutes old, one hour old, and ten minutes in the future rejected) plus malformed/empty timestamps - cover ResolveSlack and the channel-wins-over-deployment precedence with focused config tests - warn-log every rejected delivery with the channel_id and where the verifying secret came from (channel vs deployment fallback), so a channel bound to a second Slack app is diagnosable the moment the fallback secret appears - localize the slack config parse error (error.slack.configInvalid, zh-CN + en-US) instead of returning a raw English sentence - parse the chat.postMessage request body as JSON in the client test; url.ParseQuery on a JSON body could never see thread_ts, so the old assertion passed even if omitempty regressed --- internal/pkg/config/slack_config_test.go | 44 +++++++++++++++++++ internal/pkg/i18nx/locales/en-US.yml | 1 + internal/pkg/i18nx/locales/zh-CN.yml | 1 + internal/services/slack_inbound_service.go | 19 ++++++++ .../services/slack_inbound_service_test.go | 32 ++++++++++++++ internal/services/slack_oauth_service.go | 2 +- internal/slack/client_test.go | 11 ++--- 7 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 internal/pkg/config/slack_config_test.go diff --git a/internal/pkg/config/slack_config_test.go b/internal/pkg/config/slack_config_test.go new file mode 100644 index 00000000..79432184 --- /dev/null +++ b/internal/pkg/config/slack_config_test.go @@ -0,0 +1,44 @@ +package config + +import "testing" + +func TestSlackAppChannelValuesWinOverDeployment(t *testing.T) { + cfg := Config{Slack: SlackConfig{ + ClientID: "deploy-client-id", + ClientSecret: "deploy-client-secret", + BotToken: "xoxb-deploy", + SigningSecret: "deploy-signing-secret", + }} + + app := cfg.SlackApp("xoxb-channel", "channel-signing-secret") + if app.ClientID != "deploy-client-id" || app.ClientSecret != "deploy-client-secret" { + t.Fatalf("the app itself is deployment-wide, got %+v", app) + } + if app.BotToken != "xoxb-channel" { + t.Fatalf("channel bot token must win over the deployment one, got %q", app.BotToken) + } + if app.SigningSecret != "channel-signing-secret" { + t.Fatalf("channel signing secret must win over the deployment one, got %q", app.SigningSecret) + } + + fallback := cfg.SlackApp(" ", "") + if fallback.BotToken != "xoxb-deploy" || fallback.SigningSecret != "deploy-signing-secret" { + t.Fatalf("blank channel values must fall back to the deployment app, got %+v", fallback) + } +} + +// ResolveSlack must stay usable before Load has run (tests, standalone +// commands): the channel values pass through and nothing panics. +func TestResolveSlackWithoutLoadedConfig(t *testing.T) { + saved := GetCurrent() + defer SetCurrent(saved) + + SetCurrent(nil) + app := ResolveSlack("xoxb-channel", "channel-signing-secret") + if app.BotToken != "xoxb-channel" || app.SigningSecret != "channel-signing-secret" { + t.Fatalf("expected channel values to pass through, got %+v", app) + } + if app.ClientID != "" || app.ClientSecret != "" { + t.Fatalf("no deployment app exists before Load, got %+v", app) + } +} diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index 9990dd82..4a05cef4 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -360,6 +360,7 @@ error.slack.oauth.exchangeFailed: "Slack installation failed: %s" error.slack.oauth.authTestFailed: "The bot token Slack returned could not be verified: %s" error.slack.oauth.scopeMissing: "The installation is missing the \"%s\" scope, so the bot cannot post replies." error.slack.oauth.tokenTypeUnexpected: "Slack issued a \"%s\" token instead of a bot token. Reinstall the app and choose the workspace rather than yourself." +error.slack.configInvalid: "The saved Slack channel configuration could not be parsed." 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 d72f4de4..e7edea11 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -360,6 +360,7 @@ error.slack.oauth.exchangeFailed: "Slack 安装失败:%s" error.slack.oauth.authTestFailed: "无法校验 Slack 返回的 Bot Token:%s" error.slack.oauth.scopeMissing: "本次安装缺少 \"%s\" 权限,机器人将无法发送回复。" error.slack.oauth.tokenTypeUnexpected: "Slack 返回的是 \"%s\" 令牌而不是 Bot Token。请重新安装应用,并选择工作区而非个人账号。" +error.slack.configInvalid: "已保存的 Slack 渠道配置无法解析。" error.whatsapp.oauth.tokenInspectFailed: "无法校验 Access Token:%s" error.whatsapp.oauth.tokenInvalid: "Meta 返回该 Access Token 无效。" error.whatsapp.oauth.scopeMissing: "授权令牌缺少 \"%s\" 权限。" diff --git a/internal/services/slack_inbound_service.go b/internal/services/slack_inbound_service.go index 711d214c..f48fa18b 100644 --- a/internal/services/slack_inbound_service.go +++ b/internal/services/slack_inbound_service.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "log/slog" "strconv" "strings" "time" @@ -83,9 +84,13 @@ func (s *slackInboundService) HandleWebhook(ctx context.Context, channelID strin slackCfg := config.ResolveSlack(cfg.BotToken, cfg.SigningSecret) if slackCfg.SigningSecret != "" { if strings.TrimSpace(signatureHeader) == "" || strings.TrimSpace(timestampHeader) == "" { + slog.Warn("slack webhook rejected: missing signature headers", + "channel_id", channelID, "secret_source", slackSecretSource(cfg.SigningSecret, slackCfg)) return nil, errorsx.UnauthorizedI18n("error.auth.invalidSignature") } if !verifySlackSignature(slackCfg.SigningSecret, timestampHeader, signatureHeader, rawPayload) { + slog.Warn("slack webhook rejected: signature verification failed", + "channel_id", channelID, "secret_source", slackSecretSource(cfg.SigningSecret, slackCfg)) return nil, errorsx.UnauthorizedI18n("error.auth.invalidSignature") } } @@ -135,6 +140,20 @@ func (s *slackInboundService) HandleWebhook(ctx context.Context, channelID strin return nil, nil } +// slackSecretSource names where the verifying secret came from. A channel +// bound to a different Slack app than the deployment-wide one starts failing +// the moment the fallback secret appears, and this log field is the only way +// an operator can tell that apart from a spoofed delivery. +func slackSecretSource(channelSigningSecret string, resolved config.SlackConfig) string { + if strings.TrimSpace(channelSigningSecret) != "" { + return "channel" + } + if resolved.SigningSecret != "" { + return "deployment_fallback" + } + return "none" +} + // slackTimestampTolerance is how far a request timestamp may drift from now. // // Slack's own verification guide requires rejecting anything older than five diff --git a/internal/services/slack_inbound_service_test.go b/internal/services/slack_inbound_service_test.go index b46f890c..02973ecd 100644 --- a/internal/services/slack_inbound_service_test.go +++ b/internal/services/slack_inbound_service_test.go @@ -178,3 +178,35 @@ func TestSlackInboundAndOutbound(t *testing.T) { t.Fatalf("expected outbox channel type 'slack', got %s", outbox.ChannelType) } } + +// The signature covers the timestamp and the body, but nothing in it expires, +// so the five-minute drift window is the only replay protection a captured +// delivery faces. Lock the window down. +func TestVerifySlackSignatureEnforcesReplayWindow(t *testing.T) { + payload := []byte(`{"type":"event_callback","event":{"user":"U1","text":"hi"}}`) + now := time.Now() + cases := []struct { + name string + at time.Time + accept bool + }{ + {name: "fresh", at: now, accept: true}, + {name: "four minutes old", at: now.Add(-4 * time.Minute), accept: true}, + {name: "ten minutes old", at: now.Add(-10 * time.Minute)}, + {name: "one hour old", at: now.Add(-time.Hour)}, + {name: "ten minutes in the future", at: now.Add(10 * time.Minute)}, + } + for _, tc := range cases { + timestamp, signature := signSlackPayloadAt(t, slackTestSigningSecret, payload, tc.at) + if got := verifySlackSignature(slackTestSigningSecret, timestamp, signature, payload); got != tc.accept { + t.Fatalf("%s: verifySlackSignature = %v, want %v", tc.name, got, tc.accept) + } + } + + if verifySlackSignature(slackTestSigningSecret, "not-a-number", "v0=deadbeef", payload) { + t.Fatal("malformed timestamp must be rejected") + } + if verifySlackSignature(slackTestSigningSecret, "", "v0=deadbeef", payload) { + t.Fatal("empty timestamp must be rejected") + } +} diff --git a/internal/services/slack_oauth_service.go b/internal/services/slack_oauth_service.go index 6aeef693..8211ed21 100644 --- a/internal/services/slack_oauth_service.go +++ b/internal/services/slack_oauth_service.go @@ -150,7 +150,7 @@ func (s *slackOAuthService) loadTargetChannel(channelID int64) (*models.Channel, } parsed, err := ChannelService.ParseSlackChannelConfig(channel.ConfigJSON) if err != nil { - return nil, nil, errorsx.InvalidParam("invalid slack configuration") + return nil, nil, errorsx.InvalidParamI18n("error.slack.configInvalid") } if parsed != nil { cfg = parsed diff --git a/internal/slack/client_test.go b/internal/slack/client_test.go index a1e599ab..fc99620a 100644 --- a/internal/slack/client_test.go +++ b/internal/slack/client_test.go @@ -188,13 +188,14 @@ func TestPostMessageOmitsThreadTSForTopLevel(t *testing.T) { if _, err := client.PostMessage(context.Background(), "C0123", "top level", ""); err != nil { t.Fatalf("PostMessage failed: %v", err) } - // thread_ts is omitempty on the request struct, so a top-level post must not - // carry an empty one. - form, err := url.ParseQuery(gotRaw) - if err != nil { + // thread_ts is omitempty on the request struct, so a top-level post must + // not carry an empty one. The body is JSON, so it has to be unmarshalled + // rather than form-parsed. + var body map[string]any + if err := json.Unmarshal([]byte(gotRaw), &body); err != nil { t.Fatalf("parse request body: %v", err) } - if _, present := form["thread_ts"]; present { + if _, present := body["thread_ts"]; present { t.Errorf("request body carried thread_ts for a top-level message: %s", gotRaw) } } From ff355d41a1aa9ad1f107f4c199d75979fe27182c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:34:00 +0700 Subject: [PATCH 4/5] feat(channels): finish the Slack OAuth operator flow with a popup callback page The slack_oauth_url endpoint existed but no page consumed the installation code: the button opened the authorize URL with redirect_uri pointing at the channel list, which ignores ?code=, so the credentials were lost. Mirror the WhatsApp flow: - /dashboard/channels/slack-callback exchanges the code once (StrictMode safe), reports the workspace, warnings, and saved vs form-filled state, and hands the bot credentials back over postMessage with a pinned origin - the channel form's Slack section opens that flow in a popup (state carries crove_slack_connect:), tracks the connecting state, releases it when the popup closes, and fills bot token / team id / team name / app id / default channel from the exchange result - connectSlackOAuth + fetchSlackOAuthURL in web/lib/api/admin.ts - 16 new i18n keys per locale (zh-CN, en-US, vi-VN) --- .../dashboard/channels/_components/edit.tsx | 114 ++++++++- .../channels/_components/slack-oauth.ts | 27 +++ .../channels/slack-callback/page.tsx | 226 ++++++++++++++++++ web/lib/api/admin.ts | 49 ++++ web/messages/en-US.json | 16 ++ web/messages/vi-VN.json | 16 ++ web/messages/zh-CN.json | 16 ++ 7 files changed, 458 insertions(+), 6 deletions(-) create mode 100644 web/app/(dashboard)/dashboard/channels/_components/slack-oauth.ts create mode 100644 web/app/(dashboard)/dashboard/channels/slack-callback/page.tsx diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index 96ac6a15..293c50a7 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -23,10 +23,12 @@ import { type AIAgent, type AdminChannel, type CreateAdminChannelPayload, + type SlackOAuthConnectResult, type WhatsAppOAuthConnectResult, type WxWorkKFAccount, fetchAIAgentsAll, fetchChannel, + fetchSlackOAuthURL, fetchWhatsAppOAuthURL, fetchWxWorkKFAccounts, rollbackChannelAIAgentRollout, @@ -40,6 +42,11 @@ import { isWhatsAppOAuthMessage, whatsAppWebhookPath, } from "./whatsapp-oauth" +import { + SLACK_OAUTH_CALLBACK_PATH, + SLACK_OAUTH_STATE_PREFIX, + isSlackOAuthMessage, +} from "./slack-oauth" type ChannelFormDialogProps = { open: boolean @@ -1155,6 +1162,8 @@ function ChannelFormBody({ const [currentStatus, setCurrentStatus] = useState(0) const [whatsAppConnecting, setWhatsAppConnecting] = useState(false) const whatsAppPopup = useRef(null) + const [slackConnecting, setSlackConnecting] = useState(false) + const slackPopup = useRef(null) const form = useForm< z.input, undefined, @@ -1407,6 +1416,60 @@ function ChannelFormBody({ return () => window.clearInterval(timer) }, [whatsAppConnecting]) + useEffect(() => { + function handleMessage(event: MessageEvent) { + if (event.origin !== window.location.origin) { + return + } + if (!isSlackOAuthMessage(event.data)) { + return + } + const payload: SlackOAuthConnectResult = event.data.payload + slackPopup.current = null + setSlackConnecting(false) + + if (payload.botToken) { + setValue("slackBotToken", payload.botToken, { shouldDirty: true }) + } + if (payload.teamId) { + setValue("slackTeamId", payload.teamId, { shouldDirty: true }) + } + if (payload.teamName) { + setValue("slackTeamName", payload.teamName, { shouldDirty: true }) + } + if (payload.appId) { + setValue("slackAppId", payload.appId, { shouldDirty: true }) + } + if (payload.defaultChannelId) { + setValue("slackDefaultChannel", payload.defaultChannelId, { + shouldDirty: true, + }) + } + toast.success(t("channel.slackFilledFromOAuth")) + for (const warning of payload.warnings ?? []) { + toast.error(warning) + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [setValue, t]) + + // Release the button if the operator closes the Slack window without + // finishing the installation. + useEffect(() => { + if (!slackConnecting) { + return + } + const timer = window.setInterval(() => { + if (slackPopup.current?.closed) { + slackPopup.current = null + setSlackConnecting(false) + } + }, 600) + return () => window.clearInterval(timer) + }, [slackConnecting]) + async function handleConnectWhatsApp() { if (whatsAppConnecting) { return @@ -1459,6 +1522,41 @@ function ChannelFormBody({ } } + async function handleConnectSlack() { + if (slackConnecting) { + return + } + setSlackConnecting(true) + try { + const redirectUri = window.location.origin + SLACK_OAUTH_CALLBACK_PATH + const state = itemId + ? `${SLACK_OAUTH_STATE_PREFIX}:${itemId}` + : SLACK_OAUTH_STATE_PREFIX + const { authUrl } = await fetchSlackOAuthURL(redirectUri, state) + // No noopener: the landing page needs window.opener to hand the bot + // credentials back to this form. + const popup = window.open( + authUrl, + "crove-slack-oauth", + "width=760,height=820,menubar=no,toolbar=no,location=yes" + ) + if (!popup) { + setSlackConnecting(false) + toast.error(t("channel.slackPopupBlocked")) + return + } + slackPopup.current = popup + } catch (error) { + slackPopup.current = null + setSlackConnecting(false) + toast.error( + t("channel.slackConnectFailed", { + error: error instanceof Error ? error.message : String(error), + }) + ) + } + } + async function handleResetUserTokenSecret() { if (!itemId) { return @@ -2092,13 +2190,17 @@ function ChannelFormBody({ type="button" variant="default" size="sm" - onClick={() => { - const redirectUri = window.location.origin + "/dashboard/channels" - window.open(`/api/dashboard/channel/slack_oauth_url?redirect_uri=${encodeURIComponent(redirectUri)}`, "_blank") - }} + disabled={slackConnecting} + onClick={() => void handleConnectSlack()} > - - {t("channel.connectSlackButton")} + {slackConnecting ? ( + + ) : ( + + )} + {slackConnecting + ? t("channel.slackConnecting") + : t("channel.connectSlackButton")}
diff --git a/web/app/(dashboard)/dashboard/channels/_components/slack-oauth.ts b/web/app/(dashboard)/dashboard/channels/_components/slack-oauth.ts new file mode 100644 index 00000000..28aa5384 --- /dev/null +++ b/web/app/(dashboard)/dashboard/channels/_components/slack-oauth.ts @@ -0,0 +1,27 @@ +import type { SlackOAuthConnectResult } from "@/lib/api/admin" + +// Contract between the Slack OAuth landing page and the channel form. The +// landing page runs in a popup opened by the form, so the exchanged bot +// credentials travel back over postMessage with the origin pinned to this app. +export const SLACK_OAUTH_MESSAGE = "crove:slack-oauth" + +export const SLACK_OAUTH_CALLBACK_PATH = "/dashboard/channels/slack-callback" + +export const SLACK_OAUTH_STATE_PREFIX = "crove_slack_connect" + +export type SlackOAuthMessage = { + type: typeof SLACK_OAUTH_MESSAGE + payload: SlackOAuthConnectResult +} + +export function isSlackOAuthMessage(data: unknown): data is SlackOAuthMessage { + if (typeof data !== "object" || data === null) { + return false + } + const candidate = data as Partial + return ( + candidate.type === SLACK_OAUTH_MESSAGE && + typeof candidate.payload === "object" && + candidate.payload !== null + ) +} diff --git a/web/app/(dashboard)/dashboard/channels/slack-callback/page.tsx b/web/app/(dashboard)/dashboard/channels/slack-callback/page.tsx new file mode 100644 index 00000000..7d6c0aa4 --- /dev/null +++ b/web/app/(dashboard)/dashboard/channels/slack-callback/page.tsx @@ -0,0 +1,226 @@ +"use client" + +import { Suspense, useEffect, useRef, useState, useSyncExternalStore } from "react" +import Link from "next/link" +import { useSearchParams } from "next/navigation" +import { + AlertTriangleIcon, + CheckCircle2Icon, + Loader2Icon, + SlackIcon, +} from "lucide-react" + +import { Button, buttonVariants } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + connectSlackOAuth, + type SlackOAuthConnectResult, +} from "@/lib/api/admin" +import { cn } from "@/lib/utils" +import { useI18n } from "@/i18n/provider" +import { SLACK_OAUTH_MESSAGE } from "../_components/slack-oauth" + +type Exchange = { + status: "exchanging" | "success" | "error" + result: SlackOAuthConnectResult | null + failure: string +} + +const IDLE_EXCHANGE: Exchange = { status: "exchanging", result: null, failure: "" } + +function parseChannelId(state: string | null): number | undefined { + if (!state) { + return undefined + } + const separator = state.indexOf(":") + if (separator < 0) { + return undefined + } + const parsed = Number.parseInt(state.slice(separator + 1), 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined +} + +function subscribeNoop() { + return () => {} +} + +function getIsPopup() { + return window.opener !== null && window.opener !== window +} + +function getIsPopupOnServer() { + return false +} + +function SlackOAuthCallback() { + const searchParams = useSearchParams() + const t = useI18n() + const [exchange, setExchange] = useState(IDLE_EXCHANGE) + const started = useRef(false) + // window.opener only exists in a browser, and this page is statically + // exported, so the server snapshot has to differ from the client one. + const openedAsPopup = useSyncExternalStore( + subscribeNoop, + getIsPopup, + getIsPopupOnServer + ) + + // A rejection or a missing code is knowable from the URL alone, so it is + // derived during render instead of being written into state from an effect. + const oauthError = searchParams.get("error") + const code = searchParams.get("code") + const precheckFailure = oauthError + ? oauthError === "access_denied" + ? t("channel.slackCallbackDenied") + : searchParams.get("error_description") || oauthError + : code + ? "" + : t("channel.slackCallbackMissingCode") + + useEffect(() => { + if (precheckFailure) { + return + } + // StrictMode mounts effects twice and Slack accepts an installation code + // only once, so the exchange must run a single time per window. + if (started.current) { + return + } + started.current = true + + const oauthState = searchParams.get("state") + // This page's own URL without the query is exactly the redirect_uri that + // built the authorization link, and Slack requires the two to match. + const redirectUri = window.location.origin + window.location.pathname + + void (async () => { + try { + const result = await connectSlackOAuth({ + code: code as string, + state: oauthState ?? undefined, + channelId: parseChannelId(oauthState), + redirectUri, + }) + setExchange({ status: "success", result, failure: "" }) + window.opener?.postMessage( + { type: SLACK_OAUTH_MESSAGE, payload: result }, + window.location.origin + ) + } catch (error) { + setExchange({ + status: "error", + result: null, + failure: error instanceof Error ? error.message : String(error), + }) + } + })() + }, [code, precheckFailure, searchParams]) + + const status = precheckFailure ? "error" : exchange.status + const failure = precheckFailure || exchange.failure + const result = precheckFailure ? null : exchange.result + + return ( +
+ + + + {status === "success" ? ( + + ) : status === "error" ? ( + + ) : ( + + )} + {status === "success" + ? t("channel.slackCallbackSuccessTitle") + : status === "error" + ? t("channel.slackCallbackFailedTitle") + : t("channel.slackCallbackTitle")} + + + {status === "success" + ? result?.connected + ? t("channel.slackCallbackSuccessSaved") + : t("channel.slackCallbackSuccessForm") + : status === "error" + ? failure + : t("channel.slackCallbackExchanging")} + + + + + {status === "success" && result ? ( +
+
+ {t("channel.slackCallbackWorkspaceTitle")} +
+
    +
  • + + + {result.teamName || result.teamId} + + {result.teamId ? ( + + {result.teamId} + + ) : null} +
  • +
+
+ ) : null} + + {result?.warnings && result.warnings.length > 0 ? ( +
+
+ {t("channel.slackCallbackWarningsTitle")} +
+
    + {result.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ ) : null} + +
+ {openedAsPopup ? ( + + ) : null} + + {t("channel.slackCallbackBack")} + +
+
+
+
+ ) +} + +export default function SlackOAuthCallbackPage() { + return ( + + + + ) +} diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index f8bc9848..84629edc 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -280,6 +280,33 @@ export type ConnectWhatsAppOAuthPayload = { wabaId?: string } +export type SlackOAuthURLResult = { + authUrl: string + clientId: string + redirectUri: string +} + +export type SlackOAuthConnectResult = { + connected: boolean + channelId?: number + botToken: string + tokenMasked: string + appId?: string + botUserId?: string + teamId?: string + teamName?: string + defaultChannelId?: string + scopes?: string[] + warnings?: string[] +} + +export type ConnectSlackOAuthPayload = { + code: string + state?: string + channelId?: number + redirectUri?: string +} + export type AIAgent = { id: number name: string @@ -1071,6 +1098,28 @@ export function connectWhatsAppOAuth(payload: ConnectWhatsAppOAuthPayload) { ) } +// The Slack app is deployment-wide, so unlike the Meta flow there is no +// per-channel app to select and only the redirect target and state travel in +// the query. +export function fetchSlackOAuthURL(redirectUri: string, state?: string) { + return request( + `/api/dashboard/channel/slack_oauth_url${toQueryString({ + redirect_uri: redirectUri, + state, + })}` + ) +} + +export function connectSlackOAuth(payload: ConnectSlackOAuthPayload) { + return request( + "/api/dashboard/channel/slack_oauth_callback", + { + method: "POST", + body: JSON.stringify(payload), + } + ) +} + export function fetchAIAgents( query?: Record ) { diff --git a/web/messages/en-US.json b/web/messages/en-US.json index d1cf3a05..870569a6 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -730,6 +730,22 @@ "slackConnectTitle": "1-Click Slack App / Bot Connection", "slackConnectDescription": "Connect your company Slack workspace to Crove Desk. Channel mentions and direct messages will create tickets and trigger AI agent support.", "connectSlackButton": "Add to Slack", + "slackConnecting": "Opening Slack authorization…", + "slackConnectFailed": "Could not start Slack authorization: {error}", + "slackPopupBlocked": "Your browser blocked the Slack sign-in window. Allow pop-ups for this site and try again.", + "slackFilledFromOAuth": "Slack credentials were filled in from Slack. Review them and save the channel.", + "slackCallbackTitle": "Connecting Slack workspace", + "slackCallbackExchanging": "Exchanging the installation code for credentials…", + "slackCallbackSuccessTitle": "Slack connected", + "slackCallbackSuccessSaved": "The bot credentials were saved onto the channel.", + "slackCallbackSuccessForm": "The bot credentials were filled into the channel form. Review them and save.", + "slackCallbackFailedTitle": "Slack connection failed", + "slackCallbackDenied": "You denied Crove Desk authorization on Slack.", + "slackCallbackMissingCode": "Slack did not return an installation code.", + "slackCallbackWorkspaceTitle": "Connected workspace", + "slackCallbackWarningsTitle": "Warnings", + "slackCallbackClose": "Close", + "slackCallbackBack": "Back to channels", "slackTeamName": "Workspace Name", "slackDefaultChannel": "Default Channel ID (e.g. C0123456789)", "slackBotToken": "Bot User OAuth Token (xoxb-...)", diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json index 527baebe..f61f8b5b 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -731,6 +731,22 @@ "slackConnectTitle": "Kết nối Slack Workspace / Bot 1-Click", "slackConnectDescription": "Kết nối không gian làm việc Slack của công ty với Crove Desk. Tin nhắn nhắc tên bot hoặc DM sẽ tự động tạo Ticket và nhận phản hồi từ AI Agent.", "connectSlackButton": "Thêm vào Slack (Add to Slack)", + "slackConnecting": "Đang mở cửa sổ cấp quyền Slack…", + "slackConnectFailed": "Không thể bắt đầu cấp quyền Slack: {error}", + "slackPopupBlocked": "Trình duyệt đã chặn cửa sổ đăng nhập Slack. Hãy cho phép popup cho trang này rồi thử lại.", + "slackFilledFromOAuth": "Thông tin xác thực Slack đã được điền tự động. Hãy kiểm tra rồi lưu kênh.", + "slackCallbackTitle": "Đang kết nối Slack workspace", + "slackCallbackExchanging": "Đang đổi mã cài đặt lấy thông tin xác thực…", + "slackCallbackSuccessTitle": "Slack đã được kết nối", + "slackCallbackSuccessSaved": "Thông tin xác thực bot đã được lưu vào kênh.", + "slackCallbackSuccessForm": "Thông tin xác thực bot đã được điền vào form kênh. Hãy kiểm tra rồi lưu.", + "slackCallbackFailedTitle": "Kết nối Slack thất bại", + "slackCallbackDenied": "Bạn đã từ chối cấp quyền cho Crove Desk trên Slack.", + "slackCallbackMissingCode": "Slack không trả về mã cài đặt.", + "slackCallbackWorkspaceTitle": "Workspace đã kết nối", + "slackCallbackWarningsTitle": "Cảnh báo", + "slackCallbackClose": "Đóng", + "slackCallbackBack": "Về danh sách kênh", "slackTeamName": "Tên Workspace", "slackDefaultChannel": "Channel ID Mặc định (ví dụ C0123456789)", "slackBotToken": "Bot User OAuth Token (xoxb-...)", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index f5de5ce3..5caf3c1e 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -730,6 +730,22 @@ "slackConnectTitle": "Slack Workspace 一键授权连接", "slackConnectDescription": "将 Crove Desk 机器人应用添加至您的 Slack 工作区,频道提及与私聊消息将自动同步至工作台。", "connectSlackButton": "添加到 Slack (Add to Slack)", + "slackConnecting": "正在打开 Slack 授权窗口…", + "slackConnectFailed": "无法发起 Slack 授权:{error}", + "slackPopupBlocked": "浏览器拦截了 Slack 登录窗口。请允许本站弹出窗口后重试。", + "slackFilledFromOAuth": "Slack 凭证已从 Slack 自动填入,请检查后保存渠道。", + "slackCallbackTitle": "正在连接 Slack 工作区", + "slackCallbackExchanging": "正在交换安装代码以获取凭证…", + "slackCallbackSuccessTitle": "Slack 已连接", + "slackCallbackSuccessSaved": "机器人凭证已保存到渠道。", + "slackCallbackSuccessForm": "机器人凭证已填入渠道表单,请检查后保存。", + "slackCallbackFailedTitle": "Slack 连接失败", + "slackCallbackDenied": "你已在 Slack 上拒绝授权 Crove Desk。", + "slackCallbackMissingCode": "Slack 未返回安装代码。", + "slackCallbackWorkspaceTitle": "已连接的工作区", + "slackCallbackWarningsTitle": "警告", + "slackCallbackClose": "关闭", + "slackCallbackBack": "返回渠道列表", "slackTeamName": "工作区名称", "slackDefaultChannel": "默认转发频道 ID (如 C0123456789)", "slackBotToken": "Bot User OAuth Token (xoxb-...)", From bbaef96e1007de93219f3f7402aeba11fe34677b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:44:34 +0700 Subject: [PATCH 5/5] fix(channels): guard the Slack callback workspace row on a present team identity --- web/app/(dashboard)/dashboard/channels/slack-callback/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/(dashboard)/dashboard/channels/slack-callback/page.tsx b/web/app/(dashboard)/dashboard/channels/slack-callback/page.tsx index 7d6c0aa4..ecc32d02 100644 --- a/web/app/(dashboard)/dashboard/channels/slack-callback/page.tsx +++ b/web/app/(dashboard)/dashboard/channels/slack-callback/page.tsx @@ -156,7 +156,7 @@ function SlackOAuthCallback() { - {status === "success" && result ? ( + {status === "success" && (result?.teamName || result?.teamId) ? (
{t("channel.slackCallbackWorkspaceTitle")}