package auth import ( "context" "encoding/json" "fmt" "io/fs" "net/http" "os" "path/filepath" "runtime" "strings" "sync" "sync/atomic" "time" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" ) // PluginAuthParser parses auth JSON owned by plugin providers. 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 } var pluginAuthParserValue atomic.Value // RegisterPluginAuthParser registers the current plugin auth parser. func RegisterPluginAuthParser(parser PluginAuthParser) { pluginAuthParserValue.Store(pluginAuthParserHolder{parser: parser}) } func currentPluginAuthParser() PluginAuthParser { value := pluginAuthParserValue.Load() if value == nil { return nil } holder, ok := value.(pluginAuthParserHolder) if !ok { return nil } return holder.parser } // 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. 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 persists token storage and metadata to the resolved auth file path. func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) { if auth == nil { return "", fmt.Errorf("auth filestore: auth is nil") } cliproxyauth.NormalizeCredentialMetadata(auth.Metadata) if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil { return "", fmt.Errorf("auth filestore: %w", errWeight) } 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 auth.Disabled { if _, statErr := os.Stat(path); 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) } // metadataSetter is a private interface for TokenStorage implementations that support metadata injection. type metadataSetter interface { SetMetadata(map[string]any) } switch { case auth.Storage != nil: if auth.Metadata == nil { auth.Metadata = make(map[string]any) } auth.Metadata["disabled"] = auth.Disabled if setter, ok := auth.Storage.(metadataSetter); ok { setter.SetMetadata(auth.Metadata) } if err = auth.Storage.SaveTokenToFile(path); err != nil { return "", err } case auth.Metadata != nil: auth.Metadata["disabled"] = auth.Disabled raw, errMarshal := json.Marshal(auth.Metadata) if errMarshal != nil { return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal) } if existing, errRead := os.ReadFile(path); errRead == nil { if jsonEqual(existing, raw) { break } file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) if errOpen != nil { return "", fmt.Errorf("auth filestore: open existing failed: %w", errOpen) } if _, errWrite := file.Write(raw); errWrite != nil { _ = file.Close() return "", fmt.Errorf("auth filestore: write existing failed: %w", errWrite) } if errClose := file.Close(); errClose != nil { return "", fmt.Errorf("auth filestore: close existing failed: %w", errClose) } break } else if !os.IsNotExist(errRead) { return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead) } if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil { return "", fmt.Errorf("auth filestore: write file failed: %w", errWrite) } default: return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID) } if auth.Attributes == nil { auth.Attributes = make(map[string]string) } auth.Attributes[cliproxyauth.AttributePath] = path auth.Attributes[cliproxyauth.AttributeSource] = path auth.Attributes[cliproxyauth.AttributeSourceBackend] = cliproxyauth.AuthSourceFile if strings.TrimSpace(auth.FileName) == "" { auth.FileName = auth.ID } return path, 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 } auths, errReadAuths := s.readAuthFiles(path, dir) if errReadAuths != nil { return nil } if len(auths) > 0 { entries = append(entries, auths...) } return nil }) if err != nil { return nil, err } return entries, 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) readAuthFiles(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) } cliproxyauth.NormalizeCredentialMetadata(metadata) if errWeight := cliproxyauth.ValidateAuthWeight(&cliproxyauth.Auth{Metadata: metadata}); errWeight != nil { return nil, errWeight } provider, _ := metadata["type"].(string) provider = strings.TrimSpace(provider) if strings.EqualFold(provider, "gemini") { return nil, nil } info, errStat := os.Stat(path) if errStat != nil { return nil, fmt.Errorf("stat file: %w", errStat) } if parser := currentPluginAuthParser(); parser != nil { auths, handled, errParse := parsePluginAuthFile(parser, pluginapi.AuthParseRequest{ Provider: provider, Path: path, FileName: s.idFor(path, baseDir), RawJSON: data, }) if errParse == nil && handled { auths = compactPluginAuths(auths) if len(auths) == 0 { return nil, nil } disabled, _ := metadata["disabled"].(bool) for index, auth := range auths { if auth == nil { continue } cliproxyauth.NormalizeCredentialMetadata(auth.Metadata) 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[cliproxyauth.AttributePath] = path auth.Attributes[cliproxyauth.AttributeSource] = path auth.Attributes[cliproxyauth.AttributeSourceBackend] = cliproxyauth.AuthSourceFile if disabled { auth.Disabled = true auth.Status = cliproxyauth.StatusDisabled if auth.Metadata == nil { auth.Metadata = make(map[string]any) } auth.Metadata["disabled"] = true } if errWeight := cliproxyauth.ApplyAuthWeightMetadata(auth, metadata); errWeight != nil { return nil, errWeight } cliproxyauth.ApplyCustomHeadersFromMetadata(auth) } return auths, nil } } if provider == "" { provider = "unknown" } if provider == "antigravity" { projectID := "" if pid, ok := metadata["project_id"].(string); ok { projectID = strings.TrimSpace(pid) } if projectID == "" { accessToken := extractAccessToken(metadata) if accessToken != "" { fetchedProjectID, errFetch := FetchAntigravityProjectID(context.Background(), accessToken, http.DefaultClient) if errFetch == nil && strings.TrimSpace(fetchedProjectID) != "" { metadata["project_id"] = strings.TrimSpace(fetchedProjectID) if raw, errMarshal := json.Marshal(metadata); errMarshal == nil { if file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600); errOpen == nil { _, _ = file.Write(raw) _ = file.Close() } } } } } } info, errStat = os.Stat(path) if errStat != nil { return nil, fmt.Errorf("stat file: %w", errStat) } id := s.idFor(path, baseDir) disabled, _ := metadata["disabled"].(bool) status := cliproxyauth.StatusActive if disabled { status = cliproxyauth.StatusDisabled } auth := &cliproxyauth.Auth{ ID: id, Provider: provider, FileName: id, Label: s.labelFor(metadata), Status: status, Disabled: disabled, Attributes: map[string]string{ cliproxyauth.AttributePath: path, cliproxyauth.AttributeSource: path, cliproxyauth.AttributeSourceBackend: cliproxyauth.AuthSourceFile, }, 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 } cliproxyauth.ApplyCustomHeadersFromMetadata(auth) 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 } if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil { continue } out = append(out, auth) } return out } func (s *FileTokenStore) idFor(path, baseDir string) string { id := path if baseDir != "" { if rel, errRel := filepath.Rel(baseDir, path); errRel == nil && rel != "" { id = rel } } // On Windows, normalize ID casing to avoid duplicate auth entries caused by case-insensitive paths. if runtime.GOOS == "windows" { id = strings.ToLower(id) } return id } 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 fileName := strings.TrimSpace(auth.FileName); fileName != "" { if filepath.IsAbs(fileName) { return fileName, nil } if dir := s.baseDirSnapshot(); dir != "" { return filepath.Join(dir, fileName), nil } return fileName, 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) baseDirSnapshot() string { s.dirLock.RLock() defer s.dirLock.RUnlock() return s.baseDir } func extractAccessToken(metadata map[string]any) string { if at, ok := metadata["access_token"].(string); ok { if v := strings.TrimSpace(at); v != "" { return v } } if tokenMap, ok := metadata["token"].(map[string]any); ok { if at, ok := tokenMap["access_token"].(string); ok { if v := strings.TrimSpace(at); v != "" { return v } } } return "" } // jsonEqual compares two JSON blobs by parsing them into Go objects and deep comparing. 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 } }