feat(auth): implement OAuth 2.0 authorization code flow and dynamic client registration

- Add OAuth 2.0 support with authorization code flow and dynamic client registration.
- Introduce new handlers for OAuth metadata, client registration, authorization, and token issuance.
- Enhance authentication middleware to support OAuth client credentials.
- Create in-memory stores for authorization codes and tokens.
- Update configuration to include OAuth client details.
- Ensure validation checks for OAuth clients in the configuration.
This commit is contained in:
2026-03-26 21:17:55 +02:00
parent ed05d390b7
commit 56c84df342
19 changed files with 970 additions and 40 deletions

View File

@@ -36,11 +36,11 @@ type MCPConfig struct {
}
type AuthConfig struct {
Mode string `yaml:"mode"`
HeaderName string `yaml:"header_name"`
QueryParam string `yaml:"query_param"`
AllowQueryParam bool `yaml:"allow_query_param"`
Keys []APIKey `yaml:"keys"`
HeaderName string `yaml:"header_name"`
QueryParam string `yaml:"query_param"`
AllowQueryParam bool `yaml:"allow_query_param"`
Keys []APIKey `yaml:"keys"`
OAuth OAuthConfig `yaml:"oauth"`
}
type APIKey struct {
@@ -49,6 +49,17 @@ type APIKey struct {
Description string `yaml:"description"`
}
type OAuthConfig struct {
Clients []OAuthClient `yaml:"clients"`
}
type OAuthClient struct {
ID string `yaml:"id"`
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
Description string `yaml:"description"`
}
type DatabaseConfig struct {
URL string `yaml:"url"`
MaxConns int32 `yaml:"max_conns"`

View File

@@ -32,8 +32,10 @@ func Load(explicitPath string) (*Config, string, error) {
}
func ResolvePath(explicitPath string) string {
if strings.TrimSpace(explicitPath) != "" {
return explicitPath
if path := strings.TrimSpace(explicitPath); path != "" {
if path != ".yaml" && path != ".yml" {
return path
}
}
if envPath := strings.TrimSpace(os.Getenv("AMCS_CONFIG")); envPath != "" {
@@ -59,7 +61,6 @@ func defaultConfig() Config {
Transport: "streamable_http",
},
Auth: AuthConfig{
Mode: "api_keys",
HeaderName: "x-brain-key",
QueryParam: "key",
},

View File

@@ -18,6 +18,18 @@ func TestResolvePathPrecedence(t *testing.T) {
}
}
func TestResolvePathIgnoresBareYAMLExtension(t *testing.T) {
t.Setenv("AMCS_CONFIG", "/tmp/from-env.yaml")
if got := ResolvePath(".yaml"); got != "/tmp/from-env.yaml" {
t.Fatalf("ResolvePath(.yaml) = %q, want %q", got, "/tmp/from-env.yaml")
}
if got := ResolvePath(".yml"); got != "/tmp/from-env.yaml" {
t.Fatalf("ResolvePath(.yml) = %q, want %q", got, "/tmp/from-env.yaml")
}
}
func TestLoadAppliesEnvOverrides(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "test.yaml")
if err := os.WriteFile(configPath, []byte(`

View File

@@ -10,10 +10,9 @@ func (c Config) Validate() error {
return fmt.Errorf("invalid config: database.url is required")
}
if len(c.Auth.Keys) == 0 {
return fmt.Errorf("invalid config: auth.keys must not be empty")
if len(c.Auth.Keys) == 0 && len(c.Auth.OAuth.Clients) == 0 {
return fmt.Errorf("invalid config: at least one of auth.keys or auth.oauth.clients must be configured")
}
for i, key := range c.Auth.Keys {
if strings.TrimSpace(key.ID) == "" {
return fmt.Errorf("invalid config: auth.keys[%d].id is required", i)
@@ -22,6 +21,14 @@ func (c Config) Validate() error {
return fmt.Errorf("invalid config: auth.keys[%d].value is required", i)
}
}
for i, client := range c.Auth.OAuth.Clients {
if strings.TrimSpace(client.ClientID) == "" {
return fmt.Errorf("invalid config: auth.oauth.clients[%d].client_id is required", i)
}
if strings.TrimSpace(client.ClientSecret) == "" {
return fmt.Errorf("invalid config: auth.oauth.clients[%d].client_secret is required", i)
}
}
if strings.TrimSpace(c.MCP.Path) == "" {
return fmt.Errorf("invalid config: mcp.path is required")

View File

@@ -67,3 +67,47 @@ func TestValidateRejectsEmptyAuthKeyValue(t *testing.T) {
t.Fatal("Validate() error = nil, want error for empty auth key value")
}
}
func TestValidateAcceptsOAuthClients(t *testing.T) {
cfg := validConfig()
cfg.Auth = AuthConfig{
OAuth: OAuthConfig{
Clients: []OAuthClient{{
ID: "oauth-client",
ClientID: "client-id",
ClientSecret: "client-secret",
}},
},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestValidateAcceptsBothAuthMethods(t *testing.T) {
cfg := validConfig()
cfg.Auth = AuthConfig{
Keys: []APIKey{{ID: "key1", Value: "secret"}},
OAuth: OAuthConfig{
Clients: []OAuthClient{{
ID: "oauth-client",
ClientID: "client-id",
ClientSecret: "client-secret",
}},
},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestValidateRejectsEmptyAuth(t *testing.T) {
cfg := validConfig()
cfg.Auth = AuthConfig{}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() error = nil, want error when neither auth.keys nor auth.oauth.clients is configured")
}
}