diff --git a/cmd/hyperfleet-api/environments/e_integration_testing.go b/cmd/hyperfleet-api/environments/e_integration_testing.go index 765c41d0..ef6d9e6d 100755 --- a/cmd/hyperfleet-api/environments/e_integration_testing.go +++ b/cmd/hyperfleet-api/environments/e_integration_testing.go @@ -38,6 +38,15 @@ func (e *integrationTestingEnvImpl) OverrideConfig(c *config.ApplicationConfig) c.Database.SSL.Mode = SSLModeDisable } + // JWT issuer config for integration tests. + // JWKCertURL is filled in later by StartJWKCertServerMock once the mock server is running. + c.Server.JWT.Enabled = true + c.Server.JWT.Configs = []config.JWTIssuerConfig{{ + IssuerURL: "https://hyperfleet.test/auth", + IdentityClaim: "email", + Header: "X-HyperFleet-Identity", + }} + return nil } diff --git a/cmd/hyperfleet-api/server/api_server.go b/cmd/hyperfleet-api/server/api_server.go index e7f5cf7d..56be4751 100755 --- a/cmd/hyperfleet-api/server/api_server.go +++ b/cmd/hyperfleet-api/server/api_server.go @@ -33,11 +33,21 @@ func NewAPIServer(tracingEnabled bool) Server { var mainHandler http.Handler = mainRouter if env().Config.Server.JWT.Enabled { + cfgs := env().Config.Server.JWT.Configs + issuers := make([]auth.JWTIssuerHandlerConfig, len(cfgs)) + for i, c := range cfgs { + issuers[i] = auth.JWTIssuerHandlerConfig{ + IssuerURL: c.IssuerURL, + Audience: c.Audience, + KeysFile: c.JWKCertFile, + KeysURL: c.JWKCertURL, + IdentityClaim: c.IdentityClaim, + IdentityClaimPattern: c.IdentityClaimPattern, + Header: c.Header, + } + } 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: issuers, PublicPaths: []string{ "^/api/hyperfleet/?$", "^/api/hyperfleet/v1/?$", diff --git a/cmd/hyperfleet-api/server/routes.go b/cmd/hyperfleet-api/server/routes.go index 7f961e5a..3973eec4 100755 --- a/cmd/hyperfleet-api/server/routes.go +++ b/cmd/hyperfleet-api/server/routes.go @@ -86,17 +86,12 @@ 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") - apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity) - } + // Identity config is resolved per-request from context when JWT is enabled + // (JWTHandler stores the matched issuer's config). An empty static config is used + // as the fallback for non-JWT paths (dev/no-auth mode). + callerIdentityMW, mwErr := auth.NewCallerIdentityMiddleware(auth.CallerIdentityConfig{}) + check(mwErr, "Unable to create caller identity middleware") + apiV1Router.Use(callerIdentityMW.ResolveCallerIdentity) // Auto-discovered routes (no manual editing needed) LoadDiscoveredRoutes(apiV1Router, services) diff --git a/docs/authentication.md b/docs/authentication.md index 6fbc50d5..e357d730 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -33,30 +33,7 @@ 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`. - -To get proper caller attribution without JWT, configure an identity header: - -```bash -./bin/hyperfleet-api serve \ - --server-jwt-enabled=false \ - --server-identity-header=X-HyperFleet-Identity -``` - -Then pass the header in requests: - -```bash -# Create with attribution -curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters \ - -H "Content-Type: application/json" \ - -H "X-HyperFleet-Identity: dev-user@local" \ - -d '{"kind":"Cluster","name":"my-cluster","spec":{}}' - -# Read requests work without the header -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. +When JWT is disabled, caller identity resolution is inactive. Audit fields (`created_by`, `updated_by`, `deleted_by`) fall back to `system@hyperfleet.local`. Read requests (GET, LIST) are always allowed without identity. **Important**: Never disable authentication in production environments. @@ -69,21 +46,33 @@ For local development with real JWT validation, you can use Google Cloud identit - [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) installed - Authenticated with `gcloud auth login` -### Start the server +### Create a config file -```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 \ - --db-host localhost --db-port 5432 --db-name hyperfleet --db-username hyperfleet +```yaml +# dev-config.yaml +server: + jwt: + enabled: true + configs: + - issuer_url: "https://accounts.google.com" + jwk_cert_url: "https://www.googleapis.com/oauth2/v3/certs" + audience: "32555940559.apps.googleusercontent.com" + identity_claim: email + +database: + host: localhost + port: 5432 + name: hyperfleet + username: hyperfleet + password: devpassword ``` The audience `32555940559.apps.googleusercontent.com` is the default gcloud CLI OAuth client ID. It matches the `aud` claim in tokens generated by `gcloud auth print-identity-token`. +```bash +./bin/hyperfleet-api serve --config=dev-config.yaml +``` + ### Generate a token and make requests ```bash @@ -99,13 +88,6 @@ curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"kind":"Cluster","name":"my-cluster","spec":{}}' - -# Override identity via header (header takes precedence over JWT) -curl -X POST http://localhost:8000/api/hyperfleet/v1/clusters \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -H "X-HyperFleet-Identity: gateway-user@corp.com" \ - -d '{"kind":"Cluster","name":"my-cluster-2","spec":{}}' ``` ### How it works @@ -141,14 +123,14 @@ curl -H "Authorization: Bearer ${TOKEN}" \ ### JWT Authentication -HyperFleet API validates JWT tokens using RS256 signature verification. +HyperFleet API validates JWT tokens using RS256 signature verification. With multiple issuers configured, the server tries each issuer in order and accepts the request as soon as any issuer validates the token. -**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` -4. Expiration - Token not expired -5. Claims - Required claims present +**Token validation checks (per issuer):** +1. Signature — token signed by the issuer's JWKS keys +2. Issuer — `iss` claim matches the configured `issuer_url` +3. Audience — `aud` claim matches the configured `audience` (skipped if not configured) +4. Expiration — token not expired +5. Identity — configured `identity_claim` is present and non-empty **Token format:** ```text @@ -163,63 +145,106 @@ curl -H "Authorization: Bearer ${TOKEN}" \ ## 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, the matched issuer's `identity_claim`, `identity_claim_pattern`, and `header` settings determine how identity is resolved for that request. | Layer | Component | Responsibility | |-------|-----------|----------------| -| Outer | `JWTHandler` | Validates `Authorization: Bearer` token | -| Inner | `ResolveCallerIdentity` middleware | Resolves who is recorded as the actor | +| Outer | `JWTHandler` | Validates `Authorization: Bearer` token; stores matched issuer config in context | +| Inner | `ResolveCallerIdentity` middleware | Reads the per-request issuer config from context; resolves audit identity | -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. 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. -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. +### Per-issuer identity claim -### JWT claim - -Configure which JWT claim is used as the caller identity: +Each issuer config defines which JWT claim is used as the caller identity for tokens from that issuer: ```yaml server: jwt: - identity_claim: email # or preferred_username, sub, etc. + configs: + - issuer_url: https://idp-a.example.com/realms/hyperfleet + jwk_cert_url: https://idp-a.example.com/realms/hyperfleet/protocol/openid-connect/certs + identity_claim: email # or preferred_username, sub, etc. + + - issuer_url: https://accounts.google.com + jwk_cert_url: https://www.googleapis.com/oauth2/v3/certs + identity_claim: sub ``` -### HTTP identity header (optional) +If the configured claim is absent from the token the request is rejected with `401 Unauthorized`. + +### Identity claim pattern (optional, per issuer) -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 set, the resolved identity value must match the configured regex. Requests whose identity does not match are rejected with `401 Unauthorized`. An invalid regex prevents the server from starting. ```yaml server: - identity_header: X-HyperFleet-Identity + jwt: + configs: + - issuer_url: https://idp-a.example.com/realms/hyperfleet + jwk_cert_url: https://idp-a.example.com/realms/hyperfleet/protocol/openid-connect/certs + identity_claim: email + identity_claim_pattern: '^[^@]+@[^@]+$' # require email-like values ``` -**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. +Use regex alternation to allow a fixed set of values (e.g., service accounts): -```bash -export HYPERFLEET_SERVER_IDENTITY_HEADER=X-HyperFleet-Identity +```yaml + - issuer_url: https://sa-issuer.example.com + jwk_cert_url: https://sa-issuer.example.com/certs + identity_claim: sub + identity_claim_pattern: '^(sentinel|adapter-namespace|adapter-locator)$' +``` + +The `^` and `$` anchors ensure full-string matching; without them a value like `evil-sentinel` would also pass. When no pattern is set, any non-empty string that passes length and character validation is accepted. + +### HTTP identity header (optional, per issuer) + +When set, a trusted gateway can pass 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`. + +```yaml +server: + jwt: + configs: + - issuer_url: https://idp.example.com/realms/hyperfleet + jwk_cert_url: https://idp.example.com/realms/hyperfleet/protocol/openid-connect/certs + identity_claim: email + header: X-HyperFleet-Identity # optional header override ``` +**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. + Identity values from both sources are validated: trimmed of whitespace, limited to 256 characters, and rejected if they contain control characters. ## Configuration -### Environment Variables +### Config file (required for JWT issuers) -```bash -# Development (no auth) -export HYPERFLEET_SERVER_JWT_ENABLED=false +The `server.jwt.configs` array cannot be expressed as individual environment variables. Use a YAML config file or a JSON environment variable: -# Production (with auth) +**YAML config file (recommended):** +```yaml +server: + jwt: + enabled: true + 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 + identity_claim: email +``` + +**JSON environment variable (for environments without a config file mount):** +```bash 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 +export HYPERFLEET_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","audience":"https://your-api.example.com","identity_claim":"email"}]' ``` See [Deployment](deployment.md) for complete configuration options. ### Kubernetes Deployment -Configure via Helm values: +Configure via Helm values (mounts a ConfigMap with the YAML config): ```yaml # values.yaml @@ -227,8 +252,11 @@ 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 + identity_claim: email ``` Deploy: @@ -242,8 +270,8 @@ helm install hyperfleet-api ./charts/ --values values.yaml **401 Unauthorized** - Check token is valid and not expired -- Verify `HYPERFLEET_SERVER_JWT_ISSUER_URL` and `HYPERFLEET_SERVER_JWT_AUDIENCE` match token claims -- Ensure `Authorization` header is correctly formatted +- Verify the token's `iss` and `aud` claims match an entry in `server.jwt.configs` +- Ensure `Authorization: Bearer ` header is correctly formatted **Token debugging** ```bash diff --git a/docs/config.md b/docs/config.md index b6957993..bd4ce0bd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -255,12 +255,14 @@ 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` | []object | `[]` | Ordered list of JWT issuer configurations (YAML file or JSON env var only — not expressible as individual env vars). See [Authentication](authentication.md) for the full per-issuer field reference. | +| `server.jwt.configs[].issuer_url` | string | `""` | Expected `iss` claim for this issuer (required per entry) | +| `server.jwt.configs[].jwk_cert_url` | string | `""` | JWKS endpoint URL for this issuer's public keys | +| `server.jwt.configs[].jwk_cert_file` | string | `""` | Path to a local JWKS file (alternative or supplement to `jwk_cert_url`) | +| `server.jwt.configs[].audience` | string | `""` | Expected `aud` claim for this issuer (optional; any audience accepted if empty) | +| `server.jwt.configs[].identity_claim` | string | `""` | JWT claim used as audit identity for this issuer (required per entry; e.g. `email`, `sub`) | +| `server.jwt.configs[].identity_claim_pattern` | string | `""` | Optional regex the identity value must match; non-matching requests are rejected with 401 | +| `server.jwt.configs[].header` | string | `""` | Optional HTTP header that overrides the JWT claim for audit identity (gateway-set only) | **Example:** ```yaml @@ -274,42 +276,48 @@ 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: 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 + identity_claim: email ``` #### 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: +The API records who performed each mutation in the `created_by`, `updated_by`, and `deleted_by` audit fields. Identity settings are configured per issuer in `server.jwt.configs[*]`: | 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`) | +| `identity_claim` | JWT claim used as audit identity (e.g., `email`, `preferred_username`, `sub`) | +| `identity_claim_pattern` | Optional regex the resolved identity value must match | +| `header` | Optional HTTP header that overrides the JWT claim (gateway-set; takes precedence) | -**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. +**Validation:** Identity values are trimmed, must not exceed 256 characters, and must not contain control characters. If `identity_claim_pattern` is set, the value must also match the regex — requests that don't match are rejected with `401 Unauthorized`. -**Validation:** Identity values are trimmed, must not exceed 256 characters, and must not contain control characters. - -**Example — header-based identity (behind an authenticating proxy):** +**Example — JWT-based identity:** ```yaml server: - identity_header: X-Forwarded-Email jwt: - enabled: false + enabled: true + configs: + - issuer_url: https://idp.example.com/realms/hyperfleet + jwk_cert_url: https://idp.example.com/realms/hyperfleet/protocol/openid-connect/certs + identity_claim: email ``` -**Example — JWT-based identity:** +**Example — multiple issuers with different identity rules:** ```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 + configs: + - issuer_url: https://idp.example.com/realms/hyperfleet + jwk_cert_url: https://idp.example.com/realms/hyperfleet/protocol/openid-connect/certs + identity_claim: email + - issuer_url: https://sa-issuer.example.com + jwk_cert_url: https://sa-issuer.example.com/certs + identity_claim: sub + identity_claim_pattern: '^(sentinel|adapter-namespace|adapter-locator)$' ``` @@ -383,12 +391,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` | `HYPERFLEET_SERVER_JWT_CONFIGS` (JSON array) | []object | `[]` | | **Database** | | | | | `database.dialect` | `HYPERFLEET_DATABASE_DIALECT` | string | `postgres` | | `database.host` | `HYPERFLEET_DATABASE_HOST` | string | `localhost` | @@ -433,6 +436,8 @@ Complete table of all configuration properties, their environment variables, and All CLI flags and their corresponding configuration paths. +> **Note:** `server.jwt.configs` (the array of issuer configurations) cannot be set via CLI flags because arrays of structs are not expressible as individual flag values. Use a YAML config file or the `HYPERFLEET_SERVER_JWT_CONFIGS` environment variable (JSON array format). + | CLI Flag | Config Path | Type | |----------|-------------|------| | `--config` | N/A (config file path) | string | @@ -447,12 +452,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 | diff --git a/pkg/auth/auth_middleware.go b/pkg/auth/auth_middleware.go index 0e2d2338..d9acef75 100755 --- a/pkg/auth/auth_middleware.go +++ b/pkg/auth/auth_middleware.go @@ -3,6 +3,7 @@ package auth import ( "fmt" "net/http" + "regexp" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/validation" @@ -14,7 +15,8 @@ type CallerIdentityMiddleware interface { } type callerIdentityMiddleware struct { - cfg CallerIdentityConfig + compiledPattern *regexp.Regexp + cfg CallerIdentityConfig } var _ CallerIdentityMiddleware = &callerIdentityMiddleware{} @@ -25,11 +27,21 @@ func NewCallerIdentityMiddleware(cfg CallerIdentityConfig) (CallerIdentityMiddle return nil, fmt.Errorf("identity header name %q is not allowed", cfg.HeaderName) } } - return &callerIdentityMiddleware{cfg: cfg}, nil + var compiledPattern *regexp.Regexp + if cfg.IdentityClaimPattern != "" { + var err error + compiledPattern, err = regexp.Compile(cfg.IdentityClaimPattern) + if err != nil { + return nil, fmt.Errorf("identity_claim_pattern is not a valid regex: %w", err) + } + } + return &callerIdentityMiddleware{cfg: cfg, compiledPattern: compiledPattern}, nil } // ResolveCallerIdentity attaches the resolved caller identity to the request context. // JWT validation is performed by JWTHandler; this middleware only resolves attribution. +// When JWT is enabled, the matched issuer's identity config is read from the request context +// (set by JWTHandler). When JWT is disabled, the static middleware config is used instead. 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 +50,12 @@ func (m *callerIdentityMiddleware) ResolveCallerIdentity(next http.Handler) http } ctx := r.Context() - identity, err := CallerIdentityFromRequest(ctx, r, m.cfg) + cfg, pattern, ok := GetMatchedIdentityConfig(ctx) + if !ok { + cfg = m.cfg + pattern = m.compiledPattern + } + identity, err := CallerIdentityFromRequest(ctx, r, cfg, pattern) if identity != "" { ctx = SetUsernameContext(ctx, identity) diff --git a/pkg/auth/context.go b/pkg/auth/context.go index ccc124a4..68b0d7d2 100755 --- a/pkg/auth/context.go +++ b/pkg/auth/context.go @@ -3,6 +3,7 @@ package auth import ( "context" "fmt" + "regexp" "strings" "github.com/golang-jwt/jwt/v5" @@ -11,13 +12,35 @@ import ( type contextKey string const ( - ContextUsernameKey contextKey = "username" - ContextJWTTokenKey contextKey = "jwt_token" + ContextUsernameKey contextKey = "username" + ContextJWTTokenKey contextKey = "jwt_token" + ContextMatchedIdentityConfigKey contextKey = "matched_identity_cfg" // DefaultJWTIdentityClaim is used when server.jwt.identity_claim is unset. DefaultJWTIdentityClaim = "email" ) +type matchedIdentityContext struct { + cfg *CallerIdentityConfig + pattern *regexp.Regexp +} + +// SetMatchedIdentityConfig stores the per-issuer identity config resolved by JWTHandler +// in the request context so CallerIdentityMiddleware can read it. +func SetMatchedIdentityConfig(ctx context.Context, cfg CallerIdentityConfig, pattern *regexp.Regexp) context.Context { + return context.WithValue(ctx, ContextMatchedIdentityConfigKey, matchedIdentityContext{cfg: &cfg, pattern: pattern}) +} + +// GetMatchedIdentityConfig retrieves the per-issuer identity config stored by JWTHandler. +// Returns false if no config was set (JWT disabled or public path). +func GetMatchedIdentityConfig(ctx context.Context) (CallerIdentityConfig, *regexp.Regexp, bool) { + v, ok := ctx.Value(ContextMatchedIdentityConfigKey).(matchedIdentityContext) + if !ok { + return CallerIdentityConfig{}, nil, false + } + return *v.cfg, v.pattern, true +} + // Payload defines the structure of the JWT payload we expect type Payload struct { Username string `json:"username"` diff --git a/pkg/auth/identity.go b/pkg/auth/identity.go index d5a17140..4ca644b9 100644 --- a/pkg/auth/identity.go +++ b/pkg/auth/identity.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "regexp" "strings" ) @@ -13,16 +14,22 @@ const maxCallerIdentityLen = 256 // 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) +// - IdentityClaimPattern: when non-empty, the resolved identity must match this regex type CallerIdentityConfig struct { - JWTIdentityClaim string - HeaderName string + JWTIdentityClaim string + IdentityClaimPattern string + HeaderName string } // 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) { +// compiledPattern is the pre-compiled form of CallerIdentityConfig.IdentityClaimPattern; +// when non-nil the resolved JWT identity must match it. +func CallerIdentityFromRequest( + ctx context.Context, r *http.Request, cfg CallerIdentityConfig, compiledPattern *regexp.Regexp, +) (string, error) { if cfg.HeaderName != "" { raw := r.Header.Get(cfg.HeaderName) if raw != "" { @@ -41,7 +48,16 @@ func CallerIdentityFromRequest(ctx context.Context, r *http.Request, cfg CallerI if err != nil { return "", err } - return normalizeIdentity(raw, fmt.Sprintf("JWT claim %q", cfg.JWTIdentityClaim)) + identity, err := normalizeIdentity(raw, fmt.Sprintf("JWT claim %q", cfg.JWTIdentityClaim)) + if err != nil { + return "", err + } + if identity != "" && compiledPattern != nil { + if !compiledPattern.MatchString(identity) { + return "", fmt.Errorf("identity claim value does not match required pattern %q", cfg.IdentityClaimPattern) + } + } + return identity, nil } return "", nil diff --git a/pkg/auth/identity_test.go b/pkg/auth/identity_test.go index 9d6c7a7c..a77794cc 100644 --- a/pkg/auth/identity_test.go +++ b/pkg/auth/identity_test.go @@ -3,6 +3,7 @@ package auth import ( "net/http" "net/http/httptest" + "regexp" "strings" "testing" @@ -120,6 +121,41 @@ func TestCallerIdentityFromRequest(t *testing.T) { }, want: "user@example.com", }, + { + name: "pattern matches email-like value", + claims: jwt.MapClaims{"email": "user@example.com"}, + cfg: CallerIdentityConfig{ + JWTIdentityClaim: "email", + IdentityClaimPattern: `^[^@]+@[^@]+$`, + }, + want: "user@example.com", + }, + { + name: "pattern rejects non-matching value", + claims: jwt.MapClaims{"sub": "svc-account-123"}, + cfg: CallerIdentityConfig{ + JWTIdentityClaim: "sub", + IdentityClaimPattern: `^[^@]+@[^@]+$`, + }, + wantErr: true, + }, + { + name: "no pattern accepts any non-empty value", + claims: jwt.MapClaims{"sub": "svc-account-123"}, + cfg: CallerIdentityConfig{ + JWTIdentityClaim: "sub", + }, + want: "svc-account-123", + }, + { + name: "pattern accepts non-email identity claim", + claims: jwt.MapClaims{"sub": "svc-account-123"}, + cfg: CallerIdentityConfig{ + JWTIdentityClaim: "sub", + IdentityClaimPattern: `^svc-`, + }, + want: "svc-account-123", + }, } for _, tc := range tests { @@ -137,7 +173,11 @@ func TestCallerIdentityFromRequest(t *testing.T) { r.Header.Set(headerName, tc.headerValue) } - identity, err := CallerIdentityFromRequest(r.Context(), r, tc.cfg) + var compiledPattern *regexp.Regexp + if tc.cfg.IdentityClaimPattern != "" { + compiledPattern = regexp.MustCompile(tc.cfg.IdentityClaimPattern) + } + identity, err := CallerIdentityFromRequest(r.Context(), r, tc.cfg, compiledPattern) if tc.wantErr { Expect(err).To(HaveOccurred()) return @@ -175,6 +215,26 @@ func TestNewCallerIdentityMiddleware(t *testing.T) { Expect(err).NotTo(HaveOccurred()) Expect(mw).NotTo(BeNil()) }) + + t.Run("rejects invalid identity claim pattern", func(t *testing.T) { + RegisterTestingT(t) + _, err := NewCallerIdentityMiddleware(CallerIdentityConfig{ + JWTIdentityClaim: "email", + IdentityClaimPattern: `[invalid`, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not a valid regex")) + }) + + t.Run("returns middleware with valid identity claim pattern", func(t *testing.T) { + RegisterTestingT(t) + mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{ + JWTIdentityClaim: "email", + IdentityClaimPattern: `^[^@]+@[^@]+$`, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(mw).NotTo(BeNil()) + }) } func TestResolveCallerIdentityMiddleware(t *testing.T) { @@ -353,6 +413,51 @@ func TestResolveCallerIdentityMiddleware(t *testing.T) { Expect(w.Code).To(Equal(http.StatusOK)) }) + t.Run("returns 401 when identity does not match pattern", func(t *testing.T) { + RegisterTestingT(t) + mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{ + JWTIdentityClaim: "sub", + IdentityClaimPattern: `^[^@]+@[^@]+$`, + }) + Expect(err).NotTo(HaveOccurred()) + + 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{"sub": "svc-account-123"})) + w := httptest.NewRecorder() + mw.ResolveCallerIdentity(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(nextCalled).To(BeFalse()) + }) + + t.Run("allows POST when identity matches pattern", func(t *testing.T) { + RegisterTestingT(t) + mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{ + JWTIdentityClaim: "sub", + IdentityClaimPattern: `^svc-`, + }) + Expect(err).NotTo(HaveOccurred()) + + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + Expect(GetUsernameFromContext(r.Context())).To(Equal("svc-account-123")) + }) + + r := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/clusters", nil) + r = r.WithContext(contextWithClaims(jwt.MapClaims{"sub": "svc-account-123"})) + w := httptest.NewRecorder() + mw.ResolveCallerIdentity(next).ServeHTTP(w, r) + + Expect(called).To(BeTrue()) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + t.Run("header identity takes precedence over JWT on POST", func(t *testing.T) { RegisterTestingT(t) mw, err := NewCallerIdentityMiddleware(CallerIdentityConfig{ diff --git a/pkg/auth/jwt_handler.go b/pkg/auth/jwt_handler.go index 76657991..818232a8 100644 --- a/pkg/auth/jwt_handler.go +++ b/pkg/auth/jwt_handler.go @@ -24,70 +24,126 @@ const ( defaultLeeway = 30 * time.Second ) +// JWTHandlerConfig defines the JWT handler's overall configuration. +// A request is accepted if its token validates against any entry in Issuers. type JWTHandlerConfig struct { Next http.Handler - KeysFile string - KeysURL string - IssuerURL string - Audience string PublicPaths []string + Issuers []JWTIssuerHandlerConfig } -func NewJWTHandler(ctx context.Context, cfg JWTHandlerConfig) (*JWTHandler, error) { - ctx, cancel := context.WithCancel(ctx) +// JWTIssuerHandlerConfig is the per-issuer configuration for the JWT handler. +type JWTIssuerHandlerConfig struct { + IssuerURL string + Audience string + KeysFile string + KeysURL string + IdentityClaim string + IdentityClaimPattern string + Header string +} - kf, err := buildKeyfunc(ctx, cfg) - if err != nil { - cancel() - return nil, fmt.Errorf("failed to build JWKS keyfunc: %w", err) +// compiledIssuer holds the pre-built state for a single issuer. +type compiledIssuer struct { + keyfunc keyfunc.Keyfunc + compiledPattern *regexp.Regexp + parser *jwt.Parser + identityCfg CallerIdentityConfig +} + +// JWTHandler validates JWT tokens against one or more issuers. Call Close() during +// shutdown to stop background JWKS refresh goroutines. +type JWTHandler struct { + issuers []compiledIssuer + cancels []context.CancelFunc + next http.Handler + publicPatterns []*regexp.Regexp +} + +func NewJWTHandler(ctx context.Context, cfg JWTHandlerConfig) (*JWTHandler, error) { + if len(cfg.Issuers) == 0 { + return nil, fmt.Errorf("at least one issuer must be configured") } publicPatterns := make([]*regexp.Regexp, 0, len(cfg.PublicPaths)) for _, p := range cfg.PublicPaths { re, err := regexp.Compile(p) if err != nil { - cancel() return nil, fmt.Errorf("invalid public path pattern %q: %w", p, err) } publicPatterns = append(publicPatterns, re) } + issuers := make([]compiledIssuer, 0, len(cfg.Issuers)) + cancels := make([]context.CancelFunc, 0, len(cfg.Issuers)) + for i, ic := range cfg.Issuers { + issuerCtx, cancel := context.WithCancel(ctx) + ci, err := buildCompiledIssuer(issuerCtx, ic) + if err != nil { + cancel() + for _, c := range cancels { + c() + } + return nil, fmt.Errorf("issuer[%d]: %w", i, err) + } + issuers = append(issuers, ci) + cancels = append(cancels, cancel) + } + + return &JWTHandler{ + issuers: issuers, + cancels: cancels, + publicPatterns: publicPatterns, + next: cfg.Next, + }, nil +} + +func buildCompiledIssuer(ctx context.Context, ic JWTIssuerHandlerConfig) (compiledIssuer, error) { + kf, err := buildKeyfunc(ctx, ic) + if err != nil { + return compiledIssuer{}, fmt.Errorf("failed to build keyfunc: %w", err) + } + parserOpts := []jwt.ParserOption{ jwt.WithValidMethods([]string{defaultSigningAlgorithm}), jwt.WithExpirationRequired(), jwt.WithLeeway(defaultLeeway), } - if cfg.IssuerURL != "" { - parserOpts = append(parserOpts, jwt.WithIssuer(cfg.IssuerURL)) + if ic.IssuerURL != "" { + parserOpts = append(parserOpts, jwt.WithIssuer(ic.IssuerURL)) } else { logger.Warn(ctx, "JWT issuer validation disabled: no issuer_url configured") } - if cfg.Audience != "" { - parserOpts = append(parserOpts, jwt.WithAudience(cfg.Audience)) + if ic.Audience != "" { + parserOpts = append(parserOpts, jwt.WithAudience(ic.Audience)) } - return &JWTHandler{ - keyfunc: kf, - parser: jwt.NewParser(parserOpts...), - publicPatterns: publicPatterns, - next: cfg.Next, - cancel: cancel, - }, nil -} + var compiledPattern *regexp.Regexp + if ic.IdentityClaimPattern != "" { + compiledPattern, err = regexp.Compile(ic.IdentityClaimPattern) + if err != nil { + return compiledIssuer{}, fmt.Errorf("identity_claim_pattern %q is not a valid regex: %w", + ic.IdentityClaimPattern, err) + } + } -// JWTHandler validates JWT tokens on incoming requests. Call Close() during -// shutdown to stop the background JWKS refresh goroutine. -type JWTHandler struct { - keyfunc keyfunc.Keyfunc - next http.Handler - parser *jwt.Parser - cancel context.CancelFunc - publicPatterns []*regexp.Regexp + return compiledIssuer{ + parser: jwt.NewParser(parserOpts...), + keyfunc: kf, + compiledPattern: compiledPattern, + identityCfg: CallerIdentityConfig{ + JWTIdentityClaim: ic.IdentityClaim, + IdentityClaimPattern: ic.IdentityClaimPattern, + HeaderName: ic.Header, + }, + }, nil } func (h *JWTHandler) Close() { - if h.cancel != nil { - h.cancel() + for _, cancel := range h.cancels { + if cancel != nil { + cancel() + } } } @@ -112,52 +168,58 @@ func (h *JWTHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } 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) { - handleError(r.Context(), w, r, hferrors.CodeAuthExpiredToken, "JWT token has expired") - } else { - handleError(r.Context(), w, r, hferrors.CodeAuthInvalidCredentials, "invalid or expired JWT token") + var lastErr error + for _, issuer := range h.issuers { + token, err := issuer.parser.Parse(tokenString, issuer.keyfunc.Keyfunc) + if err != nil { + lastErr = err + continue } + ctx := SetJWTTokenContext(r.Context(), token) + ctx = SetMatchedIdentityConfig(ctx, issuer.identityCfg, issuer.compiledPattern) + h.next.ServeHTTP(w, r.WithContext(ctx)) return } - ctx := SetJWTTokenContext(r.Context(), token) - h.next.ServeHTTP(w, r.WithContext(ctx)) + logger.WithError(r.Context(), lastErr).Warn("JWT validation failed against all configured issuers") + if errors.Is(lastErr, jwt.ErrTokenExpired) { + handleError(r.Context(), w, r, hferrors.CodeAuthExpiredToken, "JWT token has expired") + } else { + handleError(r.Context(), w, r, hferrors.CodeAuthInvalidCredentials, "invalid or expired JWT token") + } } -func buildKeyfunc(ctx context.Context, cfg JWTHandlerConfig) (keyfunc.Keyfunc, error) { - hasFile := cfg.KeysFile != "" - hasURL := cfg.KeysURL != "" +func buildKeyfunc(ctx context.Context, ic JWTIssuerHandlerConfig) (keyfunc.Keyfunc, error) { + hasFile := ic.KeysFile != "" + hasURL := ic.KeysURL != "" if !hasFile && !hasURL { return nil, fmt.Errorf("at least one of KeysFile or KeysURL must be provided") } if hasFile && !hasURL { - data, err := os.ReadFile(cfg.KeysFile) + data, err := os.ReadFile(ic.KeysFile) 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", ic.KeysFile, 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", ic.KeysFile, err) } return kf, nil } if !hasFile && hasURL { - kf, err := keyfunc.NewDefaultCtx(ctx, []string{cfg.KeysURL}) + kf, err := keyfunc.NewDefaultCtx(ctx, []string{ic.KeysURL}) 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", ic.KeysURL, err) } return kf, nil } - data, err := os.ReadFile(cfg.KeysFile) + data, err := os.ReadFile(ic.KeysFile) 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", ic.KeysFile, err) } fileKF, err := keyfunc.NewJWKSetJSON(json.RawMessage(data)) if err != nil { @@ -167,7 +229,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(), + ic.KeysURL: jwkset.NewMemoryStorage(), }, }) if err != nil { diff --git a/pkg/auth/jwt_handler_test.go b/pkg/auth/jwt_handler_test.go index 958d200b..14ae9ee6 100644 --- a/pkg/auth/jwt_handler_test.go +++ b/pkg/auth/jwt_handler_test.go @@ -19,6 +19,11 @@ import ( const testKID = "test-key-1" +// issuer1 builds a JWTIssuerHandlerConfig for the given server URL. +func issuer1(keysURL, issuerURL string) JWTIssuerHandlerConfig { + return JWTIssuerHandlerConfig{KeysURL: keysURL, IssuerURL: issuerURL} +} + func TestJWTHandler(t *testing.T) { RegisterTestingT(t) @@ -34,8 +39,7 @@ func TestJWTHandler(t *testing.T) { }) handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ - KeysURL: jwksServer.URL, - IssuerURL: "https://test-issuer.example.com", + Issuers: []JWTIssuerHandlerConfig{issuer1(jwksServer.URL, "https://test-issuer.example.com")}, PublicPaths: []string{"^/healthz$", "^/openapi$"}, Next: nextHandler, }) @@ -64,9 +68,8 @@ 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: []JWTIssuerHandlerConfig{issuer1(jwksServer.URL, "https://test-issuer.example.com")}, + Next: claimsHandler, }) Expect(err).NotTo(HaveOccurred()) @@ -173,6 +176,89 @@ func TestJWTHandler(t *testing.T) { }) } +func TestJWTHandler_MultiIssuer(t *testing.T) { + RegisterTestingT(t) + + keyA, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + keyB, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + + serverA := newJWKSServer(t, &keyA.PublicKey) + defer serverA.Close() + serverB := newJWKSServer(t, &keyB.PublicKey) + defer serverB.Close() + + handler, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ + Issuers: []JWTIssuerHandlerConfig{ + {KeysURL: serverA.URL, IssuerURL: "https://idp-a.example.com"}, + {KeysURL: serverB.URL, IssuerURL: "https://idp-b.example.com"}, + }, + Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + }), + }) + Expect(err).NotTo(HaveOccurred()) + + t.Run("token from issuer A accepted", func(t *testing.T) { + RegisterTestingT(t) + token := signToken(t, keyA, jwt.MapClaims{ + "iss": "https://idp-a.example.com", + "exp": time.Now().Add(time.Hour).Unix(), + }) + rr := serve(handler, "/protected", "Bearer "+token) + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + + t.Run("token from issuer B accepted", func(t *testing.T) { + RegisterTestingT(t) + token := signToken(t, keyB, jwt.MapClaims{ + "iss": "https://idp-b.example.com", + "exp": time.Now().Add(time.Hour).Unix(), + }) + rr := serve(handler, "/protected", "Bearer "+token) + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + + t.Run("token from unknown issuer rejected", func(t *testing.T) { + RegisterTestingT(t) + unknownKey, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + token := signToken(t, unknownKey, jwt.MapClaims{ + "iss": "https://idp-c.example.com", + "exp": time.Now().Add(time.Hour).Unix(), + }) + rr := serve(handler, "/protected", "Bearer "+token) + Expect(rr.Code).To(Equal(http.StatusUnauthorized)) + }) + + t.Run("matched identity config stored in context", func(t *testing.T) { + RegisterTestingT(t) + identityHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cfg, _, ok := GetMatchedIdentityConfig(r.Context()) + Expect(ok).To(BeTrue()) + fmt.Fprint(w, cfg.JWTIdentityClaim) + w.WriteHeader(http.StatusOK) + }) + h, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ + Issuers: []JWTIssuerHandlerConfig{ + {KeysURL: serverA.URL, IssuerURL: "https://idp-a.example.com", IdentityClaim: "email"}, + {KeysURL: serverB.URL, IssuerURL: "https://idp-b.example.com", IdentityClaim: "sub"}, + }, + Next: identityHandler, + }) + Expect(err).NotTo(HaveOccurred()) + + token := signToken(t, keyB, jwt.MapClaims{ + "iss": "https://idp-b.example.com", + "exp": time.Now().Add(time.Hour).Unix(), + }) + rr := serve(h, "/protected", "Bearer "+token) + Expect(rr.Body.String()).To(Equal("sub")) + }) +} + func TestJWTHandler_FailClosed_NoValidKeys(t *testing.T) { RegisterTestingT(t) @@ -185,8 +271,7 @@ 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: []JWTIssuerHandlerConfig{issuer1(badServer.URL, "https://test-issuer.example.com")}, Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), @@ -201,12 +286,22 @@ func TestJWTHandler_FailClosed_NoValidKeys(t *testing.T) { Expect(rr.Code).To(Equal(http.StatusUnauthorized)) } +func TestJWTHandler_RequiresIssuers(t *testing.T) { + RegisterTestingT(t) + + _, err := NewJWTHandler(t.Context(), JWTHandlerConfig{ + Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("at least one issuer")) +} + func TestJWTHandler_RequiresKeysConfig(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) {}), + Issuers: []JWTIssuerHandlerConfig{{IssuerURL: "https://test-issuer.example.com"}}, + Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), }) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("KeysFile or KeysURL")) @@ -222,9 +317,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: []JWTIssuerHandlerConfig{{ + KeysURL: jwksServer.URL, + IssuerURL: "https://test-issuer.example.com", + Audience: "my-api", + }}, Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), @@ -264,8 +361,7 @@ 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: []JWTIssuerHandlerConfig{issuer1(jwksServer.URL, "https://test-issuer.example.com")}, Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), @@ -290,8 +386,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: []JWTIssuerHandlerConfig{{ + KeysFile: jwksFile, + IssuerURL: "https://test-issuer.example.com", + }}, Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, "ok") @@ -336,9 +434,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: []JWTIssuerHandlerConfig{{ + KeysFile: jwksFile, + KeysURL: jwksServer.URL, + IssuerURL: "https://test-issuer.example.com", + }}, Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), @@ -383,9 +483,8 @@ 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: []JWTIssuerHandlerConfig{issuer1(jwksServer.URL, "https://test-issuer.example.com")}, + Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), }) Expect(err).NotTo(HaveOccurred()) @@ -403,9 +502,8 @@ 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: []JWTIssuerHandlerConfig{issuer1(jwksServer.URL, "https://test-issuer.example.com")}, + Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }), }) Expect(err).NotTo(HaveOccurred()) diff --git a/pkg/config/dump.go b/pkg/config/dump.go index 51676518..ac021015 100644 --- a/pkg/config/dump.go +++ b/pkg/config/dump.go @@ -16,7 +16,7 @@ func DumpConfig(config *ApplicationConfig) string { BindAddress: %s EnableHTTPS: %t EnableJWT: %t - IssuerURL: %s + JWTConfigs: %d Database: Host: %s Port: %d @@ -39,7 +39,7 @@ 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), config.Database.Host, config.Database.Port, config.Database.Name, diff --git a/pkg/config/flags.go b/pkg/config/flags.go index 0bb4cef1..e449f4e5 100644 --- a/pkg/config/flags.go +++ b/pkg/config/flags.go @@ -29,20 +29,10 @@ 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") + // Note: server.jwt.configs (issuer list) is configured via YAML config file only; + // array-of-structs cannot be expressed as CLI flags. } // AddDatabaseFlags adds database configuration flags following standard naming diff --git a/pkg/config/helpers_test.go b/pkg/config/helpers_test.go index 2fb48ca5..59cf9fa7 100644 --- a/pkg/config/helpers_test.go +++ b/pkg/config/helpers_test.go @@ -37,10 +37,6 @@ 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") - // Adapters config - empty arrays are valid t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_CLUSTER", `[]`) t.Setenv("HYPERFLEET_ADAPTERS_REQUIRED_NODEPOOL", `[]`) diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 0d173822..8ee9fca1 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -179,16 +179,6 @@ func (l *ConfigLoader) validateConfig(config *ApplicationConfig) error { 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 +306,9 @@ 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") + // Note: server.jwt.configs is a slice of structs configured via YAML config file. // Database config l.bindEnv("database.dialect") l.bindEnv("database.host") @@ -387,10 +374,6 @@ 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")) // Database flags: --db-* -> database.* diff --git a/pkg/config/loader_test.go b/pkg/config/loader_test.go index eec2fd27..e9fd6e8f 100644 --- a/pkg/config/loader_test.go +++ b/pkg/config/loader_test.go @@ -25,8 +25,6 @@ func TestConfigLoader_ExplicitConfigFlag(t *testing.T) { 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" database: @@ -269,8 +267,6 @@ func TestConfigLoader_CompletePriorityChain(t *testing.T) { 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" database: @@ -348,7 +344,6 @@ func TestConfigLoader_DefaultValues(t *testing.T) { 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.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..72f6dcbf 100755 --- a/pkg/config/server.go +++ b/pkg/config/server.go @@ -5,8 +5,6 @@ import ( "net" "strconv" "time" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/validation" ) // ServerConfig holds HTTP/HTTPS server configuration @@ -15,7 +13,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"` @@ -62,39 +59,42 @@ func (c *TLSConfig) Validate() error { return nil } +// JWTIssuerConfig holds per-issuer JWT authentication and identity configuration. +// Each entry represents a distinct identity provider. A request is accepted if +// its token validates against any entry. +type JWTIssuerConfig struct { + IssuerURL string `mapstructure:"issuer_url" json:"issuer_url"` + Audience string `mapstructure:"audience" json:"audience"` + JWKCertFile string `mapstructure:"jwk_cert_file" json:"jwk_cert_file"` + JWKCertURL string `mapstructure:"jwk_cert_url" json:"jwk_cert_url"` + IdentityClaim string `mapstructure:"identity_claim" json:"identity_claim"` + IdentityClaimPattern string `mapstructure:"identity_claim_pattern" json:"identity_claim_pattern"` + Header string `mapstructure:"header" json:"header"` +} + // JWTConfig holds JWT authentication configuration 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"` } 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 c.IdentityClaim == "" { - return fmt.Errorf("server.jwt.identity_claim is required when jwt is enabled") - } - return nil -} - -// 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) + // Allow empty configs at load time; NewJWTHandler validates before starting. + for i, cfg := range c.Configs { + if cfg.IssuerURL == "" { + return fmt.Errorf("server.jwt.configs[%d].issuer_url is required", i) + } + if cfg.IdentityClaim == "" { + return fmt.Errorf("server.jwt.configs[%d].identity_claim is required", i) + } } return nil } -// JWKConfig holds JWK certificate configuration +// JWKConfig holds JWK certificate configuration (used for the identity-header-only dev mode) 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"` @@ -118,10 +118,8 @@ func NewServerConfig() *ServerConfig { KeyFile: "", }, JWT: JWTConfig{ - Enabled: true, - IssuerURL: "", - Audience: "", - IdentityClaim: "email", + Enabled: true, + Configs: []JWTIssuerConfig{}, }, JWK: JWKConfig{ CertFile: "", diff --git a/pkg/config/server_test.go b/pkg/config/server_test.go index dabb1e6b..c3cf748c 100644 --- a/pkg/config/server_test.go +++ b/pkg/config/server_test.go @@ -15,54 +15,51 @@ func TestJWTConfig_Validate(t *testing.T) { Expect(cfg.Validate()).To(Succeed()) }) - t.Run("enabled JWT without issuer URL fails", func(t *testing.T) { + t.Run("enabled JWT with empty configs passes", func(t *testing.T) { RegisterTestingT(t) - cfg := JWTConfig{Enabled: true, IssuerURL: ""} - err := cfg.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("issuer_url")) + cfg := JWTConfig{Enabled: true, Configs: []JWTIssuerConfig{}} + Expect(cfg.Validate()).To(Succeed()) }) - t.Run("enabled JWT with issuer URL passes", func(t *testing.T) { + t.Run("enabled JWT with valid config passes", func(t *testing.T) { RegisterTestingT(t) cfg := JWTConfig{ - Enabled: true, - IssuerURL: "https://sso.example.com/auth/realms/test", - IdentityClaim: "email", + Enabled: true, + Configs: []JWTIssuerConfig{ + { + IssuerURL: "https://sso.example.com/auth/realms/test", + IdentityClaim: "email", + JWKCertURL: "https://sso.example.com/certs", + }, + }, } Expect(cfg.Validate()).To(Succeed()) }) - t.Run("enabled JWT without identity claim fails", func(t *testing.T) { + t.Run("enabled JWT config missing issuer_url fails", 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{ + {IdentityClaim: "email", JWKCertURL: "https://sso.example.com/certs"}, + }, + } err := cfg.Validate() Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("identity_claim")) - }) -} - -func TestServerConfig_ValidateIdentityHeader(t *testing.T) { - RegisterTestingT(t) - - t.Run("empty identity header requires nothing", func(t *testing.T) { - RegisterTestingT(t) - cfg := &ServerConfig{} - Expect(cfg.ValidateIdentityHeader()).To(Succeed()) + Expect(err.Error()).To(ContainSubstring("issuer_url")) }) - t.Run("forbidden header name fails", func(t *testing.T) { + t.Run("enabled JWT config missing identity_claim fails", func(t *testing.T) { RegisterTestingT(t) - cfg := &ServerConfig{IdentityHeader: "Authorization"} - err := cfg.ValidateIdentityHeader() + cfg := JWTConfig{ + Enabled: true, + Configs: []JWTIssuerConfig{ + {IssuerURL: "https://sso.example.com/auth/realms/test", JWKCertURL: "https://sso.example.com/certs"}, + }, + } + 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()) + Expect(err.Error()).To(ContainSubstring("identity_claim")) }) } diff --git a/test/helper.go b/test/helper.go index 144fe618..fa294aa0 100755 --- a/test/helper.go +++ b/test/helper.go @@ -156,8 +156,8 @@ func (helper *Helper) Teardown() { func (helper *Helper) startAPIServer() { ctx := context.Background() - // Configure JWK certificate URL for API server - helper.Env().Config.Server.JWK.CertURL = jwkURL + // Configure JWK certificate URL for the first (and only) integration test issuer + helper.Env().Config.Server.JWT.Configs[0].JWKCertURL = jwkURL // Disable tracing for integration tests (no OTLP collector required) helper.APIServer = server.NewAPIServer(false) listener, err := helper.APIServer.Listen() @@ -379,14 +379,20 @@ func WithIdentityHeader(headerName, headerValue string) openapi.RequestEditorFn } } -// IdentityHeaderName returns the configured identity header name for integration tests. +// IdentityHeaderName returns the identity header name configured for the first JWT issuer. func IdentityHeaderName() string { - return environments.Environment().Config.Server.IdentityHeader + cfgs := environments.Environment().Config.Server.JWT.Configs + if len(cfgs) == 0 { + return "" + } + return cfgs[0].Header } func (helper *Helper) StartJWKCertServerMock() (teardown func() error) { jwkURL, teardown = mocks.NewJWKCertServerMock(helper.T, helper.JWTCA, jwkKID, jwkAlg) - helper.Env().Config.Server.JWK.CertURL = jwkURL + if cfgs := helper.Env().Config.Server.JWT.Configs; len(cfgs) > 0 { + helper.Env().Config.Server.JWT.Configs[0].JWKCertURL = jwkURL + } return teardown } @@ -586,8 +592,13 @@ func (helper *Helper) ResetDB() error { } func (helper *Helper) CreateJWTString(account *TestAccount) string { + var issuerURL, audience string + if cfgs := helper.Env().Config.Server.JWT.Configs; len(cfgs) > 0 { + issuerURL = cfgs[0].IssuerURL + audience = cfgs[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, @@ -595,8 +606,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