Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions internal/handler/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
})
}
6 changes: 5 additions & 1 deletion internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
187 changes: 187 additions & 0 deletions internal/service/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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", "")
}