diff --git a/internal/handler/connector.go b/internal/handler/connector.go index 6ab920886e..1117e8bffd 100644 --- a/internal/handler/connector.go +++ b/internal/handler/connector.go @@ -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") +} diff --git a/internal/handler/connector_test.go b/internal/handler/connector_test.go index c2db649e41..602f2faaaf 100644 --- a/internal/handler/connector_test.go +++ b/internal/handler/connector_test.go @@ -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 "box" +} + +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: "box callback"}} + 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() != "box callback" { + 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) diff --git a/internal/router/router.go b/internal/router/router.go index 6968ebab23..08dd2634c9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 5b2bfa854d..3447b52298 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -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", diff --git a/internal/service/connector.go b/internal/service/connector.go index 4d048cfe35..5ce01161dd 100644 --- a/internal/service/connector.go +++ b/internal/service/connector.go @@ -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.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 +} diff --git a/internal/service/connector_box_oauth_test.go b/internal/service/connector_box_oauth_test.go new file mode 100644 index 0000000000..01341aaf0e --- /dev/null +++ b/internal/service/connector_box_oauth_test.go @@ -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()) + } +}