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:
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user