Skip to content

HYPERFLEET-1237 - feat: support multiple JWT issuer configurations#246

Draft
rh-amarin wants to merge 2 commits into
openshift-hyperfleet:mainfrom
rh-amarin:jwt-nonmail
Draft

HYPERFLEET-1237 - feat: support multiple JWT issuer configurations#246
rh-amarin wants to merge 2 commits into
openshift-hyperfleet:mainfrom
rh-amarin:jwt-nonmail

Conversation

@rh-amarin

@rh-amarin rh-amarin commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replace the single-issuer JWT scalar fields (issuer_url, audience, identity_claim, identity_claim_pattern, plus the global identity_header and jwk.*) with an ordered list server.jwt.configs. A request is accepted if its token validates against any configured issuer — enabling multi-IdP deployments (e.g. RH SSO + Google) without forking the binary.
  • JWTHandler now compiles each issuer at startup (keyfunc, parser, identity pattern) and tries them in sequence; the first match stores the matched issuer's identity config in the request context for CallerIdentityMiddleware to consume.
  • Per-issuer fields: issuer_url, audience, jwk_cert_url, jwk_cert_file, identity_claim, identity_claim_pattern, header.

Config shape change

Before:

server:
  jwt:
    enabled: true
    issuer_url: https://idp.example.com/realms/hyperfleet
    audience: hyperfleet-api
    identity_claim: email
    identity_claim_pattern: '^[^@]+@[^@]+$'
  jwk:
    cert_url: https://idp.example.com/realms/hyperfleet/protocol/openid-connect/certs
  identity_header: X-HyperFleet-Identity

After:

server:
  jwt:
    enabled: true
    configs:
      - issuer_url: https://idp.example.com/realms/hyperfleet
        jwk_cert_url: https://idp.example.com/realms/hyperfleet/protocol/openid-connect/certs
        audience: hyperfleet-api
        identity_claim: email
        identity_claim_pattern: '^[^@]+@[^@]+$'
        header: X-HyperFleet-Identity
      - issuer_url: https://accounts.google.com
        jwk_cert_url: https://www.googleapis.com/oauth2/v3/certs
        identity_claim: email

Test plan

  • make verify passes (vet + gofmt)
  • make test — all 1241 unit tests pass, including new TestJWTHandler_MultiIssuer (token accepted by issuer A, token accepted by issuer B, unknown issuer rejected, matched identity config stored in context)
  • make test-integration — auth integration tests against a real DB

🤖 Generated with Claude Code

…ation

Introduces an optional regex pattern (`identity_claim_pattern`) that the
resolved JWT identity value must match. This allows operators to restrict
which identities can perform mutations — for example, enforcing email-like
values or allowing only a fixed set of service account names.

- Add `IdentityClaimPattern` field to `JWTConfig` and `CallerIdentityConfig`
- Compile pattern once at startup in `NewCallerIdentityMiddleware`; invalid regex prevents server from starting
- Pass compiled `*regexp.Regexp` to `CallerIdentityFromRequest` to avoid per-request recompilation
- Wire `IdentityClaimPattern` from config through routes
- Bind `server.jwt.identity_claim_pattern` env var and `--server-jwt-identity-claim-pattern` flag
- Add unit and middleware-level tests covering match, reject, no-pattern, and invalid-pattern cases
- Document new setting in `docs/authentication.md` and `docs/config.md`
@openshift-ci openshift-ci Bot requested review from crizzo71 and rafabene June 23, 2026 16:38
@openshift-ci

openshift-ci Bot commented Jun 23, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign vkareh for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added per-issuer JWT caller-identity claim pattern validation. Requests whose resolved identity doesn’t match the configured pattern are rejected with 401 Unauthorized.
    • Added support for multiple JWT issuers with issuer-specific identity claim, optional pattern rules, and identity header configuration.
  • Documentation

    • Updated authentication and configuration guides to use the new server.jwt.configs[*] structure, including regex behavior and matching/rejection examples.
  • Configuration / Compatibility

    • Updated JWT configuration to remove previous single-issuer fields; corresponding JWT-related CLI flags are no longer available.

Walkthrough

JWT configuration is refactored from a single-issuer top-level structure to a multi-issuer array of JWTIssuerConfig entries. Each issuer holds its own URL, audience, JWKS sources, identity claim field, optional identity claim pattern regex, and HTTP header. CallerIdentityConfig now includes IdentityClaimPattern; NewCallerIdentityMiddleware compiles the pattern at construction, returning an error for invalid regexes. CallerIdentityFromRequest gains a compiledPattern parameter and enforces regex matching when set. The JWT handler refactors from a single parser/keyfunc pair to per-issuer compiledIssuer state, iterating through all configured issuers on each request. Matched issuer configuration (including pattern) is stored in request context via new SetMatchedIdentityConfig/GetMatchedIdentityConfig helpers, enabling the identity resolution middleware to use per-issuer rules. Config loading removes flat JWT environment-variable and CLI flag bindings; multi-issuer configuration is now YAML-only via HYPERFLEET_SERVER_JWT_CONFIGS JSON array. Tests expand to cover pattern validation, multi-issuer token acceptance/rejection, and per-issuer context storage. Documentation is updated to describe per-issuer identity settings, regex constraints, and configuration via YAML config files.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant JWTHandler
  participant Issuer1Parser
  participant Issuer2Parser
  participant IdentityMW
  participant Next

  Client->>JWTHandler: GET /api/... + Bearer Token
  JWTHandler->>Issuer1Parser: Validate token with issuer 1
  alt Issuer1 validation fails
    Issuer1Parser-->>JWTHandler: Error
    JWTHandler->>Issuer2Parser: Validate token with issuer 2
    alt Issuer2 validation succeeds
      Issuer2Parser-->>JWTHandler: Parsed JWT + matched issuer config
    else Issuer2 fails
      Issuer2Parser-->>JWTHandler: Error
      JWTHandler-->>Client: 401 Invalid or expired JWT
    end
  else Issuer1 validation succeeds
    Issuer1Parser-->>JWTHandler: Parsed JWT + matched issuer config
  end
  JWTHandler->>JWTHandler: SetMatchedIdentityConfig(ctx, issuer config, pattern)
  JWTHandler->>IdentityMW: ServeHTTP with context
  IdentityMW->>IdentityMW: GetMatchedIdentityConfig(ctx)
  IdentityMW->>IdentityMW: Compile JWT claim → identity
  alt Pattern set and mismatch
    IdentityMW-->>Client: 401 Identity claim does not match pattern
  else Pattern matches or not set
    IdentityMW->>Next: ServeHTTP with username in context
    Next-->>Client: 200 OK
  end
Loading

🎯 4 (Complex) | ⏱️ ~45 minutes


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 2 warnings)

Check name Status Explanation Resolution
Sec-02: Secrets In Log Output ❌ Error Line 184 (pkg/auth/jwt_handler.go) logs JWT parse errors directly without sanitization, violating security best practices. golang-jwt/keyfunc error messages may expose token-related sensitive data. Sanitize JWT error logging: catch parse errors, extract only safe fields (ErrTokenExpired type check), avoid logging raw parse errors containing potential secrets.
Docstring Coverage ⚠️ Warning Docstring coverage is 31.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
No Pii Or Sensitive Data In Logs ⚠️ Warning Identity claim pattern is exposed in HTTP error responses: pkg/auth/identity.go:57-58 returns fmt.Errorf with cfg.IdentityClaimPattern, which auth_middleware.go:21 formats into client response, dis... Remove cfg.IdentityClaimPattern from identity.go:57 error message. Return generic "invalid identity" instead of including pattern string in client-facing errors.
✅ Passed checks (8 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the primary architectural change: migration from single-issuer JWT fields to a multi-issuer configuration list.
Description check ✅ Passed The description is directly related to the changeset, detailing the before/after config structure, per-issuer fields, and test coverage for multi-issuer JWT validation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
No Hardcoded Secrets ✅ Passed No hardcoded secrets found. All sensitive config (JWT issuers, JWKS URLs, DB passwords) loaded from environment variables. Test fixtures use standard placeholders (test/test, example.com, https://h...
No Weak Cryptography ✅ Passed PR uses only strong cryptography: RSA 2048 for JWT (stdlib/3rd-party libs), regex.MatchString for pattern validation. No md5/des/rc4/SHA1, ECB, custom ciphers, or non-constant-time comparisons dete...
No Injection Vectors ✅ Passed No injection vectors found. Regex patterns from configuration are compiled with error handling (regexp.Compile with error checks in jwt_handler.go:123 and auth_middleware.go:33) before use. No SQL/...
No Privileged Containers ✅ Passed No privileged container settings detected. Dockerfile uses root only in builder stage with documented justification and immediate privilege revocation. Kubernetes manifests enforce non-root (runAsU...
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Comment @coderabbitai help to get the list of available commands.

@hyperfleet-ci-bot

hyperfleet-ci-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

Risk Score: 5 — risk/high

Signal Detail Points
PR size 978 lines (>500) +2
Sensitive paths cmd/ +2
Test coverage Missing tests for: cmd/hyperfleet-api/environments cmd/hyperfleet-api/server test +1

Computed by hyperfleet-risk-scorer

Replace single-issuer JWT scalars with an ordered list of issuer configs.
A request is accepted if its token validates against any configured issuer,
enabling multi-IdP deployments without forking the binary.

- Add JWTIssuerConfig struct with per-issuer issuer_url, audience,
  jwk_cert_url, jwk_cert_file, identity_claim, identity_claim_pattern,
  header fields; JWTConfig now holds Configs []JWTIssuerConfig
- Redesign JWTHandler: try each compiledIssuer in order; on first success
  store matched identity config in context via SetMatchedIdentityConfig
- Update CallerIdentityMiddleware to read per-request identity config from
  context (JWT path) with static config as non-JWT fallback
- Remove removed scalar fields from config flags, env bindings, and tests
- Fix integration test wiring: OverrideConfig sets JWT.Configs[0];
  StartJWKCertServerMock and CreateJWTString read from Configs[0]
- Update docs/authentication.md and docs/config.md for new config shape

Co-Authored-By: Claude <noreply@anthropic.com>
@rh-amarin rh-amarin changed the title HYPERFLEET-1237 - feat: add configurable identity claim pattern validation HYPERFLEET-1237 - feat: support multiple JWT issuer configurations Jun 23, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
pkg/auth/jwt_handler_test.go (1)

224-233: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Isolate unknown-issuer rejection from signature rejection (CWE-287, CWE-290).

This subtest uses a new key (unknownKey), so rejection can occur before issuer-claim enforcement is evaluated. Use a configured key (for example keyA) with an unknown iss to prove issuer validation is enforced independently.

Suggested test change
 	t.Run("token from unknown issuer rejected", func(t *testing.T) {
 		RegisterTestingT(t)
-		unknownKey, err := rsa.GenerateKey(rand.Reader, 2048)
-		Expect(err).NotTo(HaveOccurred())
-		token := signToken(t, unknownKey, jwt.MapClaims{
+		token := signToken(t, keyA, jwt.MapClaims{
 			"iss": "https://idp-c.example.com",
 			"exp": time.Now().Add(time.Hour).Unix(),
 		})
 		rr := serve(handler, "/protected", "Bearer "+token)
 		Expect(rr.Code).To(Equal(http.StatusUnauthorized))
 	})

As per path instructions, “Error paths SHOULD be tested, not just happy paths.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/auth/jwt_handler_test.go` around lines 224 - 233, The test conflates
signature validation failure (due to using an unknown key) with issuer
validation failure, making it impossible to verify that issuer validation is
being enforced independently. Replace the use of the newly generated unknownKey
in the signToken call with a configured key like keyA, while keeping the unknown
issuer claim set to "https://idp-c.example.com". This ensures the signature
validation passes but the issuer validation fails, proving that issuer-claim
enforcement is checked independently of signature verification.

Source: Path instructions

pkg/config/server.go (1)

81-95: 🔒 Security & Privacy | 🔵 Trivial

Config validation defers key source checks to handler startup — confirm this is intentional layering, not a gap.

JWTConfig.Validate() checks only IssuerURL and IdentityClaim, deferring JWK source validation to NewJWTHandler at startup (buildKeyfunc line 196-197: "at least one of KeysFile or KeysURL must be provided"). The startup path is fail-fast (check() → os.Exit(1)) before the server listens, so an issuer without a key source cannot reach production. IdentityClaimPattern regex is also compiled/validated at handler startup (buildCompiledIssuer), not config load time.

This validation layering is suboptimal from a defense-in-depth perspective — config validation should catch missing key sources rather than deferring to handler initialization — but the security boundary holds: the process exits before listening if a key source is absent. Confirm in code review whether this intentional split is documented or acceptable for your deployment model.

Audience is conditionally enforced (jwt.WithAudience appended only if set); no cross-service token confusion window exists if Audience is required in your configs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/config/server.go` around lines 81 - 95, The JWTConfig.Validate() method
should perform comprehensive validation of all required JWT configuration fields
at config load time, rather than deferring validation to handler startup.
Currently, the Validate method only checks IssuerURL and IdentityClaim, but
defers KeysFile/KeysURL presence validation to NewJWTHandler (buildKeyfunc
function) and IdentityClaimPattern regex compilation to buildCompiledIssuer.
Move the key source validation logic (ensuring at least one of KeysFile or
KeysURL is provided) from the handler initialization path into the
JWTConfig.Validate() method for each config in the Configs slice, and add regex
compilation validation for IdentityClaimPattern if it is set. This ensures all
critical JWT configuration issues are caught at config load time, providing
proper defense-in-depth validation layering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/hyperfleet-api/server/routes.go`:
- Around line 89-94: The callerIdentityMW middleware with an empty
CallerIdentityConfig is being installed unconditionally, which breaks no-auth
mode by returning 401 errors on all mutating requests when no identity config
exists in context. Only install the NewCallerIdentityMiddleware and the
callerIdentityMW.ResolveCallerIdentity middleware if JWT is actually enabled or
a real identity source exists. Gate the middleware creation and installation
behind a condition that checks whether JWT is configured, rather than
unconditionally installing it with a fallback empty config.

In `@pkg/auth/jwt_handler.go`:
- Around line 121-138: The header value ic.Header is being stored in the
compiledIssuer struct's identityCfg without validation, which allows it to
bypass the per-issuer identity claim pattern gate. Add validation of ic.Header
before storing it in the HeaderName field of the CallerIdentityConfig within the
compiledIssuer return statement. Apply the same forbidden-header validation
logic that is used in NewCallerIdentityMiddleware to ensure headers are properly
validated at this system boundary before being stored in the configuration.
- Around line 171-189: Preserve the expired token error classification when
iterating through issuers. Currently in the loop that attempts to parse the
token with each issuer, the `lastErr` variable is unconditionally overwritten
with each failed attempt, which causes an earlier jwt.ErrTokenExpired error to
be lost if a subsequent issuer fails with a different error. Before assigning
the new error to lastErr after issuer.parser.Parse fails, check if lastErr is
already set to jwt.ErrTokenExpired and skip the assignment if so, ensuring that
the expired token classification is preserved for the final error handling logic
that checks for jwt.ErrTokenExpired.

In `@pkg/config/loader.go`:
- Around line 306-311: The documentation advertises
`HYPERFLEET_SERVER_JWT_CONFIGS` as a supported environment variable override for
JWT configurations, but the implementation does not actually handle this
environment variable. The config loader does not bind `server.jwt.configs` in
the `bindAllEnvVars()` function around line 306-311, nor does it include this
key in the `handleJSONArrayEnvVars()` function (lines 235-275) which is required
to properly parse JSON array environment variables. You must resolve this
inconsistency by either: 1) removing `HYPERFLEET_SERVER_JWT_CONFIGS` from the
documentation (config.md and authentication.md) since JWT configs are currently
YAML-only as noted in the comment, OR 2) adding the `server.jwt.configs` key
mapping to the `handleJSONArrayEnvVars()` function to actually implement support
for the JSON environment variable. Choose the approach that aligns with the
intended design and implement it consistently across both code and
documentation.

In `@test/helper.go`:
- Around line 159-160: The code at the index assignment
`helper.Env().Config.Server.JWT.Configs[0] = jwkURL` accesses the Configs slice
without checking if it contains any elements, which can cause an index out of
range panic if the slice is empty. Add a guard check using
`len(helper.Env().Config.Server.JWT.Configs) > 0` before this line, following
the same pattern used by other helper methods in the file like
IdentityHeaderName, StartJWKCertServerMock, and CreateJWTString. Only assign the
jwkURL if the Configs slice has at least one element.

---

Nitpick comments:
In `@pkg/auth/jwt_handler_test.go`:
- Around line 224-233: The test conflates signature validation failure (due to
using an unknown key) with issuer validation failure, making it impossible to
verify that issuer validation is being enforced independently. Replace the use
of the newly generated unknownKey in the signToken call with a configured key
like keyA, while keeping the unknown issuer claim set to
"https://idp-c.example.com". This ensures the signature validation passes but
the issuer validation fails, proving that issuer-claim enforcement is checked
independently of signature verification.

In `@pkg/config/server.go`:
- Around line 81-95: The JWTConfig.Validate() method should perform
comprehensive validation of all required JWT configuration fields at config load
time, rather than deferring validation to handler startup. Currently, the
Validate method only checks IssuerURL and IdentityClaim, but defers
KeysFile/KeysURL presence validation to NewJWTHandler (buildKeyfunc function)
and IdentityClaimPattern regex compilation to buildCompiledIssuer. Move the key
source validation logic (ensuring at least one of KeysFile or KeysURL is
provided) from the handler initialization path into the JWTConfig.Validate()
method for each config in the Configs slice, and add regex compilation
validation for IdentityClaimPattern if it is set. This ensures all critical JWT
configuration issues are caught at config load time, providing proper
defense-in-depth validation layering.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Enterprise

Run ID: 7d6df354-9c2f-4c9f-ade0-ca9194723da4

📥 Commits

Reviewing files that changed from the base of the PR and between f273759 and ba12a4d.

📒 Files selected for processing (17)
  • cmd/hyperfleet-api/environments/e_integration_testing.go
  • cmd/hyperfleet-api/server/api_server.go
  • cmd/hyperfleet-api/server/routes.go
  • docs/authentication.md
  • docs/config.md
  • pkg/auth/auth_middleware.go
  • pkg/auth/context.go
  • pkg/auth/jwt_handler.go
  • pkg/auth/jwt_handler_test.go
  • pkg/config/dump.go
  • pkg/config/flags.go
  • pkg/config/helpers_test.go
  • pkg/config/loader.go
  • pkg/config/loader_test.go
  • pkg/config/server.go
  • pkg/config/server_test.go
  • test/helper.go
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • openshift-hyperfleet/architecture (manual)
  • openshift-hyperfleet/hyperfleet-api (manual)
  • openshift-hyperfleet/hyperfleet-sentinel (manual)
  • openshift-hyperfleet/hyperfleet-adapter (manual)
  • openshift-hyperfleet/hyperfleet-broker (manual)
💤 Files with no reviewable changes (2)
  • pkg/config/helpers_test.go
  • pkg/config/loader_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/auth/auth_middleware.go

Comment on lines +89 to +94
// Identity config is resolved per-request from context when JWT is enabled
// (JWTHandler stores the matched issuer's config). An empty static config is used
// as the fallback for non-JWT paths (dev/no-auth mode).
callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{})
check(mwErr, "Unable to create caller identity middleware")
apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not install an empty identity resolver in no-auth mode.

With JWT disabled, no matched identity config is ever placed in context. This middleware then falls back to the empty config on Line 92, cannot resolve an identity, and returns 401 for every mutating request. That breaks run-no-auth. CWE/CVE: not applicable.

Gate the middleware unless a real identity source exists
-	// Identity config is resolved per-request from context when JWT is enabled
-	// (JWTHandler stores the matched issuer's config). An empty static config is used
-	// as the fallback for non-JWT paths (dev/no-auth mode).
-	callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{})
-	check(mwErr, "Unable to create caller identity middleware")
-	apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)
+	if env().Config.Server.JWT.Enabled {
+		// Identity config is resolved per-request from context when JWTHandler stores
+		// the matched issuer's config.
+		callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{})
+		check(mwErr, "Unable to create caller identity middleware")
+		apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)
+	}

As per coding guidelines, make run-no-auth must “Start server without auth.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Identity config is resolved per-request from context when JWT is enabled
// (JWTHandler stores the matched issuer's config). An empty static config is used
// as the fallback for non-JWT paths (dev/no-auth mode).
callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{})
check(mwErr, "Unable to create caller identity middleware")
apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)
if env().Config.Server.JWT.Enabled {
// Identity config is resolved per-request from context when JWTHandler stores
// the matched issuer's config.
callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{})
check(mwErr, "Unable to create caller identity middleware")
apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/hyperfleet-api/server/routes.go` around lines 89 - 94, The
callerIdentityMW middleware with an empty CallerIdentityConfig is being
installed unconditionally, which breaks no-auth mode by returning 401 errors on
all mutating requests when no identity config exists in context. Only install
the NewCallerIdentityMiddleware and the callerIdentityMW.ResolveCallerIdentity
middleware if JWT is actually enabled or a real identity source exists. Gate the
middleware creation and installation behind a condition that checks whether JWT
is configured, rather than unconditionally installing it with a fallback empty
config.

Source: Coding guidelines

Comment thread pkg/auth/jwt_handler.go
Comment on lines +121 to +138
var compiledPattern *regexp.Regexp
if ic.IdentityClaimPattern != "" {
compiledPattern, err = regexp.Compile(ic.IdentityClaimPattern)
if err != nil {
return compiledIssuer{}, fmt.Errorf("identity_claim_pattern %q is not a valid regex: %w",
ic.IdentityClaimPattern, err)
}
}

// JWTHandler validates JWT tokens on incoming requests. Call Close() during
// shutdown to stop the background JWKS refresh goroutine.
type JWTHandler struct {
keyfunc keyfunc.Keyfunc
next http.Handler
parser *jwt.Parser
cancel context.CancelFunc
publicPatterns []*regexp.Regexp
return compiledIssuer{
parser: jwt.NewParser(parserOpts...),
keyfunc: kf,
compiledPattern: compiledPattern,
identityCfg: CallerIdentityConfig{
JWTIdentityClaim: ic.IdentityClaim,
IdentityClaimPattern: ic.IdentityClaimPattern,
HeaderName: ic.Header,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate per-issuer identity headers before storing them.

Line 137 copies ic.Header into the matched identity config, but the only forbidden-header check is in NewCallerIdentityMiddleware, which routes.go now calls with an empty config. CallerIdentityFromRequest also resolves HeaderName before the JWT claim and returns before applying IdentityClaimPattern, so a configured header can bypass the per-issuer claim-pattern gate. CWE-287.

Security hardening direction
 func buildCompiledIssuer(ctx context.Context, ic JWTIssuerHandlerConfig) (compiledIssuer, error) {
 	kf, err := buildKeyfunc(ctx, ic)
 	if err != nil {
 		return compiledIssuer{}, fmt.Errorf("failed to build keyfunc: %w", err)
 	}
+
+	if ic.Header != "" && validation.IsForbiddenIdentityHeaderName(ic.Header) {
+		return compiledIssuer{}, fmt.Errorf("identity header name %q is not allowed", ic.Header)
+	}

Also apply IdentityClaimPattern to the final resolved identity, or disallow header precedence when IdentityClaimPattern is configured.

As per path instructions, Security (SEC-01 to SEC-03): “Validate input at system boundaries (HTTP handlers, CLI parsers, webhook receivers).”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/auth/jwt_handler.go` around lines 121 - 138, The header value ic.Header
is being stored in the compiledIssuer struct's identityCfg without validation,
which allows it to bypass the per-issuer identity claim pattern gate. Add
validation of ic.Header before storing it in the HeaderName field of the
CallerIdentityConfig within the compiledIssuer return statement. Apply the same
forbidden-header validation logic that is used in NewCallerIdentityMiddleware to
ensure headers are properly validated at this system boundary before being
stored in the configuration.

Source: Path instructions

Comment thread pkg/auth/jwt_handler.go
Comment on lines +171 to +189
var lastErr error
for _, issuer := range h.issuers {
token, err := issuer.parser.Parse(tokenString, issuer.keyfunc.Keyfunc)
if err != nil {
lastErr = err
continue
}
ctx := SetJWTTokenContext(r.Context(), token)
ctx = SetMatchedIdentityConfig(ctx, issuer.identityCfg, issuer.compiledPattern)
h.next.ServeHTTP(w, r.WithContext(ctx))
return
}

ctx := SetJWTTokenContext(r.Context(), token)
h.next.ServeHTTP(w, r.WithContext(ctx))
logger.WithError(r.Context(), lastErr).Warn("JWT validation failed against all configured issuers")
if errors.Is(lastErr, jwt.ErrTokenExpired) {
handleError(r.Context(), w, r, hferrors.CodeAuthExpiredToken, "JWT token has expired")
} else {
handleError(r.Context(), w, r, hferrors.CodeAuthInvalidCredentials, "invalid or expired JWT token")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve expired-token classification across issuer attempts.

lastErr is overwritten by later issuer failures. An expired token for issuer A can return HYPERFLEET-AUT-002 instead of HYPERFLEET-AUT-003 if issuer B is attempted afterward and fails with a non-expiry error. CWE/CVE: not applicable.

Order-independent error classification
 	var lastErr error
+	expired := false
 	for _, issuer := range h.issuers {
 		token, err := issuer.parser.Parse(tokenString, issuer.keyfunc.Keyfunc)
 		if err != nil {
+			if errors.Is(err, jwt.ErrTokenExpired) {
+				expired = true
+			}
 			lastErr = err
 			continue
 		}
@@
 	}
 
 	logger.WithError(r.Context(), lastErr).Warn("JWT validation failed against all configured issuers")
-	if errors.Is(lastErr, jwt.ErrTokenExpired) {
+	if expired {
 		handleError(r.Context(), w, r, hferrors.CodeAuthExpiredToken, "JWT token has expired")
 	} else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var lastErr error
for _, issuer := range h.issuers {
token, err := issuer.parser.Parse(tokenString, issuer.keyfunc.Keyfunc)
if err != nil {
lastErr = err
continue
}
ctx := SetJWTTokenContext(r.Context(), token)
ctx = SetMatchedIdentityConfig(ctx, issuer.identityCfg, issuer.compiledPattern)
h.next.ServeHTTP(w, r.WithContext(ctx))
return
}
ctx := SetJWTTokenContext(r.Context(), token)
h.next.ServeHTTP(w, r.WithContext(ctx))
logger.WithError(r.Context(), lastErr).Warn("JWT validation failed against all configured issuers")
if errors.Is(lastErr, jwt.ErrTokenExpired) {
handleError(r.Context(), w, r, hferrors.CodeAuthExpiredToken, "JWT token has expired")
} else {
handleError(r.Context(), w, r, hferrors.CodeAuthInvalidCredentials, "invalid or expired JWT token")
}
var lastErr error
expired := false
for _, issuer := range h.issuers {
token, err := issuer.parser.Parse(tokenString, issuer.keyfunc.Keyfunc)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
expired = true
}
lastErr = err
continue
}
ctx := SetJWTTokenContext(r.Context(), token)
ctx = SetMatchedIdentityConfig(ctx, issuer.identityCfg, issuer.compiledPattern)
h.next.ServeHTTP(w, r.WithContext(ctx))
return
}
logger.WithError(r.Context(), lastErr).Warn("JWT validation failed against all configured issuers")
if expired {
handleError(r.Context(), w, r, hferrors.CodeAuthExpiredToken, "JWT token has expired")
} else {
handleError(r.Context(), w, r, hferrors.CodeAuthInvalidCredentials, "invalid or expired JWT token")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/auth/jwt_handler.go` around lines 171 - 189, Preserve the expired token
error classification when iterating through issuers. Currently in the loop that
attempts to parse the token with each issuer, the `lastErr` variable is
unconditionally overwritten with each failed attempt, which causes an earlier
jwt.ErrTokenExpired error to be lost if a subsequent issuer fails with a
different error. Before assigning the new error to lastErr after
issuer.parser.Parse fails, check if lastErr is already set to
jwt.ErrTokenExpired and skip the assignment if so, ensuring that the expired
token classification is preserved for the final error handling logic that checks
for jwt.ErrTokenExpired.

Comment thread pkg/config/loader.go
Comment on lines 306 to +311
l.bindEnv("server.tls.cert_file")
l.bindEnv("server.tls.key_file")
l.bindEnv("server.jwt.enabled")
l.bindEnv("server.jwt.issuer_url")
l.bindEnv("server.jwt.audience")
l.bindEnv("server.jwt.identity_claim")
l.bindEnv("server.identity_header")
l.bindEnv("server.jwk.cert_file")
l.bindEnv("server.jwk.cert_url")
// Note: server.jwt.configs is a slice of structs configured via YAML config file.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Is HYPERFLEET_SERVER_JWT_CONFIGS bound/honored anywhere, or is config truly YAML-only?
rg -nP -C2 'JWT_CONFIGS|jwt\.configs|AutomaticEnv' pkg/config
echo "----- docs claims -----"
rg -nP -C2 'JWT_CONFIGS|jwt\.configs|identity_header|JWT_IDENTITY_CLAIM' docs

Repository: openshift-hyperfleet/hyperfleet-api

Length of output: 6480


🏁 Script executed:

#!/bin/bash
# 1. Show full bindAllEnvVars() method to see what is/isn't bound
sed -n '295,350p' pkg/config/loader.go

echo "--- CHECK: is server.jwt.identity_claim ever bound? ---"
rg -n 'server\.jwt\.identity_claim|bindEnv.*identity' pkg/config/

echo "--- CHECK: what calls the JSON array parsing code at lines 260-264? ---"
rg -n 'jwt.*Configs\|configs.*\[\]' pkg/config/loader.go | head -20

Repository: openshift-hyperfleet/hyperfleet-api

Length of output: 2272


🏁 Script executed:

#!/bin/bash
# 2. Check if server.jwt.configs binding exists or if it's AutomaticEnv only
echo "--- Full search for jwt.configs binding/handling ---"
rg -n 'jwt\.configs|JwtConfigs' pkg/config/

echo "--- Check Viper binding/Get calls for jwt.configs ---"
ast-grep run --pattern 'viper.Bind$_("server.jwt.configs")' --lang go pkg/config/

Repository: openshift-hyperfleet/hyperfleet-api

Length of output: 694


🏁 Script executed:

#!/bin/bash
# 3. Check git history — were identity_claim/identity_header ever in loader.go?
echo "--- Git log for pkg/config/loader.go (recent commits) ---"
git log --oneline -20 -- pkg/config/loader.go

echo "--- Git show: what was in bindAllEnvVars before? ---"
git log -p --follow -S 'server.jwt.identity_claim' -- pkg/config/loader.go | head -100

Repository: openshift-hyperfleet/hyperfleet-api

Length of output: 359


🏁 Script executed:

#!/bin/bash
# Show the context around the JSON parsing code to find what function uses it
sed -n '250,280p' pkg/config/loader.go

Repository: openshift-hyperfleet/hyperfleet-api

Length of output: 1467


🏁 Script executed:

#!/bin/bash
# Find function containing lines 260-264 JSON parsing
sed -n '200,320p' pkg/config/loader.go | grep -B 40 'Always set the parsed JSON array value'

Repository: openshift-hyperfleet/hyperfleet-api

Length of output: 1792


🏁 Script executed:

#!/bin/bash
# Search for where jwt.configs is actually parsed/read from env
rg -B5 -A5 'jwt.*Configs.*\[\]|Viper.*jwt' pkg/config/

Repository: openshift-hyperfleet/hyperfleet-api

Length of output: 173


🏁 Script executed:

#!/bin/bash
# Check if server.jwt.configs is in handleJSONArrayEnvVars mappings
sed -n '235,275p' pkg/config/loader.go | grep -A 20 'jsonArrayMappings'

Repository: openshift-hyperfleet/hyperfleet-api

Length of output: 1277


🏁 Script executed:

#!/bin/bash
# Search for where server.jwt.configs env var is actually handled
echo "=== Search for JWT_CONFIGS handling in Load() or elsewhere ==="
rg -n 'jwt\.configs|JWT_CONFIGS' pkg/config/loader.go

echo "=== Check if Load() calls handleJSONArrayEnvVars ==="
rg -B10 'handleJSONArrayEnvVars' pkg/config/loader.go | head -30

Repository: openshift-hyperfleet/hyperfleet-api

Length of output: 1369


🏁 Script executed:

#!/bin/bash
# Check where server.jwt.configs is actually populated — config file only?
echo "=== How jwt.configs is set in server.go Validate() ==="
sed -n '80,100p' pkg/config/server.go

echo "=== Full JWT struct definition ==="
ast-grep outline pkg/config/server.go | grep -A 30 'JWT\|Jwt'

Repository: openshift-hyperfleet/hyperfleet-api

Length of output: 1676


Environment variable documentation inconsistency: HYPERFLEET_SERVER_JWT_CONFIGS is advertised in docs but not implemented.

The docs (config.md:394, authentication.md:240) document HYPERFLEET_SERVER_JWT_CONFIGS as a supported JSON environment variable override for JWT issuer configurations. However, the code does not handle this: server.jwt.configs is neither bound in bindAllEnvVars() (line 311) nor included in handleJSONArrayEnvVars() (lines 235–275), which is the only place JSON array env vars are parsed. Without explicit handling, relying on AutomaticEnv() alone would fail to parse the JSON array correctly (Viper treats commas as CSV delimiters, not JSON syntax).

Either remove the env var from docs or add server.jwt.configs to the handleJSONArrayEnvVars() mapping to actually support it. The comment at line 311 is correct: JWT configs are currently YAML-only.

(Note: the original comment referenced removal of server.jwt.identity_claim / server.identity_header env bindings, but these keys do not exist in the codebase — no prior binding to remove.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/config/loader.go` around lines 306 - 311, The documentation advertises
`HYPERFLEET_SERVER_JWT_CONFIGS` as a supported environment variable override for
JWT configurations, but the implementation does not actually handle this
environment variable. The config loader does not bind `server.jwt.configs` in
the `bindAllEnvVars()` function around line 306-311, nor does it include this
key in the `handleJSONArrayEnvVars()` function (lines 235-275) which is required
to properly parse JSON array environment variables. You must resolve this
inconsistency by either: 1) removing `HYPERFLEET_SERVER_JWT_CONFIGS` from the
documentation (config.md and authentication.md) since JWT configs are currently
YAML-only as noted in the comment, OR 2) adding the `server.jwt.configs` key
mapping to the `handleJSONArrayEnvVars()` function to actually implement support
for the JSON environment variable. Choose the approach that aligns with the
intended design and implement it consistently across both code and
documentation.

Source: Path instructions

Comment thread test/helper.go
Comment on lines +159 to +160
// Configure JWK certificate URL for the first (and only) integration test issuer
helper.Env().Config.Server.JWT.Configs[0].JWKCertURL = jwkURL

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unguarded Configs[0] index — panic on empty slice (CWE-129).

startAPIServer indexes Server.JWT.Configs[0] with no length check, while every sibling helper in this file (IdentityHeaderName L385, StartJWKCertServerMock L393, CreateJWTString L596) guards with len(cfgs) > 0. Any test path that constructs a helper without populating Configs (e.g. JWT disabled, or a future fixture change) triggers index out of range and crashes the test binary instead of failing cleanly.

Proposed guard
-	// Configure JWK certificate URL for the first (and only) integration test issuer
-	helper.Env().Config.Server.JWT.Configs[0].JWKCertURL = jwkURL
+	// Configure JWK certificate URL for the first (and only) integration test issuer
+	if cfgs := helper.Env().Config.Server.JWT.Configs; len(cfgs) > 0 {
+		cfgs[0].JWKCertURL = jwkURL
+	}

As per path instructions (**/*.go): "Flag nil/bounds access without guards."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Configure JWK certificate URL for the first (and only) integration test issuer
helper.Env().Config.Server.JWT.Configs[0].JWKCertURL = jwkURL
// Configure JWK certificate URL for the first (and only) integration test issuer
if cfgs := helper.Env().Config.Server.JWT.Configs; len(cfgs) > 0 {
cfgs[0].JWKCertURL = jwkURL
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/helper.go` around lines 159 - 160, The code at the index assignment
`helper.Env().Config.Server.JWT.Configs[0] = jwkURL` accesses the Configs slice
without checking if it contains any elements, which can cause an index out of
range panic if the slice is empty. Add a guard check using
`len(helper.Env().Config.Server.JWT.Configs) > 0` before this line, following
the same pattern used by other helper methods in the file like
IdentityHeaderName, StartJWKCertServerMock, and CreateJWTString. Only assign the
jwkURL if the Configs slice has at least one element.

Source: Path instructions

@rh-amarin rh-amarin marked this pull request as draft June 23, 2026 21:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant