diff --git a/config/crd/bases/k8s.nginx.org_policies.yaml b/config/crd/bases/k8s.nginx.org_policies.yaml index 1ba74d4ecf..ee8c981a9d 100644 --- a/config/crd/bases/k8s.nginx.org_policies.yaml +++ b/config/crd/bases/k8s.nginx.org_policies.yaml @@ -304,6 +304,17 @@ spec: server. If not set, the hostname from the ``jwksURI`` will be used. type: string + sslVerify: + default: false + description: Enables verification of the JWKS server SSL certificate. + Default is false. + type: boolean + sslVerifyDepth: + default: 1 + description: Sets the verification depth in the JWKS server certificates + chain. The default is 1. + minimum: 0 + type: integer token: description: 'The token specifies a variable that contains the JSON Web Token. By default the JWT is passed in the Authorization @@ -311,6 +322,14 @@ spec: or a part of a query string, for example: $cookie_auth_token. Accepted variables are $http_, $arg_, $cookie_.' type: string + trustedCertSecret: + description: The name of the Kubernetes secret that stores the + CA certificate for JWKS server verification. It must be in the + same namespace as the Policy resource. The secret must be of + the type nginx.org/ca, and the certificate must be stored in + the secret under the key ca.crt. + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string type: object oidc: description: The OpenID Connect policy configures NGINX to authenticate diff --git a/deploy/crds.yaml b/deploy/crds.yaml index c79752f0ee..866ffe6f66 100644 --- a/deploy/crds.yaml +++ b/deploy/crds.yaml @@ -475,6 +475,17 @@ spec: server. If not set, the hostname from the ``jwksURI`` will be used. type: string + sslVerify: + default: false + description: Enables verification of the JWKS server SSL certificate. + Default is false. + type: boolean + sslVerifyDepth: + default: 1 + description: Sets the verification depth in the JWKS server certificates + chain. The default is 1. + minimum: 0 + type: integer token: description: 'The token specifies a variable that contains the JSON Web Token. By default the JWT is passed in the Authorization @@ -482,6 +493,14 @@ spec: or a part of a query string, for example: $cookie_auth_token. Accepted variables are $http_, $arg_, $cookie_.' type: string + trustedCertSecret: + description: The name of the Kubernetes secret that stores the + CA certificate for JWKS server verification. It must be in the + same namespace as the Policy resource. The secret must be of + the type nginx.org/ca, and the certificate must be stored in + the secret under the key ca.crt. + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string type: object oidc: description: The OpenID Connect policy configures NGINX to authenticate diff --git a/docs/crd/k8s.nginx.org_policies.md b/docs/crd/k8s.nginx.org_policies.md index cb37a32ae4..c4cf5c8f66 100644 --- a/docs/crd/k8s.nginx.org_policies.md +++ b/docs/crd/k8s.nginx.org_policies.md @@ -58,7 +58,10 @@ The `.spec` object supports the following fields: | `jwt.secret` | `string` | The name of the Kubernetes secret that stores the Htpasswd configuration. It must be in the same namespace as the Policy resource. The secret must be of the type nginx.org/htpasswd, and the config must be stored in the secret under the key htpasswd, otherwise the secret will be rejected as invalid. | | `jwt.sniEnabled` | `boolean` | Enables SNI (Server Name Indication) for the JWT policy. This is useful when the remote server requires SNI to serve the correct certificate. | | `jwt.sniName` | `string` | The SNI name to use when connecting to the remote server. If not set, the hostname from the ``jwksURI`` will be used. | +| `jwt.sslVerify` | `boolean` | Enables verification of the JWKS server SSL certificate. Default is false. | +| `jwt.sslVerifyDepth` | `integer` | Sets the verification depth in the JWKS server certificates chain. The default is 1. | | `jwt.token` | `string` | The token specifies a variable that contains the JSON Web Token. By default the JWT is passed in the Authorization header as a Bearer Token. JWT may be also passed as a cookie or a part of a query string, for example: $cookie_auth_token. Accepted variables are $http_, $arg_, $cookie_. | +| `jwt.trustedCertSecret` | `string` | The name of the Kubernetes secret that stores the CA certificate for JWKS server verification. It must be in the same namespace as the Policy resource. The secret must be of the type nginx.org/ca, and the certificate must be stored in the secret under the key ca.crt. | | `oidc` | `object` | The OpenID Connect policy configures NGINX to authenticate client requests by validating a JWT token against an OAuth2/OIDC token provider, such as Auth0 or Keycloak. | | `oidc.accessTokenEnable` | `boolean` | Option of whether Bearer token is used to authorize NGINX to access protected backend. | | `oidc.authEndpoint` | `string` | URL for the authorization endpoint provided by your OpenID Connect provider. | diff --git a/internal/configs/version2/__snapshots__/templates_test.snap b/internal/configs/version2/__snapshots__/templates_test.snap index f80dd355e3..829fbfad7c 100644 --- a/internal/configs/version2/__snapshots__/templates_test.snap +++ b/internal/configs/version2/__snapshots__/templates_test.snap @@ -1259,6 +1259,7 @@ server { proxy_set_header Content-Length ""; proxy_ssl_server_name on; proxy_ssl_name sni.idp.spec.example.com; + proxy_ssl_verify off; proxy_pass_request_headers off; proxy_pass_request_body off; proxy_set_header Host idp.spec.example.com; @@ -1271,6 +1272,7 @@ server { proxy_set_header Content-Length ""; proxy_ssl_server_name on; proxy_ssl_name sni.idp.spec.example.com; + proxy_ssl_verify off; proxy_pass_request_headers off; proxy_pass_request_body off; proxy_set_header Host idp.route.example.com; @@ -1380,6 +1382,7 @@ server { internal; proxy_method GET; proxy_set_header Content-Length ""; + proxy_ssl_verify off; proxy_pass_request_headers off; proxy_pass_request_body off; proxy_set_header Host idp.spec.example.com; @@ -1390,6 +1393,7 @@ server { internal; proxy_method GET; proxy_set_header Content-Length ""; + proxy_ssl_verify off; proxy_pass_request_headers off; proxy_pass_request_body off; proxy_set_header Host idp.route.example.com; diff --git a/internal/configs/version2/http.go b/internal/configs/version2/http.go index 007f45ee32..297a336b5e 100644 --- a/internal/configs/version2/http.go +++ b/internal/configs/version2/http.go @@ -448,6 +448,9 @@ type JwksURI struct { JwksPath string JwksSNIName string JwksSNIEnabled bool + SSLVerify bool + TrustedCert string + SSLVerifyDepth int } // BasicAuth refers to basic HTTP authentication mechanism options diff --git a/internal/configs/version2/nginx-plus.virtualserver.tmpl b/internal/configs/version2/nginx-plus.virtualserver.tmpl index 8ec53a528e..4cd3039d6f 100644 --- a/internal/configs/version2/nginx-plus.virtualserver.tmpl +++ b/internal/configs/version2/nginx-plus.virtualserver.tmpl @@ -293,6 +293,17 @@ server { proxy_ssl_name {{ .JwksSNIName }}; {{- end }} {{- end }} + {{- if .SSLVerify }} + proxy_ssl_verify on; + proxy_ssl_verify_depth {{ .SSLVerifyDepth }}; + {{- if .TrustedCert }} + proxy_ssl_trusted_certificate {{ .TrustedCert }}; + {{- else }} + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; + {{- end }} + {{- else }} + proxy_ssl_verify off; + {{- end }} proxy_pass_request_headers off; proxy_pass_request_body off; proxy_set_header Host {{ .JwksHost }}; diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go index 1eb15577f2..cb3052a2e3 100644 --- a/internal/configs/version2/templates_test.go +++ b/internal/configs/version2/templates_test.go @@ -3031,4 +3031,161 @@ var ( }, }, } + + // JWT SSL Verification test configs + virtualServerCfgWithJWTSSLDefaultCert = VirtualServerConfig{ + Upstreams: []Upstream{ + { + Name: "vs_default_cafe_tea", + Servers: []UpstreamServer{ + {Address: "10.0.0.20:80"}, + }, + }, + }, + Server: Server{ + JWTAuthList: map[string]*JWTAuth{ + "default/jwt-ssl-policy": { + Key: "default/jwt-ssl-policy", + Realm: "SSL API", + KeyCache: "1h", + JwksURI: JwksURI{ + JwksScheme: "https", + JwksHost: "idp.example.com", + JwksPath: "/keys", + SSLVerify: true, + SSLVerifyDepth: 1, + }, + }, + }, + Locations: []Location{ + { + Path: "/", + ProxyPass: "http://vs_default_cafe_tea", + JWTAuth: &JWTAuth{ + Key: "default/jwt-ssl-policy", + }, + }, + }, + }, + } + + virtualServerCfgWithJWTSSLSecretCert = VirtualServerConfig{ + Upstreams: []Upstream{ + { + Name: "vs_default_cafe_tea", + Servers: []UpstreamServer{ + {Address: "10.0.0.20:80"}, + }, + }, + }, + Server: Server{ + JWTAuthList: map[string]*JWTAuth{ + "default/jwt-ssl-policy": { + Key: "default/jwt-ssl-policy", + Realm: "SSL API", + KeyCache: "1h", + JwksURI: JwksURI{ + JwksScheme: "https", + JwksHost: "idp.example.com", + JwksPath: "/keys", + SSLVerify: true, + TrustedCert: "/etc/nginx/secrets/default-my-ca-secret", + SSLVerifyDepth: 2, + }, + }, + }, + Locations: []Location{ + { + Path: "/", + ProxyPass: "http://vs_default_cafe_tea", + JWTAuth: &JWTAuth{ + Key: "default/jwt-ssl-policy", + }, + }, + }, + }, + } + + virtualServerCfgWithJWTNoSSL = VirtualServerConfig{ + Upstreams: []Upstream{ + { + Name: "vs_default_cafe_tea", + Servers: []UpstreamServer{ + {Address: "10.0.0.20:80"}, + }, + }, + }, + Server: Server{ + JWTAuthList: map[string]*JWTAuth{ + "default/jwt-no-ssl-policy": { + Key: "default/jwt-no-ssl-policy", + Realm: "No SSL API", + KeyCache: "1h", + JwksURI: JwksURI{ + JwksScheme: "https", + JwksHost: "idp.example.com", + JwksPath: "/keys", + SSLVerify: false, + }, + }, + }, + Locations: []Location{ + { + Path: "/", + ProxyPass: "http://vs_default_cafe_tea", + JWTAuth: &JWTAuth{ + Key: "default/jwt-no-ssl-policy", + }, + }, + }, + }, + } ) + +func TestJWTSSLVerificationDefaultCert(t *testing.T) { + t.Parallel() + executor := newTmplExecutorNGINXPlus(t) + got, err := executor.ExecuteVirtualServerTemplate(&virtualServerCfgWithJWTSSLDefaultCert) + if err != nil { + t.Error(err) + } + if !bytes.Contains(got, []byte("proxy_ssl_verify on;")) { + t.Error("want `proxy_ssl_verify on;` in generated template") + } + if !bytes.Contains(got, []byte("proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;")) { + t.Error("want system CA bundle path in generated template") + } +} + +func TestJWTSSLVerificationSecretCert(t *testing.T) { + t.Parallel() + executor := newTmplExecutorNGINXPlus(t) + got, err := executor.ExecuteVirtualServerTemplate(&virtualServerCfgWithJWTSSLSecretCert) + if err != nil { + t.Error(err) + } + if !bytes.Contains(got, []byte("proxy_ssl_verify on;")) { + t.Error("want `proxy_ssl_verify on;` in generated template") + } + if !bytes.Contains(got, []byte("proxy_ssl_trusted_certificate /etc/nginx/secrets/default-my-ca-secret;")) { + t.Error("want custom CA secret path in generated template") + } + if !bytes.Contains(got, []byte("proxy_ssl_verify_depth 2;")) { + t.Error("want custom SSL verify depth in generated template") + } +} + +func TestJWTNoSSLVerification(t *testing.T) { + t.Parallel() + executor := newTmplExecutorNGINXPlus(t) + got, err := executor.ExecuteVirtualServerTemplate(&virtualServerCfgWithJWTNoSSL) + if err != nil { + t.Error(err) + } + if !bytes.Contains(got, []byte("proxy_ssl_verify off;")) { + t.Error("want `proxy_ssl_verify off;` in generated template") + } + if bytes.Contains(got, []byte("proxy_ssl_trusted_certificate")) { + t.Error("want no SSL trusted certificate directive in generated template") + } +} diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go index 94f63ed5f4..d604c66777 100644 --- a/internal/configs/virtualserver.go +++ b/internal/configs/virtualserver.go @@ -1188,6 +1188,44 @@ func (p *policiesCfg) addJWTAuthConfig( } else if jwtAuth.JwksURI != "" { uri, _ := url.Parse(jwtAuth.JwksURI) + // Handle SSL verification for JWKS + var trustedCertPath string + if jwtAuth.SSLVerify && jwtAuth.TrustedCertSecret != "" { + trustedCertSecretKey := fmt.Sprintf("%s/%s", polNamespace, jwtAuth.TrustedCertSecret) + trustedCertSecretRef := secretRefs[trustedCertSecretKey] + + // Check if secret reference exists + if trustedCertSecretRef == nil { + res.addWarningf("JWT policy %s references a non-existent trusted cert secret %s", polKey, trustedCertSecretKey) + res.isError = true + return res + } + + var secretType api_v1.SecretType + if trustedCertSecretRef.Secret != nil { + secretType = trustedCertSecretRef.Secret.Type + } + if secretType != "" && secretType != secrets.SecretTypeCA { + res.addWarningf("JWT policy %s references a secret %s of a wrong type '%s', must be '%s'", polKey, trustedCertSecretKey, secretType, secrets.SecretTypeCA) + res.isError = true + return res + } else if trustedCertSecretRef.Error != nil { + res.addWarningf("JWT policy %s references an invalid trusted cert secret %s: %v", polKey, trustedCertSecretKey, trustedCertSecretRef.Error) + res.isError = true + return res + } + + caFields := strings.Fields(trustedCertSecretRef.Path) + if len(caFields) > 0 { + trustedCertPath = caFields[0] + } + } + + sslVerifyDepth := 1 + if jwtAuth.SSLVerifyDepth != nil { + sslVerifyDepth = *jwtAuth.SSLVerifyDepth + } + JwksURI := &version2.JwksURI{ JwksScheme: uri.Scheme, JwksHost: uri.Hostname(), @@ -1195,6 +1233,9 @@ func (p *policiesCfg) addJWTAuthConfig( JwksPath: uri.Path, JwksSNIName: jwtAuth.SNIName, JwksSNIEnabled: jwtAuth.SNIEnabled, + SSLVerify: jwtAuth.SSLVerify, + TrustedCert: trustedCertPath, + SSLVerifyDepth: sslVerifyDepth, } p.JWTAuth.Auth = &version2.JWTAuth{ diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index efd59ac345..d3d0f6702d 100644 --- a/internal/configs/virtualserver_test.go +++ b/internal/configs/virtualserver_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/nginx/kubernetes-ingress/internal/configs/version2" "github.com/nginx/kubernetes-ingress/internal/k8s/secrets" nl "github.com/nginx/kubernetes-ingress/internal/logger" @@ -5722,6 +5723,9 @@ func TestGenerateVirtualServerConfigJWKSPolicy(t *testing.T) { JwksPath: "/spec-keys", JwksSNIEnabled: true, JwksSNIName: "idp.spec.example.com", + SSLVerify: false, + TrustedCert: "", + SSLVerifyDepth: 1, }, }, "default/jwt-policy-route": { @@ -5729,10 +5733,13 @@ func TestGenerateVirtualServerConfigJWKSPolicy(t *testing.T) { Realm: "Route Realm API", KeyCache: "1h", JwksURI: version2.JwksURI{ - JwksScheme: "http", - JwksHost: "idp.route.example.com", - JwksPort: "80", - JwksPath: "/route-keys", + JwksScheme: "http", + JwksHost: "idp.route.example.com", + JwksPort: "80", + JwksPath: "/route-keys", + SSLVerify: false, + TrustedCert: "", + SSLVerifyDepth: 1, }, }, }, @@ -5747,6 +5754,9 @@ func TestGenerateVirtualServerConfigJWKSPolicy(t *testing.T) { JwksPath: "/spec-keys", JwksSNIName: "idp.spec.example.com", JwksSNIEnabled: true, + SSLVerify: false, + TrustedCert: "", + SSLVerifyDepth: 1, }, }, JWKSAuthEnabled: true, @@ -5778,10 +5788,13 @@ func TestGenerateVirtualServerConfigJWKSPolicy(t *testing.T) { Realm: "Route Realm API", KeyCache: "1h", JwksURI: version2.JwksURI{ - JwksScheme: "http", - JwksHost: "idp.route.example.com", - JwksPort: "80", - JwksPath: "/route-keys", + JwksScheme: "http", + JwksHost: "idp.route.example.com", + JwksPort: "80", + JwksPath: "/route-keys", + SSLVerify: false, + TrustedCert: "", + SSLVerifyDepth: 1, }, }, }, @@ -5801,10 +5814,13 @@ func TestGenerateVirtualServerConfigJWKSPolicy(t *testing.T) { Realm: "Route Realm API", KeyCache: "1h", JwksURI: version2.JwksURI{ - JwksScheme: "http", - JwksHost: "idp.route.example.com", - JwksPort: "80", - JwksPath: "/route-keys", + JwksScheme: "http", + JwksHost: "idp.route.example.com", + JwksPort: "80", + JwksPath: "/route-keys", + SSLVerify: false, + TrustedCert: "", + SSLVerifyDepth: 1, }, }, }, @@ -5843,6 +5859,183 @@ func TestGenerateVirtualServerConfigJWKSPolicy(t *testing.T) { } } +func TestGenerateVirtualServerConfigJWTSSLVerifyDepth(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sslVerifyDepth *int + expectedDepth int + description string + }{ + { + name: "default_depth", + sslVerifyDepth: nil, // Not specified - should default to 1 + expectedDepth: 1, + description: "When SSLVerifyDepth is not specified, it should default to 1", + }, + { + name: "explicit_depth", + sslVerifyDepth: createPointerFromInt(3), // Explicitly set to 3 + expectedDepth: 3, + description: "When SSLVerifyDepth is explicitly set, it should respect that value", + }, + { + name: "explicit_zero_depth", + sslVerifyDepth: createPointerFromInt(0), // Explicitly set to 0 + expectedDepth: 0, + description: "When SSLVerifyDepth is explicitly set to 0, it should respect that value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + virtualServerEx := VirtualServerEx{ + VirtualServer: &conf_v1.VirtualServer{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "cafe", + Namespace: "default", + }, + Spec: conf_v1.VirtualServerSpec{ + Host: "cafe.example.com", + Policies: []conf_v1.PolicyReference{ + { + Name: "jwt-ssl-policy", + }, + }, + Upstreams: []conf_v1.Upstream{ + { + Name: "tea", + Service: "tea-svc", + Port: 80, + }, + }, + Routes: []conf_v1.Route{ + { + Path: "/tea", + Action: &conf_v1.Action{ + Pass: "tea", + }, + }, + }, + }, + }, + Policies: map[string]*conf_v1.Policy{ + "default/jwt-ssl-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "jwt-ssl-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + JWTAuth: &conf_v1.JWTAuth{ + Realm: "SSL Test API", + JwksURI: "https://idp.example.com/keys", + SSLVerify: true, + SSLVerifyDepth: tt.sslVerifyDepth, + }, + }, + }, + }, + Endpoints: map[string][]string{ + "default/tea-svc:80": { + "10.0.0.20:80", + }, + }, + } + + expected := version2.VirtualServerConfig{ + Upstreams: []version2.Upstream{ + { + UpstreamLabels: version2.UpstreamLabels{ + Service: "tea-svc", + ResourceType: "virtualserver", + ResourceName: "cafe", + ResourceNamespace: "default", + }, + Name: "vs_default_cafe_tea", + Servers: []version2.UpstreamServer{ + { + Address: "10.0.0.20:80", + }, + }, + Keepalive: 16, + }, + }, + HTTPSnippets: []string{}, + LimitReqZones: []version2.LimitReqZone{}, + Server: version2.Server{ + JWTAuthList: map[string]*version2.JWTAuth{ + "default/jwt-ssl-policy": { + Key: "default/jwt-ssl-policy", + Realm: "SSL Test API", + JwksURI: version2.JwksURI{ + JwksScheme: "https", + JwksHost: "idp.example.com", + JwksPath: "/keys", + SSLVerify: true, + SSLVerifyDepth: tt.expectedDepth, + }, + }, + }, + JWTAuth: &version2.JWTAuth{ + Key: "default/jwt-ssl-policy", + Realm: "SSL Test API", + JwksURI: version2.JwksURI{ + JwksScheme: "https", + JwksHost: "idp.example.com", + JwksPath: "/keys", + SSLVerify: true, + SSLVerifyDepth: tt.expectedDepth, + }, + }, + JWKSAuthEnabled: true, + ServerName: "cafe.example.com", + StatusZone: "cafe.example.com", + ProxyProtocol: true, + ServerTokens: "off", + RealIPHeader: "X-Real-IP", + SetRealIPFrom: []string{"0.0.0.0/0"}, + RealIPRecursive: true, + Snippets: []string{"# server snippet"}, + VSNamespace: "default", + VSName: "cafe", + Locations: []version2.Location{ + { + Path: "/tea", + ProxyPass: "http://vs_default_cafe_tea", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "0s", + ProxyNextUpstreamTries: 0, + HasKeepalive: true, + ProxySSLName: "tea-svc.default.svc", + ProxyPassRequestHeaders: true, + ProxySetHeaders: []version2.Header{{Name: "Host", Value: "$host"}}, + ServiceName: "tea-svc", + }, + }, + }, + } + + isPlus := false + isResolverConfigured := false + isWildcardEnabled := false + vsc := newVirtualServerConfigurator(&baseCfgParams, isPlus, isResolverConfigured, &StaticConfigParams{}, isWildcardEnabled, &fakeBV) + + result, warnings := vsc.GenerateVirtualServerConfig(&virtualServerEx, nil, nil) + + if diff := cmp.Diff(expected, result); diff != "" { + t.Errorf("%s: GenerateVirtualServerConfig() mismatch (-want +got):\n%s", tt.description, diff) + } + + if len(warnings) != 0 { + t.Errorf("%s: GenerateVirtualServerConfig returned warnings: %v", tt.description, vsc.warnings) + } + }) + } +} + func TestGenerateVirtualServerConfigAPIKeyPolicy(t *testing.T) { t.Parallel() @@ -12413,10 +12606,15 @@ func TestGeneratePolicies(t *testing.T) { Key: "default/jwt-policy-2", Realm: "My Test API", JwksURI: version2.JwksURI{ - JwksScheme: "https", - JwksHost: "idp.example.com", - JwksPort: "443", - JwksPath: "/keys", + JwksScheme: "https", + JwksHost: "idp.example.com", + JwksPort: "443", + JwksPath: "/keys", + JwksSNIName: "", + JwksSNIEnabled: false, + SSLVerify: false, + TrustedCert: "", + SSLVerifyDepth: 1, }, KeyCache: "1h", }, @@ -12454,10 +12652,15 @@ func TestGeneratePolicies(t *testing.T) { Key: "default/jwt-policy-2", Realm: "My Test API", JwksURI: version2.JwksURI{ - JwksScheme: "https", - JwksHost: "idp.example.com", - JwksPort: "", - JwksPath: "/keys", + JwksScheme: "https", + JwksHost: "idp.example.com", + JwksPort: "", + JwksPath: "/keys", + JwksSNIName: "", + JwksSNIEnabled: false, + SSLVerify: false, + TrustedCert: "", + SSLVerifyDepth: 1, }, KeyCache: "1h", }, @@ -13053,7 +13256,7 @@ func TestGeneratePolicies(t *testing.T) { result.BundleValidator = nil if !reflect.DeepEqual(tc.expected, result) { - t.Error(cmp.Diff(tc.expected, result)) + t.Error(cmp.Diff(tc.expected, result, cmpopts.IgnoreFields(policiesCfg{}, "Context"))) } if len(vsc.warnings) > 0 { t.Errorf("generatePolicies() returned unexpected warnings %v for the case of %s", vsc.warnings, tc.msg) diff --git a/internal/k8s/controller.go b/internal/k8s/controller.go index a4ffdc54da..c3ac934671 100644 --- a/internal/k8s/controller.go +++ b/internal/k8s/controller.go @@ -2415,6 +2415,10 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. if err != nil { nl.Warnf(lbc.Logger, "Error getting EgressMTLS secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) } + err = lbc.addJWTTrustedCertSecretRefs(virtualServerEx.SecretRefs, policies) + if err != nil { + nl.Warnf(lbc.Logger, "Error getting JWT trusted cert secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) + } err = lbc.addOIDCSecretRefs(virtualServerEx.SecretRefs, policies) if err != nil { nl.Warnf(lbc.Logger, "Error getting OIDC secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) @@ -2528,6 +2532,10 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. if err != nil { nl.Warnf(lbc.Logger, "Error getting EgressMTLS secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) } + err = lbc.addJWTTrustedCertSecretRefs(virtualServerEx.SecretRefs, vsRoutePolicies) + if err != nil { + nl.Warnf(lbc.Logger, "Error getting JWT trusted cert secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) + } err = lbc.addWAFPolicyRefs(virtualServerEx.ApPolRefs, virtualServerEx.LogConfRefs, vsRoutePolicies) if err != nil { @@ -2576,6 +2584,10 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. if err != nil { nl.Warnf(lbc.Logger, "Error getting EgressMTLS secrets for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) } + err = lbc.addJWTTrustedCertSecretRefs(virtualServerEx.SecretRefs, vsrSubroutePolicies) + if err != nil { + nl.Warnf(lbc.Logger, "Error getting JWT trusted cert secrets for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) + } err = lbc.addOIDCSecretRefs(virtualServerEx.SecretRefs, vsrSubroutePolicies) if err != nil { @@ -2828,6 +2840,26 @@ func (lbc *LoadBalancerController) addEgressMTLSSecretRefs(secretRefs map[string return nil } +func (lbc *LoadBalancerController) addJWTTrustedCertSecretRefs(secretRefs map[string]*secrets.SecretReference, policies []*conf_v1.Policy) error { + for _, pol := range policies { + if pol.Spec.JWTAuth == nil { + continue + } + if pol.Spec.JWTAuth.TrustedCertSecret != "" { + secretKey := fmt.Sprintf("%v/%v", pol.Namespace, pol.Spec.JWTAuth.TrustedCertSecret) + secretRef := lbc.secretStore.GetSecret(secretKey) + + secretRefs[secretKey] = secretRef + + if secretRef.Error != nil { + return secretRef.Error + } + } + } + + return nil +} + func (lbc *LoadBalancerController) addOIDCSecretRefs(secretRefs map[string]*secrets.SecretReference, policies []*conf_v1.Policy) error { for _, pol := range policies { if pol.Spec.OIDC == nil { @@ -2881,6 +2913,8 @@ func findPoliciesForSecret(policies []*conf_v1.Policy, secretNamespace string, s res = append(res, pol) } else if pol.Spec.JWTAuth != nil && pol.Spec.JWTAuth.Secret == secretName && pol.Namespace == secretNamespace { res = append(res, pol) + } else if pol.Spec.JWTAuth != nil && pol.Spec.JWTAuth.TrustedCertSecret == secretName && pol.Namespace == secretNamespace { + res = append(res, pol) } else if pol.Spec.BasicAuth != nil && pol.Spec.BasicAuth.Secret == secretName && pol.Namespace == secretNamespace { res = append(res, pol) } else if pol.Spec.EgressMTLS != nil && pol.Spec.EgressMTLS.TLSSecret == secretName && pol.Namespace == secretNamespace { diff --git a/pkg/apis/configuration/v1/types.go b/pkg/apis/configuration/v1/types.go index faecf382ce..5c0c387196 100644 --- a/pkg/apis/configuration/v1/types.go +++ b/pkg/apis/configuration/v1/types.go @@ -893,6 +893,16 @@ type JWTAuth struct { SNIEnabled bool `json:"sniEnabled"` // The SNI name to use when connecting to the remote server. If not set, the hostname from the ``jwksURI`` will be used. SNIName string `json:"sniName"` + // Enables verification of the JWKS server SSL certificate. Default is false. + // +kubebuilder:default:=false + SSLVerify bool `json:"sslVerify"` + // The name of the Kubernetes secret that stores the CA certificate for JWKS server verification. It must be in the same namespace as the Policy resource. The secret must be of the type nginx.org/ca, and the certificate must be stored in the secret under the key ca.crt. + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + TrustedCertSecret string `json:"trustedCertSecret"` + // Sets the verification depth in the JWKS server certificates chain. The default is 1. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:default:=1 + SSLVerifyDepth *int `json:"sslVerifyDepth"` } // BasicAuth holds HTTP Basic authentication configuration diff --git a/pkg/apis/configuration/v1/zz_generated.deepcopy.go b/pkg/apis/configuration/v1/zz_generated.deepcopy.go index 943fb76a03..9cc0b9da8a 100644 --- a/pkg/apis/configuration/v1/zz_generated.deepcopy.go +++ b/pkg/apis/configuration/v1/zz_generated.deepcopy.go @@ -534,6 +534,11 @@ func (in *IngressMTLS) DeepCopy() *IngressMTLS { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *JWTAuth) DeepCopyInto(out *JWTAuth) { *out = *in + if in.SSLVerifyDepth != nil { + in, out := &in.SSLVerifyDepth, &out.SSLVerifyDepth + *out = new(int) + **out = **in + } return } @@ -731,7 +736,7 @@ func (in *PolicySpec) DeepCopyInto(out *PolicySpec) { if in.JWTAuth != nil { in, out := &in.JWTAuth, &out.JWTAuth *out = new(JWTAuth) - **out = **in + (*in).DeepCopyInto(*out) } if in.BasicAuth != nil { in, out := &in.BasicAuth, &out.BasicAuth diff --git a/pkg/apis/configuration/validation/policy.go b/pkg/apis/configuration/validation/policy.go index 9d4d1bb760..af0e2bc298 100644 --- a/pkg/apis/configuration/validation/policy.go +++ b/pkg/apis/configuration/validation/policy.go @@ -246,6 +246,14 @@ func validateJWT(jwt *v1.JWTAuth, fieldPath *field.Path) field.ErrorList { allErrs = append(allErrs, field.Invalid(fieldPath.Child("sniServerName"), jwt.SNIName, "sniServerName is not a valid URI")) } } + + if jwt.TrustedCertSecret != "" { + allErrs = append(allErrs, validateSecretName(jwt.TrustedCertSecret, fieldPath.Child("trustedCertSecret"))...) + // If trustedCertSecret is set but sslVerify is false, warn user + if !jwt.SSLVerify { + allErrs = append(allErrs, field.Invalid(fieldPath.Child("sslVerify"), jwt.SSLVerify, "sslVerify should be enabled when trustedCertSecret is specified")) + } + } } return allErrs } diff --git a/pkg/apis/configuration/validation/policy_test.go b/pkg/apis/configuration/validation/policy_test.go index 24339073dc..0ba5667edb 100644 --- a/pkg/apis/configuration/validation/policy_test.go +++ b/pkg/apis/configuration/validation/policy_test.go @@ -8,6 +8,10 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" ) +func intPtr(n int) *int { + return &n +} + func TestValidatePolicy_JWTIsNotValidOn(t *testing.T) { t.Parallel() @@ -188,6 +192,62 @@ func TestValidatePolicy_JWTIsNotValidOn(t *testing.T) { }, }, }, + { + name: "SSL verification enabled but no trusted cert secret", + policy: &v1.Policy{ + Spec: v1.PolicySpec{ + JWTAuth: &v1.JWTAuth{ + Realm: "My Product API", + JwksURI: "https://myjwksuri.com", + KeyCache: "1h", + SSLVerify: true, + }, + }, + }, + }, + { + name: "Trusted cert secret provided but SSL verification disabled", + policy: &v1.Policy{ + Spec: v1.PolicySpec{ + JWTAuth: &v1.JWTAuth{ + Realm: "My Product API", + JwksURI: "https://myjwksuri.com", + KeyCache: "1h", + SSLVerify: false, + TrustedCertSecret: "my-ca-secret", + }, + }, + }, + }, + { + name: "Invalid SSL verify depth", + policy: &v1.Policy{ + Spec: v1.PolicySpec{ + JWTAuth: &v1.JWTAuth{ + Realm: "My Product API", + JwksURI: "https://myjwksuri.com", + KeyCache: "1h", + SSLVerify: true, + TrustedCertSecret: "my-ca-secret", + SSLVerifyDepth: intPtr(0), + }, + }, + }, + }, + { + name: "Invalid trusted cert secret name with special characters", + policy: &v1.Policy{ + Spec: v1.PolicySpec{ + JWTAuth: &v1.JWTAuth{ + Realm: "My Product API", + JwksURI: "https://myjwksuri.com", + KeyCache: "1h", + SSLVerify: true, + TrustedCertSecret: "my-ca-secret.invalid!", + }, + }, + }, + }, } for _, tc := range tt { @@ -284,6 +344,51 @@ func TestValidatePolicy_IsValidOnJWTPolicy(t *testing.T) { }, }, }, + { + name: "with SSL verification and trusted cert secret", + policy: &v1.Policy{ + Spec: v1.PolicySpec{ + JWTAuth: &v1.JWTAuth{ + Realm: "My Product API", + KeyCache: "1h", + JwksURI: "https://login.mydomain.com/keys", + SSLVerify: true, + TrustedCertSecret: "my-ca-secret", + }, + }, + }, + }, + { + name: "with SSL verification and custom verify depth", + policy: &v1.Policy{ + Spec: v1.PolicySpec{ + JWTAuth: &v1.JWTAuth{ + Realm: "My Product API", + KeyCache: "1h", + JwksURI: "https://login.mydomain.com/keys", + SSLVerify: true, + TrustedCertSecret: "my-ca-secret", + SSLVerifyDepth: intPtr(2), + }, + }, + }, + }, + { + name: "with SSL verification and SNI", + policy: &v1.Policy{ + Spec: v1.PolicySpec{ + JWTAuth: &v1.JWTAuth{ + Realm: "My Product API", + KeyCache: "1h", + JwksURI: "https://login.mydomain.com/keys", + SSLVerify: true, + TrustedCertSecret: "my-ca-secret", + SNIEnabled: true, + SNIName: "login.mydomain.com", + }, + }, + }, + }, } for _, tc := range tt {