feat(auth): introduce auth.providers for flexible authentication configuration

- Replaced legacy `api-keys` field with `auth.providers` in configuration, supporting multiple authentication providers including `config-api-key`.
- Added synchronization to maintain compatibility with legacy `api-keys`.
- Updated core components like request handling and middleware to use the new provider system.
- Enhanced management API endpoints for seamless integration with `auth.providers`.
This commit is contained in:
Luis Pater
2025-09-22 17:36:31 +08:00
parent c28a5d24f8
commit 4008be19f4
14 changed files with 587 additions and 90 deletions

View File

@@ -173,6 +173,7 @@ If a plaintext key is detected in the config at startup, it will be bcrypthas
``` ```
### API Keys (proxy service auth) ### 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 - GET `/api-keys` — Return the full list
- Request: - Request:
```bash ```bash

View File

@@ -173,6 +173,7 @@
``` ```
### API Keys代理服务认证 ### API Keys代理服务认证
这些接口会更新配置中 `auth.providers` 内置的 `config-api-key` 提供方,旧版顶层 `api-keys` 会自动保持同步。
- GET `/api-keys` — 返回完整列表 - GET `/api-keys` — 返回完整列表
- 请求: - 请求:
```bash ```bash

View File

@@ -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-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. | | `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. | | `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. | | `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. | | `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. | | `codex-api-key` | object | {} | List of Codex API keys. |
@@ -334,10 +339,14 @@ gemini-web:
max-chars-per-request: 1000000 # Max characters per request max-chars-per-request: 1000000 # Max characters per request
token-refresh-seconds: 540 # Cookie refresh interval in seconds token-refresh-seconds: 540 # Cookie refresh interval in seconds
# API keys for authentication # Request authentication providers
api-keys: auth:
- "your-api-key-1" providers:
- "your-api-key-2" - name: "default"
type: "config-api-key"
api-keys:
- "your-api-key-1"
- "your-api-key-2"
# API keys for official Generative Language API # API keys for official Generative Language API
generative-language-api-key: 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. 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 ### 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. 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.

View File

@@ -282,7 +282,12 @@ console.log(await claudeResponse.json());
| `quota-exceeded.switch-project` | boolean | true | 当配额超限时,是否自动切换到另一个项目。 | | `quota-exceeded.switch-project` | boolean | true | 当配额超限时,是否自动切换到另一个项目。 |
| `quota-exceeded.switch-preview-model` | boolean | true | 当配额超限时,是否自动切换到预览模型。 | | `quota-exceeded.switch-preview-model` | boolean | true | 当配额超限时,是否自动切换到预览模型。 |
| `debug` | boolean | false | 启用调试模式以获取详细日志。 | | `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密钥列表。 | | `generative-language-api-key` | string[] | [] | 生成式语言API密钥列表。 |
| `force-gpt-5-codex` | bool | false | 强制将 GPT-5 调用转换成 GPT-5 Codex。 | | `force-gpt-5-codex` | bool | false | 强制将 GPT-5 调用转换成 GPT-5 Codex。 |
| `codex-api-key` | object | {} | Codex API密钥列表。 | | `codex-api-key` | object | {} | Codex API密钥列表。 |
@@ -346,10 +351,14 @@ gemini-web:
max-chars-per-request: 1000000 # 单次请求最大字符数 max-chars-per-request: 1000000 # 单次请求最大字符数
token-refresh-seconds: 540 # Cookie 刷新间隔(秒) token-refresh-seconds: 540 # Cookie 刷新间隔(秒)
# 用于本地身份验证的 API 密钥 # 请求鉴权提供方
api-keys: auth:
- "your-api-key-1" providers:
- "your-api-key-2" - name: "default"
type: "config-api-key"
api-keys:
- "your-api-key-1"
- "your-api-key-2"
# AIStduio Gemini API 的 API 密钥 # AIStduio Gemini API 的 API 密钥
generative-language-api-key: generative-language-api-key:
@@ -415,14 +424,21 @@ openai-compatibility:
`auth-dir` 参数指定身份验证令牌的存储位置。当您运行登录命令时,应用程序将在此目录中创建包含 Google 账户身份验证令牌的 JSON 文件。多个账户可用于轮询。 `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 ### 官方生成式语言 API
`generative-language-api-key` 参数允许您定义可用于验证对官方 AIStudio Gemini API 请求的 API 密钥列表。 `generative-language-api-key` 参数允许您定义可用于验证对官方 AIStudio Gemini API 请求的 API 密钥列表。

View File

@@ -29,10 +29,14 @@ quota-exceeded:
switch-project: true # Whether to automatically switch to another project when a quota is 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 switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded
# API keys for authentication # Request authentication providers
api-keys: auth:
- "your-api-key-1" providers:
- "your-api-key-2" - name: "default"
type: "config-api-key"
api-keys:
- "your-api-key-1"
- "your-api-key-2"
# API keys for official Generative Language API # API keys for official Generative Language API
generative-language-api-key: generative-language-api-key:

View File

@@ -9,7 +9,7 @@ import (
) )
// Generic helpers for list[string] // 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() data, err := c.GetRawData()
if err != nil { if err != nil {
c.JSON(400, gin.H{"error": "failed to read body"}) 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 arr = obj.Items
} }
set(arr) set(arr)
if after != nil {
after()
}
h.persist(c) 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 { var body struct {
Old *string `json:"old"` Old *string `json:"old"`
New *string `json:"new"` 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) { if body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) {
(*target)[*body.Index] = *body.Value (*target)[*body.Index] = *body.Value
if after != nil {
after()
}
h.persist(c) h.persist(c)
return return
} }
@@ -50,23 +56,32 @@ func (h *Handler) patchStringList(c *gin.Context, target *[]string) {
for i := range *target { for i := range *target {
if (*target)[i] == *body.Old { if (*target)[i] == *body.Old {
(*target)[i] = *body.New (*target)[i] = *body.New
if after != nil {
after()
}
h.persist(c) h.persist(c)
return return
} }
} }
*target = append(*target, *body.New) *target = append(*target, *body.New)
if after != nil {
after()
}
h.persist(c) h.persist(c)
return return
} }
c.JSON(400, gin.H{"error": "missing fields"}) 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 != "" { if idxStr := c.Query("index"); idxStr != "" {
var idx int var idx int
_, err := fmt.Sscanf(idxStr, "%d", &idx) _, err := fmt.Sscanf(idxStr, "%d", &idx)
if err == nil && idx >= 0 && idx < len(*target) { if err == nil && idx >= 0 && idx < len(*target) {
*target = append((*target)[:idx], (*target)[idx+1:]...) *target = append((*target)[:idx], (*target)[idx+1:]...)
if after != nil {
after()
}
h.persist(c) h.persist(c)
return return
} }
@@ -79,6 +94,9 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string) {
} }
} }
*target = out *target = out
if after != nil {
after()
}
h.persist(c) h.persist(c)
return return
} }
@@ -88,20 +106,24 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string) {
// api-keys // api-keys
func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) } func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
func (h *Handler) PutAPIKeys(c *gin.Context) { 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 // generative-language-api-key
func (h *Handler) GetGlKeys(c *gin.Context) { func (h *Handler) GetGlKeys(c *gin.Context) {
c.JSON(200, gin.H{"generative-language-api-key": h.cfg.GlAPIKey}) c.JSON(200, gin.H{"generative-language-api-key": h.cfg.GlAPIKey})
} }
func (h *Handler) PutGlKeys(c *gin.Context) { 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) PatchGlKeys(c *gin.Context) { h.patchStringList(c, &h.cfg.GlAPIKey, nil) }
func (h *Handler) DeleteGlKeys(c *gin.Context) { h.deleteFromStringList(c, &h.cfg.GlAPIKey) } func (h *Handler) DeleteGlKeys(c *gin.Context) { h.deleteFromStringList(c, &h.cfg.GlAPIKey, nil) }
// claude-api-key: []ClaudeKey // claude-api-key: []ClaudeKey
func (h *Handler) GetClaudeKeys(c *gin.Context) { func (h *Handler) GetClaudeKeys(c *gin.Context) {

View File

@@ -23,6 +23,7 @@ import (
"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"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util" "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" "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -84,6 +85,9 @@ type Server struct {
// cfg holds the current server configuration. // cfg holds the current server configuration.
cfg *config.Config cfg *config.Config
// accessManager handles request authentication providers.
accessManager *sdkaccess.Manager
// requestLogger is the request logger instance for dynamic configuration updates. // requestLogger is the request logger instance for dynamic configuration updates.
requestLogger logging.RequestLogger requestLogger logging.RequestLogger
loggerToggle func(bool) loggerToggle func(bool)
@@ -100,10 +104,12 @@ type Server struct {
// //
// Parameters: // Parameters:
// - cfg: The server configuration // - cfg: The server configuration
// - authManager: core runtime auth manager
// - accessManager: request authentication manager
// //
// Returns: // Returns:
// - *Server: A new server instance // - *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{ optionState := &serverOptionConfig{
requestLoggerFactory: defaultRequestLoggerFactory, requestLoggerFactory: defaultRequestLoggerFactory,
} }
@@ -149,10 +155,12 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, configFilePath str
engine: engine, engine: engine,
handlers: handlers.NewBaseAPIHandlers(cfg, authManager), handlers: handlers.NewBaseAPIHandlers(cfg, authManager),
cfg: cfg, cfg: cfg,
accessManager: accessManager,
requestLogger: requestLogger, requestLogger: requestLogger,
loggerToggle: toggle, loggerToggle: toggle,
configFilePath: configFilePath, configFilePath: configFilePath,
} }
s.applyAccessConfig(cfg)
// Initialize management handler // Initialize management handler
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager) s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
@@ -180,9 +188,11 @@ func (s *Server) setupRoutes() {
claudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers) claudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers)
openaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers) openaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers)
cfgSupplier := func() *config.Config { return s.cfg }
// OpenAI compatible API routes // OpenAI compatible API routes
v1 := s.engine.Group("/v1") v1 := s.engine.Group("/v1")
v1.Use(AuthMiddleware(s.cfg)) v1.Use(AuthMiddleware(cfgSupplier, s.accessManager))
{ {
v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers)) v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers))
v1.POST("/chat/completions", openaiHandlers.ChatCompletions) v1.POST("/chat/completions", openaiHandlers.ChatCompletions)
@@ -193,7 +203,7 @@ func (s *Server) setupRoutes() {
// Gemini compatible API routes // Gemini compatible API routes
v1beta := s.engine.Group("/v1beta") v1beta := s.engine.Group("/v1beta")
v1beta.Use(AuthMiddleware(s.cfg)) v1beta.Use(AuthMiddleware(cfgSupplier, s.accessManager))
{ {
v1beta.GET("/models", geminiHandlers.GeminiModels) v1beta.GET("/models", geminiHandlers.GeminiModels)
v1beta.POST("/models/:action", geminiHandlers.GeminiHandler) 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. // UpdateClients updates the server's client list and configuration.
// This method is called when the configuration or authentication tokens change. // 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.SetConfig(cfg)
s.mgmt.SetAuthManager(s.handlers.AuthManager) s.mgmt.SetAuthManager(s.handlers.AuthManager)
} }
s.applyAccessConfig(cfg)
// Count client sources from configuration and auth directory // Count client sources from configuration and auth directory
authFiles := util.CountAuthFiles(cfg.AuthDir) authFiles := util.CountAuthFiles(cfg.AuthDir)
@@ -463,68 +486,46 @@ func (s *Server) UpdateClients(cfg *config.Config) {
// (management handlers moved to internal/api/handlers/management) // (management handlers moved to internal/api/handlers/management)
// AuthMiddleware returns a Gin middleware handler that authenticates requests // AuthMiddleware returns a Gin middleware handler that authenticates requests
// using API keys. If no API keys are configured, it allows all requests. // using the configured authentication providers. When no providers are available,
// // it allows all requests (legacy behaviour).
// Parameters: func AuthMiddleware(cfgFn func() *config.Config, manager *sdkaccess.Manager) gin.HandlerFunc {
// - cfg: The server configuration containing API keys
//
// Returns:
// - gin.HandlerFunc: The authentication middleware handler
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if cfg.AllowLocalhostUnauthenticated && strings.HasPrefix(c.Request.RemoteAddr, "127.0.0.1:") { cfg := cfgFn()
c.Next() if cfg != nil && cfg.AllowLocalhostUnauthenticated {
return 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()
if len(cfg.APIKeys) == 0 { return
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
} }
} }
if foundKey == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ if manager == nil {
"error": "Invalid API key", c.Next()
})
return return
} }
// Store the API key and user in the context result, err := manager.Authenticate(c.Request.Context(), c.Request)
c.Set("apiKey", foundKey) 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"})
}
} }
} }

View File

@@ -29,6 +29,9 @@ type Config struct {
// APIKeys is a list of keys for authenticating clients to this proxy server. // APIKeys is a list of keys for authenticating clients to this proxy server.
APIKeys []string `yaml:"api-keys" json:"api-keys"` 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 defines the behavior when a quota is exceeded.
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"` QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"`
@@ -63,6 +66,38 @@ type Config struct {
GeminiWeb GeminiWebConfig `yaml:"gemini-web" json:"gemini-web"` 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'. // GeminiWebConfig nests Gemini Web related options under 'gemini-web'.
type GeminiWebConfig struct { type GeminiWebConfig struct {
// Context enables JSON-based conversation reuse. // Context enables JSON-based conversation reuse.
@@ -196,10 +231,83 @@ func LoadConfig(configFile string) (*Config, error) {
_ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, []string{"remote-management", "secret-key"}, hashed) _ = 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 the populated configuration struct.
return &config, nil 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. // looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.
func looksLikeBcrypt(s string) bool { func looksLikeBcrypt(s string) bool {
return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$") return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")

12
sdk/access/errors.go Normal file
View File

@@ -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")
)

89
sdk/access/manager.go Normal file
View File

@@ -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
}

View File

@@ -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])
}

88
sdk/access/registry.go Normal file
View File

@@ -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
}

View File

@@ -5,6 +5,7 @@ import (
"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"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
) )
@@ -18,6 +19,7 @@ type Builder struct {
watcherFactory WatcherFactory watcherFactory WatcherFactory
hooks Hooks hooks Hooks
authManager *sdkAuth.Manager authManager *sdkAuth.Manager
accessManager *sdkaccess.Manager
coreManager *coreauth.Manager coreManager *coreauth.Manager
serverOptions []api.ServerOption serverOptions []api.ServerOption
} }
@@ -75,6 +77,12 @@ func (b *Builder) WithAuthManager(mgr *sdkAuth.Manager) *Builder {
return b 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. // WithCoreAuthManager overrides the runtime auth manager responsible for request execution.
func (b *Builder) WithCoreAuthManager(mgr *coreauth.Manager) *Builder { func (b *Builder) WithCoreAuthManager(mgr *coreauth.Manager) *Builder {
b.coreManager = mgr b.coreManager = mgr
@@ -116,6 +124,16 @@ func (b *Builder) Build() (*Service, error) {
authManager = newDefaultAuthManager() 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 coreManager := b.coreManager
if coreManager == nil { if coreManager == nil {
coreManager = coreauth.NewManager(coreauth.NewFileStore(b.cfg.AuthDir), nil, nil) coreManager = coreauth.NewManager(coreauth.NewFileStore(b.cfg.AuthDir), nil, nil)
@@ -131,6 +149,7 @@ func (b *Builder) Build() (*Service, error) {
watcherFactory: watcherFactory, watcherFactory: watcherFactory,
hooks: b.hooks, hooks: b.hooks,
authManager: authManager, authManager: authManager,
accessManager: accessManager,
coreManager: coreManager, coreManager: coreManager,
serverOptions: append([]api.ServerOption(nil), b.serverOptions...), serverOptions: append([]api.ServerOption(nil), b.serverOptions...),
} }

View File

@@ -16,6 +16,8 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "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/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util" "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" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -40,8 +42,9 @@ type Service struct {
watcherCancel context.CancelFunc watcherCancel context.CancelFunc
// legacy client caches removed // legacy client caches removed
authManager *sdkAuth.Manager authManager *sdkAuth.Manager
coreManager *coreauth.Manager accessManager *sdkaccess.Manager
coreManager *coreauth.Manager
shutdownOnce sync.Once 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. // Run starts the service and blocks until the context is cancelled or the server stops.
func (s *Service) Run(ctx context.Context) error { func (s *Service) Run(ctx context.Context) error {
if s == nil { if s == nil {
@@ -102,7 +117,8 @@ func (s *Service) Run(ctx context.Context) error {
// legacy clients removed; no caches to refresh // legacy clients removed; no caches to refresh
// handlers no longer depend on legacy clients; pass nil slice initially // 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 { if s.authManager == nil {
s.authManager = newDefaultAuthManager() s.authManager = newDefaultAuthManager()
@@ -139,6 +155,7 @@ func (s *Service) Run(ctx context.Context) error {
// Pull the latest auth snapshot and sync // Pull the latest auth snapshot and sync
auths := watcherWrapper.SnapshotAuths() auths := watcherWrapper.SnapshotAuths()
s.syncCoreAuthFromAuths(ctx, auths) s.syncCoreAuthFromAuths(ctx, auths)
s.refreshAccessProviders(newCfg)
if s.server != nil { if s.server != nil {
s.server.UpdateClients(newCfg) s.server.UpdateClients(newCfg)
} }