From d90207f73fee5eb16f029291459dd4dd76b11e1d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 04:00:51 +0000 Subject: [PATCH 1/3] Initial plan From e850345f1487bf931a539ebba16e3df71c675f05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 04:14:27 +0000 Subject: [PATCH 2/3] auth.m2m: add configurable OAuth2 endpoint path suffixes via SetEndpoints() Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- module/auth_m2m.go | 90 +++++++++++++--- module/auth_m2m_test.go | 154 ++++++++++++++++++++++++++++ module/platform_do_database.go | 6 +- module/platform_do_database_test.go | 12 +-- module/scan_provider_test.go | 10 +- plugins/auth/plugin.go | 11 ++ plugins/auth/plugin_test.go | 52 ++++++++++ 7 files changed, 304 insertions(+), 31 deletions(-) diff --git a/module/auth_m2m.go b/module/auth_m2m.go index 394c7d6d..4ea01b03 100644 --- a/module/auth_m2m.go +++ b/module/auth_m2m.go @@ -63,6 +63,32 @@ type M2MClient struct { Claims map[string]any `json:"claims,omitempty"` } +// M2MEndpointPaths configures the URL path suffixes for the OAuth2 endpoints +// exposed by the M2M auth module. Each field is matched using strings.HasSuffix +// against the incoming request path, so a prefix such as /api/v1 is allowed. +// +// The zero value is not useful; use DefaultM2MEndpointPaths() to obtain defaults. +type M2MEndpointPaths struct { + // Token is the path suffix for the token endpoint (default: /oauth/token). + Token string + // Revoke is the path suffix for the revocation endpoint (default: /oauth/revoke). + Revoke string + // Introspect is the path suffix for the introspection endpoint (default: /oauth/introspect). + Introspect string + // JWKS is the path suffix for the JWKS endpoint (default: /oauth/jwks). + JWKS string +} + +// DefaultM2MEndpointPaths returns the default OAuth2 endpoint path suffixes. +func DefaultM2MEndpointPaths() M2MEndpointPaths { + return M2MEndpointPaths{ //nolint:gosec // G101: These are URL paths, not credentials. + Token: "/oauth/token", + Revoke: "/oauth/revoke", + Introspect: "/oauth/introspect", + JWKS: "/oauth/jwks", + } +} + // M2MAuthModule provides machine-to-machine (server-to-server) OAuth2 authentication. // It supports the client_credentials grant and the JWT-bearer grant, and can issue // tokens signed with either HS256 (shared secret) or ES256 (ECDSA P-256). @@ -94,6 +120,9 @@ type M2MAuthModule struct { // Optional pluggable persistence for token revocations. revocationStore TokenRevocationStore + // Configurable OAuth2 endpoint path suffixes. + endpointPaths M2MEndpointPaths + // Introspection access-control policy (see SetIntrospectPolicy). introspectAllowOthers bool // if true, authenticated callers may inspect any token introspectRequiredScope string // scope required in caller's token to inspect others @@ -111,14 +140,15 @@ func NewM2MAuthModule(name string, hmacSecret string, tokenExpiry time.Duration, issuer = "workflow" } m := &M2MAuthModule{ - name: name, - algorithm: SigningAlgHS256, - issuer: issuer, - tokenExpiry: tokenExpiry, - hmacSecret: []byte(hmacSecret), - trustedKeys: make(map[string]*ecdsa.PublicKey), - clients: make(map[string]*M2MClient), - jtiBlacklist: make(map[string]time.Time), + name: name, + algorithm: SigningAlgHS256, + issuer: issuer, + tokenExpiry: tokenExpiry, + hmacSecret: []byte(hmacSecret), + trustedKeys: make(map[string]*ecdsa.PublicKey), + clients: make(map[string]*M2MClient), + jtiBlacklist: make(map[string]time.Time), + endpointPaths: DefaultM2MEndpointPaths(), } return m } @@ -209,6 +239,32 @@ func (m *M2MAuthModule) SetRevocationStore(store TokenRevocationStore) { m.revocationStore = store } +// SetEndpoints overrides the URL path suffixes used by Handle() to route incoming +// requests to the token, revocation, introspection, and JWKS sub-handlers. +// Any empty field in paths is left at its current value (defaulting to the standard +// paths set by NewM2MAuthModule). +// +// Example – to match Fosite/Auth0-style paths: +// +// m.SetEndpoints(M2MEndpointPaths{ +// Revoke: "/oauth/token/revoke", +// Introspect: "/oauth/token/introspect", +// }) +func (m *M2MAuthModule) SetEndpoints(paths M2MEndpointPaths) { + if paths.Token != "" { + m.endpointPaths.Token = paths.Token + } + if paths.Revoke != "" { + m.endpointPaths.Revoke = paths.Revoke + } + if paths.Introspect != "" { + m.endpointPaths.Introspect = paths.Introspect + } + if paths.JWKS != "" { + m.endpointPaths.JWKS = paths.JWKS + } +} + // Name returns the module name. func (m *M2MAuthModule) Name() string { return m.name } @@ -243,24 +299,24 @@ func (m *M2MAuthModule) RequiresServices() []modular.ServiceDependency { return // Handle routes M2M OAuth2 requests. // -// Routes: +// Routes (path suffixes are configurable via SetEndpoints): // -// POST /oauth/token — token endpoint (client_credentials + jwt-bearer grants) -// POST /oauth/revoke — token revocation (RFC 7009) -// POST /oauth/introspect — token introspection (RFC 7662) -// GET /oauth/jwks — JSON Web Key Set (ES256 public key) +// POST — token endpoint (client_credentials + jwt-bearer grants) +// POST — token revocation (RFC 7009) +// POST — token introspection (RFC 7662) +// GET — JSON Web Key Set (ES256 public key) func (m *M2MAuthModule) Handle(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") path := r.URL.Path switch { - case r.Method == http.MethodPost && strings.HasSuffix(path, "/oauth/token"): + case r.Method == http.MethodPost && strings.HasSuffix(path, m.endpointPaths.Token): m.handleToken(w, r) - case r.Method == http.MethodPost && strings.HasSuffix(path, "/oauth/revoke"): + case r.Method == http.MethodPost && strings.HasSuffix(path, m.endpointPaths.Revoke): m.handleRevoke(w, r) - case r.Method == http.MethodPost && strings.HasSuffix(path, "/oauth/introspect"): + case r.Method == http.MethodPost && strings.HasSuffix(path, m.endpointPaths.Introspect): m.handleIntrospect(w, r) - case r.Method == http.MethodGet && strings.HasSuffix(path, "/oauth/jwks"): + case r.Method == http.MethodGet && strings.HasSuffix(path, m.endpointPaths.JWKS): m.handleJWKS(w, r) default: w.WriteHeader(http.StatusNotFound) diff --git a/module/auth_m2m_test.go b/module/auth_m2m_test.go index 0e9c38c8..caed1197 100644 --- a/module/auth_m2m_test.go +++ b/module/auth_m2m_test.go @@ -2130,3 +2130,157 @@ func TestM2M_Revoke_DBStore_MultipleTokens(t *testing.T) { t.Errorf("expected %d DB rows, got %d", numTokens, len(storedJTIs)) } } + +// --- Custom endpoint path configuration --- + +func TestM2M_DefaultEndpointPaths(t *testing.T) { + defaults := DefaultM2MEndpointPaths() + if defaults.Token != "/oauth/token" { + t.Errorf("expected Token=/oauth/token, got %q", defaults.Token) + } + if defaults.Revoke != "/oauth/revoke" { + t.Errorf("expected Revoke=/oauth/revoke, got %q", defaults.Revoke) + } + if defaults.Introspect != "/oauth/introspect" { + t.Errorf("expected Introspect=/oauth/introspect, got %q", defaults.Introspect) + } + if defaults.JWKS != "/oauth/jwks" { + t.Errorf("expected JWKS=/oauth/jwks, got %q", defaults.JWKS) + } +} + +func TestM2M_SetEndpoints_CustomPaths(t *testing.T) { + m := newM2MHS256(t) + m.SetEndpoints(M2MEndpointPaths{ + Token: "/v2/oauth/token", + Revoke: "/oauth/token/revoke", + Introspect: "/oauth/token/introspect", + JWKS: "/v2/oauth/jwks", + }) + + if m.endpointPaths.Token != "/v2/oauth/token" { + t.Errorf("expected /v2/oauth/token, got %q", m.endpointPaths.Token) + } + if m.endpointPaths.Revoke != "/oauth/token/revoke" { + t.Errorf("expected /oauth/token/revoke, got %q", m.endpointPaths.Revoke) + } + if m.endpointPaths.Introspect != "/oauth/token/introspect" { + t.Errorf("expected /oauth/token/introspect, got %q", m.endpointPaths.Introspect) + } + if m.endpointPaths.JWKS != "/v2/oauth/jwks" { + t.Errorf("expected /v2/oauth/jwks, got %q", m.endpointPaths.JWKS) + } +} + +func TestM2M_SetEndpoints_EmptyFieldsPreserveDefaults(t *testing.T) { + m := newM2MHS256(t) + // Only override Revoke; other paths should remain at defaults. + m.SetEndpoints(M2MEndpointPaths{ + Revoke: "/oauth/token/revoke", + }) + + if m.endpointPaths.Token != "/oauth/token" { + t.Errorf("Token should remain default, got %q", m.endpointPaths.Token) + } + if m.endpointPaths.Revoke != "/oauth/token/revoke" { + t.Errorf("expected /oauth/token/revoke, got %q", m.endpointPaths.Revoke) + } + if m.endpointPaths.Introspect != "/oauth/introspect" { + t.Errorf("Introspect should remain default, got %q", m.endpointPaths.Introspect) + } +} + +func TestM2M_CustomTokenPath_Issues_Token(t *testing.T) { + m := newM2MHS256(t) + m.SetEndpoints(M2MEndpointPaths{Token: "/v2/oauth/token"}) + + req := httptest.NewRequest(http.MethodPost, "/v2/oauth/token", + strings.NewReader(url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"test-client"}, + "client_secret": {"test-secret"}, + }.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + m.Handle(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestM2M_OldTokenPath_Returns404_WhenOverridden(t *testing.T) { + m := newM2MHS256(t) + m.SetEndpoints(M2MEndpointPaths{Token: "/v2/oauth/token"}) + + req := httptest.NewRequest(http.MethodPost, "/oauth/token", + strings.NewReader(url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"test-client"}, + "client_secret": {"test-secret"}, + }.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + m.Handle(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 on old path after override, got %d", w.Code) + } +} + +func TestM2M_CustomRevokePath_Fosite_Style(t *testing.T) { + m := newM2MHS256(t) + m.SetEndpoints(M2MEndpointPaths{ + Revoke: "/oauth/token/revoke", + Introspect: "/oauth/token/introspect", + }) + + tok := issueTestToken(t, m, "test-client", "test-secret") + + // Revoke via Fosite-style path. + req := httptest.NewRequest(http.MethodPost, "/oauth/token/revoke", + strings.NewReader(url.Values{"token": {tok}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth("test-client", "test-secret") + w := httptest.NewRecorder() + m.Handle(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 on revoke, got %d; body: %s", w.Code, w.Body.String()) + } + + // Old /oauth/revoke should now return 404. + req2 := httptest.NewRequest(http.MethodPost, "/oauth/revoke", + strings.NewReader(url.Values{"token": {tok}}.Encode())) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req2.SetBasicAuth("test-client", "test-secret") + w2 := httptest.NewRecorder() + m.Handle(w2, req2) + if w2.Code != http.StatusNotFound { + t.Errorf("expected 404 on old /oauth/revoke, got %d", w2.Code) + } +} + +func TestM2M_CustomIntrospectPath_Fosite_Style(t *testing.T) { + m := newM2MHS256(t) + m.SetEndpoints(M2MEndpointPaths{ + Introspect: "/oauth/token/introspect", + }) + m.SetIntrospectPolicy(true, "", "", "") + + tok := issueTestToken(t, m, "test-client", "test-secret") + + req := httptest.NewRequest(http.MethodPost, "/oauth/token/introspect", + strings.NewReader(url.Values{"token": {tok}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth("test-client", "test-secret") + w := httptest.NewRecorder() + m.Handle(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + _ = json.NewDecoder(w.Body).Decode(&resp) + if active, _ := resp["active"].(bool); !active { + t.Errorf("expected active=true, got %v", resp) + } +} diff --git a/module/platform_do_database.go b/module/platform_do_database.go index 8b550f33..ea44c7a5 100644 --- a/module/platform_do_database.go +++ b/module/platform_do_database.go @@ -13,12 +13,12 @@ import ( type DODatabaseState struct { ID string `json:"id"` Name string `json:"name"` - Engine string `json:"engine"` // pg, mysql, redis, mongodb, kafka + Engine string `json:"engine"` // pg, mysql, redis, mongodb, kafka Version string `json:"version"` - Size string `json:"size"` // e.g. db-s-1vcpu-1gb + Size string `json:"size"` // e.g. db-s-1vcpu-1gb Region string `json:"region"` NumNodes int `json:"numNodes"` - Status string `json:"status"` // pending, online, resizing, migrating, error + Status string `json:"status"` // pending, online, resizing, migrating, error Host string `json:"host"` Port int `json:"port"` DatabaseName string `json:"databaseName"` diff --git a/module/platform_do_database_test.go b/module/platform_do_database_test.go index 52e36f98..b1a28dcd 100644 --- a/module/platform_do_database_test.go +++ b/module/platform_do_database_test.go @@ -6,13 +6,13 @@ func TestPlatformDODatabase_MockBackend(t *testing.T) { m := &PlatformDODatabase{ name: "test-db", config: map[string]any{ - "provider": "mock", - "engine": "pg", - "version": "16", - "size": "db-s-1vcpu-1gb", - "region": "nyc1", + "provider": "mock", + "engine": "pg", + "version": "16", + "size": "db-s-1vcpu-1gb", + "region": "nyc1", "num_nodes": 1, - "name": "test-db", + "name": "test-db", }, state: &DODatabaseState{ Name: "test-db", diff --git a/module/scan_provider_test.go b/module/scan_provider_test.go index d6b83439..187e6d86 100644 --- a/module/scan_provider_test.go +++ b/module/scan_provider_test.go @@ -58,7 +58,7 @@ func (a *scanMockApp) GetService(name string, target any) error { return nil } -func (a *scanMockApp) RegisterService(name string, svc any) error { a.services[name] = svc; return nil } +func (a *scanMockApp) RegisterService(name string, svc any) error { a.services[name] = svc; return nil } func (a *scanMockApp) RegisterConfigSection(string, modular.ConfigProvider) {} func (a *scanMockApp) GetConfigSection(string) (modular.ConfigProvider, error) { return nil, nil @@ -67,7 +67,7 @@ func (a *scanMockApp) ConfigSections() map[string]modular.ConfigProvider { retur func (a *scanMockApp) Logger() modular.Logger { return nil } func (a *scanMockApp) SetLogger(modular.Logger) {} func (a *scanMockApp) ConfigProvider() modular.ConfigProvider { return nil } -func (a *scanMockApp) SvcRegistry() modular.ServiceRegistry { return a.services } +func (a *scanMockApp) SvcRegistry() modular.ServiceRegistry { return a.services } func (a *scanMockApp) RegisterModule(modular.Module) {} func (a *scanMockApp) Init() error { return nil } func (a *scanMockApp) Start() error { return nil } @@ -83,9 +83,9 @@ func (a *scanMockApp) GetServiceEntry(string) (*modular.ServiceRegistryEntry, bo func (a *scanMockApp) GetServicesByInterface(_ reflect.Type) []*modular.ServiceRegistryEntry { return nil } -func (a *scanMockApp) GetModule(string) modular.Module { return nil } -func (a *scanMockApp) GetAllModules() map[string]modular.Module { return nil } -func (a *scanMockApp) StartTime() time.Time { return time.Time{} } +func (a *scanMockApp) GetModule(string) modular.Module { return nil } +func (a *scanMockApp) GetAllModules() map[string]modular.Module { return nil } +func (a *scanMockApp) StartTime() time.Time { return time.Time{} } func (a *scanMockApp) OnConfigLoaded(func(modular.Application) error) {} func newScanApp(provider SecurityScannerProvider) *scanMockApp { diff --git a/plugins/auth/plugin.go b/plugins/auth/plugin.go index 10d8b0ba..9c37d114 100644 --- a/plugins/auth/plugin.go +++ b/plugins/auth/plugin.go @@ -214,6 +214,16 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { requiredClaimVal := stringFromMap(introspectCfg, "requiredClaimVal") m.SetIntrospectPolicy(allowOthers, requiredScope, requiredClaim, requiredClaimVal) } + + // Configure custom endpoint path suffixes. + if endpointsCfg, ok := cfg["endpoints"].(map[string]any); ok { + m.SetEndpoints(module.M2MEndpointPaths{ + Token: stringFromMap(endpointsCfg, "token"), + Revoke: stringFromMap(endpointsCfg, "revoke"), + Introspect: stringFromMap(endpointsCfg, "introspect"), + JWKS: stringFromMap(endpointsCfg, "jwks"), + }) + } return m }, } @@ -380,6 +390,7 @@ func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { {Key: "issuer", Label: "Issuer", Type: schema.FieldTypeString, DefaultValue: "workflow", Description: "Token issuer (iss) claim", Placeholder: "workflow"}, {Key: "clients", Label: "Registered Clients", Type: schema.FieldTypeJSON, Description: "List of OAuth2 clients: [{clientId, clientSecret, scopes, description, claims}]"}, {Key: "introspect", Label: "Introspection Policy", Type: schema.FieldTypeJSON, Description: "Access-control policy for POST /oauth/introspect: {allowOthers: bool, requiredScope: string, requiredClaim: string, requiredClaimVal: string}. Default: self-only (allowOthers: false)."}, + {Key: "endpoints", Label: "Endpoint Paths", Type: schema.FieldTypeJSON, Description: "Custom OAuth2 endpoint path suffixes: {token, revoke, introspect, jwks}. Defaults: /oauth/token, /oauth/revoke, /oauth/introspect, /oauth/jwks."}, }, DefaultConfig: map[string]any{"algorithm": "ES256", "tokenExpiry": "1h", "issuer": "workflow", "clients": []any{}}, }, diff --git a/plugins/auth/plugin_test.go b/plugins/auth/plugin_test.go index 441cf771..453efc43 100644 --- a/plugins/auth/plugin_test.go +++ b/plugins/auth/plugin_test.go @@ -168,3 +168,55 @@ func TestModuleFactoryM2MWithClaims(t *testing.T) { t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) } } + +func TestModuleFactoryM2MWithCustomEndpoints(t *testing.T) { + p := New() + factories := p.ModuleFactories() + + mod := factories["auth.m2m"]("m2m-ep-test", map[string]any{ + "algorithm": "HS256", + "secret": "this-is-a-valid-secret-32-bytes!", + "clients": []any{ + map[string]any{ + "clientId": "ep-client", + "clientSecret": "ep-secret", + }, + }, + "endpoints": map[string]any{ + "token": "/v2/oauth/token", + "revoke": "/oauth/token/revoke", + "introspect": "/oauth/token/introspect", + "jwks": "/v2/oauth/jwks", + }, + }) + if mod == nil { + t.Fatal("auth.m2m factory returned nil") + } + m2mMod, ok := mod.(*module.M2MAuthModule) + if !ok { + t.Fatal("expected *module.M2MAuthModule") + } + + // Custom token path should work. + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"ep-client"}, + "client_secret": {"ep-secret"}, + } + req := httptest.NewRequest(http.MethodPost, "/v2/oauth/token", strings.NewReader(params.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + m2mMod.Handle(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 on custom token path, got %d; body: %s", w.Code, w.Body.String()) + } + + // Default token path should return 404 when overridden. + req2 := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(params.Encode())) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w2 := httptest.NewRecorder() + m2mMod.Handle(w2, req2) + if w2.Code != http.StatusNotFound { + t.Errorf("expected 404 on default path after override, got %d", w2.Code) + } +} From b197df5f5818b4ff2a32dd3bde30e43435df8db8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:30:26 +0000 Subject: [PATCH 3/3] auth.m2m: validate endpoint paths, fix data race, clarify schema description Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- module/auth_m2m.go | 74 +++++++++++++++++---- module/auth_m2m_test.go | 128 +++++++++++++++++++++++++++++------- plugins/auth/plugin.go | 11 ++-- plugins/auth/plugin_test.go | 4 +- 4 files changed, 174 insertions(+), 43 deletions(-) diff --git a/module/auth_m2m.go b/module/auth_m2m.go index 4eed434c..1c75d36a 100644 --- a/module/auth_m2m.go +++ b/module/auth_m2m.go @@ -309,25 +309,68 @@ func (m *M2MAuthModule) SetRevocationStore(store TokenRevocationStore) { // Any empty field in paths is left at its current value (defaulting to the standard // paths set by NewM2MAuthModule). // +// Each path must begin with '/' and all four resulting paths must be distinct to +// prevent ambiguous suffix matching. An error is returned if validation fails; the +// module's previous endpoint configuration is not modified. +// // Example – to match Fosite/Auth0-style paths: // -// m.SetEndpoints(M2MEndpointPaths{ +// if err := m.SetEndpoints(M2MEndpointPaths{ // Revoke: "/oauth/token/revoke", // Introspect: "/oauth/token/introspect", -// }) -func (m *M2MAuthModule) SetEndpoints(paths M2MEndpointPaths) { +// }); err != nil { +// // handle error +// } +func (m *M2MAuthModule) SetEndpoints(paths M2MEndpointPaths) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Build the candidate configuration by applying non-empty overrides. + candidate := m.endpointPaths if paths.Token != "" { - m.endpointPaths.Token = paths.Token + candidate.Token = paths.Token } if paths.Revoke != "" { - m.endpointPaths.Revoke = paths.Revoke + candidate.Revoke = paths.Revoke } if paths.Introspect != "" { - m.endpointPaths.Introspect = paths.Introspect + candidate.Introspect = paths.Introspect } if paths.JWKS != "" { - m.endpointPaths.JWKS = paths.JWKS + candidate.JWKS = paths.JWKS + } + + if err := validateEndpointPaths(candidate); err != nil { + return err } + + m.endpointPaths = candidate + return nil +} + +// validateEndpointPaths checks that all four endpoint paths are non-empty, start +// with '/', and are mutually distinct. +func validateEndpointPaths(p M2MEndpointPaths) error { + entries := []struct{ name, value string }{ + {"token", p.Token}, + {"revoke", p.Revoke}, + {"introspect", p.Introspect}, + {"jwks", p.JWKS}, + } + seen := make(map[string]string, len(entries)) + for _, e := range entries { + if e.value == "" { + return fmt.Errorf("M2M auth: endpoint %q path must not be empty", e.name) + } + if !strings.HasPrefix(e.value, "/") { + return fmt.Errorf("M2M auth: endpoint %q path %q must start with '/'", e.name, e.value) + } + if prev, exists := seen[e.value]; exists { + return fmt.Errorf("M2M auth: endpoints %q and %q share the same path %q", prev, e.name, e.value) + } + seen[e.value] = e.name + } + return nil } // Name returns the module name. @@ -337,7 +380,7 @@ func (m *M2MAuthModule) Name() string { return m.name } // that occurred in the factory (stored in initErr). func (m *M2MAuthModule) Init(_ modular.Application) error { if m.initErr != nil { - return fmt.Errorf("M2M auth: key setup failed: %w", m.initErr) + return fmt.Errorf("M2M auth: %w", m.initErr) } if m.algorithm == SigningAlgHS256 && len(m.hmacSecret) < 32 { return fmt.Errorf("M2M auth: HMAC secret must be at least 32 bytes for HS256") @@ -345,6 +388,9 @@ func (m *M2MAuthModule) Init(_ modular.Application) error { if m.algorithm == SigningAlgES256 && m.privateKey == nil { return fmt.Errorf("M2M auth: ECDSA private key required for ES256") } + if err := validateEndpointPaths(m.endpointPaths); err != nil { + return err + } return nil } @@ -373,15 +419,19 @@ func (m *M2MAuthModule) RequiresServices() []modular.ServiceDependency { return func (m *M2MAuthModule) Handle(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + m.mu.RLock() + ep := m.endpointPaths + m.mu.RUnlock() + path := r.URL.Path switch { - case r.Method == http.MethodPost && strings.HasSuffix(path, m.endpointPaths.Token): + case r.Method == http.MethodPost && strings.HasSuffix(path, ep.Token): m.handleToken(w, r) - case r.Method == http.MethodPost && strings.HasSuffix(path, m.endpointPaths.Revoke): + case r.Method == http.MethodPost && strings.HasSuffix(path, ep.Revoke): m.handleRevoke(w, r) - case r.Method == http.MethodPost && strings.HasSuffix(path, m.endpointPaths.Introspect): + case r.Method == http.MethodPost && strings.HasSuffix(path, ep.Introspect): m.handleIntrospect(w, r) - case r.Method == http.MethodGet && strings.HasSuffix(path, m.endpointPaths.JWKS): + case r.Method == http.MethodGet && strings.HasSuffix(path, ep.JWKS): m.handleJWKS(w, r) default: w.WriteHeader(http.StatusNotFound) diff --git a/module/auth_m2m_test.go b/module/auth_m2m_test.go index a395343b..27fefcb9 100644 --- a/module/auth_m2m_test.go +++ b/module/auth_m2m_test.go @@ -2350,48 +2350,122 @@ func TestM2M_DefaultEndpointPaths(t *testing.T) { func TestM2M_SetEndpoints_CustomPaths(t *testing.T) { m := newM2MHS256(t) - m.SetEndpoints(M2MEndpointPaths{ + if err := m.SetEndpoints(M2MEndpointPaths{ Token: "/v2/oauth/token", Revoke: "/oauth/token/revoke", Introspect: "/oauth/token/introspect", JWKS: "/v2/oauth/jwks", - }) + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } - if m.endpointPaths.Token != "/v2/oauth/token" { - t.Errorf("expected /v2/oauth/token, got %q", m.endpointPaths.Token) + m.mu.RLock() + ep := m.endpointPaths + m.mu.RUnlock() + if ep.Token != "/v2/oauth/token" { + t.Errorf("expected /v2/oauth/token, got %q", ep.Token) } - if m.endpointPaths.Revoke != "/oauth/token/revoke" { - t.Errorf("expected /oauth/token/revoke, got %q", m.endpointPaths.Revoke) + if ep.Revoke != "/oauth/token/revoke" { + t.Errorf("expected /oauth/token/revoke, got %q", ep.Revoke) } - if m.endpointPaths.Introspect != "/oauth/token/introspect" { - t.Errorf("expected /oauth/token/introspect, got %q", m.endpointPaths.Introspect) + if ep.Introspect != "/oauth/token/introspect" { + t.Errorf("expected /oauth/token/introspect, got %q", ep.Introspect) } - if m.endpointPaths.JWKS != "/v2/oauth/jwks" { - t.Errorf("expected /v2/oauth/jwks, got %q", m.endpointPaths.JWKS) + if ep.JWKS != "/v2/oauth/jwks" { + t.Errorf("expected /v2/oauth/jwks, got %q", ep.JWKS) } } func TestM2M_SetEndpoints_EmptyFieldsPreserveDefaults(t *testing.T) { m := newM2MHS256(t) // Only override Revoke; other paths should remain at defaults. - m.SetEndpoints(M2MEndpointPaths{ + if err := m.SetEndpoints(M2MEndpointPaths{ Revoke: "/oauth/token/revoke", - }) + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } - if m.endpointPaths.Token != "/oauth/token" { - t.Errorf("Token should remain default, got %q", m.endpointPaths.Token) + m.mu.RLock() + ep := m.endpointPaths + m.mu.RUnlock() + if ep.Token != "/oauth/token" { + t.Errorf("Token should remain default, got %q", ep.Token) } - if m.endpointPaths.Revoke != "/oauth/token/revoke" { - t.Errorf("expected /oauth/token/revoke, got %q", m.endpointPaths.Revoke) + if ep.Revoke != "/oauth/token/revoke" { + t.Errorf("expected /oauth/token/revoke, got %q", ep.Revoke) } - if m.endpointPaths.Introspect != "/oauth/introspect" { - t.Errorf("Introspect should remain default, got %q", m.endpointPaths.Introspect) + if ep.Introspect != "/oauth/introspect" { + t.Errorf("Introspect should remain default, got %q", ep.Introspect) + } +} + +func TestM2M_SetEndpoints_ValidationErrors(t *testing.T) { + tests := []struct { + name string + paths M2MEndpointPaths + wantErr string + }{ + { + name: "missing leading slash", + paths: M2MEndpointPaths{Token: "oauth/token"}, + wantErr: "must start with '/'", + }, + { + name: "duplicate paths", + paths: M2MEndpointPaths{ + Token: "/oauth/token", + Revoke: "/oauth/token", + Introspect: "/oauth/introspect", + JWKS: "/oauth/jwks", + }, + wantErr: "share the same path", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newM2MHS256(t) + err := m.SetEndpoints(tc.paths) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("expected error containing %q, got %q", tc.wantErr, err.Error()) + } + }) + } +} + +func TestM2M_SetEndpoints_InvalidPath_DoesNotMutateState(t *testing.T) { + m := newM2MHS256(t) + // Attempt an invalid override (no leading slash). + err := m.SetEndpoints(M2MEndpointPaths{Token: "bad-path"}) + if err == nil { + t.Fatal("expected validation error") + } + // Existing defaults must be intact. + m.mu.RLock() + ep := m.endpointPaths + m.mu.RUnlock() + if ep.Token != "/oauth/token" { + t.Errorf("expected original default /oauth/token to be preserved, got %q", ep.Token) + } +} + +func TestM2M_Init_ValidatesEndpointPaths(t *testing.T) { + // Directly corrupt endpointPaths to simulate a misconfiguration that + // bypassed SetEndpoints (e.g. zero-value struct). + m := newM2MHS256(t) + m.endpointPaths.Token = "no-leading-slash" + if err := m.Init(nil); err == nil { + t.Error("expected Init to reject invalid endpoint path") } } func TestM2M_CustomTokenPath_Issues_Token(t *testing.T) { m := newM2MHS256(t) - m.SetEndpoints(M2MEndpointPaths{Token: "/v2/oauth/token"}) + if err := m.SetEndpoints(M2MEndpointPaths{Token: "/v2/oauth/token"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } req := httptest.NewRequest(http.MethodPost, "/v2/oauth/token", strings.NewReader(url.Values{ @@ -2410,7 +2484,9 @@ func TestM2M_CustomTokenPath_Issues_Token(t *testing.T) { func TestM2M_OldTokenPath_Returns404_WhenOverridden(t *testing.T) { m := newM2MHS256(t) - m.SetEndpoints(M2MEndpointPaths{Token: "/v2/oauth/token"}) + if err := m.SetEndpoints(M2MEndpointPaths{Token: "/v2/oauth/token"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(url.Values{ @@ -2429,10 +2505,12 @@ func TestM2M_OldTokenPath_Returns404_WhenOverridden(t *testing.T) { func TestM2M_CustomRevokePath_Fosite_Style(t *testing.T) { m := newM2MHS256(t) - m.SetEndpoints(M2MEndpointPaths{ + if err := m.SetEndpoints(M2MEndpointPaths{ Revoke: "/oauth/token/revoke", Introspect: "/oauth/token/introspect", - }) + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } tok := issueTestToken(t, m, "test-client", "test-secret") @@ -2461,9 +2539,11 @@ func TestM2M_CustomRevokePath_Fosite_Style(t *testing.T) { func TestM2M_CustomIntrospectPath_Fosite_Style(t *testing.T) { m := newM2MHS256(t) - m.SetEndpoints(M2MEndpointPaths{ + if err := m.SetEndpoints(M2MEndpointPaths{ Introspect: "/oauth/token/introspect", - }) + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } m.SetIntrospectPolicy(true, "", "", "") tok := issueTestToken(t, m, "test-client", "test-secret") diff --git a/plugins/auth/plugin.go b/plugins/auth/plugin.go index cf991e0f..0e545bb8 100644 --- a/plugins/auth/plugin.go +++ b/plugins/auth/plugin.go @@ -218,14 +218,15 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { // Configure custom endpoint path suffixes. if endpointsCfg, ok := cfg["endpoints"].(map[string]any); ok { - m.SetEndpoints(module.M2MEndpointPaths{ + if err := m.SetEndpoints(module.M2MEndpointPaths{ Token: stringFromMap(endpointsCfg, "token"), Revoke: stringFromMap(endpointsCfg, "revoke"), Introspect: stringFromMap(endpointsCfg, "introspect"), JWKS: stringFromMap(endpointsCfg, "jwks"), - }) - } - + }); err != nil { + m.SetInitErr(err) + } + } // Register YAML-configured trusted keys for JWT-bearer grants. if trustedKeys, ok := cfg["trustedKeys"].([]any); ok { for i, tk := range trustedKeys { @@ -433,7 +434,7 @@ func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { {Key: "issuer", Label: "Issuer", Type: schema.FieldTypeString, DefaultValue: "workflow", Description: "Token issuer (iss) claim", Placeholder: "workflow"}, {Key: "clients", Label: "Registered Clients", Type: schema.FieldTypeJSON, Description: "List of OAuth2 clients: [{clientId, clientSecret, scopes, description, claims}]"}, {Key: "introspect", Label: "Introspection Policy", Type: schema.FieldTypeJSON, Description: "Access-control policy for POST /oauth/introspect: {allowOthers: bool, requiredScope: string, requiredClaim: string, requiredClaimVal: string}. Default: self-only (allowOthers: false)."}, - {Key: "endpoints", Label: "Endpoint Paths", Type: schema.FieldTypeJSON, Description: "Custom OAuth2 endpoint path suffixes: {token, revoke, introspect, jwks}. Defaults: /oauth/token, /oauth/revoke, /oauth/introspect, /oauth/jwks."}, + {Key: "endpoints", Label: "Endpoint Paths", Type: schema.FieldTypeJSON, Description: "Custom OAuth2 endpoint path suffixes matched via strings.HasSuffix (so a router mount prefix is allowed). Keys: {token, revoke, introspect, jwks}. Each value must start with '/' and all four paths must be distinct. Defaults: /oauth/token, /oauth/revoke, /oauth/introspect, /oauth/jwks."}, {Key: "trustedKeys", Label: "Trusted External Issuers", Type: schema.FieldTypeJSON, Description: "List of trusted external JWT issuers for JWT-bearer grants: [{issuer, publicKeyPEM, audiences, claimMapping}]. Supports literal \\n in PEM values for Docker/Kubernetes env vars."}, }, DefaultConfig: map[string]any{"algorithm": "ES256", "tokenExpiry": "1h", "issuer": "workflow", "clients": []any{}}, diff --git a/plugins/auth/plugin_test.go b/plugins/auth/plugin_test.go index 551abe83..a4a1e10e 100644 --- a/plugins/auth/plugin_test.go +++ b/plugins/auth/plugin_test.go @@ -205,7 +205,7 @@ func TestModuleFactoryM2MWithCustomEndpoints(t *testing.T) { t.Fatal("expected *module.M2MAuthModule") } - // Custom token path should work. + // Custom token path should work. params := url.Values{ "grant_type": {"client_credentials"}, "client_id": {"ep-client"}, @@ -256,7 +256,7 @@ func TestModuleFactoryM2MWithTrustedKeys(t *testing.T) { }, }, }, - }) + }) if mod == nil { t.Fatal("auth.m2m factory returned nil") }