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

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/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...),
}

View File

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