diff --git a/internal/handler/connector.go b/internal/handler/connector.go index 6ab920886e6..f4f3237c059 100644 --- a/internal/handler/connector.go +++ b/internal/handler/connector.go @@ -42,6 +42,8 @@ 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) + BoxWebOAuthCallback(stateID, oauthError, errorDescription, code string) string + PollBoxWebOAuthResult(userID string, req *service.PollBoxWebOAuthResultRequest) (*service.PollBoxWebOAuthResultResponse, common.ErrorCode, error) } // ConnectorHandler connector handler @@ -504,3 +506,65 @@ func (h *ConnectorHandler) googleWebOAuthCallback(c *gin.Context, source string) ) c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html)) } + +// BoxWebOAuthCallback handles the redirect from Box after the user grants access. +// This endpoint is public (no auth middleware) — Box redirects the user's browser here. +// @Summary Box OAuth Web Callback +// @Description Receives the authorization code from Box, exchanges it for tokens, +// stores the result in Redis, and renders a self-closing popup page. +// @Tags connector +// @Produce text/html +// @Param state query string true "OAuth state (flow ID)" +// @Param code query string false "Authorization code" +// @Param error query string false "Error code from Box" +// @Param error_description query string false "Human-readable error from Box" +// @Router /api/v1/connectors/box/oauth/web/callback [get] +func (h *ConnectorHandler) BoxWebOAuthCallback(c *gin.Context) { + htmlPage := h.connectorService.BoxWebOAuthCallback( + c.Query("state"), + c.Query("error"), + c.Query("error_description"), + c.Query("code"), + ) + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlPage)) +} + +// PollBoxWebOAuthResult polls for the result of a Box OAuth web flow. +// @Summary Poll Box OAuth Result +// @Description Check whether the Box OAuth callback has completed and retrieve the credentials. +// Returns code 106 (RUNNING) while authorization is still pending. +// @Tags connector +// @Accept json +// @Produce json +// @Param body body service.PollBoxWebOAuthResultRequest true "Flow ID" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/connectors/box/oauth/web/result [post] +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 { + c.JSON(http.StatusBadRequest, gin.H{ + "code": common.CodeBadRequest, + "data": nil, + "message": err.Error(), + }) + return + } + + data, code, err := h.connectorService.PollBoxWebOAuthResult(user.ID, &req) + if err != nil { + jsonError(c, code, err.Error()) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": data, + "message": "success", + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index a64c9f6e496..5870f6e1b93 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -117,6 +117,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") { @@ -144,9 +145,11 @@ func (r *Router) Setup(engine *gin.Engine) { // Document images are embedded directly in pages and match Python's public route. apiNoAuth.GET("/documents/images/:image_id", r.documentHandler.GetDocumentImage) - // Google redirects here after Gmail / Google Drive web OAuth completes. + // OAuth callbacks — Gmail, Google Drive, and Box redirect the user's browser here; + // no auth middleware is applied on this group. 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) } // Protected routes @@ -386,6 +389,7 @@ 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/result", r.connectorHandler.PollBoxWebOAuthResult) connector.GET("/:connector_id", r.connectorHandler.GetConnector) connector.GET("/:connector_id/logs", r.connectorHandler.ListLogs) connector.DELETE("/:connector_id", r.connectorHandler.DeleteConnector) diff --git a/internal/service/connector.go b/internal/service/connector.go index 9ebd62fe3fa..a9139f44682 100644 --- a/internal/service/connector.go +++ b/internal/service/connector.go @@ -50,6 +50,8 @@ const ( googleOAuthAuthorizeURL = "https://accounts.google.com/o/oauth2/auth" googleOAuthTokenURL = "https://oauth2.googleapis.com/token" googleOAuthHTTPTimeout = 7 * time.Second + boxOAuthTokenURL = "https://api.box.com/oauth2/token" + boxOAuthHTTPTimeout = 7 * time.Second ) var ( @@ -982,3 +984,188 @@ func (s *ConnectorService) ListLog(connectorID, userID string, page, pageSize in } return logs, total, common.CodeSuccess, nil } + +// ── Box OAuth ───────────────────────────────────────────────────────────────── + +// boxWebOAuthState is the Redis state written by the Box OAuth start endpoint. +// Mirrors Python: {"user_id", "auth_url", "client_id", "client_secret", "created_at"}. +type boxWebOAuthState struct { + UserID string `json:"user_id"` + AuthURL string `json:"auth_url"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + CreatedAt int64 `json:"created_at"` +} + +// boxWebOAuthResult is stored in Redis after a successful token exchange. +// Mirrors Python: {"user_id", "client_id", "client_secret", "access_token", "refresh_token"}. +type boxWebOAuthResult struct { + UserID string `json:"user_id"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type boxOAuthTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +// PollBoxWebOAuthResultRequest is the request body for POST /connectors/box/oauth/web/result. +type PollBoxWebOAuthResultRequest struct { + FlowID string `json:"flow_id"` +} + +// PollBoxWebOAuthResultResponse contains the full Box credential set. +type PollBoxWebOAuthResultResponse struct { + Credentials *boxWebOAuthResult `json:"credentials"` +} + +// BoxWebOAuthCallback handles the redirect from Box after the user grants access. +// It exchanges the authorization code for tokens and stores the result in Redis. +// Returns an HTML popup page (same shape as Google OAuth, source="box"). +func (s *ConnectorService) BoxWebOAuthCallback(stateID, oauthError, errorDescription, code string) string { + stateID = strings.TrimSpace(stateID) + if stateID == "" { + return renderGoogleWebOAuthPopup("", false, "Missing OAuth state parameter.", "box") + } + + redisClient := cache.Get() + if redisClient == nil { + return renderGoogleWebOAuthPopup(stateID, false, "Authorization session expired. Please restart from the main window.", "box") + } + + stateKey := webStateCacheKey(stateID, "box") + var state boxWebOAuthState + if ok := redisClient.GetObj(stateKey, &state); !ok { + return renderGoogleWebOAuthPopup(stateID, false, "Authorization session expired. Please restart from the main window.", "box") + } + + if strings.TrimSpace(oauthError) != "" { + redisClient.Delete(stateKey) + message := strings.TrimSpace(errorDescription) + if message == "" { + message = strings.TrimSpace(oauthError) + } + if message == "" { + message = "Authorization was cancelled." + } + return renderGoogleWebOAuthPopup(stateID, false, message, "box") + } + + code = strings.TrimSpace(code) + if code == "" { + return renderGoogleWebOAuthPopup(stateID, false, "Missing authorization code from Box.", "box") + } + + accessToken, refreshToken, err := exchangeBoxOAuthCode(state.ClientID, state.ClientSecret, code) + if err != nil { + redisClient.Delete(stateKey) + return renderGoogleWebOAuthPopup(stateID, false, "Failed to exchange tokens with Box. Please retry.", "box") + } + + result := boxWebOAuthResult{ + UserID: state.UserID, + ClientID: state.ClientID, + ClientSecret: state.ClientSecret, + AccessToken: accessToken, + RefreshToken: refreshToken, + } + if ok := redisClient.SetObj(webResultCacheKey(stateID, "box"), result, webFlowTTL); !ok { + redisClient.Delete(stateKey) + return renderGoogleWebOAuthPopup(stateID, false, "Failed to store authorization result. Please retry.", "box") + } + redisClient.Delete(stateKey) + + return renderGoogleWebOAuthPopup(stateID, true, "Authorization completed successfully.", "box") +} + +// PollBoxWebOAuthResult retrieves the Box OAuth result for the given flow. +// Mirrors Python poll_box_web_result: verifies caller owns the flow, then returns +// the stored credential set and deletes it from Redis. +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 := cache.Get() + if redisClient == nil { + return nil, common.CodeRunning, fmt.Errorf("Authorization is still pending.") + } + + resultKey := webResultCacheKey(strings.TrimSpace(req.FlowID), "box") + var result boxWebOAuthResult + 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) + return &PollBoxWebOAuthResultResponse{Credentials: &result}, common.CodeSuccess, nil +} + +// exchangeBoxOAuthCode exchanges an authorization code for Box access + refresh tokens. +// Box token endpoint: POST https://api.box.com/oauth2/token +func exchangeBoxOAuthCode(clientID, clientSecret, code string) (accessToken, refreshToken string, err error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("client_id", clientID) + form.Set("client_secret", clientSecret) + + redirectURI := defaultBoxWebOAuthRedirectURI() + if redirectURI != "" { + form.Set("redirect_uri", redirectURI) + } + + ctx, cancel := context.WithTimeout(context.Background(), boxOAuthHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, boxOAuthTokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, httpErr := http.DefaultClient.Do(req) + if httpErr != nil { + return "", "", httpErr + } + defer resp.Body.Close() + + body, httpErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if httpErr != nil { + return "", "", httpErr + } + + var token boxOAuthTokenResponse + if err := json.Unmarshal(body, &token); err != nil { + return "", "", err + } + if resp.StatusCode >= http.StatusBadRequest || token.Error != "" { + if token.ErrorDesc != "" { + return "", "", errors.New(token.ErrorDesc) + } + if token.Error != "" { + return "", "", errors.New(token.Error) + } + return "", "", fmt.Errorf("box token exchange failed: HTTP %d", resp.StatusCode) + } + if token.AccessToken == "" { + return "", "", fmt.Errorf("box token exchange returned empty access_token") + } + return token.AccessToken, token.RefreshToken, nil +} + +func defaultBoxWebOAuthRedirectURI() string { + return getenvDefault("BOX_WEB_OAUTH_REDIRECT_URI", "") +}