mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-11 06:05:45 +08:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
171
internal/service/connector_box_oauth_test.go
Normal file
171
internal/service/connector_box_oauth_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user