From 027573c89e5be5282f3a4cf7da7e67a668c854ff Mon Sep 17 00:00:00 2001 From: Jordan Evans Date: Tue, 16 Dec 2025 15:10:12 -0800 Subject: [PATCH 1/2] feat: add configurable JWT signature algorithm support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for JWT_SIGNATURE_ALGORITHM environment variable to allow operators to configure the JWT signature algorithm for token validation. Fixes: LFXV2-914 Changes: - Add parseSignatureAlgorithm() function to validate and map algorithm strings - Extend JWTAuthConfig struct with SignatureAlgorithm field - Update NewJWTAuth() to use configured algorithm with logging for non-defaults - Support 9 algorithms: PS256/384/512, RS256/384/512, ES256/384/512 - Add comprehensive test coverage (18 test cases) for algorithm validation - Update Helm chart (v0.5.3 -> v0.5.4) with jwtSignatureAlgorithm configuration - Add JWT_SIGNATURE_ALGORITHM to deployment template - Update documentation (CLAUDE.md, .env.example) Default behavior: - PS256 remains the default algorithm when env var is not set - Backward compatible with existing deployments - Case-sensitive algorithm names (uppercase required) - Fails fast at startup for invalid algorithm configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Signed-off-by: Jordan Evans --- .env.example | 3 + CLAUDE.md | 1 + charts/lfx-v2-project-service/Chart.yaml | 2 +- .../templates/deployment.yaml | 2 + charts/lfx-v2-project-service/values.yaml | 4 + cmd/project-api/main.go | 1 + internal/infrastructure/auth/jwt.go | 57 ++++++++++-- internal/infrastructure/auth/jwt_test.go | 86 ++++++++++++++++++- 8 files changed, 147 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 60facfc..26641a4 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ export JWKS_URL=http://lfx-platform-heimdall.lfx.svc.cluster.local:4457/.well-kn # JWT audience export AUDIENCE=lfx-v2-project-service +# JWT signature algorithm (PS256, PS384, PS512, RS256, RS384, RS512, ES256, ES384, ES512) +export JWT_SIGNATURE_ALGORITHM=PS256 + # Skip the ETag validation that requires the correct revision on PUT/DELETE requests. # When this is set to false, it means you need to make a GET request on the resource # to get the ETag response header and use it as the ETag request header on the PUT/DELETE diff --git a/CLAUDE.md b/CLAUDE.md index d4c1ee5..a584c74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -216,6 +216,7 @@ func TestEndpoint(t *testing.T) { | `JWKS_URL` | JWT verification endpoint | - | No | | `AUDIENCE` | JWT audience | lfx-v2-project-service | No | | `JWT_AUTH_DISABLED_MOCK_LOCAL_PRINCIPAL` | Mock auth for local dev | - | No | +| `JWT_SIGNATURE_ALGORITHM` | JWT signature algorithm | PS256 | No | ## Authorization (OpenFGA) diff --git a/charts/lfx-v2-project-service/Chart.yaml b/charts/lfx-v2-project-service/Chart.yaml index 0581c39..2526b40 100644 --- a/charts/lfx-v2-project-service/Chart.yaml +++ b/charts/lfx-v2-project-service/Chart.yaml @@ -5,5 +5,5 @@ apiVersion: v2 name: lfx-v2-project-service description: LFX Platform V2 Project Service chart type: application -version: 0.5.3 +version: 0.5.4 appVersion: "latest" diff --git a/charts/lfx-v2-project-service/templates/deployment.yaml b/charts/lfx-v2-project-service/templates/deployment.yaml index b8e52d9..1d6fbf6 100644 --- a/charts/lfx-v2-project-service/templates/deployment.yaml +++ b/charts/lfx-v2-project-service/templates/deployment.yaml @@ -37,6 +37,8 @@ spec: value: {{ .Values.app.skipEtagValidation | quote }} - name: JWT_AUTH_DISABLED_MOCK_LOCAL_PRINCIPAL value: {{ .Values.app.jwtAuthDisabledMockLocalPrincipal }} + - name: JWT_SIGNATURE_ALGORITHM + value: {{ .Values.app.jwtSignatureAlgorithm }} ports: - containerPort: {{ .Values.service.port }} name: web diff --git a/charts/lfx-v2-project-service/values.yaml b/charts/lfx-v2-project-service/values.yaml index f6ada19..63401c8 100644 --- a/charts/lfx-v2-project-service/values.yaml +++ b/charts/lfx-v2-project-service/values.yaml @@ -47,6 +47,10 @@ app: # jwtAuthDisabledMockLocalPrincipal mocks auth for local development to use a set principal # (only use for local development) jwtAuthDisabledMockLocalPrincipal: "" + # jwtSignatureAlgorithm is the JWT signature algorithm for token validation + # Supported: PS256 (default), PS384, PS512, RS256, RS384, RS512, ES256, ES384, ES512 + # Algorithm names are case-sensitive and must be uppercase + jwtSignatureAlgorithm: "PS256" # use_oidc_contextualizer is a boolean to determine if the OIDC contextualizer should be used use_oidc_contextualizer: true diff --git a/cmd/project-api/main.go b/cmd/project-api/main.go index 3ac4510..28428f9 100644 --- a/cmd/project-api/main.go +++ b/cmd/project-api/main.go @@ -51,6 +51,7 @@ func main() { JWKSURL: os.Getenv("JWKS_URL"), Audience: os.Getenv("AUDIENCE"), MockLocalPrincipal: os.Getenv("JWT_AUTH_DISABLED_MOCK_LOCAL_PRINCIPAL"), + SignatureAlgorithm: os.Getenv("JWT_SIGNATURE_ALGORITHM"), } jwtAuth, err := auth.NewJWTAuth(jwtAuthConfig) if err != nil { diff --git a/internal/infrastructure/auth/jwt.go b/internal/infrastructure/auth/jwt.go index ab362c1..df65e41 100644 --- a/internal/infrastructure/auth/jwt.go +++ b/internal/infrastructure/auth/jwt.go @@ -18,13 +18,40 @@ import ( ) const ( - // PS256 is the default for Heimdall's JWT finalizer. - signatureAlgorithm = validator.PS256 - defaultIssuer = "heimdall" - defaultAudience = "lfx-v2-project-service" - defaultJWKSURL = "http://heimdall:4457/.well-known/jwks" + // PS256 is the default signature algorithm used when JWT_SIGNATURE_ALGORITHM is not set. + defaultSignatureAlgorithm = validator.PS256 + defaultIssuer = "heimdall" + defaultAudience = "lfx-v2-project-service" + defaultJWKSURL = "http://heimdall:4457/.well-known/jwks" ) +// parseSignatureAlgorithm converts the algorithm string to a validator.SignatureAlgorithm. +// Returns PS256 as default if algoString is empty. +// Algorithm names are case-sensitive and must be uppercase (e.g., "PS256"). +func parseSignatureAlgorithm(algoString string) (validator.SignatureAlgorithm, error) { + if algoString == "" { + return validator.PS256, nil + } + + algorithms := map[string]validator.SignatureAlgorithm{ + "PS256": validator.PS256, + "PS384": validator.PS384, + "PS512": validator.PS512, + "RS256": validator.RS256, + "RS384": validator.RS384, + "RS512": validator.RS512, + "ES256": validator.ES256, + "ES384": validator.ES384, + "ES512": validator.ES512, + } + + if algo, exists := algorithms[algoString]; exists { + return algo, nil + } + + return "", errors.New("unsupported JWT signature algorithm: " + algoString + " (supported: PS256, PS384, PS512, RS256, RS384, RS512, ES256, ES384, ES512)") +} + // JWTAuthConfig holds the configuration parameters for JWT authentication. type JWTAuthConfig struct { // JWKSURL is the URL to the JSON Web Key Set endpoint @@ -33,6 +60,8 @@ type JWTAuthConfig struct { Audience string // MockLocalPrincipal is used for local development to bypass JWT validation MockLocalPrincipal string + // SignatureAlgorithm is the JWT signature algorithm (e.g., PS256, RS256, ES256) + SignatureAlgorithm string } var ( @@ -67,6 +96,20 @@ type JWTAuth struct { var _ domain.Authenticator = (*JWTAuth)(nil) func NewJWTAuth(config JWTAuthConfig) (*JWTAuth, error) { + // Parse signature algorithm + algo, err := parseSignatureAlgorithm(config.SignatureAlgorithm) + if err != nil { + slog.With(constants.ErrKey, err).Error("invalid JWT signature algorithm") + return nil, err + } + + // Log algorithm selection (especially if non-default) + if config.SignatureAlgorithm != "" && config.SignatureAlgorithm != "PS256" { + slog.Info("using non-default JWT signature algorithm", + "algorithm", config.SignatureAlgorithm, + ) + } + // Set up defaults if not provided jwksURLStr := config.JWKSURL if jwksURLStr == "" { @@ -92,10 +135,10 @@ func NewJWTAuth(config JWTAuthConfig) (*JWTAuth, error) { } provider := jwks.NewCachingProvider(issuer, 5*time.Minute, jwks.WithCustomJWKSURI(jwksURL)) - // Set up the JWT validator. + // Set up the JWT validator with selected algorithm. jwtValidator, err := validator.New( provider.KeyFunc, - signatureAlgorithm, + algo, issuer.String(), []string{audience}, validator.WithCustomClaims(customClaims), diff --git a/internal/infrastructure/auth/jwt_test.go b/internal/infrastructure/auth/jwt_test.go index 1a13dec..5c08d2c 100644 --- a/internal/infrastructure/auth/jwt_test.go +++ b/internal/infrastructure/auth/jwt_test.go @@ -216,7 +216,7 @@ func TestJWTAuth_Constants(t *testing.T) { assert.Equal(t, "heimdall", defaultIssuer) assert.Equal(t, "lfx-v2-project-service", defaultAudience) assert.Equal(t, "http://heimdall:4457/.well-known/jwks", defaultJWKSURL) - assert.NotNil(t, signatureAlgorithm) + assert.NotNil(t, defaultSignatureAlgorithm) }) } @@ -307,6 +307,38 @@ func TestJWTAuth_ConfigurationHandling(t *testing.T) { shouldError: false, description: "should accept mock principal", }, + { + name: "custom signature algorithm ES256", + config: JWTAuthConfig{ + SignatureAlgorithm: "ES256", + }, + shouldError: false, + description: "should accept valid signature algorithm", + }, + { + name: "custom signature algorithm RS256", + config: JWTAuthConfig{ + SignatureAlgorithm: "RS256", + }, + shouldError: false, + description: "should accept RS256 signature algorithm", + }, + { + name: "invalid signature algorithm", + config: JWTAuthConfig{ + SignatureAlgorithm: "INVALID", + }, + shouldError: true, + description: "should reject invalid signature algorithm", + }, + { + name: "lowercase signature algorithm rejected", + config: JWTAuthConfig{ + SignatureAlgorithm: "ps256", + }, + shouldError: true, + description: "should reject lowercase signature algorithm", + }, } for _, tt := range tests { @@ -326,3 +358,55 @@ func TestJWTAuth_ConfigurationHandling(t *testing.T) { }) } } + +func TestParseSignatureAlgorithm(t *testing.T) { + tests := []struct { + name string + algorithm string + wantErr bool + }{ + // Valid algorithms - PS family + {name: "PS256 valid", algorithm: "PS256", wantErr: false}, + {name: "PS384 valid", algorithm: "PS384", wantErr: false}, + {name: "PS512 valid", algorithm: "PS512", wantErr: false}, + + // Valid algorithms - RS family + {name: "RS256 valid", algorithm: "RS256", wantErr: false}, + {name: "RS384 valid", algorithm: "RS384", wantErr: false}, + {name: "RS512 valid", algorithm: "RS512", wantErr: false}, + + // Valid algorithms - ES family + {name: "ES256 valid", algorithm: "ES256", wantErr: false}, + {name: "ES384 valid", algorithm: "ES384", wantErr: false}, + {name: "ES512 valid", algorithm: "ES512", wantErr: false}, + + // Empty string uses default + {name: "empty defaults to PS256", algorithm: "", wantErr: false}, + + // Invalid - case sensitivity + {name: "lowercase rejected", algorithm: "ps256", wantErr: true}, + {name: "mixed case rejected", algorithm: "Ps256", wantErr: true}, + + // Invalid - HMAC algorithms not supported + {name: "HS256 unsupported", algorithm: "HS256", wantErr: true}, + {name: "HS384 unsupported", algorithm: "HS384", wantErr: true}, + {name: "HS512 unsupported", algorithm: "HS512", wantErr: true}, + + // Invalid - unknown algorithms + {name: "unknown algorithm", algorithm: "UNKNOWN", wantErr: true}, + {name: "typo", algorithm: "PS265", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + algo, err := parseSignatureAlgorithm(tt.algorithm) + if tt.wantErr { + assert.Error(t, err, "expected error for algorithm %q", tt.algorithm) + assert.Empty(t, algo, "expected empty algorithm for %q", tt.algorithm) + } else { + assert.NoError(t, err, "unexpected error for algorithm %q", tt.algorithm) + assert.NotEmpty(t, algo, "expected valid algorithm for %q", tt.algorithm) + } + }) + } +} From 88dcf5e55ecb4b88ae4fd4b86b7572e40649c6ec Mon Sep 17 00:00:00 2001 From: Jordan Evans Date: Thu, 18 Dec 2025 09:00:00 -0800 Subject: [PATCH 2/2] match version of goa to go.mod Signed-off-by: Jordan Evans --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b26c3c9..3eeaf39 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,7 @@ help: deps: @echo "==> Installing dependencies..." go mod download - go install goa.design/goa/$(GOA_VERSION)/cmd/goa@latest + go install goa.design/goa/$(GOA_VERSION)/cmd/goa@v3.22.6 @command -v golangci-lint >/dev/null 2>&1 || { \ echo "==> Installing golangci-lint..."; \ go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \