feat(go-api): Migrate Box web OAuth connector APIs to Go (#16480)

This PR migrates the Box web OAuth flow from Python to Go for:

  - POST /api/v1/connectors/box/oauth/web/start
  - GET /api/v1/connectors/box/oauth/web/callback
  - POST /api/v1/connectors/box/oauth/web/result
This commit is contained in:
Hz_
2026-06-30 18:10:36 +08:00
committed by GitHub
parent 63bdf5c5b1
commit 3633d08495
6 changed files with 618 additions and 14 deletions

View File

@@ -42,6 +42,9 @@ type connectorServiceIface interface {
StartGoogleWebOAuth(userID, source string, req *service.StartGoogleWebOAuthRequest) (*service.StartGoogleWebOAuthResponse, common.ErrorCode, error)
GoogleWebOAuthCallback(source, stateID, oauthError, errorDescription, code string) string
PollGoogleWebOAuthResult(userID, source string, req *service.PollGoogleWebOAuthResultRequest) (*service.PollGoogleWebOAuthResultResponse, common.ErrorCode, error)
StartBoxWebOAuth(userID string, req *service.StartBoxWebOAuthRequest) (*service.StartBoxWebOAuthResponse, common.ErrorCode, error)
BoxWebOAuthCallback(flowID string, oauthError string, errorDescription string, code string) string
PollBoxWebOAuthResult(userID string, req *service.PollBoxWebOAuthResultRequest) (*service.PollBoxWebOAuthResultResponse, common.ErrorCode, error)
}
// ConnectorHandler connector handler
@@ -504,3 +507,52 @@ func (h *ConnectorHandler) googleWebOAuthCallback(c *gin.Context, source string)
)
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
}
func (h *ConnectorHandler) StartBoxWebOAuth(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
var req service.StartBoxWebOAuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonError(c, common.CodeBadRequest, err.Error())
return
}
resp, code, err := h.connectorService.StartBoxWebOAuth(user.ID, &req)
if err != nil {
jsonError(c, code, err.Error())
return
}
jsonResponse(c, code, resp, "success")
}
func (h *ConnectorHandler) BoxWebOAuthCallback(c *gin.Context) {
flowID := c.Query("state")
oauthError := c.Query("error")
errorDescription := c.Query("error_description")
code := c.Query("code")
html := h.connectorService.BoxWebOAuthCallback(flowID, oauthError, errorDescription, code)
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
}
func (h *ConnectorHandler) PollBoxWebOAuthResult(c *gin.Context) {
user, errorCode, errorMessage := GetUser(c)
if errorCode != common.CodeSuccess {
jsonError(c, errorCode, errorMessage)
return
}
var req service.PollBoxWebOAuthResultRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonError(c, common.CodeBadRequest, err.Error())
return
}
resp, code, err := h.connectorService.PollBoxWebOAuthResult(user.ID, &req)
if err != nil {
jsonError(c, code, err.Error())
return
}
jsonResponse(c, code, resp, "success")
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -21,6 +22,7 @@ type fakeConnectorService struct {
total int64
code common.ErrorCode
err error
html string
}
func (s fakeConnectorService) ListConnectors(string) (*service.ListConnectorsResponse, error) {
@@ -70,6 +72,31 @@ func (s fakeConnectorService) PollGoogleWebOAuthResult(string, string, *service.
return &service.PollGoogleWebOAuthResultResponse{}, common.CodeSuccess, nil
}
func (s fakeConnectorService) StartBoxWebOAuth(string, *service.StartBoxWebOAuthRequest) (*service.StartBoxWebOAuthResponse, common.ErrorCode, error) {
if s.err != nil {
return nil, s.code, s.err
}
return &service.StartBoxWebOAuthResponse{
FlowID: "flow-1",
AuthorizationURL: "https://account.box.com/api/oauth2/authorize?state=flow-1",
ExpiresIn: 900,
}, common.CodeSuccess, nil
}
func (s fakeConnectorService) BoxWebOAuthCallback(string, string, string, string) string {
if s.html != "" {
return s.html
}
return "<html>box</html>"
}
func (s fakeConnectorService) PollBoxWebOAuthResult(string, *service.PollBoxWebOAuthResultRequest) (*service.PollBoxWebOAuthResultResponse, common.ErrorCode, error) {
if s.err != nil {
return nil, s.code, s.err
}
return &service.PollBoxWebOAuthResultResponse{}, common.CodeSuccess, nil
}
func (s fakeConnectorService) ListLog(string, string, int, int) ([]*entity.ConnectorSyncLog, int64, common.ErrorCode, error) {
if s.err != nil {
return nil, 0, s.code, s.err
@@ -91,6 +118,99 @@ func (s fakeConnectorService) RebuildConnector(string, string, string) (bool, co
return true, common.CodeSuccess, nil
}
func TestConnectorHandlerStartBoxWebOAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &ConnectorHandler{connectorService: fakeConnectorService{}}
router := gin.New()
router.POST("/api/v1/connectors/box/oauth/web/start", func(c *gin.Context) {
c.Set("user", &entity.User{ID: "tenant-1"})
h.StartBoxWebOAuth(c)
})
resp := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/connectors/box/oauth/web/start",
strings.NewReader(`{"client_id":"client-1","client_secret":"secret-1"}`),
)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if body["code"] != float64(common.CodeSuccess) {
t.Fatalf("code=%v want=%v body=%v", body["code"], common.CodeSuccess, body)
}
data, ok := body["data"].(map[string]interface{})
if !ok {
t.Fatalf("data=%#v", body["data"])
}
if data["flow_id"] != "flow-1" {
t.Fatalf("flow_id=%v", data["flow_id"])
}
}
func TestConnectorHandlerBoxWebOAuthCallback(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &ConnectorHandler{connectorService: fakeConnectorService{html: "<html>box callback</html>"}}
router := gin.New()
router.GET("/api/v1/connectors/box/oauth/web/callback", h.BoxWebOAuthCallback)
resp := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/connectors/box/oauth/web/callback?state=flow-1&code=code-1", nil)
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
t.Fatalf("content-type=%q", got)
}
if resp.Body.String() != "<html>box callback</html>" {
t.Fatalf("body=%q", resp.Body.String())
}
}
func TestConnectorHandlerPollBoxWebOAuthResult(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &ConnectorHandler{connectorService: fakeConnectorService{}}
router := gin.New()
router.POST("/api/v1/connectors/box/oauth/web/result", func(c *gin.Context) {
c.Set("user", &entity.User{ID: "tenant-1"})
h.PollBoxWebOAuthResult(c)
})
resp := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/connectors/box/oauth/web/result",
strings.NewReader(`{"flow_id":"flow-1"}`),
)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if body["code"] != float64(common.CodeSuccess) {
t.Fatalf("code=%v want=%v body=%v", body["code"], common.CodeSuccess, body)
}
}
func TestConnectorHandlerTestConnector(t *testing.T) {
gin.SetMode(gin.TestMode)

View File

@@ -145,6 +145,7 @@ func (r *Router) Setup(engine *gin.Engine) {
// the RAGFlow auth middleware.
engine.GET("/connectors/gmail/oauth/web/callback", r.connectorHandler.GmailWebOAuthCallback)
engine.GET("/connectors/google-drive/oauth/web/callback", r.connectorHandler.GoogleDriveWebOAuthCallback)
engine.GET("/connectors/box/oauth/web/callback", r.connectorHandler.BoxWebOAuthCallback)
apiNoAuth := engine.Group("/api/v1")
{
@@ -175,6 +176,7 @@ func (r *Router) Setup(engine *gin.Engine) {
// Google redirects here after Gmail / Google Drive web OAuth completes.
apiNoAuth.GET("/connectors/gmail/oauth/web/callback", r.connectorHandler.GmailWebOAuthCallback)
apiNoAuth.GET("/connectors/google-drive/oauth/web/callback", r.connectorHandler.GoogleDriveWebOAuthCallback)
apiNoAuth.GET("/connectors/box/oauth/web/callback", r.connectorHandler.BoxWebOAuthCallback)
// Forgot-password flow (fixes #15282).
// Routes are intentionally registered before any auth middleware:
// a user who has forgotten their password is, by definition,
@@ -552,6 +554,8 @@ func (r *Router) Setup(engine *gin.Engine) {
connector.POST("/", r.connectorHandler.CreateConnector)
connector.POST("/google/oauth/web/start", r.connectorHandler.StartGoogleWebOAuth)
connector.POST("/google/oauth/web/result", r.connectorHandler.PollGoogleWebOAuthResult)
connector.POST("/box/oauth/web/start", r.connectorHandler.StartBoxWebOAuth)
connector.POST("/box/oauth/web/result", r.connectorHandler.PollBoxWebOAuthResult)
connector.GET("/:connector_id", r.connectorHandler.GetConnector)
connector.PATCH("/:connector_id", r.connectorHandler.UpdateConnector)
connector.GET("/:connector_id/logs", r.connectorHandler.ListLogs)

View File

@@ -23,6 +23,9 @@ func TestConnectorRoutesDoNotConflictWithOAuthCallbacks(t *testing.T) {
engine.GET("/api/v1/connectors/google-drive/oauth/web/callback", func(c *gin.Context) {
c.String(http.StatusOK, "google-drive")
})
engine.GET("/api/v1/connectors/box/oauth/web/callback", func(c *gin.Context) {
c.String(http.StatusOK, "box")
})
connectors := engine.Group("/api/v1/connectors")
connectors.GET("/:connector_id", func(c *gin.Context) {
@@ -47,6 +50,11 @@ func TestConnectorRoutesDoNotConflictWithOAuthCallbacks(t *testing.T) {
path: "/api/v1/connectors/google-drive/oauth/web/callback",
want: "google-drive",
},
{
name: "box callback",
path: "/api/v1/connectors/box/oauth/web/callback",
want: "box",
},
{
name: "connector detail",
path: "/api/v1/connectors/connector-1",

View File

@@ -49,6 +49,8 @@ const (
webFlowTTL = 15 * time.Minute
googleOAuthAuthorizeURL = "https://accounts.google.com/o/oauth2/auth"
googleOAuthTokenURL = "https://oauth2.googleapis.com/token"
boxOAuthAuthorizeURL = "https://account.box.com/api/oauth2/authorize"
boxOAuthTokenURL = "https://api.box.com/oauth2/token"
googleOAuthHTTPTimeout = 7 * time.Second
)
@@ -64,6 +66,7 @@ var (
"https://www.googleapis.com/auth/admin.directory.user.readonly",
"https://www.googleapis.com/auth/admin.directory.group.readonly",
}
connectorRedisGet = redis.Get
)
// Sentinel errors so handlers can map to the proper response codes,
@@ -165,6 +168,53 @@ type googleOAuthCredentials struct {
Expiry string `json:"expiry,omitempty"`
}
type boxWebOAuthState struct {
UserID string `json:"user_id"`
AuthURL string `json:"auth_url"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectURI string `json:"redirect_uri"`
CreatedAt int64 `json:"created_at"`
}
type boxWebOAuthCredentials struct {
UserID string `json:"user_id,omitempty"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type StartBoxWebOAuthRequest struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectURI string `json:"redirect_uri,omitempty"`
}
type StartBoxWebOAuthResponse struct {
FlowID string `json:"flow_id"`
AuthorizationURL string `json:"authorization_url"`
ExpiresIn int64 `json:"expires_in"`
}
type PollBoxWebOAuthResultRequest struct {
FlowID string `json:"flow_id"`
}
type PollBoxWebOAuthResultResponse struct {
Credentials boxWebOAuthCredentials `json:"credentials"`
}
type boxOAuthTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type,omitempty"`
ExpiresIn int64 `json:"expires_in,omitempty"`
RestrictedTo any `json:"restricted_to,omitempty"`
Error string `json:"error,omitempty"`
ErrorDesc string `json:"error_description,omitempty"`
}
// canAccessConnector Test Authentication
func (s *ConnectorService) canAccessConnector(connector *entity.Connector, userID string) bool {
if connector.TenantID == userID {
@@ -425,28 +475,28 @@ func (s *ConnectorService) StartGoogleWebOAuth(userID, source string, req *Start
func (s *ConnectorService) GoogleWebOAuthCallback(source, stateID, oauthError, errorDescription, code string) string {
source = strings.TrimSpace(source)
if source != "google-drive" && source != "gmail" {
return renderGoogleWebOAuthPopup("", false, "Invalid Google OAuth type.", source)
return renderWebOAuthPopup("", false, "Invalid Google OAuth type.", source)
}
stateID = strings.TrimSpace(stateID)
if stateID == "" {
return renderGoogleWebOAuthPopup("", false, "Missing OAuth state parameter.", source)
return renderWebOAuthPopup("", false, "Missing OAuth state parameter.", source)
}
redisClient := redis.Get()
if redisClient == nil {
return renderGoogleWebOAuthPopup(stateID, false, "Authorization session expired. Please restart from the main window.", source)
return renderWebOAuthPopup(stateID, false, "Authorization session expired. Please restart from the main window.", source)
}
stateKey := webStateCacheKey(stateID, source)
var state googleWebOAuthState
if ok := redisClient.GetObj(stateKey, &state); !ok {
return renderGoogleWebOAuthPopup(stateID, false, "Authorization session expired. Please restart from the main window.", source)
return renderWebOAuthPopup(stateID, false, "Authorization session expired. Please restart from the main window.", source)
}
if state.ClientConfig == nil {
redisClient.Delete(stateKey)
return renderGoogleWebOAuthPopup(stateID, false, "Authorization session was invalid. Please retry.", source)
return renderWebOAuthPopup(stateID, false, "Authorization session was invalid. Please retry.", source)
}
if strings.TrimSpace(oauthError) != "" {
@@ -458,18 +508,18 @@ func (s *ConnectorService) GoogleWebOAuthCallback(source, stateID, oauthError, e
if message == "" {
message = "Authorization was cancelled."
}
return renderGoogleWebOAuthPopup(stateID, false, message, source)
return renderWebOAuthPopup(stateID, false, message, source)
}
code = strings.TrimSpace(code)
if code == "" {
return renderGoogleWebOAuthPopup(stateID, false, "Missing authorization code from Google.", source)
return renderWebOAuthPopup(stateID, false, "Missing authorization code from Google.", source)
}
credentials, err := exchangeGoogleWebOAuthCode(state.ClientConfig, googleOAuthScopesForSource(source), state.RedirectURI, code, state.CodeVerifier)
if err != nil {
redisClient.Delete(stateKey)
return renderGoogleWebOAuthPopup(stateID, false, "Failed to exchange tokens with Google. Please retry.", source)
return renderWebOAuthPopup(stateID, false, "Failed to exchange tokens with Google. Please retry.", source)
}
result := googleWebOAuthResult{
@@ -478,11 +528,11 @@ func (s *ConnectorService) GoogleWebOAuthCallback(source, stateID, oauthError, e
}
if ok := redisClient.SetObj(webResultCacheKey(stateID, source), result, webFlowTTL); !ok {
redisClient.Delete(stateKey)
return renderGoogleWebOAuthPopup(stateID, false, "Failed to exchange tokens with Google. Please retry.", source)
return renderWebOAuthPopup(stateID, false, "Failed to exchange tokens with Google. Please retry.", source)
}
redisClient.Delete(stateKey)
return renderGoogleWebOAuthPopup(stateID, true, "Authorization completed successfully.", source)
return renderWebOAuthPopup(stateID, true, "Authorization completed successfully.", source)
}
func (s *ConnectorService) PollGoogleWebOAuthResult(userID, source string, req *PollGoogleWebOAuthResultRequest) (*PollGoogleWebOAuthResultResponse, common.ErrorCode, error) {
@@ -702,7 +752,7 @@ func exchangeGoogleWebOAuthCode(clientConfig map[string]interface{}, scopes []st
return string(data), nil
}
func renderGoogleWebOAuthPopup(flowID string, success bool, message, source string) string {
func renderWebOAuthPopup(flowID string, success bool, message, source string) string {
status := "error"
autoClose := ""
if success {
@@ -717,7 +767,7 @@ func renderGoogleWebOAuthPopup(flowID string, success bool, message, source stri
"message": message,
})
title := fmt.Sprintf("%s Authorization", googleOAuthSourceDisplayName(source))
title := fmt.Sprintf("%s Authorization", webOAuthSourceDisplayName(source))
heading := "Authorization failed"
if success {
heading = "Authorization complete"
@@ -776,14 +826,17 @@ func renderGoogleWebOAuthPopup(flowID string, success bool, message, source stri
</html>`, html.EscapeString(title), html.EscapeString(heading), html.EscapeString(message), string(payload), autoClose)
}
func googleOAuthSourceDisplayName(source string) string {
func webOAuthSourceDisplayName(source string) string {
if source == "gmail" {
return "Gmail"
}
if source == "google-drive" {
return "Google Drive"
}
return "Google"
if source == "box" {
return "Box"
}
return "OAuth"
}
func (s *ConnectorService) DeleteConnector(connectorID, userID string) (bool, common.ErrorCode, error) {
@@ -982,3 +1035,199 @@ func (s *ConnectorService) ListLog(connectorID, userID string, page, pageSize in
}
return logs, total, common.CodeSuccess, nil
}
func (s *ConnectorService) StartBoxWebOAuth(userID string, req *StartBoxWebOAuthRequest) (*StartBoxWebOAuthResponse, common.ErrorCode, error) {
var clientID, clientSecret, redirectURI string
if req != nil {
clientID = strings.TrimSpace(req.ClientID)
clientSecret = strings.TrimSpace(req.ClientSecret)
redirectURI = strings.TrimSpace(req.RedirectURI)
}
if clientID == "" || clientSecret == "" {
return nil, common.CodeArgumentError, fmt.Errorf("Box client_id and client_secret are required.")
}
if redirectURI == "" {
redirectURI = defaultBoxWebOAuthRedirectURI()
}
flowID := common.GenerateUUID()
authorizationURL, err := buildBoxAuthorizationURL(clientID, redirectURI, flowID)
if err != nil {
return nil, common.CodeServerError, err
}
redisClient := connectorRedisGet()
if redisClient == nil {
return nil, common.CodeServerError, fmt.Errorf("Redis is not configured on the server.")
}
state := boxWebOAuthState{
UserID: userID,
AuthURL: authorizationURL,
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURI: redirectURI,
CreatedAt: time.Now().Unix(),
}
if ok := redisClient.SetObj(webStateCacheKey(flowID, "box"), state, webFlowTTL); !ok {
return nil, common.CodeServerError, fmt.Errorf("Failed to initialize Box OAuth flow. Please verify the client configuration.")
}
return &StartBoxWebOAuthResponse{
FlowID: flowID,
AuthorizationURL: authorizationURL,
ExpiresIn: int64(webFlowTTL.Seconds()),
}, common.CodeSuccess, nil
}
func (s *ConnectorService) BoxWebOAuthCallback(flowID string, oauthError string, errorDescription string, code string) string {
flowID = strings.TrimSpace(flowID)
if flowID == "" {
return renderWebOAuthPopup("", false, "Missing OAuth parameters.", "box")
}
redisClient := connectorRedisGet()
if redisClient == nil {
return renderWebOAuthPopup(flowID, false, "Box OAuth session expired or invalid.", "box")
}
stateKey := webStateCacheKey(flowID, "box")
var state boxWebOAuthState
if ok := redisClient.GetObj(stateKey, &state); !ok {
return renderWebOAuthPopup(flowID, false, "Box OAuth session expired or invalid.", "box")
}
if strings.TrimSpace(oauthError) != "" {
redisClient.Delete(stateKey)
message := strings.TrimSpace(errorDescription)
if message == "" {
message = strings.TrimSpace(oauthError)
}
if message == "" {
message = "Authorization failed."
}
return renderWebOAuthPopup(flowID, false, message, "box")
}
code = strings.TrimSpace(code)
if code == "" {
return renderWebOAuthPopup(flowID, false, "Missing authorization code from Box.", "box")
}
token, err := exchangeBoxAuthorizationCode(state.ClientID, state.ClientSecret, state.RedirectURI, code)
if err != nil {
redisClient.Delete(stateKey)
return renderWebOAuthPopup(flowID, false, "Failed to exchange tokens with Box. Please retry.", "box")
}
result := boxWebOAuthCredentials{
UserID: state.UserID,
ClientID: state.ClientID,
ClientSecret: state.ClientSecret,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
if ok := redisClient.SetObj(webResultCacheKey(flowID, "box"), result, webFlowTTL); !ok {
redisClient.Delete(stateKey)
return renderWebOAuthPopup(flowID, false, "Failed to exchange tokens with Box. Please retry.", "box")
}
redisClient.Delete(stateKey)
return renderWebOAuthPopup(flowID, true, "Authorization completed successfully.", "box")
}
func (s *ConnectorService) PollBoxWebOAuthResult(userID string, req *PollBoxWebOAuthResultRequest) (*PollBoxWebOAuthResultResponse, common.ErrorCode, error) {
if req == nil || strings.TrimSpace(req.FlowID) == "" {
return nil, common.CodeArgumentError, fmt.Errorf("required argument is missing: flow_id")
}
redisClient := connectorRedisGet()
if redisClient == nil {
return nil, common.CodeRunning, fmt.Errorf("Authorization is still pending.")
}
resultKey := webResultCacheKey(strings.TrimSpace(req.FlowID), "box")
var result boxWebOAuthCredentials
if ok := redisClient.GetObj(resultKey, &result); !ok {
return nil, common.CodeRunning, fmt.Errorf("Authorization is still pending.")
}
if result.UserID != userID {
return nil, common.CodePermissionError, fmt.Errorf("You are not allowed to access this authorization result.")
}
redisClient.Delete(resultKey)
result.UserID = ""
return &PollBoxWebOAuthResultResponse{Credentials: result}, common.CodeSuccess, nil
}
func defaultBoxWebOAuthRedirectURI() string {
return getenvDefault(
"BOX_WEB_OAUTH_REDIRECT_URI",
"http://localhost:9384/api/v1/connectors/box/oauth/web/callback",
)
}
func buildBoxAuthorizationURL(clientID string, redirectURI string, state string) (string, error) {
parsedURL, err := url.Parse(boxOAuthAuthorizeURL)
if err != nil {
return "", err
}
query := parsedURL.Query()
query.Set("client_id", clientID)
query.Set("redirect_uri", redirectURI)
query.Set("response_type", "code")
query.Set("state", state)
parsedURL.RawQuery = query.Encode()
return parsedURL.String(), nil
}
func exchangeBoxAuthorizationCode(clientID string, clientSecret string, redirectURI string, code string) (*boxOAuthTokenResponse, error) {
_ = redirectURI
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("client_id", clientID)
form.Set("client_secret", clientSecret)
ctx, cancel := context.WithTimeout(context.Background(), googleOAuthHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, boxOAuthTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
var token boxOAuthTokenResponse
if err := json.Unmarshal(body, &token); err != nil {
return nil, err
}
if resp.StatusCode >= http.StatusBadRequest || token.Error != "" {
if token.ErrorDesc != "" {
return nil, errors.New(token.ErrorDesc)
}
if token.Error != "" {
return nil, errors.New(token.Error)
}
return nil, fmt.Errorf("box token exchange failed: HTTP %d", resp.StatusCode)
}
if token.AccessToken == "" {
return nil, fmt.Errorf("box token exchange failed: empty access_token")
}
return &token, nil
}

View File

@@ -0,0 +1,171 @@
package service
import (
"io"
"net/http"
"net/url"
"strings"
"testing"
"ragflow/internal/common"
redisengine "ragflow/internal/engine/redis"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func forceNilConnectorRedis(t *testing.T) {
t.Helper()
previous := connectorRedisGet
connectorRedisGet = func() *redisengine.RedisClient { return nil }
t.Cleanup(func() {
connectorRedisGet = previous
})
}
func TestBuildBoxAuthorizationURL(t *testing.T) {
authURL, err := buildBoxAuthorizationURL("client-1", "http://localhost/callback", "flow-1")
if err != nil {
t.Fatalf("buildBoxAuthorizationURL: %v", err)
}
parsed, err := url.Parse(authURL)
if err != nil {
t.Fatalf("parse auth url: %v", err)
}
if got := parsed.Scheme + "://" + parsed.Host + parsed.Path; got != boxOAuthAuthorizeURL {
t.Fatalf("base url=%q want=%q", got, boxOAuthAuthorizeURL)
}
query := parsed.Query()
if query.Get("client_id") != "client-1" {
t.Fatalf("client_id=%q", query.Get("client_id"))
}
if query.Get("redirect_uri") != "http://localhost/callback" {
t.Fatalf("redirect_uri=%q", query.Get("redirect_uri"))
}
if query.Get("response_type") != "code" {
t.Fatalf("response_type=%q", query.Get("response_type"))
}
if query.Get("state") != "flow-1" {
t.Fatalf("state=%q", query.Get("state"))
}
}
func TestRenderWebOAuthPopupBoxPayload(t *testing.T) {
html := renderWebOAuthPopup("flow-1", true, "Authorization completed successfully.", "box")
for _, want := range []string{
"Box Authorization",
"Authorization complete",
"ragflow-box-oauth",
`"status":"success"`,
`"flowId":"flow-1"`,
"window.close();",
} {
if !strings.Contains(html, want) {
t.Fatalf("popup html missing %q:\n%s", want, html)
}
}
}
func TestDefaultBoxWebOAuthRedirectURI(t *testing.T) {
t.Setenv("BOX_WEB_OAUTH_REDIRECT_URI", "http://example.test/box/callback")
if got := defaultBoxWebOAuthRedirectURI(); got != "http://example.test/box/callback" {
t.Fatalf("redirect uri=%q", got)
}
}
func TestExchangeBoxAuthorizationCodeRequestBody(t *testing.T) {
oldClient := http.DefaultClient
t.Cleanup(func() { http.DefaultClient = oldClient })
var form url.Values
http.DefaultClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
t.Fatalf("method=%s", req.Method)
}
if req.URL.String() != boxOAuthTokenURL {
t.Fatalf("url=%s", req.URL.String())
}
if got := req.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
t.Fatalf("content-type=%q", got)
}
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
form, err = url.ParseQuery(string(body))
if err != nil {
t.Fatalf("parse body: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"access_token":"access-1","refresh_token":"refresh-1"}`)),
}, nil
}),
}
token, err := exchangeBoxAuthorizationCode("client-1", "secret-1", "http://localhost/callback", "code-1")
if err != nil {
t.Fatalf("exchangeBoxAuthorizationCode: %v", err)
}
if token.AccessToken != "access-1" || token.RefreshToken != "refresh-1" {
t.Fatalf("token=%#v", token)
}
want := map[string]string{
"grant_type": "authorization_code",
"code": "code-1",
"client_id": "client-1",
"client_secret": "secret-1",
}
for key, value := range want {
if form.Get(key) != value {
t.Fatalf("%s=%q want=%q", key, form.Get(key), value)
}
}
if got := form.Get("redirect_uri"); got != "" {
t.Fatalf("redirect_uri should not be sent to Box token endpoint, got %q", got)
}
}
func TestStartBoxWebOAuthRequiresClientCredentials(t *testing.T) {
svc := NewConnectorService()
_, code, err := svc.StartBoxWebOAuth("user-1", &StartBoxWebOAuthRequest{ClientID: "client-1"})
if err == nil {
t.Fatal("expected error")
}
if code != common.CodeArgumentError {
t.Fatalf("code=%v want=%v", code, common.CodeArgumentError)
}
if err.Error() != "Box client_id and client_secret are required." {
t.Fatalf("error=%q", err.Error())
}
}
func TestPollBoxWebOAuthResultPendingWithoutRedis(t *testing.T) {
forceNilConnectorRedis(t)
svc := NewConnectorService()
_, code, err := svc.PollBoxWebOAuthResult("user-1", &PollBoxWebOAuthResultRequest{FlowID: "flow-1"})
if err == nil {
t.Fatal("expected pending error")
}
if code != common.CodeRunning {
t.Fatalf("code=%v want=%v", code, common.CodeRunning)
}
if err.Error() != "Authorization is still pending." {
t.Fatalf("error=%q", err.Error())
}
}