Skip to content

Commit 734881e

Browse files
committed
feat[Go]: implement Box OAuth connector callback and poll-result APIs
Ports the two remaining Box OAuth endpoints from Python (connector_api.py lines 568–627) to the Go layer, closing subtask #15662. GET /api/v1/connectors/box/oauth/web/callback (BoxWebOAuthCallback) POST /api/v1/connectors/box/oauth/web/result (PollBoxWebOAuthResult) The callback endpoint is public (registered without auth middleware, matching the Google/Gmail pattern). Box redirects the user's browser here after consent; the handler reads the state from Redis, exchanges the authorization code for an access + refresh token pair via POST https://api.box.com/oauth2/token, stores the result in Redis, and renders an HTML self-closing popup page. The result endpoint requires authentication. It retrieves the stored credential set, verifies the caller owns the flow (user_id match), deletes the Redis key, and returns the full credential payload — mirroring Python poll_box_web_result. Redis key scheme reuses the existing helpers: state: box_web_flow_state:{flow_id} result: box_web_flow_result:{flow_id} BOX_WEB_OAUTH_REDIRECT_URI env var is forwarded to the token exchange when set; omitted otherwise (Box accepts the absence when the redirect_uri was optional). Changed files: internal/service/connector.go – Box types + BoxWebOAuthCallback + PollBoxWebOAuthResult + exchangeBoxOAuthCode internal/handler/connector.go – interface extension + two new handlers internal/router/router.go – callback registered at engine + apiNoAuth; result registered in auth-protected connector group
1 parent 461c190 commit 734881e

3 files changed

Lines changed: 256 additions & 1 deletion

File tree

internal/handler/connector.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ type connectorServiceIface interface {
4242
StartGoogleWebOAuth(userID, source string, req *service.StartGoogleWebOAuthRequest) (*service.StartGoogleWebOAuthResponse, common.ErrorCode, error)
4343
GoogleWebOAuthCallback(source, stateID, oauthError, errorDescription, code string) string
4444
PollGoogleWebOAuthResult(userID, source string, req *service.PollGoogleWebOAuthResultRequest) (*service.PollGoogleWebOAuthResultResponse, common.ErrorCode, error)
45+
BoxWebOAuthCallback(stateID, oauthError, errorDescription, code string) string
46+
PollBoxWebOAuthResult(userID string, req *service.PollBoxWebOAuthResultRequest) (*service.PollBoxWebOAuthResultResponse, common.ErrorCode, error)
4547
}
4648

4749
// ConnectorHandler connector handler
@@ -504,3 +506,65 @@ func (h *ConnectorHandler) googleWebOAuthCallback(c *gin.Context, source string)
504506
)
505507
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
506508
}
509+
510+
// BoxWebOAuthCallback handles the redirect from Box after the user grants access.
511+
// This endpoint is public (no auth middleware) — Box redirects the user's browser here.
512+
// @Summary Box OAuth Web Callback
513+
// @Description Receives the authorization code from Box, exchanges it for tokens,
514+
// stores the result in Redis, and renders a self-closing popup page.
515+
// @Tags connector
516+
// @Produce text/html
517+
// @Param state query string true "OAuth state (flow ID)"
518+
// @Param code query string false "Authorization code"
519+
// @Param error query string false "Error code from Box"
520+
// @Param error_description query string false "Human-readable error from Box"
521+
// @Router /api/v1/connectors/box/oauth/web/callback [get]
522+
func (h *ConnectorHandler) BoxWebOAuthCallback(c *gin.Context) {
523+
htmlPage := h.connectorService.BoxWebOAuthCallback(
524+
c.Query("state"),
525+
c.Query("error"),
526+
c.Query("error_description"),
527+
c.Query("code"),
528+
)
529+
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlPage))
530+
}
531+
532+
// PollBoxWebOAuthResult polls for the result of a Box OAuth web flow.
533+
// @Summary Poll Box OAuth Result
534+
// @Description Check whether the Box OAuth callback has completed and retrieve the credentials.
535+
// Returns code 106 (RUNNING) while authorization is still pending.
536+
// @Tags connector
537+
// @Accept json
538+
// @Produce json
539+
// @Param body body service.PollBoxWebOAuthResultRequest true "Flow ID"
540+
// @Success 200 {object} map[string]interface{}
541+
// @Router /api/v1/connectors/box/oauth/web/result [post]
542+
func (h *ConnectorHandler) PollBoxWebOAuthResult(c *gin.Context) {
543+
user, errorCode, errorMessage := GetUser(c)
544+
if errorCode != common.CodeSuccess {
545+
jsonError(c, errorCode, errorMessage)
546+
return
547+
}
548+
549+
var req service.PollBoxWebOAuthResultRequest
550+
if err := c.ShouldBindJSON(&req); err != nil {
551+
c.JSON(http.StatusBadRequest, gin.H{
552+
"code": common.CodeBadRequest,
553+
"data": nil,
554+
"message": err.Error(),
555+
})
556+
return
557+
}
558+
559+
data, code, err := h.connectorService.PollBoxWebOAuthResult(user.ID, &req)
560+
if err != nil {
561+
jsonError(c, code, err.Error())
562+
return
563+
}
564+
565+
c.JSON(http.StatusOK, gin.H{
566+
"code": common.CodeSuccess,
567+
"data": data,
568+
"message": "success",
569+
})
570+
}

internal/router/router.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ func (r *Router) Setup(engine *gin.Engine) {
117117
// the RAGFlow auth middleware.
118118
engine.GET("/connectors/gmail/oauth/web/callback", r.connectorHandler.GmailWebOAuthCallback)
119119
engine.GET("/connectors/google-drive/oauth/web/callback", r.connectorHandler.GoogleDriveWebOAuthCallback)
120+
engine.GET("/connectors/box/oauth/web/callback", r.connectorHandler.BoxWebOAuthCallback)
120121

121122
apiNoAuth := engine.Group("/api/v1")
122123
{
@@ -144,9 +145,11 @@ func (r *Router) Setup(engine *gin.Engine) {
144145
// Document images are embedded directly in pages and match Python's public route.
145146
apiNoAuth.GET("/documents/images/:image_id", r.documentHandler.GetDocumentImage)
146147

147-
// Google redirects here after Gmail / Google Drive web OAuth completes.
148+
// OAuth callbacks — Gmail, Google Drive, and Box redirect the user's browser here;
149+
// no auth middleware is applied on this group.
148150
apiNoAuth.GET("/connectors/gmail/oauth/web/callback", r.connectorHandler.GmailWebOAuthCallback)
149151
apiNoAuth.GET("/connectors/google-drive/oauth/web/callback", r.connectorHandler.GoogleDriveWebOAuthCallback)
152+
apiNoAuth.GET("/connectors/box/oauth/web/callback", r.connectorHandler.BoxWebOAuthCallback)
150153
}
151154

152155
// Protected routes
@@ -386,6 +389,7 @@ func (r *Router) Setup(engine *gin.Engine) {
386389
connector.POST("/", r.connectorHandler.CreateConnector)
387390
connector.POST("/google/oauth/web/start", r.connectorHandler.StartGoogleWebOAuth)
388391
connector.POST("/google/oauth/web/result", r.connectorHandler.PollGoogleWebOAuthResult)
392+
connector.POST("/box/oauth/web/result", r.connectorHandler.PollBoxWebOAuthResult)
389393
connector.GET("/:connector_id", r.connectorHandler.GetConnector)
390394
connector.GET("/:connector_id/logs", r.connectorHandler.ListLogs)
391395
connector.DELETE("/:connector_id", r.connectorHandler.DeleteConnector)

internal/service/connector.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ const (
5050
googleOAuthAuthorizeURL = "https://accounts.google.com/o/oauth2/auth"
5151
googleOAuthTokenURL = "https://oauth2.googleapis.com/token"
5252
googleOAuthHTTPTimeout = 7 * time.Second
53+
boxOAuthTokenURL = "https://api.box.com/oauth2/token"
54+
boxOAuthHTTPTimeout = 7 * time.Second
5355
)
5456

5557
var (
@@ -982,3 +984,188 @@ func (s *ConnectorService) ListLog(connectorID, userID string, page, pageSize in
982984
}
983985
return logs, total, common.CodeSuccess, nil
984986
}
987+
988+
// ── Box OAuth ─────────────────────────────────────────────────────────────────
989+
990+
// boxWebOAuthState is the Redis state written by the Box OAuth start endpoint.
991+
// Mirrors Python: {"user_id", "auth_url", "client_id", "client_secret", "created_at"}.
992+
type boxWebOAuthState struct {
993+
UserID string `json:"user_id"`
994+
AuthURL string `json:"auth_url"`
995+
ClientID string `json:"client_id"`
996+
ClientSecret string `json:"client_secret"`
997+
CreatedAt int64 `json:"created_at"`
998+
}
999+
1000+
// boxWebOAuthResult is stored in Redis after a successful token exchange.
1001+
// Mirrors Python: {"user_id", "client_id", "client_secret", "access_token", "refresh_token"}.
1002+
type boxWebOAuthResult struct {
1003+
UserID string `json:"user_id"`
1004+
ClientID string `json:"client_id"`
1005+
ClientSecret string `json:"client_secret"`
1006+
AccessToken string `json:"access_token"`
1007+
RefreshToken string `json:"refresh_token"`
1008+
}
1009+
1010+
type boxOAuthTokenResponse struct {
1011+
AccessToken string `json:"access_token"`
1012+
RefreshToken string `json:"refresh_token"`
1013+
TokenType string `json:"token_type"`
1014+
ExpiresIn int64 `json:"expires_in"`
1015+
Error string `json:"error"`
1016+
ErrorDesc string `json:"error_description"`
1017+
}
1018+
1019+
// PollBoxWebOAuthResultRequest is the request body for POST /connectors/box/oauth/web/result.
1020+
type PollBoxWebOAuthResultRequest struct {
1021+
FlowID string `json:"flow_id"`
1022+
}
1023+
1024+
// PollBoxWebOAuthResultResponse contains the full Box credential set.
1025+
type PollBoxWebOAuthResultResponse struct {
1026+
Credentials *boxWebOAuthResult `json:"credentials"`
1027+
}
1028+
1029+
// BoxWebOAuthCallback handles the redirect from Box after the user grants access.
1030+
// It exchanges the authorization code for tokens and stores the result in Redis.
1031+
// Returns an HTML popup page (same shape as Google OAuth, source="box").
1032+
func (s *ConnectorService) BoxWebOAuthCallback(stateID, oauthError, errorDescription, code string) string {
1033+
stateID = strings.TrimSpace(stateID)
1034+
if stateID == "" {
1035+
return renderGoogleWebOAuthPopup("", false, "Missing OAuth state parameter.", "box")
1036+
}
1037+
1038+
redisClient := cache.Get()
1039+
if redisClient == nil {
1040+
return renderGoogleWebOAuthPopup(stateID, false, "Authorization session expired. Please restart from the main window.", "box")
1041+
}
1042+
1043+
stateKey := webStateCacheKey(stateID, "box")
1044+
var state boxWebOAuthState
1045+
if ok := redisClient.GetObj(stateKey, &state); !ok {
1046+
return renderGoogleWebOAuthPopup(stateID, false, "Authorization session expired. Please restart from the main window.", "box")
1047+
}
1048+
1049+
if strings.TrimSpace(oauthError) != "" {
1050+
redisClient.Delete(stateKey)
1051+
message := strings.TrimSpace(errorDescription)
1052+
if message == "" {
1053+
message = strings.TrimSpace(oauthError)
1054+
}
1055+
if message == "" {
1056+
message = "Authorization was cancelled."
1057+
}
1058+
return renderGoogleWebOAuthPopup(stateID, false, message, "box")
1059+
}
1060+
1061+
code = strings.TrimSpace(code)
1062+
if code == "" {
1063+
return renderGoogleWebOAuthPopup(stateID, false, "Missing authorization code from Box.", "box")
1064+
}
1065+
1066+
accessToken, refreshToken, err := exchangeBoxOAuthCode(state.ClientID, state.ClientSecret, code)
1067+
if err != nil {
1068+
redisClient.Delete(stateKey)
1069+
return renderGoogleWebOAuthPopup(stateID, false, "Failed to exchange tokens with Box. Please retry.", "box")
1070+
}
1071+
1072+
result := boxWebOAuthResult{
1073+
UserID: state.UserID,
1074+
ClientID: state.ClientID,
1075+
ClientSecret: state.ClientSecret,
1076+
AccessToken: accessToken,
1077+
RefreshToken: refreshToken,
1078+
}
1079+
if ok := redisClient.SetObj(webResultCacheKey(stateID, "box"), result, webFlowTTL); !ok {
1080+
redisClient.Delete(stateKey)
1081+
return renderGoogleWebOAuthPopup(stateID, false, "Failed to store authorization result. Please retry.", "box")
1082+
}
1083+
redisClient.Delete(stateKey)
1084+
1085+
return renderGoogleWebOAuthPopup(stateID, true, "Authorization completed successfully.", "box")
1086+
}
1087+
1088+
// PollBoxWebOAuthResult retrieves the Box OAuth result for the given flow.
1089+
// Mirrors Python poll_box_web_result: verifies caller owns the flow, then returns
1090+
// the stored credential set and deletes it from Redis.
1091+
func (s *ConnectorService) PollBoxWebOAuthResult(userID string, req *PollBoxWebOAuthResultRequest) (*PollBoxWebOAuthResultResponse, common.ErrorCode, error) {
1092+
if req == nil || strings.TrimSpace(req.FlowID) == "" {
1093+
return nil, common.CodeArgumentError, fmt.Errorf("required argument is missing: flow_id")
1094+
}
1095+
1096+
redisClient := cache.Get()
1097+
if redisClient == nil {
1098+
return nil, common.CodeRunning, fmt.Errorf("Authorization is still pending.")
1099+
}
1100+
1101+
resultKey := webResultCacheKey(strings.TrimSpace(req.FlowID), "box")
1102+
var result boxWebOAuthResult
1103+
if ok := redisClient.GetObj(resultKey, &result); !ok {
1104+
return nil, common.CodeRunning, fmt.Errorf("Authorization is still pending.")
1105+
}
1106+
1107+
if result.UserID != userID {
1108+
return nil, common.CodePermissionError, fmt.Errorf("You are not allowed to access this authorization result.")
1109+
}
1110+
1111+
redisClient.Delete(resultKey)
1112+
return &PollBoxWebOAuthResultResponse{Credentials: &result}, common.CodeSuccess, nil
1113+
}
1114+
1115+
// exchangeBoxOAuthCode exchanges an authorization code for Box access + refresh tokens.
1116+
// Box token endpoint: POST https://api.box.com/oauth2/token
1117+
func exchangeBoxOAuthCode(clientID, clientSecret, code string) (accessToken, refreshToken string, err error) {
1118+
form := url.Values{}
1119+
form.Set("grant_type", "authorization_code")
1120+
form.Set("code", code)
1121+
form.Set("client_id", clientID)
1122+
form.Set("client_secret", clientSecret)
1123+
1124+
redirectURI := defaultBoxWebOAuthRedirectURI()
1125+
if redirectURI != "" {
1126+
form.Set("redirect_uri", redirectURI)
1127+
}
1128+
1129+
ctx, cancel := context.WithTimeout(context.Background(), boxOAuthHTTPTimeout)
1130+
defer cancel()
1131+
1132+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, boxOAuthTokenURL, strings.NewReader(form.Encode()))
1133+
if err != nil {
1134+
return "", "", err
1135+
}
1136+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1137+
req.Header.Set("Accept", "application/json")
1138+
1139+
resp, httpErr := http.DefaultClient.Do(req)
1140+
if httpErr != nil {
1141+
return "", "", httpErr
1142+
}
1143+
defer resp.Body.Close()
1144+
1145+
body, httpErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
1146+
if httpErr != nil {
1147+
return "", "", httpErr
1148+
}
1149+
1150+
var token boxOAuthTokenResponse
1151+
if err := json.Unmarshal(body, &token); err != nil {
1152+
return "", "", err
1153+
}
1154+
if resp.StatusCode >= http.StatusBadRequest || token.Error != "" {
1155+
if token.ErrorDesc != "" {
1156+
return "", "", errors.New(token.ErrorDesc)
1157+
}
1158+
if token.Error != "" {
1159+
return "", "", errors.New(token.Error)
1160+
}
1161+
return "", "", fmt.Errorf("box token exchange failed: HTTP %d", resp.StatusCode)
1162+
}
1163+
if token.AccessToken == "" {
1164+
return "", "", fmt.Errorf("box token exchange returned empty access_token")
1165+
}
1166+
return token.AccessToken, token.RefreshToken, nil
1167+
}
1168+
1169+
func defaultBoxWebOAuthRedirectURI() string {
1170+
return getenvDefault("BOX_WEB_OAUTH_REDIRECT_URI", "")
1171+
}

0 commit comments

Comments
 (0)