diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 038ea5cd..a86cd077 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -63,6 +63,18 @@ All modules are instantiated from YAML config via the plugin factory registry. O > `auth.modular` was removed in favor of `auth.jwt`. +`auth.jwt` accepts an optional `audience` string. A configured value adds the +`aud` claim to access and refresh tokens and requires both that audience and +the configured `issuer` when authenticating, reading profiles, or refreshing +tokens. Configure distinct issuer/audience pairs for environments that must +not accept each other's tokens. Legacy tokens without those claims are rejected +when this option is enabled; users must sign in again. Omitting `audience` +preserves legacy validation. An explicitly configured empty or non-string +audience fails initialization and validation instead of falling back to legacy +behavior. Every environment in a mutual trust boundary must +enforce the policy before relying on it, especially when signing keys are shared. + + ### API & CQRS | Type | Description | Plugin | |------|-------------|--------| diff --git a/module/jwt_auth.go b/module/jwt_auth.go index 57b5a3b1..a9a3cef3 100644 --- a/module/jwt_auth.go +++ b/module/jwt_auth.go @@ -35,6 +35,8 @@ type JWTAuthModule struct { secret string tokenExpiry time.Duration issuer string + audience string + validateAudience bool seedFile string responseFormat string // "standard" (default) or "v1" (access_token/refresh_token) users map[string]*User // keyed by email (used when no external userStore) @@ -66,6 +68,14 @@ func NewJWTAuthModule(name, secret string, tokenExpiry time.Duration, issuer str } } +// SetAudience opts into issuer and audience validation for every JWT entry point. +// Configure this before Init. Explicitly configured audiences must be nonempty. +// Omit this call to preserve legacy validation. +func (j *JWTAuthModule) SetAudience(audience string) { + j.audience = audience + j.validateAudience = true +} + // SetSeedFile sets the path to a JSON file of seed users to load on start. func (j *JWTAuthModule) SetSeedFile(path string) { j.seedFile = path @@ -105,6 +115,9 @@ func (j *JWTAuthModule) Init(app modular.Application) error { if len(j.secret) < 32 { return fmt.Errorf("JWT secret must be at least 32 bytes for security") } + if err := j.validateAudienceConfig(); err != nil { + return err + } j.app = app j.logger = app.Logger() @@ -127,9 +140,24 @@ func (j *JWTAuthModule) Init(app modular.Application) error { return nil } -// Authenticate implements AuthProvider -func (j *JWTAuthModule) Authenticate(tokenStr string) (bool, map[string]any, error) { - token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) { +func (j *JWTAuthModule) validateAudienceConfig() error { + if j.validateAudience && strings.TrimSpace(j.audience) == "" { + return fmt.Errorf("JWT audience must be a nonempty string when configured") + } + return nil +} + +// parseToken applies the same algorithm and optional environment boundary to +// authentication, profile requests, and refresh requests. +func (j *JWTAuthModule) parseToken(tokenStr string) (*jwt.Token, error) { + if err := j.validateAudienceConfig(); err != nil { + return nil, err + } + var options []jwt.ParserOption + if j.validateAudience { + options = append(options, jwt.WithIssuer(j.issuer), jwt.WithAudience(j.audience)) + } + return jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } @@ -137,7 +165,12 @@ func (j *JWTAuthModule) Authenticate(tokenStr string) (bool, map[string]any, err return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(j.secret), nil - }) + }, options...) +} + +// Authenticate implements AuthProvider +func (j *JWTAuthModule) Authenticate(tokenStr string) (bool, map[string]any, error) { + token, err := j.parseToken(tokenStr) if err != nil { return false, nil, nil //nolint:nilerr // Invalid token is a failed auth, not an error } @@ -426,15 +459,7 @@ func (j *JWTAuthModule) extractUserFromRequest(r *http.Request) (*User, error) { return nil, fmt.Errorf("bearer token required") } - token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method") - } - if token.Method.Alg() != jwt.SigningMethodHS256.Alg() { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(j.secret), nil - }) + token, err := j.parseToken(tokenStr) if err != nil { return nil, fmt.Errorf("invalid token") } @@ -458,6 +483,9 @@ func (j *JWTAuthModule) extractUserFromRequest(r *http.Request) (*User, error) { } func (j *JWTAuthModule) generateToken(user *User) (string, error) { + if err := j.validateAudienceConfig(); err != nil { + return "", err + } claims := jwt.MapClaims{ "sub": user.ID, "email": user.Email, @@ -478,6 +506,10 @@ func (j *JWTAuthModule) generateToken(user *User) (string, error) { claims["programIds"] = programIds } + if j.audience != "" { + claims["aud"] = j.audience + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(j.secret)) } @@ -497,6 +529,9 @@ func (j *JWTAuthModule) buildUserResponse(user *User) map[string]any { // generateRefreshToken creates a refresh JWT with longer expiry (7 days) and a "refresh" type claim. func (j *JWTAuthModule) generateRefreshToken(user *User) (string, error) { + if err := j.validateAudienceConfig(); err != nil { + return "", err + } claims := jwt.MapClaims{ "sub": user.ID, "email": user.Email, @@ -505,6 +540,10 @@ func (j *JWTAuthModule) generateRefreshToken(user *User) (string, error) { "iat": time.Now().Unix(), "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), } + if j.audience != "" { + claims["aud"] = j.audience + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(j.secret)) } @@ -526,15 +565,7 @@ func (j *JWTAuthModule) handleRefresh(w http.ResponseWriter, r *http.Request) { return } - token, err := jwt.Parse(req.RefreshToken, func(token *jwt.Token) (any, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method") - } - if token.Method.Alg() != jwt.SigningMethodHS256.Alg() { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(j.secret), nil - }) + token, err := j.parseToken(req.RefreshToken) if err != nil { w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid refresh token"}) diff --git a/module/jwt_auth_boundary_test.go b/module/jwt_auth_boundary_test.go new file mode 100644 index 00000000..c5310bb8 --- /dev/null +++ b/module/jwt_auth_boundary_test.go @@ -0,0 +1,177 @@ +package module + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func boundaryToken(t *testing.T, issuer string, audience any) string { + t.Helper() + claims := jwt.MapClaims{ + "sub": "boundary-user", "email": "boundary@example.invalid", "type": "refresh", + "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + } + if issuer != "" { + claims["iss"] = issuer + } + if audience != nil { + claims["aud"] = audience + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(strings.Repeat("boundary-fixture-", 3))) + if err != nil { + t.Fatal("signing synthetic token failed") + } + return token +} + +func boundaryModule(issuer, audience string) *JWTAuthModule { + m := NewJWTAuthModule("auth-jwt", strings.Repeat("boundary-fixture-", 3), time.Hour, issuer) + if audience != "" { + m.SetAudience(audience) + } + m.users["boundary@example.invalid"] = &User{ID: "boundary-user", Email: "boundary@example.invalid"} + return m +} + +func TestJWTAuthEnvironmentBoundary(t *testing.T) { + for _, environment := range []string{"staging", "production"} { + t.Run(environment, func(t *testing.T) { + issuer := "buymywishlist:" + environment + audience := "bmw:" + environment + other := "production" + if environment == "production" { + other = "staging" + } + m := boundaryModule(issuer, audience) + for _, tc := range []struct { + name, issuer string + audience any + valid bool + }{ + {"matching", issuer, audience, true}, + {"audience-array-matching", issuer, []string{audience}, true}, + {"other-environment", "buymywishlist:" + other, "bmw:" + other, false}, + {"wrong-issuer", "buymywishlist:" + other, audience, false}, + {"wrong-audience", issuer, "bmw:" + other, false}, + {"missing-issuer", "", audience, false}, + {"missing-audience", issuer, nil, false}, + {"empty-audience-array", issuer, []string{}, false}, + {"malformed-audience", issuer, 123, false}, + {"legacy", "buymywishlist", nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + token := boundaryToken(t, tc.issuer, tc.audience) + valid, claims, err := m.Authenticate(token) + if err != nil || valid != tc.valid || (!tc.valid && claims != nil) { + t.Fatal("unexpected authentication boundary result") + } + + // Profile parsing and refresh must use the same boundary as Authenticate. + req := httptest.NewRequest(http.MethodGet, "/auth/profile", nil) + req.Header.Set("Authorization", "Bearer "+token) + user, profileErr := m.extractUserFromRequest(req) + if (profileErr == nil && user != nil) != tc.valid { + t.Fatal("profile entry point bypassed the boundary") + } + body, err := json.Marshal(map[string]string{"refresh_token": token}) + if err != nil { + t.Fatal("encoding synthetic request failed") + } + refresh := httptest.NewRecorder() + m.handleRefresh(refresh, httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewReader(body))) + wantStatus := http.StatusUnauthorized + if tc.valid { + wantStatus = http.StatusOK + } + if refresh.Code != wantStatus { + t.Fatalf("refresh status = %d; want %d", refresh.Code, wantStatus) + } + }) + } + }) + } +} + +func TestJWTAuthEnvironmentBoundaryGeneratedTokens(t *testing.T) { + m := boundaryModule("buymywishlist:staging", "bmw:staging") + user := m.users["boundary@example.invalid"] + for _, mint := range []func(*User) (string, error){m.generateToken, m.generateRefreshToken} { + token, err := mint(user) + if err != nil { + t.Fatal("minting synthetic token failed") + } + valid, claims, err := m.Authenticate(token) + if err != nil || !valid || claims["iss"] != "buymywishlist:staging" || claims["aud"] != "bmw:staging" { + t.Fatal("generated token lacks enforced environment claims") + } + } +} + +func TestJWTAuthEnvironmentBoundaryLegacyCompatibility(t *testing.T) { + m := boundaryModule("legacy-configured-issuer", "") + valid, _, err := m.Authenticate(boundaryToken(t, "different-legacy-issuer", nil)) + if err != nil || !valid { + t.Fatal("unconfigured audience changed legacy validation") + } +} + +func TestJWTAuthEnvironmentBoundaryEmptyAudienceFailsClosed(t *testing.T) { + for _, audience := range []string{"", " "} { + m := boundaryModule("buymywishlist:staging", "") + m.SetAudience(audience) + if err := m.Init(NewMockApplication()); err == nil { + t.Fatal("empty configured audience did not fail initialization") + } + valid, _, err := m.Authenticate(boundaryToken(t, "buymywishlist:staging", nil)) + if err != nil || valid { + t.Fatal("empty configured audience accepted a legacy token") + } + for _, mint := range []func(*User) (string, error){m.generateToken, m.generateRefreshToken} { + if token, err := mint(m.users["boundary@example.invalid"]); err == nil || token != "" { + t.Fatal("empty configured audience minted a token") + } + } + } +} + +func TestAuthValidateStepJWTEnvironmentBoundary(t *testing.T) { + m := boundaryModule("buymywishlist:staging", "bmw:staging") + app := newTestAuthApp("auth-jwt", m) + step, err := NewAuthValidateStepFactory()("authorize", map[string]any{ + "auth_module": "auth-jwt", "token_source": "steps.request.headers.Authorization", + }, app) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, issuer, audience string + stop bool + }{ + {"same-environment", "buymywishlist:staging", "bmw:staging", false}, + {"other-environment", "buymywishlist:production", "bmw:production", true}, + } { + t.Run(tc.name, func(t *testing.T) { + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("request", map[string]any{"headers": map[string]any{ + "Authorization": "Bearer " + boundaryToken(t, tc.issuer, tc.audience), + }}) + response := httptest.NewRecorder() + pc.Metadata["_http_response_writer"] = response + result, err := step.Execute(context.Background(), pc) + if err != nil || result.Stop != tc.stop { + t.Fatal("unexpected pipeline authentication result") + } + if tc.stop && (response.Code != http.StatusUnauthorized || result.Output["status"] != http.StatusUnauthorized || pc.Metadata["_response_handled"] != true) { + t.Fatal("cross-environment request did not stop with HTTP 401") + } + }) + } +} diff --git a/plugins/auth/jwt_boundary_test.go b/plugins/auth/jwt_boundary_test.go new file mode 100644 index 00000000..cad53075 --- /dev/null +++ b/plugins/auth/jwt_boundary_test.go @@ -0,0 +1,52 @@ +package auth + +import ( + "strings" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/module" + "github.com/golang-jwt/jwt/v5" +) + +func TestJWTFactoryEnvironmentBoundary(t *testing.T) { + secret := strings.Repeat("boundary-fixture-", 3) + m, ok := New().ModuleFactories()["auth.jwt"]("auth-jwt", map[string]any{ + "secret": secret, "issuer": "buymywishlist:staging", "audience": "bmw:staging", + }).(*module.JWTAuthModule) + if !ok { + t.Fatal("factory returned the wrong module") + } + for _, audience := range []string{"bmw:staging", "bmw:production"} { + claims := jwt.MapClaims{"iss": "buymywishlist:staging", "aud": audience, "exp": time.Now().Add(time.Hour).Unix()} + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + if err != nil { + t.Fatal("signing synthetic token failed") + } + valid, _, err := m.Authenticate(token) + if err != nil || valid != (audience == "bmw:staging") { + t.Fatal("factory did not enable the configured boundary") + } + } +} + +func TestJWTFactoryInvalidAudienceFailsClosed(t *testing.T) { + secret := strings.Repeat("boundary-fixture-", 3) + for _, audience := range []any{"", " ", 123, nil, []string{"bmw:staging"}} { + m, ok := New().ModuleFactories()["auth.jwt"]("auth-jwt", map[string]any{ + "secret": secret, "issuer": "buymywishlist:staging", "audience": audience, + }).(*module.JWTAuthModule) + if !ok { + t.Fatal("factory returned the wrong module") + } + claims := jwt.MapClaims{"iss": "buymywishlist:staging", "exp": time.Now().Add(time.Hour).Unix()} + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + if err != nil { + t.Fatal("signing synthetic token failed") + } + valid, _, err := m.Authenticate(token) + if err != nil || valid { + t.Fatal("invalid configured audience selected legacy validation") + } + } +} diff --git a/plugins/auth/plugin.go b/plugins/auth/plugin.go index c84c9a90..2dd7a8f7 100644 --- a/plugins/auth/plugin.go +++ b/plugins/auth/plugin.go @@ -97,6 +97,10 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { issuer = iss } authMod := module.NewJWTAuthModule(name, secret, tokenExpiry, issuer) + if value, configured := cfg["audience"]; configured { + audience, _ := value.(string) + authMod.SetAudience(audience) + } if sf, ok := cfg["seedFile"].(string); ok && sf != "" { sf = config.ResolvePathInConfig(cfg, sf) authMod.SetSeedFile(sf) @@ -392,6 +396,7 @@ func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { {Key: "secret", Label: "JWT Secret", Type: schema.FieldTypeString, Required: true, Description: "Secret key for signing JWT tokens (supports $ENV_VAR expansion)", Placeholder: "$JWT_SECRET", Sensitive: true}, {Key: "tokenExpiry", Label: "Token Expiry", Type: schema.FieldTypeDuration, DefaultValue: "24h", Description: "Token expiration duration (e.g. 1h, 24h, 7d)", Placeholder: "24h"}, {Key: "issuer", Label: "Issuer", Type: schema.FieldTypeString, DefaultValue: "workflow", Description: "Token issuer claim", Placeholder: "workflow"}, + {Key: "audience", Label: "Audience", Type: schema.FieldTypeString, Description: "Token audience. When configured, must be a nonempty string; every JWT entry point requires this audience and the configured issuer."}, {Key: "seedFile", Label: "Seed Users File", Type: schema.FieldTypeString, Description: "Path to JSON file with initial user accounts", Placeholder: "data/users.json"}, {Key: "responseFormat", Label: "Response Format", Type: schema.FieldTypeSelect, Options: []string{"standard", "oauth2"}, Description: "Format of authentication response payloads"}, {Key: "allowRegistration", Label: "Allow Open Registration", Type: schema.FieldTypeBool, DefaultValue: false, Description: "When true, any visitor may register without admin intervention"}, diff --git a/schema/module_schema.go b/schema/module_schema.go index 7ae3ac7e..72452019 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -707,6 +707,7 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { {Key: "secret", Label: "JWT Secret", Type: FieldTypeString, Required: true, Description: "Secret key for signing JWT tokens (supports $ENV_VAR expansion)", Placeholder: "$JWT_SECRET", Sensitive: true}, {Key: "tokenExpiry", Label: "Token Expiry", Type: FieldTypeDuration, DefaultValue: "24h", Description: "Token expiration duration (e.g. 1h, 24h, 7d)", Placeholder: "24h"}, {Key: "issuer", Label: "Issuer", Type: FieldTypeString, DefaultValue: "workflow", Description: "Token issuer claim", Placeholder: "workflow"}, + {Key: "audience", Label: "Audience", Type: FieldTypeString, Description: "Token audience. When configured, must be a nonempty string; every JWT entry point requires this audience and the configured issuer."}, {Key: "seedFile", Label: "Seed Users File", Type: FieldTypeString, Description: "Path to JSON file with initial user accounts", Placeholder: "data/users.json"}, {Key: "responseFormat", Label: "Response Format", Type: FieldTypeSelect, Options: []string{"standard", "oauth2"}, Description: "Format of authentication response payloads"}, {Key: "allowRegistration", Label: "Allow Open Registration", Type: FieldTypeBool, DefaultValue: false, Description: "When true, any visitor may register without admin intervention"}, diff --git a/schema/module_schema_test.go b/schema/module_schema_test.go index 2d558803..479a1982 100644 --- a/schema/module_schema_test.go +++ b/schema/module_schema_test.go @@ -65,7 +65,7 @@ func TestModuleSchemaRegistry_ConfigFieldsMatchEngine(t *testing.T) { {"api.handler", []string{"resourceName", "workflowType", "workflowEngine", "initialTransition", "seedFile", "sourceResourceName", "stateFilter", "fieldMapping", "transitionMap", "summaryFields"}}, {"database.workflow", []string{"driver", "dsn", "maxOpenConns", "maxIdleConns"}}, {"messaging.kafka", []string{"brokers", "groupId"}}, - {"auth.jwt", []string{"secret", "tokenExpiry", "issuer", "seedFile", "responseFormat", "allowRegistration"}}, + {"auth.jwt", []string{"secret", "tokenExpiry", "issuer", "audience", "seedFile", "responseFormat", "allowRegistration"}}, {"static.fileserver", []string{"root", "prefix", "spaFallback", "cacheMaxAge", "router"}}, {"processing.step", []string{"componentId", "successTransition", "compensateTransition", "maxRetries", "retryBackoffMs", "timeoutSeconds"}}, {"http.middleware.securityheaders", []string{"contentSecurityPolicy", "frameOptions", "contentTypeOptions", "hstsMaxAge", "referrerPolicy", "permissionsPolicy"}}, diff --git a/schema/testdata/editor-schemas.golden.json b/schema/testdata/editor-schemas.golden.json index 26406c0e..dfb9227a 100644 --- a/schema/testdata/editor-schemas.golden.json +++ b/schema/testdata/editor-schemas.golden.json @@ -489,6 +489,12 @@ "defaultValue": "workflow", "placeholder": "workflow" }, + { + "key": "audience", + "label": "Audience", + "type": "string", + "description": "Token audience. When configured, must be a nonempty string; every JWT entry point requires this audience and the configured issuer." + }, { "key": "seedFile", "label": "Seed Users File",