diff --git a/module/auth_m2m.go b/module/auth_m2m.go index b9aee52d..1c75d36a 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", + } +} + // TrustedKeyConfig holds the configuration for a trusted external JWT issuer. // It is used to register trusted keys for the JWT-bearer grant via YAML configuration. type TrustedKeyConfig struct { @@ -120,6 +146,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 @@ -137,14 +166,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]*trustedKeyEntry), - 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]*trustedKeyEntry), + clients: make(map[string]*M2MClient), + jtiBlacklist: make(map[string]time.Time), + endpointPaths: DefaultM2MEndpointPaths(), } return m } @@ -274,6 +304,75 @@ 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). +// +// 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: +// +// if err := m.SetEndpoints(M2MEndpointPaths{ +// Revoke: "/oauth/token/revoke", +// Introspect: "/oauth/token/introspect", +// }); 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 != "" { + candidate.Token = paths.Token + } + if paths.Revoke != "" { + candidate.Revoke = paths.Revoke + } + if paths.Introspect != "" { + candidate.Introspect = paths.Introspect + } + if 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. func (m *M2MAuthModule) Name() string { return m.name } @@ -281,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") @@ -289,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 } @@ -308,24 +410,28 @@ 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") + m.mu.RLock() + ep := m.endpointPaths + m.mu.RUnlock() + path := r.URL.Path switch { - case r.Method == http.MethodPost && strings.HasSuffix(path, "/oauth/token"): + case r.Method == http.MethodPost && strings.HasSuffix(path, ep.Token): m.handleToken(w, r) - case r.Method == http.MethodPost && strings.HasSuffix(path, "/oauth/revoke"): + case r.Method == http.MethodPost && strings.HasSuffix(path, ep.Revoke): m.handleRevoke(w, r) - case r.Method == http.MethodPost && strings.HasSuffix(path, "/oauth/introspect"): + case r.Method == http.MethodPost && strings.HasSuffix(path, ep.Introspect): m.handleIntrospect(w, r) - case r.Method == http.MethodGet && strings.HasSuffix(path, "/oauth/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 9e8d11d3..27fefcb9 100644 --- a/module/auth_m2m_test.go +++ b/module/auth_m2m_test.go @@ -2329,3 +2329,237 @@ 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) + 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) + } + + 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 ep.Revoke != "/oauth/token/revoke" { + t.Errorf("expected /oauth/token/revoke, got %q", ep.Revoke) + } + if ep.Introspect != "/oauth/token/introspect" { + t.Errorf("expected /oauth/token/introspect, got %q", ep.Introspect) + } + 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. + if err := m.SetEndpoints(M2MEndpointPaths{ + Revoke: "/oauth/token/revoke", + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + 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 ep.Revoke != "/oauth/token/revoke" { + t.Errorf("expected /oauth/token/revoke, got %q", ep.Revoke) + } + 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) + 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{ + "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) + 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{ + "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) + 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") + + // 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) + 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") + + 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/plugins/auth/plugin.go b/plugins/auth/plugin.go index fe2b1387..0e545bb8 100644 --- a/plugins/auth/plugin.go +++ b/plugins/auth/plugin.go @@ -216,6 +216,17 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { m.SetIntrospectPolicy(allowOthers, requiredScope, requiredClaim, requiredClaimVal) } + // Configure custom endpoint path suffixes. + if endpointsCfg, ok := cfg["endpoints"].(map[string]any); ok { + 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 { @@ -423,6 +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 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 bca73c84..a4a1e10e 100644 --- a/plugins/auth/plugin_test.go +++ b/plugins/auth/plugin_test.go @@ -176,6 +176,59 @@ func TestModuleFactoryM2MWithClaims(t *testing.T) { } } +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) + } +} + func TestModuleFactoryM2MWithTrustedKeys(t *testing.T) { // Generate a key pair to represent an external trusted issuer. clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)