diff --git a/api/v1alpha1/jwt_types.go b/api/v1alpha1/jwt_types.go index da28346c15..ff9f65eb94 100644 --- a/api/v1alpha1/jwt_types.go +++ b/api/v1alpha1/jwt_types.go @@ -10,11 +10,28 @@ import ( ) // JWT defines the configuration for JSON Web Token (JWT) authentication. +// +// +kubebuilder:validation:XValidation:rule="!(has(self.optional) && has(self.failOpen))",message="optional and failOpen cannot both be set; failOpen already tolerates a missing JWT" type JWT struct { // Optional determines whether a missing JWT is acceptable, defaulting to false if not specified. - // Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT is presented. + // Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT + // is presented. See FailOpen if this is necessary for your use case. Optional *bool `json:"optional,omitempty"` + // FailOpen lets a request pass JWT authentication even when its JWT is + // missing or invalid, rather than being rejected. This helps when a header + // that clients use to carry a JWT may also legitimately hold a non-JWT value + // that the backend relies on. + // + // A valid JWT is still verified and its claims forwarded as usual; only the + // rejection of requests with a missing or invalid JWT is relaxed. Because this + // does not enforce authentication on its own, pair it with an Authorization + // policy when access needs to be restricted. + // + // This is broader than Optional (which tolerates a missing JWT but still + // rejects an invalid one) and takes precedence over it. + FailOpen *bool `json:"failOpen,omitempty"` + // Providers defines the JSON Web Token (JWT) authentication provider type. // When multiple JWT providers are specified, the JWT is considered valid if // any of the providers successfully validate the JWT. For additional details, diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index a566e3c1df..9bab148e80 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5025,6 +5025,11 @@ func (in *JWT) DeepCopyInto(out *JWT) { *out = new(bool) **out = **in } + if in.FailOpen != nil { + in, out := &in.FailOpen, &out.FailOpen + *out = new(bool) + **out = **in + } if in.Providers != nil { in, out := &in.Providers, &out.Providers *out = make([]JWTProvider, len(*in)) diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml index 6eba7e7eb4..3d6a17b808 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml @@ -3795,10 +3795,26 @@ spec: description: JWT defines the configuration for JSON Web Token (JWT) authentication. properties: + failOpen: + description: |- + FailOpen lets a request pass JWT authentication even when its JWT is + missing or invalid, rather than being rejected. This helps when a header + that clients use to carry a JWT may also legitimately hold a non-JWT value + that the backend relies on. + + A valid JWT is still verified and its claims forwarded as usual; only the + rejection of requests with a missing or invalid JWT is relaxed. Because this + does not enforce authentication on its own, pair it with an Authorization + policy when access needs to be restricted. + + This is broader than Optional (which tolerates a missing JWT but still + rejects an invalid one) and takes precedence over it. + type: boolean optional: description: |- Optional determines whether a missing JWT is acceptable, defaulting to false if not specified. - Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT is presented. + Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT + is presented. See FailOpen if this is necessary for your use case. type: boolean providers: description: |- @@ -5458,6 +5474,10 @@ spec: required: - providers type: object + x-kubernetes-validations: + - message: optional and failOpen cannot both be set; failOpen already + tolerates a missing JWT + rule: '!(has(self.optional) && has(self.failOpen))' mergeType: description: |- MergeType determines how this configuration is merged with existing SecurityPolicy diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml index 3f1ec675ff..3bcde3c88e 100644 --- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml +++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml @@ -3794,10 +3794,26 @@ spec: description: JWT defines the configuration for JSON Web Token (JWT) authentication. properties: + failOpen: + description: |- + FailOpen lets a request pass JWT authentication even when its JWT is + missing or invalid, rather than being rejected. This helps when a header + that clients use to carry a JWT may also legitimately hold a non-JWT value + that the backend relies on. + + A valid JWT is still verified and its claims forwarded as usual; only the + rejection of requests with a missing or invalid JWT is relaxed. Because this + does not enforce authentication on its own, pair it with an Authorization + policy when access needs to be restricted. + + This is broader than Optional (which tolerates a missing JWT but still + rejects an invalid one) and takes precedence over it. + type: boolean optional: description: |- Optional determines whether a missing JWT is acceptable, defaulting to false if not specified. - Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT is presented. + Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT + is presented. See FailOpen if this is necessary for your use case. type: boolean providers: description: |- @@ -5457,6 +5473,10 @@ spec: required: - providers type: object + x-kubernetes-validations: + - message: optional and failOpen cannot both be set; failOpen already + tolerates a missing JWT + rule: '!(has(self.optional) && has(self.failOpen))' mergeType: description: |- MergeType determines how this configuration is merged with existing SecurityPolicy diff --git a/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go index 6e796e21ae..769d3fae69 100644 --- a/internal/gatewayapi/securitypolicy.go +++ b/internal/gatewayapi/securitypolicy.go @@ -1388,8 +1388,9 @@ func (t *Translator) buildJWT( } return &ir.JWT{ - AllowMissing: ptr.Deref(policy.Spec.JWT.Optional, false), - Providers: providers, + AllowMissing: ptr.Deref(policy.Spec.JWT.Optional, false), + AllowMissingOrFailed: ptr.Deref(policy.Spec.JWT.FailOpen, false), + Providers: providers, }, nil } diff --git a/internal/gatewayapi/testdata/securitypolicy-with-jwt-fail-open.in.yaml b/internal/gatewayapi/testdata/securitypolicy-with-jwt-fail-open.in.yaml new file mode 100644 index 0000000000..30c6550481 --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-jwt-fail-open.in.yaml @@ -0,0 +1,53 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/" + backendRefs: + - name: service-1 + port: 8080 +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: default + name: policy-for-route + spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + jwt: + failOpen: true + providers: + - name: example + issuer: https://www.example.com + remoteJWKS: + uri: https://www.example.com/jwt/public-key/jwks.json diff --git a/internal/gatewayapi/testdata/securitypolicy-with-jwt-fail-open.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-jwt-fail-open.out.yaml new file mode 100644 index 0000000000..e137c64da1 --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-with-jwt-fail-open.out.yaml @@ -0,0 +1,220 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-1 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: / + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - name: envoy-gateway/gateway-1/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + ownerReference: + kind: GatewayClass + name: envoy-gateway-class + name: envoy-gateway/gateway-1 + namespace: envoy-gateway-system +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + name: policy-for-route + namespace: default + spec: + jwt: + failOpen: true + providers: + - issuer: https://www.example.com + name: example + remoteJWKS: + uri: https://www.example.com/jwt/public-key/jwks.json + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: spec.targetRef is deprecated, use spec.targetRefs instead + reason: DeprecatedField + status: "True" + type: Warning + controllerName: gateway.envoyproxy.io/gatewayclass-controller +xdsIR: + envoy-gateway/gateway-1: + accessLog: + json: + - path: /dev/stdout + globalResources: + proxyServiceCluster: + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + settings: + - addressType: IP + endpoints: + - host: 7.6.5.4 + port: 8080 + zone: zone1 + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + protocol: TCP + http: + - address: 0.0.0.0 + externalPort: 80 + hostnames: + - '*' + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-1/rule/0/backend/0 + protocol: HTTP + weight: 1 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: / + security: + jwt: + allowMissingOrFailed: true + providers: + - issuer: https://www.example.com + name: example + remoteJWKS: + uri: https://www.example.com/jwt/public-key/jwks.json + readyListener: + address: 0.0.0.0 + ipFamily: IPv4 + path: /ready + port: 19003 diff --git a/internal/ir/xds.go b/internal/ir/xds.go index 2c5d0c78e3..216f08bbc2 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -1203,6 +1203,10 @@ type JWT struct { // AllowMissing determines whether a missing JWT is acceptable. AllowMissing bool `json:"allowMissing,omitempty" yaml:"allowMissing,omitempty"` + // AllowMissingOrFailed determines whether a missing or invalid JWT is tolerated. + // When true it supersedes AllowMissing and maps to Envoy's `allow_missing_or_failed`. + AllowMissingOrFailed bool `json:"allowMissingOrFailed,omitempty" yaml:"allowMissingOrFailed,omitempty"` + // Providers defines a list of JSON Web Token (JWT) authentication providers. Providers []JWTProvider `json:"providers,omitempty" yaml:"providers,omitempty"` } diff --git a/internal/xds/translator/jwt.go b/internal/xds/translator/jwt.go index dbfea8e021..2ea27a835c 100644 --- a/internal/xds/translator/jwt.go +++ b/internal/xds/translator/jwt.go @@ -215,7 +215,16 @@ func buildJWTAuthn(irListener *ir.HTTPListener, jwtAuthn *jwtauthnv3.JwtAuthenti }) } - if route.Security.JWT.AllowMissing { + // AllowMissingOrFailed supersedes AllowMissing: it tolerates a missing or + // invalid token, whereas AllowMissing still rejects an invalid one. + switch { + case route.Security.JWT.AllowMissingOrFailed: + reqs = append(reqs, &jwtauthnv3.JwtRequirement{ + RequiresType: &jwtauthnv3.JwtRequirement_AllowMissingOrFailed{ + AllowMissingOrFailed: &emptypb.Empty{}, + }, + }) + case route.Security.JWT.AllowMissing: reqs = append(reqs, &jwtauthnv3.JwtRequirement{ RequiresType: &jwtauthnv3.JwtRequirement_AllowMissing{ AllowMissing: &emptypb.Empty{}, diff --git a/internal/xds/translator/testdata/in/xds-ir/jwt-allow-missing-or-failed.yaml b/internal/xds/translator/testdata/in/xds-ir/jwt-allow-missing-or-failed.yaml new file mode 100644 index 0000000000..6fd9e77d46 --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/jwt-allow-missing-or-failed.yaml @@ -0,0 +1,74 @@ +http: +- name: "first-listener" + address: "::" + port: 10080 + hostnames: + - "*" + path: + mergeSlashes: true + escapedSlashesAction: UnescapeAndRedirect + routes: + - name: "first-route" + hostname: "*" + pathMatch: + exact: "foo/bar" + security: + jwt: + # allowMissingOrFailed lets a valid token from one source pass even when + # another source carries an invalid token (maps to Envoy's + # allow_missing_or_failed). The two single-source providers are OR-ed. + allowMissingOrFailed: true + providers: + - name: example-cookie + issuer: https://www.example.com + audiences: + - foo.com + remoteJWKS: + uri: https://localhost/jwt/public-key/jwks.json + cacheDuration: 300s + extractFrom: + cookies: + - session_access_token + - name: example-header + issuer: https://www.example.com + audiences: + - foo.com + remoteJWKS: + uri: https://localhost/jwt/public-key/jwks.json + cacheDuration: 300s + extractFrom: + headers: + - name: Authorization + valuePrefix: 'Bearer ' + destination: + name: "first-route-dest" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 + name: "first-route-dest/backend/0" + - name: "second-route" + hostname: "*" + pathMatch: + exact: "foo/baz" + security: + jwt: + # allowMissingOrFailed supersedes allowMissing: with both set, only the + # allow_missing_or_failed requirement is emitted. + allowMissing: true + allowMissingOrFailed: true + providers: + - name: example + issuer: https://www.example.com + audiences: + - foo.com + remoteJWKS: + uri: https://localhost/jwt/public-key/jwks.json + cacheDuration: 300s + destination: + name: "second-route-dest" + settings: + - endpoints: + - host: "5.6.7.8" + port: 50000 + name: "second-route-dest/backend/0" diff --git a/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.clusters.yaml new file mode 100644 index 0000000000..7bba0835f2 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.clusters.yaml @@ -0,0 +1,88 @@ +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_PREFERRED + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: first-route-dest + ignoreHealthOnHostRemoval: true + loadBalancingPolicy: + policies: + - typedExtensionConfig: + name: envoy.load_balancing_policies.least_request + typedConfig: + '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest + localityLbConfig: + localityWeightedLbConfig: {} + name: first-route-dest + perConnectionBufferLimitBytes: 32768 + type: EDS +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_PREFERRED + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: second-route-dest + ignoreHealthOnHostRemoval: true + loadBalancingPolicy: + policies: + - typedExtensionConfig: + name: envoy.load_balancing_policies.least_request + typedConfig: + '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest + localityLbConfig: + localityWeightedLbConfig: {} + name: second-route-dest + perConnectionBufferLimitBytes: 32768 + type: EDS +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_PREFERRED + dnsRefreshRate: 30s + ignoreHealthOnHostRemoval: true + loadAssignment: + clusterName: localhost_443 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: localhost + portValue: 443 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: localhost_443/backend/-1 + loadBalancingPolicy: + policies: + - typedExtensionConfig: + name: envoy.load_balancing_policies.least_request + typedConfig: + '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest + localityLbConfig: + localityWeightedLbConfig: {} + name: localhost_443 + perConnectionBufferLimitBytes: 32768 + respectDnsTtl: true + transportSocket: + name: envoy.transport_sockets.tls + typedConfig: + '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + commonTlsContext: + validationContext: + trustedCa: + filename: /etc/ssl/certs/ca-certificates.crt + sni: localhost + type: STRICT_DNS diff --git a/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.endpoints.yaml new file mode 100644 index 0000000000..39102da400 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.endpoints.yaml @@ -0,0 +1,24 @@ +- clusterName: first-route-dest + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: first-route-dest/backend/0 +- clusterName: second-route-dest + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 5.6.7.8 + portValue: 50000 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: second-route-dest/backend/0 diff --git a/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.listeners.yaml new file mode 100644 index 0000000000..6b79a42d6d --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.listeners.yaml @@ -0,0 +1,107 @@ +- address: + socketAddress: + address: '::' + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + commonHttpProtocolOptions: + headersWithUnderscoresAction: REJECT_REQUEST + http2ProtocolOptions: + initialConnectionWindowSize: 1048576 + initialStreamWindowSize: 65536 + maxConcurrentStreams: 100 + httpFilters: + - name: envoy.filters.http.jwt_authn + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication + providers: + first-route/example-cookie: + audiences: + - foo.com + forward: true + fromCookies: + - session_access_token + issuer: https://www.example.com + normalizePayloadInMetadata: + spaceDelimitedClaims: + - scope + - scp + payloadInMetadata: example-cookie + remoteJwks: + asyncFetch: {} + cacheDuration: 300s + httpUri: + cluster: localhost_443 + timeout: 10s + uri: https://localhost/jwt/public-key/jwks.json + first-route/example-header: + audiences: + - foo.com + forward: true + fromHeaders: + - name: Authorization + valuePrefix: 'Bearer ' + issuer: https://www.example.com + normalizePayloadInMetadata: + spaceDelimitedClaims: + - scope + - scp + payloadInMetadata: example-header + remoteJwks: + asyncFetch: {} + cacheDuration: 300s + httpUri: + cluster: localhost_443 + timeout: 10s + uri: https://localhost/jwt/public-key/jwks.json + second-route/example: + audiences: + - foo.com + forward: true + issuer: https://www.example.com + normalizePayloadInMetadata: + spaceDelimitedClaims: + - scope + - scp + payloadInMetadata: example + remoteJwks: + asyncFetch: {} + cacheDuration: 300s + httpUri: + cluster: localhost_443 + timeout: 10s + uri: https://localhost/jwt/public-key/jwks.json + requirementMap: + first-route: + requiresAny: + requirements: + - providerName: first-route/example-cookie + - providerName: first-route/example-header + - allowMissingOrFailed: {} + second-route: + requiresAny: + requirements: + - providerName: second-route/example + - allowMissingOrFailed: {} + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + suppressEnvoyHeaders: true + mergeSlashes: true + normalizePath: true + pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: first-listener + serverHeaderTransformation: PASS_THROUGH + statPrefix: http-10080 + useRemoteAddress: true + name: first-listener + maxConnectionsToAcceptPerSocketEvent: 1 + name: first-listener + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.routes.yaml new file mode 100644 index 0000000000..7c52b6de16 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/jwt-allow-missing-or-failed.routes.yaml @@ -0,0 +1,29 @@ +- ignorePortInHostMatching: true + name: first-listener + virtualHosts: + - domains: + - '*' + name: first-listener/* + routes: + - match: + path: foo/bar + name: first-route + route: + cluster: first-route-dest + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.jwt_authn: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.PerRouteConfig + requirementName: first-route + - match: + path: foo/baz + name: second-route + route: + cluster: second-route-dest + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.jwt_authn: + '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.PerRouteConfig + requirementName: second-route diff --git a/release-notes/current/new_features/9313-support-failopen-to-tolerate-invalid-jwt.md b/release-notes/current/new_features/9313-support-failopen-to-tolerate-invalid-jwt.md new file mode 100644 index 0000000000..63c9a47ad7 --- /dev/null +++ b/release-notes/current/new_features/9313-support-failopen-to-tolerate-invalid-jwt.md @@ -0,0 +1 @@ +Added a `failOpen` field to JWT in `SecurityPolicy`, allowing a request to pass JWT authentication even when its JWT is missing or invalid (maps to Envoy's `allow_missing_or_failed`). Verified claims are still forwarded, so it should be paired with an Authorization policy for enforcement. diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 8c78804007..9e30d61e45 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -3526,7 +3526,8 @@ _Appears in:_ | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | -| `optional` | _boolean_ | true | | Optional determines whether a missing JWT is acceptable, defaulting to false if not specified.
Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT is presented. | +| `optional` | _boolean_ | true | | Optional determines whether a missing JWT is acceptable, defaulting to false if not specified.
Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT
is presented. See FailOpen if this is necessary for your use case. | +| `failOpen` | _boolean_ | true | | FailOpen lets a request pass JWT authentication even when its JWT is
missing or invalid, rather than being rejected. This helps when a header
that clients use to carry a JWT may also legitimately hold a non-JWT value
that the backend relies on.
A valid JWT is still verified and its claims forwarded as usual; only the
rejection of requests with a missing or invalid JWT is relaxed. Because this
does not enforce authentication on its own, pair it with an Authorization
policy when access needs to be restricted.
This is broader than Optional (which tolerates a missing JWT but still
rejects an invalid one) and takes precedence over it. | | `providers` | _[JWTProvider](#jwtprovider) array_ | true | | Providers defines the JSON Web Token (JWT) authentication provider type.
When multiple JWT providers are specified, the JWT is considered valid if
any of the providers successfully validate the JWT. For additional details,
see https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/jwt_authn_filter.html. | diff --git a/test/cel-validation/securitypolicy_test.go b/test/cel-validation/securitypolicy_test.go index 832ea4c17d..ab33fdff57 100644 --- a/test/cel-validation/securitypolicy_test.go +++ b/test/cel-validation/securitypolicy_test.go @@ -1180,6 +1180,37 @@ func TestSecurityPolicyTarget(t *testing.T) { "either remoteJWKS or localJWKS must be specified.", }, }, + { + desc: "jwt with both optional and failOpen", + mutate: func(sp *egv1a1.SecurityPolicy) { + sp.Spec = egv1a1.SecurityPolicySpec{ + JWT: &egv1a1.JWT{ + Optional: new(true), + FailOpen: new(true), + Providers: []egv1a1.JWTProvider{ + { + Name: "example", + RemoteJWKS: &egv1a1.RemoteJWKS{ + URI: "https://example.com/jwt/jwks.json", + }, + }, + }, + }, + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "eg", + }, + }, + }, + } + }, + wantErrors: []string{ + "optional and failOpen cannot both be set; failOpen already tolerates a missing JWT", + }, + }, { desc: "valueRef type of localJWKS without valueRef", mutate: func(sp *egv1a1.SecurityPolicy) { diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml index a612e04fae..d38275ff44 100644 --- a/test/helm/gateway-crds-helm/all.out.yaml +++ b/test/helm/gateway-crds-helm/all.out.yaml @@ -54157,10 +54157,26 @@ spec: description: JWT defines the configuration for JSON Web Token (JWT) authentication. properties: + failOpen: + description: |- + FailOpen lets a request pass JWT authentication even when its JWT is + missing or invalid, rather than being rejected. This helps when a header + that clients use to carry a JWT may also legitimately hold a non-JWT value + that the backend relies on. + + A valid JWT is still verified and its claims forwarded as usual; only the + rejection of requests with a missing or invalid JWT is relaxed. Because this + does not enforce authentication on its own, pair it with an Authorization + policy when access needs to be restricted. + + This is broader than Optional (which tolerates a missing JWT but still + rejects an invalid one) and takes precedence over it. + type: boolean optional: description: |- Optional determines whether a missing JWT is acceptable, defaulting to false if not specified. - Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT is presented. + Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT + is presented. See FailOpen if this is necessary for your use case. type: boolean providers: description: |- @@ -55820,6 +55836,10 @@ spec: required: - providers type: object + x-kubernetes-validations: + - message: optional and failOpen cannot both be set; failOpen already + tolerates a missing JWT + rule: '!(has(self.optional) && has(self.failOpen))' mergeType: description: |- MergeType determines how this configuration is merged with existing SecurityPolicy diff --git a/test/helm/gateway-crds-helm/e2e.out.yaml b/test/helm/gateway-crds-helm/e2e.out.yaml index b8b42a2d53..0d255a5c99 100644 --- a/test/helm/gateway-crds-helm/e2e.out.yaml +++ b/test/helm/gateway-crds-helm/e2e.out.yaml @@ -32130,10 +32130,26 @@ spec: description: JWT defines the configuration for JSON Web Token (JWT) authentication. properties: + failOpen: + description: |- + FailOpen lets a request pass JWT authentication even when its JWT is + missing or invalid, rather than being rejected. This helps when a header + that clients use to carry a JWT may also legitimately hold a non-JWT value + that the backend relies on. + + A valid JWT is still verified and its claims forwarded as usual; only the + rejection of requests with a missing or invalid JWT is relaxed. Because this + does not enforce authentication on its own, pair it with an Authorization + policy when access needs to be restricted. + + This is broader than Optional (which tolerates a missing JWT but still + rejects an invalid one) and takes precedence over it. + type: boolean optional: description: |- Optional determines whether a missing JWT is acceptable, defaulting to false if not specified. - Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT is presented. + Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT + is presented. See FailOpen if this is necessary for your use case. type: boolean providers: description: |- @@ -33793,6 +33809,10 @@ spec: required: - providers type: object + x-kubernetes-validations: + - message: optional and failOpen cannot both be set; failOpen already + tolerates a missing JWT + rule: '!(has(self.optional) && has(self.failOpen))' mergeType: description: |- MergeType determines how this configuration is merged with existing SecurityPolicy diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml index 276e43f622..72b3a1d654 100644 --- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml +++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml @@ -32130,10 +32130,26 @@ spec: description: JWT defines the configuration for JSON Web Token (JWT) authentication. properties: + failOpen: + description: |- + FailOpen lets a request pass JWT authentication even when its JWT is + missing or invalid, rather than being rejected. This helps when a header + that clients use to carry a JWT may also legitimately hold a non-JWT value + that the backend relies on. + + A valid JWT is still verified and its claims forwarded as usual; only the + rejection of requests with a missing or invalid JWT is relaxed. Because this + does not enforce authentication on its own, pair it with an Authorization + policy when access needs to be restricted. + + This is broader than Optional (which tolerates a missing JWT but still + rejects an invalid one) and takes precedence over it. + type: boolean optional: description: |- Optional determines whether a missing JWT is acceptable, defaulting to false if not specified. - Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT is presented. + Note: Even if optional is set to true, JWT authentication will still fail if an invalid JWT + is presented. See FailOpen if this is necessary for your use case. type: boolean providers: description: |- @@ -33793,6 +33809,10 @@ spec: required: - providers type: object + x-kubernetes-validations: + - message: optional and failOpen cannot both be set; failOpen already + tolerates a missing JWT + rule: '!(has(self.optional) && has(self.failOpen))' mergeType: description: |- MergeType determines how this configuration is merged with existing SecurityPolicy