Repository: nginx/kubernetes-ingress

Issue:
Add SNI for JWT policy
**Is your feature request related to a problem? Please describe.**

Network rules often rely on SNI to work, and we noticed that Nginx is not always sending the server name.

**Describe the solution you'd like**

One example is here: https://github.com/nginx/kubernetes-ingress/blob/main/internal/configs/version2/nginx-plus.virtualserver.tmpl#L230

It would be nice if all proxy_pass / external subrequests set SNI, like it is done here:

`        proxy_ssl_server_name on;                     # For SNI to the IdP`

**Describe alternatives you've considered**

None. Workaround is to use IP-addresses in firewalls etc.

**Additional context**

Acceptance Criteria:

Enable users to set:

proxy_ssl_server_name must be off by default, as it is the NGINX default.

proxy_ssl_name should be configurable, and the default value should be the NGINX default value.




Discussion:
@github-actions: Hi @anderius thanks for reporting! 

 Be sure to check out the [docs](https://docs.nginx.com/nginx-ingress-controller) and the [Contributing Guidelines](https://github.com/nginx/kubernetes-ingress/blob/main/CONTRIBUTING.md) while you wait for a human to take a look at this :slightly_smiling_face:

 Cheers!@fabriziofiorucci: Should be fixed by https://github.com/nginx/kubernetes-ingress/pull/7500

Solution patch:
diff --git a/config/crd/bases/k8s.nginx.org_policies.yaml b/config/crd/bases/k8s.nginx.org_policies.yaml
index d5a51d49e..46745fa74 100644
--- a/config/crd/bases/k8s.nginx.org_policies.yaml
+++ b/config/crd/bases/k8s.nginx.org_policies.yaml
@@ -141,6 +141,16 @@ spec:
                     type: string
                   secret:
                     type: string
+                  sniEnabled:
+                    description: Enables SNI (Server Name Indication) for the JWT
+                      policy. This is useful when the remote server requires SNI to
+                      serve the correct certificate.
+                    type: boolean
+                  sniName:
+                    description: The SNI name to use when connecting to the remote
+                      server. If not set, the hostname from the ``jwksURI`` will be
+                      used.
+                    type: string
                   token:
                     type: string
                 type: object
diff --git a/deploy/crds.yaml b/deploy/crds.yaml
index 01246997d..512f816d1 100644
--- a/deploy/crds.yaml
+++ b/deploy/crds.yaml
@@ -303,6 +303,16 @@ spec:
                     type: string
                   secret:
                     type: string
+                  sniEnabled:
+                    description: Enables SNI (Server Name Indication) for the JWT
+                      policy. This is useful when the remote server requires SNI to
+                      serve the correct certificate.
+                    type: boolean
+                  sniName:
+                    description: The SNI name to use when connecting to the remote
+                      server. If not set, the hostname from the ``jwksURI`` will be
+                      used.
+                    type: string
                   token:
                     type: string
                 type: object
diff --git a/internal/configs/version2/__snapshots__/templates_test.snap b/internal/configs/version2/__snapshots__/templates_test.snap
index 166c03048..581fe23d0 100644
--- a/internal/configs/version2/__snapshots__/templates_test.snap
+++ b/internal/configs/version2/__snapshots__/templates_test.snap
@@ -1115,6 +1115,8 @@ server {
         proxy_set_header Content-Length "";
         proxy_cache jwks_uri_cafe;
         proxy_cache_valid 200 12h;
+        proxy_ssl_server_name on;
+        proxy_ssl_name sni.idp.spec.example.com;
         proxy_set_header Host idp.spec.example.com;
         set $idp_backend idp.spec.example.com;
         proxy_pass https://$idp_backend:443/spec-keys;
@@ -1125,6 +1127,8 @@ server {
         proxy_set_header Content-Length "";
         proxy_cache jwks_uri_cafe;
         proxy_cache_valid 200 12h;
+        proxy_ssl_server_name on;
+        proxy_ssl_name sni.idp.spec.example.com;
         proxy_set_header Host idp.route.example.com;
         set $idp_backend idp.route.example.com;
         proxy_pass http://$idp_backend:80/route-keys;
diff --git a/internal/configs/version2/http.go b/internal/configs/version2/http.go
index 3f27badf1..f835656fb 100644
--- a/internal/configs/version2/http.go
+++ b/internal/configs/version2/http.go
@@ -438,10 +438,12 @@ type JWTAuth struct {
 
 // JwksURI defines the components of a JwksURI
 type JwksURI struct {
-	JwksScheme string
-	JwksHost   string
-	JwksPort   string
-	JwksPath   string
+	JwksScheme     string
+	JwksHost       string
+	JwksPort       string
+	JwksPath       string
+	JwksSNIName    string
+	JwksSNIEnabled bool
 }
 
 // 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 dd08f5301..91c592cb7 100644
--- a/internal/configs/version2/nginx-plus.virtualserver.tmpl
+++ b/internal/configs/version2/nginx-plus.virtualserver.tmpl
@@ -237,6 +237,12 @@ server {
         proxy_cache_valid 200 12h;
         {{- end }}
         {{- with .JwksURI }}
+        {{- if .JwksSNIEnabled }}
+        proxy_ssl_server_name on;
+        {{- if .JwksSNIName }}
+        proxy_ssl_name {{ .JwksSNIName }};
+        {{- end }}
+        {{- end }}
         proxy_set_header Host {{ .JwksHost }};
         set $idp_backend {{ .JwksHost }};
         proxy_pass {{ .JwksScheme}}://$idp_backend{{ if .JwksPort }}:{{ .JwksPort }}{{ end }}{{ .JwksPath }};
diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go
index fc17a4f45..e6f180841 100644
--- a/internal/configs/virtualserver.go
+++ b/internal/configs/virtualserver.go
@@ -1169,10 +1169,12 @@ func (p *policiesCfg) addJWTAuthConfig(
 		uri, _ := url.Parse(jwtAuth.JwksURI)
 
 		JwksURI := &version2.JwksURI{
-			JwksScheme: uri.Scheme,
-			JwksHost:   uri.Hostname(),
-			JwksPort:   uri.Port(),
-			JwksPath:   uri.Path,
+			JwksScheme:     uri.Scheme,
+			JwksHost:       uri.Hostname(),
+			JwksPort:       uri.Port(),
+			JwksPath:       uri.Path,
+			JwksSNIName:    jwtAuth.SNIName,
+			JwksSNIEnabled: jwtAuth.SNIEnabled,
 		}
 
 		p.JWTAuth.Auth = &version2.JWTAuth{
diff --git a/pkg/apis/configuration/v1/types.go b/pkg/apis/configuration/v1/types.go
index e700fd5db..6d21cc862 100644
--- a/pkg/apis/configuration/v1/types.go
+++ b/pkg/apis/configuration/v1/types.go
@@ -657,6 +657,10 @@ type JWTAuth struct {
 	Token    string `json:"token"`
 	JwksURI  string `json:"jwksURI"`
 	KeyCache string `json:"keyCache"`
+	// Enables SNI (Server Name Indication) for the JWT policy. This is useful when the remote server requires SNI to serve the correct certificate.
+	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"`
 }
 
 // BasicAuth holds HTTP Basic authentication configuration
diff --git a/pkg/apis/configuration/validation/policy.go b/pkg/apis/configuration/validation/policy.go
index c698a455b..96866c482 100644
--- a/pkg/apis/configuration/validation/policy.go
+++ b/pkg/apis/configuration/validation/policy.go
@@ -9,6 +9,7 @@ import (
 	"strings"
 	"unicode"
 
+	validation2 "github.com/nginx/kubernetes-ingress/internal/validation"
 	v1 "github.com/nginx/kubernetes-ingress/pkg/apis/configuration/v1"
 	"k8s.io/apimachinery/pkg/util/validation"
 	"k8s.io/apimachinery/pkg/util/validation/field"
@@ -198,6 +199,16 @@ func validateJWT(jwt *v1.JWTAuth, fieldPath *field.Path) field.ErrorList {
 		if jwt.KeyCache != "" {
 			allErrs = append(allErrs, field.Forbidden(fieldPath.Child("keyCache"), "key cache must not be used when using Secret"))
 		}
+
+		// If JwksURI is not set, then none of the SNI fields should be set.
+		if jwt.SNIEnabled {
+			return append(allErrs, field.Forbidden(fieldPath.Child("sniEnabled"), "sniEnabled can only be set when JwksURI is set"))
+		}
+
+		if jwt.SNIName != "" {
+			return append(allErrs, field.Forbidden(fieldPath.Child("sniName"), "sniName can only be set when JwksURI is set"))
+		}
+
 		return allErrs
 	}
 
@@ -213,7 +224,22 @@ func validateJWT(jwt *v1.JWTAuth, fieldPath *field.Path) field.ErrorList {
 		if jwt.KeyCache == "" {
 			allErrs = append(allErrs, field.Required(fieldPath.Child("keyCache"), "key cache must be set, example value: 1h"))
 		}
-		return allErrs
+
+		// if SNI server name is provided, but SNI is not enabled, return an error
+		if jwt.SNIName != "" && !jwt.SNIEnabled {
+			allErrs = append(allErrs, field.Forbidden(fieldPath.Child("sniServerName"), "sniServerName can only be set when sniEnabled is true"))
+		}
+
+		// if SNI is enabled and SNI server name is provided, make sure it's a valid URI
+		if jwt.SNIEnabled && jwt.SNIName != "" {
+			err := validation2.ValidateURI(jwt.SNIName,
+				validation2.WithAllowedSchemes("https"),
+				validation2.WithUserAllowed(false),
+				validation2.WithDefaultScheme("https"))
+			if err != nil {
+				allErrs = append(allErrs, field.Invalid(fieldPath.Child("sniServerName"), jwt.SNIName, "sniServerName is not a valid URI"))
+			}
+		}
 	}
 	return allErrs
 }



Test patch:
diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go
index 54a1a98b8..beaa511df 100644
--- a/internal/configs/version2/templates_test.go
+++ b/internal/configs/version2/templates_test.go
@@ -737,6 +737,15 @@ func TestExecuteVirtualServerTemplateWithJWKSWithToken(t *testing.T) {
 	if !bytes.Contains(got, []byte("proxy_cache_valid 200 12h;")) {
 		t.Error("want `proxy_cache_valid 200 12h;` in generated template")
 	}
+
+	if !bytes.Contains(got, []byte("proxy_ssl_server_name on;")) {
+		t.Error("want `proxy_ssl_server_name on;` in generated template")
+	}
+
+	if !bytes.Contains(got, []byte("proxy_ssl_name sni.idp.spec.example.com;")) {
+		t.Error("want `proxy_ssl_name sni.idp.spec.example.com;` in generated template")
+	}
+
 	snaps.MatchSnapshot(t, string(got))
 	t.Log(string(got))
 }
@@ -2345,10 +2354,12 @@ var (
 					Token:    "$http_token",
 					KeyCache: "1h",
 					JwksURI: JwksURI{
-						JwksScheme: "https",
-						JwksHost:   "idp.spec.example.com",
-						JwksPort:   "443",
-						JwksPath:   "/spec-keys",
+						JwksScheme:     "https",
+						JwksHost:       "idp.spec.example.com",
+						JwksPort:       "443",
+						JwksPath:       "/spec-keys",
+						JwksSNIEnabled: true,
+						JwksSNIName:    "sni.idp.spec.example.com",
 					},
 				},
 				"default/jwt-policy-route": {
@@ -2357,10 +2368,12 @@ var (
 					Token:    "$http_token",
 					KeyCache: "1h",
 					JwksURI: 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",
+						JwksSNIEnabled: true,
+						JwksSNIName:    "sni.idp.spec.example.com",
 					},
 				},
 			},
diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go
index 11c770049..0b84c9e3f 100644
--- a/internal/configs/virtualserver_test.go
+++ b/internal/configs/virtualserver_test.go
@@ -5641,9 +5641,11 @@ func TestGenerateVirtualServerConfigJWKSPolicy(t *testing.T) {
 				},
 				Spec: conf_v1.PolicySpec{
 					JWTAuth: &conf_v1.JWTAuth{
-						Realm:    "Spec Realm API",
-						JwksURI:  "https://idp.spec.example.com:443/spec-keys",
-						KeyCache: "1h",
+						Realm:      "Spec Realm API",
+						JwksURI:    "https://idp.spec.example.com:443/spec-keys",
+						KeyCache:   "1h",
+						SNIEnabled: true,
+						SNIName:    "idp.spec.example.com",
 					},
 				},
 			},
@@ -5713,10 +5715,12 @@ func TestGenerateVirtualServerConfigJWKSPolicy(t *testing.T) {
 					Realm:    "Spec Realm API",
 					KeyCache: "1h",
 					JwksURI: version2.JwksURI{
-						JwksScheme: "https",
-						JwksHost:   "idp.spec.example.com",
-						JwksPort:   "443",
-						JwksPath:   "/spec-keys",
+						JwksScheme:     "https",
+						JwksHost:       "idp.spec.example.com",
+						JwksPort:       "443",
+						JwksPath:       "/spec-keys",
+						JwksSNIEnabled: true,
+						JwksSNIName:    "idp.spec.example.com",
 					},
 				},
 				"default/jwt-policy-route": {
@@ -5736,10 +5740,12 @@ func TestGenerateVirtualServerConfigJWKSPolicy(t *testing.T) {
 				Realm:    "Spec Realm API",
 				KeyCache: "1h",
 				JwksURI: version2.JwksURI{
-					JwksScheme: "https",
-					JwksHost:   "idp.spec.example.com",
-					JwksPort:   "443",
-					JwksPath:   "/spec-keys",
+					JwksScheme:     "https",
+					JwksHost:       "idp.spec.example.com",
+					JwksPort:       "443",
+					JwksPath:       "/spec-keys",
+					JwksSNIName:    "idp.spec.example.com",
+					JwksSNIEnabled: true,
 				},
 			},
 			JWKSAuthEnabled: true,
diff --git a/pkg/apis/configuration/validation/policy_test.go b/pkg/apis/configuration/validation/policy_test.go
index 2a723839d..4dc5b2b73 100644
--- a/pkg/apis/configuration/validation/policy_test.go
+++ b/pkg/apis/configuration/validation/policy_test.go
@@ -95,6 +95,98 @@ func TestValidatePolicy_JWTIsNotValidOn(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "If JwksURI is not set, then none of the SNI fields should be set.",
+			policy: &v1.Policy{
+				Spec: v1.PolicySpec{
+					JWTAuth: &v1.JWTAuth{
+						Realm:      "My Product API",
+						Secret:     "my-jwk",
+						KeyCache:   "1h",
+						SNIName:    "ipd.org",
+						SNIEnabled: true,
+					},
+				},
+			},
+		},
+		{
+			name: "SNI server name passed, but SNI not enabled",
+			policy: &v1.Policy{
+				Spec: v1.PolicySpec{
+					JWTAuth: &v1.JWTAuth{
+						Realm:    "My Product API",
+						JwksURI:  "https://myjwksuri.com",
+						KeyCache: "1h",
+						SNIName:  "ipd.org",
+					},
+				},
+			},
+		},
+		{
+			name: "SNI server name passed, SNI enabled, bad SNI server name",
+			policy: &v1.Policy{
+				Spec: v1.PolicySpec{
+					JWTAuth: &v1.JWTAuth{
+						Realm:      "My Product API",
+						JwksURI:    "https://myjwksuri.com",
+						KeyCache:   "1h",
+						SNIEnabled: true,
+						SNIName:    "msql://ipd.org",
+					},
+				},
+			},
+		},
+		{
+			name: "SNI enabled, but no JwksURI",
+			policy: &v1.Policy{
+				Spec: v1.PolicySpec{
+					JWTAuth: &v1.JWTAuth{
+						Realm:      "My Product API",
+						Token:      "$cookie_auth_token",
+						SNIEnabled: true,
+					},
+				},
+			},
+		},
+		{
+			name: "Jwks URI not set, but SNIName is set",
+			policy: &v1.Policy{
+				Spec: v1.PolicySpec{
+					JWTAuth: &v1.JWTAuth{
+						Realm:   "My Product API",
+						Token:   "$cookie_auth_token",
+						SNIName: "https://idp.com",
+					},
+				},
+			},
+		},
+		{
+			name: "Jwks URI not set, Secret set, but SNIName is set and SNI is enabled",
+			policy: &v1.Policy{
+				Spec: v1.PolicySpec{
+					JWTAuth: &v1.JWTAuth{
+						Realm:      "My Product API",
+						Token:      "$cookie_auth_token",
+						Secret:     "my-jwk",
+						SNIName:    "https://idp.com",
+						SNIEnabled: true,
+					},
+				},
+			},
+		},
+		{
+			name: "Jwks URI not set, SNIName set, but SNI is not enabled",
+			policy: &v1.Policy{
+				Spec: v1.PolicySpec{
+					JWTAuth: &v1.JWTAuth{
+						Realm:   "My Product API",
+						Token:   "$cookie_auth_token",
+						Secret:  "my-jwk",
+						SNIName: "https://idp.com",
+					},
+				},
+			},
+		},
 	}
 
 	for _, tc := range tt {
@@ -164,6 +256,33 @@ func TestValidatePolicy_IsValidOnJWTPolicy(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "with SNI and without SNI server name",
+			policy: &v1.Policy{
+				Spec: v1.PolicySpec{
+					JWTAuth: &v1.JWTAuth{
+						Realm:      "My Product API",
+						KeyCache:   "1h",
+						JwksURI:    "https://login.mydomain.com/keys",
+						SNIEnabled: true,
+					},
+				},
+			},
+		},
+		{
+			name: "with SNI and with SNI server name",
+			policy: &v1.Policy{
+				Spec: v1.PolicySpec{
+					JWTAuth: &v1.JWTAuth{
+						Realm:      "My Product API",
+						KeyCache:   "1h",
+						JwksURI:    "https://login.mydomain.com/keys",
+						SNIEnabled: true,
+						SNIName:    "https://example.org",
+					},
+				},
+			},
+		},
 	}
 
 	for _, tc := range tt {
@@ -787,6 +906,27 @@ func TestValidateJWT_PassesOnValidInput(t *testing.T) {
 			},
 			msg: "jwt with jwksURI",
 		},
+		{
+			jwt: &v1.JWTAuth{
+				Realm:      "My Product API",
+				Token:      "$cookie_auth_token",
+				JwksURI:    "https://idp.com/token",
+				KeyCache:   "1h",
+				SNIEnabled: true,
+				SNIName:    "https://ipd.com:9999",
+			},
+			msg: "SNI enabled and valid SNI server name",
+		},
+		{
+			jwt: &v1.JWTAuth{
+				Realm:      "My Product API",
+				Token:      "$cookie_auth_token",
+				JwksURI:    "https://idp.com/token",
+				KeyCache:   "1h",
+				SNIEnabled: true,
+			},
+			msg: "SNI enabled and no server name passed",
+		},
 	}
 	for _, test := range tests {
 		allErrs := validateJWT(test.jwt, field.NewPath("jwt"))
@@ -890,6 +1030,35 @@ func TestValidateJWT_FailsOnInvalidInput(t *testing.T) {
 			},
 			msg: "invalid JwksURI",
 		},
+		{
+			jwt: &v1.JWTAuth{
+				Realm:      "My Product api",
+				JwksURI:    "https://idp.com/token",
+				KeyCache:   "1h",
+				SNIEnabled: true,
+				SNIName:    "msql://not-\\\\a-valid-sni",
+			},
+			msg: "invalid SNI server name",
+		},
+		{
+			jwt: &v1.JWTAuth{
+				Realm:      "My Product api",
+				JwksURI:    "https://idp.com/token",
+				KeyCache:   "1h",
+				SNIEnabled: false,
+				SNIName:    "https://idp.com",
+			},
+			msg: "SNI server name passed, SNI not enabled",
+		},
+		{
+			jwt: &v1.JWTAuth{
+				Realm:    "My Product api",
+				JwksURI:  "https://idp.com/token",
+				KeyCache: "1h",
+				SNIName:  "https://idp.com",
+			},
+			msg: "SNI server name passed, SNI not passed",
+		},
 	}
 	for _, test := range tests {
 		test := test