Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|------|-------------|--------|
Expand Down
75 changes: 53 additions & 22 deletions module/jwt_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -127,17 +140,37 @@ 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"])
}
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
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
}
Expand Down Expand Up @@ -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")
}
Expand All @@ -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,
Expand All @@ -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))
}
Expand All @@ -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,
Expand All @@ -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))
}
Expand All @@ -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"})
Expand Down
177 changes: 177 additions & 0 deletions module/jwt_auth_boundary_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
Loading
Loading