feat(channels): align social authorization

This commit is contained in:
2026-06-06 19:06:52 +08:00
parent 7c0a67a850
commit a5a28ce73e
10 changed files with 258 additions and 10 deletions
@@ -46,9 +46,17 @@ func buildChatwootEmailOAuthAuthorizationURL(accountID uint, cfg chatwootEmailOA
}
func signedChatwootOAuthState(accountID uint, secret string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
return signedChatwootOAuthStateWithReturnTo(accountID, secret, "")
}
func signedChatwootOAuthStateWithReturnTo(accountID uint, secret string, returnTo string) (string, error) {
claims := jwt.MapClaims{
"sub": accountID,
"iat": time.Now().Unix(),
})
}
if strings.TrimSpace(returnTo) != "" {
claims["return_to"] = strings.TrimSpace(returnTo)
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
@@ -31,6 +31,7 @@ import (
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/response"
)
// InstagramChannelHandler handles Instagram DM channel management.
@@ -103,6 +104,24 @@ func (h *InstagramChannelHandler) Authorization(c *gin.Context) {
})
}
// ChatwootAuthorization creates an Instagram OAuth authorization URL.
// POST /api/v1/accounts/:account_id/instagram/authorization
func (h *InstagramChannelHandler) ChatwootAuthorization(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
redirectURL, err := buildInstagramChatwootAuthorizationURL(accountID, authorizationReturnTo(c))
if err != nil {
applogger.L().Errorf("Failed to build Instagram authorization URL: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL})
}
// InstagramOAuthCallbackRequest is the DTO for the OAuth callback.
type InstagramOAuthCallbackRequest struct {
Code string `json:"code" validate:"required"`
@@ -0,0 +1,70 @@
package v1
import (
"fmt"
"net/url"
"os"
"strings"
"github.com/gin-gonic/gin"
)
const instagramAuthorizationScope = "instagram_business_basic,instagram_business_manage_messages"
const tiktokAuthorizationScope = "user.info.basic,user.info.username,user.info.stats,user.info.profile,user.account.type,user.insights,message.list.read,message.list.send,message.list.manage"
func authorizationReturnTo(c *gin.Context) string {
if value := strings.TrimSpace(c.Query("return_to")); value != "" {
return value
}
var payload struct {
ReturnTo string `json:"return_to" form:"return_to"`
}
_ = c.ShouldBind(&payload)
return strings.TrimSpace(payload.ReturnTo)
}
func buildInstagramChatwootAuthorizationURL(accountID uint, returnTo string) (string, error) {
clientID := strings.TrimSpace(os.Getenv("INSTAGRAM_APP_ID"))
clientSecret := strings.TrimSpace(os.Getenv("INSTAGRAM_APP_SECRET"))
if clientID == "" || clientSecret == "" {
return "", fmt.Errorf("Instagram OAuth is not configured")
}
state, err := signedChatwootOAuthStateWithReturnTo(accountID, clientSecret, returnTo)
if err != nil {
return "", err
}
frontendURL := strings.TrimRight(envOrDefaultV1("FRONTEND_URL", "http://localhost:3000"), "/")
params := url.Values{}
params.Set("client_id", clientID)
params.Set("redirect_uri", frontendURL+"/instagram/callback")
params.Set("scope", instagramAuthorizationScope)
params.Set("enable_fb_login", "0")
params.Set("force_authentication", "1")
params.Set("response_type", "code")
params.Set("state", state)
return "https://api.instagram.com/oauth/authorize?" + params.Encode(), nil
}
func buildTikTokChatwootAuthorizationURL(accountID uint, returnTo string) (string, error) {
clientID := strings.TrimSpace(os.Getenv("TIKTOK_APP_ID"))
clientSecret := strings.TrimSpace(os.Getenv("TIKTOK_APP_SECRET"))
if clientID == "" || clientSecret == "" {
return "", fmt.Errorf("TikTok OAuth is not configured")
}
state, err := signedChatwootOAuthStateWithReturnTo(accountID, clientSecret, returnTo)
if err != nil {
return "", err
}
frontendURL := strings.TrimRight(envOrDefaultV1("FRONTEND_URL", "http://localhost:3000"), "/")
params := url.Values{}
params.Set("client_id", clientID)
params.Set("client_key", clientID)
params.Set("redirect_uri", frontendURL+"/tiktok/callback")
params.Set("response_type", "code")
params.Set("scope", tiktokAuthorizationScope)
params.Set("state", state)
return "https://www.tiktok.com/v2/auth/authorize?" + params.Encode(), nil
}
@@ -0,0 +1,121 @@
package v1
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupSocialAuthorizationRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.RedirectTrailingSlash = false
instagramHandler := NewInstagramChannelHandler(nil, nil, nil, nil)
tiktokHandler := NewTikTokChannelHandler(nil, nil, nil, nil)
r.POST("/api/v1/accounts/:account_id/instagram/authorization", instagramHandler.ChatwootAuthorization)
r.POST("/api/v1/accounts/:account_id/tiktok/authorization", tiktokHandler.ChatwootAuthorization)
return r
}
func TestInstagramAuthorization_ReturnsChatwootPayload(t *testing.T) {
t.Setenv("INSTAGRAM_APP_ID", "instagram-client")
t.Setenv("INSTAGRAM_APP_SECRET", "instagram-secret")
t.Setenv("FRONTEND_URL", "https://app.example.test/")
r := setupSocialAuthorizationRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/instagram/authorization", strings.NewReader(`{"return_to":"onboarding"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, true, resp["success"])
parsed, err := url.Parse(resp["url"].(string))
require.NoError(t, err)
assert.Equal(t, "https://api.instagram.com/oauth/authorize", parsed.Scheme+"://"+parsed.Host+parsed.Path)
query := parsed.Query()
assert.Equal(t, "instagram-client", query.Get("client_id"))
assert.Equal(t, "https://app.example.test/instagram/callback", query.Get("redirect_uri"))
assert.Equal(t, "instagram_business_basic,instagram_business_manage_messages", query.Get("scope"))
assert.Equal(t, "0", query.Get("enable_fb_login"))
assert.Equal(t, "1", query.Get("force_authentication"))
assert.Equal(t, "code", query.Get("response_type"))
assertSocialSignedState(t, query.Get("state"), "instagram-secret", 42, "onboarding")
}
func TestTikTokAuthorization_ReturnsChatwootPayload(t *testing.T) {
t.Setenv("TIKTOK_APP_ID", "tiktok-client")
t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret")
t.Setenv("FRONTEND_URL", "https://app.example.test")
r := setupSocialAuthorizationRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/tiktok/authorization?return_to=onboarding", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, true, resp["success"])
parsed, err := url.Parse(resp["url"].(string))
require.NoError(t, err)
assert.Equal(t, "https://www.tiktok.com/v2/auth/authorize", parsed.Scheme+"://"+parsed.Host+parsed.Path)
query := parsed.Query()
assert.Equal(t, "tiktok-client", query.Get("client_id"))
assert.Equal(t, "tiktok-client", query.Get("client_key"))
assert.Equal(t, "https://app.example.test/tiktok/callback", query.Get("redirect_uri"))
assert.Equal(t, tiktokAuthorizationScope, query.Get("scope"))
assert.Equal(t, "code", query.Get("response_type"))
assertSocialSignedState(t, query.Get("state"), "tiktok-secret", 42, "onboarding")
}
func TestSocialAuthorization_BadAccountID(t *testing.T) {
r := setupSocialAuthorizationRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/abc/instagram/authorization", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
errBody := resp["error"].(map[string]interface{})
assert.Contains(t, errBody["message"], "invalid account_id")
}
func TestSocialAuthorization_NotConfigured(t *testing.T) {
t.Setenv("INSTAGRAM_APP_ID", "")
t.Setenv("INSTAGRAM_APP_SECRET", "")
r := setupSocialAuthorizationRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/instagram/authorization", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, false, resp["success"])
}
func assertSocialSignedState(t *testing.T, state string, secret string, accountID float64, returnTo string) {
t.Helper()
claims := jwt.MapClaims{}
token, err := jwt.ParseWithClaims(state, claims, func(token *jwt.Token) (any, error) {
return []byte(secret), nil
})
require.NoError(t, err)
require.True(t, token.Valid)
assert.Equal(t, accountID, claims["sub"])
assert.Equal(t, returnTo, claims["return_to"])
}
@@ -23,6 +23,7 @@ import (
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/response"
)
// TikTokChannelHandler handles TikTok Business channel management.
@@ -52,6 +53,24 @@ func NewTikTokChannelHandler(
// === TikTok Channel CRUD ===
// ChatwootAuthorization creates a TikTok OAuth authorization URL.
// POST /api/v1/accounts/:account_id/tiktok/authorization
func (h *TikTokChannelHandler) ChatwootAuthorization(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
redirectURL, err := buildTikTokChatwootAuthorizationURL(accountID, authorizationReturnTo(c))
if err != nil {
applogger.L().Errorf("Failed to build TikTok authorization URL: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL})
}
// CreateTikTokChannelRequest is the DTO for creating a TikTok Business channel inbox.
type CreateTikTokChannelRequest struct {
Name string `json:"name" validate:"required,min=2"`