diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index dc24df3b3a..6dacb743f3 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -29,6 +29,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot" geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" + gitlabauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gitlab" iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kilo" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi" @@ -54,6 +55,8 @@ const ( codexCallbackPort = 1455 geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com" geminiCLIVersion = "v1internal" + gitLabLoginModeOAuth = "oauth" + gitLabLoginModePAT = "pat" ) type callbackForwarder struct { @@ -999,6 +1002,165 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (s return store.Save(ctx, record) } +func gitLabBaseURLFromRequest(c *gin.Context) string { + if c != nil { + if raw := strings.TrimSpace(c.Query("base_url")); raw != "" { + return gitlabauth.NormalizeBaseURL(raw) + } + } + if raw := strings.TrimSpace(os.Getenv("GITLAB_BASE_URL")); raw != "" { + return gitlabauth.NormalizeBaseURL(raw) + } + return gitlabauth.DefaultBaseURL +} + +func buildGitLabAuthMetadata(baseURL, mode string, tokenResp *gitlabauth.TokenResponse, direct *gitlabauth.DirectAccessResponse) map[string]any { + metadata := map[string]any{ + "type": "gitlab", + "auth_method": strings.TrimSpace(mode), + "base_url": gitlabauth.NormalizeBaseURL(baseURL), + "last_refresh": time.Now().UTC().Format(time.RFC3339), + "refresh_interval_seconds": 240, + } + if tokenResp != nil { + metadata["access_token"] = strings.TrimSpace(tokenResp.AccessToken) + if refreshToken := strings.TrimSpace(tokenResp.RefreshToken); refreshToken != "" { + metadata["refresh_token"] = refreshToken + } + if tokenType := strings.TrimSpace(tokenResp.TokenType); tokenType != "" { + metadata["token_type"] = tokenType + } + if scope := strings.TrimSpace(tokenResp.Scope); scope != "" { + metadata["scope"] = scope + } + if expiry := gitlabauth.TokenExpiry(time.Now(), tokenResp); !expiry.IsZero() { + metadata["oauth_expires_at"] = expiry.Format(time.RFC3339) + } + } + mergeGitLabDirectAccessMetadata(metadata, direct) + return metadata +} + +func mergeGitLabDirectAccessMetadata(metadata map[string]any, direct *gitlabauth.DirectAccessResponse) { + if metadata == nil || direct == nil { + return + } + if base := strings.TrimSpace(direct.BaseURL); base != "" { + metadata["duo_gateway_base_url"] = base + } + if token := strings.TrimSpace(direct.Token); token != "" { + metadata["duo_gateway_token"] = token + } + if direct.ExpiresAt > 0 { + expiry := time.Unix(direct.ExpiresAt, 0).UTC() + metadata["duo_gateway_expires_at"] = expiry.Format(time.RFC3339) + now := time.Now().UTC() + if ttl := expiry.Sub(now); ttl > 0 { + interval := int(ttl.Seconds()) / 2 + switch { + case interval < 60: + interval = 60 + case interval > 240: + interval = 240 + } + metadata["refresh_interval_seconds"] = interval + } + } + if len(direct.Headers) > 0 { + headers := make(map[string]string, len(direct.Headers)) + for key, value := range direct.Headers { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" || value == "" { + continue + } + headers[key] = value + } + if len(headers) > 0 { + metadata["duo_gateway_headers"] = headers + } + } + if direct.ModelDetails != nil { + modelDetails := map[string]any{} + if provider := strings.TrimSpace(direct.ModelDetails.ModelProvider); provider != "" { + modelDetails["model_provider"] = provider + metadata["model_provider"] = provider + } + if model := strings.TrimSpace(direct.ModelDetails.ModelName); model != "" { + modelDetails["model_name"] = model + metadata["model_name"] = model + } + if len(modelDetails) > 0 { + metadata["model_details"] = modelDetails + } + } +} + +func primaryGitLabEmail(user *gitlabauth.User) string { + if user == nil { + return "" + } + if value := strings.TrimSpace(user.Email); value != "" { + return value + } + return strings.TrimSpace(user.PublicEmail) +} + +func gitLabAccountIdentifier(user *gitlabauth.User) string { + if user == nil { + return "user" + } + for _, value := range []string{user.Username, primaryGitLabEmail(user), user.Name} { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "user" +} + +func sanitizeGitLabFileName(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return "user" + } + var builder strings.Builder + lastDash := false + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + builder.WriteRune(r) + lastDash = false + case r >= '0' && r <= '9': + builder.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || r == '.': + builder.WriteRune(r) + lastDash = false + default: + if !lastDash { + builder.WriteRune('-') + lastDash = true + } + } + } + result := strings.Trim(builder.String(), "-") + if result == "" { + return "user" + } + return result +} + +func maskGitLabToken(token string) string { + trimmed := strings.TrimSpace(token) + if trimmed == "" { + return "" + } + if len(trimmed) <= 8 { + return trimmed + } + return trimmed[:4] + "..." + trimmed[len(trimmed)-4:] +} + func (h *Handler) RequestAnthropicToken(c *gin.Context) { ctx := context.Background() ctx = PopulateAuthContext(ctx, c) @@ -1549,6 +1711,263 @@ func (h *Handler) RequestCodexToken(c *gin.Context) { c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) } +func (h *Handler) RequestGitLabToken(c *gin.Context) { + ctx := context.Background() + ctx = PopulateAuthContext(ctx, c) + + fmt.Println("Initializing GitLab Duo authentication...") + + baseURL := gitLabBaseURLFromRequest(c) + clientID := strings.TrimSpace(c.Query("client_id")) + clientSecret := strings.TrimSpace(c.Query("client_secret")) + if clientID == "" { + clientID = strings.TrimSpace(os.Getenv("GITLAB_OAUTH_CLIENT_ID")) + } + if clientSecret == "" { + clientSecret = strings.TrimSpace(os.Getenv("GITLAB_OAUTH_CLIENT_SECRET")) + } + if clientID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "gitlab client_id is required"}) + return + } + + pkceCodes, err := gitlabauth.GeneratePKCECodes() + if err != nil { + log.Errorf("Failed to generate GitLab PKCE codes: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PKCE codes"}) + return + } + + state, err := misc.GenerateRandomState() + if err != nil { + log.Errorf("Failed to generate GitLab state parameter: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state parameter"}) + return + } + + redirectURI := gitlabauth.RedirectURL(gitlabauth.DefaultCallbackPort) + authClient := gitlabauth.NewAuthClient(h.cfg) + authURL, err := authClient.GenerateAuthURL(baseURL, clientID, redirectURI, state, pkceCodes) + if err != nil { + log.Errorf("Failed to generate GitLab authorization URL: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"}) + return + } + + RegisterOAuthSession(state, "gitlab") + + isWebUI := isWebUIRequest(c) + var forwarder *callbackForwarder + if isWebUI { + targetURL, errTarget := h.managementCallbackURL("/gitlab/callback") + if errTarget != nil { + log.WithError(errTarget).Error("failed to compute gitlab callback target") + c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"}) + return + } + var errStart error + if forwarder, errStart = startCallbackForwarder(gitlabauth.DefaultCallbackPort, "gitlab", targetURL); errStart != nil { + log.WithError(errStart).Error("failed to start gitlab callback forwarder") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start callback server"}) + return + } + } + + go func() { + if isWebUI { + defer stopCallbackForwarderInstance(gitlabauth.DefaultCallbackPort, forwarder) + } + + waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-gitlab-%s.oauth", state)) + deadline := time.Now().Add(5 * time.Minute) + var code string + for { + if !IsOAuthSessionPending(state, "gitlab") { + return + } + if time.Now().After(deadline) { + log.Error("gitlab oauth flow timed out") + SetOAuthSessionError(state, "Timeout waiting for OAuth callback") + return + } + if data, errRead := os.ReadFile(waitFile); errRead == nil { + var payload map[string]string + _ = json.Unmarshal(data, &payload) + _ = os.Remove(waitFile) + if errStr := strings.TrimSpace(payload["error"]); errStr != "" { + SetOAuthSessionError(state, errStr) + return + } + if payloadState := strings.TrimSpace(payload["state"]); payloadState != state { + SetOAuthSessionError(state, "State code error") + return + } + code = strings.TrimSpace(payload["code"]) + if code == "" { + SetOAuthSessionError(state, "Authorization code missing") + return + } + break + } + time.Sleep(500 * time.Millisecond) + } + + tokenResp, errExchange := authClient.ExchangeCodeForTokens(ctx, baseURL, clientID, clientSecret, redirectURI, code, pkceCodes.CodeVerifier) + if errExchange != nil { + log.Errorf("Failed to exchange GitLab authorization code: %v", errExchange) + SetOAuthSessionError(state, "Failed to exchange authorization code for tokens") + return + } + + user, errUser := authClient.GetCurrentUser(ctx, baseURL, tokenResp.AccessToken) + if errUser != nil { + log.Errorf("Failed to fetch GitLab user profile: %v", errUser) + SetOAuthSessionError(state, "Failed to fetch account profile") + return + } + + direct, errDirect := authClient.FetchDirectAccess(ctx, baseURL, tokenResp.AccessToken) + if errDirect != nil { + log.Errorf("Failed to fetch GitLab direct access metadata: %v", errDirect) + SetOAuthSessionError(state, "Failed to fetch GitLab Duo access") + return + } + + identifier := gitLabAccountIdentifier(user) + fileName := fmt.Sprintf("gitlab-%s.json", sanitizeGitLabFileName(identifier)) + metadata := buildGitLabAuthMetadata(baseURL, gitLabLoginModeOAuth, tokenResp, direct) + metadata["auth_kind"] = "oauth" + metadata["oauth_client_id"] = clientID + if clientSecret != "" { + metadata["oauth_client_secret"] = clientSecret + } + metadata["username"] = strings.TrimSpace(user.Username) + if email := primaryGitLabEmail(user); email != "" { + metadata["email"] = email + } + metadata["name"] = strings.TrimSpace(user.Name) + + record := &coreauth.Auth{ + ID: fileName, + Provider: "gitlab", + FileName: fileName, + Label: identifier, + Metadata: metadata, + } + savedPath, errSave := h.saveTokenRecord(ctx, record) + if errSave != nil { + log.Errorf("Failed to save GitLab auth record: %v", errSave) + SetOAuthSessionError(state, "Failed to save authentication tokens") + return + } + + fmt.Printf("GitLab Duo authentication successful. Token saved to %s\n", savedPath) + CompleteOAuthSession(state) + CompleteOAuthSessionsByProvider("gitlab") + }() + + c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state}) +} + +func (h *Handler) RequestGitLabPATToken(c *gin.Context) { + ctx := context.Background() + ctx = PopulateAuthContext(ctx, c) + + var payload struct { + BaseURL string `json:"base_url"` + PersonalAccessToken string `json:"personal_access_token"` + Token string `json:"token"` + } + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid body"}) + return + } + + baseURL := gitlabauth.NormalizeBaseURL(strings.TrimSpace(payload.BaseURL)) + if baseURL == "" { + baseURL = gitLabBaseURLFromRequest(nil) + } + pat := strings.TrimSpace(payload.PersonalAccessToken) + if pat == "" { + pat = strings.TrimSpace(payload.Token) + } + if pat == "" { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "personal_access_token is required"}) + return + } + + authClient := gitlabauth.NewAuthClient(h.cfg) + + user, err := authClient.GetCurrentUser(ctx, baseURL, pat) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": err.Error()}) + return + } + patSelf, err := authClient.GetPersonalAccessTokenSelf(ctx, baseURL, pat) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": err.Error()}) + return + } + direct, err := authClient.FetchDirectAccess(ctx, baseURL, pat) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": err.Error()}) + return + } + + identifier := gitLabAccountIdentifier(user) + fileName := fmt.Sprintf("gitlab-%s-pat.json", sanitizeGitLabFileName(identifier)) + metadata := buildGitLabAuthMetadata(baseURL, gitLabLoginModePAT, nil, direct) + metadata["auth_kind"] = "personal_access_token" + metadata["personal_access_token"] = pat + metadata["token_preview"] = maskGitLabToken(pat) + metadata["username"] = strings.TrimSpace(user.Username) + if email := primaryGitLabEmail(user); email != "" { + metadata["email"] = email + } + metadata["name"] = strings.TrimSpace(user.Name) + if patSelf != nil { + if name := strings.TrimSpace(patSelf.Name); name != "" { + metadata["pat_name"] = name + } + if len(patSelf.Scopes) > 0 { + metadata["pat_scopes"] = append([]string(nil), patSelf.Scopes...) + } + } + + record := &coreauth.Auth{ + ID: fileName, + Provider: "gitlab", + FileName: fileName, + Label: identifier + " (PAT)", + Metadata: metadata, + } + + savedPath, err := h.saveTokenRecord(ctx, record) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to save authentication tokens"}) + return + } + + response := gin.H{ + "status": "ok", + "saved_path": savedPath, + "username": strings.TrimSpace(user.Username), + "email": primaryGitLabEmail(user), + "token_label": identifier, + } + if direct != nil && direct.ModelDetails != nil { + if provider := strings.TrimSpace(direct.ModelDetails.ModelProvider); provider != "" { + response["model_provider"] = provider + } + if model := strings.TrimSpace(direct.ModelDetails.ModelName); model != "" { + response["model_name"] = model + } + } + + fmt.Printf("GitLab Duo PAT authentication successful. Token saved to %s\n", savedPath) + c.JSON(http.StatusOK, response) +} + func (h *Handler) RequestAntigravityToken(c *gin.Context) { ctx := context.Background() ctx = PopulateAuthContext(ctx, c) diff --git a/internal/api/handlers/management/auth_files_gitlab_test.go b/internal/api/handlers/management/auth_files_gitlab_test.go new file mode 100644 index 0000000000..31fca89695 --- /dev/null +++ b/internal/api/handlers/management/auth_files_gitlab_test.go @@ -0,0 +1,164 @@ +package management + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func TestRequestGitLabPATToken_SavesAuthRecord(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer glpat-test-token" { + t.Fatalf("authorization header = %q, want Bearer glpat-test-token", got) + } + + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v4/user": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 42, + "username": "gitlab-user", + "name": "GitLab User", + "email": "gitlab@example.com", + }) + case "/api/v4/personal_access_tokens/self": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 7, + "name": "management-center", + "scopes": []string{"api", "read_user"}, + "user_id": 42, + }) + case "/api/v4/code_suggestions/direct_access": + _ = json.NewEncoder(w).Encode(map[string]any{ + "base_url": "https://cloud.gitlab.example.com", + "token": "gateway-token", + "expires_at": 1893456000, + "headers": map[string]string{ + "X-Gitlab-Realm": "saas", + }, + "model_details": map[string]any{ + "model_provider": "anthropic", + "model_name": "claude-sonnet-4-5", + }, + }) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + store := &memoryAuthStore{} + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, coreauth.NewManager(nil, nil, nil)) + h.tokenStore = store + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v0/management/gitlab-auth-url", strings.NewReader(`{"base_url":"`+upstream.URL+`","personal_access_token":"glpat-test-token"}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + + h.RequestGitLabPATToken(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if got := resp["status"]; got != "ok" { + t.Fatalf("status = %#v, want ok", got) + } + if got := resp["model_provider"]; got != "anthropic" { + t.Fatalf("model_provider = %#v, want anthropic", got) + } + if got := resp["model_name"]; got != "claude-sonnet-4-5" { + t.Fatalf("model_name = %#v, want claude-sonnet-4-5", got) + } + + store.mu.Lock() + defer store.mu.Unlock() + if len(store.items) != 1 { + t.Fatalf("expected 1 saved auth record, got %d", len(store.items)) + } + var saved *coreauth.Auth + for _, item := range store.items { + saved = item + } + if saved == nil { + t.Fatal("expected saved auth record") + } + if saved.Provider != "gitlab" { + t.Fatalf("provider = %q, want gitlab", saved.Provider) + } + if got := saved.Metadata["auth_kind"]; got != "personal_access_token" { + t.Fatalf("auth_kind = %#v, want personal_access_token", got) + } + if got := saved.Metadata["model_provider"]; got != "anthropic" { + t.Fatalf("saved model_provider = %#v, want anthropic", got) + } + if got := saved.Metadata["duo_gateway_token"]; got != "gateway-token" { + t.Fatalf("saved duo_gateway_token = %#v, want gateway-token", got) + } +} + +func TestPostOAuthCallback_GitLabWritesPendingCallbackFile(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + authDir := t.TempDir() + state := "gitlab-state-123" + RegisterOAuthSession(state, "gitlab") + t.Cleanup(func() { CompleteOAuthSession(state) }) + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, coreauth.NewManager(nil, nil, nil)) + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v0/management/oauth-callback", strings.NewReader(`{"provider":"gitlab","redirect_url":"http://localhost:17171/auth/callback?code=test-code&state=`+state+`"}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + + h.PostOAuthCallback(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + filePath := filepath.Join(authDir, ".oauth-gitlab-"+state+".oauth") + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("read callback file: %v", err) + } + + var payload map[string]string + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("decode callback payload: %v", err) + } + if got := payload["code"]; got != "test-code" { + t.Fatalf("callback code = %q, want test-code", got) + } + if got := payload["state"]; got != state { + t.Fatalf("callback state = %q, want %q", got, state) + } +} + +func TestNormalizeOAuthProvider_GitLab(t *testing.T) { + provider, err := NormalizeOAuthProvider("gitlab") + if err != nil { + t.Fatalf("NormalizeOAuthProvider returned error: %v", err) + } + if provider != "gitlab" { + t.Fatalf("provider = %q, want gitlab", provider) + } +} diff --git a/internal/api/handlers/management/oauth_sessions.go b/internal/api/handlers/management/oauth_sessions.go index bc882e990e..dfcdae8870 100644 --- a/internal/api/handlers/management/oauth_sessions.go +++ b/internal/api/handlers/management/oauth_sessions.go @@ -228,6 +228,8 @@ func NormalizeOAuthProvider(provider string) (string, error) { return "anthropic", nil case "codex", "openai": return "codex", nil + case "gitlab": + return "gitlab", nil case "gemini", "google": return "gemini", nil case "iflow", "i-flow": diff --git a/internal/api/server.go b/internal/api/server.go index 5c3274c9fc..2a63c97cee 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -403,6 +403,20 @@ func (s *Server) setupRoutes() { c.String(http.StatusOK, oauthCallbackSuccessHTML) }) + s.engine.GET("/gitlab/callback", func(c *gin.Context) { + code := c.Query("code") + state := c.Query("state") + errStr := c.Query("error") + if errStr == "" { + errStr = c.Query("error_description") + } + if state != "" { + _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "gitlab", state, code, errStr) + } + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, oauthCallbackSuccessHTML) + }) + s.engine.GET("/google/callback", func(c *gin.Context) { code := c.Query("code") state := c.Query("state") @@ -658,6 +672,8 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken) mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken) + mgmt.GET("/gitlab-auth-url", s.mgmt.RequestGitLabToken) + mgmt.POST("/gitlab-auth-url", s.mgmt.RequestGitLabPATToken) mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken) mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken) mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)