mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 20:40:52 +08:00
refactor(auth): replace FileStore with FileTokenStore for unified token persistence
- Removed `FileStore` in favor of the new `FileTokenStore`. - Centralized auth JSON handling and token operations through `FileTokenStore`. - Updated all components to utilize `FileTokenStore` for consistent storage operations. - Introduced `SetBaseDir` and directory locking mechanisms for flexible configurations. - Enhanced metadata management, including path resolution and deep JSON comparisons.
This commit is contained in:
@@ -23,9 +23,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
"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"
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
@@ -155,7 +156,15 @@ func main() {
|
|||||||
panic(err)
|
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{})
|
core.RegisterExecutor(MyExecutor{})
|
||||||
|
|
||||||
hooks := cliproxy.Hooks{
|
hooks := cliproxy.Hooks{
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ func (w *Watcher) reloadClients() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") {
|
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)
|
sum := sha256.Sum256(data)
|
||||||
w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
|
w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
@@ -750,7 +750,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
|||||||
if email, _ := metadata["email"].(string); email != "" {
|
if email, _ := metadata["email"].(string); email != "" {
|
||||||
label = 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
|
id := full
|
||||||
if rel, errRel := filepath.Rel(w.authDir, full); errRel == nil && rel != "" {
|
if rel, errRel := filepath.Rel(w.authDir, full); errRel == nil && rel != "" {
|
||||||
id = rel
|
id = rel
|
||||||
|
|||||||
@@ -2,15 +2,25 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"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
|
// FileTokenStore persists token records and auth metadata using the filesystem as backing storage.
|
||||||
// filename suggested by the authenticator. Relative paths are resolved against cfg.AuthDir.
|
type FileTokenStore struct {
|
||||||
type FileTokenStore struct{}
|
mu sync.Mutex
|
||||||
|
dirLock sync.RWMutex
|
||||||
|
baseDir string
|
||||||
|
}
|
||||||
|
|
||||||
// NewFileTokenStore creates a token store that saves credentials to disk through the
|
// NewFileTokenStore creates a token store that saves credentials to disk through the
|
||||||
// TokenStorage implementation embedded in the token record.
|
// TokenStorage implementation embedded in the token record.
|
||||||
@@ -18,20 +28,298 @@ func NewFileTokenStore() *FileTokenStore {
|
|||||||
return &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.
|
// Save writes the token storage to the resolved file path.
|
||||||
func (s *FileTokenStore) Save(ctx context.Context, cfg *config.Config, record *TokenRecord) (string, error) {
|
func (s *FileTokenStore) Save(ctx context.Context, cfg *config.Config, record *TokenRecord) (string, error) {
|
||||||
if record == nil || record.Storage == nil {
|
if record == nil || record.Storage == nil {
|
||||||
return "", fmt.Errorf("cliproxy auth: token record is incomplete")
|
return "", fmt.Errorf("cliproxy auth: token record is incomplete")
|
||||||
}
|
}
|
||||||
target := record.FileName
|
target := strings.TrimSpace(record.FileName)
|
||||||
if target == "" {
|
if target == "" {
|
||||||
return "", fmt.Errorf("cliproxy auth: missing file name for provider %s", record.Provider)
|
return "", fmt.Errorf("cliproxy auth: missing file name for provider %s", record.Provider)
|
||||||
}
|
}
|
||||||
if cfg != nil && !filepath.IsAbs(target) {
|
if !filepath.IsAbs(target) {
|
||||||
target = filepath.Join(cfg.AuthDir, 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 {
|
if err := record.Storage.SaveTokenToFile(target); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return target, nil
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
29
sdk/auth/refresh_registry.go
Normal file
29
sdk/auth/refresh_registry.go
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 ""
|
|
||||||
}
|
|
||||||
@@ -817,7 +817,7 @@ func (m *Manager) persist(ctx context.Context, auth *Auth) error {
|
|||||||
if auth.Metadata == nil {
|
if auth.Metadata == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return m.store.Save(ctx, auth)
|
return m.store.SaveAuth(ctx, auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartAutoRefresh launches a background loop that evaluates auth freshness
|
// StartAutoRefresh launches a background loop that evaluates auth freshness
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import "context"
|
|||||||
type Store interface {
|
type Store interface {
|
||||||
// List returns all auth records stored in the backend.
|
// List returns all auth records stored in the backend.
|
||||||
List(ctx context.Context) ([]*Auth, error)
|
List(ctx context.Context) ([]*Auth, error)
|
||||||
// Save persists the provided auth record, replacing any existing one with same ID.
|
// SaveAuth persists the provided auth record, replacing any existing one with same ID.
|
||||||
Save(ctx context.Context, auth *Auth) error
|
SaveAuth(ctx context.Context, auth *Auth) error
|
||||||
// Delete removes the auth record identified by id.
|
// Delete removes the auth record identified by id.
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
clipauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Auth encapsulates the runtime state and metadata associated with a single credential.
|
// 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
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultAuthenticatorFactories = map[string]func() clipauth.Authenticator{
|
var (
|
||||||
"codex": func() clipauth.Authenticator { return clipauth.NewCodexAuthenticator() },
|
refreshLeadMu sync.RWMutex
|
||||||
"claude": func() clipauth.Authenticator { return clipauth.NewClaudeAuthenticator() },
|
refreshLeadFactories = make(map[string]func() *time.Duration)
|
||||||
"qwen": func() clipauth.Authenticator { return clipauth.NewQwenAuthenticator() },
|
)
|
||||||
"gemini": func() clipauth.Authenticator { return clipauth.NewGeminiAuthenticator() },
|
|
||||||
"gemini-cli": func() clipauth.Authenticator { return clipauth.NewGeminiAuthenticator() },
|
func RegisterRefreshLeadProvider(provider string, factory func() *time.Duration) {
|
||||||
"gemini-web": func() clipauth.Authenticator { return clipauth.NewGeminiWebAuthenticator() },
|
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"}
|
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 {
|
func ProviderRefreshLead(provider string, runtime any) *time.Duration {
|
||||||
provider = strings.ToLower(provider)
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
if runtime != nil {
|
if runtime != nil {
|
||||||
if eval, ok := runtime.(interface{ RefreshLead() *time.Duration }); ok {
|
if eval, ok := runtime.(interface{ RefreshLead() *time.Duration }); ok {
|
||||||
if lead := eval.RefreshLead(); lead != nil && *lead > 0 {
|
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 {
|
refreshLeadMu.RLock()
|
||||||
if auth := factory(); auth != nil {
|
factory := refreshLeadFactories[provider]
|
||||||
if lead := auth.RefreshLead(); lead != nil && *lead > 0 {
|
refreshLeadMu.RUnlock()
|
||||||
return lead
|
if factory == nil {
|
||||||
}
|
return nil
|
||||||
}
|
}
|
||||||
|
if lead := factory(); lead != nil && *lead > 0 {
|
||||||
|
return lead
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,15 @@ func (b *Builder) Build() (*Service, error) {
|
|||||||
|
|
||||||
coreManager := b.coreManager
|
coreManager := b.coreManager
|
||||||
if coreManager == nil {
|
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.
|
// Attach a default RoundTripper provider so providers can opt-in per-auth transports.
|
||||||
coreManager.SetRoundTripperProvider(newDefaultRoundTripperProvider())
|
coreManager.SetRoundTripperProvider(newDefaultRoundTripperProvider())
|
||||||
|
|||||||
Reference in New Issue
Block a user