diff --git a/config/crd/bases/k8s.nginx.org_policies.yaml b/config/crd/bases/k8s.nginx.org_policies.yaml index d5a51d49e3..46745fa740 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 01246997d7..512f816d13 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 166c030485..581fe23d00 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 3f27badf1c..f835656fb9 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 dd08f53014..91c592cb79 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/version2/templates_test.go b/internal/configs/version2/templates_test.go index 54a1a98b80..beaa511dfa 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.go b/internal/configs/virtualserver.go index fc17a4f45c..e6f1808415 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/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index 11c7700496..0b84c9e3f7 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/v1/types.go b/pkg/apis/configuration/v1/types.go index e700fd5dba..6d21cc8622 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 c698a455ba..96866c4821 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 } diff --git a/pkg/apis/configuration/validation/policy_test.go b/pkg/apis/configuration/validation/policy_test.go index 2a723839da..4dc5b2b739 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