feat(channels): align social authorization
This commit is contained in:
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user