feat(channels): align email oauth authorization
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type chatwootEmailOAuthAuthorizationConfig struct {
|
||||
ClientIDEnv string
|
||||
ClientSecretEnv string
|
||||
AuthorizeURL string
|
||||
RedirectPath string
|
||||
Scope string
|
||||
ExtraParams map[string]string
|
||||
}
|
||||
|
||||
func buildChatwootEmailOAuthAuthorizationURL(accountID uint, cfg chatwootEmailOAuthAuthorizationConfig) (string, error) {
|
||||
clientID := strings.TrimSpace(os.Getenv(cfg.ClientIDEnv))
|
||||
clientSecret := strings.TrimSpace(os.Getenv(cfg.ClientSecretEnv))
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return "", fmt.Errorf("email OAuth is not configured")
|
||||
}
|
||||
|
||||
state, err := signedChatwootOAuthState(accountID, clientSecret)
|
||||
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+cfg.RedirectPath)
|
||||
params.Set("response_type", "code")
|
||||
params.Set("scope", cfg.Scope)
|
||||
params.Set("state", state)
|
||||
for key, value := range cfg.ExtraParams {
|
||||
params.Set(key, value)
|
||||
}
|
||||
|
||||
return cfg.AuthorizeURL + "?" + params.Encode(), nil
|
||||
}
|
||||
|
||||
func signedChatwootOAuthState(accountID uint, secret string) (string, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": accountID,
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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 setupEmailOAuthAuthorizationRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.RedirectTrailingSlash = false
|
||||
googleHandler := NewGoogleChannelHandler(nil, nil, nil, nil)
|
||||
microsoftHandler := NewMicrosoftChannelHandler(nil, nil, nil, nil)
|
||||
r.POST("/api/v1/accounts/:account_id/google/authorization", googleHandler.ChatwootAuthorization)
|
||||
r.POST("/api/v1/accounts/:account_id/microsoft/authorization", microsoftHandler.ChatwootAuthorization)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestGoogleAuthorization_ReturnsChatwootPayload(t *testing.T) {
|
||||
t.Setenv("GOOGLE_OAUTH_CLIENT_ID", "google-client")
|
||||
t.Setenv("GOOGLE_OAUTH_CLIENT_SECRET", "google-secret")
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test/")
|
||||
|
||||
r := setupEmailOAuthAuthorizationRouter()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/google/authorization", strings.NewReader(`{"email":"agent@example.test"}`))
|
||||
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://accounts.google.com/o/oauth2/auth", parsed.Scheme+"://"+parsed.Host+parsed.Path)
|
||||
query := parsed.Query()
|
||||
assert.Equal(t, "google-client", query.Get("client_id"))
|
||||
assert.Equal(t, "https://app.example.test/google/callback", query.Get("redirect_uri"))
|
||||
assert.Equal(t, "email profile https://mail.google.com/", query.Get("scope"))
|
||||
assert.Equal(t, "code", query.Get("response_type"))
|
||||
assert.Equal(t, "consent", query.Get("prompt"))
|
||||
assert.Equal(t, "offline", query.Get("access_type"))
|
||||
assertSignedAccountState(t, query.Get("state"), "google-secret", 42)
|
||||
}
|
||||
|
||||
func TestMicrosoftAuthorization_ReturnsChatwootPayload(t *testing.T) {
|
||||
t.Setenv("AZURE_APP_ID", "azure-client")
|
||||
t.Setenv("AZURE_APP_SECRET", "azure-secret")
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
||||
|
||||
r := setupEmailOAuthAuthorizationRouter()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/microsoft/authorization", 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://login.microsoftonline.com/common/oauth2/v2.0/authorize", parsed.Scheme+"://"+parsed.Host+parsed.Path)
|
||||
query := parsed.Query()
|
||||
assert.Equal(t, "azure-client", query.Get("client_id"))
|
||||
assert.Equal(t, "https://app.example.test/microsoft/callback", query.Get("redirect_uri"))
|
||||
assert.Equal(t, "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email", query.Get("scope"))
|
||||
assert.Equal(t, "code", query.Get("response_type"))
|
||||
assert.Empty(t, query.Get("prompt"))
|
||||
assertSignedAccountState(t, query.Get("state"), "azure-secret", 42)
|
||||
}
|
||||
|
||||
func TestEmailOAuthAuthorization_BadAccountID(t *testing.T) {
|
||||
r := setupEmailOAuthAuthorizationRouter()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/abc/google/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 TestEmailOAuthAuthorization_NotConfigured(t *testing.T) {
|
||||
t.Setenv("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||
t.Setenv("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||
r := setupEmailOAuthAuthorizationRouter()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/42/google/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 assertSignedAccountState(t *testing.T, state string, secret string, accountID float64) {
|
||||
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"])
|
||||
}
|
||||
@@ -21,6 +21,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"
|
||||
)
|
||||
|
||||
// GoogleChannelHandler handles Google Chat channel management.
|
||||
@@ -80,6 +81,34 @@ func (h *GoogleChannelHandler) Authorization(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"authorization_url": authURL})
|
||||
}
|
||||
|
||||
// ChatwootAuthorization creates a Google email OAuth authorization URL.
|
||||
// POST /api/v1/accounts/:account_id/google/authorization
|
||||
func (h *GoogleChannelHandler) 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 := buildChatwootEmailOAuthAuthorizationURL(accountID, chatwootEmailOAuthAuthorizationConfig{
|
||||
ClientIDEnv: "GOOGLE_OAUTH_CLIENT_ID",
|
||||
ClientSecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET",
|
||||
AuthorizeURL: "https://accounts.google.com/o/oauth2/auth",
|
||||
RedirectPath: "/google/callback",
|
||||
Scope: "email profile https://mail.google.com/",
|
||||
ExtraParams: map[string]string{
|
||||
"prompt": "consent",
|
||||
"access_type": "offline",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to build Google authorization URL: %v", err)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL})
|
||||
}
|
||||
|
||||
// === OAuth Callback ===
|
||||
|
||||
// GoogleOAuthCallbackRequest is the DTO for Google OAuth callback.
|
||||
|
||||
@@ -22,6 +22,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"
|
||||
)
|
||||
|
||||
// MicrosoftChannelHandler handles Microsoft/Azure AD channel management.
|
||||
@@ -81,6 +82,30 @@ func (h *MicrosoftChannelHandler) Authorization(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"authorization_url": authURL})
|
||||
}
|
||||
|
||||
// ChatwootAuthorization creates a Microsoft email OAuth authorization URL.
|
||||
// POST /api/v1/accounts/:account_id/microsoft/authorization
|
||||
func (h *MicrosoftChannelHandler) 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 := buildChatwootEmailOAuthAuthorizationURL(accountID, chatwootEmailOAuthAuthorizationConfig{
|
||||
ClientIDEnv: "AZURE_APP_ID",
|
||||
ClientSecretEnv: "AZURE_APP_SECRET",
|
||||
AuthorizeURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||
RedirectPath: "/microsoft/callback",
|
||||
Scope: "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email",
|
||||
})
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Failed to build Microsoft authorization URL: %v", err)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL})
|
||||
}
|
||||
|
||||
// === OAuth Callback ===
|
||||
|
||||
// MicrosoftOAuthCallbackRequest is the DTO for Microsoft OAuth callback.
|
||||
|
||||
@@ -1582,6 +1582,8 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
// Notion OAuth authorization (ref: Chatwoot namespace :notion resource :authorization)
|
||||
accountScoped.POST("/notion/authorization", middleware.RoleCheck("administrator"), h.NotionIntegration.Authorization)
|
||||
accountScoped.POST("/twitter/authorization", middleware.RoleCheck("administrator"), h.TwitterChannel.ChatwootAuthorization)
|
||||
accountScoped.POST("/microsoft/authorization", middleware.RoleCheck("administrator"), h.MicrosoftChannel.ChatwootAuthorization)
|
||||
accountScoped.POST("/google/authorization", middleware.RoleCheck("administrator"), h.GoogleChannel.ChatwootAuthorization)
|
||||
|
||||
// G16: Third-party Integrations — IntegrationHook CRUD + Slack/Shopify/Linear/Notion
|
||||
// Reference: Chatwoot namespace :integrations under :account
|
||||
|
||||
Reference in New Issue
Block a user