diff --git a/MANAGEMENT_API.md b/MANAGEMENT_API.md index 4e9841f0..a456c00f 100644 --- a/MANAGEMENT_API.md +++ b/MANAGEMENT_API.md @@ -173,6 +173,7 @@ If a plaintext key is detected in the config at startup, it will be bcrypt‑has ``` ### API Keys (proxy service auth) +These endpoints update the inline `config-api-key` provider inside the `auth.providers` section of the configuration. Legacy top-level `api-keys` remain in sync automatically. - GET `/api-keys` — Return the full list - Request: ```bash diff --git a/MANAGEMENT_API_CN.md b/MANAGEMENT_API_CN.md index e9b67d7d..687de194 100644 --- a/MANAGEMENT_API_CN.md +++ b/MANAGEMENT_API_CN.md @@ -173,6 +173,7 @@ ``` ### API Keys(代理服务认证) +这些接口会更新配置中 `auth.providers` 内置的 `config-api-key` 提供方,旧版顶层 `api-keys` 会自动保持同步。 - GET `/api-keys` — 返回完整列表 - 请求: ```bash diff --git a/README.md b/README.md index aa9d0969..a0c865c0 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,12 @@ The server uses a YAML configuration file (`config.yaml`) located in the project | `quota-exceeded.switch-project` | boolean | true | Whether to automatically switch to another project when a quota is exceeded. | | `quota-exceeded.switch-preview-model` | boolean | true | Whether to automatically switch to a preview model when a quota is exceeded. | | `debug` | boolean | false | Enable debug mode for verbose logging. | -| `api-keys` | string[] | [] | List of API keys that can be used to authenticate requests. | +| `auth` | object | {} | Request authentication configuration. | +| `auth.providers` | object[] | [] | Authentication providers. Includes built-in `config-api-key` for inline keys. | +| `auth.providers.*.name` | string | "" | Provider instance name. | +| `auth.providers.*.type` | string | "" | Provider implementation identifier (for example `config-api-key`). | +| `auth.providers.*.api-keys` | string[] | [] | Inline API keys consumed by the `config-api-key` provider. | +| `api-keys` | string[] | [] | Legacy shorthand for inline API keys. Values are mirrored into the `config-api-key` provider for backwards compatibility. | | `generative-language-api-key` | string[] | [] | List of Generative Language API keys. | | `force-gpt-5-codex` | bool | false | Force the conversion of GPT-5 calls to GPT-5 Codex. | | `codex-api-key` | object | {} | List of Codex API keys. | @@ -334,10 +339,14 @@ gemini-web: max-chars-per-request: 1000000 # Max characters per request token-refresh-seconds: 540 # Cookie refresh interval in seconds -# API keys for authentication -api-keys: - - "your-api-key-1" - - "your-api-key-2" +# Request authentication providers +auth: + providers: + - name: "default" + type: "config-api-key" + api-keys: + - "your-api-key-1" + - "your-api-key-2" # API keys for official Generative Language API generative-language-api-key: @@ -408,14 +417,21 @@ And you can always use Gemini CLI with `CODE_ASSIST_ENDPOINT` set to `http://127 The `auth-dir` parameter specifies where authentication tokens are stored. When you run the login command, the application will create JSON files in this directory containing the authentication tokens for your Google accounts. Multiple accounts can be used for load balancing. -### API Keys +### Request Authentication Providers -The `api-keys` parameter allows you to define a list of API keys that can be used to authenticate requests to your proxy server. When making requests to the API, you can include one of these keys in the `Authorization` header: +Configure inbound authentication through the `auth.providers` section. The built-in `config-api-key` provider works with inline keys: ``` -Authorization: Bearer your-api-key-1 +auth: + providers: + - name: default + type: config-api-key + api-keys: + - your-api-key-1 ``` +Clients should send requests with an `Authorization: Bearer your-api-key-1` header (or `X-Goog-Api-Key`, `X-Api-Key`, or `?key=` as before). The legacy top-level `api-keys` array is still accepted and automatically synced to the default provider for backwards compatibility. + ### Official Generative Language API The `generative-language-api-key` parameter allows you to define a list of API keys that can be used to authenticate requests to the official Generative Language API. diff --git a/README_CN.md b/README_CN.md index f9a2d902..8b6e1475 100644 --- a/README_CN.md +++ b/README_CN.md @@ -282,7 +282,12 @@ console.log(await claudeResponse.json()); | `quota-exceeded.switch-project` | boolean | true | 当配额超限时,是否自动切换到另一个项目。 | | `quota-exceeded.switch-preview-model` | boolean | true | 当配额超限时,是否自动切换到预览模型。 | | `debug` | boolean | false | 启用调试模式以获取详细日志。 | -| `api-keys` | string[] | [] | 可用于验证请求的API密钥列表。 | +| `auth` | object | {} | 请求鉴权配置。 | +| `auth.providers` | object[] | [] | 鉴权提供方列表,内置 `config-api-key` 支持内联密钥。 | +| `auth.providers.*.name` | string | "" | 提供方实例名称。 | +| `auth.providers.*.type` | string | "" | 提供方实现标识(例如 `config-api-key`)。 | +| `auth.providers.*.api-keys` | string[] | [] | `config-api-key` 提供方使用的内联密钥。 | +| `api-keys` | string[] | [] | 兼容旧配置的简写,会自动同步到默认 `config-api-key` 提供方。 | | `generative-language-api-key` | string[] | [] | 生成式语言API密钥列表。 | | `force-gpt-5-codex` | bool | false | 强制将 GPT-5 调用转换成 GPT-5 Codex。 | | `codex-api-key` | object | {} | Codex API密钥列表。 | @@ -346,10 +351,14 @@ gemini-web: max-chars-per-request: 1000000 # 单次请求最大字符数 token-refresh-seconds: 540 # Cookie 刷新间隔(秒) -# 用于本地身份验证的 API 密钥 -api-keys: - - "your-api-key-1" - - "your-api-key-2" +# 请求鉴权提供方 +auth: + providers: + - name: "default" + type: "config-api-key" + api-keys: + - "your-api-key-1" + - "your-api-key-2" # AIStduio Gemini API 的 API 密钥 generative-language-api-key: @@ -415,14 +424,21 @@ openai-compatibility: `auth-dir` 参数指定身份验证令牌的存储位置。当您运行登录命令时,应用程序将在此目录中创建包含 Google 账户身份验证令牌的 JSON 文件。多个账户可用于轮询。 -### API 密钥 +### 请求鉴权提供方 -`api-keys` 参数允许您定义可用于验证对代理服务器请求的 API 密钥列表。在向 API 发出请求时,您可以在 `Authorization` 标头中包含其中一个密钥: +通过 `auth.providers` 配置接入请求鉴权。内置的 `config-api-key` 提供方支持内联密钥: ``` -Authorization: Bearer your-api-key-1 +auth: + providers: + - name: default + type: config-api-key + api-keys: + - your-api-key-1 ``` +调用时可在 `Authorization` 标头中携带密钥(或继续使用 `X-Goog-Api-Key`、`X-Api-Key`、查询参数 `key`)。为了兼容旧版本,顶层的 `api-keys` 字段仍然可用,并会自动同步到默认的 `config-api-key` 提供方。 + ### 官方生成式语言 API `generative-language-api-key` 参数允许您定义可用于验证对官方 AIStudio Gemini API 请求的 API 密钥列表。 diff --git a/config.example.yaml b/config.example.yaml index 635c87a3..8e29a1ca 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -29,10 +29,14 @@ quota-exceeded: switch-project: true # Whether to automatically switch to another project when a quota is exceeded switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded -# API keys for authentication -api-keys: - - "your-api-key-1" - - "your-api-key-2" +# Request authentication providers +auth: + providers: + - name: "default" + type: "config-api-key" + api-keys: + - "your-api-key-1" + - "your-api-key-2" # API keys for official Generative Language API generative-language-api-key: diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 97745d3f..f9230984 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -9,7 +9,7 @@ import ( ) // Generic helpers for list[string] -func (h *Handler) putStringList(c *gin.Context, set func([]string)) { +func (h *Handler) putStringList(c *gin.Context, set func([]string), after func()) { data, err := c.GetRawData() if err != nil { c.JSON(400, gin.H{"error": "failed to read body"}) @@ -27,10 +27,13 @@ func (h *Handler) putStringList(c *gin.Context, set func([]string)) { arr = obj.Items } set(arr) + if after != nil { + after() + } h.persist(c) } -func (h *Handler) patchStringList(c *gin.Context, target *[]string) { +func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) { var body struct { Old *string `json:"old"` New *string `json:"new"` @@ -43,6 +46,9 @@ func (h *Handler) patchStringList(c *gin.Context, target *[]string) { } if body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) { (*target)[*body.Index] = *body.Value + if after != nil { + after() + } h.persist(c) return } @@ -50,23 +56,32 @@ func (h *Handler) patchStringList(c *gin.Context, target *[]string) { for i := range *target { if (*target)[i] == *body.Old { (*target)[i] = *body.New + if after != nil { + after() + } h.persist(c) return } } *target = append(*target, *body.New) + if after != nil { + after() + } h.persist(c) return } c.JSON(400, gin.H{"error": "missing fields"}) } -func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string) { +func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after func()) { if idxStr := c.Query("index"); idxStr != "" { var idx int _, err := fmt.Sscanf(idxStr, "%d", &idx) if err == nil && idx >= 0 && idx < len(*target) { *target = append((*target)[:idx], (*target)[idx+1:]...) + if after != nil { + after() + } h.persist(c) return } @@ -79,6 +94,9 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string) { } } *target = out + if after != nil { + after() + } h.persist(c) return } @@ -88,20 +106,24 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string) { // api-keys func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) } func (h *Handler) PutAPIKeys(c *gin.Context) { - h.putStringList(c, func(v []string) { h.cfg.APIKeys = v }) + h.putStringList(c, func(v []string) { config.SyncInlineAPIKeys(h.cfg, v) }, nil) +} +func (h *Handler) PatchAPIKeys(c *gin.Context) { + h.patchStringList(c, &h.cfg.APIKeys, func() { config.SyncInlineAPIKeys(h.cfg, h.cfg.APIKeys) }) +} +func (h *Handler) DeleteAPIKeys(c *gin.Context) { + h.deleteFromStringList(c, &h.cfg.APIKeys, func() { config.SyncInlineAPIKeys(h.cfg, h.cfg.APIKeys) }) } -func (h *Handler) PatchAPIKeys(c *gin.Context) { h.patchStringList(c, &h.cfg.APIKeys) } -func (h *Handler) DeleteAPIKeys(c *gin.Context) { h.deleteFromStringList(c, &h.cfg.APIKeys) } // generative-language-api-key func (h *Handler) GetGlKeys(c *gin.Context) { c.JSON(200, gin.H{"generative-language-api-key": h.cfg.GlAPIKey}) } func (h *Handler) PutGlKeys(c *gin.Context) { - h.putStringList(c, func(v []string) { h.cfg.GlAPIKey = v }) + h.putStringList(c, func(v []string) { h.cfg.GlAPIKey = v }, nil) } -func (h *Handler) PatchGlKeys(c *gin.Context) { h.patchStringList(c, &h.cfg.GlAPIKey) } -func (h *Handler) DeleteGlKeys(c *gin.Context) { h.deleteFromStringList(c, &h.cfg.GlAPIKey) } +func (h *Handler) PatchGlKeys(c *gin.Context) { h.patchStringList(c, &h.cfg.GlAPIKey, nil) } +func (h *Handler) DeleteGlKeys(c *gin.Context) { h.deleteFromStringList(c, &h.cfg.GlAPIKey, nil) } // claude-api-key: []ClaudeKey func (h *Handler) GetClaudeKeys(c *gin.Context) { diff --git a/internal/api/server.go b/internal/api/server.go index 8d6f1c61..7be359e8 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -23,6 +23,7 @@ import ( "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/util" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) @@ -84,6 +85,9 @@ type Server struct { // cfg holds the current server configuration. cfg *config.Config + // accessManager handles request authentication providers. + accessManager *sdkaccess.Manager + // requestLogger is the request logger instance for dynamic configuration updates. requestLogger logging.RequestLogger loggerToggle func(bool) @@ -100,10 +104,12 @@ type Server struct { // // Parameters: // - cfg: The server configuration +// - authManager: core runtime auth manager +// - accessManager: request authentication manager // // Returns: // - *Server: A new server instance -func NewServer(cfg *config.Config, authManager *auth.Manager, configFilePath string, opts ...ServerOption) *Server { +func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdkaccess.Manager, configFilePath string, opts ...ServerOption) *Server { optionState := &serverOptionConfig{ requestLoggerFactory: defaultRequestLoggerFactory, } @@ -149,10 +155,12 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, configFilePath str engine: engine, handlers: handlers.NewBaseAPIHandlers(cfg, authManager), cfg: cfg, + accessManager: accessManager, requestLogger: requestLogger, loggerToggle: toggle, configFilePath: configFilePath, } + s.applyAccessConfig(cfg) // Initialize management handler s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager) @@ -180,9 +188,11 @@ func (s *Server) setupRoutes() { claudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers) openaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers) + cfgSupplier := func() *config.Config { return s.cfg } + // OpenAI compatible API routes v1 := s.engine.Group("/v1") - v1.Use(AuthMiddleware(s.cfg)) + v1.Use(AuthMiddleware(cfgSupplier, s.accessManager)) { v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers)) v1.POST("/chat/completions", openaiHandlers.ChatCompletions) @@ -193,7 +203,7 @@ func (s *Server) setupRoutes() { // Gemini compatible API routes v1beta := s.engine.Group("/v1beta") - v1beta.Use(AuthMiddleware(s.cfg)) + v1beta.Use(AuthMiddleware(cfgSupplier, s.accessManager)) { v1beta.GET("/models", geminiHandlers.GeminiModels) v1beta.POST("/models/:action", geminiHandlers.GeminiHandler) @@ -409,6 +419,18 @@ func corsMiddleware() gin.HandlerFunc { } } +func (s *Server) applyAccessConfig(cfg *config.Config) { + if s == nil || s.accessManager == nil { + return + } + providers, err := sdkaccess.BuildProviders(cfg) + if err != nil { + log.Errorf("failed to update request auth providers: %v", err) + return + } + s.accessManager.SetProviders(providers) +} + // UpdateClients updates the server's client list and configuration. // This method is called when the configuration or authentication tokens change. // @@ -438,6 +460,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { s.mgmt.SetConfig(cfg) s.mgmt.SetAuthManager(s.handlers.AuthManager) } + s.applyAccessConfig(cfg) // Count client sources from configuration and auth directory authFiles := util.CountAuthFiles(cfg.AuthDir) @@ -463,68 +486,46 @@ func (s *Server) UpdateClients(cfg *config.Config) { // (management handlers moved to internal/api/handlers/management) // AuthMiddleware returns a Gin middleware handler that authenticates requests -// using API keys. If no API keys are configured, it allows all requests. -// -// Parameters: -// - cfg: The server configuration containing API keys -// -// Returns: -// - gin.HandlerFunc: The authentication middleware handler -func AuthMiddleware(cfg *config.Config) gin.HandlerFunc { +// using the configured authentication providers. When no providers are available, +// it allows all requests (legacy behaviour). +func AuthMiddleware(cfgFn func() *config.Config, manager *sdkaccess.Manager) gin.HandlerFunc { return func(c *gin.Context) { - if cfg.AllowLocalhostUnauthenticated && strings.HasPrefix(c.Request.RemoteAddr, "127.0.0.1:") { - c.Next() - return - } - - if len(cfg.APIKeys) == 0 { - c.Next() - return - } - - // Get the Authorization header - authHeader := c.GetHeader("Authorization") - authHeaderGoogle := c.GetHeader("X-Goog-Api-Key") - authHeaderAnthropic := c.GetHeader("X-Api-Key") - - // Get the API key from the query parameter - apiKeyQuery, _ := c.GetQuery("key") - - if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && apiKeyQuery == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "Missing API key", - }) - return - } - - // Extract the API key - parts := strings.Split(authHeader, " ") - var apiKey string - if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { - apiKey = parts[1] - } else { - apiKey = authHeader - } - - // Find the API key in the in-memory list - var foundKey string - for i := range cfg.APIKeys { - if cfg.APIKeys[i] == apiKey || cfg.APIKeys[i] == authHeaderGoogle || cfg.APIKeys[i] == authHeaderAnthropic || cfg.APIKeys[i] == apiKeyQuery { - foundKey = cfg.APIKeys[i] - break + cfg := cfgFn() + if cfg != nil && cfg.AllowLocalhostUnauthenticated { + ip := c.ClientIP() + if ip == "127.0.0.1" || ip == "::1" || strings.HasPrefix(c.Request.RemoteAddr, "127.0.0.1:") || strings.HasPrefix(c.Request.RemoteAddr, "[::1]:") { + c.Next() + return } } - if foundKey == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "error": "Invalid API key", - }) + + if manager == nil { + c.Next() return } - // Store the API key and user in the context - c.Set("apiKey", foundKey) + result, err := manager.Authenticate(c.Request.Context(), c.Request) + if err == nil { + if result != nil { + c.Set("apiKey", result.Principal) + c.Set("accessProvider", result.Provider) + if len(result.Metadata) > 0 { + c.Set("accessMetadata", result.Metadata) + } + } + c.Next() + return + } - c.Next() + switch { + case errors.Is(err, sdkaccess.ErrNoCredentials): + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing API key"}) + case errors.Is(err, sdkaccess.ErrInvalidCredential): + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"}) + default: + log.Errorf("authentication middleware error: %v", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Authentication service error"}) + } } } diff --git a/internal/config/config.go b/internal/config/config.go index 9f53092d..8f413362 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,9 @@ type Config struct { // APIKeys is a list of keys for authenticating clients to this proxy server. APIKeys []string `yaml:"api-keys" json:"api-keys"` + // Access holds request authentication provider configuration. + Access AccessConfig `yaml:"auth" json:"auth"` + // QuotaExceeded defines the behavior when a quota is exceeded. QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"` @@ -63,6 +66,38 @@ type Config struct { GeminiWeb GeminiWebConfig `yaml:"gemini-web" json:"gemini-web"` } +// AccessConfig groups request authentication providers. +type AccessConfig struct { + // Providers lists configured authentication providers. + Providers []AccessProvider `yaml:"providers" json:"providers"` +} + +// AccessProvider describes a request authentication provider entry. +type AccessProvider struct { + // Name is the instance identifier for the provider. + Name string `yaml:"name" json:"name"` + + // Type selects the provider implementation registered via the SDK. + Type string `yaml:"type" json:"type"` + + // SDK optionally names a third-party SDK module providing this provider. + SDK string `yaml:"sdk,omitempty" json:"sdk,omitempty"` + + // APIKeys lists inline keys for providers that require them. + APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"` + + // Config passes provider-specific options to the implementation. + Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"` +} + +const ( + // AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys. + AccessProviderTypeConfigAPIKey = "config-api-key" + + // DefaultAccessProviderName is applied when no provider name is supplied. + DefaultAccessProviderName = "config-inline" +) + // GeminiWebConfig nests Gemini Web related options under 'gemini-web'. type GeminiWebConfig struct { // Context enables JSON-based conversation reuse. @@ -196,10 +231,83 @@ func LoadConfig(configFile string) (*Config, error) { _ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, []string{"remote-management", "secret-key"}, hashed) } + // Sync request authentication providers with inline API keys for backwards compatibility. + syncInlineAccessProvider(&config) + // Return the populated configuration struct. return &config, nil } +// SyncInlineAPIKeys updates the inline API key provider and top-level APIKeys field. +func SyncInlineAPIKeys(cfg *Config, keys []string) { + if cfg == nil { + return + } + cloned := append([]string(nil), keys...) + cfg.APIKeys = cloned + if provider := cfg.ConfigAPIKeyProvider(); provider != nil { + if provider.Name == "" { + provider.Name = DefaultAccessProviderName + } + provider.APIKeys = cloned + return + } + cfg.Access.Providers = append(cfg.Access.Providers, AccessProvider{ + Name: DefaultAccessProviderName, + Type: AccessProviderTypeConfigAPIKey, + APIKeys: cloned, + }) +} + +// ConfigAPIKeyProvider returns the first inline API key provider if present. +func (c *Config) ConfigAPIKeyProvider() *AccessProvider { + if c == nil { + return nil + } + for i := range c.Access.Providers { + if c.Access.Providers[i].Type == AccessProviderTypeConfigAPIKey { + if c.Access.Providers[i].Name == "" { + c.Access.Providers[i].Name = DefaultAccessProviderName + } + return &c.Access.Providers[i] + } + } + return nil +} + +func syncInlineAccessProvider(cfg *Config) { + if cfg == nil { + return + } + if len(cfg.Access.Providers) == 0 { + if len(cfg.APIKeys) == 0 { + return + } + cfg.Access.Providers = append(cfg.Access.Providers, AccessProvider{ + Name: DefaultAccessProviderName, + Type: AccessProviderTypeConfigAPIKey, + APIKeys: append([]string(nil), cfg.APIKeys...), + }) + return + } + provider := cfg.ConfigAPIKeyProvider() + if provider == nil { + if len(cfg.APIKeys) == 0 { + return + } + cfg.Access.Providers = append(cfg.Access.Providers, AccessProvider{ + Name: DefaultAccessProviderName, + Type: AccessProviderTypeConfigAPIKey, + APIKeys: append([]string(nil), cfg.APIKeys...), + }) + return + } + if len(provider.APIKeys) == 0 && len(cfg.APIKeys) > 0 { + provider.APIKeys = append([]string(nil), cfg.APIKeys...) + } + cfg.APIKeys = append([]string(nil), provider.APIKeys...) +} + // looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash. func looksLikeBcrypt(s string) bool { return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$") diff --git a/sdk/access/errors.go b/sdk/access/errors.go new file mode 100644 index 00000000..6ea2cc1a --- /dev/null +++ b/sdk/access/errors.go @@ -0,0 +1,12 @@ +package access + +import "errors" + +var ( + // ErrNoCredentials indicates no recognizable credentials were supplied. + ErrNoCredentials = errors.New("access: no credentials provided") + // ErrInvalidCredential signals that supplied credentials were rejected by a provider. + ErrInvalidCredential = errors.New("access: invalid credential") + // ErrNotHandled tells the manager to continue trying other providers. + ErrNotHandled = errors.New("access: not handled") +) diff --git a/sdk/access/manager.go b/sdk/access/manager.go new file mode 100644 index 00000000..fb5f8cca --- /dev/null +++ b/sdk/access/manager.go @@ -0,0 +1,89 @@ +package access + +import ( + "context" + "errors" + "net/http" + "sync" +) + +// Manager coordinates authentication providers. +type Manager struct { + mu sync.RWMutex + providers []Provider +} + +// NewManager constructs an empty manager. +func NewManager() *Manager { + return &Manager{} +} + +// SetProviders replaces the active provider list. +func (m *Manager) SetProviders(providers []Provider) { + if m == nil { + return + } + cloned := make([]Provider, len(providers)) + copy(cloned, providers) + m.mu.Lock() + m.providers = cloned + m.mu.Unlock() +} + +// Providers returns a snapshot of the active providers. +func (m *Manager) Providers() []Provider { + if m == nil { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + snapshot := make([]Provider, len(m.providers)) + copy(snapshot, m.providers) + return snapshot +} + +// Authenticate evaluates providers until one succeeds. +func (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, error) { + if m == nil { + return nil, nil + } + providers := m.Providers() + if len(providers) == 0 { + return nil, nil + } + + var ( + missing bool + invalid bool + ) + + for _, provider := range providers { + if provider == nil { + continue + } + res, err := provider.Authenticate(ctx, r) + if err == nil { + return res, nil + } + if errors.Is(err, ErrNotHandled) { + continue + } + if errors.Is(err, ErrNoCredentials) { + missing = true + continue + } + if errors.Is(err, ErrInvalidCredential) { + invalid = true + continue + } + return nil, err + } + + if invalid { + return nil, ErrInvalidCredential + } + if missing { + return nil, ErrNoCredentials + } + return nil, ErrNoCredentials +} diff --git a/sdk/access/providers/configapikey/provider.go b/sdk/access/providers/configapikey/provider.go new file mode 100644 index 00000000..f8f9dce6 --- /dev/null +++ b/sdk/access/providers/configapikey/provider.go @@ -0,0 +1,103 @@ +package configapikey + +import ( + "context" + "net/http" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" +) + +type provider struct { + name string + keys map[string]struct{} +} + +func init() { + sdkaccess.RegisterProvider(config.AccessProviderTypeConfigAPIKey, newProvider) +} + +func newProvider(cfg *config.AccessProvider, _ *config.Config) (sdkaccess.Provider, error) { + name := cfg.Name + if name == "" { + name = config.DefaultAccessProviderName + } + keys := make(map[string]struct{}, len(cfg.APIKeys)) + for _, key := range cfg.APIKeys { + if key == "" { + continue + } + keys[key] = struct{}{} + } + return &provider{name: name, keys: keys}, nil +} + +func (p *provider) Identifier() string { + if p == nil || p.name == "" { + return config.DefaultAccessProviderName + } + return p.name +} + +func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, error) { + if p == nil { + return nil, sdkaccess.ErrNotHandled + } + if len(p.keys) == 0 { + return nil, sdkaccess.ErrNotHandled + } + authHeader := r.Header.Get("Authorization") + authHeaderGoogle := r.Header.Get("X-Goog-Api-Key") + authHeaderAnthropic := r.Header.Get("X-Api-Key") + queryKey := "" + if r.URL != nil { + queryKey = r.URL.Query().Get("key") + } + if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" { + return nil, sdkaccess.ErrNoCredentials + } + + apiKey := extractBearerToken(authHeader) + + candidates := []struct { + value string + source string + }{ + {apiKey, "authorization"}, + {authHeaderGoogle, "x-goog-api-key"}, + {authHeaderAnthropic, "x-api-key"}, + {queryKey, "query-key"}, + } + + for _, candidate := range candidates { + if candidate.value == "" { + continue + } + if _, ok := p.keys[candidate.value]; ok { + return &sdkaccess.Result{ + Provider: p.Identifier(), + Principal: candidate.value, + Metadata: map[string]string{ + "source": candidate.source, + }, + }, nil + } + } + + return nil, sdkaccess.ErrInvalidCredential +} + +func extractBearerToken(header string) string { + if header == "" { + return "" + } + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 { + return header + } + if strings.ToLower(parts[0]) != "bearer" { + return header + } + return strings.TrimSpace(parts[1]) +} diff --git a/sdk/access/registry.go b/sdk/access/registry.go new file mode 100644 index 00000000..21a9db56 --- /dev/null +++ b/sdk/access/registry.go @@ -0,0 +1,88 @@ +package access + +import ( + "context" + "fmt" + "net/http" + "sync" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +// Provider validates credentials for incoming requests. +type Provider interface { + Identifier() string + Authenticate(ctx context.Context, r *http.Request) (*Result, error) +} + +// Result conveys authentication outcome. +type Result struct { + Provider string + Principal string + Metadata map[string]string +} + +// ProviderFactory builds a provider from configuration data. +type ProviderFactory func(cfg *config.AccessProvider, root *config.Config) (Provider, error) + +var ( + registryMu sync.RWMutex + registry = make(map[string]ProviderFactory) +) + +// RegisterProvider registers a provider factory for a given type identifier. +func RegisterProvider(typ string, factory ProviderFactory) { + if typ == "" || factory == nil { + return + } + registryMu.Lock() + registry[typ] = factory + registryMu.Unlock() +} + +func buildProvider(cfg *config.AccessProvider, root *config.Config) (Provider, error) { + if cfg == nil { + return nil, fmt.Errorf("access: nil provider config") + } + registryMu.RLock() + factory, ok := registry[cfg.Type] + registryMu.RUnlock() + if !ok { + return nil, fmt.Errorf("access: provider type %q is not registered", cfg.Type) + } + provider, err := factory(cfg, root) + if err != nil { + return nil, fmt.Errorf("access: failed to build provider %q: %w", cfg.Name, err) + } + return provider, nil +} + +// BuildProviders constructs providers declared in configuration. +func BuildProviders(root *config.Config) ([]Provider, error) { + if root == nil { + return nil, nil + } + providers := make([]Provider, 0, len(root.Access.Providers)) + for i := range root.Access.Providers { + providerCfg := &root.Access.Providers[i] + if providerCfg.Type == "" { + continue + } + provider, err := buildProvider(providerCfg, root) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + if len(providers) == 0 && len(root.APIKeys) > 0 { + config.SyncInlineAPIKeys(root, root.APIKeys) + if providerCfg := root.ConfigAPIKeyProvider(); providerCfg != nil { + provider, err := buildProvider(providerCfg, root) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + } + return providers, nil +} diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go index aa5a365e..8273e681 100644 --- a/sdk/cliproxy/builder.go +++ b/sdk/cliproxy/builder.go @@ -5,6 +5,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/api" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" ) @@ -18,6 +19,7 @@ type Builder struct { watcherFactory WatcherFactory hooks Hooks authManager *sdkAuth.Manager + accessManager *sdkaccess.Manager coreManager *coreauth.Manager serverOptions []api.ServerOption } @@ -75,6 +77,12 @@ func (b *Builder) WithAuthManager(mgr *sdkAuth.Manager) *Builder { return b } +// WithRequestAccessManager overrides the request authentication manager. +func (b *Builder) WithRequestAccessManager(mgr *sdkaccess.Manager) *Builder { + b.accessManager = mgr + return b +} + // WithCoreAuthManager overrides the runtime auth manager responsible for request execution. func (b *Builder) WithCoreAuthManager(mgr *coreauth.Manager) *Builder { b.coreManager = mgr @@ -116,6 +124,16 @@ func (b *Builder) Build() (*Service, error) { authManager = newDefaultAuthManager() } + accessManager := b.accessManager + if accessManager == nil { + accessManager = sdkaccess.NewManager() + } + providers, err := sdkaccess.BuildProviders(b.cfg) + if err != nil { + return nil, err + } + accessManager.SetProviders(providers) + coreManager := b.coreManager if coreManager == nil { coreManager = coreauth.NewManager(coreauth.NewFileStore(b.cfg.AuthDir), nil, nil) @@ -131,6 +149,7 @@ func (b *Builder) Build() (*Service, error) { watcherFactory: watcherFactory, hooks: b.hooks, authManager: authManager, + accessManager: accessManager, coreManager: coreManager, serverOptions: append([]api.ServerOption(nil), b.serverOptions...), } diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index c42c4c39..43fd33f7 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -16,6 +16,8 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" + _ "github.com/router-for-me/CLIProxyAPI/v6/sdk/access/providers/configapikey" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" @@ -40,8 +42,9 @@ type Service struct { watcherCancel context.CancelFunc // legacy client caches removed - authManager *sdkAuth.Manager - coreManager *coreauth.Manager + authManager *sdkAuth.Manager + accessManager *sdkaccess.Manager + coreManager *coreauth.Manager shutdownOnce sync.Once } @@ -56,6 +59,18 @@ func newDefaultAuthManager() *sdkAuth.Manager { ) } +func (s *Service) refreshAccessProviders(cfg *config.Config) { + if s == nil || s.accessManager == nil || cfg == nil { + return + } + providers, err := sdkaccess.BuildProviders(cfg) + if err != nil { + log.Errorf("failed to rebuild request auth providers: %v", err) + return + } + s.accessManager.SetProviders(providers) +} + // Run starts the service and blocks until the context is cancelled or the server stops. func (s *Service) Run(ctx context.Context) error { if s == nil { @@ -102,7 +117,8 @@ func (s *Service) Run(ctx context.Context) error { // legacy clients removed; no caches to refresh // handlers no longer depend on legacy clients; pass nil slice initially - s.server = api.NewServer(s.cfg, s.coreManager, s.configPath, s.serverOptions...) + s.refreshAccessProviders(s.cfg) + s.server = api.NewServer(s.cfg, s.coreManager, s.accessManager, s.configPath, s.serverOptions...) if s.authManager == nil { s.authManager = newDefaultAuthManager() @@ -139,6 +155,7 @@ func (s *Service) Run(ctx context.Context) error { // Pull the latest auth snapshot and sync auths := watcherWrapper.SnapshotAuths() s.syncCoreAuthFromAuths(ctx, auths) + s.refreshAccessProviders(newCfg) if s.server != nil { s.server.UpdateClients(newCfg) }