diff --git a/module/auth_m2m.go b/module/auth_m2m.go index 394c7d6d..b9aee52d 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,46 @@ 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) + } + 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() + m.trustedKeys[issuer] = &trustedKeyEntry{ + pubKey: ecKey, + audiences: audiences, + claimMapping: claimMapping, + } + return nil } // RegisterClient registers a new OAuth2 client. @@ -676,19 +741,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 +767,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 +1110,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..9e8d11d3 100644 --- a/module/auth_m2m_test.go +++ b/module/auth_m2m_test.go @@ -1458,11 +1458,210 @@ 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_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) + 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, 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.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, 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.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..fe2b1387 100644 --- a/plugins/auth/plugin.go +++ b/plugins/auth/plugin.go @@ -1,6 +1,7 @@ package auth import ( + "fmt" "log" "time" @@ -214,6 +215,48 @@ 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 i, tk := range trustedKeys { + tkMap, ok := tk.(map[string]any) + if !ok { + m.SetInitErr(fmt.Errorf("auth.m2m: trustedKeys[%d] must be an object", i)) + break + } + issuer := stringFromMap(tkMap, "issuer") + publicKeyPEM := stringFromMap(tkMap, "publicKeyPEM") + 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 { + 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 +423,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..bca73c84 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,122 @@ 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()) + } +} + +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") + } +}