diff --git a/internal/handler/connector.go b/internal/handler/connector.go index 999b08aeaf..6ab920886e 100644 --- a/internal/handler/connector.go +++ b/internal/handler/connector.go @@ -39,6 +39,9 @@ type connectorServiceIface interface { RebuildConnector(connectorID, userID, kbID string) (bool, common.ErrorCode, error) TestConnector(connectorID, userID string) error UpdateConnector(connectorID, userID string, req *service.UpdateConnectorRequest) (*entity.Connector, common.ErrorCode, error) + 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) } // ConnectorHandler connector handler @@ -418,3 +421,86 @@ func (h *ConnectorHandler) RebuildConnector(c *gin.Context) { "message": "success", }) } + +func (h *ConnectorHandler) StartGoogleWebOAuth(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + var req service.StartGoogleWebOAuthRequest + 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.StartGoogleWebOAuth(user.ID, c.DefaultQuery("type", "google-drive"), &req) + if err != nil { + jsonError(c, code, err.Error()) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": data, + "message": "success", + }) +} + +func (h *ConnectorHandler) PollGoogleWebOAuthResult(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + var req service.PollGoogleWebOAuthResultRequest + 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.PollGoogleWebOAuthResult(user.ID, c.Query("type"), &req) + if err != nil { + jsonError(c, code, err.Error()) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "data": data, + "message": "success", + }) +} + +func (h *ConnectorHandler) GoogleWebOAuthCallback(c *gin.Context) { + h.googleWebOAuthCallback(c, c.Param("source")) +} + +func (h *ConnectorHandler) GoogleDriveWebOAuthCallback(c *gin.Context) { + h.googleWebOAuthCallback(c, "google-drive") +} + +func (h *ConnectorHandler) GmailWebOAuthCallback(c *gin.Context) { + h.googleWebOAuthCallback(c, "gmail") +} + +func (h *ConnectorHandler) googleWebOAuthCallback(c *gin.Context, source string) { + html := h.connectorService.GoogleWebOAuthCallback( + source, + c.Query("state"), + c.Query("error"), + c.Query("error_description"), + c.Query("code"), + ) + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html)) +} diff --git a/internal/handler/connector_test.go b/internal/handler/connector_test.go index a5e17c50f0..c2db649e41 100644 --- a/internal/handler/connector_test.go +++ b/internal/handler/connector_test.go @@ -52,6 +52,24 @@ func (s fakeConnectorService) UpdateConnector(string, string, *service.UpdateCon return s.connector, common.CodeSuccess, nil } +func (s fakeConnectorService) StartGoogleWebOAuth(string, string, *service.StartGoogleWebOAuthRequest) (*service.StartGoogleWebOAuthResponse, common.ErrorCode, error) { + if s.err != nil { + return nil, s.code, s.err + } + return &service.StartGoogleWebOAuthResponse{}, common.CodeSuccess, nil +} + +func (s fakeConnectorService) GoogleWebOAuthCallback(string, string, string, string, string) string { + return "" +} + +func (s fakeConnectorService) PollGoogleWebOAuthResult(string, string, *service.PollGoogleWebOAuthResultRequest) (*service.PollGoogleWebOAuthResultResponse, common.ErrorCode, error) { + if s.err != nil { + return nil, s.code, s.err + } + return &service.PollGoogleWebOAuthResultResponse{}, 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 diff --git a/internal/router/router.go b/internal/router/router.go index 038962c490..3c32532a07 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -101,6 +101,11 @@ func (r *Router) Setup(engine *gin.Engine) { // User logout endpoint engine.GET("/v1/user/logout", r.userHandler.Logout) + // OAuth callbacks are invoked by third-party providers and cannot rely on + // 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) + apiNoAuth := engine.Group("/api/v1") { apiNoAuth.GET("/system/ping", r.systemHandler.Ping) @@ -126,6 +131,10 @@ 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. + apiNoAuth.GET("/connectors/gmail/oauth/web/callback", r.connectorHandler.GmailWebOAuthCallback) + apiNoAuth.GET("/connectors/google-drive/oauth/web/callback", r.connectorHandler.GoogleDriveWebOAuthCallback) } // Protected routes @@ -354,6 +363,8 @@ func (r *Router) Setup(engine *gin.Engine) { { connector.GET("/", r.connectorHandler.ListConnectors) connector.POST("/", r.connectorHandler.CreateConnector) + connector.POST("/google/oauth/web/start", r.connectorHandler.StartGoogleWebOAuth) + connector.POST("/google/oauth/web/result", r.connectorHandler.PollGoogleWebOAuthResult) 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/router/router_test.go b/internal/router/router_test.go new file mode 100644 index 0000000000..5e6c420af0 --- /dev/null +++ b/internal/router/router_test.go @@ -0,0 +1,71 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestConnectorRoutesDoNotConflictWithOAuthCallbacks(t *testing.T) { + gin.SetMode(gin.TestMode) + + engine := gin.New() + engine.GET("/api/v1/connectors/gmail/oauth/web/callback", func(c *gin.Context) { + c.String(http.StatusOK, "gmail") + }) + engine.GET("/api/v1/connectors/google-drive/oauth/web/callback", func(c *gin.Context) { + c.String(http.StatusOK, "google-drive") + }) + + connectors := engine.Group("/api/v1/connectors") + connectors.GET("/:connector_id", func(c *gin.Context) { + c.String(http.StatusOK, c.Param("connector_id")) + }) + connectors.GET("/:connector_id/logs", func(c *gin.Context) { + c.String(http.StatusOK, c.Param("connector_id")) + }) + + tests := []struct { + name string + path string + want string + }{ + { + name: "gmail callback", + path: "/api/v1/connectors/gmail/oauth/web/callback", + want: "gmail", + }, + { + name: "google drive callback", + path: "/api/v1/connectors/google-drive/oauth/web/callback", + want: "google-drive", + }, + { + name: "connector detail", + path: "/api/v1/connectors/connector-1", + want: "connector-1", + }, + { + name: "connector logs", + path: "/api/v1/connectors/connector-1/logs", + want: "connector-1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + engine.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String()) + } + if resp.Body.String() != tt.want { + t.Fatalf("body=%q want=%q", resp.Body.String(), tt.want) + } + }) + } +} diff --git a/internal/service/connector.go b/internal/service/connector.go index 78bd393d5f..9ebd62fe3f 100644 --- a/internal/service/connector.go +++ b/internal/service/connector.go @@ -18,12 +18,23 @@ package service import ( "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" "errors" "fmt" + "html" + "io" + "net/http" + "net/url" + "os" "strings" + "time" "gorm.io/gorm" + "ragflow/internal/cache" "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/engine" @@ -35,6 +46,24 @@ const ( connectorStatusUnstarted = "0" defaultConnectorFreq = 5 defaultConnectorTimeout = 60 * 29 + webFlowTTL = 15 * time.Minute + googleOAuthAuthorizeURL = "https://accounts.google.com/o/oauth2/auth" + googleOAuthTokenURL = "https://oauth2.googleapis.com/token" + googleOAuthHTTPTimeout = 7 * time.Second +) + +var ( + googleDriveOAuthScopes = []string{ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/admin.directory.group.readonly", + "https://www.googleapis.com/auth/admin.directory.user.readonly", + } + gmailOAuthScopes = []string{ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/admin.directory.user.readonly", + "https://www.googleapis.com/auth/admin.directory.group.readonly", + } ) // Sentinel errors so handlers can map to the proper response codes, @@ -83,6 +112,59 @@ type RebuildConnectorRequest struct { KbID string `json:"kb_id"` } +type StartGoogleWebOAuthRequest struct { + Credentials json.RawMessage `json:"credentials"` + RedirectURI string `json:"redirect_uri,omitempty"` +} + +type StartGoogleWebOAuthResponse struct { + FlowID string `json:"flow_id"` + AuthorizationURL string `json:"authorization_url"` + ExpiresIn int64 `json:"expires_in"` +} + +type PollGoogleWebOAuthResultRequest struct { + FlowID string `json:"flow_id"` +} + +type PollGoogleWebOAuthResultResponse struct { + Credentials string `json:"credentials"` +} + +type googleWebOAuthState struct { + UserID string `json:"user_id"` + ClientConfig map[string]interface{} `json:"client_config"` + RedirectURI string `json:"redirect_uri"` + CodeVerifier string `json:"code_verifier"` + CreatedAt int64 `json:"created_at"` +} + +type googleWebOAuthResult struct { + UserID string `json:"user_id"` + Credentials string `json:"credentials"` +} + +type googleOAuthTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` + Scope string `json:"scope,omitempty"` + IDToken string `json:"id_token,omitempty"` + Error string `json:"error,omitempty"` + ErrorDesc string `json:"error_description,omitempty"` +} + +type googleOAuthCredentials struct { + Token string `json:"token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenURI string `json:"token_uri"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Scopes []string `json:"scopes"` + Expiry string `json:"expiry,omitempty"` +} + // canAccessConnector Test Authentication func (s *ConnectorService) canAccessConnector(connector *entity.Connector, userID string) bool { if connector.TenantID == userID { @@ -262,6 +344,448 @@ func (s *ConnectorService) TestConnector(connectorID, userID string) error { return nil } +func (s *ConnectorService) StartGoogleWebOAuth(userID, source string, req *StartGoogleWebOAuthRequest) (*StartGoogleWebOAuthResponse, common.ErrorCode, error) { + source = strings.TrimSpace(source) + if source == "" { + source = "google-drive" + } + if source != "google-drive" && source != "gmail" { + return nil, common.CodeArgumentError, fmt.Errorf("Invalid Google OAuth type.") + } + + if req == nil || len(req.Credentials) == 0 { + return nil, common.CodeArgumentError, fmt.Errorf("required argument is missing: credentials") + } + + redirectURI := strings.TrimSpace(req.RedirectURI) + if redirectURI == "" { + redirectURI = defaultGoogleWebOAuthRedirectURI(source) + } + if redirectURI == "" { + return nil, common.CodeServerError, fmt.Errorf("Google OAuth redirect URI is not configured on the server.") + } + + credentials, err := loadGoogleCredentials(req.Credentials) + if err != nil { + return nil, common.CodeArgumentError, err + } + if hasRefreshToken(credentials) { + return nil, common.CodeArgumentError, fmt.Errorf("Uploaded credentials already include a refresh token.") + } + + clientConfig, err := getGoogleWebClientConfig(credentials) + if err != nil { + return nil, common.CodeArgumentError, err + } + + webConfig, _ := clientConfig["web"].(map[string]interface{}) + clientID := strings.TrimSpace(stringValue(webConfig["client_id"])) + authURI := strings.TrimSpace(stringValue(webConfig["auth_uri"])) + if authURI == "" { + authURI = googleOAuthAuthorizeURL + } + if clientID == "" || authURI == "" { + return nil, common.CodeServerError, fmt.Errorf("Failed to initialize Google OAuth flow. Please verify the uploaded client configuration.") + } + + codeVerifier, codeChallenge, err := newPKCEChallenge() + if err != nil { + return nil, common.CodeServerError, err + } + + flowID := common.GenerateUUID() + authorizationURL, err := buildGoogleAuthorizationURL(authURI, clientID, redirectURI, flowID, googleOAuthScopesForSource(source), codeChallenge) + if err != nil { + return nil, common.CodeServerError, fmt.Errorf("Failed to initialize Google OAuth flow. Please verify the uploaded client configuration.") + } + + redisClient := cache.Get() + if redisClient == nil { + return nil, common.CodeServerError, fmt.Errorf("Redis is not configured on the server.") + } + + state := googleWebOAuthState{ + UserID: userID, + ClientConfig: clientConfig, + RedirectURI: redirectURI, + CodeVerifier: codeVerifier, + CreatedAt: time.Now().Unix(), + } + if ok := redisClient.SetObj(webStateCacheKey(flowID, source), state, webFlowTTL); !ok { + return nil, common.CodeServerError, fmt.Errorf("Failed to initialize Google OAuth flow. Please verify the uploaded client configuration.") + } + + return &StartGoogleWebOAuthResponse{ + FlowID: flowID, + AuthorizationURL: authorizationURL, + ExpiresIn: int64(webFlowTTL.Seconds()), + }, common.CodeSuccess, nil +} + +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) + } + + stateID = strings.TrimSpace(stateID) + if stateID == "" { + return renderGoogleWebOAuthPopup("", false, "Missing OAuth state parameter.", source) + } + + redisClient := cache.Get() + if redisClient == nil { + return renderGoogleWebOAuthPopup(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) + } + + if state.ClientConfig == nil { + redisClient.Delete(stateKey) + return renderGoogleWebOAuthPopup(stateID, false, "Authorization session was invalid. Please retry.", source) + } + + 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, source) + } + + code = strings.TrimSpace(code) + if code == "" { + return renderGoogleWebOAuthPopup(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) + } + + result := googleWebOAuthResult{ + UserID: state.UserID, + Credentials: credentials, + } + 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) + } + redisClient.Delete(stateKey) + + return renderGoogleWebOAuthPopup(stateID, true, "Authorization completed successfully.", source) +} + +func (s *ConnectorService) PollGoogleWebOAuthResult(userID, source string, req *PollGoogleWebOAuthResultRequest) (*PollGoogleWebOAuthResultResponse, common.ErrorCode, error) { + source = strings.TrimSpace(source) + if source != "google-drive" && source != "gmail" { + return nil, common.CodeArgumentError, fmt.Errorf("Invalid Google OAuth type.") + } + 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), source) + var result googleWebOAuthResult + 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 &PollGoogleWebOAuthResultResponse{Credentials: result.Credentials}, common.CodeSuccess, nil +} + +func defaultGoogleWebOAuthRedirectURI(source string) string { + if source == "gmail" { + return getenvDefault("GMAIL_WEB_OAUTH_REDIRECT_URI", "http://localhost:9384/api/v1/connectors/gmail/oauth/web/callback") + } + return getenvDefault("GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI", "http://localhost:9384/api/v1/connectors/google-drive/oauth/web/callback") +} + +func getenvDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func webStateCacheKey(flowID, source string) string { + return fmt.Sprintf("%s_web_flow_state:%s", source, flowID) +} + +func webResultCacheKey(flowID, source string) string { + return fmt.Sprintf("%s_web_flow_result:%s", source, flowID) +} + +func loadGoogleCredentials(raw json.RawMessage) (map[string]interface{}, error) { + var credentials map[string]interface{} + if err := json.Unmarshal(raw, &credentials); err == nil && credentials != nil { + return credentials, nil + } + + var rawString string + if err := json.Unmarshal(raw, &rawString); err != nil { + return nil, fmt.Errorf("Invalid Google credentials JSON.") + } + if err := json.Unmarshal([]byte(strings.TrimSpace(rawString)), &credentials); err != nil || credentials == nil { + return nil, fmt.Errorf("Invalid Google credentials JSON.") + } + return credentials, nil +} + +func hasRefreshToken(credentials map[string]interface{}) bool { + value, ok := credentials["refresh_token"] + if !ok || value == nil { + return false + } + if token, ok := value.(string); ok { + return strings.TrimSpace(token) != "" + } + return true +} + +func getGoogleWebClientConfig(credentials map[string]interface{}) (map[string]interface{}, error) { + webSection, ok := credentials["web"].(map[string]interface{}) + if !ok || webSection == nil { + return nil, fmt.Errorf("Google OAuth JSON must include a 'web' client configuration to use browser-based authorization.") + } + return map[string]interface{}{"web": webSection}, nil +} + +func stringValue(value interface{}) string { + if s, ok := value.(string); ok { + return s + } + return "" +} + +func googleOAuthScopesForSource(source string) []string { + if source == "gmail" { + return gmailOAuthScopes + } + return googleDriveOAuthScopes +} + +func newPKCEChallenge() (string, string, error) { + randomBytes := make([]byte, 64) + if _, err := rand.Read(randomBytes); err != nil { + return "", "", fmt.Errorf("failed to generate OAuth code verifier: %w", err) + } + verifier := base64.RawURLEncoding.EncodeToString(randomBytes) + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + return verifier, challenge, nil +} + +func buildGoogleAuthorizationURL(authURI, clientID, redirectURI, state string, scopes []string, codeChallenge string) (string, error) { + parsedURL, err := url.Parse(authURI) + 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("scope", strings.Join(scopes, " ")) + query.Set("access_type", "offline") + query.Set("include_granted_scopes", "true") + query.Set("prompt", "consent") + query.Set("state", state) + query.Set("code_challenge", codeChallenge) + query.Set("code_challenge_method", "S256") + parsedURL.RawQuery = query.Encode() + return parsedURL.String(), nil +} + +func exchangeGoogleWebOAuthCode(clientConfig map[string]interface{}, scopes []string, redirectURI, code, codeVerifier string) (string, error) { + webConfig, ok := clientConfig["web"].(map[string]interface{}) + if !ok || webConfig == nil { + return "", fmt.Errorf("invalid Google OAuth client configuration") + } + + clientID := strings.TrimSpace(stringValue(webConfig["client_id"])) + clientSecret := strings.TrimSpace(stringValue(webConfig["client_secret"])) + tokenURI := strings.TrimSpace(stringValue(webConfig["token_uri"])) + if tokenURI == "" { + tokenURI = googleOAuthTokenURL + } + if clientID == "" || tokenURI == "" { + return "", fmt.Errorf("invalid Google OAuth client configuration") + } + + form := url.Values{} + form.Set("client_id", clientID) + if clientSecret != "" { + form.Set("client_secret", clientSecret) + } + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + form.Set("grant_type", "authorization_code") + if strings.TrimSpace(codeVerifier) != "" { + form.Set("code_verifier", codeVerifier) + } + + ctx, cancel := context.WithTimeout(context.Background(), googleOAuthHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURI, 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, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", err + } + + var token googleOAuthTokenResponse + 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("google token exchange failed: HTTP %d", resp.StatusCode) + } + if token.AccessToken == "" { + return "", fmt.Errorf("google token exchange failed: empty access_token") + } + + expiry := "" + if token.ExpiresIn > 0 { + expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).UTC().Format(time.RFC3339Nano) + } + credentials := googleOAuthCredentials{ + Token: token.AccessToken, + RefreshToken: token.RefreshToken, + TokenURI: tokenURI, + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: scopes, + Expiry: expiry, + } + data, err := json.Marshal(credentials) + if err != nil { + return "", err + } + return string(data), nil +} + +func renderGoogleWebOAuthPopup(flowID string, success bool, message, source string) string { + status := "error" + autoClose := "" + if success { + status = "success" + autoClose = "window.close();" + } + payloadType := fmt.Sprintf("ragflow-%s-oauth", source) + payload, _ := json.Marshal(map[string]string{ + "type": payloadType, + "status": status, + "flowId": flowID, + "message": message, + }) + + title := fmt.Sprintf("%s Authorization", googleOAuthSourceDisplayName(source)) + heading := "Authorization failed" + if success { + heading = "Authorization complete" + } + + return fmt.Sprintf(` + + + + %s + + + +
+

%s

+

%s

+

You can close this window.

+
+ + +`, html.EscapeString(title), html.EscapeString(heading), html.EscapeString(message), string(payload), autoClose) +} + +func googleOAuthSourceDisplayName(source string) string { + if source == "gmail" { + return "Gmail" + } + if source == "google-drive" { + return "Google Drive" + } + return "Google" +} + func (s *ConnectorService) DeleteConnector(connectorID, userID string) (bool, common.ErrorCode, error) { if connectorID == "" { return false, common.CodeDataError, fmt.Errorf("connector_id is required")