refactor(sdk): simplify provider lifecycle and registration logic

This commit is contained in:
Luis Pater
2026-02-10 15:38:03 +08:00
parent 896de027cc
commit 0040d78496
15 changed files with 391 additions and 534 deletions

View File

@@ -445,7 +445,7 @@ func main() {
} }
// Register built-in access providers before constructing services. // Register built-in access providers before constructing services.
configaccess.Register() configaccess.Register(&cfg.SDKConfig)
// Handle different command modes based on the provided flags. // Handle different command modes based on the provided flags.

View File

@@ -7,80 +7,71 @@ The `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` package centralizes inb
```go ```go
import ( import (
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
) )
``` ```
Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`. Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`.
## Provider Registry
Providers are registered globally and then attached to a `Manager` as a snapshot:
- `RegisterProvider(type, provider)` installs a pre-initialized provider instance.
- Registration order is preserved the first time each `type` is seen.
- `RegisteredProviders()` returns the providers in that order.
## Manager Lifecycle ## Manager Lifecycle
```go ```go
manager := sdkaccess.NewManager() manager := sdkaccess.NewManager()
providers, err := sdkaccess.BuildProviders(cfg) manager.SetProviders(sdkaccess.RegisteredProviders())
if err != nil {
return err
}
manager.SetProviders(providers)
``` ```
* `NewManager` constructs an empty manager. * `NewManager` constructs an empty manager.
* `SetProviders` replaces the provider slice using a defensive copy. * `SetProviders` replaces the provider slice using a defensive copy.
* `Providers` retrieves a snapshot that can be iterated safely from other goroutines. * `Providers` retrieves a snapshot that can be iterated safely from other goroutines.
* `BuildProviders` translates `config.Config` access declarations into runnable providers. When the config omits explicit providers but defines inline API keys, the helper auto-installs the built-in `config-api-key` provider.
If the manager itself is `nil` or no providers are configured, the call returns `nil, nil`, allowing callers to treat access control as disabled.
## Authenticating Requests ## Authenticating Requests
```go ```go
result, err := manager.Authenticate(ctx, req) result, authErr := manager.Authenticate(ctx, req)
switch { switch {
case err == nil: case authErr == nil:
// Authentication succeeded; result describes the provider and principal. // Authentication succeeded; result describes the provider and principal.
case errors.Is(err, sdkaccess.ErrNoCredentials): case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNoCredentials):
// No recognizable credentials were supplied. // No recognizable credentials were supplied.
case errors.Is(err, sdkaccess.ErrInvalidCredential): case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeInvalidCredential):
// Supplied credentials were present but rejected. // Supplied credentials were present but rejected.
default: default:
// Transport-level failure was returned by a provider. // Internal/transport failure was returned by a provider.
} }
``` ```
`Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that surface `ErrNotHandled`, and tracks whether any provider reported `ErrNoCredentials` or `ErrInvalidCredential` for downstream error reporting. `Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that return `AuthErrorCodeNotHandled`, and aggregates `AuthErrorCodeNoCredentials` / `AuthErrorCodeInvalidCredential` for a final result.
If the manager itself is `nil` or no providers are registered, the call returns `nil, nil`, allowing callers to treat access control as disabled without branching on errors.
Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential). Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential).
## Configuration Layout ## Built-in `config-api-key` Provider
The manager expects access providers under the `auth.providers` key inside `config.yaml`: The proxy includes one built-in access provider:
- `config-api-key`: Validates API keys declared under top-level `api-keys`.
- Credential sources: `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, `?key=`, `?auth_token=`
- Metadata: `Result.Metadata["source"]` is set to the matched source label.
In the CLI server and `sdk/cliproxy`, this provider is registered automatically based on the loaded configuration.
```yaml ```yaml
auth: api-keys:
providers: - sk-test-123
- name: inline-api - sk-prod-456
type: config-api-key
api-keys:
- sk-test-123
- sk-prod-456
``` ```
Fields map directly to `config.AccessProvider`: `name` labels the provider, `type` selects the registered factory, `sdk` can name an external module, `api-keys` seeds inline credentials, and `config` passes provider-specific options. ## Loading Providers from External Go Modules
### Loading providers from external SDK modules To consume a provider shipped in another Go module, import it for its registration side effect:
To consume a provider shipped in another Go module, point the `sdk` field at the module path and import it for its registration side effect:
```yaml
auth:
providers:
- name: partner-auth
type: partner-token
sdk: github.com/acme/xplatform/sdk/access/providers/partner
config:
region: us-west-2
audience: cli-proxy
```
```go ```go
import ( import (
@@ -89,19 +80,11 @@ import (
) )
``` ```
The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before `BuildProviders` is called. The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before you call `RegisteredProviders()` (or before `cliproxy.NewBuilder().Build()`).
## Built-in Providers
The SDK ships with one provider out of the box:
- `config-api-key`: Validates API keys declared inline or under top-level `api-keys`. It accepts the key from `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, or the `?key=` query string and reports `ErrInvalidCredential` when no match is found.
Additional providers can be delivered by third-party packages. When a provider package is imported, it registers itself with `sdkaccess.RegisterProvider`.
### Metadata and auditing ### Metadata and auditing
`Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, or `query-key`). Populate this map in custom providers to enrich logs and downstream auditing. `Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, `query-key`, `query-auth-token`). Populate this map in custom providers to enrich logs and downstream auditing.
## Writing Custom Providers ## Writing Custom Providers
@@ -110,13 +93,13 @@ type customProvider struct{}
func (p *customProvider) Identifier() string { return "my-provider" } func (p *customProvider) Identifier() string { return "my-provider" }
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) { func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) {
token := r.Header.Get("X-Custom") token := r.Header.Get("X-Custom")
if token == "" { if token == "" {
return nil, sdkaccess.ErrNoCredentials return nil, sdkaccess.NewNotHandledError()
} }
if token != "expected" { if token != "expected" {
return nil, sdkaccess.ErrInvalidCredential return nil, sdkaccess.NewInvalidCredentialError()
} }
return &sdkaccess.Result{ return &sdkaccess.Result{
Provider: p.Identifier(), Provider: p.Identifier(),
@@ -126,51 +109,46 @@ func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sd
} }
func init() { func init() {
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) { sdkaccess.RegisterProvider("custom", &customProvider{})
return &customProvider{}, nil
})
} }
``` ```
A provider must implement `Identifier()` and `Authenticate()`. To expose it to configuration, call `RegisterProvider` inside `init`. Provider factories receive the specific `AccessProvider` block plus the full root configuration for contextual needs. A provider must implement `Identifier()` and `Authenticate()`. To make it available to the access manager, call `RegisterProvider` inside `init` with an initialized provider instance.
## Error Semantics ## Error Semantics
- `ErrNoCredentials`: no credentials were present or recognized by any provider. - `NewNoCredentialsError()` (`AuthErrorCodeNoCredentials`): no credentials were present or recognized. (HTTP 401)
- `ErrInvalidCredential`: at least one provider processed the credentials but rejected them. - `NewInvalidCredentialError()` (`AuthErrorCodeInvalidCredential`): credentials were present but rejected. (HTTP 401)
- `ErrNotHandled`: instructs the manager to fall through to the next provider without affecting aggregate error reporting. - `NewNotHandledError()` (`AuthErrorCodeNotHandled`): fall through to the next provider.
- `NewInternalAuthError(message, cause)` (`AuthErrorCodeInternal`): transport/system failure. (HTTP 500)
Return custom errors to surface transport failures; they propagate immediately to the caller instead of being masked. Errors propagate immediately to the caller unless they are classified as `not_handled` / `no_credentials` / `invalid_credential` and can be aggregated by the manager.
## Integration with cliproxy Service ## Integration with cliproxy Service
`sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a preconfigured manager allows you to extend or override the default providers: `sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a manager lets you reuse the same instance in your host process:
```go ```go
coreCfg, _ := config.LoadConfig("config.yaml") coreCfg, _ := config.LoadConfig("config.yaml")
providers, _ := sdkaccess.BuildProviders(coreCfg) accessManager := sdkaccess.NewManager()
manager := sdkaccess.NewManager()
manager.SetProviders(providers)
svc, _ := cliproxy.NewBuilder(). svc, _ := cliproxy.NewBuilder().
WithConfig(coreCfg). WithConfig(coreCfg).
WithAccessManager(manager). WithConfigPath("config.yaml").
WithRequestAccessManager(accessManager).
Build() Build()
``` ```
The service reuses the manager for every inbound request, ensuring consistent authentication across embedded deployments and the canonical CLI binary. Register any custom providers (typically via blank imports) before calling `Build()` so they are present in the global registry snapshot.
### Hot reloading providers ### Hot reloading
When configuration changes, rebuild providers and swap them into the manager: When configuration changes, refresh any config-backed providers and then reset the manager's provider chain:
```go ```go
providers, err := sdkaccess.BuildProviders(newCfg) // configaccess is github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access
if err != nil { configaccess.Register(&newCfg.SDKConfig)
log.Errorf("reload auth providers failed: %v", err) accessManager.SetProviders(sdkaccess.RegisteredProviders())
return
}
accessManager.SetProviders(providers)
``` ```
This mirrors the behaviour in `cliproxy.Service.refreshAccessProviders` and `api.Server.applyAccessConfig`, enabling runtime updates without restarting the process. This mirrors the behaviour in `internal/access.ApplyAccessProviders`, enabling runtime updates without restarting the process.

View File

@@ -7,80 +7,71 @@
```go ```go
import ( import (
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
) )
``` ```
通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。 通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。
## Provider Registry
访问提供者是全局注册,然后以快照形式挂到 `Manager` 上:
- `RegisterProvider(type, provider)` 注册一个已经初始化好的 provider 实例。
- 每个 `type` 第一次出现时会记录其注册顺序。
- `RegisteredProviders()` 会按该顺序返回 provider 列表。
## 管理器生命周期 ## 管理器生命周期
```go ```go
manager := sdkaccess.NewManager() manager := sdkaccess.NewManager()
providers, err := sdkaccess.BuildProviders(cfg) manager.SetProviders(sdkaccess.RegisteredProviders())
if err != nil {
return err
}
manager.SetProviders(providers)
``` ```
- `NewManager` 创建空管理器。 - `NewManager` 创建空管理器。
- `SetProviders` 替换提供者切片并做防御性拷贝。 - `SetProviders` 替换提供者切片并做防御性拷贝。
- `Providers` 返回适合并发读取的快照。 - `Providers` 返回适合并发读取的快照。
- `BuildProviders``config.Config` 中的访问配置转换成可运行的提供者。当配置没有显式声明但包含顶层 `api-keys` 时,会自动挂载内建的 `config-api-key` 提供者。
如果管理器本身为 `nil` 或未配置任何 provider调用会返回 `nil, nil`,可视为关闭访问控制。
## 认证请求 ## 认证请求
```go ```go
result, err := manager.Authenticate(ctx, req) result, authErr := manager.Authenticate(ctx, req)
switch { switch {
case err == nil: case authErr == nil:
// Authentication succeeded; result carries provider and principal. // Authentication succeeded; result carries provider and principal.
case errors.Is(err, sdkaccess.ErrNoCredentials): case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNoCredentials):
// No recognizable credentials were supplied. // No recognizable credentials were supplied.
case errors.Is(err, sdkaccess.ErrInvalidCredential): case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeInvalidCredential):
// Credentials were present but rejected. // Credentials were present but rejected.
default: default:
// Provider surfaced a transport-level failure. // Provider surfaced a transport-level failure.
} }
``` ```
`Manager.Authenticate`配置顺序遍历提供者。遇到成功立即返回,`ErrNotHandled` 会继续尝试下一个;若发现 `ErrNoCredentials` `ErrInvalidCredential`会在遍历结束后汇总给调用方。 `Manager.Authenticate` 按顺序遍历 provider遇到成功立即返回,`AuthErrorCodeNotHandled` 会继续尝试下一个;`AuthErrorCodeNoCredentials` / `AuthErrorCodeInvalidCredential` 会在遍历结束后汇总给调用方。
若管理器本身为 `nil` 或尚未注册提供者,调用会返回 `nil, nil`,让调用方无需针对错误做额外分支即可关闭访问控制。
`Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。 `Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。
## 配置结构 ## 内建 `config-api-key` Provider
`config.yaml``auth.providers` 下定义访问提供者: 代理内置一个访问提供者:
- `config-api-key`:校验 `config.yaml` 顶层的 `api-keys`
- 凭证来源:`Authorization: Bearer``X-Goog-Api-Key``X-Api-Key``?key=``?auth_token=`
- 元数据:`Result.Metadata["source"]` 会写入匹配到的来源标识
在 CLI 服务端与 `sdk/cliproxy` 中,该 provider 会根据加载到的配置自动注册。
```yaml ```yaml
auth: api-keys:
providers: - sk-test-123
- name: inline-api - sk-prod-456
type: config-api-key
api-keys:
- sk-test-123
- sk-prod-456
``` ```
条目映射到 `config.AccessProvider``name` 指定实例名,`type` 选择注册的工厂,`sdk` 可引用第三方模块,`api-keys` 提供内联凭证,`config` 用于传递特定选项。 ## 引入外部 Go 模块提供者
### 引入外部 SDK 提供者 若要消费其它 Go 模块输出的访问提供者,直接用空白标识符导入以触发其 `init` 注册即可:
若要消费其它 Go 模块输出的访问提供者,可在配置里填写 `sdk` 字段并在代码中引入该包,利用其 `init` 注册过程:
```yaml
auth:
providers:
- name: partner-auth
type: partner-token
sdk: github.com/acme/xplatform/sdk/access/providers/partner
config:
region: us-west-2
audience: cli-proxy
```
```go ```go
import ( import (
@@ -89,19 +80,11 @@ import (
) )
``` ```
通过空白标识符导入可确保 `init` 调用,先于 `BuildProviders` 完成 `sdkaccess.RegisterProvider` 空白导入可确保 `init` 先执行,从而在你调用 `RegisteredProviders()`(或 `cliproxy.NewBuilder().Build()`)之前完成 `sdkaccess.RegisterProvider`
## 内建提供者
当前 SDK 默认内置:
- `config-api-key`:校验配置中的 API Key。它从 `Authorization: Bearer``X-Goog-Api-Key``X-Api-Key` 以及查询参数 `?key=` 提取凭证,不匹配时抛出 `ErrInvalidCredential`
导入第三方包即可通过 `sdkaccess.RegisterProvider` 注册更多类型。
### 元数据与审计 ### 元数据与审计
`Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization``x-goog-api-key``x-api-key``query-key`)。自定义提供者同样可以填充该 Map以便丰富日志与审计场景。 `Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization``x-goog-api-key``x-api-key``query-key``query-auth-token`)。自定义提供者同样可以填充该 Map以便丰富日志与审计场景。
## 编写自定义提供者 ## 编写自定义提供者
@@ -110,13 +93,13 @@ type customProvider struct{}
func (p *customProvider) Identifier() string { return "my-provider" } func (p *customProvider) Identifier() string { return "my-provider" }
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) { func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) {
token := r.Header.Get("X-Custom") token := r.Header.Get("X-Custom")
if token == "" { if token == "" {
return nil, sdkaccess.ErrNoCredentials return nil, sdkaccess.NewNotHandledError()
} }
if token != "expected" { if token != "expected" {
return nil, sdkaccess.ErrInvalidCredential return nil, sdkaccess.NewInvalidCredentialError()
} }
return &sdkaccess.Result{ return &sdkaccess.Result{
Provider: p.Identifier(), Provider: p.Identifier(),
@@ -126,51 +109,46 @@ func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sd
} }
func init() { func init() {
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) { sdkaccess.RegisterProvider("custom", &customProvider{})
return &customProvider{}, nil
})
} }
``` ```
自定义提供者需要实现 `Identifier()``Authenticate()`。在 `init` 中调用 `RegisterProvider` 暴露给配置层,工厂函数既能读取当前条目,也能访问完整根配置 自定义提供者需要实现 `Identifier()``Authenticate()`。在 `init`用已初始化实例调用 `RegisterProvider` 注册到全局 registry
## 错误语义 ## 错误语义
- `ErrNoCredentials`:任何提供者都未识别到凭证。 - `NewNoCredentialsError()``AuthErrorCodeNoCredentials`未提供或未识别到凭证。HTTP 401
- `ErrInvalidCredential`:至少一个提供者处理了凭证但判定无效。 - `NewInvalidCredentialError()``AuthErrorCodeInvalidCredential`凭证存在但校验失败。HTTP 401
- `ErrNotHandled`:告诉管理器跳到下一个提供者,不影响最终错误统计 - `NewNotHandledError()``AuthErrorCodeNotHandled`:告诉管理器跳到下一个 provider
- `NewInternalAuthError(message, cause)``AuthErrorCodeInternal`):网络/系统错误。HTTP 500
自定义错误(例如网络异常)会马上冒泡返回。 除可汇总的 `not_handled` / `no_credentials` / `invalid_credential` 外,其它错误会立即冒泡返回。
## 与 cliproxy 集成 ## 与 cliproxy 集成
使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果需要扩展内置行为,可传入自定义管理器: 使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果希望在宿主进程里复用同一个 `Manager` 实例,可传入自定义管理器:
```go ```go
coreCfg, _ := config.LoadConfig("config.yaml") coreCfg, _ := config.LoadConfig("config.yaml")
providers, _ := sdkaccess.BuildProviders(coreCfg) accessManager := sdkaccess.NewManager()
manager := sdkaccess.NewManager()
manager.SetProviders(providers)
svc, _ := cliproxy.NewBuilder(). svc, _ := cliproxy.NewBuilder().
WithConfig(coreCfg). WithConfig(coreCfg).
WithAccessManager(manager). WithConfigPath("config.yaml").
WithRequestAccessManager(accessManager).
Build() Build()
``` ```
服务会复用该管理器处理每一个入站请求,实现与 CLI 二进制一致的访问控制体验 请在调用 `Build()` 之前完成自定义 provider 的注册(通常通过空白导入触发 `init`),以确保它们被包含在全局 registry 的快照中
### 动态热更新提供者 ### 动态热更新提供者
当配置发生变化时,可以重新构建提供者并替换当前列表 当配置发生变化时,刷新依赖配置的 provider然后重置 manager 的 provider 链
```go ```go
providers, err := sdkaccess.BuildProviders(newCfg) // configaccess is github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access
if err != nil { configaccess.Register(&newCfg.SDKConfig)
log.Errorf("reload auth providers failed: %v", err) accessManager.SetProviders(sdkaccess.RegisteredProviders())
return
}
accessManager.SetProviders(providers)
``` ```
这一流程与 `cliproxy.Service.refreshAccessProviders``api.Server.applyAccessConfig` 保持一致,避免为更新访问策略而重启进程。 这一流程与 `internal/access.ApplyAccessProviders` 保持一致,避免为更新访问策略而重启进程。

View File

@@ -4,19 +4,28 @@ import (
"context" "context"
"net/http" "net/http"
"strings" "strings"
"sync"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
) )
var registerOnce sync.Once
// Register ensures the config-access provider is available to the access manager. // Register ensures the config-access provider is available to the access manager.
func Register() { func Register(cfg *sdkconfig.SDKConfig) {
registerOnce.Do(func() { if cfg == nil {
sdkaccess.RegisterProvider(sdkconfig.AccessProviderTypeConfigAPIKey, newProvider) sdkaccess.UnregisterProvider(sdkaccess.AccessProviderTypeConfigAPIKey)
}) return
}
keys := normalizeKeys(cfg.APIKeys)
if len(keys) == 0 {
sdkaccess.UnregisterProvider(sdkaccess.AccessProviderTypeConfigAPIKey)
return
}
sdkaccess.RegisterProvider(
sdkaccess.AccessProviderTypeConfigAPIKey,
newProvider(sdkaccess.DefaultAccessProviderName, keys),
)
} }
type provider struct { type provider struct {
@@ -24,34 +33,31 @@ type provider struct {
keys map[string]struct{} keys map[string]struct{}
} }
func newProvider(cfg *sdkconfig.AccessProvider, _ *sdkconfig.SDKConfig) (sdkaccess.Provider, error) { func newProvider(name string, keys []string) *provider {
name := cfg.Name providerName := strings.TrimSpace(name)
if name == "" { if providerName == "" {
name = sdkconfig.DefaultAccessProviderName providerName = sdkaccess.DefaultAccessProviderName
} }
keys := make(map[string]struct{}, len(cfg.APIKeys)) keySet := make(map[string]struct{}, len(keys))
for _, key := range cfg.APIKeys { for _, key := range keys {
if key == "" { keySet[key] = struct{}{}
continue
}
keys[key] = struct{}{}
} }
return &provider{name: name, keys: keys}, nil return &provider{name: providerName, keys: keySet}
} }
func (p *provider) Identifier() string { func (p *provider) Identifier() string {
if p == nil || p.name == "" { if p == nil || p.name == "" {
return sdkconfig.DefaultAccessProviderName return sdkaccess.DefaultAccessProviderName
} }
return p.name return p.name
} }
func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, error) { func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) {
if p == nil { if p == nil {
return nil, sdkaccess.ErrNotHandled return nil, sdkaccess.NewNotHandledError()
} }
if len(p.keys) == 0 { if len(p.keys) == 0 {
return nil, sdkaccess.ErrNotHandled return nil, sdkaccess.NewNotHandledError()
} }
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
authHeaderGoogle := r.Header.Get("X-Goog-Api-Key") authHeaderGoogle := r.Header.Get("X-Goog-Api-Key")
@@ -63,7 +69,7 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.
queryAuthToken = r.URL.Query().Get("auth_token") queryAuthToken = r.URL.Query().Get("auth_token")
} }
if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" && queryAuthToken == "" { if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" && queryAuthToken == "" {
return nil, sdkaccess.ErrNoCredentials return nil, sdkaccess.NewNoCredentialsError()
} }
apiKey := extractBearerToken(authHeader) apiKey := extractBearerToken(authHeader)
@@ -94,7 +100,7 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.
} }
} }
return nil, sdkaccess.ErrInvalidCredential return nil, sdkaccess.NewInvalidCredentialError()
} }
func extractBearerToken(header string) string { func extractBearerToken(header string) string {
@@ -110,3 +116,26 @@ func extractBearerToken(header string) string {
} }
return strings.TrimSpace(parts[1]) return strings.TrimSpace(parts[1])
} }
func normalizeKeys(keys []string) []string {
if len(keys) == 0 {
return nil
}
normalized := make([]string, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
continue
}
if _, exists := seen[trimmedKey]; exists {
continue
}
seen[trimmedKey] = struct{}{}
normalized = append(normalized, trimmedKey)
}
if len(normalized) == 0 {
return nil
}
return normalized
}

View File

@@ -6,9 +6,9 @@ import (
"sort" "sort"
"strings" "strings"
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
"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" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -17,26 +17,26 @@ import (
// ordered provider slice along with the identifiers of providers that were added, updated, or // ordered provider slice along with the identifiers of providers that were added, updated, or
// removed compared to the previous configuration. // removed compared to the previous configuration.
func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) { func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) {
_ = oldCfg
if newCfg == nil { if newCfg == nil {
return nil, nil, nil, nil, nil return nil, nil, nil, nil, nil
} }
result = sdkaccess.RegisteredProviders()
existingMap := make(map[string]sdkaccess.Provider, len(existing)) existingMap := make(map[string]sdkaccess.Provider, len(existing))
for _, provider := range existing { for _, provider := range existing {
if provider == nil { providerID := identifierFromProvider(provider)
if providerID == "" {
continue continue
} }
existingMap[provider.Identifier()] = provider existingMap[providerID] = provider
} }
oldCfgMap := accessProviderMap(oldCfg) finalIDs := make(map[string]struct{}, len(result))
newEntries := collectProviderEntries(newCfg)
result = make([]sdkaccess.Provider, 0, len(newEntries))
finalIDs := make(map[string]struct{}, len(newEntries))
isInlineProvider := func(id string) bool { isInlineProvider := func(id string) bool {
return strings.EqualFold(id, sdkConfig.DefaultAccessProviderName) return strings.EqualFold(id, sdkaccess.DefaultAccessProviderName)
} }
appendChange := func(list *[]string, id string) { appendChange := func(list *[]string, id string) {
if isInlineProvider(id) { if isInlineProvider(id) {
@@ -45,85 +45,28 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Prov
*list = append(*list, id) *list = append(*list, id)
} }
for _, providerCfg := range newEntries { for _, provider := range result {
key := providerIdentifier(providerCfg) providerID := identifierFromProvider(provider)
if key == "" { if providerID == "" {
continue continue
} }
finalIDs[providerID] = struct{}{}
forceRebuild := strings.EqualFold(strings.TrimSpace(providerCfg.Type), sdkConfig.AccessProviderTypeConfigAPIKey) existingProvider, exists := existingMap[providerID]
if oldCfgProvider, ok := oldCfgMap[key]; ok { if !exists {
isAliased := oldCfgProvider == providerCfg appendChange(&added, providerID)
if !forceRebuild && !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) { continue
if existingProvider, okExisting := existingMap[key]; okExisting {
result = append(result, existingProvider)
finalIDs[key] = struct{}{}
continue
}
}
} }
if !providerInstanceEqual(existingProvider, provider) {
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig) appendChange(&updated, providerID)
if buildErr != nil {
return nil, nil, nil, nil, buildErr
}
if _, ok := oldCfgMap[key]; ok {
if _, existed := existingMap[key]; existed {
appendChange(&updated, key)
} else {
appendChange(&added, key)
}
} else {
appendChange(&added, key)
}
result = append(result, provider)
finalIDs[key] = struct{}{}
}
if len(result) == 0 {
if inline := sdkConfig.MakeInlineAPIKeyProvider(newCfg.APIKeys); inline != nil {
key := providerIdentifier(inline)
if key != "" {
if oldCfgProvider, ok := oldCfgMap[key]; ok {
if providerConfigEqual(oldCfgProvider, inline) {
if existingProvider, okExisting := existingMap[key]; okExisting {
result = append(result, existingProvider)
finalIDs[key] = struct{}{}
goto inlineDone
}
}
}
provider, buildErr := sdkaccess.BuildProvider(inline, &newCfg.SDKConfig)
if buildErr != nil {
return nil, nil, nil, nil, buildErr
}
if _, existed := existingMap[key]; existed {
appendChange(&updated, key)
} else if _, hadOld := oldCfgMap[key]; hadOld {
appendChange(&updated, key)
} else {
appendChange(&added, key)
}
result = append(result, provider)
finalIDs[key] = struct{}{}
}
}
inlineDone:
}
removedSet := make(map[string]struct{})
for id := range existingMap {
if _, ok := finalIDs[id]; !ok {
if isInlineProvider(id) {
continue
}
removedSet[id] = struct{}{}
} }
} }
removed = make([]string, 0, len(removedSet)) for providerID := range existingMap {
for id := range removedSet { if _, exists := finalIDs[providerID]; exists {
removed = append(removed, id) continue
}
appendChange(&removed, providerID)
} }
sort.Strings(added) sort.Strings(added)
@@ -142,6 +85,7 @@ func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Con
} }
existing := manager.Providers() existing := manager.Providers()
configaccess.Register(&newCfg.SDKConfig)
providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing) providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing)
if err != nil { if err != nil {
log.Errorf("failed to reconcile request auth providers: %v", err) log.Errorf("failed to reconcile request auth providers: %v", err)
@@ -160,111 +104,24 @@ func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Con
return false, nil return false, nil
} }
func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider { func identifierFromProvider(provider sdkaccess.Provider) string {
result := make(map[string]*sdkConfig.AccessProvider)
if cfg == nil {
return result
}
for i := range cfg.Access.Providers {
providerCfg := &cfg.Access.Providers[i]
if providerCfg.Type == "" {
continue
}
key := providerIdentifier(providerCfg)
if key == "" {
continue
}
result[key] = providerCfg
}
if len(result) == 0 && len(cfg.APIKeys) > 0 {
if provider := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); provider != nil {
if key := providerIdentifier(provider); key != "" {
result[key] = provider
}
}
}
return result
}
func collectProviderEntries(cfg *config.Config) []*sdkConfig.AccessProvider {
entries := make([]*sdkConfig.AccessProvider, 0, len(cfg.Access.Providers))
for i := range cfg.Access.Providers {
providerCfg := &cfg.Access.Providers[i]
if providerCfg.Type == "" {
continue
}
if key := providerIdentifier(providerCfg); key != "" {
entries = append(entries, providerCfg)
}
}
if len(entries) == 0 && len(cfg.APIKeys) > 0 {
if inline := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); inline != nil {
entries = append(entries, inline)
}
}
return entries
}
func providerIdentifier(provider *sdkConfig.AccessProvider) string {
if provider == nil { if provider == nil {
return "" return ""
} }
if name := strings.TrimSpace(provider.Name); name != "" { return strings.TrimSpace(provider.Identifier())
return name
}
typ := strings.TrimSpace(provider.Type)
if typ == "" {
return ""
}
if strings.EqualFold(typ, sdkConfig.AccessProviderTypeConfigAPIKey) {
return sdkConfig.DefaultAccessProviderName
}
return typ
} }
func providerConfigEqual(a, b *sdkConfig.AccessProvider) bool { func providerInstanceEqual(a, b sdkaccess.Provider) bool {
if a == nil || b == nil { if a == nil || b == nil {
return a == nil && b == nil return a == nil && b == nil
} }
if !strings.EqualFold(strings.TrimSpace(a.Type), strings.TrimSpace(b.Type)) { if reflect.TypeOf(a) != reflect.TypeOf(b) {
return false return false
} }
if strings.TrimSpace(a.SDK) != strings.TrimSpace(b.SDK) { valueA := reflect.ValueOf(a)
return false valueB := reflect.ValueOf(b)
if valueA.Kind() == reflect.Pointer && valueB.Kind() == reflect.Pointer {
return valueA.Pointer() == valueB.Pointer()
} }
if !stringSetEqual(a.APIKeys, b.APIKeys) { return reflect.DeepEqual(a, b)
return false
}
if len(a.Config) != len(b.Config) {
return false
}
if len(a.Config) > 0 && !reflect.DeepEqual(a.Config, b.Config) {
return false
}
return true
}
func stringSetEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
if len(a) == 0 {
return true
}
seen := make(map[string]int, len(a))
for _, val := range a {
seen[val]++
}
for _, val := range b {
count := seen[val]
if count == 0 {
return false
}
if count == 1 {
delete(seen, val)
} else {
seen[val] = count - 1
}
}
return len(seen) == 0
} }

View File

@@ -109,14 +109,13 @@ func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.c
func (h *Handler) PutAPIKeys(c *gin.Context) { func (h *Handler) PutAPIKeys(c *gin.Context) {
h.putStringList(c, func(v []string) { h.putStringList(c, func(v []string) {
h.cfg.APIKeys = append([]string(nil), v...) h.cfg.APIKeys = append([]string(nil), v...)
h.cfg.Access.Providers = nil
}, nil) }, nil)
} }
func (h *Handler) PatchAPIKeys(c *gin.Context) { func (h *Handler) PatchAPIKeys(c *gin.Context) {
h.patchStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil }) h.patchStringList(c, &h.cfg.APIKeys, func() {})
} }
func (h *Handler) DeleteAPIKeys(c *gin.Context) { func (h *Handler) DeleteAPIKeys(c *gin.Context) {
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil }) h.deleteFromStringList(c, &h.cfg.APIKeys, func() {})
} }
// gemini-api-key: []GeminiKey // gemini-api-key: []GeminiKey

View File

@@ -1033,14 +1033,10 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
return return
} }
switch { statusCode := err.HTTPStatusCode()
case errors.Is(err, sdkaccess.ErrNoCredentials): if statusCode >= http.StatusInternalServerError {
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) log.Errorf("authentication middleware error: %v", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Authentication service error"})
} }
c.AbortWithStatusJSON(statusCode, gin.H{"error": err.Message})
} }
} }

View File

@@ -589,9 +589,6 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.ErrorLogsMaxFiles = 10 cfg.ErrorLogsMaxFiles = 10
} }
// Sync request authentication providers with inline API keys for backwards compatibility.
syncInlineAccessProvider(&cfg)
// Sanitize Gemini API key configuration and migrate legacy entries. // Sanitize Gemini API key configuration and migrate legacy entries.
cfg.SanitizeGeminiKeys() cfg.SanitizeGeminiKeys()
@@ -825,18 +822,6 @@ func normalizeModelPrefix(prefix string) string {
return trimmed return trimmed
} }
func syncInlineAccessProvider(cfg *Config) {
if cfg == nil {
return
}
if len(cfg.APIKeys) == 0 {
if provider := cfg.ConfigAPIKeyProvider(); provider != nil && len(provider.APIKeys) > 0 {
cfg.APIKeys = append([]string(nil), provider.APIKeys...)
}
}
cfg.Access.Providers = nil
}
// 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$")
@@ -924,7 +909,7 @@ func hashSecret(secret string) (string, error) {
// SaveConfigPreserveComments writes the config back to YAML while preserving existing comments // SaveConfigPreserveComments writes the config back to YAML while preserving existing comments
// and key ordering by loading the original file into a yaml.Node tree and updating values in-place. // and key ordering by loading the original file into a yaml.Node tree and updating values in-place.
func SaveConfigPreserveComments(configFile string, cfg *Config) error { func SaveConfigPreserveComments(configFile string, cfg *Config) error {
persistCfg := sanitizeConfigForPersist(cfg) persistCfg := cfg
// Load original YAML as a node tree to preserve comments and ordering. // Load original YAML as a node tree to preserve comments and ordering.
data, err := os.ReadFile(configFile) data, err := os.ReadFile(configFile)
if err != nil { if err != nil {
@@ -992,16 +977,6 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
return err return err
} }
func sanitizeConfigForPersist(cfg *Config) *Config {
if cfg == nil {
return nil
}
clone := *cfg
clone.SDKConfig = cfg.SDKConfig
clone.SDKConfig.Access = AccessConfig{}
return &clone
}
// SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"] // SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"]
// while preserving comments and positions. // while preserving comments and positions.
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error { func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {

View File

@@ -20,9 +20,6 @@ type SDKConfig 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,omitempty" json:"auth,omitempty"`
// Streaming configures server-side streaming behavior (keep-alives and safe bootstrap retries). // Streaming configures server-side streaming behavior (keep-alives and safe bootstrap retries).
Streaming StreamingConfig `yaml:"streaming" json:"streaming"` Streaming StreamingConfig `yaml:"streaming" json:"streaming"`
@@ -42,65 +39,3 @@ type StreamingConfig struct {
// <= 0 disables bootstrap retries. Default is 0. // <= 0 disables bootstrap retries. Default is 0.
BootstrapRetries int `yaml:"bootstrap-retries,omitempty" json:"bootstrap-retries,omitempty"` BootstrapRetries int `yaml:"bootstrap-retries,omitempty" json:"bootstrap-retries,omitempty"`
} }
// AccessConfig groups request authentication providers.
type AccessConfig struct {
// Providers lists configured authentication providers.
Providers []AccessProvider `yaml:"providers,omitempty" json:"providers,omitempty"`
}
// 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"
)
// ConfigAPIKeyProvider returns the first inline API key provider if present.
func (c *SDKConfig) 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
}
// MakeInlineAPIKeyProvider constructs an inline API key provider configuration.
// It returns nil when no keys are supplied.
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
if len(keys) == 0 {
return nil
}
provider := &AccessProvider{
Name: DefaultAccessProviderName,
Type: AccessProviderTypeConfigAPIKey,
APIKeys: append([]string(nil), keys...),
}
return provider
}

View File

@@ -1,12 +1,90 @@
package access package access
import "errors" import (
"fmt"
var ( "net/http"
// ErrNoCredentials indicates no recognizable credentials were supplied. "strings"
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")
) )
// AuthErrorCode classifies authentication failures.
type AuthErrorCode string
const (
AuthErrorCodeNoCredentials AuthErrorCode = "no_credentials"
AuthErrorCodeInvalidCredential AuthErrorCode = "invalid_credential"
AuthErrorCodeNotHandled AuthErrorCode = "not_handled"
AuthErrorCodeInternal AuthErrorCode = "internal_error"
)
// AuthError carries authentication failure details and HTTP status.
type AuthError struct {
Code AuthErrorCode
Message string
StatusCode int
Cause error
}
func (e *AuthError) Error() string {
if e == nil {
return ""
}
message := strings.TrimSpace(e.Message)
if message == "" {
message = "authentication error"
}
if e.Cause != nil {
return fmt.Sprintf("%s: %v", message, e.Cause)
}
return message
}
func (e *AuthError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// HTTPStatusCode returns a safe fallback for missing status codes.
func (e *AuthError) HTTPStatusCode() int {
if e == nil || e.StatusCode <= 0 {
return http.StatusInternalServerError
}
return e.StatusCode
}
func newAuthError(code AuthErrorCode, message string, statusCode int, cause error) *AuthError {
return &AuthError{
Code: code,
Message: message,
StatusCode: statusCode,
Cause: cause,
}
}
func NewNoCredentialsError() *AuthError {
return newAuthError(AuthErrorCodeNoCredentials, "Missing API key", http.StatusUnauthorized, nil)
}
func NewInvalidCredentialError() *AuthError {
return newAuthError(AuthErrorCodeInvalidCredential, "Invalid API key", http.StatusUnauthorized, nil)
}
func NewNotHandledError() *AuthError {
return newAuthError(AuthErrorCodeNotHandled, "authentication provider did not handle request", 0, nil)
}
func NewInternalAuthError(message string, cause error) *AuthError {
normalizedMessage := strings.TrimSpace(message)
if normalizedMessage == "" {
normalizedMessage = "Authentication service error"
}
return newAuthError(AuthErrorCodeInternal, normalizedMessage, http.StatusInternalServerError, cause)
}
func IsAuthErrorCode(authErr *AuthError, code AuthErrorCode) bool {
if authErr == nil {
return false
}
return authErr.Code == code
}

View File

@@ -2,7 +2,6 @@ package access
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"sync" "sync"
) )
@@ -43,7 +42,7 @@ func (m *Manager) Providers() []Provider {
} }
// Authenticate evaluates providers until one succeeds. // Authenticate evaluates providers until one succeeds.
func (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, error) { func (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, *AuthError) {
if m == nil { if m == nil {
return nil, nil return nil, nil
} }
@@ -61,29 +60,29 @@ func (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, e
if provider == nil { if provider == nil {
continue continue
} }
res, err := provider.Authenticate(ctx, r) res, authErr := provider.Authenticate(ctx, r)
if err == nil { if authErr == nil {
return res, nil return res, nil
} }
if errors.Is(err, ErrNotHandled) { if IsAuthErrorCode(authErr, AuthErrorCodeNotHandled) {
continue continue
} }
if errors.Is(err, ErrNoCredentials) { if IsAuthErrorCode(authErr, AuthErrorCodeNoCredentials) {
missing = true missing = true
continue continue
} }
if errors.Is(err, ErrInvalidCredential) { if IsAuthErrorCode(authErr, AuthErrorCodeInvalidCredential) {
invalid = true invalid = true
continue continue
} }
return nil, err return nil, authErr
} }
if invalid { if invalid {
return nil, ErrInvalidCredential return nil, NewInvalidCredentialError()
} }
if missing { if missing {
return nil, ErrNoCredentials return nil, NewNoCredentialsError()
} }
return nil, ErrNoCredentials return nil, NewNoCredentialsError()
} }

View File

@@ -2,17 +2,15 @@ package access
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"strings"
"sync" "sync"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
) )
// Provider validates credentials for incoming requests. // Provider validates credentials for incoming requests.
type Provider interface { type Provider interface {
Identifier() string Identifier() string
Authenticate(ctx context.Context, r *http.Request) (*Result, error) Authenticate(ctx context.Context, r *http.Request) (*Result, *AuthError)
} }
// Result conveys authentication outcome. // Result conveys authentication outcome.
@@ -22,66 +20,64 @@ type Result struct {
Metadata map[string]string Metadata map[string]string
} }
// ProviderFactory builds a provider from configuration data.
type ProviderFactory func(cfg *config.AccessProvider, root *config.SDKConfig) (Provider, error)
var ( var (
registryMu sync.RWMutex registryMu sync.RWMutex
registry = make(map[string]ProviderFactory) registry = make(map[string]Provider)
order []string
) )
// RegisterProvider registers a provider factory for a given type identifier. // RegisterProvider registers a pre-built provider instance for a given type identifier.
func RegisterProvider(typ string, factory ProviderFactory) { func RegisterProvider(typ string, provider Provider) {
if typ == "" || factory == nil { normalizedType := strings.TrimSpace(typ)
if normalizedType == "" || provider == nil {
return return
} }
registryMu.Lock() registryMu.Lock()
registry[typ] = factory if _, exists := registry[normalizedType]; !exists {
order = append(order, normalizedType)
}
registry[normalizedType] = provider
registryMu.Unlock() registryMu.Unlock()
} }
func BuildProvider(cfg *config.AccessProvider, root *config.SDKConfig) (Provider, error) { // UnregisterProvider removes a provider by type identifier.
if cfg == nil { func UnregisterProvider(typ string) {
return nil, fmt.Errorf("access: nil provider config") normalizedType := strings.TrimSpace(typ)
if normalizedType == "" {
return
} }
registryMu.RLock() registryMu.Lock()
factory, ok := registry[cfg.Type] if _, exists := registry[normalizedType]; !exists {
registryMu.RUnlock() registryMu.Unlock()
if !ok { return
return nil, fmt.Errorf("access: provider type %q is not registered", cfg.Type)
} }
provider, err := factory(cfg, root) delete(registry, normalizedType)
if err != nil { for index := range order {
return nil, fmt.Errorf("access: failed to build provider %q: %w", cfg.Name, err) if order[index] != normalizedType {
}
return provider, nil
}
// BuildProviders constructs providers declared in configuration.
func BuildProviders(root *config.SDKConfig) ([]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 continue
} }
provider, err := BuildProvider(providerCfg, root) order = append(order[:index], order[index+1:]...)
if err != nil { break
return nil, err }
registryMu.Unlock()
}
// RegisteredProviders returns the global provider instances in registration order.
func RegisteredProviders() []Provider {
registryMu.RLock()
if len(order) == 0 {
registryMu.RUnlock()
return nil
}
providers := make([]Provider, 0, len(order))
for _, providerType := range order {
provider, exists := registry[providerType]
if !exists || provider == nil {
continue
} }
providers = append(providers, provider) providers = append(providers, provider)
} }
if len(providers) == 0 { registryMu.RUnlock()
if inline := config.MakeInlineAPIKeyProvider(root.APIKeys); inline != nil { return providers
provider, err := BuildProvider(inline, root)
if err != nil {
return nil, err
}
providers = append(providers, provider)
}
}
return providers, nil
} }

47
sdk/access/types.go Normal file
View File

@@ -0,0 +1,47 @@
package access
// AccessConfig groups request authentication providers.
type AccessConfig struct {
// Providers lists configured authentication providers.
Providers []AccessProvider `yaml:"providers,omitempty" json:"providers,omitempty"`
}
// 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"
)
// MakeInlineAPIKeyProvider constructs an inline API key provider configuration.
// It returns nil when no keys are supplied.
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
if len(keys) == 0 {
return nil
}
provider := &AccessProvider{
Name: DefaultAccessProviderName,
Type: AccessProviderTypeConfigAPIKey,
APIKeys: append([]string(nil), keys...),
}
return provider
}

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"strings" "strings"
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/api" "github.com/router-for-me/CLIProxyAPI/v6/internal/api"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" 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"
@@ -186,11 +187,8 @@ func (b *Builder) Build() (*Service, error) {
accessManager = sdkaccess.NewManager() accessManager = sdkaccess.NewManager()
} }
providers, err := sdkaccess.BuildProviders(&b.cfg.SDKConfig) configaccess.Register(&b.cfg.SDKConfig)
if err != nil { accessManager.SetProviders(sdkaccess.RegisteredProviders())
return nil, err
}
accessManager.SetProviders(providers)
coreManager := b.coreManager coreManager := b.coreManager
if coreManager == nil { if coreManager == nil {

View File

@@ -7,8 +7,6 @@ package config
import internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" import internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
type SDKConfig = internalconfig.SDKConfig type SDKConfig = internalconfig.SDKConfig
type AccessConfig = internalconfig.AccessConfig
type AccessProvider = internalconfig.AccessProvider
type Config = internalconfig.Config type Config = internalconfig.Config
@@ -34,15 +32,9 @@ type OpenAICompatibilityModel = internalconfig.OpenAICompatibilityModel
type TLS = internalconfig.TLSConfig type TLS = internalconfig.TLSConfig
const ( const (
AccessProviderTypeConfigAPIKey = internalconfig.AccessProviderTypeConfigAPIKey DefaultPanelGitHubRepository = internalconfig.DefaultPanelGitHubRepository
DefaultAccessProviderName = internalconfig.DefaultAccessProviderName
DefaultPanelGitHubRepository = internalconfig.DefaultPanelGitHubRepository
) )
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
return internalconfig.MakeInlineAPIKeyProvider(keys)
}
func LoadConfig(configFile string) (*Config, error) { return internalconfig.LoadConfig(configFile) } func LoadConfig(configFile string) (*Config, error) { return internalconfig.LoadConfig(configFile) }
func LoadConfigOptional(configFile string, optional bool) (*Config, error) { func LoadConfigOptional(configFile string, optional bool) (*Config, error) {