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

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

View File

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

View File

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