diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index c30ba6c4..a960b586 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -29,6 +29,7 @@ import ( xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer" @@ -63,6 +64,7 @@ var ( callbackForwarders = make(map[int]*callbackForwarder) errAuthFileMustBeJSON = errors.New("auth file must be .json") errAuthFileNotFound = errors.New("auth file not found") + errPluginVirtualAuth = errors.New("plugin virtual auth cannot be modified directly; edit or delete the source auth file") newCodexOAuthService = func(cfg *config.Config) codexOAuthService { return codex.NewCodexAuth(cfg) } ) @@ -1031,6 +1033,9 @@ func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name)) targetID := "" if targetAuth := h.findAuthForDelete(name); targetAuth != nil { + if !isPluginVirtualSourceDelete(name, targetAuth) { + return filepath.Base(name), http.StatusConflict, errPluginVirtualAuth + } targetID = strings.TrimSpace(targetAuth.ID) if path := strings.TrimSpace(authAttribute(targetAuth, "path")); path != "" { targetPath = path @@ -1050,14 +1055,24 @@ func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil { return filepath.Base(name), http.StatusInternalServerError, errDeleteRecord } - if targetID != "" { - h.removeAuth(ctx, targetID) - } else { - h.removeAuth(ctx, targetPath) - } + h.removeAuthsForPath(ctx, targetPath, targetID) return filepath.Base(name), http.StatusOK, nil } +func isPluginVirtualSourceDelete(name string, auth *coreauth.Auth) bool { + if !coreauth.IsPluginVirtualAuth(auth) { + return true + } + sourcePath := strings.TrimSpace(authAttribute(auth, coreauth.AttributeVirtualSource)) + if sourcePath == "" { + sourcePath = strings.TrimSpace(authAttribute(auth, "path")) + } + if sourcePath == "" { + return false + } + return strings.EqualFold(filepath.Base(strings.TrimSpace(name)), filepath.Base(sourcePath)) +} + func (h *Handler) findAuthForDelete(name string) *coreauth.Auth { if h == nil || h.authManager == nil { return nil @@ -1265,6 +1280,10 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) return } + if coreauth.IsPluginVirtualAuth(targetAuth) { + c.JSON(http.StatusConflict, gin.H{"error": errPluginVirtualAuth.Error()}) + return + } if coreauth.IsConfigAPIKeyAuth(targetAuth) { h.mu.Lock() @@ -1368,6 +1387,10 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) return } + if coreauth.IsPluginVirtualAuth(targetAuth) { + c.JSON(http.StatusConflict, gin.H{"error": errPluginVirtualAuth.Error()}) + return + } changed := false touchedRoots := make(map[string]struct{}, len(req)) @@ -1697,6 +1720,53 @@ func (h *Handler) removeAuth(ctx context.Context, id string) { h.authManager.Remove(ctx, authID) } +func (h *Handler) removeAuthsForPath(ctx context.Context, path string, fallbackID string) { + if h == nil || h.authManager == nil { + return + } + removed := false + for _, auth := range h.authManager.List() { + if auth == nil { + continue + } + if sameAuthFilePath(authAttribute(auth, "path"), path) || sameAuthFilePath(authAttribute(auth, coreauth.AttributeVirtualSource), path) { + h.removeAuth(ctx, auth.ID) + removed = true + } + } + if removed { + return + } + if strings.TrimSpace(fallbackID) != "" { + h.removeAuth(ctx, fallbackID) + return + } + h.removeAuth(ctx, path) +} + +func sameAuthFilePath(left, right string) bool { + left = cleanAuthFilePath(left) + right = cleanAuthFilePath(right) + if left == "" || right == "" { + return false + } + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func cleanAuthFilePath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + if abs, errAbs := filepath.Abs(path); errAbs == nil && strings.TrimSpace(abs) != "" { + path = abs + } + return filepath.Clean(path) +} + func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error { if strings.TrimSpace(path) == "" { return fmt.Errorf("auth path is empty") @@ -1740,7 +1810,7 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (s } savedPath, errSave := store.Save(ctx, record) if errSave != nil { - return "", errSave + return savedPath, errSave } if h.postAuthPersistHook != nil { if errHook := h.postAuthPersistHook(ctx, record); errHook != nil { @@ -2506,13 +2576,13 @@ func (h *Handler) GetAuthStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "error", "error": message}) return case pluginapi.AuthLoginStatusSuccess: - record := host.AuthDataToCoreAuth(resp.Auth, "", "") - if record == nil { + records := pluginLoginPollAuths(host, resp) + if len(records) == 0 { SetOAuthSessionError(state, "Authentication failed") c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Authentication failed"}) return } - if _, errSave := h.saveTokenRecord(ctx, record); errSave != nil { + if errSave := h.savePluginLoginRecords(ctx, records); errSave != nil { log.WithError(errSave).WithField("provider", provider).Error("failed to save plugin auth tokens") SetOAuthSessionError(state, "Failed to save authentication tokens") c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Failed to save authentication tokens"}) @@ -2530,6 +2600,53 @@ func (h *Handler) GetAuthStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "wait"}) } +func pluginLoginPollAuths(host *pluginhost.Host, resp pluginapi.AuthLoginPollResponse) []*coreauth.Auth { + if host == nil { + return nil + } + authDatas := resp.Auths + if len(authDatas) == 0 { + authDatas = []pluginapi.AuthData{resp.Auth} + } + records := make([]*coreauth.Auth, 0, len(authDatas)) + for _, authData := range authDatas { + record := host.AuthDataToCoreAuth(authData, "", "") + if record == nil { + return nil + } + records = append(records, record) + } + return records +} + +func (h *Handler) savePluginLoginRecords(ctx context.Context, records []*coreauth.Auth) error { + savedPaths := make([]string, 0, len(records)) + for _, record := range records { + savedPath, errSave := h.saveTokenRecord(ctx, record) + if strings.TrimSpace(savedPath) != "" { + savedPaths = append(savedPaths, savedPath) + } + if errSave != nil { + h.rollbackSavedTokenRecords(ctx, savedPaths) + return errSave + } + } + return nil +} + +func (h *Handler) rollbackSavedTokenRecords(ctx context.Context, savedPaths []string) { + for i := len(savedPaths) - 1; i >= 0; i-- { + path := strings.TrimSpace(savedPaths[i]) + if path == "" { + continue + } + if errDelete := h.deleteTokenRecord(ctx, path); errDelete != nil { + log.WithError(errDelete).WithField("path", path).Warn("failed to roll back plugin auth token") + } + h.removeAuthsForPath(ctx, path, path) + } +} + // PopulateAuthContext extracts request info and adds it to the context func PopulateAuthContext(ctx context.Context, c *gin.Context) context.Context { info := &coreauth.RequestInfo{ diff --git a/internal/api/handlers/management/auth_files_plugin_oauth_test.go b/internal/api/handlers/management/auth_files_plugin_oauth_test.go new file mode 100644 index 00000000..29acc762 --- /dev/null +++ b/internal/api/handlers/management/auth_files_plugin_oauth_test.go @@ -0,0 +1,213 @@ +package management + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +func TestPluginLoginPollAuthsExpandsMultipleAuths(t *testing.T) { + host := pluginhost.New() + resp := pluginapi.AuthLoginPollResponse{ + Status: pluginapi.AuthLoginStatusSuccess, + Auths: []pluginapi.AuthData{ + { + Provider: "gemini-cli", + ID: "geminicli.json", + FileName: "geminicli.json", + StorageJSON: []byte(`{"type":"gemini-cli"}`), + }, + { + Provider: "gemini-cli", + ID: "geminicli-project-a.json", + FileName: "geminicli-project-a.json", + StorageJSON: []byte(`{"type":"gemini-cli","project_id":"project-a"}`), + Metadata: map[string]any{"project_id": "project-a"}, + }, + }, + } + + records := pluginLoginPollAuths(host, resp) + if len(records) != 2 { + t.Fatalf("pluginLoginPollAuths() len = %d, want two records", len(records)) + } + if records[0].ID != "geminicli.json" || records[1].ID != "geminicli-project-a.json" { + t.Fatalf("records = %#v, want both plugin auths", records) + } + if gotProject := records[1].Metadata["project_id"]; gotProject != "project-a" { + t.Fatalf("project_id = %#v, want project-a", gotProject) + } +} + +func TestSavePluginLoginRecordsRollsBackSavedAuthsOnFailure(t *testing.T) { + store := &pluginLoginRollbackStore{failAt: 2} + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil) + h.tokenStore = store + + records := []*coreauth.Auth{ + { + ID: "geminicli.json", + FileName: "geminicli.json", + Provider: "gemini-cli", + Metadata: map[string]any{"type": "gemini-cli"}, + }, + { + ID: "geminicli-project-a.json", + FileName: "geminicli-project-a.json", + Provider: "gemini-cli", + Metadata: map[string]any{"type": "gemini-cli", "project_id": "project-a"}, + }, + } + + errSave := h.savePluginLoginRecords(context.Background(), records) + if errSave == nil { + t.Fatal("savePluginLoginRecords() error = nil, want rollback-triggering error") + } + if len(store.saved) != 2 { + t.Fatalf("saved len = %d, want two attempted saves", len(store.saved)) + } + if !store.deleted["geminicli.json"] || !store.deleted["geminicli-project-a.json"] { + t.Fatalf("deleted = %#v, want both saved auths rolled back", store.deleted) + } +} + +func TestPatchPluginVirtualAuthStatusReturnsConflict(t *testing.T) { + manager := coreauth.NewManager(nil, nil, nil) + auth := pluginVirtualAuthForTest(t.TempDir(), "source.json", "auth-1") + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("register virtual auth: %v", errRegister) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/status", strings.NewReader(`{"name":"auth-1","disabled":true}`)) + req.Header.Set("Content-Type", "application/json") + ctx.Request = req + + h.PatchAuthFileStatus(ctx) + + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusConflict, rec.Body.String()) + } +} + +func TestPatchPluginVirtualAuthFieldsReturnsConflict(t *testing.T) { + manager := coreauth.NewManager(nil, nil, nil) + auth := pluginVirtualAuthForTest(t.TempDir(), "source.json", "auth-1") + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("register virtual auth: %v", errRegister) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(`{"name":"auth-1","note":"hello"}`)) + req.Header.Set("Content-Type", "application/json") + ctx.Request = req + + h.PatchAuthFileFields(ctx) + + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusConflict, rec.Body.String()) + } +} + +func TestDeletePluginVirtualSourceRemovesExpandedRuntimeAuths(t *testing.T) { + authDir := t.TempDir() + fileName := "source.json" + filePath := filepath.Join(authDir, fileName) + if errWrite := os.WriteFile(filePath, []byte(`{"type":"gemini-cli"}`), 0o600); errWrite != nil { + t.Fatalf("write source auth file: %v", errWrite) + } + + manager := coreauth.NewManager(nil, nil, nil) + for _, id := range []string{"auth-1", "auth-2"} { + auth := pluginVirtualAuthForTest(authDir, fileName, id) + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("register virtual auth %s: %v", id, errRegister) + } + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager) + h.tokenStore = &memoryAuthStore{} + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil) + ctx.Request = req + + h.DeleteAuthFile(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if _, errStat := os.Stat(filePath); !os.IsNotExist(errStat) { + t.Fatalf("expected source auth file to be removed, stat err: %v", errStat) + } + for _, id := range []string{"auth-1", "auth-2"} { + if _, ok := manager.GetByID(id); ok { + t.Fatalf("expected virtual auth %s to be removed", id) + } + } +} + +func pluginVirtualAuthForTest(authDir, fileName, id string) *coreauth.Auth { + filePath := filepath.Join(authDir, fileName) + auth := &coreauth.Auth{ + ID: id, + FileName: fileName, + Provider: "gemini-cli", + Attributes: map[string]string{ + "path": filePath, + }, + Metadata: map[string]any{ + "type": "gemini-cli", + }, + } + coreauth.MarkPluginVirtualAuth(auth, filePath, 0) + return auth +} + +type pluginLoginRollbackStore struct { + failAt int + saved []string + deleted map[string]bool +} + +func (s *pluginLoginRollbackStore) List(context.Context) ([]*coreauth.Auth, error) { + return nil, nil +} + +func (s *pluginLoginRollbackStore) Save(_ context.Context, auth *coreauth.Auth) (string, error) { + path := strings.TrimSpace(auth.FileName) + if path == "" { + path = strings.TrimSpace(auth.ID) + } + s.saved = append(s.saved, path) + if len(s.saved) == s.failAt { + return path, errors.New("save failed after write") + } + return path, nil +} + +func (s *pluginLoginRollbackStore) Delete(_ context.Context, id string) error { + if s.deleted == nil { + s.deleted = make(map[string]bool) + } + s.deleted[id] = true + return nil +} + +func (s *pluginLoginRollbackStore) SetBaseDir(string) {} diff --git a/internal/api/handlers/management/oauth_callback.go b/internal/api/handlers/management/oauth_callback.go index 251f999e..832462db 100644 --- a/internal/api/handlers/management/oauth_callback.go +++ b/internal/api/handlers/management/oauth_callback.go @@ -25,14 +25,26 @@ func (h *Handler) PostOAuthCallback(c *gin.Context) { } var req oauthCallbackRequest - if err := c.ShouldBindJSON(&req); err != nil { + if errBindJSON := c.ShouldBindJSON(&req); errBindJSON != nil { c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid body"}) return } + h.handleOAuthCallback(c, req) +} - canonicalProvider, err := NormalizeOAuthProvider(req.Provider) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "unsupported provider"}) +func (h *Handler) GetOAuthCallback(c *gin.Context) { + req := oauthCallbackRequest{ + Provider: strings.TrimSpace(c.Query("provider")), + Code: strings.TrimSpace(c.Query("code")), + State: strings.TrimSpace(c.Query("state")), + Error: firstNonEmpty(c.Query("error"), c.Query("error_description")), + } + h.handleOAuthCallback(c, req) +} + +func (h *Handler) handleOAuthCallback(c *gin.Context, req oauthCallbackRequest) { + if h == nil || h.cfg == nil { + c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "handler not initialized"}) return } @@ -74,11 +86,26 @@ func (h *Handler) PostOAuthCallback(c *gin.Context) { return } - sessionProvider, sessionStatus, ok := GetOAuthSession(state) + sessionProvider, sessionStatus, isPlugin, _, ok := GetOAuthSessionDetails(state) if !ok { c.JSON(http.StatusNotFound, gin.H{"status": "error", "error": "unknown or expired state"}) return } + provider := strings.TrimSpace(req.Provider) + if provider == "" { + provider = sessionProvider + } + var canonicalProvider string + var errNormalize error + if isPlugin { + canonicalProvider, errNormalize = NormalizePluginOAuthCallbackProvider(provider) + } else { + canonicalProvider, errNormalize = NormalizeOAuthCallbackProvider(provider) + } + if errNormalize != nil { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "unsupported provider"}) + return + } if sessionStatus != "" { c.JSON(http.StatusConflict, gin.H{"status": "error", "error": sessionStatus}) return @@ -105,3 +132,13 @@ func (h *Handler) PostOAuthCallback(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } + +func firstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/api/handlers/management/oauth_callback_test.go b/internal/api/handlers/management/oauth_callback_test.go index 065f89f0..832423bb 100644 --- a/internal/api/handlers/management/oauth_callback_test.go +++ b/internal/api/handlers/management/oauth_callback_test.go @@ -50,6 +50,72 @@ func TestPostOAuthCallbackCreatesMissingAuthDir(t *testing.T) { } } +func TestGetOAuthCallbackWritesPluginProviderCallback(t *testing.T) { + authDir := filepath.Join(t.TempDir(), "missing-auth") + state := "test-geminicli-state" + if errRegister := RegisterPluginOAuthSession(state, "gemini-cli", nil); errRegister != nil { + t.Fatalf("register plugin oauth session: %v", errRegister) + } + defer CompleteOAuthSession(state) + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil) + router := gin.New() + router.GET("/v0/management/oauth-callback", h.GetOAuthCallback) + + req := httptest.NewRequest(http.MethodGet, "/v0/management/oauth-callback?state="+state+"&code=test-code", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, w.Code, w.Body.String()) + } + + callbackPath := filepath.Join(authDir, ".oauth-gemini-cli-"+state+".oauth") + data, errRead := os.ReadFile(callbackPath) + if errRead != nil { + t.Fatalf("expected callback file to be written: %v", errRead) + } + + var payload oauthCallbackFilePayload + if errUnmarshal := json.Unmarshal(data, &payload); errUnmarshal != nil { + t.Fatalf("failed to decode callback payload: %v", errUnmarshal) + } + if payload.State != state || payload.Code != "test-code" || payload.Error != "" { + t.Fatalf("unexpected callback payload: %+v", payload) + } +} + +func TestGetOAuthCallbackDoesNotAliasPluginProvider(t *testing.T) { + authDir := filepath.Join(t.TempDir(), "missing-auth") + state := "test-openai-plugin-state" + if errRegister := RegisterPluginOAuthSession(state, "openai", nil); errRegister != nil { + t.Fatalf("register plugin oauth session: %v", errRegister) + } + defer CompleteOAuthSession(state) + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil) + router := gin.New() + router.GET("/v0/management/oauth-callback", h.GetOAuthCallback) + + req := httptest.NewRequest(http.MethodGet, "/v0/management/oauth-callback?state="+state+"&code=test-code", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, w.Code, w.Body.String()) + } + + callbackPath := filepath.Join(authDir, ".oauth-openai-"+state+".oauth") + if _, errRead := os.ReadFile(callbackPath); errRead != nil { + t.Fatalf("expected plugin callback provider to stay openai: %v", errRead) + } + if _, errRead := os.ReadFile(filepath.Join(authDir, ".oauth-codex-"+state+".oauth")); errRead == nil { + t.Fatal("unexpected codex callback file for openai plugin provider") + } +} + func TestWriteOAuthCallbackFileForPendingSessionCreatesMissingAuthDirForCallbackProviders(t *testing.T) { providers := []string{"anthropic", "codex", "gemini", "antigravity", "xai"} for _, provider := range providers { diff --git a/internal/api/handlers/management/oauth_sessions.go b/internal/api/handlers/management/oauth_sessions.go index a6a6202d..078c51c6 100644 --- a/internal/api/handlers/management/oauth_sessions.go +++ b/internal/api/handlers/management/oauth_sessions.go @@ -200,9 +200,6 @@ func (s *oauthSessionStore) IsPending(state, provider string) bool { if session.Status != "" { return false } - if session.Source == oauthSessionSourcePlugin { - return false - } if provider == "" { return true } @@ -317,6 +314,38 @@ func NormalizeOAuthProvider(provider string) (string, error) { } } +func NormalizeOAuthCallbackProvider(provider string) (string, error) { + if normalized, errNormalize := NormalizeOAuthProvider(provider); errNormalize == nil { + return normalized, nil + } + return NormalizePluginOAuthCallbackProvider(provider) +} + +func NormalizePluginOAuthCallbackProvider(provider string) (string, error) { + trimmed := strings.ToLower(strings.TrimSpace(provider)) + if trimmed == "" { + return "", errUnsupportedOAuthFlow + } + for _, r := range trimmed { + switch { + case r >= 'a' && r <= 'z': + case r >= '0' && r <= '9': + case r == '-': + default: + return "", errUnsupportedOAuthFlow + } + } + return trimmed, nil +} + +func normalizeOAuthCallbackProviderForPendingSession(provider, state string) (string, error) { + session, ok := oauthSessions.Get(state) + if ok && session.Source == oauthSessionSourcePlugin { + return NormalizePluginOAuthCallbackProvider(provider) + } + return NormalizeOAuthCallbackProvider(provider) +} + type oauthCallbackFilePayload struct { Code string `json:"code"` State string `json:"state"` @@ -324,12 +353,20 @@ type oauthCallbackFilePayload struct { } func WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage string) (string, error) { + canonicalProvider, err := NormalizeOAuthCallbackProvider(provider) + if err != nil { + return "", err + } + return writeOAuthCallbackFile(authDir, canonicalProvider, state, code, errorMessage) +} + +func writeOAuthCallbackFile(authDir, canonicalProvider, state, code, errorMessage string) (string, error) { if strings.TrimSpace(authDir) == "" { return "", fmt.Errorf("auth dir is empty") } - canonicalProvider, err := NormalizeOAuthProvider(provider) - if err != nil { - return "", err + canonicalProvider = strings.TrimSpace(canonicalProvider) + if canonicalProvider == "" { + return "", errUnsupportedOAuthFlow } if err := ValidateOAuthState(state); err != nil { return "", err @@ -356,12 +393,12 @@ func WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage string) } func WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage string) (string, error) { - canonicalProvider, err := NormalizeOAuthProvider(provider) + canonicalProvider, err := normalizeOAuthCallbackProviderForPendingSession(provider, state) if err != nil { return "", err } if !IsOAuthSessionPending(state, canonicalProvider) { return "", errOAuthSessionNotPending } - return WriteOAuthCallbackFile(authDir, canonicalProvider, state, code, errorMessage) + return writeOAuthCallbackFile(authDir, canonicalProvider, state, code, errorMessage) } diff --git a/internal/api/server.go b/internal/api/server.go index bddd963b..01875fd6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -595,6 +595,9 @@ func (s *Server) registerManagementRoutes() { log.Info("management routes registered after secret key configuration") + s.engine.POST("/v0/management/oauth-callback", s.managementAvailabilityMiddleware(), s.mgmt.PostOAuthCallback) + s.engine.GET("/v0/management/oauth-callback", s.managementAvailabilityMiddleware(), s.mgmt.GetOAuthCallback) + mgmt := s.engine.Group("/v0/management") mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware()) { @@ -731,7 +734,6 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken) mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken) mgmt.GET("/xai-auth-url", s.mgmt.RequestXAIToken) - mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback) mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus) } } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 80051e46..011c1f1e 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -11,6 +11,7 @@ import ( "time" gin "github.com/gin-gonic/gin" + managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management" proxyconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" @@ -122,6 +123,30 @@ func TestManagementResponseExposesPluginSupportHeaderForCORS(t *testing.T) { } } +func TestOAuthCallbackRouteSkipsManagementKeyMiddleware(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "test-management-key") + + server := newTestServer(t) + state := "server-plugin-oauth-state" + if errRegister := managementHandlers.RegisterPluginOAuthSession(state, "gemini-cli", nil); errRegister != nil { + t.Fatalf("register plugin oauth session: %v", errRegister) + } + defer managementHandlers.CompleteOAuthSession(state) + + req := httptest.NewRequest(http.MethodGet, "/v0/management/oauth-callback?state="+state+"&code=test-code", nil) + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + + callbackPath := filepath.Join(server.cfg.AuthDir, ".oauth-gemini-cli-"+state+".oauth") + if _, errRead := os.ReadFile(callbackPath); errRead != nil { + t.Fatalf("expected callback file to be written without management key: %v", errRead) + } +} + func TestNewServerWithPluginHostInjectsHandlerInterceptors(t *testing.T) { host := pluginhost.New() server := newTestServerWithOptions(t, WithPluginHost(host)) diff --git a/internal/pluginhost/auth_provider.go b/internal/pluginhost/auth_provider.go index 6439f690..32cf6e24 100644 --- a/internal/pluginhost/auth_provider.go +++ b/internal/pluginhost/auth_provider.go @@ -161,6 +161,14 @@ func (h *Host) callAuthProviderIdentifier(pluginID string, provider pluginapi.Au } func (h *Host) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) { + auths, handled, errParseAuths := h.ParseAuths(ctx, req) + if errParseAuths != nil || !handled || len(auths) == 0 { + return nil, handled, errParseAuths + } + return auths[0], true, nil +} + +func (h *Host) ParseAuths(ctx context.Context, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) { if h == nil { return nil, false, nil } @@ -169,21 +177,29 @@ func (h *Host) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (* if record == nil { return nil, false, nil } - return h.callParseAuth(ctx, *record, req) + return h.callParseAuths(ctx, *record, req) } for _, record := range h.Snapshot().records { if record.plugin.Capabilities.AuthProvider == nil || h.isPluginFused(record.id) { continue } - auth, handled, errParse := h.callParseAuth(ctx, record, req) + auths, handled, errParse := h.callParseAuths(ctx, record, req) if errParse != nil || handled { - return auth, handled, errParse + return auths, handled, errParse } } return nil, false, nil } func (h *Host) callParseAuth(ctx context.Context, record capabilityRecord, req pluginapi.AuthParseRequest) (auth *coreauth.Auth, handled bool, err error) { + auths, handled, errParseAuths := h.callParseAuths(ctx, record, req) + if errParseAuths != nil || !handled || len(auths) == 0 { + return nil, handled, errParseAuths + } + return auths[0], true, nil +} + +func (h *Host) callParseAuths(ctx context.Context, record capabilityRecord, req pluginapi.AuthParseRequest) (auths []*coreauth.Auth, handled bool, err error) { provider := record.plugin.Capabilities.AuthProvider if h == nil || provider == nil || h.isPluginFused(record.id) { return nil, false, nil @@ -191,7 +207,7 @@ func (h *Host) callParseAuth(ctx context.Context, record capabilityRecord, req p defer func() { if recovered := recover(); recovered != nil { h.fusePlugin(record.id, "AuthProvider.ParseAuth", recovered) - auth = nil + auths = nil handled = false err = fmt.Errorf("auth provider panic: %v", recovered) } @@ -211,21 +227,32 @@ func (h *Host) callParseAuth(ctx context.Context, record capabilityRecord, req p if !resp.Handled { return nil, false, nil } - data := resp.Auth - if strings.TrimSpace(data.Provider) == "" { - data.Provider = req.Provider - } - if strings.TrimSpace(data.Provider) == "" { - data.Provider = normalizeProviderID(provider.Identifier()) - } - if normalizeProviderID(data.Provider) == "" { - return nil, true, fmt.Errorf("auth provider %s returned auth without provider", record.id) + datas := pluginAuthParseResponseAuths(resp) + auths = make([]*coreauth.Auth, 0, len(datas)) + for _, data := range datas { + if strings.TrimSpace(data.Provider) == "" { + data.Provider = req.Provider + } + if strings.TrimSpace(data.Provider) == "" { + data.Provider = normalizeProviderID(provider.Identifier()) + } + if normalizeProviderID(data.Provider) == "" { + return nil, true, fmt.Errorf("auth provider %s returned auth without provider", record.id) + } + parsed := h.AuthDataToCoreAuth(data, req.Path, req.FileName) + if parsed == nil { + return nil, true, fmt.Errorf("auth provider %s returned invalid auth data", record.id) + } + auths = append(auths, parsed) } - parsed := h.AuthDataToCoreAuth(data, req.Path, req.FileName) - if parsed == nil { - return nil, true, fmt.Errorf("auth provider %s returned invalid auth data", record.id) + return auths, true, nil +} + +func pluginAuthParseResponseAuths(resp pluginapi.AuthParseResponse) []pluginapi.AuthData { + if len(resp.Auths) > 0 { + return append([]pluginapi.AuthData(nil), resp.Auths...) } - return parsed, true, nil + return []pluginapi.AuthData{resp.Auth} } func (h *Host) StartLogin(ctx context.Context, provider string, baseURL string) (pluginapi.AuthLoginStartResponse, bool, error) { diff --git a/internal/pluginhost/auth_provider_test.go b/internal/pluginhost/auth_provider_test.go index 717d340b..f6d01e9b 100644 --- a/internal/pluginhost/auth_provider_test.go +++ b/internal/pluginhost/auth_provider_test.go @@ -117,6 +117,51 @@ func TestParseAuthDefaultsProviderFromAuthProviderIdentifier(t *testing.T) { } } +func TestParseAuthsExpandsMultiplePluginAuths(t *testing.T) { + host := newHostWithRecords(capabilityRecord{ + id: "geminicli", + plugin: pluginapi.Plugin{ + Capabilities: pluginapi.Capabilities{ + AuthProvider: fakeAuthProvider{ + identifier: "gemini-cli", + parseAuth: func(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) { + return pluginapi.AuthParseResponse{ + Handled: true, + Auths: []pluginapi.AuthData{ + { + Provider: "gemini-cli", + ID: "user.json", + FileName: "user.json", + StorageJSON: []byte(`{"type":"gemini-cli"}`), + }, + { + Provider: "gemini-cli", + ID: "user-project-a.json", + FileName: "user-project-a.json", + StorageJSON: []byte(`{"type":"gemini-cli","project_id":"project-a"}`), + Metadata: map[string]any{"project_id": "project-a"}, + }, + }, + }, nil + }, + }, + }, + }, + }) + host.runtimeConfig = &config.Config{AuthDir: t.TempDir()} + + auths, handled, errParse := host.ParseAuths(context.Background(), pluginapi.AuthParseRequest{Provider: "gemini-cli"}) + if errParse != nil { + t.Fatalf("ParseAuths() error = %v", errParse) + } + if !handled || len(auths) != 2 { + t.Fatalf("ParseAuths() handled=%t len=%d, want two auths", handled, len(auths)) + } + if auths[1].Provider != "gemini-cli" || auths[1].Metadata["project_id"] != "project-a" { + t.Fatalf("second auth = %#v, want project-a virtual auth", auths[1]) + } +} + func TestStartLoginPassesProviderBaseURLHostAndHTTPClient(t *testing.T) { authDir := t.TempDir() expiresAt := time.Now().Add(time.Minute).UTC() diff --git a/internal/watcher/synthesizer/context.go b/internal/watcher/synthesizer/context.go index 4572f8bb..dce219c4 100644 --- a/internal/watcher/synthesizer/context.go +++ b/internal/watcher/synthesizer/context.go @@ -14,6 +14,12 @@ type PluginAuthParser interface { ParseAuth(context.Context, pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) } +// PluginMultiAuthParser expands one auth JSON payload into multiple plugin auth records. +// Returning handled=true with an empty slice means the plugin intentionally suppresses built-in parsing. +type PluginMultiAuthParser interface { + ParseAuths(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) +} + // SynthesisContext provides the context needed for auth synthesis. type SynthesisContext struct { // Config is the current configuration diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index f4cd1dea..04d50f0a 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -81,26 +81,38 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] return nil } if ctx.PluginAuthParser != nil { - auth, handled, errParse := ctx.PluginAuthParser.ParseAuth(context.Background(), pluginapi.AuthParseRequest{ + auths, handled, errParse := parsePluginFileAuths(ctx.PluginAuthParser, pluginapi.AuthParseRequest{ Provider: provider, Path: fullPath, FileName: filepath.Base(fullPath), RawJSON: data, }) - if errParse == nil && handled && auth != nil { - auth.CreatedAt = now - auth.UpdatedAt = now - if auth.Attributes == nil { - auth.Attributes = make(map[string]string) + if errParse == nil && handled { + auths = compactPluginAuths(auths) + if len(auths) == 0 { + return nil } - auth.Attributes["path"] = fullPath - auth.Attributes["source"] = fullPath perAccountExcluded := extractExcludedModelsFromMetadata(metadata) perAccountModelAliases := extractOAuthModelAliasesFromMetadata(metadata) - coreauth.SetOAuthModelAliasesAttribute(auth, perAccountModelAliases) - ApplyAuthExcludedModelsMeta(auth, cfg, perAccountExcluded, "oauth") - coreauth.ApplyCustomHeadersFromMetadata(auth) - return []*coreauth.Auth{auth} + for index, auth := range auths { + if auth == nil { + continue + } + if len(auths) > 1 { + coreauth.MarkPluginVirtualAuth(auth, fullPath, index) + } + auth.CreatedAt = now + auth.UpdatedAt = now + if auth.Attributes == nil { + auth.Attributes = make(map[string]string) + } + auth.Attributes["path"] = fullPath + auth.Attributes["source"] = fullPath + coreauth.SetOAuthModelAliasesAttribute(auth, perAccountModelAliases) + ApplyAuthExcludedModelsMeta(auth, cfg, perAccountExcluded, "oauth") + coreauth.ApplyCustomHeadersFromMetadata(auth) + } + return auths } } if provider == "" { @@ -197,6 +209,34 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] return []*coreauth.Auth{a} } +func parsePluginFileAuths(parser PluginAuthParser, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) { + if parser == nil { + return nil, false, nil + } + if multiParser, ok := parser.(PluginMultiAuthParser); ok { + return multiParser.ParseAuths(context.Background(), req) + } + auth, handled, errParse := parser.ParseAuth(context.Background(), req) + if errParse != nil || !handled || auth == nil { + return nil, handled, errParse + } + return []*coreauth.Auth{auth}, true, nil +} + +func compactPluginAuths(auths []*coreauth.Auth) []*coreauth.Auth { + if len(auths) == 0 { + return nil + } + out := auths[:0] + for _, auth := range auths { + if auth == nil { + continue + } + out = append(out, auth) + } + return out +} + // extractOAuthModelAliasesFromMetadata reads per-account model aliases from OAuth JSON metadata. // Supports both "model_aliases" and "model-aliases" keys. func extractOAuthModelAliasesFromMetadata(metadata map[string]any) []config.OAuthModelAlias { diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index 00276736..b5238554 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -1,6 +1,7 @@ package synthesizer import ( + "context" "encoding/json" "os" "path/filepath" @@ -9,6 +10,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/config" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" ) func TestNewFileSynthesizer(t *testing.T) { @@ -160,6 +162,105 @@ func TestFileSynthesizer_Synthesize_IgnoresGeminiProviderFile(t *testing.T) { } } +func TestSynthesizeAuthFileExpandsPluginMultiAuths(t *testing.T) { + tempDir := t.TempDir() + fullPath := filepath.Join(tempDir, "geminicli.json") + raw := []byte(`{"type":"gemini-cli","excluded_models":["model-a"],"headers":{"X-Test":"value"}}`) + + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tempDir, + Now: time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC), + PluginAuthParser: multiAuthParserFunc(func(ctx context.Context, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) { + if req.Provider != "gemini-cli" || req.Path != fullPath || req.FileName != "geminicli.json" { + t.Fatalf("ParseAuths request = %#v, want file context", req) + } + return []*coreauth.Auth{ + { + ID: "geminicli.json", + Provider: "gemini-cli", + Metadata: map[string]any{ + "type": "gemini-cli", + "headers": map[string]any{ + "X-Test": "value", + }, + }, + }, + nil, + { + ID: "geminicli-project-a.json", + Provider: "gemini-cli", + Metadata: map[string]any{ + "type": "gemini-cli", + "project_id": "project-a", + "headers": map[string]any{ + "X-Test": "value", + }, + }, + }, + }, true, nil + }), + } + + auths := SynthesizeAuthFile(ctx, fullPath, raw) + if len(auths) != 2 { + t.Fatalf("SynthesizeAuthFile() len = %d, want two plugin auths", len(auths)) + } + if firstIndex, secondIndex := auths[0].EnsureIndex(), auths[1].EnsureIndex(); firstIndex == "" || firstIndex == secondIndex { + t.Fatalf("auth indexes = %q/%q, want distinct non-empty indexes", firstIndex, secondIndex) + } + for _, auth := range auths { + if !coreauth.IsPluginVirtualAuth(auth) { + t.Fatalf("auth attributes = %#v, want plugin virtual marker", auth.Attributes) + } + if auth.Attributes[coreauth.AttributeVirtualSource] != fullPath { + t.Fatalf("virtual_source = %q, want %q", auth.Attributes[coreauth.AttributeVirtualSource], fullPath) + } + if auth.Attributes["path"] != fullPath || auth.Attributes["source"] != fullPath { + t.Fatalf("auth attributes = %#v, want source path", auth.Attributes) + } + if gotHeader := auth.Attributes["header:X-Test"]; gotHeader != "value" { + t.Fatalf("header:X-Test = %q, want value", gotHeader) + } + if gotKind := auth.Attributes["auth_kind"]; gotKind != "oauth" { + t.Fatalf("auth_kind = %q, want oauth", gotKind) + } + } + if gotProject := auths[1].Metadata["project_id"]; gotProject != "project-a" { + t.Fatalf("project_id = %#v, want project-a", gotProject) + } +} + +func TestSynthesizeAuthFilePluginHandledEmptySuppressesBuiltin(t *testing.T) { + tempDir := t.TempDir() + fullPath := filepath.Join(tempDir, "codex.json") + raw := []byte(`{"type":"codex","access_token":"token"}`) + + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tempDir, + Now: time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC), + PluginAuthParser: multiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) { + return nil, true, nil + }), + } + + auths := SynthesizeAuthFile(ctx, fullPath, raw) + if len(auths) != 0 { + t.Fatalf("SynthesizeAuthFile() len = %d, want plugin-handled empty result", len(auths)) + } +} + +type multiAuthParserFunc func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) + +func (f multiAuthParserFunc) ParseAuth(context.Context, pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) { + return nil, false, nil +} + +func (f multiAuthParserFunc) ParseAuths(ctx context.Context, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) { + return f(ctx, req) +} + func TestFileSynthesizer_Synthesize_SkipsInvalidFiles(t *testing.T) { tempDir := t.TempDir() diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index f256f839..bc89b322 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -23,6 +23,12 @@ type PluginAuthParser interface { ParseAuth(context.Context, pluginapi.AuthParseRequest) (*cliproxyauth.Auth, bool, error) } +// PluginMultiAuthParser expands one auth JSON payload into multiple plugin auth records. +// Returning handled=true with an empty slice means the plugin intentionally suppresses built-in parsing. +type PluginMultiAuthParser interface { + ParseAuths(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) +} + type pluginAuthParserHolder struct { parser PluginAuthParser } @@ -171,12 +177,12 @@ func (s *FileTokenStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) if !strings.HasSuffix(strings.ToLower(d.Name()), ".json") { return nil } - auth, err := s.readAuthFile(path, dir) - if err != nil { + auths, errReadAuths := s.readAuthFiles(path, dir) + if errReadAuths != nil { return nil } - if auth != nil { - entries = append(entries, auth) + if len(auths) > 0 { + entries = append(entries, auths...) } return nil }) @@ -213,7 +219,7 @@ func (s *FileTokenStore) resolveDeletePath(id string) (string, error) { return filepath.Join(dir, id), nil } -func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) { +func (s *FileTokenStore) readAuthFiles(path, baseDir string) ([]*cliproxyauth.Auth, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read file: %w", err) @@ -235,22 +241,34 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, return nil, fmt.Errorf("stat file: %w", errStat) } if parser := currentPluginAuthParser(); parser != nil { - auth, handled, errParse := parser.ParseAuth(context.Background(), pluginapi.AuthParseRequest{ + auths, handled, errParse := parsePluginAuthFile(parser, pluginapi.AuthParseRequest{ Provider: provider, Path: path, FileName: s.idFor(path, baseDir), RawJSON: data, }) - if errParse == nil && handled && auth != nil { - auth.CreatedAt = info.ModTime() - auth.UpdatedAt = info.ModTime() - if auth.Attributes == nil { - auth.Attributes = make(map[string]string) + if errParse == nil && handled { + auths = compactPluginAuths(auths) + if len(auths) == 0 { + return nil, nil } - auth.Attributes["path"] = path - auth.Attributes["source"] = path - cliproxyauth.ApplyCustomHeadersFromMetadata(auth) - return auth, nil + for index, auth := range auths { + if auth == nil { + continue + } + if len(auths) > 1 { + cliproxyauth.MarkPluginVirtualAuth(auth, path, index) + } + auth.CreatedAt = info.ModTime() + auth.UpdatedAt = info.ModTime() + if auth.Attributes == nil { + auth.Attributes = make(map[string]string) + } + auth.Attributes["path"] = path + auth.Attributes["source"] = path + cliproxyauth.ApplyCustomHeadersFromMetadata(auth) + } + return auths, nil } } if provider == "" { @@ -305,7 +323,43 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, auth.Attributes["email"] = email } cliproxyauth.ApplyCustomHeadersFromMetadata(auth) - return auth, nil + return []*cliproxyauth.Auth{auth}, nil +} + +func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) { + auths, errReadAuths := s.readAuthFiles(path, baseDir) + if errReadAuths != nil || len(auths) == 0 { + return nil, errReadAuths + } + return auths[0], nil +} + +func parsePluginAuthFile(parser PluginAuthParser, req pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) { + if parser == nil { + return nil, false, nil + } + if multiParser, ok := parser.(PluginMultiAuthParser); ok { + return multiParser.ParseAuths(context.Background(), req) + } + auth, handled, errParse := parser.ParseAuth(context.Background(), req) + if errParse != nil || !handled || auth == nil { + return nil, handled, errParse + } + return []*cliproxyauth.Auth{auth}, true, nil +} + +func compactPluginAuths(auths []*cliproxyauth.Auth) []*cliproxyauth.Auth { + if len(auths) == 0 { + return nil + } + out := auths[:0] + for _, auth := range auths { + if auth == nil { + continue + } + out = append(out, auth) + } + return out } func (s *FileTokenStore) idFor(path, baseDir string) string { diff --git a/sdk/auth/filestore_test.go b/sdk/auth/filestore_test.go index 9e135ad4..32164bed 100644 --- a/sdk/auth/filestore_test.go +++ b/sdk/auth/filestore_test.go @@ -1,6 +1,14 @@ package auth -import "testing" +import ( + "context" + "os" + "path/filepath" + "testing" + + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) func TestExtractAccessToken(t *testing.T) { t.Parallel() @@ -78,3 +86,109 @@ func TestExtractAccessToken(t *testing.T) { }) } } + +func TestFileTokenStoreListExpandsPluginMultiAuths(t *testing.T) { + baseDir := t.TempDir() + path := filepath.Join(baseDir, "geminicli.json") + if errWrite := os.WriteFile(path, []byte(`{"type":"gemini-cli","headers":{"X-Test":"value"}}`), 0o600); errWrite != nil { + t.Fatalf("write auth file: %v", errWrite) + } + + RegisterPluginAuthParser(fileStoreMultiAuthParserFunc(func(ctx context.Context, req pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) { + if req.Provider != "gemini-cli" || req.Path != path || req.FileName != "geminicli.json" { + t.Fatalf("ParseAuths request = %#v, want file context", req) + } + return []*cliproxyauth.Auth{ + { + ID: "geminicli.json", + Provider: "gemini-cli", + Metadata: map[string]any{ + "type": "gemini-cli", + "headers": map[string]any{ + "X-Test": "value", + }, + }, + }, + nil, + { + ID: "geminicli-project-a.json", + Provider: "gemini-cli", + Metadata: map[string]any{ + "type": "gemini-cli", + "project_id": "project-a", + "headers": map[string]any{ + "X-Test": "value", + }, + }, + }, + }, true, nil + })) + t.Cleanup(func() { + RegisterPluginAuthParser(nil) + }) + + store := NewFileTokenStore() + store.SetBaseDir(baseDir) + auths, errList := store.List(context.Background()) + if errList != nil { + t.Fatalf("List() error = %v", errList) + } + if len(auths) != 2 { + t.Fatalf("List() len = %d, want two plugin auths", len(auths)) + } + if firstIndex, secondIndex := auths[0].EnsureIndex(), auths[1].EnsureIndex(); firstIndex == "" || firstIndex == secondIndex { + t.Fatalf("auth indexes = %q/%q, want distinct non-empty indexes", firstIndex, secondIndex) + } + for _, auth := range auths { + if !cliproxyauth.IsPluginVirtualAuth(auth) { + t.Fatalf("auth attributes = %#v, want plugin virtual marker", auth.Attributes) + } + if auth.Attributes[cliproxyauth.AttributeVirtualSource] != path { + t.Fatalf("virtual_source = %q, want %q", auth.Attributes[cliproxyauth.AttributeVirtualSource], path) + } + if auth.Attributes["path"] != path || auth.Attributes["source"] != path { + t.Fatalf("auth attributes = %#v, want source path", auth.Attributes) + } + if gotHeader := auth.Attributes["header:X-Test"]; gotHeader != "value" { + t.Fatalf("header:X-Test = %q, want value", gotHeader) + } + } + if gotProject := auths[1].Metadata["project_id"]; gotProject != "project-a" { + t.Fatalf("project_id = %#v, want project-a", gotProject) + } +} + +func TestFileTokenStoreListPluginHandledEmptySuppressesBuiltin(t *testing.T) { + baseDir := t.TempDir() + path := filepath.Join(baseDir, "codex.json") + if errWrite := os.WriteFile(path, []byte(`{"type":"codex","access_token":"token"}`), 0o600); errWrite != nil { + t.Fatalf("write auth file: %v", errWrite) + } + + RegisterPluginAuthParser(fileStoreMultiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) { + return nil, true, nil + })) + t.Cleanup(func() { + RegisterPluginAuthParser(nil) + }) + + store := NewFileTokenStore() + store.SetBaseDir(baseDir) + auths, errList := store.List(context.Background()) + if errList != nil { + t.Fatalf("List() error = %v", errList) + } + if len(auths) != 0 { + t.Fatalf("List() len = %d, want plugin-handled empty result", len(auths)) + } +} + +type fileStoreMultiAuthParserFunc func(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) + +func (f fileStoreMultiAuthParserFunc) ParseAuth(context.Context, pluginapi.AuthParseRequest) (*cliproxyauth.Auth, bool, error) { + return nil, false, nil +} + +func (f fileStoreMultiAuthParserFunc) ParseAuths(ctx context.Context, req pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) { + return f(ctx, req) +} diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index b8d0a430..54a52559 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -5032,6 +5032,9 @@ func (m *Manager) persist(ctx context.Context, auth *Auth) error { return nil } } + if IsPluginVirtualAuth(auth) { + return nil + } // Skip persistence when metadata is absent (e.g., runtime-only auths). if auth.Metadata == nil { return nil diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 398bd13c..8c900951 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -100,6 +100,49 @@ type Auth struct { indexAssigned bool `json:"-"` } +const ( + AttributeAuthIndexSeed = "auth_index_seed" + AttributePluginVirtual = "plugin_virtual" + AttributeVirtualSource = "virtual_source" + pluginVirtualAttrEnabled = "true" +) + +// MarkPluginVirtualAuth marks an auth that was expanded from a plugin-owned source file. +func MarkPluginVirtualAuth(auth *Auth, sourcePath string, ordinal int) { + if auth == nil { + return + } + if auth.Attributes == nil { + auth.Attributes = make(map[string]string) + } + auth.Attributes[AttributePluginVirtual] = pluginVirtualAttrEnabled + sourcePath = strings.TrimSpace(sourcePath) + if sourcePath != "" { + auth.Attributes[AttributeVirtualSource] = sourcePath + } + seedID := strings.TrimSpace(auth.ID) + if seedID == "" { + seedID = strings.TrimSpace(auth.FileName) + } + if seedID == "" { + seedID = strconv.Itoa(ordinal) + } + auth.Attributes[AttributeAuthIndexSeed] = strings.Join([]string{ + strings.ToLower(strings.TrimSpace(auth.Provider)), + sourcePath, + seedID, + strconv.Itoa(ordinal), + }, "|") +} + +// IsPluginVirtualAuth reports whether an auth was expanded from a plugin-owned source file. +func IsPluginVirtualAuth(auth *Auth) bool { + if auth == nil || len(auth.Attributes) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(auth.Attributes[AttributePluginVirtual]), pluginVirtualAttrEnabled) +} + const ( recentRequestBucketSeconds int64 = 10 * 60 recentRequestBucketCount = 20 @@ -257,6 +300,12 @@ func (a *Auth) indexSeed() string { return "" } + if a.Attributes != nil { + if seed := strings.TrimSpace(a.Attributes[AttributeAuthIndexSeed]); seed != "" { + return AttributeAuthIndexSeed + ":" + seed + } + } + provider := strings.ToLower(strings.TrimSpace(a.Provider)) compatName := "" baseURL := "" diff --git a/sdk/cliproxy/types.go b/sdk/cliproxy/types.go index 719e0309..d6c2b399 100644 --- a/sdk/cliproxy/types.go +++ b/sdk/cliproxy/types.go @@ -86,6 +86,12 @@ type PluginAuthParser interface { ParseAuth(context.Context, pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) } +// PluginMultiAuthParser expands one auth JSON payload into multiple plugin auth records. +// Returning handled=true with an empty slice means the plugin intentionally suppresses built-in parsing. +type PluginMultiAuthParser interface { + ParseAuths(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) +} + // WatcherWrapper exposes the subset of watcher methods required by the SDK. type WatcherWrapper struct { start func(ctx context.Context) error diff --git a/sdk/pluginapi/types.go b/sdk/pluginapi/types.go index 6f9f53f7..5bd97508 100644 --- a/sdk/pluginapi/types.go +++ b/sdk/pluginapi/types.go @@ -253,6 +253,8 @@ type AuthParseResponse struct { Handled bool // Auth is the parsed auth record when Handled is true. Auth AuthData + // Auths contains multiple parsed auth records when one auth material expands into several runtime auths. + Auths []AuthData } // AuthProvider parses, logs in, polls, and refreshes plugin provider auths. @@ -326,6 +328,8 @@ type AuthLoginPollResponse struct { Message string // Auth is the completed auth record when Status is success. Auth AuthData + // Auths contains multiple completed auth records when one login flow expands into several runtime auths. + Auths []AuthData } // AuthRefreshRequest asks a plugin to refresh provider auth data. diff --git a/sdk/pluginapi/types_test.go b/sdk/pluginapi/types_test.go index 6a5556ef..de0d5c4e 100644 --- a/sdk/pluginapi/types_test.go +++ b/sdk/pluginapi/types_test.go @@ -51,6 +51,61 @@ func TestMetadataConfigFieldsExposePluginSchema(t *testing.T) { } } +func TestAuthParseResponseSupportsMultipleAuths(t *testing.T) { + resp := AuthParseResponse{ + Handled: true, + Auth: AuthData{ + Provider: "gemini-cli", + ID: "primary.json", + }, + Auths: []AuthData{ + {Provider: "gemini-cli", ID: "primary.json"}, + {Provider: "gemini-cli", ID: "primary-project-a.json"}, + }, + } + + raw, errMarshal := json.Marshal(resp) + if errMarshal != nil { + t.Fatalf("Marshal() error = %v", errMarshal) + } + var decoded AuthParseResponse + if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil { + t.Fatalf("Unmarshal() error = %v", errUnmarshal) + } + if !decoded.Handled || len(decoded.Auths) != 2 || decoded.Auths[1].ID != "primary-project-a.json" { + t.Fatalf("decoded response = %#v, want two auths", decoded) + } + if decoded.Auth.ID != "primary.json" { + t.Fatalf("decoded Auth.ID = %q, want primary.json", decoded.Auth.ID) + } +} + +func TestAuthLoginPollResponseSupportsMultipleAuths(t *testing.T) { + resp := AuthLoginPollResponse{ + Status: AuthLoginStatusSuccess, + Auth: AuthData{ + Provider: "gemini-cli", + ID: "primary.json", + }, + Auths: []AuthData{ + {Provider: "gemini-cli", ID: "primary.json"}, + {Provider: "gemini-cli", ID: "primary-project-a.json"}, + }, + } + + raw, errMarshal := json.Marshal(resp) + if errMarshal != nil { + t.Fatalf("Marshal() error = %v", errMarshal) + } + var decoded AuthLoginPollResponse + if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil { + t.Fatalf("Unmarshal() error = %v", errUnmarshal) + } + if decoded.Status != AuthLoginStatusSuccess || len(decoded.Auths) != 2 { + t.Fatalf("decoded response = %#v, want success with two auths", decoded) + } +} + func TestResourceRouteMenuFieldsExposeManagementUIHints(t *testing.T) { route := ResourceRoute{ Path: "/status",