From f46f11468479d673969711c0bc372e9130e52d68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:54:23 +0000 Subject: [PATCH 1/3] Initial plan From 76db07be987164d8af63592bdcbbd5a696a27cea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:23:42 +0000 Subject: [PATCH 2/3] feat(auth.m2m): YAML-configurable trusted keys for federated JWT-bearer grants Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- module/auth_m2m.go | 140 ++++++++++++++++++++-- module/auth_m2m_test.go | 177 +++++++++++++++++++++++++++- module/platform_do_database.go | 6 +- module/platform_do_database_test.go | 12 +- module/scan_provider_test.go | 10 +- plugins/auth/plugin.go | 37 ++++++ plugins/auth/plugin_test.go | 72 +++++++++++ 7 files changed, 430 insertions(+), 24 deletions(-) diff --git a/module/auth_m2m.go b/module/auth_m2m.go index 394c7d6d..66e60571 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"` } +// 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 { + // Issuer is the expected `iss` claim value (e.g. "https://legacy-platform.example.com"). + Issuer string `json:"issuer" yaml:"issuer"` + // Algorithm is the expected signing algorithm (e.g. "ES256"). Currently only ES256 is supported. + Algorithm string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"` + // PublicKeyPEM is the PEM-encoded EC public key for the trusted issuer. + // Literal `\n` sequences (common in Docker/Kubernetes env vars) are normalised to newlines. + PublicKeyPEM string `json:"publicKeyPEM,omitempty" yaml:"publicKeyPEM,omitempty"` //nolint:gosec // G117: config DTO field + // Audiences is an optional list of accepted audience values. + // When non-empty, the assertion's `aud` claim must contain at least one of these values. + Audiences []string `json:"audiences,omitempty" yaml:"audiences,omitempty"` + // ClaimMapping renames claims from the external assertion before they are included in the + // issued token. The map key is the external claim name; the value is the local claim name. + // For example {"user_id": "sub"} promotes the external `user_id` claim to `sub`. + ClaimMapping map[string]string `json:"claimMapping,omitempty" yaml:"claimMapping,omitempty"` +} + +// trustedKeyEntry is the internal representation of a trusted external JWT issuer. +type trustedKeyEntry struct { + pubKey *ecdsa.PublicKey + audiences []string + claimMapping map[string]string +} + // 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). @@ -84,7 +110,7 @@ type M2MAuthModule struct { publicKey *ecdsa.PublicKey // Trusted public keys for JWT-bearer grant (keyed by key ID or issuer) - trustedKeys map[string]*ecdsa.PublicKey + trustedKeys map[string]*trustedKeyEntry // Registered clients mu sync.RWMutex @@ -116,7 +142,7 @@ func NewM2MAuthModule(name string, hmacSecret string, tokenExpiry time.Duration, issuer: issuer, tokenExpiry: tokenExpiry, hmacSecret: []byte(hmacSecret), - trustedKeys: make(map[string]*ecdsa.PublicKey), + trustedKeys: make(map[string]*trustedKeyEntry), clients: make(map[string]*M2MClient), jtiBlacklist: make(map[string]time.Time), } @@ -166,7 +192,43 @@ func (m *M2MAuthModule) SetInitErr(err error) { func (m *M2MAuthModule) AddTrustedKey(keyID string, pubKey *ecdsa.PublicKey) { m.mu.Lock() defer m.mu.Unlock() - m.trustedKeys[keyID] = pubKey + m.trustedKeys[keyID] = &trustedKeyEntry{pubKey: pubKey} +} + +// AddTrustedKeyFromPEM parses a PEM-encoded EC public key and registers it as a trusted +// key for JWT-bearer assertion validation. Literal `\n` sequences in the PEM string are +// normalised to real newlines so that env-var-injected keys (Docker/Kubernetes) work without +// additional preprocessing by the caller. +// +// audiences is an optional list; when non-empty the assertion's `aud` claim must match at +// least one entry. claimMapping renames external claims before they are forwarded into the +// issued token (map key = external name, map value = local name). +func (m *M2MAuthModule) AddTrustedKeyFromPEM(issuer, publicKeyPEM string, audiences []string, claimMapping map[string]string) error { + // Normalise escaped newlines that are common in Docker/Kubernetes env vars. + normalised := strings.ReplaceAll(publicKeyPEM, `\n`, "\n") + + block, _ := pem.Decode([]byte(normalised)) + if block == nil { + return fmt.Errorf("auth.m2m: failed to decode PEM block for issuer %q", issuer) + } + + pubAny, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return fmt.Errorf("auth.m2m: parse public key for issuer %q: %w", issuer, err) + } + ecKey, ok := pubAny.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("auth.m2m: public key for issuer %q is not an ECDSA key", issuer) + } + + m.mu.Lock() + defer m.mu.Unlock() + m.trustedKeys[issuer] = &trustedKeyEntry{ + pubKey: ecKey, + audiences: audiences, + claimMapping: claimMapping, + } + return nil } // RegisterClient registers a new OAuth2 client. @@ -676,19 +738,19 @@ func (m *M2MAuthModule) validateJWTAssertion(assertion string) (jwt.MapClaims, e m.mu.RLock() // Try kid first, then iss. - var selectedKey *ecdsa.PublicKey + var selectedEntry *trustedKeyEntry if kid != "" { - selectedKey = m.trustedKeys[kid] + selectedEntry = m.trustedKeys[kid] } - if selectedKey == nil && iss != "" { - selectedKey = m.trustedKeys[iss] + if selectedEntry == nil && iss != "" { + selectedEntry = m.trustedKeys[iss] } hmacSecret := m.hmacSecret m.mu.RUnlock() // Try EC key if found. - if selectedKey != nil { - k := selectedKey + if selectedEntry != nil && selectedEntry.pubKey != nil { + k := selectedEntry.pubKey token, err := jwt.Parse(assertion, func(token *jwt.Token) (any, error) { if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) @@ -702,6 +764,19 @@ func (m *M2MAuthModule) validateJWTAssertion(assertion string) (jwt.MapClaims, e if !ok || !token.Valid { return nil, fmt.Errorf("invalid assertion claims") } + + // Validate audience if configured. + if len(selectedEntry.audiences) > 0 { + if err := validateAssertionAudience(claims, selectedEntry.audiences); err != nil { + return nil, err + } + } + + // Apply claim mapping if configured. + if len(selectedEntry.claimMapping) > 0 { + claims = applyAssertionClaimMapping(claims, selectedEntry.claimMapping) + } + return claims, nil } @@ -1032,3 +1107,50 @@ func oauthError(code, description string) map[string]string { "error_description": description, } } + +// validateAssertionAudience checks that the JWT claims contain at least one of the +// required audience values. The `aud` claim can be a single string or a JSON array. +func validateAssertionAudience(claims jwt.MapClaims, requiredAudiences []string) error { + aud := claims["aud"] + if aud == nil { + return fmt.Errorf("assertion missing aud claim, expected one of %v", requiredAudiences) + } + var tokenAuds []string + switch v := aud.(type) { + case string: + tokenAuds = []string{v} + case []any: + for _, a := range v { + if s, ok := a.(string); ok { + tokenAuds = append(tokenAuds, s) + } + } + } + for _, required := range requiredAudiences { + for _, tokenAud := range tokenAuds { + if tokenAud == required { + return nil + } + } + } + return fmt.Errorf("assertion audience %v does not include required audience %v", tokenAuds, requiredAudiences) +} + +// applyAssertionClaimMapping renames claims from an external assertion before they are +// forwarded into the issued token. The mapping key is the external claim name; the +// value is the local claim name. The original claim is removed when the names differ. +func applyAssertionClaimMapping(claims jwt.MapClaims, mapping map[string]string) jwt.MapClaims { + result := make(jwt.MapClaims, len(claims)) + for k, v := range claims { + result[k] = v + } + for externalKey, localKey := range mapping { + if val, exists := claims[externalKey]; exists { + result[localKey] = val + if externalKey != localKey { + delete(result, externalKey) + } + } + } + return result +} diff --git a/module/auth_m2m_test.go b/module/auth_m2m_test.go index 0e9c38c8..cedb8f98 100644 --- a/module/auth_m2m_test.go +++ b/module/auth_m2m_test.go @@ -1458,11 +1458,186 @@ func TestM2M_AddTrustedKey(t *testing.T) { stored := m.trustedKeys["svc"] m.mu.RUnlock() - if stored == nil { + if stored == nil || stored.pubKey == nil { t.Error("expected key to be stored") } } +// ecPublicKeyToPEM marshals an ECDSA public key to a PEM-encoded string. +func ecPublicKeyToPEM(t *testing.T, pub *ecdsa.PublicKey) string { + t.Helper() + pkixBytes, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + t.Fatalf("MarshalPKIXPublicKey: %v", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pkixBytes})) +} + +func TestM2M_AddTrustedKeyFromPEM_Valid(t *testing.T) { + m := newM2MES256(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + pemStr := ecPublicKeyToPEM(t, &key.PublicKey) + + if err := m.AddTrustedKeyFromPEM("issuer-a", pemStr, nil, nil); err != nil { + t.Fatalf("AddTrustedKeyFromPEM: %v", err) + } + + m.mu.RLock() + stored := m.trustedKeys["issuer-a"] + m.mu.RUnlock() + + if stored == nil || stored.pubKey == nil { + t.Error("expected key to be stored") + } +} + +func TestM2M_AddTrustedKeyFromPEM_EscapedNewlines(t *testing.T) { + m := newM2MES256(t) + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + pemStr := ecPublicKeyToPEM(t, &key.PublicKey) + // Simulate Docker/Kubernetes env var with literal \n instead of real newlines. + escapedPEM := strings.ReplaceAll(pemStr, "\n", `\n`) + + if err := m.AddTrustedKeyFromPEM("issuer-b", escapedPEM, nil, nil); err != nil { + t.Fatalf("AddTrustedKeyFromPEM with escaped newlines: %v", err) + } + + m.mu.RLock() + stored := m.trustedKeys["issuer-b"] + m.mu.RUnlock() + + if stored == nil || stored.pubKey == nil { + t.Error("expected key to be stored after escaped-newline normalisation") + } +} + +func TestM2M_AddTrustedKeyFromPEM_Invalid(t *testing.T) { + m := newM2MES256(t) + err := m.AddTrustedKeyFromPEM("issuer-bad", "not-a-pem", nil, nil) + if err == nil { + t.Error("expected error for invalid PEM, got nil") + } +} + +func TestM2M_JWTBearer_AudienceValid(t *testing.T) { + server := newM2MES256(t) + clientKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + pemStr := ecPublicKeyToPEM(t, &clientKey.PublicKey) + + if err := server.AddTrustedKeyFromPEM("client-svc", pemStr, []string{"test-issuer"}, nil); err != nil { + t.Fatalf("AddTrustedKeyFromPEM: %v", err) + } + + claims := jwt.MapClaims{ + "iss": "client-svc", + "sub": "client-svc", + "aud": "test-issuer", + "iat": time.Now().Unix(), + "exp": time.Now().Add(5 * time.Minute).Unix(), + } + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + assertion, err := tok.SignedString(clientKey) + if err != nil { + t.Fatalf("sign assertion: %v", err) + } + + params := url.Values{ + "grant_type": {GrantTypeJWTBearer}, + "assertion": {assertion}, + } + w := postToken(t, server, params) + if w.Code != http.StatusOK { + t.Errorf("expected 200 with valid audience, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestM2M_JWTBearer_AudienceMismatch(t *testing.T) { + server := newM2MES256(t) + clientKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + pemStr := ecPublicKeyToPEM(t, &clientKey.PublicKey) + + // Require audience "test-issuer" but assertion will have "wrong-audience". + if err := server.AddTrustedKeyFromPEM("client-svc", pemStr, []string{"test-issuer"}, nil); err != nil { + t.Fatalf("AddTrustedKeyFromPEM: %v", err) + } + + claims := jwt.MapClaims{ + "iss": "client-svc", + "sub": "client-svc", + "aud": "wrong-audience", + "iat": time.Now().Unix(), + "exp": time.Now().Add(5 * time.Minute).Unix(), + } + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + assertion, _ := tok.SignedString(clientKey) + + params := url.Values{ + "grant_type": {GrantTypeJWTBearer}, + "assertion": {assertion}, + } + w := postToken(t, server, params) + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for audience mismatch, got %d", w.Code) + } +} + +func TestM2M_JWTBearer_ClaimMapping(t *testing.T) { + server := newM2MES256(t) + clientKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + pemStr := ecPublicKeyToPEM(t, &clientKey.PublicKey) + + // Map external claim "user_id" → local claim "ext_user". + claimMapping := map[string]string{"user_id": "ext_user"} + if err := server.AddTrustedKeyFromPEM("client-svc", pemStr, nil, claimMapping); err != nil { + t.Fatalf("AddTrustedKeyFromPEM: %v", err) + } + + claims := jwt.MapClaims{ + "iss": "client-svc", + "sub": "client-svc", + "aud": "test-issuer", + "iat": time.Now().Unix(), + "exp": time.Now().Add(5 * time.Minute).Unix(), + "user_id": "u-42", + } + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + assertion, _ := tok.SignedString(clientKey) + + params := url.Values{ + "grant_type": {GrantTypeJWTBearer}, + "assertion": {assertion}, + } + w := postToken(t, server, params) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Parse the issued access token to verify claim mapping was applied. + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + accessToken, _ := resp["access_token"].(string) + if accessToken == "" { + t.Fatal("no access_token in response") + } + + // Parse unverified to inspect claims. + parser := new(jwt.Parser) + parsed, _, err := parser.ParseUnverified(accessToken, jwt.MapClaims{}) + if err != nil { + t.Fatalf("parse issued token: %v", err) + } + issuedClaims, _ := parsed.Claims.(jwt.MapClaims) + + if issuedClaims["ext_user"] != "u-42" { + t.Errorf("expected ext_user=u-42 in issued token, got %v", issuedClaims["ext_user"]) + } + if _, exists := issuedClaims["user_id"]; exists { + t.Error("expected user_id to be removed by claim mapping") + } +} + // --- DefaultExpiry / issuer defaults --- func TestM2M_DefaultExpiry(t *testing.T) { 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..14086d8b 100644 --- a/plugins/auth/plugin.go +++ b/plugins/auth/plugin.go @@ -214,6 +214,42 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { requiredClaimVal := stringFromMap(introspectCfg, "requiredClaimVal") m.SetIntrospectPolicy(allowOthers, requiredScope, requiredClaim, requiredClaimVal) } + + // Register YAML-configured trusted keys for JWT-bearer grants. + if trustedKeys, ok := cfg["trustedKeys"].([]any); ok { + for _, tk := range trustedKeys { + tkMap, ok := tk.(map[string]any) + if !ok { + continue + } + issuer := stringFromMap(tkMap, "issuer") + publicKeyPEM := stringFromMap(tkMap, "publicKeyPEM") + if issuer == "" || publicKeyPEM == "" { + continue + } + var audiences []string + if auds, ok := tkMap["audiences"].([]any); ok { + for _, a := range auds { + if s, ok := a.(string); ok { + audiences = append(audiences, s) + } + } + } + var claimMapping map[string]string + if cm, ok := tkMap["claimMapping"].(map[string]any); ok { + claimMapping = make(map[string]string, len(cm)) + for k, v := range cm { + if s, ok := v.(string); ok { + claimMapping[k] = s + } + } + } + if err := m.AddTrustedKeyFromPEM(issuer, publicKeyPEM, audiences, claimMapping); err != nil { + m.SetInitErr(err) + break + } + } + } return m }, } @@ -380,6 +416,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: "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 441cf771..00ac9ba6 100644 --- a/plugins/auth/plugin_test.go +++ b/plugins/auth/plugin_test.go @@ -1,14 +1,21 @@ package auth import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" "github.com/GoCodeAlone/workflow/module" "github.com/GoCodeAlone/workflow/plugin" + "github.com/golang-jwt/jwt/v5" ) func TestPluginImplementsEnginePlugin(t *testing.T) { @@ -168,3 +175,68 @@ func TestModuleFactoryM2MWithClaims(t *testing.T) { t.Fatalf("expected 200, got %d; body: %s", w.Code, w.Body.String()) } } + +func TestModuleFactoryM2MWithTrustedKeys(t *testing.T) { + // Generate a key pair to represent an external trusted issuer. + clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + pkixBytes, err := x509.MarshalPKIXPublicKey(&clientKey.PublicKey) + if err != nil { + t.Fatalf("MarshalPKIXPublicKey: %v", err) + } + pubKeyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pkixBytes})) + + p := New() + factories := p.ModuleFactories() + + mod := factories["auth.m2m"]("m2m-test", map[string]any{ + "algorithm": "ES256", + "trustedKeys": []any{ + map[string]any{ + "issuer": "https://external-issuer.example.com", + "publicKeyPEM": pubKeyPEM, + "audiences": []any{"test-audience"}, + "claimMapping": map[string]any{ + "user_id": "ext_user", + }, + }, + }, + }) + if mod == nil { + t.Fatal("auth.m2m factory returned nil") + } + + m2mMod, ok := mod.(*module.M2MAuthModule) + if !ok { + t.Fatal("expected *module.M2MAuthModule") + } + + // Issue a JWT assertion signed by the external issuer's key. + claims := jwt.MapClaims{ + "iss": "https://external-issuer.example.com", + "sub": "external-service", + "aud": "test-audience", + "iat": time.Now().Unix(), + "exp": time.Now().Add(5 * time.Minute).Unix(), + } + tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + assertion, err := tok.SignedString(clientKey) + if err != nil { + t.Fatalf("sign assertion: %v", err) + } + + params := url.Values{ + "grant_type": {module.GrantTypeJWTBearer}, + "assertion": {assertion}, + } + req := httptest.NewRequest("POST", "/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 for JWT-bearer with trusted key, got %d: %s", w.Code, w.Body.String()) + } +} From 1cfb1c7dcb4bbcdc224bc2118638140d99b4aeb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:31:49 +0000 Subject: [PATCH 3/3] fix(auth.m2m): address review feedback - P-256 enforcement, error handling, test fixes, example go.mod Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- example/go.mod | 14 +++++----- example/go.sum | 28 +++++++++---------- module/auth_m2m.go | 3 +++ module/auth_m2m_test.go | 28 +++++++++++++++++-- plugins/auth/plugin.go | 15 ++++++++--- plugins/auth/plugin_test.go | 54 +++++++++++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 27 deletions(-) diff --git a/example/go.mod b/example/go.mod index b00541e5..ae6989fb 100644 --- a/example/go.mod +++ b/example/go.mod @@ -5,7 +5,7 @@ go 1.26.0 replace github.com/GoCodeAlone/workflow => ../ require ( - github.com/GoCodeAlone/modular v1.12.0 + github.com/GoCodeAlone/modular v1.12.3 github.com/GoCodeAlone/workflow v0.0.0-00010101000000-000000000000 ) @@ -20,12 +20,12 @@ require ( cloud.google.com/go/storage v1.60.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/DataDog/datadog-go/v5 v5.4.0 // indirect - github.com/GoCodeAlone/modular/modules/auth v1.12.0 // indirect - github.com/GoCodeAlone/modular/modules/cache v1.12.0 // indirect - github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.0 // indirect - github.com/GoCodeAlone/modular/modules/jsonschema v1.12.0 // indirect - github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.5.0 // indirect - github.com/GoCodeAlone/modular/modules/scheduler v1.12.0 // indirect + github.com/GoCodeAlone/modular/modules/auth v1.14.0 // indirect + github.com/GoCodeAlone/modular/modules/cache v1.14.0 // indirect + github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.7.0 // indirect + github.com/GoCodeAlone/modular/modules/jsonschema v1.14.0 // indirect + github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.7.0 // indirect + github.com/GoCodeAlone/modular/modules/scheduler v1.14.0 // indirect github.com/GoCodeAlone/yaegi v0.17.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect diff --git a/example/go.sum b/example/go.sum index f1c81b9c..b872c630 100644 --- a/example/go.sum +++ b/example/go.sum @@ -30,20 +30,20 @@ github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/datadog-go/v5 v5.4.0 h1:Ea3eXUVwrVV28F/fo3Dr3aa+TL/Z7Xi6SUPKW8L99aI= github.com/DataDog/datadog-go/v5 v5.4.0/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= -github.com/GoCodeAlone/modular v1.12.0 h1:C4tLfJe65rrUQsbtndiVfldtT8IRKZcHczNRNbBK4wo= -github.com/GoCodeAlone/modular v1.12.0/go.mod h1:ET7mlekRjkRq9mwJdWmaC2KDUWvjla2IqKVFrYO2JnY= -github.com/GoCodeAlone/modular/modules/auth v1.12.0 h1:eO4iq8tkz8W5sLKRSG5dC+ACITMtxZrtSJ+ReE3fKdA= -github.com/GoCodeAlone/modular/modules/auth v1.12.0/go.mod h1:D+yfkgN3MTkyl1xe8h2UL7uqB9Vj1lO3wUrscfnJ/NU= -github.com/GoCodeAlone/modular/modules/cache v1.12.0 h1:Ue6aXytFq1I+OnC3PcV2KlUg4lHiuGWH0Qq+v/lqyp0= -github.com/GoCodeAlone/modular/modules/cache v1.12.0/go.mod h1:kSaT8wNy/3YGmtIpDqPbW6MRqKOp2yc8a5MHdAag2CE= -github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.0 h1:K6X+X1sOq+lpI1Oa+XUzH+GlSRYJQfDTTcvMjZfkbFU= -github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.5.0/go.mod h1:Q0TpCFTtd0q20okDyi63ALS+1xmkYU4wNUOqwczyih0= -github.com/GoCodeAlone/modular/modules/jsonschema v1.12.0 h1:urGK8Xtwku4tn8nBeVZn9UqvldnCptZ3rLCXO21vSz4= -github.com/GoCodeAlone/modular/modules/jsonschema v1.12.0/go.mod h1:+/0p1alfSbhhshcNRId1HRRIupeu0DPC7BH8AYiBQ1I= -github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.5.0 h1:zcF46oZ7MJFfZCmzqc1n9ZTw6wrTJSFr04yaz6EYKeo= -github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.5.0/go.mod h1:ycmJYst0dgaeLYBDOFGYz3ZiVK0fVcbl59omBySpKis= -github.com/GoCodeAlone/modular/modules/scheduler v1.12.0 h1:kxeLUpFFZ2HWV5B7Ra1WaOr1DDee5G6kAZ6F1BUXX/Y= -github.com/GoCodeAlone/modular/modules/scheduler v1.12.0/go.mod h1:VpDSAU0Guj8geVz19YCSknyCJp0j3TMBaxLEYXedkZc= +github.com/GoCodeAlone/modular v1.12.3 h1:WcNqc1ZG+Lv/xzF8wTDavGIOeAvlV4wEd5HO2mVTUwE= +github.com/GoCodeAlone/modular v1.12.3/go.mod h1:nDdyW/eJu4gDFNueb6vWwLvti3bPHSZJHkWGiwEmi2I= +github.com/GoCodeAlone/modular/modules/auth v1.14.0 h1:Y+p4/HIcxkajlcNhcPlqpwAt1SCHjB4AaDMEys50E3I= +github.com/GoCodeAlone/modular/modules/auth v1.14.0/go.mod h1:fkwPn2svDsCHBI19gtUHxo064SL+EudjB+o7VjL9ug8= +github.com/GoCodeAlone/modular/modules/cache v1.14.0 h1:ykQRwXJGXaRtAsnW9Tgs0LvXExonkKr8P7XIHxPaYdY= +github.com/GoCodeAlone/modular/modules/cache v1.14.0/go.mod h1:tcIjHJHZ5fVU8sstILrXeVQgjpZcUkErnNjRaxkBSR8= +github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.7.0 h1:clGAyaOfyDc9iY63ONfZiHReVccVhK/yH19QEb14SSI= +github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.7.0/go.mod h1:0AnfWGVmrqyv91rduc6mrPqW6WQchDAa2WtM0Qmw/WA= +github.com/GoCodeAlone/modular/modules/jsonschema v1.14.0 h1:dCiPIO+NvJPizfCeUQqGXHD1WitOVYpKuL3fxMEjRlw= +github.com/GoCodeAlone/modular/modules/jsonschema v1.14.0/go.mod h1:5Hm+R9G41wwb0hKefx9+9PMqffjU1tA7roW3t3sTaLE= +github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.7.0 h1:TtVD+tE8ABN98n50MFVyMAvMsBM4JE86KRgCRDzPDC4= +github.com/GoCodeAlone/modular/modules/reverseproxy/v2 v2.7.0/go.mod h1:N7d8aSV4eqr90qjlIOs/8EmW7avt9gwX06Uh+zKDr4s= +github.com/GoCodeAlone/modular/modules/scheduler v1.14.0 h1:JSrzo4FB7uGASExv+fCLRd6pXWULV1mJYvzmM9PzUeM= +github.com/GoCodeAlone/modular/modules/scheduler v1.14.0/go.mod h1:emkR2AnilabLJZv1rOTDO9eGpRBmZs487H00Lnp9jIc= github.com/GoCodeAlone/yaegi v0.17.1 h1:aPAwU29L9cGceRAff02c5pjQcT5KapDB4fWFZK9tElE= github.com/GoCodeAlone/yaegi v0.17.1/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= diff --git a/module/auth_m2m.go b/module/auth_m2m.go index 66e60571..b9aee52d 100644 --- a/module/auth_m2m.go +++ b/module/auth_m2m.go @@ -220,6 +220,9 @@ func (m *M2MAuthModule) AddTrustedKeyFromPEM(issuer, publicKeyPEM string, audien if !ok { return fmt.Errorf("auth.m2m: public key for issuer %q is not an ECDSA key", issuer) } + if ecKey.Curve != elliptic.P256() { + return fmt.Errorf("auth.m2m: public key for issuer %q must use P-256 (ES256) curve", issuer) + } m.mu.Lock() defer m.mu.Unlock() diff --git a/module/auth_m2m_test.go b/module/auth_m2m_test.go index cedb8f98..9e8d11d3 100644 --- a/module/auth_m2m_test.go +++ b/module/auth_m2m_test.go @@ -1519,6 +1519,24 @@ func TestM2M_AddTrustedKeyFromPEM_Invalid(t *testing.T) { } } +func TestM2M_AddTrustedKeyFromPEM_NonP256Rejected(t *testing.T) { + m := newM2MES256(t) + // Generate a P-384 key, which should be rejected. + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatalf("generate P-384 key: %v", err) + } + pkixBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatalf("MarshalPKIXPublicKey: %v", err) + } + pemStr := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pkixBytes})) + + if err := m.AddTrustedKeyFromPEM("issuer-p384", pemStr, nil, nil); err == nil { + t.Error("expected error for P-384 key, got nil") + } +} + func TestM2M_JWTBearer_AudienceValid(t *testing.T) { server := newM2MES256(t) clientKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -1569,7 +1587,10 @@ func TestM2M_JWTBearer_AudienceMismatch(t *testing.T) { "exp": time.Now().Add(5 * time.Minute).Unix(), } tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) - assertion, _ := tok.SignedString(clientKey) + assertion, err := tok.SignedString(clientKey) + if err != nil { + t.Fatalf("sign assertion: %v", err) + } params := url.Values{ "grant_type": {GrantTypeJWTBearer}, @@ -1601,7 +1622,10 @@ func TestM2M_JWTBearer_ClaimMapping(t *testing.T) { "user_id": "u-42", } tok := jwt.NewWithClaims(jwt.SigningMethodES256, claims) - assertion, _ := tok.SignedString(clientKey) + assertion, err := tok.SignedString(clientKey) + if err != nil { + t.Fatalf("sign assertion: %v", err) + } params := url.Values{ "grant_type": {GrantTypeJWTBearer}, diff --git a/plugins/auth/plugin.go b/plugins/auth/plugin.go index 14086d8b..fe2b1387 100644 --- a/plugins/auth/plugin.go +++ b/plugins/auth/plugin.go @@ -1,6 +1,7 @@ package auth import ( + "fmt" "log" "time" @@ -217,15 +218,21 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { // Register YAML-configured trusted keys for JWT-bearer grants. if trustedKeys, ok := cfg["trustedKeys"].([]any); ok { - for _, tk := range trustedKeys { + for i, tk := range trustedKeys { tkMap, ok := tk.(map[string]any) if !ok { - continue + m.SetInitErr(fmt.Errorf("auth.m2m: trustedKeys[%d] must be an object", i)) + break } issuer := stringFromMap(tkMap, "issuer") publicKeyPEM := stringFromMap(tkMap, "publicKeyPEM") - if issuer == "" || publicKeyPEM == "" { - continue + if issuer == "" { + m.SetInitErr(fmt.Errorf("auth.m2m: trustedKeys[%d] missing required field \"issuer\"", i)) + break + } + if publicKeyPEM == "" { + m.SetInitErr(fmt.Errorf("auth.m2m: trustedKeys[%d] (issuer %q) missing required field \"publicKeyPEM\"", i, issuer)) + break } var audiences []string if auds, ok := tkMap["audiences"].([]any); ok { diff --git a/plugins/auth/plugin_test.go b/plugins/auth/plugin_test.go index 00ac9ba6..bca73c84 100644 --- a/plugins/auth/plugin_test.go +++ b/plugins/auth/plugin_test.go @@ -240,3 +240,57 @@ func TestModuleFactoryM2MWithTrustedKeys(t *testing.T) { t.Fatalf("expected 200 for JWT-bearer with trusted key, got %d: %s", w.Code, w.Body.String()) } } + +func TestModuleFactoryM2MWithTrustedKeys_MissingIssuer(t *testing.T) { + p := New() + factories := p.ModuleFactories() + + mod := factories["auth.m2m"]("m2m-test", map[string]any{ + "algorithm": "ES256", + "trustedKeys": []any{ + map[string]any{ + // issuer is missing + "publicKeyPEM": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtest==\n-----END PUBLIC KEY-----", + }, + }, + }) + if mod == nil { + t.Fatal("auth.m2m factory returned nil") + } + m2mMod, ok := mod.(*module.M2MAuthModule) + if !ok { + t.Fatal("expected *module.M2MAuthModule") + } + + // Init should fail because trustedKeys[0] is missing issuer. + if err := m2mMod.Init(nil); err == nil { + t.Error("expected Init to return error for trustedKeys entry missing issuer") + } +} + +func TestModuleFactoryM2MWithTrustedKeys_MissingPEM(t *testing.T) { + p := New() + factories := p.ModuleFactories() + + mod := factories["auth.m2m"]("m2m-test", map[string]any{ + "algorithm": "ES256", + "trustedKeys": []any{ + map[string]any{ + "issuer": "https://external.example.com", + // publicKeyPEM is missing + }, + }, + }) + if mod == nil { + t.Fatal("auth.m2m factory returned nil") + } + m2mMod, ok := mod.(*module.M2MAuthModule) + if !ok { + t.Fatal("expected *module.M2MAuthModule") + } + + // Init should fail because trustedKeys[0] is missing publicKeyPEM. + if err := m2mMod.Init(nil); err == nil { + t.Error("expected Init to return error for trustedKeys entry missing publicKeyPEM") + } +}