diff --git a/examples/custom-provider/main.go b/examples/custom-provider/main.go index ab7fb31c..1b4592c2 100644 --- a/examples/custom-provider/main.go +++ b/examples/custom-provider/main.go @@ -23,9 +23,10 @@ import ( "time" "github.com/gin-gonic/gin" - api "github.com/router-for-me/CLIProxyAPI/v6/internal/api" + "github.com/router-for-me/CLIProxyAPI/v6/internal/api" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" @@ -155,7 +156,15 @@ func main() { panic(err) } - core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil) + tokenStore := sdkAuth.GetTokenStore() + if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok { + dirSetter.SetBaseDir(cfg.AuthDir) + } + store, ok := tokenStore.(coreauth.Store) + if !ok { + panic("token store does not implement coreauth.Store") + } + core := coreauth.NewManager(store, nil, nil) core.RegisterExecutor(MyExecutor{}) hooks := cliproxy.Hooks{ diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index fb1667ce..b07df209 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -528,7 +528,7 @@ func (w *Watcher) reloadClients() { return nil } if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") { - if data, err := os.ReadFile(path); err == nil && len(data) > 0 { + if data, errReadFile := os.ReadFile(path); errReadFile == nil && len(data) > 0 { sum := sha256.Sum256(data) w.lastAuthHashes[path] = hex.EncodeToString(sum[:]) } @@ -750,7 +750,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if email, _ := metadata["email"].(string); email != "" { label = email } - // Use relative path under authDir as ID to stay consistent with FileStore + // Use relative path under authDir as ID to stay consistent with the file-based token store id := full if rel, errRel := filepath.Rel(w.authDir, full); errRel == nil && rel != "" { id = rel diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index a68e25bb..da63b86d 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -2,15 +2,25 @@ package auth import ( "context" + "encoding/json" "fmt" + "io/fs" + "os" "path/filepath" + "strings" + "sync" + "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" ) -// FileTokenStore persists token records into the configured auth directory using the -// filename suggested by the authenticator. Relative paths are resolved against cfg.AuthDir. -type FileTokenStore struct{} +// FileTokenStore persists token records and auth metadata using the filesystem as backing storage. +type FileTokenStore struct { + mu sync.Mutex + dirLock sync.RWMutex + baseDir string +} // NewFileTokenStore creates a token store that saves credentials to disk through the // TokenStorage implementation embedded in the token record. @@ -18,20 +28,298 @@ func NewFileTokenStore() *FileTokenStore { return &FileTokenStore{} } +// SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided. +func (s *FileTokenStore) SetBaseDir(dir string) { + s.dirLock.Lock() + s.baseDir = strings.TrimSpace(dir) + s.dirLock.Unlock() +} + // Save writes the token storage to the resolved file path. func (s *FileTokenStore) Save(ctx context.Context, cfg *config.Config, record *TokenRecord) (string, error) { if record == nil || record.Storage == nil { return "", fmt.Errorf("cliproxy auth: token record is incomplete") } - target := record.FileName + target := strings.TrimSpace(record.FileName) if target == "" { return "", fmt.Errorf("cliproxy auth: missing file name for provider %s", record.Provider) } - if cfg != nil && !filepath.IsAbs(target) { - target = filepath.Join(cfg.AuthDir, target) + if !filepath.IsAbs(target) { + baseDir := s.baseDirFromConfig(cfg) + if baseDir != "" { + target = filepath.Join(baseDir, target) + } } + s.mu.Lock() + defer s.mu.Unlock() if err := record.Storage.SaveTokenToFile(target); err != nil { return "", err } return target, nil } + +// List enumerates all auth JSON files under the configured directory. +func (s *FileTokenStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) { + dir := s.baseDirSnapshot() + if dir == "" { + return nil, fmt.Errorf("auth filestore: directory not configured") + } + entries := make([]*cliproxyauth.Auth, 0) + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(strings.ToLower(d.Name()), ".json") { + return nil + } + auth, err := s.readAuthFile(path, dir) + if err != nil { + return nil + } + if auth != nil { + entries = append(entries, auth) + } + return nil + }) + if err != nil { + return nil, err + } + return entries, nil +} + +// SaveAuth writes the auth metadata back to its source file location. +func (s *FileTokenStore) SaveAuth(ctx context.Context, auth *cliproxyauth.Auth) error { + if auth == nil { + return fmt.Errorf("auth filestore: auth is nil") + } + path, err := s.resolveAuthPath(auth) + if err != nil { + return err + } + if path == "" { + return fmt.Errorf("auth filestore: missing file path attribute for %s", auth.ID) + } + // If the auth has been disabled and the original file was removed, avoid recreating it on disk. + if auth.Disabled { + if _, statErr := os.Stat(path); statErr != nil { + if os.IsNotExist(statErr) { + return nil + } + } + } + s.mu.Lock() + defer s.mu.Unlock() + if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("auth filestore: create dir failed: %w", err) + } + raw, err := json.Marshal(auth.Metadata) + if err != nil { + return fmt.Errorf("auth filestore: marshal metadata failed: %w", err) + } + if existing, errRead := os.ReadFile(path); errRead == nil { + if jsonEqual(existing, raw) { + return nil + } + } + tmp := path + ".tmp" + if err = os.WriteFile(tmp, raw, 0o600); err != nil { + return fmt.Errorf("auth filestore: write temp failed: %w", err) + } + if err = os.Rename(tmp, path); err != nil { + return fmt.Errorf("auth filestore: rename failed: %w", err) + } + return nil +} + +// Delete removes the auth file. +func (s *FileTokenStore) Delete(ctx context.Context, id string) error { + id = strings.TrimSpace(id) + if id == "" { + return fmt.Errorf("auth filestore: id is empty") + } + path, err := s.resolveDeletePath(id) + if err != nil { + return err + } + if err = os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("auth filestore: delete failed: %w", err) + } + return nil +} + +func (s *FileTokenStore) resolveDeletePath(id string) (string, error) { + if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) { + return id, nil + } + dir := s.baseDirSnapshot() + if dir == "" { + return "", fmt.Errorf("auth filestore: directory not configured") + } + return filepath.Join(dir, id), nil +} + +func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + if len(data) == 0 { + return nil, nil + } + metadata := make(map[string]any) + if err = json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("unmarshal auth json: %w", err) + } + provider, _ := metadata["type"].(string) + if provider == "" { + provider = "unknown" + } + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat file: %w", err) + } + id := s.idFor(path, baseDir) + auth := &cliproxyauth.Auth{ + ID: id, + Provider: provider, + Label: s.labelFor(metadata), + Status: cliproxyauth.StatusActive, + Attributes: map[string]string{"path": path}, + Metadata: metadata, + CreatedAt: info.ModTime(), + UpdatedAt: info.ModTime(), + LastRefreshedAt: time.Time{}, + NextRefreshAfter: time.Time{}, + } + if email, ok := metadata["email"].(string); ok && email != "" { + auth.Attributes["email"] = email + } + return auth, nil +} + +func (s *FileTokenStore) idFor(path, baseDir string) string { + if baseDir == "" { + return path + } + rel, err := filepath.Rel(baseDir, path) + if err != nil { + return path + } + return rel +} + +func (s *FileTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) { + if auth == nil { + return "", fmt.Errorf("auth filestore: auth is nil") + } + if auth.Attributes != nil { + if p := strings.TrimSpace(auth.Attributes["path"]); p != "" { + return p, nil + } + } + if auth.ID == "" { + return "", fmt.Errorf("auth filestore: missing id") + } + if filepath.IsAbs(auth.ID) { + return auth.ID, nil + } + dir := s.baseDirSnapshot() + if dir == "" { + return "", fmt.Errorf("auth filestore: directory not configured") + } + return filepath.Join(dir, auth.ID), nil +} + +func (s *FileTokenStore) labelFor(metadata map[string]any) string { + if metadata == nil { + return "" + } + if v, ok := metadata["label"].(string); ok && v != "" { + return v + } + if v, ok := metadata["email"].(string); ok && v != "" { + return v + } + if project, ok := metadata["project_id"].(string); ok && project != "" { + return project + } + return "" +} + +func (s *FileTokenStore) baseDirFromConfig(cfg *config.Config) string { + if cfg != nil && strings.TrimSpace(cfg.AuthDir) != "" { + return strings.TrimSpace(cfg.AuthDir) + } + return s.baseDirSnapshot() +} + +func (s *FileTokenStore) baseDirSnapshot() string { + s.dirLock.RLock() + defer s.dirLock.RUnlock() + return s.baseDir +} + +func jsonEqual(a, b []byte) bool { + var objA any + var objB any + if err := json.Unmarshal(a, &objA); err != nil { + return false + } + if err := json.Unmarshal(b, &objB); err != nil { + return false + } + return deepEqualJSON(objA, objB) +} + +func deepEqualJSON(a, b any) bool { + switch valA := a.(type) { + case map[string]any: + valB, ok := b.(map[string]any) + if !ok || len(valA) != len(valB) { + return false + } + for key, subA := range valA { + subB, ok1 := valB[key] + if !ok1 || !deepEqualJSON(subA, subB) { + return false + } + } + return true + case []any: + sliceB, ok := b.([]any) + if !ok || len(valA) != len(sliceB) { + return false + } + for i := range valA { + if !deepEqualJSON(valA[i], sliceB[i]) { + return false + } + } + return true + case float64: + valB, ok := b.(float64) + if !ok { + return false + } + return valA == valB + case string: + valB, ok := b.(string) + if !ok { + return false + } + return valA == valB + case bool: + valB, ok := b.(bool) + if !ok { + return false + } + return valA == valB + case nil: + return b == nil + default: + return false + } +} diff --git a/sdk/auth/refresh_registry.go b/sdk/auth/refresh_registry.go new file mode 100644 index 00000000..0f7fb505 --- /dev/null +++ b/sdk/auth/refresh_registry.go @@ -0,0 +1,29 @@ +package auth + +import ( + "time" + + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func init() { + registerRefreshLead("codex", func() Authenticator { return NewCodexAuthenticator() }) + registerRefreshLead("claude", func() Authenticator { return NewClaudeAuthenticator() }) + registerRefreshLead("qwen", func() Authenticator { return NewQwenAuthenticator() }) + registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() }) + registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() }) + registerRefreshLead("gemini-web", func() Authenticator { return NewGeminiWebAuthenticator() }) +} + +func registerRefreshLead(provider string, factory func() Authenticator) { + cliproxyauth.RegisterRefreshLeadProvider(provider, func() *time.Duration { + if factory == nil { + return nil + } + auth := factory() + if auth == nil { + return nil + } + return auth.RefreshLead() + }) +} diff --git a/sdk/cliproxy/auth/filestore.go b/sdk/cliproxy/auth/filestore.go deleted file mode 100644 index 6fbcbd54..00000000 --- a/sdk/cliproxy/auth/filestore.go +++ /dev/null @@ -1,256 +0,0 @@ -package auth - -import ( - "context" - "encoding/json" - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - "sync" - "time" -) - -// FileStore implements Store backed by JSON files in a directory. -type FileStore struct { - dir string - mu sync.Mutex -} - -// NewFileStore builds a file-backed store rooted at dir. -func NewFileStore(dir string) *FileStore { - return &FileStore{dir: dir} -} - -// List enumerates all auth JSON files under the store directory. -func (s *FileStore) List(ctx context.Context) ([]*Auth, error) { - if s.dir == "" { - return nil, fmt.Errorf("auth filestore: directory not configured") - } - entries := make([]*Auth, 0) - err := filepath.WalkDir(s.dir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - if !strings.HasSuffix(strings.ToLower(d.Name()), ".json") { - return nil - } - auth, err := s.readFile(path) - if err != nil { - // Record error but keep scanning to surface remaining auths. - return nil - } - if auth != nil { - entries = append(entries, auth) - } - return nil - }) - if err != nil { - return nil, err - } - return entries, nil -} - -// Save writes the auth metadata back to its source file location. -func (s *FileStore) Save(ctx context.Context, auth *Auth) error { - if auth == nil { - return fmt.Errorf("auth filestore: auth is nil") - } - path := s.resolvePath(auth) - if path == "" { - return fmt.Errorf("auth filestore: missing file path attribute for %s", auth.ID) - } - // If the auth has been disabled and the original file was removed, avoid - // recreating it on disk. This lets operators delete auth files explicitly. - if auth.Disabled { - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return nil - } - } - } - s.mu.Lock() - defer s.mu.Unlock() - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("auth filestore: create dir failed: %w", err) - } - raw, err := json.Marshal(auth.Metadata) - if err != nil { - return fmt.Errorf("auth filestore: marshal metadata failed: %w", err) - } - if existing, errReadFile := os.ReadFile(path); errReadFile == nil { - if jsonEqual(existing, raw) { - return nil - } - } - tmp := path + ".tmp" - if err = os.WriteFile(tmp, raw, 0o600); err != nil { - return fmt.Errorf("auth filestore: write temp failed: %w", err) - } - if err = os.Rename(tmp, path); err != nil { - return fmt.Errorf("auth filestore: rename failed: %w", err) - } - return nil -} - -func jsonEqual(a, b []byte) bool { - var objA any - var objB any - if err := json.Unmarshal(a, &objA); err != nil { - return false - } - if err := json.Unmarshal(b, &objB); err != nil { - return false - } - return deepEqualJSON(objA, objB) -} - -func deepEqualJSON(a, b any) bool { - switch valA := a.(type) { - case map[string]any: - valB, ok := b.(map[string]any) - if !ok || len(valA) != len(valB) { - return false - } - for key, subA := range valA { - subB, ok1 := valB[key] - if !ok1 || !deepEqualJSON(subA, subB) { - return false - } - } - return true - case []any: - sliceB, ok := b.([]any) - if !ok || len(valA) != len(sliceB) { - return false - } - for i := range valA { - if !deepEqualJSON(valA[i], sliceB[i]) { - return false - } - } - return true - case float64: - valB, ok := b.(float64) - if !ok { - return false - } - return valA == valB - case string: - valB, ok := b.(string) - if !ok { - return false - } - return valA == valB - case bool: - valB, ok := b.(bool) - if !ok { - return false - } - return valA == valB - case nil: - return b == nil - default: - return false - } -} - -// Delete removes the auth file. -func (s *FileStore) Delete(ctx context.Context, id string) error { - if id == "" { - return fmt.Errorf("auth filestore: id is empty") - } - path := filepath.Join(s.dir, id) - if strings.ContainsRune(id, os.PathSeparator) { - path = id - } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("auth filestore: delete failed: %w", err) - } - return nil -} - -func (s *FileStore) readFile(path string) (*Auth, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read file: %w", err) - } - if len(data) == 0 { - return nil, nil - } - metadata := make(map[string]any) - if err = json.Unmarshal(data, &metadata); err != nil { - return nil, fmt.Errorf("unmarshal auth json: %w", err) - } - provider, _ := metadata["type"].(string) - if provider == "" { - provider = "unknown" - } - info, err := os.Stat(path) - if err != nil { - return nil, fmt.Errorf("stat file: %w", err) - } - id := s.idFor(path) - auth := &Auth{ - ID: id, - Provider: provider, - Label: s.labelFor(metadata), - Status: StatusActive, - Attributes: map[string]string{"path": path}, - Metadata: metadata, - CreatedAt: info.ModTime(), - UpdatedAt: info.ModTime(), - LastRefreshedAt: time.Time{}, - NextRefreshAfter: time.Time{}, - } - if email, ok := metadata["email"].(string); ok && email != "" { - auth.Attributes["email"] = email - } - return auth, nil -} - -func (s *FileStore) idFor(path string) string { - rel, err := filepath.Rel(s.dir, path) - if err != nil { - return path - } - return rel -} - -func (s *FileStore) resolvePath(auth *Auth) string { - if auth == nil { - return "" - } - if auth.Attributes != nil { - if p := auth.Attributes["path"]; p != "" { - return p - } - } - if filepath.IsAbs(auth.ID) { - return auth.ID - } - if auth.ID == "" { - return "" - } - return filepath.Join(s.dir, auth.ID) -} - -func (s *FileStore) labelFor(metadata map[string]any) string { - if metadata == nil { - return "" - } - if v, ok := metadata["label"].(string); ok && v != "" { - return v - } - if v, ok := metadata["email"].(string); ok && v != "" { - return v - } - if project, ok := metadata["project_id"].(string); ok && project != "" { - return project - } - return "" -} diff --git a/sdk/cliproxy/auth/manager.go b/sdk/cliproxy/auth/manager.go index 16881df4..72584724 100644 --- a/sdk/cliproxy/auth/manager.go +++ b/sdk/cliproxy/auth/manager.go @@ -817,7 +817,7 @@ func (m *Manager) persist(ctx context.Context, auth *Auth) error { if auth.Metadata == nil { return nil } - return m.store.Save(ctx, auth) + return m.store.SaveAuth(ctx, auth) } // StartAutoRefresh launches a background loop that evaluates auth freshness diff --git a/sdk/cliproxy/auth/store.go b/sdk/cliproxy/auth/store.go index 85b79b29..97cdf65a 100644 --- a/sdk/cliproxy/auth/store.go +++ b/sdk/cliproxy/auth/store.go @@ -6,8 +6,8 @@ import "context" type Store interface { // List returns all auth records stored in the backend. List(ctx context.Context) ([]*Auth, error) - // Save persists the provided auth record, replacing any existing one with same ID. - Save(ctx context.Context, auth *Auth) error + // SaveAuth persists the provided auth record, replacing any existing one with same ID. + SaveAuth(ctx context.Context, auth *Auth) error // Delete removes the auth record identified by id. Delete(ctx context.Context, id string) error } diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 98b5f288..492cc570 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -4,9 +4,8 @@ import ( "encoding/json" "strconv" "strings" + "sync" "time" - - clipauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" ) // Auth encapsulates the runtime state and metadata associated with a single credential. @@ -172,13 +171,19 @@ func (a *Auth) ExpirationTime() (time.Time, bool) { return time.Time{}, false } -var defaultAuthenticatorFactories = map[string]func() clipauth.Authenticator{ - "codex": func() clipauth.Authenticator { return clipauth.NewCodexAuthenticator() }, - "claude": func() clipauth.Authenticator { return clipauth.NewClaudeAuthenticator() }, - "qwen": func() clipauth.Authenticator { return clipauth.NewQwenAuthenticator() }, - "gemini": func() clipauth.Authenticator { return clipauth.NewGeminiAuthenticator() }, - "gemini-cli": func() clipauth.Authenticator { return clipauth.NewGeminiAuthenticator() }, - "gemini-web": func() clipauth.Authenticator { return clipauth.NewGeminiWebAuthenticator() }, +var ( + refreshLeadMu sync.RWMutex + refreshLeadFactories = make(map[string]func() *time.Duration) +) + +func RegisterRefreshLeadProvider(provider string, factory func() *time.Duration) { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" || factory == nil { + return + } + refreshLeadMu.Lock() + refreshLeadFactories[provider] = factory + refreshLeadMu.Unlock() } var expireKeys = [...]string{"expired", "expire", "expires_at", "expiresAt", "expiry", "expires"} @@ -216,7 +221,7 @@ func expirationFromMap(meta map[string]any) (time.Time, bool) { } func ProviderRefreshLead(provider string, runtime any) *time.Duration { - provider = strings.ToLower(provider) + provider = strings.ToLower(strings.TrimSpace(provider)) if runtime != nil { if eval, ok := runtime.(interface{ RefreshLead() *time.Duration }); ok { if lead := eval.RefreshLead(); lead != nil && *lead > 0 { @@ -224,12 +229,14 @@ func ProviderRefreshLead(provider string, runtime any) *time.Duration { } } } - if factory, ok := defaultAuthenticatorFactories[provider]; ok { - if auth := factory(); auth != nil { - if lead := auth.RefreshLead(); lead != nil && *lead > 0 { - return lead - } - } + refreshLeadMu.RLock() + factory := refreshLeadFactories[provider] + refreshLeadMu.RUnlock() + if factory == nil { + return nil + } + if lead := factory(); lead != nil && *lead > 0 { + return lead } return nil } diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go index 4d3b571c..091aa010 100644 --- a/sdk/cliproxy/builder.go +++ b/sdk/cliproxy/builder.go @@ -183,7 +183,15 @@ func (b *Builder) Build() (*Service, error) { coreManager := b.coreManager if coreManager == nil { - coreManager = coreauth.NewManager(coreauth.NewFileStore(b.cfg.AuthDir), nil, nil) + tokenStore := sdkAuth.GetTokenStore() + if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok && b.cfg != nil { + dirSetter.SetBaseDir(b.cfg.AuthDir) + } + store, ok := tokenStore.(coreauth.Store) + if !ok { + return nil, fmt.Errorf("cliproxy: token store does not implement coreauth.Store") + } + coreManager = coreauth.NewManager(store, nil, nil) } // Attach a default RoundTripper provider so providers can opt-in per-auth transports. coreManager.SetRoundTripperProvider(newDefaultRoundTripperProvider())