diff --git a/charts/README.md b/charts/README.md index 6847b02d..a0dd5f1c 100644 --- a/charts/README.md +++ b/charts/README.md @@ -44,9 +44,9 @@ helm install hyperfleet-api oci://REGISTRY/hyperfleet-api \ | ports.api | int | `8000` | API server port | | ports.health | int | `8080` | Health check endpoint port | | ports.metrics | int | `9090` | Prometheus metrics endpoint port | -| config | object | `{"adapters":{"required":{"cluster":[],"nodepool":[]}},"database":{"debug":false,"dialect":"postgres","host":"","name":"hyperfleet","pool":{"conn_max_idle_time":"1m","conn_max_lifetime":"5m","conn_retry_attempts":10,"conn_retry_interval":"3s","max_connections":50,"max_idle_connections":10,"request_timeout":"30s"},"port":5432,"ssl":{"mode":"disable","root_cert_file":""}},"entities":[{"kind":"Channel","plural":"channels","search_disallowed_fields":["spec"],"spec_schema_name":"ChannelSpec"},{"kind":"Version","on_parent_delete":"restrict","parent_kind":"Channel","plural":"versions","search_disallowed_fields":["spec"],"spec_schema_name":"VersionSpec"},{"kind":"WifConfig","plural":"wifconfigs","search_disallowed_fields":["spec"],"spec_schema_name":"WifConfigSpec"}],"existingConfigMap":"","health":{"db_ping_timeout":"2s","host":"0.0.0.0","port":8080,"shutdown_timeout":"20s","tls":{"enabled":false}},"logging":{"format":"json","level":"info","masking":{"enabled":true,"fields":["password","secret","token","api_key","access_token","refresh_token","client_secret"],"headers":["Authorization","X-API-Key","Cookie","X-Auth-Token","X-Forwarded-Authorization","X-HyperFleet-Identity"]},"otel":{"enabled":false},"output":"stdout"},"metrics":{"host":"0.0.0.0","label_metrics_inclusion_duration":"168h","port":9090,"reconciliation_stuck_threshold":"10m","tls":{"enabled":false}},"server":{"host":"0.0.0.0","hostname":"","identity_header":"","jwk":{"cert_file":"","cert_url":""},"jwt":{"audience":"","enabled":false,"identity_claim":"email","issuer_url":""},"port":8000,"timeouts":{"read":"5s","write":"30s"},"tls":{"cert_file":"","enabled":false,"key_file":""}}}` | Application configuration. All settings in this section generate the ConfigMap consumed by the API server. Set `config.existingConfigMap` to use a pre-existing ConfigMap instead. | +| config | object | `{"adapters":{"required":{"cluster":[],"nodepool":[]}},"database":{"debug":false,"dialect":"postgres","host":"","name":"hyperfleet","pool":{"conn_max_idle_time":"1m","conn_max_lifetime":"5m","conn_retry_attempts":10,"conn_retry_interval":"3s","max_connections":50,"max_idle_connections":10,"request_timeout":"30s"},"port":5432,"ssl":{"mode":"disable","root_cert_file":""}},"entities":[{"kind":"Channel","plural":"channels","search_disallowed_fields":["spec"],"spec_schema_name":"ChannelSpec"},{"kind":"Version","on_parent_delete":"restrict","parent_kind":"Channel","plural":"versions","search_disallowed_fields":["spec"],"spec_schema_name":"VersionSpec"},{"kind":"WifConfig","plural":"wifconfigs","search_disallowed_fields":["spec"],"spec_schema_name":"WifConfigSpec"}],"existingConfigMap":"","health":{"db_ping_timeout":"2s","host":"0.0.0.0","port":8080,"shutdown_timeout":"20s","tls":{"enabled":false}},"logging":{"format":"json","level":"info","masking":{"enabled":true,"fields":["password","secret","token","api_key","access_token","refresh_token","client_secret"],"headers":["Authorization","X-API-Key","Cookie","X-Auth-Token","X-Forwarded-Authorization","X-HyperFleet-Identity"]},"otel":{"enabled":false},"output":"stdout"},"metrics":{"host":"0.0.0.0","label_metrics_inclusion_duration":"168h","port":9090,"reconciliation_stuck_threshold":"10m","tls":{"enabled":false}},"server":{"host":"0.0.0.0","hostname":"","jwt":{"configs":[],"enabled":false},"port":8000,"timeouts":{"read":"5s","write":"30s"},"tls":{"cert_file":"","enabled":false,"key_file":""}}}` | Application configuration. All settings in this section generate the ConfigMap consumed by the API server. Set `config.existingConfigMap` to use a pre-existing ConfigMap instead. | | config.existingConfigMap | string | `""` | Use an existing ConfigMap instead of generating one. When set, all other `config.*` values are ignored. | -| config.server | object | `{"host":"0.0.0.0","hostname":"","identity_header":"","jwk":{"cert_file":"","cert_url":""},"jwt":{"audience":"","enabled":false,"identity_claim":"email","issuer_url":""},"port":8000,"timeouts":{"read":"5s","write":"30s"},"tls":{"cert_file":"","enabled":false,"key_file":""}}` | HTTP server settings | +| config.server | object | `{"host":"0.0.0.0","hostname":"","jwt":{"configs":[],"enabled":false},"port":8000,"timeouts":{"read":"5s","write":"30s"},"tls":{"cert_file":"","enabled":false,"key_file":""}}` | HTTP server settings | | config.server.hostname | string | `""` | Public hostname advertised by the API (leave empty for auto-detect) | | config.server.host | string | `"0.0.0.0"` | Listen address | | config.server.port | int | `8000` | Listen port (must match `ports.api`) | @@ -57,15 +57,9 @@ helm install hyperfleet-api oci://REGISTRY/hyperfleet-api \ | config.server.tls.enabled | bool | `false` | Enable TLS on the API listener | | config.server.tls.cert_file | string | `""` | Path to TLS certificate file | | config.server.tls.key_file | string | `""` | Path to TLS key file | -| config.server.jwt | object | `{"audience":"","enabled":false,"identity_claim":"email","issuer_url":""}` | JWT authentication settings | +| config.server.jwt | object | `{"configs":[],"enabled":false}` | JWT authentication settings | | config.server.jwt.enabled | bool | `false` | Enable JWT authentication | -| config.server.jwt.issuer_url | string | `""` | OIDC issuer URL for token validation | -| config.server.jwt.audience | string | `""` | Expected JWT audience claim | -| config.server.jwt.identity_claim | string | `"email"` | JWT claim used as the caller identity | -| config.server.identity_header | string | `""` | HTTP header used to pass caller identity (bypasses JWT when set) | -| config.server.jwk | object | `{"cert_file":"","cert_url":""}` | JWK settings for token verification | -| config.server.jwk.cert_file | string | `""` | Path to a local JWK certificate file | -| config.server.jwk.cert_url | string | `""` | URL to fetch JWK certificates from | +| config.server.jwt.configs | list | `[]` | List of JWT issuer configurations (multi-issuer support) | | config.database | object | `{"debug":false,"dialect":"postgres","host":"","name":"hyperfleet","pool":{"conn_max_idle_time":"1m","conn_max_lifetime":"5m","conn_retry_attempts":10,"conn_retry_interval":"3s","max_connections":50,"max_idle_connections":10,"request_timeout":"30s"},"port":5432,"ssl":{"mode":"disable","root_cert_file":""}}` | Database connection settings. Credentials must be provided via a Secret — see `database.external.secretName` or use the built-in PostgreSQL (`database.postgresql.enabled`). | | config.database.dialect | string | `"postgres"` | SQL dialect | | config.database.host | string | `""` | Database host (auto-set when using the built-in PostgreSQL) | diff --git a/charts/templates/NOTES.txt b/charts/templates/NOTES.txt index 185568c7..1a5c872c 100644 --- a/charts/templates/NOTES.txt +++ b/charts/templates/NOTES.txt @@ -18,24 +18,28 @@ Validation schema validation is ENABLED. {{- end }} Caller identity (audit attribution): - config.server.jwt.identity_claim: {{ .Values.config.server.jwt.identity_claim | default "" | quote }} - config.server.identity_header: {{ .Values.config.server.identity_header | default "" | quote }} + JWT issuers configured: {{ len .Values.config.server.jwt.configs }} -When identity_header is set and a request includes a non-empty header with that name, -the header value overrides the JWT claim for audit fields (created_by, updated_by). -When identity_claim is set, the named JWT claim is used as the caller identity. +Each JWT issuer config can specify its own identity_claim (defaults to "email") and +identity_header. When identity_header is set per-issuer, a matching non-empty header +value overrides the JWT claim for audit fields (created_by, updated_by, deleted_by). Only trusted gateways should set the identity header in production. Override in values.yaml: config: server: jwt: - identity_claim: preferred_username - identity_header: X-HyperFleet-Identity + enabled: true + configs: + - issuer_url: "https://idp.example.com/auth/realms/my-realm" + jwk_cert_url: "https://idp.example.com/certs" Or at install/upgrade: - --set config.server.jwt.identity_claim=preferred_username - --set config.server.identity_header=X-HyperFleet-Identity + --set config.server.jwt.enabled=true + --set-json 'config.server.jwt.configs=[{"issuer_url":"https://idp.example.com","jwk_cert_url":"https://idp.example.com/certs"}]' + +Full issuer field reference (header, audience, identity_claim, identity_claim_pattern, identity_header): + https://github.com/openshift-hyperfleet/hyperfleet-api/blob/main/docs/authentication.md#issuer-configuration-reference Documentation: https://github.com/openshift-hyperfleet/hyperfleet-api/blob/main/docs/deployment.md diff --git a/charts/templates/configmap.yaml b/charts/templates/configmap.yaml index d1f55fb5..92e3c981 100644 --- a/charts/templates/configmap.yaml +++ b/charts/templates/configmap.yaml @@ -28,15 +28,33 @@ data: jwt: enabled: {{ .Values.config.server.jwt.enabled }} - issuer_url: {{ .Values.config.server.jwt.issuer_url | quote }} - audience: {{ .Values.config.server.jwt.audience | quote }} - identity_claim: {{ .Values.config.server.jwt.identity_claim | quote }} - - identity_header: {{ .Values.config.server.identity_header | quote }} - - jwk: - cert_file: {{ .Values.config.server.jwk.cert_file | quote }} - cert_url: {{ .Values.config.server.jwk.cert_url | quote }} + {{- if .Values.config.server.jwt.configs }} + configs: + {{- range .Values.config.server.jwt.configs }} + - issuer_url: {{ .issuer_url | quote }} + {{- if .jwk_cert_url }} + jwk_cert_url: {{ .jwk_cert_url | quote }} + {{- end }} + {{- if .jwk_cert_file }} + jwk_cert_file: {{ .jwk_cert_file | quote }} + {{- end }} + {{- if .header }} + header: {{ .header | quote }} + {{- end }} + {{- if .audience }} + audience: {{ .audience | quote }} + {{- end }} + {{- if .identity_claim }} + identity_claim: {{ .identity_claim | quote }} + {{- end }} + {{- if .identity_claim_pattern }} + identity_claim_pattern: {{ .identity_claim_pattern | quote }} + {{- end }} + {{- if .identity_header }} + identity_header: {{ .identity_header | quote }} + {{- end }} + {{- end }} + {{- end }} database: dialect: {{ .Values.config.database.dialect }} diff --git a/charts/values.schema.json b/charts/values.schema.json index 1e8fbe35..d64eb84d 100644 --- a/charts/values.schema.json +++ b/charts/values.schema.json @@ -145,35 +145,47 @@ "type": "boolean", "description": "Enable JWT token validation" }, - "issuer_url": { - "type": "string", - "description": "Expected JWT issuer URL" - }, - "audience": { - "type": "string", - "description": "Expected JWT audience claim" - }, - "identity_claim": { - "type": "string", - "description": "JWT claim to extract the caller identity from (e.g. email, sub)" - } - } - }, - "identity_header": { - "type": "string", - "description": "HTTP header carrying a pre-authenticated identity (bypasses JWT when set)" - }, - "jwk": { - "type": "object", - "description": "JWK key material for token verification", - "properties": { - "cert_file": { - "type": "string", - "description": "Path to a local JWK certificate file" - }, - "cert_url": { - "type": "string", - "description": "URL to fetch JWK certificates from" + "configs": { + "type": "array", + "description": "List of JWT issuer configurations (multi-issuer support)", + "items": { + "type": "object", + "properties": { + "issuer_url": { + "type": "string", + "description": "OIDC issuer URL for token validation (required)" + }, + "jwk_cert_url": { + "type": "string", + "description": "URL to fetch JWK certificates from" + }, + "jwk_cert_file": { + "type": "string", + "description": "Path to a local JWK certificate file" + }, + "header": { + "type": "string", + "description": "HTTP header to read the JWT from (default: Authorization)" + }, + "audience": { + "type": "string", + "description": "Expected JWT audience claim" + }, + "identity_claim": { + "type": "string", + "description": "JWT claim to extract the caller identity from (default: email)" + }, + "identity_claim_pattern": { + "type": "string", + "description": "Regex pattern to validate the identity claim value" + }, + "identity_header": { + "type": "string", + "description": "HTTP header carrying a pre-authenticated identity (bypasses JWT claim when set)" + } + }, + "required": ["issuer_url"] + } } } } diff --git a/charts/values.yaml b/charts/values.yaml index 90671d58..b2449829 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -69,22 +69,19 @@ config: jwt: # -- Enable JWT authentication enabled: false - # -- OIDC issuer URL for token validation - issuer_url: "" - # -- Expected JWT audience claim - audience: "" - # -- JWT claim used as the caller identity - identity_claim: email - - # -- HTTP header used to pass caller identity (bypasses JWT when set) - identity_header: "" - - # -- JWK settings for token verification - jwk: - # -- Path to a local JWK certificate file - cert_file: "" - # -- URL to fetch JWK certificates from - cert_url: "" + # -- List of JWT issuer configurations (multi-issuer support) + configs: [] + # Example: + # configs: + # - issuer_url: "https://idp.example.com/auth/realms/my-realm" + # jwk_cert_url: "https://idp.example.com/certs" + # audience: "my-api" + # identity_claim: "email" + # identity_claim_pattern: "^[^@]+@example\\.com$" + # identity_header: "X-HyperFleet-Identity" + # - issuer_url: "https://other-idp.example.com" + # jwk_cert_file: "/etc/hyperfleet/jwks.json" + # header: "X-Custom-Auth" # -- Database connection settings. Credentials must be provided via a # Secret — see `database.external.secretName` or use the built-in diff --git a/cmd/hyperfleet-api/environments/e_integration_testing.go b/cmd/hyperfleet-api/environments/e_integration_testing.go index 765c41d0..e5f5f272 100755 --- a/cmd/hyperfleet-api/environments/e_integration_testing.go +++ b/cmd/hyperfleet-api/environments/e_integration_testing.go @@ -38,6 +38,18 @@ func (e *integrationTestingEnvImpl) OverrideConfig(c *config.ApplicationConfig) c.Database.SSL.Mode = SSLModeDisable } + // Bootstrap a default JWT issuer for integration tests. + // JWKCertURL is overridden by the JWK mock server in test.Helper.startAPIServer(). + if c.Server.JWT.Enabled && len(c.Server.JWT.Configs) == 0 { + c.Server.JWT.Configs = []config.JWTIssuerConfig{ + { + IssuerURL: "https://test-issuer.example.com", + Header: "Authorization", + IdentityClaim: "email", + }, + } + } + return nil } diff --git a/cmd/hyperfleet-api/server/api_server.go b/cmd/hyperfleet-api/server/api_server.go index e7f5cf7d..91690682 100755 --- a/cmd/hyperfleet-api/server/api_server.go +++ b/cmd/hyperfleet-api/server/api_server.go @@ -34,10 +34,7 @@ func NewAPIServer(tracingEnabled bool) Server { if env().Config.Server.JWT.Enabled { jwtHandler, err := auth.NewJWTHandler(context.Background(), auth.JWTHandlerConfig{ - KeysFile: env().Config.Server.JWK.CertFile, - KeysURL: env().Config.Server.JWK.CertURL, - IssuerURL: env().Config.Server.JWT.IssuerURL, - Audience: env().Config.Server.JWT.Audience, + Issuers: env().Config.Server.JWT.Configs, PublicPaths: []string{ "^/api/hyperfleet/?$", "^/api/hyperfleet/v1/?$", diff --git a/cmd/hyperfleet-api/server/routes.go b/cmd/hyperfleet-api/server/routes.go index 186fad1f..12e3b406 100755 --- a/cmd/hyperfleet-api/server/routes.go +++ b/cmd/hyperfleet-api/server/routes.go @@ -87,15 +87,8 @@ func (s *apiServer) routes(tracingEnabled bool) *mux.Router { err = registerAPIMiddleware(apiV1Router) check(err, "Failed to initialize API middleware") - identityCfg := auth.CallerIdentityConfig{ - HeaderName: env().Config.Server.IdentityHeader, - } - if env().Config.Server.JWT.Enabled && env().Config.Server.JWT.IdentityClaim != "" { - identityCfg.JWTIdentityClaim = env().Config.Server.JWT.IdentityClaim - } - if identityCfg.JWTIdentityClaim != "" || identityCfg.HeaderName != "" { - callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(identityCfg) - check(mwErr, "Unable to create caller identity middleware") + if env().Config.Server.JWT.Enabled { + callerIdentityMW := auth.NewCallerIdentityMiddleware() apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity) } diff --git a/configs/config.yaml.example b/configs/config.yaml.example index 7d5f80c6..8b0c49e4 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -19,15 +19,15 @@ server: jwt: enabled: true # Enable JWT authentication - issuer_url: "" # JWT issuer URL (required when jwt.enabled=true) - audience: "" # JWT audience claim (optional) - identity_claim: email # JWT claim used as request identity for audit (e.g. email, preferred_username, sub) - - identity_header: "" # HTTP header name for caller identity; leave empty to disable (e.g. X-HyperFleet-Identity) - - jwk: - cert_file: "" # JWK certificate file path - cert_url: "" # JWK certificate URL (required when jwt.enabled=true and cert_file is not set) + configs: # List of JWT issuer configurations (required when jwt.enabled=true) + - issuer_url: "" # OIDC issuer URL for token validation (required) + jwk_cert_url: "" # URL to fetch JWK certificates from (required unless jwk_cert_file is set) + jwk_cert_file: "" # Path to a local JWK certificate file (alternative to jwk_cert_url) + header: Authorization # HTTP header to read the JWT from (default: Authorization) + audience: "" # Expected JWT audience claim (optional) + identity_claim: email # JWT claim used as request identity for audit (default: email) + identity_claim_pattern: "" # Regex pattern to validate the identity claim value (optional) + identity_header: "" # Per-issuer HTTP header for caller identity; overrides JWT claim when set (e.g. X-HyperFleet-Identity) # Database Configuration database: diff --git a/docs/api-operator-guide.md b/docs/api-operator-guide.md index 9436958d..77aabe5c 100644 --- a/docs/api-operator-guide.md +++ b/docs/api-operator-guide.md @@ -639,12 +639,13 @@ server: enabled: false # Production (with JWT authentication) +# See "Issuer configuration reference" in docs/authentication.md server: jwt: enabled: true - issuer_url: https://your-idp.example.com/auth/realms/your-realm - jwk: - cert_url: https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs + configs: + - issuer_url: https://your-idp.example.com/auth/realms/your-realm + jwk_cert_url: https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs ``` **Direct binary execution:** @@ -654,10 +655,8 @@ server: ./hyperfleet-api serve --server-jwt-enabled=false # Production (with JWT authentication) -./hyperfleet-api serve \ - --server-jwt-enabled=true \ - --server-jwt-issuer-url=https://your-idp.example.com/auth/realms/your-realm \ - --server-jwk-cert-url=https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs +# Configure issuers in a config file +./hyperfleet-api serve --config config.yaml ``` See [Authentication Guide](authentication.md) for detailed JWT setup. diff --git a/docs/authentication.md b/docs/authentication.md index 23880a78..b3980f11 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -33,30 +33,25 @@ export HYPERFLEET_SERVER_JWT_ENABLED=false ### Caller identity in development mode -When JWT is disabled and no `identity_header` is configured, caller identity resolution is inactive. Audit fields (`created_by`, `updated_by`, `deleted_by`) fall back to `system@hyperfleet.local`. +When JWT is disabled, caller identity resolution is inactive. Audit fields (`created_by`, `updated_by`, `deleted_by`) fall back to `system@hyperfleet.local`. -To get proper caller attribution without JWT, configure an identity header: +When JWT is enabled, mutating requests (POST, PATCH, PUT, DELETE) that cannot resolve a caller identity are rejected with `401 Unauthorized`. Read requests (GET, LIST) are allowed without identity. -```bash -./bin/hyperfleet-api serve \ - --server-jwt-enabled=false \ - --server-identity-header=X-HyperFleet-Identity -``` - -Then pass the header in requests: +The `identity_header` field is per-issuer and only takes effect when JWT is enabled. When configured, a trusted gateway can override the JWT claim by setting this header: ```bash -# Create with attribution +# Mutating request with identity header (JWT must still be valid) curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters \ - -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ -H "X-HyperFleet-Identity: dev-user@local" \ + -H "Content-Type: application/json" \ -d '{"kind":"Cluster","name":"my-cluster","spec":{}}' -# Read requests work without the header +# Read requests work without identity curl http://localhost:8000/api/hyperfleet/v1/clusters | jq ``` -When `identity_header` or `identity_claim` is configured, mutating requests (POST, PATCH, PUT, DELETE) that cannot resolve a caller identity are rejected with `401 Unauthorized`. Read requests (GET, LIST) are allowed without identity. +See [Caller identity for audit](#caller-identity-for-audit) below for full details. **Important**: Never disable authentication in production environments. @@ -71,14 +66,24 @@ For local development with real JWT validation, you can use Google Cloud identit ### Start the server +Configure via YAML (per-issuer JWT config, no CLI flags for configs): + +```yaml +server: + jwt: + enabled: true + configs: + - issuer_url: "https://accounts.google.com" + jwk_cert_url: "https://www.googleapis.com/oauth2/v3/certs" + header: Authorization + audience: "32555940559.apps.googleusercontent.com" + identity_claim: "email" + identity_claim_pattern: "" + identity_header: "X-HyperFleet-Identity" +``` + ```bash -./bin/hyperfleet-api serve \ - --server-jwt-enabled=true \ - --server-jwt-issuer-url="https://accounts.google.com" \ - --server-jwk-cert-url="https://www.googleapis.com/oauth2/v3/certs" \ - --server-jwt-audience="32555940559.apps.googleusercontent.com" \ - --server-jwt-identity-claim="email" \ - --server-identity-header=X-HyperFleet-Identity \ +./bin/hyperfleet-api serve --config config.yaml \ --db-host localhost --db-port 5432 --db-name hyperfleet --db-username hyperfleet ``` @@ -112,7 +117,7 @@ curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters \ Google identity tokens are standard OIDC JWTs signed by Google's keys. The server validates them like any other JWT: -1. Fetches Google's public keys from the `jwk_cert_url` +1. Fetches Google's public keys from the configured `jwk_cert_url` in the issuer config 2. Verifies the RS256 signature 3. Checks `iss` matches `https://accounts.google.com` 4. Checks `aud` matches the configured audience @@ -144,61 +149,107 @@ curl -H "Authorization: Bearer ${TOKEN}" \ HyperFleet API validates JWT tokens using RS256 signature verification. **Token validation checks:** + 1. Signature - Token signed by trusted issuer -2. Issuer - Matches configured `HYPERFLEET_SERVER_JWT_ISSUER_URL` -3. Audience - Matches configured `HYPERFLEET_SERVER_JWT_AUDIENCE` +2. Issuer - Matches one of the configured `issuer_url` values in `server.jwt.configs` +3. Audience - Matches the `audience` configured for that issuer 4. Expiration - Token not expired 5. Claims - Required claims present **Token format:** + ```text Authorization: Bearer ``` Example request: + ```bash curl -H "Authorization: Bearer ${TOKEN}" \ http://localhost:8000/api/hyperfleet/v1/clusters ``` +### Issuer configuration reference + +Each entry in `server.jwt.configs` supports the following fields: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `issuer_url` | Yes | | Expected `iss` claim for this issuer | +| `jwk_cert_url` | One of `jwk_cert_url` / `jwk_cert_file` | | JWKS endpoint URL for this issuer's public keys | +| `jwk_cert_file` | One of `jwk_cert_url` / `jwk_cert_file` | | Path to a local JWKS file | +| `header` | No | `Authorization` | HTTP header to read the JWT from | +| `audience` | No | `""` (any) | Expected `aud` claim; skipped if empty | +| `identity_claim` | No | `email` | JWT claim used as audit identity | +| `identity_claim_pattern` | No | `""` (none) | Regex the identity value must match; non-matching requests get 401 | +| `identity_header` | No | `""` (disabled) | HTTP header that overrides the JWT claim for audit identity (gateway-set only) | + +**Complete example with all fields:** + +```yaml +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 + jwk_cert_file: "" + header: Authorization + audience: "" + identity_claim: email + identity_claim_pattern: "" + identity_header: "" +``` + ## Caller identity for audit -Authentication (JWT validation) and caller identity (audit attribution) are separate concerns. Identity resolution is enabled by setting `identity_claim` (in the JWT config) and/or `identity_header`. When neither is set, no identity middleware is registered and audit fields fall back to `system@hyperfleet.local`. +Authentication (JWT validation) and caller identity (audit attribution) are separate concerns. When JWT is enabled, identity resolution is always active because `identity_claim` defaults to `email`. Audit fields (`created_by`, `updated_by`, `deleted_by`) are populated from the matched issuer's identity settings. When JWT is disabled, audit fields fall back to `system@hyperfleet.local`. | Layer | Component | Responsibility | |-------|-----------|----------------| | Outer | `JWTHandler` | Validates `Authorization: Bearer` token | | Inner | `ResolveCallerIdentity` middleware | Resolves who is recorded as the actor | -The resolved identity is written to `created_by` on create, `updated_by` on update, and `deleted_by` on delete. Precedence: identity header > JWT claim. +The resolved identity is written to `created_by` on create, `updated_by` on update, and `deleted_by` on delete. Precedence: per-issuer identity header > JWT claim. When identity resolution is configured, mutating requests (POST, PATCH, PUT, DELETE) that cannot resolve a caller identity are rejected with `401 Unauthorized`. Read requests (GET, LIST) are allowed without identity. -### JWT claim +### JWT claim (per-issuer) -Configure which JWT claim is used as the caller identity: +Configure which JWT claim is used as the caller identity for each issuer: ```yaml server: jwt: - identity_claim: email # or preferred_username, sub, etc. + configs: + - issuer_url: https://idp.example.com + jwk_cert_url: https://idp.example.com/certs + header: Authorization + audience: "" + identity_claim: email # or preferred_username, sub, etc. + identity_claim_pattern: "" + identity_header: "" ``` -### HTTP identity header (optional) +### HTTP identity header (per-issuer, optional) -When set, a trusted gateway can set the caller identity via HTTP header. **If the header is present and non-empty, it overrides the JWT claim** for audit fields. JWT validation is still required when `jwt.enabled=true`. +When `identity_header` is set on an issuer config, a trusted gateway can set the caller identity via HTTP header. **If the header is present and non-empty, it overrides the JWT claim** for audit fields. JWT validation is still required. ```yaml server: - identity_header: X-HyperFleet-Identity + jwt: + configs: + - issuer_url: https://idp.example.com + jwk_cert_url: https://idp.example.com/certs + header: Authorization + audience: "" + identity_claim: email + identity_claim_pattern: "" + identity_header: X-HyperFleet-Identity ``` **Security:** Clients must not be able to set this header directly. Configure your ingress/gateway to strip the header from external requests and set it from the authenticated upstream user. -```bash -export HYPERFLEET_SERVER_IDENTITY_HEADER=X-HyperFleet-Identity -``` - Identity values from both sources are validated: trimmed of whitespace, limited to 256 characters, and rejected if they contain control characters. ## Configuration @@ -209,10 +260,9 @@ Identity values from both sources are validated: trimmed of whitespace, limited # Development (no auth) export HYPERFLEET_SERVER_JWT_ENABLED=false -# Production (with auth) +# Production (with auth) - configure issuers in config.yaml export HYPERFLEET_SERVER_JWT_ENABLED=true -export HYPERFLEET_SERVER_JWT_ISSUER_URL=https://your-idp.example.com/auth/realms/your-realm -export HYPERFLEET_SERVER_JWT_AUDIENCE=https://your-api.example.com +# Issuer configs must be set via YAML config file (server.jwt.configs list) ``` See [Deployment](deployment.md) for complete configuration options. @@ -222,32 +272,37 @@ See [Deployment](deployment.md) for complete configuration options. Configure via Helm values: ```yaml -# values.yaml +# values.yaml — see "Issuer configuration reference" above for all fields config: server: jwt: enabled: true - issuer_url: https://your-idp.example.com/auth/realms/your-realm - audience: https://your-api.example.com + configs: + - issuer_url: https://your-idp.example.com/auth/realms/your-realm + jwk_cert_url: https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs + audience: https://your-api.example.com ``` Deploy: + ```bash helm install hyperfleet-api oci://quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api-chart: --values values.yaml ``` -> **Note:** You may also choose to install from the ./charts folder, if you've cloned this repository locally. +> **Note:** You may also choose to install from the ./charts folder, if you've cloned this repository locally. ## Troubleshooting ### Common Issues **401 Unauthorized** + - Check token is valid and not expired -- Verify `HYPERFLEET_SERVER_JWT_ISSUER_URL` and `HYPERFLEET_SERVER_JWT_AUDIENCE` match token claims +- Verify `issuer_url` and `audience` in `server.jwt.configs` match token claims - Ensure `Authorization` header is correctly formatted **Token debugging** + ```bash # Decode JWT token (header and payload only, not verified) echo $TOKEN | cut -d. -f2 | base64 -d | jq diff --git a/docs/config.md b/docs/config.md index 6fbc8bb3..20ce3d06 100644 --- a/docs/config.md +++ b/docs/config.md @@ -7,19 +7,21 @@ Complete reference for configuring HyperFleet API following the [HyperFleet Conf ## Quick Start **Development:** + ```bash # Create config.yaml with database settings, then: hyperfleet-api serve --config=config.yaml ``` **Production (Kubernetes):** + ```bash helm install hyperfleet-api oci://quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api-chart: \ --set 'config.adapters.required.cluster={validation,dns}' \ --set 'config.adapters.required.nodepool={validation}' ``` -> **Note:** You may also choose to install from the ./charts folder, if you've cloned this repository locally. +> **Note:** You may also choose to install from the ./charts folder, if you've cloned this repository locally. See [Configuration Examples](#configuration-examples) for complete setup. @@ -36,6 +38,7 @@ HyperFleet API supports multiple configuration sources: | **CLI Flags** | Quick overrides, testing | `--server-port=9000` | All configuration follows these conventions: + - **Environment variables**: `HYPERFLEET_*` prefix, uppercase, underscores (e.g., `HYPERFLEET_SERVER_PORT`) - **CLI flags**: `--kebab-case`, lowercase, hyphens (e.g., `--server-port`) - **YAML properties**: `snake_case`, lowercase, underscores (e.g., `server.port`) @@ -59,6 +62,7 @@ Configuration sources are applied in the following order (highest to lowest prio **Examples**: *Flag overrides environment variable:* + ```bash export HYPERFLEET_SERVER_PORT=8000 hyperfleet-api serve --server-port=9000 @@ -66,6 +70,7 @@ hyperfleet-api serve --server-port=9000 ``` *Environment variable overrides config file:* + ```bash # config.yaml has: database.password: "config-password" export HYPERFLEET_DATABASE_PASSWORD=secret-password @@ -89,11 +94,13 @@ See [OpenTelemetry Configuration](#opentelemetry-configuration) for details. The configuration file is resolved in the following order: 1. **`--config` flag** - Explicit path provided via CLI + ```bash hyperfleet-api serve --config=/path/to/config.yaml ``` 2. **`HYPERFLEET_CONFIG` environment variable** - Path in environment + ```bash export HYPERFLEET_CONFIG=/path/to/config.yaml ``` @@ -134,6 +141,7 @@ PostgreSQL database connection settings. | `database.debug` | bool | `false` | Enable SQL query logging | **Example:** + ```yaml database: host: postgres.example.com @@ -164,6 +172,7 @@ Specifies which adapters must be ready for resources to be marked as "Ready". Sh | `adapters.required.nodepool` | []string | `[]` | Nodepool adapters required for Ready state (e.g., `["validation"]`) | **Example:** + ```yaml adapters: required: @@ -178,6 +187,7 @@ adapters: ``` **Environment variable (JSON array format):** + ```bash export HYPERFLEET_ADAPTERS_REQUIRED_CLUSTER='["validation","dns","pullsecret","hypershift"]' export HYPERFLEET_ADAPTERS_REQUIRED_NODEPOOL='["validation","hypershift"]' @@ -196,6 +206,7 @@ Logging behavior and output settings. | `logging.masking.enabled` | bool | `true` | Enable sensitive data masking in logs | **Example:** + ```yaml logging: level: info @@ -257,14 +268,10 @@ HTTP server settings for the API endpoint. | `server.tls.cert_file` | string | `""` | Path to TLS certificate file | | `server.tls.key_file` | string | `""` | Path to TLS key file | | `server.jwt.enabled` | bool | `true` | Enable JWT authentication | -| `server.jwt.issuer_url` | string | `""` | Expected JWT issuer URL for token validation (required when JWT is enabled) | -| `server.jwt.audience` | string | `""` | Expected JWT audience claim (optional) | -| `server.jwt.identity_claim` | string | `email` | JWT claim used as request identity for audit (e.g. `email`, `preferred_username`, `sub`) | -| `server.identity_header` | string | `""` | HTTP header name for caller identity; when set and non-empty, overrides JWT claim for audit attribution | -| `server.jwk.cert_file` | string | `""` | JWK certificate file path (optional) | -| `server.jwk.cert_url` | string | `""` | JWK certificate URL (required when JWT is enabled and cert_file is not set) | +| `server.jwt.configs` | list | `[]` | YAML only. List of JWT issuer configurations (required when JWT is enabled). See [Issuer configuration reference](authentication.md#issuer-configuration-reference) for all fields and defaults. | **Example:** + ```yaml server: hostname: api.example.com @@ -276,43 +283,15 @@ server: key_file: /etc/certs/tls.key jwt: enabled: true - issuer_url: https://your-idp.example.com/auth/realms/your-realm - audience: "" - jwk: - cert_url: https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs + configs: + - issuer_url: ... # see field reference below ``` -#### Caller Identity - -The API records who performed each mutation in the `created_by`, `updated_by`, and `deleted_by` audit fields. Two settings control how the caller identity is resolved: - -| Setting | Purpose | -|---------|---------| -| `server.identity_header` | HTTP header to read the caller identity from (e.g., `X-Forwarded-Email`) | -| `server.jwt.identity_claim` | JWT claim to use as fallback (e.g., `email`, `preferred_username`, `sub`) | +See [Issuer configuration reference](authentication.md#issuer-configuration-reference) for the complete field table, defaults, and examples. -**Precedence:** If both are configured and the header is present in the request, the header value wins. The JWT claim is used only when the header is not configured or is empty in the request. +### Caller Identity -**Validation:** Identity values are trimmed, must not exceed 256 characters, and must not contain control characters. - -**Example — header-based identity (behind an authenticating proxy):** -```yaml -server: - identity_header: X-Forwarded-Email - jwt: - enabled: false -``` - -**Example — JWT-based identity:** -```yaml -server: - jwt: - enabled: true - issuer_url: https://idp.example.com/realms/hyperfleet - identity_claim: email - jwk: - cert_url: https://idp.example.com/realms/hyperfleet/protocol/openid-connect/certs -``` +See [Caller identity for audit](authentication.md#caller-identity-for-audit) for full details on identity resolution, precedence rules, and per-issuer configuration. @@ -329,6 +308,7 @@ Prometheus metrics endpoint settings. | `metrics.label_metrics_inclusion_duration` | duration | `168h` | Duration to include label metrics (7 days) | **Example:** + ```yaml metrics: host: 0.0.0.0 # Required for Kubernetes Service access @@ -353,6 +333,7 @@ Health check endpoint settings. | `health.db_ping_timeout` | duration | `2s` | Database ping timeout for readiness check | **Example:** + ```yaml health: host: 0.0.0.0 # Required for Kubernetes probes @@ -363,7 +344,6 @@ health: - --- ## Complete Reference @@ -385,12 +365,7 @@ Complete table of all configuration properties, their environment variables, and | `server.tls.cert_file` | `HYPERFLEET_SERVER_TLS_CERT_FILE` | string | `""` | | `server.tls.key_file` | `HYPERFLEET_SERVER_TLS_KEY_FILE` | string | `""` | | `server.jwt.enabled` | `HYPERFLEET_SERVER_JWT_ENABLED` | bool | `true` | -| `server.jwt.issuer_url` | `HYPERFLEET_SERVER_JWT_ISSUER_URL` | string | `""` | -| `server.jwt.audience` | `HYPERFLEET_SERVER_JWT_AUDIENCE` | string | `""` | -| `server.jwt.identity_claim` | `HYPERFLEET_SERVER_JWT_IDENTITY_CLAIM` | string | `email` | -| `server.identity_header` | `HYPERFLEET_SERVER_IDENTITY_HEADER` | string | `""` | -| `server.jwk.cert_file` | `HYPERFLEET_SERVER_JWK_CERT_FILE` | string | `""` | -| `server.jwk.cert_url` | `HYPERFLEET_SERVER_JWK_CERT_URL` | string | `""` | +| `server.jwt.configs` | (YAML only) | list | `[]` | | **Database** | | | | | `database.dialect` | `HYPERFLEET_DATABASE_DIALECT` | string | `postgres` | | `database.host` | `HYPERFLEET_DATABASE_HOST` | string | `localhost` | @@ -449,12 +424,6 @@ All CLI flags and their corresponding configuration paths. | `--server-https-cert-file` | `server.tls.cert_file` | string | | `--server-https-key-file` | `server.tls.key_file` | string | | `--server-jwt-enabled` | `server.jwt.enabled` | bool | -| `--server-jwt-issuer-url` | `server.jwt.issuer_url` | string | -| `--server-jwt-audience` | `server.jwt.audience` | string | -| `--server-jwt-identity-claim` | `server.jwt.identity_claim` | string | -| `--server-identity-header` | `server.identity_header` | string | -| `--server-jwk-cert-file` | `server.jwk.cert_file` | string | -| `--server-jwk-cert-url` | `server.jwk.cert_url` | string | | **Database** | | | | `--db-dialect` | `database.dialect` | string | | `--db-host` | `database.host` | string | @@ -489,6 +458,7 @@ All CLI flags and their corresponding configuration paths. ### Development Minimal config for local development (no authentication): + ```yaml server: jwt: @@ -530,11 +500,14 @@ The application performs comprehensive validation at startup. ### Validation Rules **Server**: + - `server.port`: 1-65535 - `server.timeouts.read`: ≥ 1s - `server.timeouts.write`: ≥ 1s +- `server.jwt.configs`: required non-empty when `server.jwt.enabled=true`; see [Issuer configuration reference](authentication.md#issuer-configuration-reference) for per-field validation rules **Database**: + - `database.host`: required - `database.port`: 1-65535 - `database.name`: required @@ -543,10 +516,12 @@ The application performs comprehensive validation at startup. - `database.ssl.mode`: must be `disable`, `require`, `verify-ca`, or `verify-full` **Logging**: + - `logging.level`: must be `debug`, `info`, `warn`, or `error` - `logging.format`: must be `json` or `text` **Adapters**: + - `adapters.required.cluster`: must be array of strings - `adapters.required.nodepool`: must be array of strings @@ -568,6 +543,7 @@ Error: Configuration validation failed: ### Configuration not loading **Check configuration file path:** + ```bash # Verify file exists ls -l /etc/hyperfleet/config.yaml @@ -582,6 +558,7 @@ hyperfleet-api serve --config=/path/to/config.yaml ### Environment variables not working **Verify variable names:** + ```bash # Check all HYPERFLEET_* variables env | grep HYPERFLEET_ @@ -598,32 +575,40 @@ export SERVER_PORT=8000 # ❌ Missing HYPERFLEET_ prefix **Common issues:** 1. **Invalid log level:** + ```text Error: Logging.Level must be one of: debug, info, warn, error ``` + Solution: Use lowercase: `info`, not `INFO` 2. **Invalid port:** + ```text Error: Server.Port must be between 1 and 65535 ``` + Solution: Check port value in config file or environment variable 3. **Missing required field:** + ```text Error: Database.Host is required ``` + Solution: Set via config file, environment variable, or CLI flag ### Debugging configuration **Enable debug logging to see configuration loading:** + ```bash export HYPERFLEET_LOGGING_LEVEL=debug hyperfleet-api serve ``` **Check effective configuration:** + ```bash # The application logs loaded configuration at startup (with secrets masked) # Look for log messages like: diff --git a/docs/deployment.md b/docs/deployment.md index 37272887..3d6b73c7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -56,7 +56,7 @@ helm install hyperfleet-api oci://quay.io/redhat-services-prod/hyperfleet-tenant --set image.tag= ``` -> **Note:** You may also choose to install from the ./charts folder, if you've cloned this repository locally. +> **Note:** You may also choose to install from the ./charts folder, if you've cloned this repository locally. **Verify:** @@ -173,19 +173,15 @@ helm install hyperfleet-api oci://quay.io/redhat-services-prod/hyperfleet-tenant --set image.repository=redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api \ --set image.tag=v1.0.0 \ --set config.server.jwt.enabled=true \ - --set config.server.jwt.issuer_url=https://your-idp.example.com/auth/realms/your-realm \ - --set config.server.jwk.cert_url=https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs + --set-json 'config.server.jwt.configs=[{"issuer_url":"https://your-idp.example.com/auth/realms/your-realm","jwk_cert_url":"https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs"}]' ``` | Value | Required when JWT enabled | Description | |-------|---------------------------|-------------| | `config.server.jwt.enabled` | Yes | Set to `true` | -| `config.server.jwt.issuer_url` | Yes | Expected JWT issuer URL for token validation | -| `config.server.jwk.cert_url` | Yes (unless `cert_file` is set) | URL to fetch JWK signing keys | -| `config.server.jwt.audience` | No | Expected JWT audience claim | -| `config.server.jwt.identity_claim` | No | JWT claim used as caller identity (default: `email`) | +| `config.server.jwt.configs` | Yes | List of issuer configs. Each requires `issuer_url` and `jwk_cert_url` or `jwk_cert_file`. See link below for all optional fields. | -See [Authentication](authentication.md) for full reference including identity header configuration and caller identity details. +See [Issuer configuration reference](authentication.md#issuer-configuration-reference) for the full field table, defaults, and [Caller identity for audit](authentication.md#caller-identity-for-audit) for identity header and claim details. --- @@ -327,9 +323,10 @@ config: server: jwt: enabled: true - issuer_url: https://your-idp.example.com/auth/realms/your-realm - jwk: - cert_url: https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs + configs: + - issuer_url: https://your-idp.example.com/auth/realms/your-realm + jwk_cert_url: https://your-idp.example.com/auth/realms/your-realm/protocol/openid-connect/certs + # See "Issuer configuration reference" in authentication.md adapters: required: @@ -432,6 +429,7 @@ kubectl get configmaps --namespace hyperfleet-system ### Health Checks The deployment includes: + - Liveness probe: `GET /healthz` (port 8080) — returns 200 if the process is alive - Readiness probe: `GET /readyz` (port 8080) — returns 200 when ready to receive traffic, 503 during startup/shutdown - Metrics: `GET /metrics` (port 9090) — Prometheus metrics endpoint @@ -508,6 +506,7 @@ Before deploying to production, ensure: The configuration file path — set via `--config` or `HYPERFLEET_CONFIG` — is a trust boundary. The API validates configuration **content** on startup (unknown fields are rejected, required values are enforced, TLS/JWT/timeout settings are checked) and will refuse to start with an invalid configuration. However, **path and permission safety is the operator's responsibility**. The API reads whatever file the process can access at the given path without checking permissions or ownership. Ensure configuration files are: + - Owned by the service account running the API (e.g., `root:root` or a dedicated user) - Mode `0600` (owner read/write only) or `0640` if group-readable access is needed - Never world-writable diff --git a/pkg/auth/auth_middleware.go b/pkg/auth/auth_middleware.go index 0e2d2338..c4c192b9 100755 --- a/pkg/auth/auth_middleware.go +++ b/pkg/auth/auth_middleware.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/validation" ) // CallerIdentityMiddleware resolves and attaches the caller identity used for audit fields. @@ -13,23 +12,18 @@ type CallerIdentityMiddleware interface { ResolveCallerIdentity(next http.Handler) http.Handler } -type callerIdentityMiddleware struct { - cfg CallerIdentityConfig -} +type callerIdentityMiddleware struct{} var _ CallerIdentityMiddleware = &callerIdentityMiddleware{} -func NewCallerIdentityMiddleware(cfg CallerIdentityConfig) (CallerIdentityMiddleware, error) { - if cfg.HeaderName != "" { - if validation.IsForbiddenIdentityHeaderName(cfg.HeaderName) { - return nil, fmt.Errorf("identity header name %q is not allowed", cfg.HeaderName) - } - } - return &callerIdentityMiddleware{cfg: cfg}, nil +func NewCallerIdentityMiddleware() CallerIdentityMiddleware { + return &callerIdentityMiddleware{} } // ResolveCallerIdentity attaches the resolved caller identity to the request context. // JWT validation is performed by JWTHandler; this middleware only resolves attribution. +// The matched issuer config (identity_claim, identity_claim_pattern) is read from context. +// If an identity header is configured, it takes precedence over JWT claims. func (m *callerIdentityMiddleware) ResolveCallerIdentity(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if shouldSkipCallerIdentity(r.URL.Path) { @@ -38,7 +32,7 @@ func (m *callerIdentityMiddleware) ResolveCallerIdentity(next http.Handler) http } ctx := r.Context() - identity, err := CallerIdentityFromRequest(ctx, r, m.cfg) + identity, err := CallerIdentityFromRequest(ctx, r) if identity != "" { ctx = SetUsernameContext(ctx, identity) diff --git a/pkg/auth/context.go b/pkg/auth/context.go index ccc124a4..e0200e51 100755 --- a/pkg/auth/context.go +++ b/pkg/auth/context.go @@ -6,16 +6,16 @@ import ( "strings" "github.com/golang-jwt/jwt/v5" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" ) type contextKey string const ( - ContextUsernameKey contextKey = "username" - ContextJWTTokenKey contextKey = "jwt_token" - - // DefaultJWTIdentityClaim is used when server.jwt.identity_claim is unset. - DefaultJWTIdentityClaim = "email" + ContextUsernameKey contextKey = "username" + ContextJWTTokenKey contextKey = "jwt_token" + ContextJWTIssuerConfigKey contextKey = "jwt_issuer_config" ) // Payload defines the structure of the JWT payload we expect @@ -47,6 +47,15 @@ func SetJWTTokenContext(ctx context.Context, token *jwt.Token) context.Context { return context.WithValue(ctx, ContextJWTTokenKey, token) } +func SetJWTIssuerConfigContext(ctx context.Context, cfg config.JWTIssuerConfig) context.Context { + return context.WithValue(ctx, ContextJWTIssuerConfigKey, cfg) +} + +func GetJWTIssuerConfigFromContext(ctx context.Context) (config.JWTIssuerConfig, bool) { + cfg, ok := ctx.Value(ContextJWTIssuerConfigKey).(config.JWTIssuerConfig) + return cfg, ok +} + func GetJWTTokenFromContext(ctx context.Context) *jwt.Token { token, ok := ctx.Value(ContextJWTTokenKey).(*jwt.Token) if !ok { @@ -122,7 +131,7 @@ func GetAuthPayloadFromContext(ctx context.Context) (*Payload, error) { // GetIdentityFromContext returns the configured JWT claim value used as the request identity. func GetIdentityFromContext(ctx context.Context, identityClaim string) (string, error) { if identityClaim == "" { - identityClaim = DefaultJWTIdentityClaim + identityClaim = config.DefaultJWTIdentityClaim } userToken := GetJWTTokenFromContext(ctx) diff --git a/pkg/auth/identity.go b/pkg/auth/identity.go index d5a17140..e7aa7bbb 100644 --- a/pkg/auth/identity.go +++ b/pkg/auth/identity.go @@ -5,46 +5,64 @@ import ( "fmt" "net/http" "strings" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" ) const maxCallerIdentityLen = 256 -// CallerIdentityConfig controls how the caller identity is resolved for audit fields. -// Identity resolution is enabled by setting the relevant fields: -// - HeaderName: when non-empty, the named HTTP header is checked first -// - JWTIdentityClaim: when non-empty, the JWT claim is used as fallback (or primary when no header is configured) -type CallerIdentityConfig struct { - JWTIdentityClaim string - HeaderName string -} +// CallerIdentityFromRequest resolves the caller identity. +// Priority: identity header (per-issuer, if configured and present) > JWT claim from matched issuer config. +// The issuer config is set in context by JWTHandler when a token is validated. +func CallerIdentityFromRequest(ctx context.Context, r *http.Request) (string, error) { + issuerCfg, ok := GetJWTIssuerConfigFromContext(ctx) + if !ok { + return "", nil + } -// CallerIdentityFromRequest resolves the caller identity with header-primary precedence. -// When the identity header is configured and present, it overrides the JWT claim. -// Both header and JWT identity values are normalized: trimmed, length-checked, and -// validated for control characters before being accepted. -func CallerIdentityFromRequest(ctx context.Context, r *http.Request, cfg CallerIdentityConfig) (string, error) { - if cfg.HeaderName != "" { - raw := r.Header.Get(cfg.HeaderName) - if raw != "" { - identity, err := normalizeIdentity(raw, "identity header") - if err != nil { + if issuerCfg.IdentityHeader != "" { + headerVal := r.Header.Get(issuerCfg.IdentityHeader) + identity, err := normalizeIdentity(headerVal, fmt.Sprintf("header %q", issuerCfg.IdentityHeader)) + if err != nil { + return "", err + } + if identity != "" { + if err := matchIdentityPattern(identity, "identity header value", issuerCfg); err != nil { return "", err } - if identity != "" { - return identity, nil - } + return identity, nil } } - if cfg.JWTIdentityClaim != "" { - raw, err := GetIdentityFromContext(ctx, cfg.JWTIdentityClaim) - if err != nil { + identityClaim := issuerCfg.IdentityClaim + if identityClaim == "" { + return "", nil + } + + raw, err := GetIdentityFromContext(ctx, identityClaim) + if err != nil { + return "", err + } + + identity, err := normalizeIdentity(raw, fmt.Sprintf("JWT claim %q", identityClaim)) + if err != nil { + return "", err + } + + if identity != "" { + if err := matchIdentityPattern(identity, "identity claim value", issuerCfg); err != nil { return "", err } - return normalizeIdentity(raw, fmt.Sprintf("JWT claim %q", cfg.JWTIdentityClaim)) } - return "", nil + return identity, nil +} + +func matchIdentityPattern(identity, source string, cfg config.JWTIssuerConfig) error { + if cfg.CompiledPattern != nil && !cfg.CompiledPattern.MatchString(identity) { + return fmt.Errorf("%s does not match required pattern", source) + } + return nil } // normalizeIdentity trims, length-checks, and validates a caller identity value diff --git a/pkg/auth/identity_test.go b/pkg/auth/identity_test.go index 9d6c7a7c..275cc951 100644 --- a/pkg/auth/identity_test.go +++ b/pkg/auth/identity_test.go @@ -1,124 +1,139 @@ package auth import ( + "context" "net/http" "net/http/httptest" + "regexp" "strings" "testing" "github.com/golang-jwt/jwt/v5" . "github.com/onsi/gomega" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" ) func TestCallerIdentityFromRequest(t *testing.T) { tests := []struct { claims jwt.MapClaims + issuerCfg *config.JWTIssuerConfig name string - headerValue string want string - cfg CallerIdentityConfig + headerValue string setHeader bool wantErr bool }{ { - name: "header overrides JWT claim", - claims: jwt.MapClaims{"email": "jwt@example.com"}, - setHeader: true, - headerValue: "gateway-user@example.com", - cfg: CallerIdentityConfig{ - JWTIdentityClaim: "email", - HeaderName: "X-HyperFleet-Identity", + name: "resolves identity from JWT email claim", + claims: jwt.MapClaims{"email": "jwt@example.com"}, + issuerCfg: &config.JWTIssuerConfig{ + IdentityClaim: "email", }, - want: "gateway-user@example.com", + want: "jwt@example.com", }, { - name: "falls back to JWT when header absent", + name: "resolves identity from custom claim", + claims: jwt.MapClaims{"sub": "subject-id", "email": "jwt@example.com"}, + issuerCfg: &config.JWTIssuerConfig{ + IdentityClaim: "sub", + }, + want: "subject-id", + }, + { + name: "no issuer config in context returns empty", + claims: jwt.MapClaims{"email": "jwt@example.com"}, + issuerCfg: nil, + want: "", + }, + { + name: "empty identity_claim returns empty", claims: jwt.MapClaims{"email": "jwt@example.com"}, - cfg: CallerIdentityConfig{ - JWTIdentityClaim: "email", - HeaderName: "X-HyperFleet-Identity", + issuerCfg: &config.JWTIssuerConfig{ + IdentityClaim: "", }, - want: "jwt@example.com", + want: "", }, { - name: "rejects invalid header value", - setHeader: true, - headerValue: "bad\x00value", - cfg: CallerIdentityConfig{ - HeaderName: "X-HyperFleet-Identity", + name: "missing claim returns error", + claims: jwt.MapClaims{"email": "jwt@example.com"}, + issuerCfg: &config.JWTIssuerConfig{ + IdentityClaim: "missing_claim", }, wantErr: true, }, { - name: "rejects header value exceeding max length", - setHeader: true, - headerValue: strings.Repeat("a", maxCallerIdentityLen+1), - cfg: CallerIdentityConfig{ - HeaderName: "X-HyperFleet-Identity", + name: "rejects oversized JWT claim value", + claims: jwt.MapClaims{"email": strings.Repeat("x", maxCallerIdentityLen+1)}, + issuerCfg: &config.JWTIssuerConfig{ + IdentityClaim: "email", }, wantErr: true, }, { - name: "header only without JWT claim", - setHeader: true, - headerValue: "dev-user", - cfg: CallerIdentityConfig{ - HeaderName: "X-HyperFleet-Identity", + name: "trims whitespace from JWT claim value", + claims: jwt.MapClaims{"email": " user@example.com "}, + issuerCfg: &config.JWTIssuerConfig{ + IdentityClaim: "email", }, - want: "dev-user", + want: "user@example.com", }, { - name: "no resolution when nothing configured", - claims: jwt.MapClaims{"email": "jwt@example.com"}, - cfg: CallerIdentityConfig{}, - want: "", + name: "identity_claim_pattern match passes", + claims: jwt.MapClaims{"email": "user@example.com"}, + issuerCfg: &config.JWTIssuerConfig{ + IdentityClaim: "email", + IdentityClaimPattern: `^[^@]+@example\.com$`, + CompiledPattern: regexp.MustCompile(`^[^@]+@example\.com$`), + }, + want: "user@example.com", }, { - name: "empty header falls back to JWT", + name: "identity_claim_pattern mismatch returns error", + claims: jwt.MapClaims{"email": "user@other.com"}, + issuerCfg: &config.JWTIssuerConfig{ + IdentityClaim: "email", + IdentityClaimPattern: `^[^@]+@example\.com$`, + CompiledPattern: regexp.MustCompile(`^[^@]+@example\.com$`), + }, + wantErr: true, + }, + { + name: "header overrides JWT claim", claims: jwt.MapClaims{"email": "jwt@example.com"}, setHeader: true, - headerValue: "", - cfg: CallerIdentityConfig{ - JWTIdentityClaim: "email", - HeaderName: "X-HyperFleet-Identity", - }, - want: "jwt@example.com", + headerValue: "gateway-user@example.com", + issuerCfg: &config.JWTIssuerConfig{IdentityClaim: "email", IdentityHeader: "X-HyperFleet-Identity"}, + want: "gateway-user@example.com", }, { - name: "whitespace-only header falls back to JWT", + name: "empty header falls back to JWT", claims: jwt.MapClaims{"email": "jwt@example.com"}, setHeader: true, - headerValue: " ", - cfg: CallerIdentityConfig{ - JWTIdentityClaim: "email", - HeaderName: "X-HyperFleet-Identity", - }, - want: "jwt@example.com", + headerValue: "", + issuerCfg: &config.JWTIssuerConfig{IdentityClaim: "email", IdentityHeader: "X-HyperFleet-Identity"}, + want: "jwt@example.com", }, { - name: "header at exact max length accepted", + name: "rejects invalid header value", setHeader: true, - headerValue: strings.Repeat("a", maxCallerIdentityLen), - cfg: CallerIdentityConfig{ - HeaderName: "X-HyperFleet-Identity", - }, - want: strings.Repeat("a", maxCallerIdentityLen), + headerValue: "bad\x00value", + issuerCfg: &config.JWTIssuerConfig{IdentityHeader: "X-HyperFleet-Identity"}, + wantErr: true, }, { - name: "rejects oversized JWT claim value", - claims: jwt.MapClaims{"email": strings.Repeat("x", maxCallerIdentityLen+1)}, - cfg: CallerIdentityConfig{ - JWTIdentityClaim: "email", - }, - wantErr: true, + name: "rejects header value exceeding max length", + setHeader: true, + headerValue: strings.Repeat("a", maxCallerIdentityLen+1), + issuerCfg: &config.JWTIssuerConfig{IdentityHeader: "X-HyperFleet-Identity"}, + wantErr: true, }, { - name: "trims whitespace from JWT claim value", - claims: jwt.MapClaims{"email": " user@example.com "}, - cfg: CallerIdentityConfig{ - JWTIdentityClaim: "email", - }, - want: "user@example.com", + name: "header at exact max length accepted", + setHeader: true, + headerValue: strings.Repeat("a", maxCallerIdentityLen), + issuerCfg: &config.JWTIssuerConfig{IdentityHeader: "X-HyperFleet-Identity"}, + want: strings.Repeat("a", maxCallerIdentityLen), }, } @@ -126,18 +141,19 @@ func TestCallerIdentityFromRequest(t *testing.T) { t.Run(tc.name, func(t *testing.T) { RegisterTestingT(t) r := httptest.NewRequest(http.MethodGet, "/api/hyperfleet/v1/clusters", nil) + ctx := r.Context() if tc.claims != nil { - r = r.WithContext(contextWithClaims(tc.claims)) + ctx = contextWithClaims(tc.claims) } - if tc.setHeader { - headerName := tc.cfg.HeaderName - if headerName == "" { - headerName = "X-HyperFleet-Identity" - } - r.Header.Set(headerName, tc.headerValue) + if tc.issuerCfg != nil { + ctx = SetJWTIssuerConfigContext(ctx, *tc.issuerCfg) + } + r = r.WithContext(ctx) + if tc.setHeader && tc.issuerCfg != nil { + r.Header.Set(tc.issuerCfg.IdentityHeader, tc.headerValue) } - identity, err := CallerIdentityFromRequest(r.Context(), r, tc.cfg) + identity, err := CallerIdentityFromRequest(r.Context(), r) if tc.wantErr { Expect(err).To(HaveOccurred()) return @@ -151,28 +167,9 @@ func TestCallerIdentityFromRequest(t *testing.T) { func TestNewCallerIdentityMiddleware(t *testing.T) { RegisterTestingT(t) - t.Run("rejects forbidden header name", func(t *testing.T) { - RegisterTestingT(t) - _, err := NewCallerIdentityMiddleware(CallerIdentityConfig{ - HeaderName: "Authorization", - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not allowed")) - }) - - t.Run("returns middleware when header validation passes", func(t *testing.T) { - RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{ - HeaderName: "X-HyperFleet-Identity", - }) - Expect(err).NotTo(HaveOccurred()) - Expect(mw).NotTo(BeNil()) - }) - - t.Run("returns middleware with no config", func(t *testing.T) { + t.Run("returns middleware", func(t *testing.T) { RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{}) - Expect(err).NotTo(HaveOccurred()) + mw := NewCallerIdentityMiddleware() Expect(mw).NotTo(BeNil()) }) } @@ -180,10 +177,27 @@ func TestNewCallerIdentityMiddleware(t *testing.T) { func TestResolveCallerIdentityMiddleware(t *testing.T) { RegisterTestingT(t) + issuerCfg := config.JWTIssuerConfig{IdentityClaim: "email"} + issuerCfgWithHeader := config.JWTIssuerConfig{ + IdentityClaim: "email", + IdentityHeader: "X-HyperFleet-Identity", + } + + contextWithIssuerAndClaims := func(claims jwt.MapClaims) context.Context { + ctx := contextWithClaims(claims) + return SetJWTIssuerConfigContext(ctx, issuerCfg) + } + + contextWithIssuerHeaderAndClaims := func(claims jwt.MapClaims) context.Context { + ctx := contextWithClaims(claims) + return SetJWTIssuerConfigContext(ctx, issuerCfgWithHeader) + } + + newMW := NewCallerIdentityMiddleware + t.Run("skips openapi paths", func(t *testing.T) { RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{JWTIdentityClaim: "email"}) - Expect(err).NotTo(HaveOccurred()) + mw := newMW() called := false next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -201,8 +215,7 @@ func TestResolveCallerIdentityMiddleware(t *testing.T) { t.Run("allows GET without identity", func(t *testing.T) { RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{JWTIdentityClaim: "email"}) - Expect(err).NotTo(HaveOccurred()) + mw := newMW() called := false next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -220,8 +233,7 @@ func TestResolveCallerIdentityMiddleware(t *testing.T) { t.Run("returns 401 on POST without identity", func(t *testing.T) { RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{JWTIdentityClaim: "email"}) - Expect(err).NotTo(HaveOccurred()) + mw := newMW() nextCalled := false next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { @@ -238,8 +250,7 @@ func TestResolveCallerIdentityMiddleware(t *testing.T) { t.Run("returns 401 on PATCH without identity", func(t *testing.T) { RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{HeaderName: "X-HyperFleet-Identity"}) - Expect(err).NotTo(HaveOccurred()) + mw := newMW() nextCalled := false next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { @@ -256,8 +267,7 @@ func TestResolveCallerIdentityMiddleware(t *testing.T) { t.Run("returns 401 on DELETE without identity", func(t *testing.T) { RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{HeaderName: "X-HyperFleet-Identity"}) - Expect(err).NotTo(HaveOccurred()) + mw := newMW() nextCalled := false next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { @@ -272,19 +282,35 @@ func TestResolveCallerIdentityMiddleware(t *testing.T) { Expect(nextCalled).To(BeFalse()) }) - t.Run("allows POST with identity from header", func(t *testing.T) { + t.Run("returns 401 on PUT without identity", func(t *testing.T) { RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{HeaderName: "X-HyperFleet-Identity"}) - Expect(err).NotTo(HaveOccurred()) + mw := newMW() + + nextCalled := false + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + nextCalled = true + }) + + r := httptest.NewRequest(http.MethodPut, "/api/hyperfleet/v1/clusters/123", nil) + w := httptest.NewRecorder() + mw.ResolveCallerIdentity(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(nextCalled).To(BeFalse()) + }) + + t.Run("allows POST with identity from JWT", func(t *testing.T) { + RegisterTestingT(t) + mw := newMW() called := false next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true - Expect(GetUsernameFromContext(r.Context())).To(Equal("user@example.com")) + Expect(GetUsernameFromContext(r.Context())).To(Equal("jwt@example.com")) }) r := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/clusters", nil) - r.Header.Set("X-HyperFleet-Identity", "user@example.com") + r = r.WithContext(contextWithIssuerAndClaims(jwt.MapClaims{"email": "jwt@example.com"})) w := httptest.NewRecorder() mw.ResolveCallerIdentity(next).ServeHTTP(w, r) @@ -292,50 +318,49 @@ func TestResolveCallerIdentityMiddleware(t *testing.T) { Expect(w.Code).To(Equal(http.StatusOK)) }) - t.Run("returns 401 on PUT without identity", func(t *testing.T) { + t.Run("allows POST with identity from header", func(t *testing.T) { RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{HeaderName: "X-HyperFleet-Identity"}) - Expect(err).NotTo(HaveOccurred()) + mw := newMW() - nextCalled := false - next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { - nextCalled = true + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + Expect(GetUsernameFromContext(r.Context())).To(Equal("user@example.com")) }) - r := httptest.NewRequest(http.MethodPut, "/api/hyperfleet/v1/clusters/123", nil) + r := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/clusters", nil) + r = r.WithContext(SetJWTIssuerConfigContext(r.Context(), issuerCfgWithHeader)) + r.Header.Set("X-HyperFleet-Identity", "user@example.com") w := httptest.NewRecorder() mw.ResolveCallerIdentity(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(nextCalled).To(BeFalse()) + Expect(called).To(BeTrue()) + Expect(w.Code).To(Equal(http.StatusOK)) }) - t.Run("returns 401 on POST with oversized header", func(t *testing.T) { + t.Run("header identity takes precedence over JWT on POST", func(t *testing.T) { RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{HeaderName: "X-HyperFleet-Identity"}) - Expect(err).NotTo(HaveOccurred()) + mw := newMW() - nextCalled := false - next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { - nextCalled = true + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + Expect(GetUsernameFromContext(r.Context())).To(Equal("header@gateway.com")) }) r := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/clusters", nil) - r.Header.Set("X-HyperFleet-Identity", strings.Repeat("a", maxCallerIdentityLen+1)) + r = r.WithContext(contextWithIssuerHeaderAndClaims(jwt.MapClaims{"email": "jwt@example.com"})) + r.Header.Set("X-HyperFleet-Identity", "header@gateway.com") w := httptest.NewRecorder() mw.ResolveCallerIdentity(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(nextCalled).To(BeFalse()) + Expect(called).To(BeTrue()) + Expect(w.Code).To(Equal(http.StatusOK)) }) t.Run("POST with empty header falls back to JWT", func(t *testing.T) { RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{ - JWTIdentityClaim: "email", - HeaderName: "X-HyperFleet-Identity", - }) - Expect(err).NotTo(HaveOccurred()) + mw := newMW() called := false next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -344,7 +369,7 @@ func TestResolveCallerIdentityMiddleware(t *testing.T) { }) r := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/clusters", nil) - r = r.WithContext(contextWithClaims(jwt.MapClaims{"email": "jwt@example.com"})) + r = r.WithContext(contextWithIssuerHeaderAndClaims(jwt.MapClaims{"email": "jwt@example.com"})) r.Header.Set("X-HyperFleet-Identity", "") w := httptest.NewRecorder() mw.ResolveCallerIdentity(next).ServeHTTP(w, r) @@ -353,27 +378,42 @@ func TestResolveCallerIdentityMiddleware(t *testing.T) { Expect(w.Code).To(Equal(http.StatusOK)) }) - t.Run("header identity takes precedence over JWT on POST", func(t *testing.T) { + t.Run("returns 401 on POST with oversized header", func(t *testing.T) { RegisterTestingT(t) - mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{ - JWTIdentityClaim: "email", - HeaderName: "X-HyperFleet-Identity", + mw := newMW() + + nextCalled := false + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + nextCalled = true }) - Expect(err).NotTo(HaveOccurred()) - called := false - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called = true - Expect(GetUsernameFromContext(r.Context())).To(Equal("header@gateway.com")) + r := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/clusters", nil) + r = r.WithContext(SetJWTIssuerConfigContext(r.Context(), issuerCfgWithHeader)) + r.Header.Set("X-HyperFleet-Identity", strings.Repeat("a", maxCallerIdentityLen+1)) + w := httptest.NewRecorder() + mw.ResolveCallerIdentity(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(nextCalled).To(BeFalse()) + }) + + t.Run("returns 401 on POST with oversized JWT claim", func(t *testing.T) { + RegisterTestingT(t) + mw := newMW() + + nextCalled := false + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + nextCalled = true }) r := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/clusters", nil) - r = r.WithContext(contextWithClaims(jwt.MapClaims{"email": "jwt@example.com"})) - r.Header.Set("X-HyperFleet-Identity", "header@gateway.com") + r = r.WithContext(contextWithIssuerAndClaims(jwt.MapClaims{ + "email": strings.Repeat("a", maxCallerIdentityLen+1), + })) w := httptest.NewRecorder() mw.ResolveCallerIdentity(next).ServeHTTP(w, r) - Expect(called).To(BeTrue()) - Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(nextCalled).To(BeFalse()) }) } diff --git a/pkg/auth/jwt_handler.go b/pkg/auth/jwt_handler.go index 76657991..a643672d 100644 --- a/pkg/auth/jwt_handler.go +++ b/pkg/auth/jwt_handler.go @@ -15,6 +15,7 @@ import ( "github.com/MicahParks/keyfunc/v3" "github.com/golang-jwt/jwt/v5" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" hferrors "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) @@ -26,20 +27,56 @@ const ( type JWTHandlerConfig struct { Next http.Handler - KeysFile string - KeysURL string - IssuerURL string - Audience string + Issuers []config.JWTIssuerConfig PublicPaths []string } +// issuerValidator holds the pre-built keyfunc and parser for a single JWT issuer. +type issuerValidator struct { + keyfunc keyfunc.Keyfunc + parser *jwt.Parser + header string + issuerCfg config.JWTIssuerConfig +} + func NewJWTHandler(ctx context.Context, cfg JWTHandlerConfig) (*JWTHandler, error) { + if len(cfg.Issuers) == 0 { + return nil, fmt.Errorf("at least one issuer config is required") + } + + jwtCfg := config.JWTConfig{Enabled: true, Configs: cfg.Issuers} + jwtCfg.ApplyDefaults() + if err := jwtCfg.Validate(); err != nil { + return nil, fmt.Errorf("issuer config validation failed: %w", err) + } + cfg.Issuers = jwtCfg.Configs + ctx, cancel := context.WithCancel(ctx) - kf, err := buildKeyfunc(ctx, cfg) - if err != nil { - cancel() - return nil, fmt.Errorf("failed to build JWKS keyfunc: %w", err) + validators := make([]issuerValidator, 0, len(cfg.Issuers)) + for i, issuer := range cfg.Issuers { + kf, err := buildKeyfunc(ctx, issuer) + if err != nil { + cancel() + return nil, fmt.Errorf("failed to build JWKS keyfunc for issuer %d (%s): %w", i, issuer.IssuerURL, err) + } + + parserOpts := []jwt.ParserOption{ + jwt.WithValidMethods([]string{defaultSigningAlgorithm}), + jwt.WithExpirationRequired(), + jwt.WithLeeway(defaultLeeway), + jwt.WithIssuer(issuer.IssuerURL), + } + if issuer.Audience != "" { + parserOpts = append(parserOpts, jwt.WithAudience(issuer.Audience)) + } + + validators = append(validators, issuerValidator{ + keyfunc: kf, + parser: jwt.NewParser(parserOpts...), + header: issuer.Header, + issuerCfg: issuer, + }) } publicPatterns := make([]*regexp.Regexp, 0, len(cfg.PublicPaths)) @@ -52,23 +89,8 @@ func NewJWTHandler(ctx context.Context, cfg JWTHandlerConfig) (*JWTHandler, erro publicPatterns = append(publicPatterns, re) } - parserOpts := []jwt.ParserOption{ - jwt.WithValidMethods([]string{defaultSigningAlgorithm}), - jwt.WithExpirationRequired(), - jwt.WithLeeway(defaultLeeway), - } - if cfg.IssuerURL != "" { - parserOpts = append(parserOpts, jwt.WithIssuer(cfg.IssuerURL)) - } else { - logger.Warn(ctx, "JWT issuer validation disabled: no issuer_url configured") - } - if cfg.Audience != "" { - parserOpts = append(parserOpts, jwt.WithAudience(cfg.Audience)) - } - return &JWTHandler{ - keyfunc: kf, - parser: jwt.NewParser(parserOpts...), + validators: validators, publicPatterns: publicPatterns, next: cfg.Next, cancel: cancel, @@ -78,9 +100,8 @@ func NewJWTHandler(ctx context.Context, cfg JWTHandlerConfig) (*JWTHandler, erro // JWTHandler validates JWT tokens on incoming requests. Call Close() during // shutdown to stop the background JWKS refresh goroutine. type JWTHandler struct { - keyfunc keyfunc.Keyfunc + validators []issuerValidator next http.Handler - parser *jwt.Parser cancel context.CancelFunc publicPatterns []*regexp.Regexp } @@ -99,65 +120,85 @@ func (h *JWTHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - handleError(r.Context(), w, r, hferrors.CodeAuthNoCredentials, "missing Authorization header") - return - } + // Try each issuer's validator: check its header, extract Bearer token, validate + var lastErr error + sawNonBearer := false + for _, v := range h.validators { + headerVal := r.Header.Get(v.header) + if headerVal == "" { + continue + } + + parts := strings.Fields(headerVal) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + sawNonBearer = true + continue + } - parts := strings.Fields(authHeader) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - handleError(r.Context(), w, r, hferrors.CodeAuthInvalidCredentials, "Authorization header must use Bearer scheme") + token, err := v.parser.Parse(parts[1], v.keyfunc.Keyfunc) + if err != nil { + if !errors.Is(lastErr, jwt.ErrTokenExpired) { + lastErr = err + } + continue + } + + ctx := SetJWTTokenContext(r.Context(), token) + ctx = SetJWTIssuerConfigContext(ctx, v.issuerCfg) + h.next.ServeHTTP(w, r.WithContext(ctx)) return } - tokenString := parts[1] - token, err := h.parser.Parse(tokenString, h.keyfunc.Keyfunc) - if err != nil { - logger.WithError(r.Context(), err).Warn("JWT validation failed") - if errors.Is(err, jwt.ErrTokenExpired) { + // No validator matched — return the most appropriate error + if lastErr != nil { + logger.WithError(r.Context(), lastErr).Warn("JWT validation failed") + 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") + handleError(r.Context(), w, r, hferrors.CodeAuthInvalidCredentials, "invalid JWT token") } return } - ctx := SetJWTTokenContext(r.Context(), token) - h.next.ServeHTTP(w, r.WithContext(ctx)) + if sawNonBearer { + handleError(r.Context(), w, r, hferrors.CodeAuthInvalidCredentials, "authorization header does not use Bearer scheme") + return + } + + handleError(r.Context(), w, r, hferrors.CodeAuthNoCredentials, "missing authorization header") } -func buildKeyfunc(ctx context.Context, cfg JWTHandlerConfig) (keyfunc.Keyfunc, error) { - hasFile := cfg.KeysFile != "" - hasURL := cfg.KeysURL != "" +func buildKeyfunc(ctx context.Context, issuer config.JWTIssuerConfig) (keyfunc.Keyfunc, error) { + hasFile := issuer.JWKCertFile != "" + hasURL := issuer.JWKCertURL != "" if !hasFile && !hasURL { - return nil, fmt.Errorf("at least one of KeysFile or KeysURL must be provided") + return nil, fmt.Errorf("at least one of jwk_cert_file or jwk_cert_url must be provided") } if hasFile && !hasURL { - data, err := os.ReadFile(cfg.KeysFile) + data, err := os.ReadFile(issuer.JWKCertFile) if err != nil { - return nil, fmt.Errorf("failed to read JWKS file %q: %w", cfg.KeysFile, err) + return nil, fmt.Errorf("failed to read JWKS file %q: %w", issuer.JWKCertFile, err) } kf, err := keyfunc.NewJWKSetJSON(json.RawMessage(data)) if err != nil { - return nil, fmt.Errorf("failed to parse JWKS file %q: %w", cfg.KeysFile, err) + return nil, fmt.Errorf("failed to parse JWKS file %q: %w", issuer.JWKCertFile, err) } return kf, nil } if !hasFile && hasURL { - kf, err := keyfunc.NewDefaultCtx(ctx, []string{cfg.KeysURL}) + kf, err := keyfunc.NewDefaultCtx(ctx, []string{issuer.JWKCertURL}) if err != nil { - return nil, fmt.Errorf("failed to create JWKS client from URL %q: %w", cfg.KeysURL, err) + return nil, fmt.Errorf("failed to create JWKS client from URL %q: %w", issuer.JWKCertURL, err) } return kf, nil } - data, err := os.ReadFile(cfg.KeysFile) + data, err := os.ReadFile(issuer.JWKCertFile) if err != nil { - return nil, fmt.Errorf("failed to read JWKS file %q: %w", cfg.KeysFile, err) + return nil, fmt.Errorf("failed to read JWKS file %q: %w", issuer.JWKCertFile, err) } fileKF, err := keyfunc.NewJWKSetJSON(json.RawMessage(data)) if err != nil { @@ -167,7 +208,7 @@ func buildKeyfunc(ctx context.Context, cfg JWTHandlerConfig) (keyfunc.Keyfunc, e httpStorage, err := jwkset.NewHTTPClient(jwkset.HTTPClientOptions{ Given: fileKF.Storage(), HTTPURLs: map[string]jwkset.Storage{ - cfg.KeysURL: jwkset.NewMemoryStorage(), + issuer.JWKCertURL: jwkset.NewMemoryStorage(), }, }) if err != nil { diff --git a/pkg/auth/jwt_handler_test.go b/pkg/auth/jwt_handler_test.go index 958d200b..6debb1af 100644 --- a/pkg/auth/jwt_handler_test.go +++ b/pkg/auth/jwt_handler_test.go @@ -15,6 +15,8 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/mendsley/gojwk" . "github.com/onsi/gomega" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" ) const testKID = "test-key-1" @@ -34,8 +36,10 @@ func TestJWTHandler(t *testing.T) { }) handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ - KeysURL: jwksServer.URL, - IssuerURL: "https://test-issuer.example.com", + Issuers: []config.JWTIssuerConfig{{ + IssuerURL: "https://test-issuer.example.com", + JWKCertURL: jwksServer.URL, + }}, PublicPaths: []string{"^/healthz$", "^/openapi$"}, Next: nextHandler, }) @@ -64,9 +68,11 @@ func TestJWTHandler(t *testing.T) { w.WriteHeader(http.StatusOK) }) h, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ - KeysURL: jwksServer.URL, - IssuerURL: "https://test-issuer.example.com", - Next: claimsHandler, + Issuers: []config.JWTIssuerConfig{{ + IssuerURL: "https://test-issuer.example.com", + JWKCertURL: jwksServer.URL, + }}, + Next: claimsHandler, }) Expect(err).NotTo(HaveOccurred()) @@ -185,8 +191,10 @@ func TestJWTHandler_FailClosed_NoValidKeys(t *testing.T) { defer badServer.Close() handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ - KeysURL: badServer.URL, - IssuerURL: "https://test-issuer.example.com", + Issuers: []config.JWTIssuerConfig{{ + IssuerURL: "https://test-issuer.example.com", + JWKCertURL: badServer.URL, + }}, Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), @@ -201,15 +209,14 @@ func TestJWTHandler_FailClosed_NoValidKeys(t *testing.T) { Expect(rr.Code).To(Equal(http.StatusUnauthorized)) } -func TestJWTHandler_RequiresKeysConfig(t *testing.T) { +func TestJWTHandler_RequiresIssuersConfig(t *testing.T) { RegisterTestingT(t) _, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ - IssuerURL: "https://test-issuer.example.com", - Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), + Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), }) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("KeysFile or KeysURL")) + Expect(err.Error()).To(ContainSubstring("at least one issuer")) } func TestJWTHandler_WithAudience(t *testing.T) { @@ -222,9 +229,11 @@ func TestJWTHandler_WithAudience(t *testing.T) { defer jwksServer.Close() handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ - KeysURL: jwksServer.URL, - IssuerURL: "https://test-issuer.example.com", - Audience: "my-api", + Issuers: []config.JWTIssuerConfig{{ + IssuerURL: "https://test-issuer.example.com", + JWKCertURL: jwksServer.URL, + Audience: "my-api", + }}, Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), @@ -264,8 +273,10 @@ func TestJWTHandler_WithoutAudience_AcceptsAny(t *testing.T) { defer jwksServer.Close() handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ - KeysURL: jwksServer.URL, - IssuerURL: "https://test-issuer.example.com", + Issuers: []config.JWTIssuerConfig{{ + IssuerURL: "https://test-issuer.example.com", + JWKCertURL: jwksServer.URL, + }}, Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), @@ -290,8 +301,10 @@ func TestJWTHandler_FileOnlyKeyfunc(t *testing.T) { jwksFile := writeJWKSFile(t, &privateKey.PublicKey) handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ - KeysFile: jwksFile, - IssuerURL: "https://test-issuer.example.com", + Issuers: []config.JWTIssuerConfig{{ + IssuerURL: "https://test-issuer.example.com", + JWKCertFile: jwksFile, + }}, Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, "ok") @@ -336,9 +349,11 @@ func TestJWTHandler_CombinedKeyfunc(t *testing.T) { defer jwksServer.Close() handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ - KeysFile: jwksFile, - KeysURL: jwksServer.URL, - IssuerURL: "https://test-issuer.example.com", + Issuers: []config.JWTIssuerConfig{{ + IssuerURL: "https://test-issuer.example.com", + JWKCertFile: jwksFile, + JWKCertURL: jwksServer.URL, + }}, Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), @@ -383,9 +398,11 @@ func TestJWTHandler_Close(t *testing.T) { defer jwksServer.Close() handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ - KeysURL: jwksServer.URL, - IssuerURL: "https://test-issuer.example.com", - Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), + Issuers: []config.JWTIssuerConfig{{ + IssuerURL: "https://test-issuer.example.com", + JWKCertURL: jwksServer.URL, + }}, + Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), }) Expect(err).NotTo(HaveOccurred()) @@ -403,9 +420,11 @@ func TestJWTHandler_ResponseBody(t *testing.T) { defer jwksServer.Close() handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ - KeysURL: jwksServer.URL, - IssuerURL: "https://test-issuer.example.com", - Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), + Issuers: []config.JWTIssuerConfig{{ + IssuerURL: "https://test-issuer.example.com", + JWKCertURL: jwksServer.URL, + }}, + Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), }) Expect(err).NotTo(HaveOccurred()) @@ -463,6 +482,131 @@ func TestJWTHandler_ResponseBody(t *testing.T) { }) } +func TestJWTHandler_MultiIssuer(t *testing.T) { + RegisterTestingT(t) + + key1, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + key2, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + + jwksServer1 := newJWKSServer(t, &key1.PublicKey) + defer jwksServer1.Close() + jwksServer2 := newJWKSServer(t, &key2.PublicKey) + defer jwksServer2.Close() + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + issuerCfg, ok := GetJWTIssuerConfigFromContext(r.Context()) + if ok { + w.Header().Set("X-Matched-Issuer", issuerCfg.IssuerURL) + } + w.WriteHeader(http.StatusOK) + }) + + handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ + Issuers: []config.JWTIssuerConfig{ + {IssuerURL: "https://issuer-1.example.com", JWKCertURL: jwksServer1.URL}, + {IssuerURL: "https://issuer-2.example.com", JWKCertURL: jwksServer2.URL}, + }, + Next: nextHandler, + }) + Expect(err).NotTo(HaveOccurred()) + + validIssuerCases := []struct { + name string + key *rsa.PrivateKey + issuerURL string + }{ + {"token from issuer 1 matches issuer 1 config", key1, "https://issuer-1.example.com"}, + {"token from issuer 2 matches issuer 2 config", key2, "https://issuer-2.example.com"}, + } + for _, tc := range validIssuerCases { + t.Run(tc.name, func(t *testing.T) { + RegisterTestingT(t) + token := signToken(t, tc.key, jwt.MapClaims{ + "iss": tc.issuerURL, + "exp": time.Now().Add(time.Hour).Unix(), + }) + rr := serve(handler, "/protected", "Bearer "+token) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Header().Get("X-Matched-Issuer")).To(Equal(tc.issuerURL)) + }) + } + + t.Run("token from unknown issuer rejected", func(t *testing.T) { + RegisterTestingT(t) + token := signToken(t, key1, jwt.MapClaims{ + "iss": "https://unknown-issuer.example.com", + "exp": time.Now().Add(time.Hour).Unix(), + }) + rr := serve(handler, "/protected", "Bearer "+token) + Expect(rr.Code).To(Equal(http.StatusUnauthorized)) + }) + + t.Run("token signed with wrong key rejected", func(t *testing.T) { + RegisterTestingT(t) + wrongKey, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + token := signToken(t, wrongKey, jwt.MapClaims{ + "iss": "https://issuer-1.example.com", + "exp": time.Now().Add(time.Hour).Unix(), + }) + rr := serve(handler, "/protected", "Bearer "+token) + Expect(rr.Code).To(Equal(http.StatusUnauthorized)) + }) +} + +func TestJWTHandler_CustomHeader(t *testing.T) { + RegisterTestingT(t) + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + + jwksServer := newJWKSServer(t, &privateKey.PublicKey) + defer jwksServer.Close() + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + issuerCfg, ok := GetJWTIssuerConfigFromContext(r.Context()) + if ok { + w.Header().Set("X-Matched-Issuer", issuerCfg.IssuerURL) + } + w.WriteHeader(http.StatusOK) + }) + + handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ + Issuers: []config.JWTIssuerConfig{{ + IssuerURL: "https://test-issuer.example.com", + JWKCertURL: jwksServer.URL, + Header: "X-Custom-Auth", + }}, + Next: nextHandler, + }) + Expect(err).NotTo(HaveOccurred()) + + t.Run("valid token on custom header passes", func(t *testing.T) { + RegisterTestingT(t) + token := signToken(t, privateKey, jwt.MapClaims{ + "iss": "https://test-issuer.example.com", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + rr := serveWithHeader(handler, "/protected", "X-Custom-Auth", "Bearer "+token) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Header().Get("X-Matched-Issuer")).To(Equal("https://test-issuer.example.com")) + }) + + t.Run("token on default Authorization header ignored when issuer uses custom header", func(t *testing.T) { + RegisterTestingT(t) + token := signToken(t, privateKey, jwt.MapClaims{ + "iss": "https://test-issuer.example.com", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + rr := serve(handler, "/protected", "Bearer "+token) + Expect(rr.Code).To(Equal(http.StatusUnauthorized)) + }) +} + // --- helpers --- func signToken(t *testing.T, key *rsa.PrivateKey, claims jwt.MapClaims) string { @@ -477,9 +621,13 @@ func signToken(t *testing.T, key *rsa.PrivateKey, claims jwt.MapClaims) string { } func serve(handler http.Handler, path, authHeader string) *httptest.ResponseRecorder { + return serveWithHeader(handler, path, "Authorization", authHeader) +} + +func serveWithHeader(handler http.Handler, path, headerName, headerValue string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, path, nil) - if authHeader != "" { - req.Header.Set("Authorization", authHeader) + if headerValue != "" { + req.Header.Set(headerName, headerValue) } rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) diff --git a/pkg/config/dump.go b/pkg/config/dump.go index 07e225b0..4262db6b 100644 --- a/pkg/config/dump.go +++ b/pkg/config/dump.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "strings" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" ) @@ -18,7 +19,7 @@ func DumpConfig(config *ApplicationConfig) string { BindAddress: %s EnableHTTPS: %t EnableJWT: %t - IssuerURL: %s + Issuers: %d configured%s Database: Host: %s Port: %d @@ -42,7 +43,8 @@ func DumpConfig(config *ApplicationConfig) string { config.Server.BindAddress(), config.Server.TLS.Enabled, config.Server.JWT.Enabled, - config.Server.JWT.IssuerURL, + len(config.Server.JWT.Configs), + formatIssuers(config.Server.JWT.Configs), config.Database.Host, config.Database.Port, config.Database.Name, @@ -61,6 +63,24 @@ func DumpConfig(config *ApplicationConfig) string { ) } +func formatIssuers(configs []JWTIssuerConfig) string { + if len(configs) == 0 { + return "" + } + var b strings.Builder + for i, cfg := range configs { + jwkSource := "none" + if cfg.JWKCertURL != "" { + jwkSource = "url" + } else if cfg.JWKCertFile != "" { + jwkSource = "file" + } + fmt.Fprintf(&b, "\n [%d] IssuerURL: %s, Header: %s, JWK: %s", + i, cfg.IssuerURL, cfg.Header, jwkSource) + } + return b.String() +} + // entityKindNames extracts Kind strings from entity descriptors for logging. func entityKindNames(entities []registry.EntityDescriptor) []string { kinds := make([]string, len(entities)) diff --git a/pkg/config/dump_test.go b/pkg/config/dump_test.go new file mode 100644 index 00000000..1598b24a --- /dev/null +++ b/pkg/config/dump_test.go @@ -0,0 +1,42 @@ +package config + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestDumpConfig_NilConfig(t *testing.T) { + RegisterTestingT(t) + Expect(DumpConfig(nil)).To(Equal("nil config")) +} + +func TestDumpConfig_NoIssuers(t *testing.T) { + RegisterTestingT(t) + + cfg := NewApplicationConfig() + cfg.Server.JWT.Enabled = false + + result := DumpConfig(cfg) + Expect(result).To(ContainSubstring("EnableJWT: false")) + Expect(result).To(ContainSubstring("Issuers: 0 configured")) + Expect(result).NotTo(ContainSubstring("[0]")) +} + +func TestDumpConfig_WithIssuers(t *testing.T) { + RegisterTestingT(t) + + cfg := NewApplicationConfig() + cfg.Server.JWT.Enabled = true + cfg.Server.JWT.Configs = []JWTIssuerConfig{ + {IssuerURL: "https://accounts.google.com", Header: "Authorization", JWKCertURL: "https://jwks.example.com"}, + {IssuerURL: "https://login.example.com", Header: "X-Token", JWKCertFile: "/path/to/keys.json"}, + {IssuerURL: "https://misconfigured.com", Header: "Authorization"}, + } + + result := DumpConfig(cfg) + Expect(result).To(ContainSubstring("Issuers: 3 configured")) + Expect(result).To(ContainSubstring("[0] IssuerURL: https://accounts.google.com, Header: Authorization, JWK: url")) + Expect(result).To(ContainSubstring("[1] IssuerURL: https://login.example.com, Header: X-Token, JWK: file")) + Expect(result).To(ContainSubstring("[2] IssuerURL: https://misconfigured.com, Header: Authorization, JWK: none")) +} diff --git a/pkg/config/flags.go b/pkg/config/flags.go index b43a1027..7710f219 100644 --- a/pkg/config/flags.go +++ b/pkg/config/flags.go @@ -29,20 +29,6 @@ func AddServerFlags(cmd *cobra.Command) { cmd.Flags().String("server-https-key-file", defaults.TLS.KeyFile, "Path to TLS key file") cmd.Flags().Bool("server-https-enabled", defaults.TLS.Enabled, "Enable HTTPS rather than HTTP") cmd.Flags().Bool("server-jwt-enabled", defaults.JWT.Enabled, "Enable JWT authentication") - cmd.Flags().String("server-jwt-issuer-url", defaults.JWT.IssuerURL, "Expected JWT issuer URL for token validation") - cmd.Flags().String("server-jwt-audience", defaults.JWT.Audience, "Expected JWT audience (optional)") - cmd.Flags().String( - "server-jwt-identity-claim", - defaults.JWT.IdentityClaim, - "JWT claim used as request identity for audit", - ) - cmd.Flags().String( - "server-identity-header", - defaults.IdentityHeader, - "HTTP header name for caller identity (overrides JWT claim when set); leave empty to disable", - ) - cmd.Flags().String("server-jwk-cert-file", defaults.JWK.CertFile, "JWK certificate file path") - cmd.Flags().String("server-jwk-cert-url", defaults.JWK.CertURL, "JWK certificate URL") } // AddDatabaseFlags adds database configuration flags following standard naming diff --git a/pkg/config/helpers_test.go b/pkg/config/helpers_test.go index 2fb48ca5..28c09d2e 100644 --- a/pkg/config/helpers_test.go +++ b/pkg/config/helpers_test.go @@ -37,9 +37,8 @@ func SetMinimalTestEnv(t *testing.T) { t.Setenv("HYPERFLEET_HEALTH_HOST", "localhost") t.Setenv("HYPERFLEET_HEALTH_PORT", "8080") - // JWT config - required when JWT is enabled (default) - t.Setenv("HYPERFLEET_SERVER_JWT_ISSUER_URL", "https://test-idp.example.com/auth/realms/test") - t.Setenv("HYPERFLEET_SERVER_JWK_CERT_URL", "https://test-idp.example.com/certs") + // JWT disabled for non-JWT tests (JWT-specific tests configure their own issuers) + t.Setenv("HYPERFLEET_SERVER_JWT_ENABLED", "false") // Adapters config - empty arrays are valid t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_CLUSTER", `[]`) diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 96a43393..bdc288c0 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -176,19 +176,6 @@ func (l *ConfigLoader) validateConfig(config *ApplicationConfig) error { if valErr := config.Server.TLS.Validate(); valErr != nil { return fmt.Errorf("server TLS validation failed: %w", valErr) } - if valErr := config.Server.JWT.Validate(); valErr != nil { - return fmt.Errorf("server JWT validation failed: %w", valErr) - } - if valErr := config.Server.ValidateIdentityHeader(); valErr != nil { - return fmt.Errorf("server identity header validation failed: %w", valErr) - } - if config.Server.JWT.Enabled && - config.Server.JWK.CertFile == "" && - config.Server.JWK.CertURL == "" { - return fmt.Errorf( - "server JWK validation failed: server.jwk.cert_file or server.jwk.cert_url required when jwt is enabled", - ) - } if valErr := config.Health.Validate(); valErr != nil { return fmt.Errorf("health config validation failed: %w", valErr) } @@ -316,12 +303,8 @@ func (l *ConfigLoader) bindAllEnvVars() { 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") + // server.jwt.configs is a list of structs — loaded from YAML config only. + // Viper cannot bind env vars to individual list elements. // Database config l.bindEnv("database.dialect") l.bindEnv("database.host") @@ -390,12 +373,7 @@ func (l *ConfigLoader) bindFlags(cmd *cobra.Command) { l.bindPFlag("server.tls.key_file", cmd.Flags().Lookup("server-https-key-file")) l.bindPFlag("server.tls.enabled", cmd.Flags().Lookup("server-https-enabled")) l.bindPFlag("server.jwt.enabled", cmd.Flags().Lookup("server-jwt-enabled")) - l.bindPFlag("server.jwt.issuer_url", cmd.Flags().Lookup("server-jwt-issuer-url")) - l.bindPFlag("server.jwt.audience", cmd.Flags().Lookup("server-jwt-audience")) - l.bindPFlag("server.jwt.identity_claim", cmd.Flags().Lookup("server-jwt-identity-claim")) - l.bindPFlag("server.identity_header", cmd.Flags().Lookup("server-identity-header")) - l.bindPFlag("server.jwk.cert_file", cmd.Flags().Lookup("server-jwk-cert-file")) - l.bindPFlag("server.jwk.cert_url", cmd.Flags().Lookup("server-jwk-cert-url")) + // server.jwt.configs: no CLI flags — per-issuer config is YAML-only // Database flags: --db-* -> database.* l.bindPFlag("database.host", cmd.Flags().Lookup("db-host")) l.bindPFlag("database.port", cmd.Flags().Lookup("db-port")) diff --git a/pkg/config/loader_test.go b/pkg/config/loader_test.go index eec2fd27..e00ed6ee 100644 --- a/pkg/config/loader_test.go +++ b/pkg/config/loader_test.go @@ -26,9 +26,7 @@ server: host: "config-file-host" port: 9999 jwt: - issuer_url: "https://test-idp.example.com/auth/realms/test" - jwk: - cert_url: "https://test-idp.example.com/certs" + enabled: false database: host: "localhost" port: 5432 @@ -270,9 +268,7 @@ server: host: "file-host" port: 7000 jwt: - issuer_url: "https://test-idp.example.com/auth/realms/test" - jwk: - cert_url: "https://test-idp.example.com/certs" + enabled: false database: host: "file-db" port: 5432 @@ -330,7 +326,7 @@ adapters: // Priority 4: Default (from NewApplicationConfig) // These fields are not set in file, env, or flags Expect(cfg.Server.Timeouts.Read.Seconds()).To(Equal(float64(5)), "Default value") - Expect(cfg.Server.JWT.Enabled).To(BeTrue(), "Default value") + Expect(cfg.Server.JWT.Enabled).To(BeFalse(), "Disabled by SetMinimalTestEnv") } // TestConfigLoader_DefaultValues tests that defaults work when nothing is set @@ -347,8 +343,7 @@ func TestConfigLoader_DefaultValues(t *testing.T) { Expect(cfg.Server.Timeouts.Read.Seconds()).To(Equal(float64(5)), "Default read timeout") Expect(cfg.Server.Timeouts.Write.Seconds()).To(Equal(float64(30)), "Default write timeout") Expect(cfg.Server.TLS.Enabled).To(BeFalse(), "Default TLS disabled") - Expect(cfg.Server.JWT.Enabled).To(BeTrue(), "Default JWT enabled") - Expect(cfg.Server.JWT.IdentityClaim).To(Equal("email"), "Default JWT identity claim") + Expect(cfg.Server.JWT.Enabled).To(BeFalse(), "Disabled by SetMinimalTestEnv") Expect(cfg.Database.Dialect).To(Equal("postgres"), "Default database dialect") Expect(cfg.Database.Port).To(Equal(5432), "Default database port") Expect(cfg.Logging.Level).To(Equal("info"), "Default log level") diff --git a/pkg/config/server.go b/pkg/config/server.go index 45a1aa08..f5d08280 100755 --- a/pkg/config/server.go +++ b/pkg/config/server.go @@ -3,7 +3,9 @@ package config import ( "fmt" "net" + "regexp" "strconv" + "strings" "time" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/validation" @@ -15,8 +17,6 @@ type ServerConfig struct { Hostname string `mapstructure:"hostname" json:"hostname" validate:"omitempty,hostname|ip"` Host string `mapstructure:"host" json:"host" validate:"required,hostname|ip"` OpenAPISchemaPath string `mapstructure:"openapi_schema_path" json:"openapi_schema_path"` - IdentityHeader string `mapstructure:"identity_header" json:"identity_header"` - JWK JWKConfig `mapstructure:"jwk" json:"jwk" validate:"required"` TLS TLSConfig `mapstructure:"tls" json:"tls" validate:"required"` JWT JWTConfig `mapstructure:"jwt" json:"jwt" validate:"required"` Timeouts TimeoutsConfig `mapstructure:"timeouts" json:"timeouts" validate:"required"` @@ -62,44 +62,82 @@ func (c *TLSConfig) Validate() error { return nil } -// JWTConfig holds JWT authentication configuration +// JWTIssuerConfig holds configuration for a single JWT issuer (trust anchor). +// Each entry defines how to validate tokens from one identity provider. +type JWTIssuerConfig struct { + CompiledPattern *regexp.Regexp `mapstructure:"-" json:"-"` + IssuerURL string `mapstructure:"issuer_url" json:"issuer_url" validate:"required,url"` + JWKCertURL string `mapstructure:"jwk_cert_url" json:"jwk_cert_url" validate:"omitempty,url"` + JWKCertFile string `mapstructure:"jwk_cert_file" json:"jwk_cert_file" validate:"omitempty,filepath"` + Header string `mapstructure:"header" json:"header"` + IdentityHeader string `mapstructure:"identity_header" json:"identity_header"` + Audience string `mapstructure:"audience" json:"audience"` + IdentityClaim string `mapstructure:"identity_claim" json:"identity_claim"` + IdentityClaimPattern string `mapstructure:"identity_claim_pattern" json:"identity_claim_pattern"` +} + +// JWTConfig holds JWT authentication configuration with support for multiple issuers. type JWTConfig struct { - IssuerURL string `mapstructure:"issuer_url" json:"issuer_url" validate:"omitempty,url"` - Audience string `mapstructure:"audience" json:"audience"` - IdentityClaim string `mapstructure:"identity_claim" json:"identity_claim"` - Enabled bool `mapstructure:"enabled" json:"enabled"` + Configs []JWTIssuerConfig `mapstructure:"configs" json:"configs"` + Enabled bool `mapstructure:"enabled" json:"enabled"` +} + +const ( + DefaultJWTHeader = "Authorization" + DefaultJWTIdentityClaim = "email" +) + +// ApplyDefaults sets default values for optional fields in each issuer config. +// Call this after config loading and before Validate(). +func (c *JWTConfig) ApplyDefaults() { + for i := range c.Configs { + if c.Configs[i].Header == "" { + c.Configs[i].Header = DefaultJWTHeader + } + if c.Configs[i].IdentityClaim == "" { + c.Configs[i].IdentityClaim = DefaultJWTIdentityClaim + } + } } func (c *JWTConfig) Validate() error { if !c.Enabled { return nil } - if c.IssuerURL == "" { - return fmt.Errorf("server.jwt.issuer_url is required when jwt is enabled") + if len(c.Configs) == 0 { + return fmt.Errorf("server.jwt.configs requires at least one issuer when jwt is enabled") } - if c.IdentityClaim == "" { - return fmt.Errorf("server.jwt.identity_claim is required when jwt is enabled") - } - return nil -} + for i := range c.Configs { + cfg := &c.Configs[i] -// ValidateIdentityHeader validates the identity header name if set. -func (s *ServerConfig) ValidateIdentityHeader() error { - if s.IdentityHeader == "" { - return nil - } - if validation.IsForbiddenIdentityHeaderName(s.IdentityHeader) { - return fmt.Errorf("server.identity_header %q is not allowed", s.IdentityHeader) + if cfg.IssuerURL == "" { + return fmt.Errorf("server.jwt.configs[%d].issuer_url is required", i) + } + if cfg.JWKCertURL == "" && cfg.JWKCertFile == "" { + return fmt.Errorf("server.jwt.configs[%d] requires jwk_cert_url or jwk_cert_file", i) + } + if cfg.Header != "" && validation.IsForbiddenJWTSourceHeaderName(cfg.Header) { + return fmt.Errorf("server.jwt.configs[%d].header %q is not allowed as a JWT source", i, cfg.Header) + } + if cfg.IdentityHeader != "" { + if validation.IsForbiddenIdentityHeaderName(cfg.IdentityHeader) { + return fmt.Errorf("server.jwt.configs[%d].identity_header %q is not allowed", i, cfg.IdentityHeader) + } + if strings.EqualFold(cfg.Header, cfg.IdentityHeader) { + return fmt.Errorf("server.jwt.configs[%d].identity_header must differ from header %q", i, cfg.Header) + } + } + if cfg.IdentityClaimPattern != "" { + re, err := regexp.Compile(cfg.IdentityClaimPattern) + if err != nil { + return fmt.Errorf("server.jwt.configs[%d].identity_claim_pattern is invalid: %w", i, err) + } + cfg.CompiledPattern = re + } } return nil } -// JWKConfig holds JWK certificate configuration -type JWKConfig struct { - CertFile string `mapstructure:"cert_file" json:"cert_file" validate:"omitempty,filepath"` - CertURL string `mapstructure:"cert_url" json:"cert_url" validate:"omitempty,url"` -} - // NewServerConfig returns default ServerConfig values // These defaults can be overridden by config file, env vars, or CLI flags func NewServerConfig() *ServerConfig { @@ -118,14 +156,7 @@ func NewServerConfig() *ServerConfig { KeyFile: "", }, JWT: JWTConfig{ - Enabled: true, - IssuerURL: "", - Audience: "", - IdentityClaim: "email", - }, - JWK: JWKConfig{ - CertFile: "", - CertURL: "", + Enabled: true, }, } } diff --git a/pkg/config/server_test.go b/pkg/config/server_test.go index dabb1e6b..290dbf98 100644 --- a/pkg/config/server_test.go +++ b/pkg/config/server_test.go @@ -15,54 +15,157 @@ func TestJWTConfig_Validate(t *testing.T) { Expect(cfg.Validate()).To(Succeed()) }) + t.Run("enabled JWT with no configs fails", func(t *testing.T) { + RegisterTestingT(t) + cfg := JWTConfig{Enabled: true} + err := cfg.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("at least one issuer")) + }) + t.Run("enabled JWT without issuer URL fails", func(t *testing.T) { RegisterTestingT(t) - cfg := JWTConfig{Enabled: true, IssuerURL: ""} + cfg := JWTConfig{Enabled: true, Configs: []JWTIssuerConfig{{JWKCertURL: "https://keys.example.com"}}} err := cfg.Validate() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("issuer_url")) }) - t.Run("enabled JWT with issuer URL passes", func(t *testing.T) { + t.Run("enabled JWT without JWK source fails", func(t *testing.T) { RegisterTestingT(t) - cfg := JWTConfig{ - Enabled: true, - IssuerURL: "https://sso.example.com/auth/realms/test", - IdentityClaim: "email", - } + cfg := JWTConfig{Enabled: true, Configs: []JWTIssuerConfig{{IssuerURL: "https://issuer.example.com"}}} + err := cfg.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("jwk_cert_url or jwk_cert_file")) + }) + + t.Run("valid single issuer config with cert URL passes", func(t *testing.T) { + RegisterTestingT(t) + cfg := JWTConfig{Enabled: true, Configs: []JWTIssuerConfig{{ + IssuerURL: "https://issuer.example.com", + JWKCertURL: "https://keys.example.com", + }}} Expect(cfg.Validate()).To(Succeed()) }) - t.Run("enabled JWT without identity claim fails", func(t *testing.T) { + t.Run("valid single issuer config with cert file passes", func(t *testing.T) { RegisterTestingT(t) - cfg := JWTConfig{Enabled: true, IssuerURL: "https://sso.example.com/auth/realms/test", IdentityClaim: ""} + cfg := JWTConfig{Enabled: true, Configs: []JWTIssuerConfig{{ + IssuerURL: "https://issuer.example.com", + JWKCertFile: "/etc/hyperfleet/jwks.json", + }}} + Expect(cfg.Validate()).To(Succeed()) + }) + + t.Run("ApplyDefaults sets header and identity_claim when empty", func(t *testing.T) { + RegisterTestingT(t) + cfg := JWTConfig{Enabled: true, Configs: []JWTIssuerConfig{{ + IssuerURL: "https://issuer.example.com", + JWKCertURL: "https://keys.example.com", + }}} + cfg.ApplyDefaults() + Expect(cfg.Validate()).To(Succeed()) + Expect(cfg.Configs[0].Header).To(Equal(DefaultJWTHeader)) + Expect(cfg.Configs[0].IdentityClaim).To(Equal(DefaultJWTIdentityClaim)) + }) + + t.Run("invalid identity_claim_pattern fails", func(t *testing.T) { + RegisterTestingT(t) + cfg := JWTConfig{Enabled: true, Configs: []JWTIssuerConfig{{ + IssuerURL: "https://issuer.example.com", + JWKCertURL: "https://keys.example.com", + IdentityClaimPattern: "[invalid", + }}} err := cfg.Validate() Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("identity_claim")) + Expect(err.Error()).To(ContainSubstring("identity_claim_pattern")) + }) + + t.Run("valid identity_claim_pattern passes", func(t *testing.T) { + RegisterTestingT(t) + cfg := JWTConfig{Enabled: true, Configs: []JWTIssuerConfig{{ + IssuerURL: "https://issuer.example.com", + JWKCertURL: "https://keys.example.com", + IdentityClaimPattern: `^[^@]+@[^@]+$`, + }}} + Expect(cfg.Validate()).To(Succeed()) + }) + + t.Run("multiple issuers all validated", func(t *testing.T) { + RegisterTestingT(t) + cfg := JWTConfig{Enabled: true, Configs: []JWTIssuerConfig{ + {IssuerURL: "https://issuer1.example.com", JWKCertURL: "https://keys1.example.com"}, + {IssuerURL: "", JWKCertURL: "https://keys2.example.com"}, + }} + err := cfg.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("configs[1]")) }) } -func TestServerConfig_ValidateIdentityHeader(t *testing.T) { +func TestJWTConfig_ValidateIdentityHeader(t *testing.T) { RegisterTestingT(t) + base := func(header string) *JWTConfig { + return &JWTConfig{ + Enabled: true, + Configs: []JWTIssuerConfig{{ + IssuerURL: "https://issuer.example.com", + JWKCertURL: "https://issuer.example.com/.well-known/jwks.json", + IdentityHeader: header, + }}, + } + } + t.Run("empty identity header requires nothing", func(t *testing.T) { RegisterTestingT(t) - cfg := &ServerConfig{} - Expect(cfg.ValidateIdentityHeader()).To(Succeed()) + cfg := base("") + Expect(cfg.Validate()).To(Succeed()) }) t.Run("forbidden header name fails", func(t *testing.T) { RegisterTestingT(t) - cfg := &ServerConfig{IdentityHeader: "Authorization"} - err := cfg.ValidateIdentityHeader() + cfg := base("Authorization") + err := cfg.Validate() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not allowed")) }) t.Run("valid header name passes", func(t *testing.T) { RegisterTestingT(t) - cfg := &ServerConfig{IdentityHeader: "X-HyperFleet-Identity"} - Expect(cfg.ValidateIdentityHeader()).To(Succeed()) + cfg := base("X-HyperFleet-Identity") + Expect(cfg.Validate()).To(Succeed()) + }) + + t.Run("forbidden JWT source header fails", func(t *testing.T) { + RegisterTestingT(t) + cfg := &JWTConfig{ + Enabled: true, + Configs: []JWTIssuerConfig{{ + IssuerURL: "https://issuer.example.com", + JWKCertURL: "https://keys.example.com", + Header: "Cookie", + }}, + } + err := cfg.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not allowed as a JWT source")) + }) + + t.Run("header equal to identity_header fails", func(t *testing.T) { + RegisterTestingT(t) + cfg := &JWTConfig{ + Enabled: true, + Configs: []JWTIssuerConfig{{ + IssuerURL: "https://issuer.example.com", + JWKCertURL: "https://keys.example.com", + Header: "X-Token", + IdentityHeader: "x-token", + }}, + } + err := cfg.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("must differ from header")) }) } diff --git a/pkg/validation/identity_header.go b/pkg/validation/identity_header.go index b077f5f3..786b4c99 100644 --- a/pkg/validation/identity_header.go +++ b/pkg/validation/identity_header.go @@ -17,3 +17,14 @@ func IsForbiddenIdentityHeaderName(name string) bool { _, ok := forbiddenIdentityHeaderNames[http.CanonicalHeaderKey(name)] return ok } + +var forbiddenJWTSourceHeaderNames = map[string]struct{}{ + "Cookie": {}, + "Set-Cookie": {}, +} + +// IsForbiddenJWTSourceHeaderName reports whether name must not be used as the JWT token source header. +func IsForbiddenJWTSourceHeaderName(name string) bool { + _, ok := forbiddenJWTSourceHeaderNames[http.CanonicalHeaderKey(name)] + return ok +} diff --git a/test/helper.go b/test/helper.go index a4572727..9843f1a8 100755 --- a/test/helper.go +++ b/test/helper.go @@ -47,6 +47,8 @@ var ( once sync.Once ) +const defaultTestIdentityHeader = "X-HyperFleet-Identity" + // jwkURL stores the JWK mock server URL for testing var jwkURL string @@ -163,10 +165,23 @@ func (helper *Helper) Teardown() { } } +func (helper *Helper) requireJWTIssuers() { + if helper.Env().Config.Server.JWT.Enabled && len(helper.Env().Config.Server.JWT.Configs) == 0 { + helper.T.Fatal("JWT enabled but no issuer configs defined") + } +} + func (helper *Helper) startAPIServer() { ctx := context.Background() // Configure JWK certificate URL for API server - helper.Env().Config.Server.JWK.CertURL = jwkURL + helper.requireJWTIssuers() + if len(helper.Env().Config.Server.JWT.Configs) > 0 { + cfg := &helper.Env().Config.Server.JWT.Configs[0] + cfg.JWKCertURL = jwkURL + if cfg.IdentityHeader == "" { + cfg.IdentityHeader = defaultTestIdentityHeader + } + } // Disable tracing for integration tests (no OTLP collector required) helper.APIServer = server.NewAPIServer(false) listener, err := helper.APIServer.Listen() @@ -388,14 +403,21 @@ func WithIdentityHeader(headerName, headerValue string) openapi.RequestEditorFn } } -// IdentityHeaderName returns the configured identity header name for integration tests. +// IdentityHeaderName returns the configured identity header name from the first JWT issuer config. func IdentityHeaderName() string { - return environments.Environment().Config.Server.IdentityHeader + configs := environments.Environment().Config.Server.JWT.Configs + if len(configs) > 0 { + return configs[0].IdentityHeader + } + return "" } func (helper *Helper) StartJWKCertServerMock() (teardown func() error) { + helper.requireJWTIssuers() jwkURL, teardown = mocks.NewJWKCertServerMock(helper.T, helper.JWTCA, jwkKID, jwkAlg) - helper.Env().Config.Server.JWK.CertURL = jwkURL + if len(helper.Env().Config.Server.JWT.Configs) > 0 { + helper.Env().Config.Server.JWT.Configs[0].JWKCertURL = jwkURL + } return teardown } @@ -595,8 +617,14 @@ func (helper *Helper) ResetDB() error { } func (helper *Helper) CreateJWTString(account *TestAccount) string { + helper.requireJWTIssuers() + var issuerURL, audience string + if len(helper.Env().Config.Server.JWT.Configs) > 0 { + issuerURL = helper.Env().Config.Server.JWT.Configs[0].IssuerURL + audience = helper.Env().Config.Server.JWT.Configs[0].Audience + } claims := jwt.MapClaims{ - "iss": helper.Env().Config.Server.JWT.IssuerURL, + "iss": issuerURL, "username": strings.ToLower(account.Username), "first_name": account.FirstName, "last_name": account.LastName, @@ -604,8 +632,8 @@ func (helper *Helper) CreateJWTString(account *TestAccount) string { "iat": time.Now().Unix(), "exp": time.Now().Add(1 * time.Hour).Unix(), } - if aud := helper.Env().Config.Server.JWT.Audience; aud != "" { - claims["aud"] = aud + if audience != "" { + claims["aud"] = audience } if account.Email != "" { claims["email"] = account.Email diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go index 7138a117..c6000229 100755 --- a/test/integration/integration_test.go +++ b/test/integration/integration_test.go @@ -27,17 +27,6 @@ func TestMain(m *testing.M) { _ = os.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_NODEPOOL", `["validation","hypershift"]`) } - // JWT config - required for validation; integration tests use local test keys - if os.Getenv("HYPERFLEET_SERVER_JWT_ISSUER_URL") == "" { - _ = os.Setenv("HYPERFLEET_SERVER_JWT_ISSUER_URL", "https://test-idp.example.com/auth/realms/test") - } - if os.Getenv("HYPERFLEET_SERVER_JWK_CERT_URL") == "" { - _ = os.Setenv("HYPERFLEET_SERVER_JWK_CERT_URL", "https://test-idp.example.com/certs") - } - if os.Getenv("HYPERFLEET_SERVER_IDENTITY_HEADER") == "" { - _ = os.Setenv("HYPERFLEET_SERVER_IDENTITY_HEADER", "X-HyperFleet-Identity") - } - // Set OpenAPI schema path for integration tests if not already set // This enables schema validation middleware during tests // Uses HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH (config system standard)