From b61dc8f11617738cb5d3f3bf4c142c90d37b50ef Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Fri, 14 Nov 2025 16:28:31 +0000 Subject: [PATCH 01/17] Add proxy_ssl_verify and related nginx directives to OIDC configuration --- internal/configs/oidc/oidc.conf | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/configs/oidc/oidc.conf b/internal/configs/oidc/oidc.conf index 67e1a84f2f..e7b4c5209d 100644 --- a/internal/configs/oidc/oidc.conf +++ b/internal/configs/oidc/oidc.conf @@ -12,7 +12,12 @@ proxy_cache jwk; # Cache the JWK Set received from IdP proxy_cache_valid 200 12h; # How long to consider keys "fresh" proxy_cache_use_stale error timeout updating; # Use old JWK Set if cannot reach IdP - proxy_ssl_server_name on; # For SNI to the IdP + + proxy_ssl_verify on; # Enforce TLS certificate verification + proxy_ssl_verify_depth 2; # Allow intermediate CA chains of depth 2 + proxy_ssl_server_name on; # Send SNI to IdP host + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; # Use system CA bundle + proxy_method GET; # In case client request was non-GET proxy_set_header Content-Length ""; # '' proxy_pass $oidc_jwt_keyfile; # Expecting to find a URI here @@ -42,7 +47,11 @@ # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) proxy_pass_request_headers off; - proxy_ssl_server_name on; # For SNI to the IdP + proxy_ssl_verify on; # Enforce TLS certificate verification + proxy_ssl_verify_depth 2; # Allow intermediate CA chains of depth 2 + proxy_ssl_server_name on; # Send SNI to IdP host + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; # Use system CA bundle + proxy_set_header Content-Type "application/x-www-form-urlencoded"; proxy_set_header Authorization $arg_secret_basic; proxy_pass $oidc_token_endpoint; @@ -57,7 +66,11 @@ # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) proxy_pass_request_headers off; - proxy_ssl_server_name on; # For SNI to the IdP + proxy_ssl_verify on; # Enforce TLS certificate verification + proxy_ssl_verify_depth 2; # Allow intermediate CA chains of depth 2 + proxy_ssl_server_name on; # Send SNI to IdP host + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; # Use system CA bundle + proxy_set_header Content-Type "application/x-www-form-urlencoded"; proxy_set_header Authorization $arg_secret_basic; proxy_pass $oidc_token_endpoint; From d3c58cfa4e6a30c50b22248eb470624a1c0806fe Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Tue, 18 Nov 2025 15:11:31 +0000 Subject: [PATCH 02/17] Add OIDC template parsing and update tests --- build/Dockerfile | 8 +++++-- build/scripts/common.sh | 3 ++- cmd/nginx-ingress/main.go | 4 +++- internal/configs/configurator.go | 24 +++++++++++++++++++ internal/configs/configurator_bench_test.go | 2 +- internal/configs/configurator_test.go | 2 +- .../__snapshots__/templates_test.snap | 9 +++---- internal/configs/version2/http.go | 3 +++ .../version2/nginx-plus.virtualserver.tmpl | 11 +-------- .../{oidc/oidc.conf => version2/oidc.tmpl} | 16 ++++--------- .../configs/version2/template_executor.go | 20 +++++++++++++++- .../version2/template_executor_test.go | 2 +- internal/configs/version2/templates_test.go | 8 +++---- internal/nginx/fake_manager.go | 12 ++++++++++ internal/nginx/manager.go | 18 ++++++++++++++ internal/telemetry/collector_test.go | 3 ++- 16 files changed, 105 insertions(+), 40 deletions(-) rename internal/configs/{oidc/oidc.conf => version2/oidc.tmpl} (82%) diff --git a/build/Dockerfile b/build/Dockerfile index bb7981c9dd..8968f58307 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -662,8 +662,12 @@ LABEL org.nginx.kic.image.build.version="local" COPY --link --chown=101:0 nginx-ingress / # root is required for `setcap` invocation USER 0 -RUN --mount=type=bind,target=/tmp [ -z "${BUILD_OS##*plus*}" ] && PLUS=-plus; cp -a /tmp/internal/configs/version1/nginx$PLUS.ingress.tmpl /tmp/internal/configs/version1/nginx$PLUS.tmpl \ - /tmp/internal/configs/version2/nginx$PLUS.virtualserver.tmpl /tmp/internal/configs/version2/nginx$PLUS.transportserver.tmpl / \ +RUN --mount=type=bind,target=/tmp if [ -z "${BUILD_OS##*plus*}" ]; then PLUS=-plus; fi \ + && cp -a /tmp/internal/configs/version1/nginx$PLUS.ingress.tmpl \ + /tmp/internal/configs/version1/nginx$PLUS.tmpl \ + /tmp/internal/configs/version2/nginx$PLUS.virtualserver.tmpl \ + /tmp/internal/configs/version2/nginx$PLUS.transportserver.tmpl / \ + && if [ -z "${BUILD_OS##*plus*}" ]; then cp -a /tmp/internal/configs/version2/oidc.tmpl /; fi \ && chown -R 101:0 /*.tmpl \ && chmod -R g=u /*.tmpl \ && setcap 'cap_net_bind_service=+ep' /nginx-ingress && setcap -v 'cap_net_bind_service=+ep' /nginx-ingress diff --git a/build/scripts/common.sh b/build/scripts/common.sh index 0a59f628c2..30a7dd1630 100755 --- a/build/scripts/common.sh +++ b/build/scripts/common.sh @@ -4,12 +4,13 @@ set -e PLUS="" if [ -z "${BUILD_OS##*plus*}" ]; then - mkdir -p /etc/nginx/oidc/ + mkdir -p /etc/nginx/oidc/ /etc/nginx/oidc-conf.d/ cp -a /code/internal/configs/oidc/* /etc/nginx/oidc/ mkdir -p /etc/nginx/state_files/ mkdir -p /etc/nginx/reporting/ mkdir -p /etc/nginx/secrets/mgmt/ PLUS=-plus + cp -a /code/internal/configs/version2/oidc.tmpl / fi mkdir -p /etc/nginx/njs/ && cp -a /code/internal/configs/njs/* /etc/nginx/njs/ diff --git a/cmd/nginx-ingress/main.go b/cmd/nginx-ingress/main.go index dc551c5e4a..678e3c7142 100644 --- a/cmd/nginx-ingress/main.go +++ b/cmd/nginx-ingress/main.go @@ -541,11 +541,13 @@ func createTemplateExecutors(ctx context.Context) (*version1.TemplateExecutor, * nginxIngressTemplatePath := "nginx.ingress.tmpl" nginxVirtualServerTemplatePath := "nginx.virtualserver.tmpl" nginxTransportServerTemplatePath := "nginx.transportserver.tmpl" + nginxOIDCConfTemplatePath := "" if *nginxPlus { nginxConfTemplatePath = "nginx-plus.tmpl" nginxIngressTemplatePath = "nginx-plus.ingress.tmpl" nginxVirtualServerTemplatePath = "nginx-plus.virtualserver.tmpl" nginxTransportServerTemplatePath = "nginx-plus.transportserver.tmpl" + nginxOIDCConfTemplatePath = "oidc.tmpl" } if *mainTemplatePath != "" { @@ -566,7 +568,7 @@ func createTemplateExecutors(ctx context.Context) (*version1.TemplateExecutor, * nl.Fatalf(l, "Error creating TemplateExecutor: %v", err) } - templateExecutorV2, err := version2.NewTemplateExecutor(nginxVirtualServerTemplatePath, nginxTransportServerTemplatePath) + templateExecutorV2, err := version2.NewTemplateExecutor(nginxVirtualServerTemplatePath, nginxTransportServerTemplatePath, nginxOIDCConfTemplatePath) if err != nil { nl.Fatalf(l, "Error creating TemplateExecutorV2: %v", err) } diff --git a/internal/configs/configurator.go b/internal/configs/configurator.go index d8cf544735..65c9419a18 100644 --- a/internal/configs/configurator.go +++ b/internal/configs/configurator.go @@ -641,6 +641,18 @@ func (cnf *Configurator) addOrUpdateVirtualServer(virtualServerEx *VirtualServer } changed := cnf.nginxManager.CreateConfig(name, content) + if vsCfg.Server.OIDC != nil { + name := getFileNameForOIDCVirtualServer(virtualServerEx.VirtualServer) + + content, err := cnf.templateExecutorV2.ExecuteOIDCTemplate(vsCfg.Server.OIDC) + if err != nil { + return false, warnings, weightUpdates, fmt.Errorf("error generating VirtualServer OIDC config: %v: %w", name, err) + } + oidcChanged := cnf.nginxManager.CreateOIDCConfig(name, content) + if oidcChanged { + changed = true + } + } cnf.virtualServers[name] = virtualServerEx if (cnf.isPlus && cnf.isPrometheusEnabled) || cnf.isLatencyMetricsEnabled { @@ -1029,6 +1041,14 @@ func (cnf *Configurator) DeleteIngress(key string, skipReload bool) error { func (cnf *Configurator) DeleteVirtualServer(key string, skipReload bool) error { name := getFileNameForVirtualServerFromKey(key) cnf.nginxManager.DeleteConfig(name) + if cnf.virtualServers[name] != nil { + for _, policy := range cnf.virtualServers[name].Policies { + if policy.Spec.OIDC != nil { + oidcName := getFileNameForOIDCVirtualServer(cnf.virtualServers[name].VirtualServer) + cnf.nginxManager.DeleteOIDCConfig(oidcName) + } + } + } if cnf.isPlus { cnf.nginxManager.DeleteKeyValStateFiles(name) @@ -1555,6 +1575,10 @@ func getFileNameForVirtualServer(virtualServer *conf_v1.VirtualServer) string { return fmt.Sprintf("vs_%s_%s", virtualServer.Namespace, virtualServer.Name) } +func getFileNameForOIDCVirtualServer(virtualServer *conf_v1.VirtualServer) string { + return fmt.Sprintf("oidc_%s_%s", virtualServer.Namespace, virtualServer.Name) +} + func getFileNameForTransportServer(transportServer *conf_v1.TransportServer) string { return fmt.Sprintf("ts_%s_%s", transportServer.Namespace, transportServer.Name) } diff --git a/internal/configs/configurator_bench_test.go b/internal/configs/configurator_bench_test.go index e06e15b345..cf8d1fb586 100644 --- a/internal/configs/configurator_bench_test.go +++ b/internal/configs/configurator_bench_test.go @@ -18,7 +18,7 @@ func createTestConfiguratorBench() (*Configurator, error) { return nil, err } - templateExecutorV2, err := version2.NewTemplateExecutor("version2/nginx-plus.virtualserver.tmpl", "version2/nginx-plus.transportserver.tmpl") + templateExecutorV2, err := version2.NewTemplateExecutor("version2/nginx-plus.virtualserver.tmpl", "version2/nginx-plus.transportserver.tmpl", "version2/oidc.tmpl") if err != nil { return nil, err } diff --git a/internal/configs/configurator_test.go b/internal/configs/configurator_test.go index b9f376392e..97bae53292 100644 --- a/internal/configs/configurator_test.go +++ b/internal/configs/configurator_test.go @@ -42,7 +42,7 @@ func createTestConfigurator(t *testing.T) *Configurator { t.Fatal(err) } - templateExecutorV2, err := version2.NewTemplateExecutor("version2/nginx-plus.virtualserver.tmpl", "version2/nginx-plus.transportserver.tmpl") + templateExecutorV2, err := version2.NewTemplateExecutor("version2/nginx-plus.virtualserver.tmpl", "version2/nginx-plus.transportserver.tmpl", "version2/oidc.tmpl") if err != nil { t.Fatal(err) } diff --git a/internal/configs/version2/__snapshots__/templates_test.snap b/internal/configs/version2/__snapshots__/templates_test.snap index 50f2fe69b4..dbe230e96d 100644 --- a/internal/configs/version2/__snapshots__/templates_test.snap +++ b/internal/configs/version2/__snapshots__/templates_test.snap @@ -1483,7 +1483,7 @@ server { set $resource_type "virtualserver"; set $resource_name ""; set $resource_namespace ""; - include oidc/oidc.conf; + include oidc-conf.d/oidc__.conf; set $oidc_debug true; set $oidc_pkce_enable 1; @@ -1500,7 +1500,6 @@ server { set $oidc_scopes ""; set $oidc_client ""; set $oidc_client_secret ""; - set $redir_location ""; server_tokens ""; @@ -1533,7 +1532,7 @@ server { set $resource_type "virtualserver"; set $resource_name ""; set $resource_namespace ""; - include oidc/oidc.conf; + include oidc-conf.d/oidc__.conf; set $oidc_pkce_enable 1; set $oidc_client_auth_method "client_secret_post"; @@ -1549,7 +1548,6 @@ server { set $oidc_scopes ""; set $oidc_client ""; set $oidc_client_secret ""; - set $redir_location ""; server_tokens ""; @@ -1582,7 +1580,7 @@ server { set $resource_type "virtualserver"; set $resource_name ""; set $resource_namespace ""; - include oidc/oidc.conf; + include oidc-conf.d/oidc__.conf; set $oidc_pkce_enable 1; set $oidc_client_auth_method "client_secret_post"; @@ -1598,7 +1596,6 @@ server { set $oidc_scopes ""; set $oidc_client ""; set $oidc_client_secret ""; - set $redir_location ""; server_tokens ""; diff --git a/internal/configs/version2/http.go b/internal/configs/version2/http.go index ea806bd563..ee805aae34 100644 --- a/internal/configs/version2/http.go +++ b/internal/configs/version2/http.go @@ -157,6 +157,9 @@ type OIDC struct { AuthExtraArgs string AccessTokenEnable bool PKCEEnable bool + TLSVerify bool + VerifyDepth int + CAFile string } // APIKey holds API key configuration. diff --git a/internal/configs/version2/nginx-plus.virtualserver.tmpl b/internal/configs/version2/nginx-plus.virtualserver.tmpl index 4886cc2463..2e363fe461 100644 --- a/internal/configs/version2/nginx-plus.virtualserver.tmpl +++ b/internal/configs/version2/nginx-plus.virtualserver.tmpl @@ -135,7 +135,7 @@ server { set $resource_namespace "{{$s.VSNamespace}}"; {{- with $oidc := $s.OIDC }} - include oidc/oidc.conf; + include oidc-conf.d/oidc_{{$s.VSNamespace}}_{{$s.VSName}}.conf; {{- if eq $s.NGINXDebugLevel "debug" }} set $oidc_debug true; @@ -155,15 +155,6 @@ server { set $oidc_scopes "{{ $oidc.Scope }}"; set $oidc_client "{{ $oidc.ClientID }}"; set $oidc_client_secret "{{ $oidc.ClientSecret }}"; - set $redir_location "{{ $oidc.RedirectURI }}"; - {{- if and $oidc.RedirectURI (ne $oidc.RedirectURI "/_codexch") }} - # Custom OIDC redirect location based on policy redirectURI - location = {{ $oidc.RedirectURI }} { - status_zone "OIDC code exchange"; - js_content oidc.codeExchange; - error_page 500 502 504 @oidc_error; - } - {{- end }} {{- end }} {{- with $ssl := $s.SSL }} diff --git a/internal/configs/oidc/oidc.conf b/internal/configs/version2/oidc.tmpl similarity index 82% rename from internal/configs/oidc/oidc.conf rename to internal/configs/version2/oidc.tmpl index e7b4c5209d..3968c8faf7 100644 --- a/internal/configs/oidc/oidc.conf +++ b/internal/configs/version2/oidc.tmpl @@ -13,10 +13,8 @@ proxy_cache_valid 200 12h; # How long to consider keys "fresh" proxy_cache_use_stale error timeout updating; # Use old JWK Set if cannot reach IdP - proxy_ssl_verify on; # Enforce TLS certificate verification - proxy_ssl_verify_depth 2; # Allow intermediate CA chains of depth 2 + proxy_ssl_verify off; # Enforce TLS certificate verification proxy_ssl_server_name on; # Send SNI to IdP host - proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; # Use system CA bundle proxy_method GET; # In case client request was non-GET proxy_set_header Content-Length ""; # '' @@ -30,8 +28,8 @@ default_type text/plain; # In case we throw an error } - #set $redir_location "/_codexch"; check for changes in case location value is changed from /_codexch - location = /_codexch { + set $redir_location "{{ .RedirectURI }}"; + location = {{ .RedirectURI }} { # This location is called by the IdP after successful authentication status_zone "OIDC code exchange"; js_content oidc.codeExchange; @@ -47,10 +45,8 @@ # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) proxy_pass_request_headers off; - proxy_ssl_verify on; # Enforce TLS certificate verification - proxy_ssl_verify_depth 2; # Allow intermediate CA chains of depth 2 + proxy_ssl_verify off; # Enforce TLS certificate verification proxy_ssl_server_name on; # Send SNI to IdP host - proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; # Use system CA bundle proxy_set_header Content-Type "application/x-www-form-urlencoded"; proxy_set_header Authorization $arg_secret_basic; @@ -66,10 +62,8 @@ # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) proxy_pass_request_headers off; - proxy_ssl_verify on; # Enforce TLS certificate verification - proxy_ssl_verify_depth 2; # Allow intermediate CA chains of depth 2 + proxy_ssl_verify off; # Enforce TLS certificate verification proxy_ssl_server_name on; # Send SNI to IdP host - proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; # Use system CA bundle proxy_set_header Content-Type "application/x-www-form-urlencoded"; proxy_set_header Authorization $arg_secret_basic; diff --git a/internal/configs/version2/template_executor.go b/internal/configs/version2/template_executor.go index 085e1e7b48..ad5c3495f0 100644 --- a/internal/configs/version2/template_executor.go +++ b/internal/configs/version2/template_executor.go @@ -20,10 +20,11 @@ type TemplateExecutor struct { virtualServerTemplate *template.Template transportServerTemplate *template.Template tlsPassthroughHostsTemplate *template.Template + oidcTemplate *template.Template } // NewTemplateExecutor creates a TemplateExecutor. -func NewTemplateExecutor(virtualServerTemplatePath string, transportServerTemplatePath string) (*TemplateExecutor, error) { +func NewTemplateExecutor(virtualServerTemplatePath string, transportServerTemplatePath string, oidcTemplatePath string) (*TemplateExecutor, error) { // template names must be the base name of the template file https://golang.org/pkg/text/template/#Template.ParseFiles vsTemplate, err := template.New(path.Base(virtualServerTemplatePath)).Funcs(helperFunctions).ParseFiles(virtualServerTemplatePath) @@ -41,12 +42,20 @@ func NewTemplateExecutor(virtualServerTemplatePath string, transportServerTempla return nil, err } + var oidcTemplate *template.Template + if oidcTemplatePath != "" { + oidcTemplate, err = template.New(path.Base(oidcTemplatePath)).Funcs(helperFunctions).ParseFiles(oidcTemplatePath) + if err != nil { + return nil, err + } + } return &TemplateExecutor{ originalVirtualServerTemplate: vsTemplate, originalTrasportServerTemplate: tsTemplate, virtualServerTemplate: vsTemplate, transportServerTemplate: tsTemplate, tlsPassthroughHostsTemplate: tlsPassthroughHostsTemplate, + oidcTemplate: oidcTemplate, }, nil } @@ -109,3 +118,12 @@ func (te *TemplateExecutor) ExecuteTLSPassthroughHostsTemplate(cfg *TLSPassthrou } return configBuffer.Bytes(), nil } + +// ExecuteOIDCTemplate generates the content of an OIDC configuration file. +func (te *TemplateExecutor) ExecuteOIDCTemplate(cfg *OIDC) ([]byte, error) { + var configBuffer bytes.Buffer + if err := te.oidcTemplate.Execute(&configBuffer, cfg); err != nil { + return nil, err + } + return configBuffer.Bytes(), nil +} diff --git a/internal/configs/version2/template_executor_test.go b/internal/configs/version2/template_executor_test.go index 292d5ee4b5..a1c20198c6 100644 --- a/internal/configs/version2/template_executor_test.go +++ b/internal/configs/version2/template_executor_test.go @@ -95,7 +95,7 @@ func TestTemplateExecutorUsesOriginalTStemplate(t *testing.T) { func newTestTemplateExecutor(t *testing.T) *TemplateExecutor { t.Helper() - te, err := NewTemplateExecutor("nginx-plus.virtualserver.tmpl", "nginx-plus.transportserver.tmpl") + te, err := NewTemplateExecutor("nginx-plus.virtualserver.tmpl", "nginx-plus.transportserver.tmpl", "oidc.tmpl") if err != nil { t.Fatal(err) } diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go index 7b5253f92a..7fd9e09cc1 100644 --- a/internal/configs/version2/templates_test.go +++ b/internal/configs/version2/templates_test.go @@ -24,7 +24,7 @@ func createPointerFromInt(n int) *int { func newTmplExecutorNGINXPlus(t *testing.T) *TemplateExecutor { t.Helper() - executor, err := NewTemplateExecutor("nginx-plus.virtualserver.tmpl", "nginx-plus.transportserver.tmpl") + executor, err := NewTemplateExecutor("nginx-plus.virtualserver.tmpl", "nginx-plus.transportserver.tmpl", "oidc.tmpl") if err != nil { t.Fatal(err) } @@ -33,7 +33,7 @@ func newTmplExecutorNGINXPlus(t *testing.T) *TemplateExecutor { func newTmplExecutorNGINX(t *testing.T) *TemplateExecutor { t.Helper() - executor, err := NewTemplateExecutor("nginx.virtualserver.tmpl", "nginx.transportserver.tmpl") + executor, err := NewTemplateExecutor("nginx.virtualserver.tmpl", "nginx.transportserver.tmpl", "oidc.tmpl") if err != nil { t.Fatal(err) } @@ -811,7 +811,7 @@ func TestExecuteVirtualServerTemplateWithAPIKeyPolicyNGINXPlus(t *testing.T) { func TestExecuteVirtualServerTemplate_WithCustomOIDCRedirectLocation(t *testing.T) { t.Parallel() executor := newTmplExecutorNGINXPlus(t) - got, err := executor.ExecuteVirtualServerTemplate(&virtualServerCfg) + got, err := executor.ExecuteOIDCTemplate(virtualServerCfg.Server.OIDC) if err != nil { t.Error(err) } @@ -849,7 +849,7 @@ func TestExecuteVirtualServerTemplateWithOIDCAndPKCEPolicyNGINXPlus(t *testing.T } want := "keyval $pkce_id $pkce_code_verifier zone=oidc_pkce;" - want2 := "include oidc/oidc.conf;" + want2 := fmt.Sprintf("include oidc-conf.d/oidc_%s_%s.conf;", virtualServerCfgWithOIDCAndPKCETurnedOn.Server.VSNamespace, virtualServerCfgWithOIDCAndPKCETurnedOn.Server.VSName) if !bytes.Contains(got, []byte(want)) { t.Errorf("want %q in generated template", want) diff --git a/internal/nginx/fake_manager.go b/internal/nginx/fake_manager.go index bc008d1d79..197b046ad6 100644 --- a/internal/nginx/fake_manager.go +++ b/internal/nginx/fake_manager.go @@ -44,6 +44,13 @@ func (fm *FakeManager) CreateConfig(name string, content []byte) bool { return true } +// CreateOIDCConfig provides a fake implementation of CreateOIDCConfig. +func (fm *FakeManager) CreateOIDCConfig(name string, content []byte) bool { + nl.Debugf(fm.logger, "Writing OIDC config %v", name) + nl.Debug(fm.logger, string(content)) + return true +} + // CreateAppProtectResourceFile provides a fake implementation of CreateAppProtectResourceFile func (fm *FakeManager) CreateAppProtectResourceFile(name string, content []byte) { nl.Debugf(fm.logger, "Writing Ap Resource File %v", name) @@ -65,6 +72,11 @@ func (fm *FakeManager) DeleteConfig(name string) { nl.Debugf(fm.logger, "Deleting config %v", name) } +// DeleteOIDCConfig provides a fake implementation of DeleteOIDCConfig. +func (fm *FakeManager) DeleteOIDCConfig(name string) { + nl.Debugf(fm.logger, "Deleting OIDC config %v", name) +} + // CreateStreamConfig provides a fake implementation of CreateStreamConfig. func (fm *FakeManager) CreateStreamConfig(name string, content []byte) bool { nl.Debugf(fm.logger, "Writing stream config %v", name) diff --git a/internal/nginx/manager.go b/internal/nginx/manager.go index 4eb8bb121c..888498f686 100644 --- a/internal/nginx/manager.go +++ b/internal/nginx/manager.go @@ -75,6 +75,8 @@ type Manager interface { CreateStreamConfig(name string, content []byte) bool DeleteStreamConfig(name string) CreateTLSPassthroughHostsConfig(content []byte) bool + CreateOIDCConfig(name string, content []byte) bool + DeleteOIDCConfig(name string) CreateSecret(name string, content []byte, mode os.FileMode) string DeleteSecret(name string) CreateAppProtectResourceFile(name string, content []byte) @@ -114,6 +116,7 @@ type LocalManager struct { debug bool dhparamFilename string tlsPassthroughHostsFilename string + oidcConfPath string verifyConfigGenerator *verifyConfigGenerator verifyClient *verifyClient configVersion int @@ -147,6 +150,7 @@ func NewLocalManager(ctx context.Context, confPath string, debug bool, mc collec mainConfFilename: path.Join(confPath, "nginx.conf"), configVersionFilename: path.Join(confPath, "config-version.conf"), tlsPassthroughHostsFilename: path.Join(confPath, "tls-passthrough-hosts.conf"), + oidcConfPath: path.Join(confPath, "oidc-conf.d"), debug: debug, verifyConfigGenerator: verifyConfigGenerator, configVersion: 0, @@ -179,6 +183,11 @@ func (lm *LocalManager) CreateConfig(name string, content []byte) bool { return createConfig(lm.logger, lm.getFilenameForConfig(name), content) } +// CreateOIDCConfig creates an OIDC configuration file. If the file already exists, it will be overridden. +func (lm *LocalManager) CreateOIDCConfig(name string, content []byte) bool { + return createConfig(lm.logger, lm.getFilenameForOIDCConfig(name), content) +} + func createConfig(l *slog.Logger, filename string, content []byte) bool { nl.Debugf(l, "Writing config to %v", filename) nl.Debug(l, string(content)) @@ -196,6 +205,11 @@ func (lm *LocalManager) DeleteConfig(name string) { deleteConfig(lm.logger, lm.getFilenameForConfig(name)) } +// DeleteOIDCConfig deletes the configuration file from the conf.d folder. +func (lm *LocalManager) DeleteOIDCConfig(name string) { + deleteConfig(lm.logger, lm.getFilenameForOIDCConfig(name)) +} + func deleteConfig(l *slog.Logger, filename string) { nl.Infof(l, "Deleting config from %v", filename) @@ -208,6 +222,10 @@ func (lm *LocalManager) getFilenameForConfig(name string) string { return path.Join(lm.confdPath, name+".conf") } +func (lm *LocalManager) getFilenameForOIDCConfig(name string) string { + return path.Join(lm.oidcConfPath, name+".conf") +} + // CreateStreamConfig creates a configuration file for stream module. // If the file already exists, it will be overridden. func (lm *LocalManager) CreateStreamConfig(name string, content []byte) bool { diff --git a/internal/telemetry/collector_test.go b/internal/telemetry/collector_test.go index 389b08e835..b9a0a027f0 100644 --- a/internal/telemetry/collector_test.go +++ b/internal/telemetry/collector_test.go @@ -2646,7 +2646,7 @@ func newConfigurator(t *testing.T) *configs.Configurator { t.Fatal(err) } - templateExecutorV2, err := version2.NewTemplateExecutor(virtualServerTemplatePath, transportServerTemplatePath) + templateExecutorV2, err := version2.NewTemplateExecutor(virtualServerTemplatePath, transportServerTemplatePath, oidcTemplatePath) if err != nil { t.Fatal(err) } @@ -2712,6 +2712,7 @@ const ( ingressTemplatePath = "../configs/version1/nginx-plus.ingress.tmpl" virtualServerTemplatePath = "../configs/version2/nginx-plus.virtualserver.tmpl" transportServerTemplatePath = "../configs/version2/nginx-plus.transportserver.tmpl" + oidcTemplatePath = "../configs/version2/oidc.tmpl" ) // telemetryNICData holds static test data for telemetry tests. From 1c406630529738be4f3f8c2f9474c98f97425c44 Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Tue, 18 Nov 2025 15:25:12 +0000 Subject: [PATCH 03/17] Add new OIDC TLS verification values to Policy CRD --- config/crd/bases/k8s.nginx.org_policies.yaml | 19 +++++++++++++++++++ deploy/crds.yaml | 19 +++++++++++++++++++ docs/crd/k8s.nginx.org_policies.md | 3 +++ pkg/apis/configuration/v1/types.go | 10 ++++++++++ .../configuration/v1/zz_generated.deepcopy.go | 5 +++++ 5 files changed, 56 insertions(+) diff --git a/config/crd/bases/k8s.nginx.org_policies.yaml b/config/crd/bases/k8s.nginx.org_policies.yaml index 0794caad39..5204c01a8b 100644 --- a/config/crd/bases/k8s.nginx.org_policies.yaml +++ b/config/crd/bases/k8s.nginx.org_policies.yaml @@ -390,10 +390,29 @@ spec: with a + sign, for example openid+profile+email, openid+email+userDefinedScope. The default is openid. type: string + sslVerify: + default: false + description: Enables verification of the IDP server SSL certificate. + Default is false. + type: boolean + sslVerifyDepth: + default: 1 + description: Sets the verification depth in the IDP server certificates + chain. The default is 1. + minimum: 0 + type: integer tokenEndpoint: description: URL for the token endpoint provided by your OpenID Connect provider. type: string + trustedCertSecret: + description: The name of the Kubernetes secret that stores the + CA certificate for IDP 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 zoneSyncLeeway: description: Specifies the maximum timeout in milliseconds for synchronizing ID/access tokens and shared values between Ingress diff --git a/deploy/crds.yaml b/deploy/crds.yaml index fd080f2d59..85a5b6cfa2 100644 --- a/deploy/crds.yaml +++ b/deploy/crds.yaml @@ -561,10 +561,29 @@ spec: with a + sign, for example openid+profile+email, openid+email+userDefinedScope. The default is openid. type: string + sslVerify: + default: false + description: Enables verification of the IDP server SSL certificate. + Default is false. + type: boolean + sslVerifyDepth: + default: 1 + description: Sets the verification depth in the IDP server certificates + chain. The default is 1. + minimum: 0 + type: integer tokenEndpoint: description: URL for the token endpoint provided by your OpenID Connect provider. type: string + trustedCertSecret: + description: The name of the Kubernetes secret that stores the + CA certificate for IDP 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 zoneSyncLeeway: description: Specifies the maximum timeout in milliseconds for synchronizing ID/access tokens and shared values between Ingress diff --git a/docs/crd/k8s.nginx.org_policies.md b/docs/crd/k8s.nginx.org_policies.md index c4cf5c8f66..7df0e5b227 100644 --- a/docs/crd/k8s.nginx.org_policies.md +++ b/docs/crd/k8s.nginx.org_policies.md @@ -74,7 +74,10 @@ The `.spec` object supports the following fields: | `oidc.postLogoutRedirectURI` | `string` | URI to redirect to after the logout has been performed. Requires endSessionEndpoint. The default is /_logout. | | `oidc.redirectURI` | `string` | Allows overriding the default redirect URI. The default is /_codexch. | | `oidc.scope` | `string` | List of OpenID Connect scopes. The scope openid always needs to be present and others can be added concatenating them with a + sign, for example openid+profile+email, openid+email+userDefinedScope. The default is openid. | +| `oidc.sslVerify` | `boolean` | Enables verification of the IDP server SSL certificate. Default is false. | +| `oidc.sslVerifyDepth` | `integer` | Sets the verification depth in the IDP server certificates chain. The default is 1. | | `oidc.tokenEndpoint` | `string` | URL for the token endpoint provided by your OpenID Connect provider. | +| `oidc.trustedCertSecret` | `string` | The name of the Kubernetes secret that stores the CA certificate for IDP 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.zoneSyncLeeway` | `integer` | Specifies the maximum timeout in milliseconds for synchronizing ID/access tokens and shared values between Ingress Controller pods. The default is 200. | | `rateLimit` | `object` | The rate limit policy controls the rate of processing requests per a defined key. | | `rateLimit.burst` | `integer` | Excessive requests are delayed until their number exceeds the burst size, in which case the request is terminated with an error. | diff --git a/pkg/apis/configuration/v1/types.go b/pkg/apis/configuration/v1/types.go index 1f3dd4a6b7..41356b7bb4 100644 --- a/pkg/apis/configuration/v1/types.go +++ b/pkg/apis/configuration/v1/types.go @@ -975,6 +975,16 @@ type OIDC struct { AccessTokenEnable bool `json:"accessTokenEnable"` // Switches Proof Key for Code Exchange on. The OpenID client needs to be in public mode. clientSecret is not used in this mode. PKCEEnable bool `json:"pkceEnable"` + // Enables verification of the IDP 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 IDP 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 IDP server certificates chain. The default is 1. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:default:=1 + SSLVerifyDepth *int `json:"sslVerifyDepth"` } // The WAF policy configures NGINX Plus to secure client requests using App Protect WAF policies. diff --git a/pkg/apis/configuration/v1/zz_generated.deepcopy.go b/pkg/apis/configuration/v1/zz_generated.deepcopy.go index 9cc0b9da8a..8469484682 100644 --- a/pkg/apis/configuration/v1/zz_generated.deepcopy.go +++ b/pkg/apis/configuration/v1/zz_generated.deepcopy.go @@ -630,6 +630,11 @@ func (in *OIDC) DeepCopyInto(out *OIDC) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.SSLVerifyDepth != nil { + in, out := &in.SSLVerifyDepth, &out.SSLVerifyDepth + *out = new(int) + **out = **in + } return } From 53ca87a7f75595bff92f60da1b5a4863a79bb069 Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Wed, 19 Nov 2025 10:56:00 +0000 Subject: [PATCH 04/17] Extend virtualserver to render oidc tls verification parameters --- internal/configs/version2/oidc.tmpl | 20 +++- internal/configs/virtualserver.go | 74 +++++++++++++ internal/configs/virtualserver_test.go | 116 ++++++++++++++++++++ internal/k8s/controller.go | 32 ++++++ pkg/apis/configuration/validation/policy.go | 8 ++ 5 files changed, 246 insertions(+), 4 deletions(-) diff --git a/internal/configs/version2/oidc.tmpl b/internal/configs/version2/oidc.tmpl index 3968c8faf7..435a117ac4 100644 --- a/internal/configs/version2/oidc.tmpl +++ b/internal/configs/version2/oidc.tmpl @@ -13,7 +13,11 @@ proxy_cache_valid 200 12h; # How long to consider keys "fresh" proxy_cache_use_stale error timeout updating; # Use old JWK Set if cannot reach IdP - proxy_ssl_verify off; # Enforce TLS certificate verification + {{- if .TLSVerify }} + proxy_ssl_verify on; + proxy_ssl_verify_depth {{ .VerifyDepth }}; + proxy_ssl_trusted_certificate {{ .CAFile }}; + {{- end }} proxy_ssl_server_name on; # Send SNI to IdP host proxy_method GET; # In case client request was non-GET @@ -45,7 +49,11 @@ # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) proxy_pass_request_headers off; - proxy_ssl_verify off; # Enforce TLS certificate verification + {{- if .TLSVerify }} + proxy_ssl_verify on; + proxy_ssl_verify_depth {{ .VerifyDepth }}; + proxy_ssl_trusted_certificate {{ .CAFile }}; + {{- end }} proxy_ssl_server_name on; # Send SNI to IdP host proxy_set_header Content-Type "application/x-www-form-urlencoded"; @@ -62,8 +70,12 @@ # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) proxy_pass_request_headers off; - proxy_ssl_verify off; # Enforce TLS certificate verification - proxy_ssl_server_name on; # Send SNI to IdP host + {{- if .TLSVerify }} + proxy_ssl_verify on; + proxy_ssl_verify_depth {{ .VerifyDepth }}; + proxy_ssl_trusted_certificate {{ .CAFile }}; + {{- end }} + proxy_ssl_server_name on; # Send SNI to IdP host proxy_set_header Content-Type "application/x-www-form-urlencoded"; proxy_set_header Authorization $arg_secret_basic; diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go index 8dc42d007a..8311760872 100644 --- a/internal/configs/virtualserver.go +++ b/internal/configs/virtualserver.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path" + "regexp" "sort" "strconv" "strings" @@ -1500,6 +1501,57 @@ func (p *policiesCfg) addOIDCConfig( authExtraArgs = strings.Join(oidc.AuthExtraArgs, "&") } + // Handle SSL verification for JWKS + var trustedCertPath string + if oidc.SSLVerify && oidc.TrustedCertSecret != "" { + trustedCertSecretKey := fmt.Sprintf("%s/%s", polNamespace, oidc.TrustedCertSecret) + trustedCertSecretRef := secretRefs[trustedCertSecretKey] + + // Check if secret reference exists + if trustedCertSecretRef == nil { + res.addWarningf("OIDC 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("OIDC 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("OIDC 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] + } + } else if oidc.SSLVerify && oidc.TrustedCertSecret == "" { + osr, err := readOSRelease() + if err != nil { + res.addWarningf("OIDC policy %s could not read OS release info to load CA bundle: %v", polKey, err) + res.isError = true + return res + } + trustedCertPath = getOSCABundlePath(string(osr)) + if _, err := os.Stat(trustedCertPath); os.IsNotExist(err) { + res.addWarningf("OIDC policy %s could not load OS CA bundle: %v", polKey, err) + res.isError = true + return res + } + } + + sslVerifyDepth := 1 + if oidc.SSLVerifyDepth != nil { + sslVerifyDepth = *oidc.SSLVerifyDepth + } + oidcPolCfg.oidc = &version2.OIDC{ AuthEndpoint: oidc.AuthEndpoint, AuthExtraArgs: authExtraArgs, @@ -1514,6 +1566,9 @@ func (p *policiesCfg) addOIDCConfig( ZoneSyncLeeway: generateIntFromPointer(oidc.ZoneSyncLeeway, 200), AccessTokenEnable: oidc.AccessTokenEnable, PKCEEnable: oidc.PKCEEnable, + TLSVerify: oidc.SSLVerify, + VerifyDepth: sslVerifyDepth, + CAFile: trustedCertPath, } oidcPolCfg.key = polKey } @@ -3513,3 +3568,22 @@ func generateDosCfg(dosResource *appProtectDosResource) *version2.Dos { dos.ApDosLogConf = dosResource.AppProtectDosLogConfFile return dos } + +func readOSRelease() ([]byte, error) { + return os.ReadFile("/etc/os-release") +} + +func getOSCABundlePath(s string) string { + alpineRegex := regexp.MustCompile(`ID=\"?alpine\"?`) + rhelRegex := regexp.MustCompile(`ID=\"?rhel\"?`) + // Logic to get the OS CA bundle path. + caFilePath := "/etc/ssl/certs/ca-certificates.crt" // Default for Debian, the default image base + + if alpineRegex.MatchString(s) { + caFilePath = "/etc/ssl/cert.pem" + } else if rhelRegex.MatchString(s) { + caFilePath = "/etc/pki/tls/certs/ca-bundle.crt" + } + + return caFilePath +} diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index bf5f8d78d1..ce676b9a6b 100644 --- a/internal/configs/virtualserver_test.go +++ b/internal/configs/virtualserver_test.go @@ -14764,6 +14764,7 @@ func TestGeneratePoliciesFails(t *testing.T) { PostLogoutRedirectURI: "/_logout", ZoneSyncLeeway: 200, AccessTokenEnable: true, + VerifyDepth: 1, }, "default/oidc-policy", }, @@ -22964,3 +22965,118 @@ func TestGenerateVirtualServerConfigWithForeignNamespaceServiceInVSR(t *testing. t.Error(cmp.Diff(expected, result)) } } + +func TestGetOSCABundlePath(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + { + name: "Debian default", + input: ` +PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" +NAME="Debian GNU/Linux" +VERSION_ID="12" +VERSION="12 (bookworm)" +VERSION_CODENAME=bookworm +ID=debian +HOME_URL="https://www.debian.org/" +SUPPORT_URL="https://www.debian.org/support" +BUG_REPORT_URL="https://bugs.debian.org/" + `, + expected: "/etc/ssl/certs/ca-certificates.crt", + }, + { + name: "Alpine with quotes", + input: ` +NAME="Alpine Linux" +ID="alpine" +VERSION_ID=3.22.2 +PRETTY_NAME="Alpine Linux v3.22" +HOME_URL="https://alpinelinux.org/" +BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues" + `, + expected: "/etc/ssl/cert.pem", + }, + { + name: "Alpine without quotes", + input: ` +NAME="Alpine Linux" +ID=alpine +VERSION_ID=3.19.9 +PRETTY_NAME="Alpine Linux v3.19" +HOME_URL="https://alpinelinux.org/" +BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues" + `, + expected: "/etc/ssl/cert.pem", + }, + { + name: "RHEL8 with quotes", + input: ` +NAME="Red Hat Enterprise Linux" +VERSION="8.10 (Ootpa)" +ID="rhel" +ID_LIKE="fedora" +VERSION_ID="8.10" +PLATFORM_ID="platform:el8" +PRETTY_NAME="Red Hat Enterprise Linux 8.10 (Ootpa)" +ANSI_COLOR="0;31" +CPE_NAME="cpe:/o:redhat:enterprise_linux:8::baseos" +HOME_URL="https://www.redhat.com/" +DOCUMENTATION_URL="https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8" +BUG_REPORT_URL="https://issues.redhat.com/" + +REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 8" +REDHAT_BUGZILLA_PRODUCT_VERSION=8.10 +REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux" +REDHAT_SUPPORT_PRODUCT_VERSION="8.10" + `, + expected: "/etc/pki/tls/certs/ca-bundle.crt", + }, + { + name: "RHEL9 with quotes", + input: ` +NAME="Red Hat Enterprise Linux" +VERSION="9.7 (Plow)" +ID="rhel" +ID_LIKE="fedora" +VERSION_ID="9.7" +PLATFORM_ID="platform:el9" +PRETTY_NAME="Red Hat Enterprise Linux 9.7 (Plow)" +ANSI_COLOR="0;31" +LOGO="fedora-logo-icon" +CPE_NAME="cpe:/o:redhat:enterprise_linux:9::baseos" +HOME_URL="https://www.redhat.com/" +DOCUMENTATION_URL="https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/9" +BUG_REPORT_URL="https://issues.redhat.com/" + +REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 9" +REDHAT_BUGZILLA_PRODUCT_VERSION=9.7 +REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux" +REDHAT_SUPPORT_PRODUCT_VERSION="9.7" + `, + expected: "/etc/pki/tls/certs/ca-bundle.crt", + }, + { + name: "Unknown OS", + input: `ID="ubuntu"`, + expected: "/etc/ssl/certs/ca-certificates.crt", + }, + { + name: "Empty string", + input: "", + expected: "/etc/ssl/certs/ca-certificates.crt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getOSCABundlePath(tt.input) + if result != tt.expected { + t.Errorf("want %q, got %q", tt.expected, result) + } + }) + } +} diff --git a/internal/k8s/controller.go b/internal/k8s/controller.go index 58ad41d198..f2b13cbdf0 100644 --- a/internal/k8s/controller.go +++ b/internal/k8s/controller.go @@ -2556,6 +2556,11 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. nl.Warnf(lbc.Logger, "Error getting OIDC secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) } + err = lbc.addOIDCTrustedCertSecretRefs(virtualServerEx.SecretRefs, vsRoutePolicies) + if err != nil { + nl.Warnf(lbc.Logger, "Error getting OIDC trusted cert secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) + } + err = lbc.addAPIKeySecretRefs(virtualServerEx.SecretRefs, vsRoutePolicies) if err != nil { nl.Warnf(lbc.Logger, "Error getting APIKey secrets for VirtualServer %v/%v: %v", virtualServer.Namespace, virtualServer.Name, err) @@ -2595,6 +2600,11 @@ func (lbc *LoadBalancerController) createVirtualServerEx(virtualServer *conf_v1. nl.Warnf(lbc.Logger, "Error getting OIDC secrets for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) } + err = lbc.addOIDCTrustedCertSecretRefs(virtualServerEx.SecretRefs, vsrSubroutePolicies) + if err != nil { + nl.Warnf(lbc.Logger, "Error getting OIDC trusted cert secrets for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) + } + err = lbc.addAPIKeySecretRefs(virtualServerEx.SecretRefs, vsrSubroutePolicies) if err != nil { nl.Warnf(lbc.Logger, "Error getting APIKey secrets for VirtualServerRoute %v/%v: %v", vsr.Namespace, vsr.Name, err) @@ -2884,6 +2894,26 @@ func (lbc *LoadBalancerController) addOIDCSecretRefs(secretRefs map[string]*secr return nil } +func (lbc *LoadBalancerController) addOIDCTrustedCertSecretRefs(secretRefs map[string]*secrets.SecretReference, policies []*conf_v1.Policy) error { + for _, pol := range policies { + if pol.Spec.OIDC == nil { + continue + } + if pol.Spec.OIDC.TrustedCertSecret != "" { + secretKey := fmt.Sprintf("%v/%v", pol.Namespace, pol.Spec.OIDC.TrustedCertSecret) + secretRef := lbc.secretStore.GetSecret(secretKey) + + secretRefs[secretKey] = secretRef + + if secretRef.Error != nil { + return secretRef.Error + } + } + } + + return nil +} + func (lbc *LoadBalancerController) addAPIKeySecretRefs(secretRefs map[string]*secrets.SecretReference, policies []*conf_v1.Policy) error { for _, pol := range policies { if pol.Spec.APIKey == nil { @@ -2925,6 +2955,8 @@ func findPoliciesForSecret(policies []*conf_v1.Policy, secretNamespace string, s res = append(res, pol) } else if pol.Spec.OIDC != nil && pol.Spec.OIDC.ClientSecret == secretName && pol.Namespace == secretNamespace { res = append(res, pol) + } else if pol.Spec.OIDC != nil && pol.Spec.OIDC.TrustedCertSecret == secretName && pol.Namespace == secretNamespace { + res = append(res, pol) } else if pol.Spec.APIKey != nil && pol.Spec.APIKey.ClientSecret == secretName && pol.Namespace == secretNamespace { res = append(res, pol) } diff --git a/pkg/apis/configuration/validation/policy.go b/pkg/apis/configuration/validation/policy.go index af0e2bc298..31075292e4 100644 --- a/pkg/apis/configuration/validation/policy.go +++ b/pkg/apis/configuration/validation/policy.go @@ -337,6 +337,14 @@ func validateOIDC(oidc *v1.OIDC, fieldPath *field.Path) field.ErrorList { allErrs = append(allErrs, validateQueryString(strings.Join(oidc.AuthExtraArgs, "&"), fieldPath.Child("authExtraArgs"))...) } + if oidc.TrustedCertSecret != "" { + allErrs = append(allErrs, validateSecretName(oidc.TrustedCertSecret, fieldPath.Child("trustedCertSecret"))...) + // If trustedCertSecret is set but sslVerify is false, warn user + if !oidc.SSLVerify { + allErrs = append(allErrs, field.Invalid(fieldPath.Child("sslVerify"), oidc.SSLVerify, "sslVerify should be enabled when trustedCertSecret is specified")) + } + } + allErrs = append(allErrs, validateURL(oidc.AuthEndpoint, fieldPath.Child("authEndpoint"))...) allErrs = append(allErrs, validateURL(oidc.TokenEndpoint, fieldPath.Child("tokenEndpoint"))...) allErrs = append(allErrs, validateURL(oidc.JWKSURI, fieldPath.Child("jwksURI"))...) From 8b2359ef72e2983578742e49aa568b4dca0a3df9 Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Wed, 19 Nov 2025 10:58:07 +0000 Subject: [PATCH 05/17] Extend the OIDC example to include HTTPS on Keycloak --- .../common-secrets/keycloak-ca-secret.yaml | 7 +++ .../common-secrets/keycloak-tls-secret.yaml | 8 +++ examples/custom-resources/oidc/README.md | 26 ++++++-- .../oidc/keycloak-ca-secret.yaml | 1 + .../oidc/keycloak-tls-secret.yaml | 1 + examples/custom-resources/oidc/keycloak.yaml | 59 ++++++++++++------- examples/custom-resources/oidc/oidc.yaml | 6 +- 7 files changed, 79 insertions(+), 29 deletions(-) create mode 100644 examples/common-secrets/keycloak-ca-secret.yaml create mode 100644 examples/common-secrets/keycloak-tls-secret.yaml create mode 120000 examples/custom-resources/oidc/keycloak-ca-secret.yaml create mode 120000 examples/custom-resources/oidc/keycloak-tls-secret.yaml diff --git a/examples/common-secrets/keycloak-ca-secret.yaml b/examples/common-secrets/keycloak-ca-secret.yaml new file mode 100644 index 0000000000..7e03f850b6 --- /dev/null +++ b/examples/common-secrets/keycloak-ca-secret.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-ca +type: nginx.org/ca +data: + ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZ4ekNDQTYrZ0F3SUJBZ0lVSnIxb2VDQTcxTmhjQ3VIVmh1NHVQcXNEVDhjd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2N6RUxNQWtHQTFVRUJoTUNTVVV4RFRBTEJnTlZCQWdNQkVOdmNtc3hEVEFMQmdOVkJBY01CRU52Y21zeApDekFKQmdOVkJBb01Ba1kxTVF3d0NnWURWUVFMREFOT1NVTXhLekFwQmdOVkJBTU1JbXRsZVdOc2IyRnJMbVJsClptRjFiSFF1YzNaakxtTnNkWE4wWlhJdWJHOWpZV3d3SGhjTk1qVXhNVEUzTVRJeU9EVTVXaGNOTXpVeE1URTEKTVRJeU9EVTVXakJ6TVFzd0NRWURWUVFHRXdKSlJURU5NQXNHQTFVRUNBd0VRMjl5YXpFTk1Bc0dBMVVFQnd3RQpRMjl5YXpFTE1Ba0dBMVVFQ2d3Q1JqVXhEREFLQmdOVkJBc01BMDVKUXpFck1Da0dBMVVFQXd3aWEyVjVZMnh2CllXc3VaR1ZtWVhWc2RDNXpkbU11WTJ4MWMzUmxjaTVzYjJOaGJEQ0NBaUl3RFFZSktvWklodmNOQVFFQkJRQUQKZ2dJUEFEQ0NBZ29DZ2dJQkFLK1M5cVRXdGZ3YU1GdjI3aU5Ob3FJU2FaMXJ4R0VlZlpTd29ZTCtBZEVpTXhEMgpxN0dvbkc3QlZaOVoxQWpMTlhtcGwyeFF1ZVZBN25RYXpXa2JJazhhQytJOWwwQ1NudXNiK1UwMUV6V0g5MVJ2CnNRM0pFdlV1WXlma3loNnRGeGoyNTJFMTQ3TE1HcTlXOHVlRDNJYnNWVjRZWlBDY0VHN1c3aEYzSTVObllYa1kKYzVKWFFtKzNTODl5V2hWUDg1MHpGTEpTVUFkcHhuMW9qdmFhV3FZWHVrODd4ajU3U1VueGpzR3pTN1dxMDJxVAo0WjVBNENjUGR5Y2Vha2MzcDRLQXVQaHZSYnltK2l1ME13WWFDZ3ZDZnV2eTZLa0l5c1lCL0dUUmhKcFltU2FKCmNsUXFxT3NRTjd6U3crZDlrOFNlSnFONGVCMHVFbmNkSkRwcDJTa05qa3ZRaEhkRllncm5KN1dZVmZBSmxibkIKSUc3VVBmRCtIZDduRkhFdWIvdE4zN20xRnkyZjhjUnNkNWZ1Q29NNHhoelRucm1wbDFiTEZYN2dOLzgrNGZmZwpzc2VCSmZqbExKbCtzTTA4RlI0aVFicGMzK0xZbkpzOXdsUE82VTBzQ2VJUXB4SnVTUEh5aENtN2hFR2N5NGdKCi9SdENwZHVabitrMDdnTXVnUlVVdUxNV2JNWjhaTEh2cWNwbTliY05aQXNxRERzdkIxMTdldFh4Q1luVC9DRGMKYVJBMVh3a2ptNS9DY0k0U0JqNzQrZC8rMnJDbHBEZGl0dWxyTnJ6WjcrVTZFSU1pdDV3SnJ0V09nVlpYdGNxOAp3UlFWdHZINVNkZjZnWVlvOE9HejhRenAzZEJ5TEVaWDU1V0FzT084dWhhcUJGenk1TEVaSlk1WEo2SlpBZ01CCkFBR2pVekJSTUIwR0ExVWREZ1FXQkJUcmR5VTZzRVhRWHR5S1doRFhVbHJaMmpjQXhEQWZCZ05WSFNNRUdEQVcKZ0JUcmR5VTZzRVhRWHR5S1doRFhVbHJaMmpjQXhEQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BMEdDU3FHU0liMwpEUUVCQ3dVQUE0SUNBUUNTTDNFcDBrWUFBUnIrSStVcjhRdmhkdDg1NUtIbFIwenMyZkN3elFORjArUlFXTm1WCjF5ZitUaEkwRW13NnVaWk5rYXNWenFQcENZNU81VWkwK2ZhL09LaUs1eGdXVjVlais1SEJndFk1VitVVE9yYm0KTlpiODE2UnBya1NadTBFWlpqNVNnUEhmSzUraVNuWnVKL0J2Nk13WjNYR2F1N3pHdlBBRGpqSVBwMEJqczluVwpWUXQyQWRmZlQrUXd1UHgreVJEZTFEdHhpSkEvMlQxN0c3eUN3NnBDWHJsVHdNckk5dTVtSHhmM0FSaVJOZ01mCndPSHJBNXNJYkhFVVQ5QXc3WXp6OFZMNHk0d29la3IzRmwxUjh2OVkzKzJaNmw2TWNIS3FGV2owVkxCU0JFdnAKZzFBSGtZcUdRL2NzbXBOSWMvQ2ZUdWFSdzVsTFZqNlpVMjNmaENsYW9ldWUyejk2U0w5NGltZnFlbUJNOGtwNQp3djNoS3dqdVBsMGxEbkxKd21IOHFMK094L0Y3eTZseWhnSnRVeGtsc0hWQXlPeDlrekM4ZkNZL3BMbDhqc1lvCnh0c29ZMlRhcW1uSEUwNk5KaUk4VVBLd3NzM1M1cUFEV2xzSk4rc2ZURzViVTFZWlVUcjhybi9yc2VlQ1dpOFkKTFdXV3JHeVBjeVd3ZDNJY0pZSVIyWEdXNHNDYkpUdHllMGszRm9WUHV3VHdSVGlkVnVaWjN4OXIzbkpuSk84WAphdkpXa1Z3b0paK1ZRd1AyN1BPc1RFZVR2cFNWMjZkNENJYlZmSnRDZldhd1cybUU5QlozZ1RBbmFuK2pEQTl4CndTektWSW0yYnBIZCtHU0QwUXJvZkNXL2h1OUN2Q2p4aGR0aHlSYzJKOWd2SzFXMjAwZW9HdFl6d0E9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== diff --git a/examples/common-secrets/keycloak-tls-secret.yaml b/examples/common-secrets/keycloak-tls-secret.yaml new file mode 100644 index 0000000000..94e6a604b6 --- /dev/null +++ b/examples/common-secrets/keycloak-tls-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-tls +type: kubernetes.io/tls +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZ4ekNDQTYrZ0F3SUJBZ0lVSnIxb2VDQTcxTmhjQ3VIVmh1NHVQcXNEVDhjd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2N6RUxNQWtHQTFVRUJoTUNTVVV4RFRBTEJnTlZCQWdNQkVOdmNtc3hEVEFMQmdOVkJBY01CRU52Y21zeApDekFKQmdOVkJBb01Ba1kxTVF3d0NnWURWUVFMREFOT1NVTXhLekFwQmdOVkJBTU1JbXRsZVdOc2IyRnJMbVJsClptRjFiSFF1YzNaakxtTnNkWE4wWlhJdWJHOWpZV3d3SGhjTk1qVXhNVEUzTVRJeU9EVTVXaGNOTXpVeE1URTEKTVRJeU9EVTVXakJ6TVFzd0NRWURWUVFHRXdKSlJURU5NQXNHQTFVRUNBd0VRMjl5YXpFTk1Bc0dBMVVFQnd3RQpRMjl5YXpFTE1Ba0dBMVVFQ2d3Q1JqVXhEREFLQmdOVkJBc01BMDVKUXpFck1Da0dBMVVFQXd3aWEyVjVZMnh2CllXc3VaR1ZtWVhWc2RDNXpkbU11WTJ4MWMzUmxjaTVzYjJOaGJEQ0NBaUl3RFFZSktvWklodmNOQVFFQkJRQUQKZ2dJUEFEQ0NBZ29DZ2dJQkFLK1M5cVRXdGZ3YU1GdjI3aU5Ob3FJU2FaMXJ4R0VlZlpTd29ZTCtBZEVpTXhEMgpxN0dvbkc3QlZaOVoxQWpMTlhtcGwyeFF1ZVZBN25RYXpXa2JJazhhQytJOWwwQ1NudXNiK1UwMUV6V0g5MVJ2CnNRM0pFdlV1WXlma3loNnRGeGoyNTJFMTQ3TE1HcTlXOHVlRDNJYnNWVjRZWlBDY0VHN1c3aEYzSTVObllYa1kKYzVKWFFtKzNTODl5V2hWUDg1MHpGTEpTVUFkcHhuMW9qdmFhV3FZWHVrODd4ajU3U1VueGpzR3pTN1dxMDJxVAo0WjVBNENjUGR5Y2Vha2MzcDRLQXVQaHZSYnltK2l1ME13WWFDZ3ZDZnV2eTZLa0l5c1lCL0dUUmhKcFltU2FKCmNsUXFxT3NRTjd6U3crZDlrOFNlSnFONGVCMHVFbmNkSkRwcDJTa05qa3ZRaEhkRllncm5KN1dZVmZBSmxibkIKSUc3VVBmRCtIZDduRkhFdWIvdE4zN20xRnkyZjhjUnNkNWZ1Q29NNHhoelRucm1wbDFiTEZYN2dOLzgrNGZmZwpzc2VCSmZqbExKbCtzTTA4RlI0aVFicGMzK0xZbkpzOXdsUE82VTBzQ2VJUXB4SnVTUEh5aENtN2hFR2N5NGdKCi9SdENwZHVabitrMDdnTXVnUlVVdUxNV2JNWjhaTEh2cWNwbTliY05aQXNxRERzdkIxMTdldFh4Q1luVC9DRGMKYVJBMVh3a2ptNS9DY0k0U0JqNzQrZC8rMnJDbHBEZGl0dWxyTnJ6WjcrVTZFSU1pdDV3SnJ0V09nVlpYdGNxOAp3UlFWdHZINVNkZjZnWVlvOE9HejhRenAzZEJ5TEVaWDU1V0FzT084dWhhcUJGenk1TEVaSlk1WEo2SlpBZ01CCkFBR2pVekJSTUIwR0ExVWREZ1FXQkJUcmR5VTZzRVhRWHR5S1doRFhVbHJaMmpjQXhEQWZCZ05WSFNNRUdEQVcKZ0JUcmR5VTZzRVhRWHR5S1doRFhVbHJaMmpjQXhEQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BMEdDU3FHU0liMwpEUUVCQ3dVQUE0SUNBUUNTTDNFcDBrWUFBUnIrSStVcjhRdmhkdDg1NUtIbFIwenMyZkN3elFORjArUlFXTm1WCjF5ZitUaEkwRW13NnVaWk5rYXNWenFQcENZNU81VWkwK2ZhL09LaUs1eGdXVjVlais1SEJndFk1VitVVE9yYm0KTlpiODE2UnBya1NadTBFWlpqNVNnUEhmSzUraVNuWnVKL0J2Nk13WjNYR2F1N3pHdlBBRGpqSVBwMEJqczluVwpWUXQyQWRmZlQrUXd1UHgreVJEZTFEdHhpSkEvMlQxN0c3eUN3NnBDWHJsVHdNckk5dTVtSHhmM0FSaVJOZ01mCndPSHJBNXNJYkhFVVQ5QXc3WXp6OFZMNHk0d29la3IzRmwxUjh2OVkzKzJaNmw2TWNIS3FGV2owVkxCU0JFdnAKZzFBSGtZcUdRL2NzbXBOSWMvQ2ZUdWFSdzVsTFZqNlpVMjNmaENsYW9ldWUyejk2U0w5NGltZnFlbUJNOGtwNQp3djNoS3dqdVBsMGxEbkxKd21IOHFMK094L0Y3eTZseWhnSnRVeGtsc0hWQXlPeDlrekM4ZkNZL3BMbDhqc1lvCnh0c29ZMlRhcW1uSEUwNk5KaUk4VVBLd3NzM1M1cUFEV2xzSk4rc2ZURzViVTFZWlVUcjhybi9yc2VlQ1dpOFkKTFdXV3JHeVBjeVd3ZDNJY0pZSVIyWEdXNHNDYkpUdHllMGszRm9WUHV3VHdSVGlkVnVaWjN4OXIzbkpuSk84WAphdkpXa1Z3b0paK1ZRd1AyN1BPc1RFZVR2cFNWMjZkNENJYlZmSnRDZldhd1cybUU5QlozZ1RBbmFuK2pEQTl4CndTektWSW0yYnBIZCtHU0QwUXJvZkNXL2h1OUN2Q2p4aGR0aHlSYzJKOWd2SzFXMjAwZW9HdFl6d0E9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUpRZ0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQ1N3d2dna29BZ0VBQW9JQ0FRQ3ZrdmFrMXJYOEdqQmIKOXU0alRhS2lFbW1kYThSaEhuMlVzS0dDL2dIUklqTVE5cXV4cUp4dXdWV2ZXZFFJeXpWNXFaZHNVTG5sUU81MApHczFwR3lKUEdndmlQWmRBa3A3ckcvbE5OUk0xaC9kVWI3RU55UkwxTG1NbjVNb2VyUmNZOXVkaE5lT3l6QnF2ClZ2TG5nOXlHN0ZWZUdHVHduQkJ1MXU0UmR5T1RaMkY1R0hPU1YwSnZ0MHZQY2xvVlQvT2RNeFN5VWxBSGFjWjkKYUk3Mm1scW1GN3BQTzhZK2UwbEo4WTdCczB1MXF0TnFrK0dlUU9BbkQzY25IbXBITjZlQ2dMajRiMFc4cHZvcgp0RE1HR2dvTHduN3I4dWlwQ01yR0FmeGswWVNhV0prbWlYSlVLcWpyRURlODBzUG5mWlBFbmlhamVIZ2RMaEozCkhTUTZhZGtwRFk1TDBJUjNSV0lLNXllMW1GWHdDWlc1d1NCdTFEM3cvaDNlNXhSeExtLzdUZCs1dFJjdG4vSEUKYkhlWDdncURPTVljMDU2NXFaZFd5eFYrNERmL1B1SDM0TExIZ1NYNDVTeVpmckROUEJVZUlrRzZYTi9pMkp5YgpQY0pUenVsTkxBbmlFS2NTYmtqeDhvUXB1NFJCbk11SUNmMGJRcVhibVovcE5PNERMb0VWRkxpekZtekdmR1N4Cjc2bktadlczRFdRTEtndzdMd2RkZTNyVjhRbUowL3dnM0drUU5WOEpJNXVmd25DT0VnWSsrUG5mL3Rxd3BhUTMKWXJicGF6YTgyZS9sT2hDRElyZWNDYTdWam9GV1Y3WEt2TUVVRmJieCtVblgrb0dHS1BEaHMvRU02ZDNRY2l4RwpWK2VWZ0xEanZMb1dxZ1JjOHVTeEdTV09WeWVpV1FJREFRQUJBb0lDQUI4YzFtczRoekJBL2MvV0xyWC8xSEdPCi9MdENOUjhXdFo5TE81dklZazhLbGUwTUlUbk96TVhOcWR3ZW9YWGJlTUx4L0J6Y0kwME9XQk1vQ3IxMDZ2d0UKZkJXZjMzVTRaa1A0aFpHYWRhaDNTeXRoellqSldId3RON0lDbDVTZkRLaEdYSk03NXZrd3RRdmNSeGdpcEVvZQppRFF2ODNjMTJLMmpsYlZ2bk5US3JabTFiUW1DUUFvbSs1NnJ2MjNtYUorelJSZ2lnUDhIVGY2OE1CVmdIZTh2CjVqcVRONXFyNHoxZ3VuRDEwbFZEaThwbm9VUVhjQUZMK3N2cVZsLy9hMFl6aEZPMStEQXBrTXg4MXN2ZWdtZzYKRTU3QlFWeHU2K3Z4dnlXb2dTeU94Ymp3QTF3SjRUd2llQllVYlZYUXlZWStsazlDa2xwdFp5VkhlenVFdFZBRwoxSkdDb3NyZkl0eUE3YXdSVXFSMWFwajJPS0Jyd1dROW5nK0dvR0RRNnBLTHdzN1F6TUlkUVNMZGY2SVZMZWE4CjJTZ1AyK0hycUIrR1g5ZlJGdUFPRTRQNGprNGtMT3FTSkR4OENvdkdiS3NSUm1RR1lGL282dFhJYm5zZFVNaUsKZUVuYUVINXRmVWZ1WXNlWTlmR3pTUGFJT1FLeXI4TWJtanp3NFprNE9VUmxRWE41K3kzOGxXREVyb3NybG9VUwpZSkxucU5sVUEvNk96d3RTaCtRenFLeGEzcS9jYndFbU54NDYzOTlzRDNxSndXQlBXcng1REtiSlp4SWtQNFVOCnE1YUVGZW5kY09mZTNxNG9uQm1FQ28weW1zRko0eGNTUFdYUGJtRk91dHJiTnN5R0xDRURlUjNpRmM0Qk12aTYKZURwSHl0MTlXTDloLysyVC9OeG5Bb0lCQVFEd2JLaTd3TUgyWUlUYzBkTi82SEtiaVdJQXVFZkZMbzZSOUxuTwpaWnR2QW5tQTNSTmpJanB3R1BGQ3A0QjdjdEpiVWtIbzlSemdZQ3RKeGF6ZE5wWXRRaFhMRFZ0c0ExZ21zQUo0CmJwcVUrVnhoeTd5R1lzZDNZVDRVcUVRZFNtbHdYRHhuSFlsb0hWd3ExQlRxeWNZSjNYZGV6bE9BS0JzZFR6eGwKZmJGdjJjSXM3aTBUcU1VbzZSd1hVcnhSSU85TGNJL2ZaSWROVmd4MVk2Nm9SaXoxdnlVYVlISUNGM3V6OHl6VgpYc1JoMVZzNFdXVXRhTmlzbXBWTkFnTEtHTUthWGlKNU9pRjA1RWljcUs0bWtNR2lUMFFaZElpNkdyd3dEMkU5Cm5qZjY2bkwycmtGQXRudHpaVkdXMTVwVkUxc2ZJV0UrbHcycGlhSjFESTVNVm4zYkFvSUJBUUM2OHNtWTZFK2QKQmVzTUpuSXhVaXdPUWZ6a256OG5udGVvMzFSb2c5R1dDejBnNlJFVnBRdDZkVU43Wi8yQnZKVUtkNk0yZmlYYwppUmNFRTF6RHpaZ3RMVmhWbG5ZaWZnT1lsbU9NRlRZbHRGUnQ0emgzbUM1TUxOVThVVmhNUjE2cHYvVkF2RmhpCnlxbVNQVG1acjNVZkNKdjZjeFZRbFZJYlVTTVI3bmlSSW13T2JPYUJ3OFpCV0JaOUVCYi90N0ROUVpCV2ZKdTIKejdESXhsUXBxcFM1aWZLRlZpT2pCL0hXcnJ1RHpLUjROdFFDSkRoZERoUlZCWWxsSkpKRE9GV3VCbm5hS3EveQpiZWlrV2dSZmI3YUNSOU5LUVk3aHM4d2dDL0MzNFVBR2lyN2pJNURpQmFKOGVlM0xoMk5GRTI4VWFUTW5mSDNJClFQbVN1MUI2NjJqYkFvSUJBSEZ6QktnY0RDckRYczZJWUtIeHdPcnVDQVhJNzJ6M1RDVkpjc2dYSUNKZzY0N0kKUTFhN0Z4SkFZdEFPRkUyc1grRGh6dUlyajZXOUc1QWpMQy95aXlqdUR6U1NwL292RmRDanEzYkMwa1RMNmpEbgpuNTFXVFVOaTZwVjYxVEZ4SkpIMXBEY1FNLytpSXhTK29PUXR0RHFCZTh1TDF0RVptN25YNHVzTlJjWSszaWF2CmVTdldycnBnVFhZZi8yYlZBTFg3ZHBoMmFuWXV6WkF6S242VEpySUxzV2xoNjBwYlpHOEVwN3BEanEyUHJRekkKK2pwVVNESWllNk1yK0w3K3NnMS9zQXErU0gxTkg0cDAra0NPZkNDb0FMMTJSUEowblNxY2gwazVPTGM1SEdpVQp6NHZHMERnaXJqNWNuS0hha1Z2K04xSCttMTdONkpBTkRiU3Q5NU1DZ2dFQkFLS3R4UG4zSmRoSkh4bEtsMUlOCjVHSmZ6N1lPVVVHaitweHNBcUtVR3B4TG1WejdFeS9YbUI1dXpsTWowYmpFcHBrZU5IdWwyRUtKVk9ycUFtNHMKaVFDL0ZjQWNseDQ2czl4aSthc2JoaXZYT1NVS2RjZTBPSTEyOGZOMEFiY1czK3d0S3ppeTdPTEM0ajVzWXFRMgp4MTlDK2FBOTVzMWhzcm9zcDZ6aDdDNjNXbnBQRDJMYVBybjc4azNQNDRPUWtCeDhzaUpnZW92aFBUL3BQYkdvClM1VU0wbXB1NDhIcGx1dXV6MlBJZjFKUXU3cEZWSHE5VnJvSmdGN3dMUXFyaWZ0T2pWaG9qd1VSMlVDelNGelgKOUdSNEpnZlc5b08zRnFqSVd5ZFhyb1JDMWdzSGx2cm4xbFlsTCtWTklmZ3BDaDhqMEN6TEt4VklYU1R2TlFCUgp1OE1DZ2dFQWJuQ0JSdmtNUnh6S1NkM1V5USszV0tBdnYzVHk2L1phNy8rVVBhZzFRTE5RSksram50L3AvNWpzCkRCRWhPSDhMT2MxTnEzSEpWUVBjV2kwVXo1aDhOWVJwUWx2QWNlRjRTb3d4ZGIrWWRTS1RIN3daVDFwa1dkY0kKWTNib1N0Y0hsOVlCM1Q5STVIZWR4dHgzMytNSkppMXcwVGswK3lNcHBJUmV2VFg3WjVpbnZpbHE3eWJITzd6NwoxbHRHaGJJTjZSblVzcy9mZE1ianJ4N3dreEtqMmlKLzYyM3B1Z0tsakdndUlVR1l3ODNaeUREaGp3ZVJleTlMCmphQVViN01CQ2xsR0NNS3hEZGtsUUN3eHh0YmpySk1QZ0JSbVRjK093QzNwUDNqemJLNlRuQWhnL3JxeEpaYnAKN3hFL0lyd3lxOXJtY3lqZzlsVkh0RHRFN1RDSVRnPT0KLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQo= diff --git a/examples/custom-resources/oidc/README.md b/examples/custom-resources/oidc/README.md index 817b7c5c32..26d3e98bb6 100644 --- a/examples/custom-resources/oidc/README.md +++ b/examples/custom-resources/oidc/README.md @@ -53,13 +53,19 @@ kubectl apply -f webapp.yaml ## Step 3 - Deploy Keycloak -1. Create the Keycloak deployment and service: +1. Setup the secret required for Keycloak to run with https: + + ```shell + kubectl apply -f keycloak-tls-secret.yaml + ``` + +2. Create the Keycloak deployment and service: ```shell kubectl apply -f keycloak.yaml ``` -2. Create a VirtualServer resource for Keycloak: +3. Create a VirtualServer resource for Keycloak: ```shell kubectl apply -f virtual-server-idp.yaml @@ -115,7 +121,15 @@ Steps: kubectl apply -f nginx-config.yaml ``` -## Step 7 - Deploy the OIDC Policy +## Step 7 - Setup the Keycloak CA certificate + +Create a Secret containing the Keycloak CA, this used in the OIDC Policy to verify the Keycloak TLS certificate + +```shell +kubectl apply -f keycloak-ca-secret.yaml +``` + +## Step 8 - Deploy the OIDC Policy Create a policy with the name `oidc-policy` that references the secret from the previous step: @@ -123,7 +137,7 @@ Create a policy with the name `oidc-policy` that references the secret from the kubectl apply -f oidc.yaml ``` -## Step 8 - Configure Load Balancing +## Step 9 - Configure Load Balancing Create a VirtualServer resource for the web application: @@ -133,7 +147,7 @@ kubectl apply -f virtual-server.yaml Note that the VirtualServer references the policy `oidc-policy` created in Step 6. -## Step 9 - Test the Configuration +## Step 10 - Test the Configuration 1. Open a web browser and navigate to the URL of the web application: `https://webapp.example.com`. You will be redirected to Keycloak. @@ -142,7 +156,7 @@ Note that the VirtualServer references the policy `oidc-policy` created in Step 3. Once logged in, you will be redirected to the web application and get a response from it. Notice the field `User ID` in the response, this will match the ID for your user in Keycloak. ![webapp](./webapp.png) -## Step 10 - Log Out +## Step 11 - Log Out 1. To log out, navigate to `https://webapp.example.com/logout`. Your session will be terminated, and you will be redirected to the default post logout URI `https://webapp.example.com/_logout`. diff --git a/examples/custom-resources/oidc/keycloak-ca-secret.yaml b/examples/custom-resources/oidc/keycloak-ca-secret.yaml new file mode 120000 index 0000000000..2f1afcb5db --- /dev/null +++ b/examples/custom-resources/oidc/keycloak-ca-secret.yaml @@ -0,0 +1 @@ +../../common-secrets/keycloak-ca-secret.yaml \ No newline at end of file diff --git a/examples/custom-resources/oidc/keycloak-tls-secret.yaml b/examples/custom-resources/oidc/keycloak-tls-secret.yaml new file mode 120000 index 0000000000..ed0af5e93b --- /dev/null +++ b/examples/custom-resources/oidc/keycloak-tls-secret.yaml @@ -0,0 +1 @@ +../../common-secrets/keycloak-tls-secret.yaml \ No newline at end of file diff --git a/examples/custom-resources/oidc/keycloak.yaml b/examples/custom-resources/oidc/keycloak.yaml index 5d0c1a064e..0e4b2deb01 100644 --- a/examples/custom-resources/oidc/keycloak.yaml +++ b/examples/custom-resources/oidc/keycloak.yaml @@ -9,6 +9,9 @@ spec: - name: http port: 8080 targetPort: 8080 + - name: https + port: 8443 + targetPort: 8443 selector: app: keycloak --- @@ -30,24 +33,38 @@ spec: app: keycloak spec: containers: - - name: keycloak - image: quay.io/keycloak/keycloak:26.4.4 - args: ["start-dev"] - env: - - name: KC_BOOTSTRAP_ADMIN_USERNAME - value: "admin" - - name: KC_BOOTSTRAP_ADMIN_PASSWORD - value: "admin" - - name: KC_HTTP_ENABLED - value: "true" - - name: KC_PROXY_HEADERS - value: "xforwarded" - ports: - - name: http - containerPort: 8080 - - name: https - containerPort: 8443 - readinessProbe: - httpGet: - path: /realms/master - port: 8080 + - name: keycloak + image: quay.io/keycloak/keycloak:26.4.4 + args: ["start"] + env: + - name: KC_BOOTSTRAP_ADMIN_USERNAME + value: "admin" + - name: KC_BOOTSTRAP_ADMIN_PASSWORD + value: "admin" + - name: KC_HTTP_ENABLED + value: "true" + - name: KC_PROXY_HEADERS + value: "xforwarded" + - name: KC_HTTPS_CERTIFICATE_FILE + value: "/etc/x509/https/tls.crt" + - name: KC_HTTPS_CERTIFICATE_KEY_FILE + value: "/etc/x509/https/tls.key" + - name: KC_HOSTNAME_STRICT + value: "false" + volumeMounts: + - name: tls-secret + mountPath: /etc/x509/https + readOnly: true + ports: + - name: http + containerPort: 8080 + - name: https + containerPort: 8443 + readinessProbe: + httpGet: + path: /realms/master + port: 8080 + volumes: + - name: tls-secret + secret: + secretName: keycloak-tls diff --git a/examples/custom-resources/oidc/oidc.yaml b/examples/custom-resources/oidc/oidc.yaml index 990924f3de..d5ace343e2 100644 --- a/examples/custom-resources/oidc/oidc.yaml +++ b/examples/custom-resources/oidc/oidc.yaml @@ -7,8 +7,10 @@ spec: clientID: nginx-plus clientSecret: oidc-secret authEndpoint: https://keycloak.example.com/realms/master/protocol/openid-connect/auth - tokenEndpoint: http://keycloak.default.svc.cluster.local:8080/realms/master/protocol/openid-connect/token - jwksURI: http://keycloak.default.svc.cluster.local:8080/realms/master/protocol/openid-connect/certs + tokenEndpoint: https://keycloak.default.svc.cluster.local:8443/realms/master/protocol/openid-connect/token + jwksURI: https://keycloak.default.svc.cluster.local:8443/realms/master/protocol/openid-connect/certs endSessionEndpoint: https://keycloak.example.com/realms/master/protocol/openid-connect/logout scope: openid+profile+email accessTokenEnable: true + sslVerify: true + trustedCertSecret: keycloak-ca From 4ffa0f1abcf46e94baff2980e97d63d1c96087ea Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Wed, 19 Nov 2025 11:58:58 +0000 Subject: [PATCH 06/17] Add template tests --- .../__snapshots__/templates_test.snap | 239 ++++++++++++++++++ internal/configs/version2/templates_test.go | 64 ++++- 2 files changed, 301 insertions(+), 2 deletions(-) diff --git a/internal/configs/version2/__snapshots__/templates_test.snap b/internal/configs/version2/__snapshots__/templates_test.snap index dbe230e96d..66ca0ff35b 100644 --- a/internal/configs/version2/__snapshots__/templates_test.snap +++ b/internal/configs/version2/__snapshots__/templates_test.snap @@ -2844,6 +2844,245 @@ server { --- +[TestExecuteVirtualServerTemplate_WithCustomOIDCRedirectLocation - 1] + # Advanced configuration START + set $internal_error_message "NGINX / OpenID Connect login failure\n"; + set $pkce_id ""; + set $idp_sid ""; + # resolver 8.8.8.8; # For DNS lookup of IdP endpoints; + subrequest_output_buffer_size 32k; # To fit a complete tokenset response + gunzip on; # Decompress IdP responses if necessary + # Advanced configuration END + + location = /_jwks_uri { + internal; + proxy_cache jwk; # Cache the JWK Set received from IdP + proxy_cache_valid 200 12h; # How long to consider keys "fresh" + proxy_cache_use_stale error timeout updating; # Use old JWK Set if cannot reach IdP + proxy_ssl_server_name on; # Send SNI to IdP host + + proxy_method GET; # In case client request was non-GET + proxy_set_header Content-Length ""; # '' + proxy_pass $oidc_jwt_keyfile; # Expecting to find a URI here + proxy_ignore_headers Cache-Control Expires Set-Cookie; # Does not influence caching + } + + location @do_oidc_flow { + status_zone "OIDC start"; + js_content oidc.auth; + default_type text/plain; # In case we throw an error + } + + set $redir_location "/custom-location"; + location = /custom-location { + # This location is called by the IdP after successful authentication + status_zone "OIDC code exchange"; + js_content oidc.codeExchange; + error_page 500 502 504 @oidc_error; + } + + location = /_token { + # This location is called by oidcCodeExchange(). We use the proxy_ directives + # to construct the OpenID Connect token request, as per: + # http://openid.net/specs/openid-connect-core-1_0.html#TokenRequest + internal; + + # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) + proxy_pass_request_headers off; + proxy_ssl_server_name on; # Send SNI to IdP host + + proxy_set_header Content-Type "application/x-www-form-urlencoded"; + proxy_set_header Authorization $arg_secret_basic; + proxy_pass $oidc_token_endpoint; + } + + location = /_refresh { + # This location is called by oidcAuth() when performing a token refresh. We + # use the proxy_ directives to construct the OpenID Connect token request, as per: + # https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken + internal; + + # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) + proxy_pass_request_headers off; + proxy_ssl_server_name on; # Send SNI to IdP host + + proxy_set_header Content-Type "application/x-www-form-urlencoded"; + proxy_set_header Authorization $arg_secret_basic; + proxy_pass $oidc_token_endpoint; + } + + location = /_token_validation { + # Internal location to verify any JWT (e.g., id_token, logout_token) + # using the auth_jwt module. Extracts the claims and returns them as JSON. + internal; + auth_jwt "" token=$arg_token; + js_content oidc.extractTokenClaims; + error_page 500 502 504 @oidc_error; + } + + location = /logout { + status_zone "OIDC logout"; + add_header Set-Cookie "auth_token=; $oidc_cookie_flags"; + add_header Set-Cookie "auth_nonce=; $oidc_cookie_flags"; + add_header Set-Cookie "auth_redir=; $oidc_cookie_flags"; + js_content oidc.logout; + } + + location = /front_channel_logout { + status_zone "OIDC logout"; + add_header Cache-Control "no-store"; + default_type text/plain; + js_content oidc.handleFrontChannelLogout; + } + + location = /_logout { + # This location is the default value of $oidc_logout_redirect (in case it wasn't configured) + default_type text/plain; + return 200 "Logged out\n"; + } + + location @oidc_error { + # This location is called when oidcAuth() or oidcCodeExchange() returns an error + status_zone "OIDC error"; + default_type text/plain; + return 500 $internal_error_message; + } + + # location /api/ { + # api write=on; + # allow 127.0.0.1; # Only the NGINX host may call the NGINX Plus API + # deny all; + # access_log off; + # } + +# vim: syntax=nginx + +--- + +[TestExecuteVirtualServerTemplate_WithOIDCTLSVerify - 1] + # Advanced configuration START + set $internal_error_message "NGINX / OpenID Connect login failure\n"; + set $pkce_id ""; + set $idp_sid ""; + # resolver 8.8.8.8; # For DNS lookup of IdP endpoints; + subrequest_output_buffer_size 32k; # To fit a complete tokenset response + gunzip on; # Decompress IdP responses if necessary + # Advanced configuration END + + location = /_jwks_uri { + internal; + proxy_cache jwk; # Cache the JWK Set received from IdP + proxy_cache_valid 200 12h; # How long to consider keys "fresh" + proxy_cache_use_stale error timeout updating; # Use old JWK Set if cannot reach IdP + proxy_ssl_verify on; + proxy_ssl_verify_depth 1; + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificate.crt; + proxy_ssl_server_name on; # Send SNI to IdP host + + proxy_method GET; # In case client request was non-GET + proxy_set_header Content-Length ""; # '' + proxy_pass $oidc_jwt_keyfile; # Expecting to find a URI here + proxy_ignore_headers Cache-Control Expires Set-Cookie; # Does not influence caching + } + + location @do_oidc_flow { + status_zone "OIDC start"; + js_content oidc.auth; + default_type text/plain; # In case we throw an error + } + + set $redir_location ""; + location = { + # This location is called by the IdP after successful authentication + status_zone "OIDC code exchange"; + js_content oidc.codeExchange; + error_page 500 502 504 @oidc_error; + } + + location = /_token { + # This location is called by oidcCodeExchange(). We use the proxy_ directives + # to construct the OpenID Connect token request, as per: + # http://openid.net/specs/openid-connect-core-1_0.html#TokenRequest + internal; + + # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) + proxy_pass_request_headers off; + proxy_ssl_verify on; + proxy_ssl_verify_depth 1; + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificate.crt; + proxy_ssl_server_name on; # Send SNI to IdP host + + proxy_set_header Content-Type "application/x-www-form-urlencoded"; + proxy_set_header Authorization $arg_secret_basic; + proxy_pass $oidc_token_endpoint; + } + + location = /_refresh { + # This location is called by oidcAuth() when performing a token refresh. We + # use the proxy_ directives to construct the OpenID Connect token request, as per: + # https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken + internal; + + # Exclude client headers to avoid CORS errors with certain IdPs (e.g., Microsoft Entra ID) + proxy_pass_request_headers off; + proxy_ssl_verify on; + proxy_ssl_verify_depth 1; + proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificate.crt; + proxy_ssl_server_name on; # Send SNI to IdP host + + proxy_set_header Content-Type "application/x-www-form-urlencoded"; + proxy_set_header Authorization $arg_secret_basic; + proxy_pass $oidc_token_endpoint; + } + + location = /_token_validation { + # Internal location to verify any JWT (e.g., id_token, logout_token) + # using the auth_jwt module. Extracts the claims and returns them as JSON. + internal; + auth_jwt "" token=$arg_token; + js_content oidc.extractTokenClaims; + error_page 500 502 504 @oidc_error; + } + + location = /logout { + status_zone "OIDC logout"; + add_header Set-Cookie "auth_token=; $oidc_cookie_flags"; + add_header Set-Cookie "auth_nonce=; $oidc_cookie_flags"; + add_header Set-Cookie "auth_redir=; $oidc_cookie_flags"; + js_content oidc.logout; + } + + location = /front_channel_logout { + status_zone "OIDC logout"; + add_header Cache-Control "no-store"; + default_type text/plain; + js_content oidc.handleFrontChannelLogout; + } + + location = /_logout { + # This location is the default value of $oidc_logout_redirect (in case it wasn't configured) + default_type text/plain; + return 200 "Logged out\n"; + } + + location @oidc_error { + # This location is called when oidcAuth() or oidcCodeExchange() returns an error + status_zone "OIDC error"; + default_type text/plain; + return 500 $internal_error_message; + } + + # location /api/ { + # api write=on; + # allow 127.0.0.1; # Only the NGINX host may call the NGINX Plus API + # deny all; + # access_log off; + # } + +# vim: syntax=nginx + +--- + [TestTLSPassthroughHosts - 1] # mapping between TLS Passthrough hosts and unix sockets diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go index 7fd9e09cc1..813d22931c 100644 --- a/internal/configs/version2/templates_test.go +++ b/internal/configs/version2/templates_test.go @@ -811,7 +811,7 @@ func TestExecuteVirtualServerTemplateWithAPIKeyPolicyNGINXPlus(t *testing.T) { func TestExecuteVirtualServerTemplate_WithCustomOIDCRedirectLocation(t *testing.T) { t.Parallel() executor := newTmplExecutorNGINXPlus(t) - got, err := executor.ExecuteOIDCTemplate(virtualServerCfg.Server.OIDC) + got, err := executor.ExecuteOIDCTemplate(virtualServerCfgWithOIDCAndCustomRedirectURI.Server.OIDC) if err != nil { t.Error(err) } @@ -837,6 +837,31 @@ func TestExecuteVirtualServerTemplate_WithCustomOIDCRedirectLocation(t *testing. if !bytes.Contains(got, []byte(expectedRedirVar)) { t.Errorf("Should set $redir_location to custom value: %s", expectedRedirVar) } + snaps.MatchSnapshot(t, string(got)) + t.Log(string(got)) +} + +func TestExecuteVirtualServerTemplate_WithOIDCTLSVerify(t *testing.T) { + t.Parallel() + executor := newTmplExecutorNGINXPlus(t) + got, err := executor.ExecuteOIDCTemplate(virtualServerCfgWithOIDCAndTLSVerify.Server.OIDC) + if err != nil { + t.Error(err) + } + + expectedDirectives := []string{ + "proxy_ssl_verify on;", + "proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificate.crt;", + "proxy_ssl_verify_depth 1;", + } + + for _, directive := range expectedDirectives { + if !bytes.Contains(got, []byte(directive)) { + t.Errorf("Should contain directive: %s", directive) + } + } + snaps.MatchSnapshot(t, string(got)) + t.Log(string(got)) } func TestExecuteVirtualServerTemplateWithOIDCAndPKCEPolicyNGINXPlus(t *testing.T) { @@ -1523,7 +1548,6 @@ var ( JwksURI: "https://idp.example.com/jwks", TokenEndpoint: "https://idp.example.com/token", EndSessionEndpoint: "https://idp.example.com/logout", - RedirectURI: "/custom-location", PostLogoutRedirectURI: "https://example.com/logout", ZoneSyncLeeway: 0, Scope: "openid+profile+email", @@ -2821,6 +2845,42 @@ var ( }, } + virtualServerCfgWithOIDCAndCustomRedirectURI = VirtualServerConfig{ + Server: Server{ + ServerName: "example.com", + StatusZone: "example.com", + ProxyProtocol: true, + OIDC: &OIDC{ + RedirectURI: "/custom-location", + }, + NGINXDebugLevel: "error", + Locations: []Location{ + { + Path: "/", + }, + }, + }, + } + + virtualServerCfgWithOIDCAndTLSVerify = VirtualServerConfig{ + Server: Server{ + ServerName: "example.com", + StatusZone: "example.com", + ProxyProtocol: true, + OIDC: &OIDC{ + TLSVerify: true, + VerifyDepth: 1, + CAFile: "/etc/ssl/certs/ca-certificate.crt", + }, + NGINXDebugLevel: "error", + Locations: []Location{ + { + Path: "/", + }, + }, + }, + } + virtualServerCfgWithCachePolicyNGINXPlus = VirtualServerConfig{ CacheZones: []CacheZone{ { From cca7ad2440a834acc7257ffe07ff7c2924b8d471 Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Wed, 19 Nov 2025 14:29:47 +0000 Subject: [PATCH 07/17] refactor OS calls to manager package --- cmd/nginx-ingress/main.go | 8 + internal/configs/config_params.go | 1 + internal/configs/virtualserver.go | 62 ++--- internal/configs/virtualserver_test.go | 307 ++++++++++++++++--------- internal/nginx/fake_manager.go | 6 + internal/nginx/manager.go | 37 +++ internal/nginx/manager_test.go | 115 +++++++++ 7 files changed, 387 insertions(+), 149 deletions(-) diff --git a/cmd/nginx-ingress/main.go b/cmd/nginx-ingress/main.go index 678e3c7142..f18c87aa76 100644 --- a/cmd/nginx-ingress/main.go +++ b/cmd/nginx-ingress/main.go @@ -79,6 +79,7 @@ const ( socketPath = "/var/lib/nginx" fatalEventFlushTime = 200 * time.Millisecond secretErrorReason = "SecretError" + fileErrorReason = "FileError" configMapErrorReason = "ConfigMapError" ) @@ -191,6 +192,12 @@ func main() { if err != nil { logEventAndExit(ctx, eventRecorder, pod, secretErrorReason, err) } + + caBundlePath, err := nginxManager.GetOSCABundlePath() + if err != nil { + logEventAndExit(ctx, eventRecorder, pod, fileErrorReason, err) + } + globalConfigurationValidator := createGlobalConfigurationValidator() mustProcessGlobalConfiguration(ctx) @@ -226,6 +233,7 @@ func main() { StaticSSLPath: staticSSLPath, NginxVersion: nginxVersion, AppProtectBundlePath: appProtectBundlePath, + DefaultCABundle: caBundlePath, } if *nginxPlus { diff --git a/internal/configs/config_params.go b/internal/configs/config_params.go index 1410fd45f6..d2885c9cab 100644 --- a/internal/configs/config_params.go +++ b/internal/configs/config_params.go @@ -169,6 +169,7 @@ type StaticConfigParams struct { IsDirectiveAutoadjustEnabled bool NginxVersion nginx.Version AppProtectBundlePath string + DefaultCABundle string } // GlobalConfigParams holds global configuration parameters. For now, it only holds listeners. diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go index 8311760872..6594aae067 100644 --- a/internal/configs/virtualserver.go +++ b/internal/configs/virtualserver.go @@ -9,7 +9,6 @@ import ( "net/url" "os" "path" - "regexp" "sort" "strconv" "strings" @@ -304,6 +303,7 @@ type virtualServerConfigurator struct { isIPV6Disabled bool DynamicSSLReloadEnabled bool StaticSSLPath string + CABundlePath string DynamicWeightChangesReload bool bundleValidator bundleValidator IngressControllerReplicas int @@ -354,6 +354,7 @@ func newVirtualServerConfigurator( isIPV6Disabled: staticParams.DisableIPV6, DynamicSSLReloadEnabled: staticParams.DynamicSSLReload, StaticSSLPath: staticParams.StaticSSLPath, + CABundlePath: staticParams.DefaultCABundle, DynamicWeightChangesReload: staticParams.DynamicWeightChangesReload, bundleValidator: bundleValidator, } @@ -427,10 +428,11 @@ func (vsc *virtualServerConfigurator) GenerateVirtualServerConfig( tlsRedirectConfig := generateTLSRedirectConfig(vsEx.VirtualServer.Spec.TLS) policyOpts := policyOptions{ - tls: sslConfig != nil, - zoneSync: vsEx.ZoneSync, - secretRefs: vsEx.SecretRefs, - apResources: apResources, + tls: sslConfig != nil, + zoneSync: vsEx.ZoneSync, + secretRefs: vsEx.SecretRefs, + apResources: apResources, + defaultCABundle: vsc.CABundlePath, } ownerDetails := policyOwnerDetails{ @@ -1048,10 +1050,11 @@ type policyOwnerDetails struct { } type policyOptions struct { - tls bool - zoneSync bool - secretRefs map[string]*secrets.SecretReference - apResources *appProtectResourcesForVS + tls bool + zoneSync bool + secretRefs map[string]*secrets.SecretReference + apResources *appProtectResourcesForVS + defaultCABundle string } type validationResults struct { @@ -1431,9 +1434,10 @@ func (p *policiesCfg) addOIDCConfig( oidc *conf_v1.OIDC, polKey string, polNamespace string, - secretRefs map[string]*secrets.SecretReference, + policyOpts policyOptions, oidcPolCfg *oidcPolicyCfg, ) *validationResults { + secretRefs := policyOpts.secretRefs res := newValidationResults() if p.OIDC { res.addWarningf( @@ -1501,9 +1505,9 @@ func (p *policiesCfg) addOIDCConfig( authExtraArgs = strings.Join(oidc.AuthExtraArgs, "&") } - // Handle SSL verification for JWKS - var trustedCertPath string + trustedCertPath := policyOpts.defaultCABundle if oidc.SSLVerify && oidc.TrustedCertSecret != "" { + // Override default CA bundle if trusted cert secret is provided trustedCertSecretKey := fmt.Sprintf("%s/%s", polNamespace, oidc.TrustedCertSecret) trustedCertSecretRef := secretRefs[trustedCertSecretKey] @@ -1532,19 +1536,6 @@ func (p *policiesCfg) addOIDCConfig( if len(caFields) > 0 { trustedCertPath = caFields[0] } - } else if oidc.SSLVerify && oidc.TrustedCertSecret == "" { - osr, err := readOSRelease() - if err != nil { - res.addWarningf("OIDC policy %s could not read OS release info to load CA bundle: %v", polKey, err) - res.isError = true - return res - } - trustedCertPath = getOSCABundlePath(string(osr)) - if _, err := os.Stat(trustedCertPath); os.IsNotExist(err) { - res.addWarningf("OIDC policy %s could not load OS CA bundle: %v", polKey, err) - res.isError = true - return res - } } sslVerifyDepth := 1 @@ -1866,7 +1857,7 @@ func (vsc *virtualServerConfigurator) generatePolicies( case pol.Spec.EgressMTLS != nil: res = config.addEgressMTLSConfig(pol.Spec.EgressMTLS, key, polNamespace, policyOpts.secretRefs) case pol.Spec.OIDC != nil: - res = config.addOIDCConfig(pol.Spec.OIDC, key, polNamespace, policyOpts.secretRefs, vsc.oidcPolCfg) + res = config.addOIDCConfig(pol.Spec.OIDC, key, polNamespace, policyOpts, vsc.oidcPolCfg) case pol.Spec.APIKey != nil: res = config.addAPIKeyConfig(pol.Spec.APIKey, key, polNamespace, ownerDetails.vsNamespace, ownerDetails.vsName, policyOpts.secretRefs) @@ -3568,22 +3559,3 @@ func generateDosCfg(dosResource *appProtectDosResource) *version2.Dos { dos.ApDosLogConf = dosResource.AppProtectDosLogConfFile return dos } - -func readOSRelease() ([]byte, error) { - return os.ReadFile("/etc/os-release") -} - -func getOSCABundlePath(s string) string { - alpineRegex := regexp.MustCompile(`ID=\"?alpine\"?`) - rhelRegex := regexp.MustCompile(`ID=\"?rhel\"?`) - // Logic to get the OS CA bundle path. - caFilePath := "/etc/ssl/certs/ca-certificates.crt" // Default for Debian, the default image base - - if alpineRegex.MatchString(s) { - caFilePath = "/etc/ssl/cert.pem" - } else if rhelRegex.MatchString(s) { - caFilePath = "/etc/pki/tls/certs/ca-bundle.crt" - } - - return caFilePath -} diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index ce676b9a6b..fcff0f3549 100644 --- a/internal/configs/virtualserver_test.go +++ b/internal/configs/virtualserver_test.go @@ -22966,117 +22966,216 @@ func TestGenerateVirtualServerConfigWithForeignNamespaceServiceInVSR(t *testing. } } -func TestGetOSCABundlePath(t *testing.T) { +func TestGenerateVirtualServerConfigWithOIDCTLSVerifyOn(t *testing.T) { t.Parallel() + tests := []struct { - name string - input string - expected string + msg string + virtualServerEx VirtualServerEx + expected version2.VirtualServerConfig }{ { - name: "Debian default", - input: ` -PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" -NAME="Debian GNU/Linux" -VERSION_ID="12" -VERSION="12 (bookworm)" -VERSION_CODENAME=bookworm -ID=debian -HOME_URL="https://www.debian.org/" -SUPPORT_URL="https://www.debian.org/support" -BUG_REPORT_URL="https://bugs.debian.org/" - `, - expected: "/etc/ssl/certs/ca-certificates.crt", - }, - { - name: "Alpine with quotes", - input: ` -NAME="Alpine Linux" -ID="alpine" -VERSION_ID=3.22.2 -PRETTY_NAME="Alpine Linux v3.22" -HOME_URL="https://alpinelinux.org/" -BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues" - `, - expected: "/etc/ssl/cert.pem", - }, - { - name: "Alpine without quotes", - input: ` -NAME="Alpine Linux" -ID=alpine -VERSION_ID=3.19.9 -PRETTY_NAME="Alpine Linux v3.19" -HOME_URL="https://alpinelinux.org/" -BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues" - `, - expected: "/etc/ssl/cert.pem", - }, - { - name: "RHEL8 with quotes", - input: ` -NAME="Red Hat Enterprise Linux" -VERSION="8.10 (Ootpa)" -ID="rhel" -ID_LIKE="fedora" -VERSION_ID="8.10" -PLATFORM_ID="platform:el8" -PRETTY_NAME="Red Hat Enterprise Linux 8.10 (Ootpa)" -ANSI_COLOR="0;31" -CPE_NAME="cpe:/o:redhat:enterprise_linux:8::baseos" -HOME_URL="https://www.redhat.com/" -DOCUMENTATION_URL="https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8" -BUG_REPORT_URL="https://issues.redhat.com/" - -REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 8" -REDHAT_BUGZILLA_PRODUCT_VERSION=8.10 -REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux" -REDHAT_SUPPORT_PRODUCT_VERSION="8.10" - `, - expected: "/etc/pki/tls/certs/ca-bundle.crt", - }, - { - name: "RHEL9 with quotes", - input: ` -NAME="Red Hat Enterprise Linux" -VERSION="9.7 (Plow)" -ID="rhel" -ID_LIKE="fedora" -VERSION_ID="9.7" -PLATFORM_ID="platform:el9" -PRETTY_NAME="Red Hat Enterprise Linux 9.7 (Plow)" -ANSI_COLOR="0;31" -LOGO="fedora-logo-icon" -CPE_NAME="cpe:/o:redhat:enterprise_linux:9::baseos" -HOME_URL="https://www.redhat.com/" -DOCUMENTATION_URL="https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/9" -BUG_REPORT_URL="https://issues.redhat.com/" - -REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 9" -REDHAT_BUGZILLA_PRODUCT_VERSION=9.7 -REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux" -REDHAT_SUPPORT_PRODUCT_VERSION="9.7" - `, - expected: "/etc/pki/tls/certs/ca-bundle.crt", - }, - { - name: "Unknown OS", - input: `ID="ubuntu"`, - expected: "/etc/ssl/certs/ca-certificates.crt", - }, - { - name: "Empty string", - input: "", - expected: "/etc/ssl/certs/ca-certificates.crt", + msg: "oidc at vs spec level with zone sync enabled", + 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: "oidc-policy", + }, + }, + Upstreams: []conf_v1.Upstream{ + { + Name: "tea", + Service: "tea-svc", + Port: 80, + }, + { + Name: "coffee", + Service: "coffee-svc", + Port: 80, + }, + }, + Routes: []conf_v1.Route{ + { + Path: "/tea", + Action: &conf_v1.Action{ + Pass: "tea", + }, + }, + { + Path: "/coffee", + Action: &conf_v1.Action{ + Pass: "coffee", + }, + }, + }, + }, + }, + Policies: map[string]*conf_v1.Policy{ + "default/oidc-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "oidc-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + OIDC: &conf_v1.OIDC{ + AuthEndpoint: "https://auth.example.com", + TokenEndpoint: "https://token.example.com", + JWKSURI: "https://jwks.example.com", + EndSessionEndpoint: "https://logout.example.com", + ClientID: "example-client-id", + ClientSecret: "example-client-secret", + Scope: "openid+profile+email", + SSLVerify: true, + }, + }, + }, + }, + Endpoints: map[string][]string{ + "default/tea-svc:80": { + "10.0.0.20:80", + }, + "default/coffee-svc:80": { + "10.0.0.30:80", + }, + }, + SecretRefs: map[string]*secrets.SecretReference{ + "default/example-client-secret": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeOIDC, + Data: map[string][]byte{ + "client-secret": []byte("c2VjcmV0"), + }, + }, + }, + }, + ZoneSync: true, + }, + expected: version2.VirtualServerConfig{ + Upstreams: []version2.Upstream{ + { + UpstreamLabels: version2.UpstreamLabels{ + Service: "coffee-svc", + ResourceType: "virtualserver", + ResourceName: "cafe", + ResourceNamespace: "default", + }, + Name: "vs_default_cafe_coffee", + Servers: []version2.UpstreamServer{ + { + Address: "10.0.0.30:80", + }, + }, + }, + { + 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", + }, + }, + }, + }, + HTTPSnippets: []string{}, + LimitReqZones: []version2.LimitReqZone{}, + Server: version2.Server{ + ServerName: "cafe.example.com", + StatusZone: "cafe.example.com", + ServerTokens: "off", + VSNamespace: "default", + VSName: "cafe", + Locations: []version2.Location{ + { + Path: "/tea", + ProxyPass: "http://vs_default_cafe_tea", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "0s", + ProxyNextUpstreamTries: 0, + ProxySSLName: "tea-svc.default.svc", + ProxyPassRequestHeaders: true, + ProxySetHeaders: []version2.Header{{Name: "Host", Value: "$host"}}, + ServiceName: "tea-svc", + OIDC: true, + }, + { + Path: "/coffee", + ProxyPass: "http://vs_default_cafe_coffee", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "0s", + ProxyNextUpstreamTries: 0, + ProxySSLName: "coffee-svc.default.svc", + ProxyPassRequestHeaders: true, + ProxySetHeaders: []version2.Header{{Name: "Host", Value: "$host"}}, + ServiceName: "coffee-svc", + OIDC: true, + }, + }, + OIDC: &version2.OIDC{ + AuthEndpoint: "https://auth.example.com", + TokenEndpoint: "https://token.example.com", + JwksURI: "https://jwks.example.com", + EndSessionEndpoint: "https://logout.example.com", + ClientID: "example-client-id", + ClientSecret: "c2VjcmV0", + Scope: "openid+profile+email", + TLSVerify: true, + VerifyDepth: 1, + CAFile: "/etc/ssl/certs/ca-certificate.crt", + ZoneSyncLeeway: 200, + RedirectURI: "/_codexch", + PostLogoutRedirectURI: "/_logout", + }, + }, + }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getOSCABundlePath(tt.input) - if result != tt.expected { - t.Errorf("want %q, got %q", tt.expected, result) - } + baseCfgParams := ConfigParams{ + Context: context.Background(), + ServerTokens: "off", + } + + vsc := newVirtualServerConfigurator( + &baseCfgParams, + false, + false, + &StaticConfigParams{ + DefaultCABundle: "/etc/ssl/certs/ca-certificate.crt", + }, + false, + &fakeBV, + ) + + for _, test := range tests { + result, warnings := vsc.GenerateVirtualServerConfig(&test.virtualServerEx, nil, nil) + + sort.Slice(result.Maps, func(i, j int) bool { + return result.Maps[i].Variable < result.Maps[j].Variable + }) + + sort.Slice(test.expected.Maps, func(i, j int) bool { + return test.expected.Maps[i].Variable < test.expected.Maps[j].Variable }) + + if diff := cmp.Diff(test.expected, result); diff != "" { + t.Errorf("GenerateVirtualServerConfig() mismatch (-want +got):\n%s", diff) + t.Error(test.msg) + } + + if len(warnings) != 0 { + t.Errorf("GenerateVirtualServerConfig returned warnings: %v", vsc.warnings) + } } } diff --git a/internal/nginx/fake_manager.go b/internal/nginx/fake_manager.go index 197b046ad6..8042f8720d 100644 --- a/internal/nginx/fake_manager.go +++ b/internal/nginx/fake_manager.go @@ -209,3 +209,9 @@ func (fm *FakeManager) UpsertSplitClientsKeyVal(_ string, _ string, _ string) { func (fm *FakeManager) DeleteKeyValStateFiles(_ string) { nl.Debugf(fm.logger, "Deleting keyval state files") } + +// GetOSCABundlePath is a fake implementation of GetOSCABundlePath +func (fm *FakeManager) GetOSCABundlePath() (string, error) { + nl.Debugf(fm.logger, "Getting OS CA Bundle Path") + return "/etc/ssl/certs/ca-certificates.crt", nil +} diff --git a/internal/nginx/manager.go b/internal/nginx/manager.go index 888498f686..716cd8f108 100644 --- a/internal/nginx/manager.go +++ b/internal/nginx/manager.go @@ -100,6 +100,7 @@ type Manager interface { AgentQuit() AgentVersion() string GetSecretsDir() string + GetOSCABundlePath() (string, error) UpsertSplitClientsKeyVal(zoneName string, key string, value string) DeleteKeyValStateFiles(virtualServerName string) } @@ -767,3 +768,39 @@ func (lm *LocalManager) DeleteKeyValStateFiles(virtualServerName string) { } } } + +func readOSRelease() ([]byte, error) { + return os.ReadFile("/etc/os-release") +} + +// GetOSCABundlePath returns the path to the OS CA bundle file based on the OS type. +func (lm *LocalManager) GetOSCABundlePath() (string, error) { + sBytes, err := readOSRelease() + if err != nil { + // Default to Debian path if unable to read the file. + return "", err + } + s := string(sBytes) + caFilePath := getOSCABundlePath(s) + + if _, err := os.Stat(caFilePath); os.IsNotExist(err) { + return "", fmt.Errorf("CA bundle file does not exist at path: %s", caFilePath) + } + + return caFilePath, nil +} + +func getOSCABundlePath(s string) string { + alpineRegex := regexp.MustCompile(`ID=\"?alpine\"?`) + rhelRegex := regexp.MustCompile(`ID=\"?rhel\"?`) + // Logic to get the OS CA bundle path. + caFilePath := "/etc/ssl/certs/ca-certificates.crt" // Default for Debian, the default image base + + if alpineRegex.MatchString(s) { + caFilePath = "/etc/ssl/cert.pem" + } else if rhelRegex.MatchString(s) { + caFilePath = "/etc/pki/tls/certs/ca-bundle.crt" + } + + return caFilePath +} diff --git a/internal/nginx/manager_test.go b/internal/nginx/manager_test.go index 3cc857f264..b724d20ba4 100644 --- a/internal/nginx/manager_test.go +++ b/internal/nginx/manager_test.go @@ -106,3 +106,118 @@ func TestFormatUpdateServersInPlusLog(t *testing.T) { }) } } + +func TestGetOSCABundlePath(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + { + name: "Debian default", + input: ` +PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" +NAME="Debian GNU/Linux" +VERSION_ID="12" +VERSION="12 (bookworm)" +VERSION_CODENAME=bookworm +ID=debian +HOME_URL="https://www.debian.org/" +SUPPORT_URL="https://www.debian.org/support" +BUG_REPORT_URL="https://bugs.debian.org/" + `, + expected: "/etc/ssl/certs/ca-certificates.crt", + }, + { + name: "Alpine with quotes", + input: ` +NAME="Alpine Linux" +ID="alpine" +VERSION_ID=3.22.2 +PRETTY_NAME="Alpine Linux v3.22" +HOME_URL="https://alpinelinux.org/" +BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues" + `, + expected: "/etc/ssl/cert.pem", + }, + { + name: "Alpine without quotes", + input: ` +NAME="Alpine Linux" +ID=alpine +VERSION_ID=3.19.9 +PRETTY_NAME="Alpine Linux v3.19" +HOME_URL="https://alpinelinux.org/" +BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues" + `, + expected: "/etc/ssl/cert.pem", + }, + { + name: "RHEL8 with quotes", + input: ` +NAME="Red Hat Enterprise Linux" +VERSION="8.10 (Ootpa)" +ID="rhel" +ID_LIKE="fedora" +VERSION_ID="8.10" +PLATFORM_ID="platform:el8" +PRETTY_NAME="Red Hat Enterprise Linux 8.10 (Ootpa)" +ANSI_COLOR="0;31" +CPE_NAME="cpe:/o:redhat:enterprise_linux:8::baseos" +HOME_URL="https://www.redhat.com/" +DOCUMENTATION_URL="https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8" +BUG_REPORT_URL="https://issues.redhat.com/" + +REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 8" +REDHAT_BUGZILLA_PRODUCT_VERSION=8.10 +REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux" +REDHAT_SUPPORT_PRODUCT_VERSION="8.10" + `, + expected: "/etc/pki/tls/certs/ca-bundle.crt", + }, + { + name: "RHEL9 with quotes", + input: ` +NAME="Red Hat Enterprise Linux" +VERSION="9.7 (Plow)" +ID="rhel" +ID_LIKE="fedora" +VERSION_ID="9.7" +PLATFORM_ID="platform:el9" +PRETTY_NAME="Red Hat Enterprise Linux 9.7 (Plow)" +ANSI_COLOR="0;31" +LOGO="fedora-logo-icon" +CPE_NAME="cpe:/o:redhat:enterprise_linux:9::baseos" +HOME_URL="https://www.redhat.com/" +DOCUMENTATION_URL="https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/9" +BUG_REPORT_URL="https://issues.redhat.com/" + +REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 9" +REDHAT_BUGZILLA_PRODUCT_VERSION=9.7 +REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux" +REDHAT_SUPPORT_PRODUCT_VERSION="9.7" + `, + expected: "/etc/pki/tls/certs/ca-bundle.crt", + }, + { + name: "Unknown OS", + input: `ID="ubuntu"`, + expected: "/etc/ssl/certs/ca-certificates.crt", + }, + { + name: "Empty string", + input: "", + expected: "/etc/ssl/certs/ca-certificates.crt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getOSCABundlePath(tt.input) + if result != tt.expected { + t.Errorf("want %q, got %q", tt.expected, result) + } + }) + } +} From 6f665a7c38e8f881ceb8ea60ea48c4770907d63e Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Wed, 19 Nov 2025 15:55:02 +0000 Subject: [PATCH 08/17] Add GenerateVirtualServerConfig tests --- internal/configs/virtualserver_test.go | 227 ++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index fcff0f3549..3df65088fd 100644 --- a/internal/configs/virtualserver_test.go +++ b/internal/configs/virtualserver_test.go @@ -12191,6 +12191,7 @@ func TestGeneratePolicies(t *testing.T) { }, }, }, + defaultCABundle: "/etc/ssl/certs/ca-certificate.crt", apResources: &appProtectResourcesForVS{ Policies: map[string]string{ "default/dataguard-alarm": "/etc/nginx/waf/nac-policies/default-dataguard-alarm", @@ -22975,7 +22976,7 @@ func TestGenerateVirtualServerConfigWithOIDCTLSVerifyOn(t *testing.T) { expected version2.VirtualServerConfig }{ { - msg: "oidc at vs spec level with zone sync enabled", + msg: "oidc at vs spec level with TLSVerify & zone sync enabled", virtualServerEx: VirtualServerEx{ VirtualServer: &conf_v1.VirtualServer{ ObjectMeta: meta_v1.ObjectMeta{ @@ -23179,3 +23180,227 @@ func TestGenerateVirtualServerConfigWithOIDCTLSVerifyOn(t *testing.T) { } } } + +func TestGenerateVirtualServerConfigWithOIDCTLSCASecret(t *testing.T) { + t.Parallel() + + tests := []struct { + msg string + virtualServerEx VirtualServerEx + expected version2.VirtualServerConfig + }{ + { + msg: "oidc at vs spec level with TLSVerify, custom ca cert & zone sync enabled", + 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: "oidc-policy", + }, + }, + Upstreams: []conf_v1.Upstream{ + { + Name: "tea", + Service: "tea-svc", + Port: 80, + }, + { + Name: "coffee", + Service: "coffee-svc", + Port: 80, + }, + }, + Routes: []conf_v1.Route{ + { + Path: "/tea", + Action: &conf_v1.Action{ + Pass: "tea", + }, + }, + { + Path: "/coffee", + Action: &conf_v1.Action{ + Pass: "coffee", + }, + }, + }, + }, + }, + Policies: map[string]*conf_v1.Policy{ + "default/oidc-policy": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "oidc-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + OIDC: &conf_v1.OIDC{ + AuthEndpoint: "https://auth.example.com", + TokenEndpoint: "https://token.example.com", + JWKSURI: "https://jwks.example.com", + EndSessionEndpoint: "https://logout.example.com", + ClientID: "example-client-id", + ClientSecret: "example-client-secret", + Scope: "openid+profile+email", + SSLVerify: true, + TrustedCertSecret: "example-ca-secret", + }, + }, + }, + }, + Endpoints: map[string][]string{ + "default/tea-svc:80": { + "10.0.0.20:80", + }, + "default/coffee-svc:80": { + "10.0.0.30:80", + }, + }, + SecretRefs: map[string]*secrets.SecretReference{ + "default/example-client-secret": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeOIDC, + Data: map[string][]byte{ + "client-secret": []byte("c2VjcmV0"), + }, + }, + }, + "default/example-ca-secret": { + Secret: &api_v1.Secret{ + Type: secrets.SecretTypeCA, + Data: map[string][]byte{ + "ca.crt": []byte("ca-certificate-data"), + }, + }, + Path: "/etc/nginx/secrets/default-example-ca-secret-ca.crt", + }, + }, + ZoneSync: true, + }, + expected: version2.VirtualServerConfig{ + Upstreams: []version2.Upstream{ + { + UpstreamLabels: version2.UpstreamLabels{ + Service: "coffee-svc", + ResourceType: "virtualserver", + ResourceName: "cafe", + ResourceNamespace: "default", + }, + Name: "vs_default_cafe_coffee", + Servers: []version2.UpstreamServer{ + { + Address: "10.0.0.30:80", + }, + }, + }, + { + 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", + }, + }, + }, + }, + HTTPSnippets: []string{}, + LimitReqZones: []version2.LimitReqZone{}, + Server: version2.Server{ + ServerName: "cafe.example.com", + StatusZone: "cafe.example.com", + ServerTokens: "off", + VSNamespace: "default", + VSName: "cafe", + Locations: []version2.Location{ + { + Path: "/tea", + ProxyPass: "http://vs_default_cafe_tea", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "0s", + ProxyNextUpstreamTries: 0, + ProxySSLName: "tea-svc.default.svc", + ProxyPassRequestHeaders: true, + ProxySetHeaders: []version2.Header{{Name: "Host", Value: "$host"}}, + ServiceName: "tea-svc", + OIDC: true, + }, + { + Path: "/coffee", + ProxyPass: "http://vs_default_cafe_coffee", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "0s", + ProxyNextUpstreamTries: 0, + ProxySSLName: "coffee-svc.default.svc", + ProxyPassRequestHeaders: true, + ProxySetHeaders: []version2.Header{{Name: "Host", Value: "$host"}}, + ServiceName: "coffee-svc", + OIDC: true, + }, + }, + OIDC: &version2.OIDC{ + AuthEndpoint: "https://auth.example.com", + TokenEndpoint: "https://token.example.com", + JwksURI: "https://jwks.example.com", + EndSessionEndpoint: "https://logout.example.com", + ClientID: "example-client-id", + ClientSecret: "c2VjcmV0", + Scope: "openid+profile+email", + TLSVerify: true, + VerifyDepth: 1, + CAFile: "/etc/nginx/secrets/default-example-ca-secret-ca.crt", + ZoneSyncLeeway: 200, + RedirectURI: "/_codexch", + PostLogoutRedirectURI: "/_logout", + }, + }, + }, + }, + } + + baseCfgParams := ConfigParams{ + Context: context.Background(), + ServerTokens: "off", + } + + vsc := newVirtualServerConfigurator( + &baseCfgParams, + false, + false, + &StaticConfigParams{ + DefaultCABundle: "/etc/ssl/certs/ca-certificate.crt", + }, + false, + &fakeBV, + ) + + for _, test := range tests { + result, warnings := vsc.GenerateVirtualServerConfig(&test.virtualServerEx, nil, nil) + + sort.Slice(result.Maps, func(i, j int) bool { + return result.Maps[i].Variable < result.Maps[j].Variable + }) + + sort.Slice(test.expected.Maps, func(i, j int) bool { + return test.expected.Maps[i].Variable < test.expected.Maps[j].Variable + }) + + if diff := cmp.Diff(test.expected, result); diff != "" { + t.Errorf("GenerateVirtualServerConfig() mismatch (-want +got):\n%s", diff) + t.Error(test.msg) + } + + if len(warnings) != 0 { + t.Errorf("GenerateVirtualServerConfig returned warnings: %v", vsc.warnings) + } + } +} From dece5dcfb7942fb35522a7107bc1ecaef4aa367d Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Thu, 20 Nov 2025 14:57:31 +0000 Subject: [PATCH 09/17] Expand OIDC e2e tests with https keycloak --- .../common-secrets/keycloak-ca-secret.yaml | 7 + .../common-secrets/keycloak-tls-secret.yaml | 8 + .../data/common/app/keycloak-secure/app.yaml | 69 ++++++ tests/data/oidc/keycloak-ca-secret.yaml | 1 + tests/data/oidc/keycloak-tls-secret.yaml | 1 + tests/data/oidc/oidc-tls.yaml | 16 ++ tests/data/oidc/pkce-tls.yaml | 16 ++ tests/suite/test_oidc.py | 210 +++++++++++++----- 8 files changed, 271 insertions(+), 57 deletions(-) create mode 100644 tests/data/common-secrets/keycloak-ca-secret.yaml create mode 100644 tests/data/common-secrets/keycloak-tls-secret.yaml create mode 100644 tests/data/common/app/keycloak-secure/app.yaml create mode 120000 tests/data/oidc/keycloak-ca-secret.yaml create mode 120000 tests/data/oidc/keycloak-tls-secret.yaml create mode 100644 tests/data/oidc/oidc-tls.yaml create mode 100644 tests/data/oidc/pkce-tls.yaml diff --git a/tests/data/common-secrets/keycloak-ca-secret.yaml b/tests/data/common-secrets/keycloak-ca-secret.yaml new file mode 100644 index 0000000000..75b984ba22 --- /dev/null +++ b/tests/data/common-secrets/keycloak-ca-secret.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-ca-secret +type: nginx.org/ca +data: + ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZ4ekNDQTYrZ0F3SUJBZ0lVSnIxb2VDQTcxTmhjQ3VIVmh1NHVQcXNEVDhjd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2N6RUxNQWtHQTFVRUJoTUNTVVV4RFRBTEJnTlZCQWdNQkVOdmNtc3hEVEFMQmdOVkJBY01CRU52Y21zeApDekFKQmdOVkJBb01Ba1kxTVF3d0NnWURWUVFMREFOT1NVTXhLekFwQmdOVkJBTU1JbXRsZVdOc2IyRnJMbVJsClptRjFiSFF1YzNaakxtTnNkWE4wWlhJdWJHOWpZV3d3SGhjTk1qVXhNVEUzTVRJeU9EVTVXaGNOTXpVeE1URTEKTVRJeU9EVTVXakJ6TVFzd0NRWURWUVFHRXdKSlJURU5NQXNHQTFVRUNBd0VRMjl5YXpFTk1Bc0dBMVVFQnd3RQpRMjl5YXpFTE1Ba0dBMVVFQ2d3Q1JqVXhEREFLQmdOVkJBc01BMDVKUXpFck1Da0dBMVVFQXd3aWEyVjVZMnh2CllXc3VaR1ZtWVhWc2RDNXpkbU11WTJ4MWMzUmxjaTVzYjJOaGJEQ0NBaUl3RFFZSktvWklodmNOQVFFQkJRQUQKZ2dJUEFEQ0NBZ29DZ2dJQkFLK1M5cVRXdGZ3YU1GdjI3aU5Ob3FJU2FaMXJ4R0VlZlpTd29ZTCtBZEVpTXhEMgpxN0dvbkc3QlZaOVoxQWpMTlhtcGwyeFF1ZVZBN25RYXpXa2JJazhhQytJOWwwQ1NudXNiK1UwMUV6V0g5MVJ2CnNRM0pFdlV1WXlma3loNnRGeGoyNTJFMTQ3TE1HcTlXOHVlRDNJYnNWVjRZWlBDY0VHN1c3aEYzSTVObllYa1kKYzVKWFFtKzNTODl5V2hWUDg1MHpGTEpTVUFkcHhuMW9qdmFhV3FZWHVrODd4ajU3U1VueGpzR3pTN1dxMDJxVAo0WjVBNENjUGR5Y2Vha2MzcDRLQXVQaHZSYnltK2l1ME13WWFDZ3ZDZnV2eTZLa0l5c1lCL0dUUmhKcFltU2FKCmNsUXFxT3NRTjd6U3crZDlrOFNlSnFONGVCMHVFbmNkSkRwcDJTa05qa3ZRaEhkRllncm5KN1dZVmZBSmxibkIKSUc3VVBmRCtIZDduRkhFdWIvdE4zN20xRnkyZjhjUnNkNWZ1Q29NNHhoelRucm1wbDFiTEZYN2dOLzgrNGZmZwpzc2VCSmZqbExKbCtzTTA4RlI0aVFicGMzK0xZbkpzOXdsUE82VTBzQ2VJUXB4SnVTUEh5aENtN2hFR2N5NGdKCi9SdENwZHVabitrMDdnTXVnUlVVdUxNV2JNWjhaTEh2cWNwbTliY05aQXNxRERzdkIxMTdldFh4Q1luVC9DRGMKYVJBMVh3a2ptNS9DY0k0U0JqNzQrZC8rMnJDbHBEZGl0dWxyTnJ6WjcrVTZFSU1pdDV3SnJ0V09nVlpYdGNxOAp3UlFWdHZINVNkZjZnWVlvOE9HejhRenAzZEJ5TEVaWDU1V0FzT084dWhhcUJGenk1TEVaSlk1WEo2SlpBZ01CCkFBR2pVekJSTUIwR0ExVWREZ1FXQkJUcmR5VTZzRVhRWHR5S1doRFhVbHJaMmpjQXhEQWZCZ05WSFNNRUdEQVcKZ0JUcmR5VTZzRVhRWHR5S1doRFhVbHJaMmpjQXhEQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BMEdDU3FHU0liMwpEUUVCQ3dVQUE0SUNBUUNTTDNFcDBrWUFBUnIrSStVcjhRdmhkdDg1NUtIbFIwenMyZkN3elFORjArUlFXTm1WCjF5ZitUaEkwRW13NnVaWk5rYXNWenFQcENZNU81VWkwK2ZhL09LaUs1eGdXVjVlais1SEJndFk1VitVVE9yYm0KTlpiODE2UnBya1NadTBFWlpqNVNnUEhmSzUraVNuWnVKL0J2Nk13WjNYR2F1N3pHdlBBRGpqSVBwMEJqczluVwpWUXQyQWRmZlQrUXd1UHgreVJEZTFEdHhpSkEvMlQxN0c3eUN3NnBDWHJsVHdNckk5dTVtSHhmM0FSaVJOZ01mCndPSHJBNXNJYkhFVVQ5QXc3WXp6OFZMNHk0d29la3IzRmwxUjh2OVkzKzJaNmw2TWNIS3FGV2owVkxCU0JFdnAKZzFBSGtZcUdRL2NzbXBOSWMvQ2ZUdWFSdzVsTFZqNlpVMjNmaENsYW9ldWUyejk2U0w5NGltZnFlbUJNOGtwNQp3djNoS3dqdVBsMGxEbkxKd21IOHFMK094L0Y3eTZseWhnSnRVeGtsc0hWQXlPeDlrekM4ZkNZL3BMbDhqc1lvCnh0c29ZMlRhcW1uSEUwNk5KaUk4VVBLd3NzM1M1cUFEV2xzSk4rc2ZURzViVTFZWlVUcjhybi9yc2VlQ1dpOFkKTFdXV3JHeVBjeVd3ZDNJY0pZSVIyWEdXNHNDYkpUdHllMGszRm9WUHV3VHdSVGlkVnVaWjN4OXIzbkpuSk84WAphdkpXa1Z3b0paK1ZRd1AyN1BPc1RFZVR2cFNWMjZkNENJYlZmSnRDZldhd1cybUU5QlozZ1RBbmFuK2pEQTl4CndTektWSW0yYnBIZCtHU0QwUXJvZkNXL2h1OUN2Q2p4aGR0aHlSYzJKOWd2SzFXMjAwZW9HdFl6d0E9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== diff --git a/tests/data/common-secrets/keycloak-tls-secret.yaml b/tests/data/common-secrets/keycloak-tls-secret.yaml new file mode 100644 index 0000000000..edad567559 --- /dev/null +++ b/tests/data/common-secrets/keycloak-tls-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-tls-secret +type: kubernetes.io/tls +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZ4ekNDQTYrZ0F3SUJBZ0lVSnIxb2VDQTcxTmhjQ3VIVmh1NHVQcXNEVDhjd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2N6RUxNQWtHQTFVRUJoTUNTVVV4RFRBTEJnTlZCQWdNQkVOdmNtc3hEVEFMQmdOVkJBY01CRU52Y21zeApDekFKQmdOVkJBb01Ba1kxTVF3d0NnWURWUVFMREFOT1NVTXhLekFwQmdOVkJBTU1JbXRsZVdOc2IyRnJMbVJsClptRjFiSFF1YzNaakxtTnNkWE4wWlhJdWJHOWpZV3d3SGhjTk1qVXhNVEUzTVRJeU9EVTVXaGNOTXpVeE1URTEKTVRJeU9EVTVXakJ6TVFzd0NRWURWUVFHRXdKSlJURU5NQXNHQTFVRUNBd0VRMjl5YXpFTk1Bc0dBMVVFQnd3RQpRMjl5YXpFTE1Ba0dBMVVFQ2d3Q1JqVXhEREFLQmdOVkJBc01BMDVKUXpFck1Da0dBMVVFQXd3aWEyVjVZMnh2CllXc3VaR1ZtWVhWc2RDNXpkbU11WTJ4MWMzUmxjaTVzYjJOaGJEQ0NBaUl3RFFZSktvWklodmNOQVFFQkJRQUQKZ2dJUEFEQ0NBZ29DZ2dJQkFLK1M5cVRXdGZ3YU1GdjI3aU5Ob3FJU2FaMXJ4R0VlZlpTd29ZTCtBZEVpTXhEMgpxN0dvbkc3QlZaOVoxQWpMTlhtcGwyeFF1ZVZBN25RYXpXa2JJazhhQytJOWwwQ1NudXNiK1UwMUV6V0g5MVJ2CnNRM0pFdlV1WXlma3loNnRGeGoyNTJFMTQ3TE1HcTlXOHVlRDNJYnNWVjRZWlBDY0VHN1c3aEYzSTVObllYa1kKYzVKWFFtKzNTODl5V2hWUDg1MHpGTEpTVUFkcHhuMW9qdmFhV3FZWHVrODd4ajU3U1VueGpzR3pTN1dxMDJxVAo0WjVBNENjUGR5Y2Vha2MzcDRLQXVQaHZSYnltK2l1ME13WWFDZ3ZDZnV2eTZLa0l5c1lCL0dUUmhKcFltU2FKCmNsUXFxT3NRTjd6U3crZDlrOFNlSnFONGVCMHVFbmNkSkRwcDJTa05qa3ZRaEhkRllncm5KN1dZVmZBSmxibkIKSUc3VVBmRCtIZDduRkhFdWIvdE4zN20xRnkyZjhjUnNkNWZ1Q29NNHhoelRucm1wbDFiTEZYN2dOLzgrNGZmZwpzc2VCSmZqbExKbCtzTTA4RlI0aVFicGMzK0xZbkpzOXdsUE82VTBzQ2VJUXB4SnVTUEh5aENtN2hFR2N5NGdKCi9SdENwZHVabitrMDdnTXVnUlVVdUxNV2JNWjhaTEh2cWNwbTliY05aQXNxRERzdkIxMTdldFh4Q1luVC9DRGMKYVJBMVh3a2ptNS9DY0k0U0JqNzQrZC8rMnJDbHBEZGl0dWxyTnJ6WjcrVTZFSU1pdDV3SnJ0V09nVlpYdGNxOAp3UlFWdHZINVNkZjZnWVlvOE9HejhRenAzZEJ5TEVaWDU1V0FzT084dWhhcUJGenk1TEVaSlk1WEo2SlpBZ01CCkFBR2pVekJSTUIwR0ExVWREZ1FXQkJUcmR5VTZzRVhRWHR5S1doRFhVbHJaMmpjQXhEQWZCZ05WSFNNRUdEQVcKZ0JUcmR5VTZzRVhRWHR5S1doRFhVbHJaMmpjQXhEQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BMEdDU3FHU0liMwpEUUVCQ3dVQUE0SUNBUUNTTDNFcDBrWUFBUnIrSStVcjhRdmhkdDg1NUtIbFIwenMyZkN3elFORjArUlFXTm1WCjF5ZitUaEkwRW13NnVaWk5rYXNWenFQcENZNU81VWkwK2ZhL09LaUs1eGdXVjVlais1SEJndFk1VitVVE9yYm0KTlpiODE2UnBya1NadTBFWlpqNVNnUEhmSzUraVNuWnVKL0J2Nk13WjNYR2F1N3pHdlBBRGpqSVBwMEJqczluVwpWUXQyQWRmZlQrUXd1UHgreVJEZTFEdHhpSkEvMlQxN0c3eUN3NnBDWHJsVHdNckk5dTVtSHhmM0FSaVJOZ01mCndPSHJBNXNJYkhFVVQ5QXc3WXp6OFZMNHk0d29la3IzRmwxUjh2OVkzKzJaNmw2TWNIS3FGV2owVkxCU0JFdnAKZzFBSGtZcUdRL2NzbXBOSWMvQ2ZUdWFSdzVsTFZqNlpVMjNmaENsYW9ldWUyejk2U0w5NGltZnFlbUJNOGtwNQp3djNoS3dqdVBsMGxEbkxKd21IOHFMK094L0Y3eTZseWhnSnRVeGtsc0hWQXlPeDlrekM4ZkNZL3BMbDhqc1lvCnh0c29ZMlRhcW1uSEUwNk5KaUk4VVBLd3NzM1M1cUFEV2xzSk4rc2ZURzViVTFZWlVUcjhybi9yc2VlQ1dpOFkKTFdXV3JHeVBjeVd3ZDNJY0pZSVIyWEdXNHNDYkpUdHllMGszRm9WUHV3VHdSVGlkVnVaWjN4OXIzbkpuSk84WAphdkpXa1Z3b0paK1ZRd1AyN1BPc1RFZVR2cFNWMjZkNENJYlZmSnRDZldhd1cybUU5QlozZ1RBbmFuK2pEQTl4CndTektWSW0yYnBIZCtHU0QwUXJvZkNXL2h1OUN2Q2p4aGR0aHlSYzJKOWd2SzFXMjAwZW9HdFl6d0E9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUpRZ0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQ1N3d2dna29BZ0VBQW9JQ0FRQ3ZrdmFrMXJYOEdqQmIKOXU0alRhS2lFbW1kYThSaEhuMlVzS0dDL2dIUklqTVE5cXV4cUp4dXdWV2ZXZFFJeXpWNXFaZHNVTG5sUU81MApHczFwR3lKUEdndmlQWmRBa3A3ckcvbE5OUk0xaC9kVWI3RU55UkwxTG1NbjVNb2VyUmNZOXVkaE5lT3l6QnF2ClZ2TG5nOXlHN0ZWZUdHVHduQkJ1MXU0UmR5T1RaMkY1R0hPU1YwSnZ0MHZQY2xvVlQvT2RNeFN5VWxBSGFjWjkKYUk3Mm1scW1GN3BQTzhZK2UwbEo4WTdCczB1MXF0TnFrK0dlUU9BbkQzY25IbXBITjZlQ2dMajRiMFc4cHZvcgp0RE1HR2dvTHduN3I4dWlwQ01yR0FmeGswWVNhV0prbWlYSlVLcWpyRURlODBzUG5mWlBFbmlhamVIZ2RMaEozCkhTUTZhZGtwRFk1TDBJUjNSV0lLNXllMW1GWHdDWlc1d1NCdTFEM3cvaDNlNXhSeExtLzdUZCs1dFJjdG4vSEUKYkhlWDdncURPTVljMDU2NXFaZFd5eFYrNERmL1B1SDM0TExIZ1NYNDVTeVpmckROUEJVZUlrRzZYTi9pMkp5YgpQY0pUenVsTkxBbmlFS2NTYmtqeDhvUXB1NFJCbk11SUNmMGJRcVhibVovcE5PNERMb0VWRkxpekZtekdmR1N4Cjc2bktadlczRFdRTEtndzdMd2RkZTNyVjhRbUowL3dnM0drUU5WOEpJNXVmd25DT0VnWSsrUG5mL3Rxd3BhUTMKWXJicGF6YTgyZS9sT2hDRElyZWNDYTdWam9GV1Y3WEt2TUVVRmJieCtVblgrb0dHS1BEaHMvRU02ZDNRY2l4RwpWK2VWZ0xEanZMb1dxZ1JjOHVTeEdTV09WeWVpV1FJREFRQUJBb0lDQUI4YzFtczRoekJBL2MvV0xyWC8xSEdPCi9MdENOUjhXdFo5TE81dklZazhLbGUwTUlUbk96TVhOcWR3ZW9YWGJlTUx4L0J6Y0kwME9XQk1vQ3IxMDZ2d0UKZkJXZjMzVTRaa1A0aFpHYWRhaDNTeXRoellqSldId3RON0lDbDVTZkRLaEdYSk03NXZrd3RRdmNSeGdpcEVvZQppRFF2ODNjMTJLMmpsYlZ2bk5US3JabTFiUW1DUUFvbSs1NnJ2MjNtYUorelJSZ2lnUDhIVGY2OE1CVmdIZTh2CjVqcVRONXFyNHoxZ3VuRDEwbFZEaThwbm9VUVhjQUZMK3N2cVZsLy9hMFl6aEZPMStEQXBrTXg4MXN2ZWdtZzYKRTU3QlFWeHU2K3Z4dnlXb2dTeU94Ymp3QTF3SjRUd2llQllVYlZYUXlZWStsazlDa2xwdFp5VkhlenVFdFZBRwoxSkdDb3NyZkl0eUE3YXdSVXFSMWFwajJPS0Jyd1dROW5nK0dvR0RRNnBLTHdzN1F6TUlkUVNMZGY2SVZMZWE4CjJTZ1AyK0hycUIrR1g5ZlJGdUFPRTRQNGprNGtMT3FTSkR4OENvdkdiS3NSUm1RR1lGL282dFhJYm5zZFVNaUsKZUVuYUVINXRmVWZ1WXNlWTlmR3pTUGFJT1FLeXI4TWJtanp3NFprNE9VUmxRWE41K3kzOGxXREVyb3NybG9VUwpZSkxucU5sVUEvNk96d3RTaCtRenFLeGEzcS9jYndFbU54NDYzOTlzRDNxSndXQlBXcng1REtiSlp4SWtQNFVOCnE1YUVGZW5kY09mZTNxNG9uQm1FQ28weW1zRko0eGNTUFdYUGJtRk91dHJiTnN5R0xDRURlUjNpRmM0Qk12aTYKZURwSHl0MTlXTDloLysyVC9OeG5Bb0lCQVFEd2JLaTd3TUgyWUlUYzBkTi82SEtiaVdJQXVFZkZMbzZSOUxuTwpaWnR2QW5tQTNSTmpJanB3R1BGQ3A0QjdjdEpiVWtIbzlSemdZQ3RKeGF6ZE5wWXRRaFhMRFZ0c0ExZ21zQUo0CmJwcVUrVnhoeTd5R1lzZDNZVDRVcUVRZFNtbHdYRHhuSFlsb0hWd3ExQlRxeWNZSjNYZGV6bE9BS0JzZFR6eGwKZmJGdjJjSXM3aTBUcU1VbzZSd1hVcnhSSU85TGNJL2ZaSWROVmd4MVk2Nm9SaXoxdnlVYVlISUNGM3V6OHl6VgpYc1JoMVZzNFdXVXRhTmlzbXBWTkFnTEtHTUthWGlKNU9pRjA1RWljcUs0bWtNR2lUMFFaZElpNkdyd3dEMkU5Cm5qZjY2bkwycmtGQXRudHpaVkdXMTVwVkUxc2ZJV0UrbHcycGlhSjFESTVNVm4zYkFvSUJBUUM2OHNtWTZFK2QKQmVzTUpuSXhVaXdPUWZ6a256OG5udGVvMzFSb2c5R1dDejBnNlJFVnBRdDZkVU43Wi8yQnZKVUtkNk0yZmlYYwppUmNFRTF6RHpaZ3RMVmhWbG5ZaWZnT1lsbU9NRlRZbHRGUnQ0emgzbUM1TUxOVThVVmhNUjE2cHYvVkF2RmhpCnlxbVNQVG1acjNVZkNKdjZjeFZRbFZJYlVTTVI3bmlSSW13T2JPYUJ3OFpCV0JaOUVCYi90N0ROUVpCV2ZKdTIKejdESXhsUXBxcFM1aWZLRlZpT2pCL0hXcnJ1RHpLUjROdFFDSkRoZERoUlZCWWxsSkpKRE9GV3VCbm5hS3EveQpiZWlrV2dSZmI3YUNSOU5LUVk3aHM4d2dDL0MzNFVBR2lyN2pJNURpQmFKOGVlM0xoMk5GRTI4VWFUTW5mSDNJClFQbVN1MUI2NjJqYkFvSUJBSEZ6QktnY0RDckRYczZJWUtIeHdPcnVDQVhJNzJ6M1RDVkpjc2dYSUNKZzY0N0kKUTFhN0Z4SkFZdEFPRkUyc1grRGh6dUlyajZXOUc1QWpMQy95aXlqdUR6U1NwL292RmRDanEzYkMwa1RMNmpEbgpuNTFXVFVOaTZwVjYxVEZ4SkpIMXBEY1FNLytpSXhTK29PUXR0RHFCZTh1TDF0RVptN25YNHVzTlJjWSszaWF2CmVTdldycnBnVFhZZi8yYlZBTFg3ZHBoMmFuWXV6WkF6S242VEpySUxzV2xoNjBwYlpHOEVwN3BEanEyUHJRekkKK2pwVVNESWllNk1yK0w3K3NnMS9zQXErU0gxTkg0cDAra0NPZkNDb0FMMTJSUEowblNxY2gwazVPTGM1SEdpVQp6NHZHMERnaXJqNWNuS0hha1Z2K04xSCttMTdONkpBTkRiU3Q5NU1DZ2dFQkFLS3R4UG4zSmRoSkh4bEtsMUlOCjVHSmZ6N1lPVVVHaitweHNBcUtVR3B4TG1WejdFeS9YbUI1dXpsTWowYmpFcHBrZU5IdWwyRUtKVk9ycUFtNHMKaVFDL0ZjQWNseDQ2czl4aSthc2JoaXZYT1NVS2RjZTBPSTEyOGZOMEFiY1czK3d0S3ppeTdPTEM0ajVzWXFRMgp4MTlDK2FBOTVzMWhzcm9zcDZ6aDdDNjNXbnBQRDJMYVBybjc4azNQNDRPUWtCeDhzaUpnZW92aFBUL3BQYkdvClM1VU0wbXB1NDhIcGx1dXV6MlBJZjFKUXU3cEZWSHE5VnJvSmdGN3dMUXFyaWZ0T2pWaG9qd1VSMlVDelNGelgKOUdSNEpnZlc5b08zRnFqSVd5ZFhyb1JDMWdzSGx2cm4xbFlsTCtWTklmZ3BDaDhqMEN6TEt4VklYU1R2TlFCUgp1OE1DZ2dFQWJuQ0JSdmtNUnh6S1NkM1V5USszV0tBdnYzVHk2L1phNy8rVVBhZzFRTE5RSksram50L3AvNWpzCkRCRWhPSDhMT2MxTnEzSEpWUVBjV2kwVXo1aDhOWVJwUWx2QWNlRjRTb3d4ZGIrWWRTS1RIN3daVDFwa1dkY0kKWTNib1N0Y0hsOVlCM1Q5STVIZWR4dHgzMytNSkppMXcwVGswK3lNcHBJUmV2VFg3WjVpbnZpbHE3eWJITzd6NwoxbHRHaGJJTjZSblVzcy9mZE1ianJ4N3dreEtqMmlKLzYyM3B1Z0tsakdndUlVR1l3ODNaeUREaGp3ZVJleTlMCmphQVViN01CQ2xsR0NNS3hEZGtsUUN3eHh0YmpySk1QZ0JSbVRjK093QzNwUDNqemJLNlRuQWhnL3JxeEpaYnAKN3hFL0lyd3lxOXJtY3lqZzlsVkh0RHRFN1RDSVRnPT0KLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQo= diff --git a/tests/data/common/app/keycloak-secure/app.yaml b/tests/data/common/app/keycloak-secure/app.yaml new file mode 100644 index 0000000000..ce006c29f5 --- /dev/null +++ b/tests/data/common/app/keycloak-secure/app.yaml @@ -0,0 +1,69 @@ +apiVersion: v1 +kind: Service +metadata: + name: keycloak + labels: + app: keycloak +spec: + ports: + - name: http + port: 8080 + targetPort: 8080 + - name: https + port: 8443 + targetPort: 8443 + selector: + app: keycloak +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak + labels: + app: keycloak +spec: + replicas: 1 + selector: + matchLabels: + app: keycloak + template: + metadata: + labels: + app: keycloak + spec: + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:26.4 + args: ["start"] + env: + - name: KC_BOOTSTRAP_ADMIN_USERNAME + value: "admin" + - name: KC_BOOTSTRAP_ADMIN_PASSWORD + value: "admin" + - name: KC_HTTP_ENABLED + value: "true" + - name: KC_HOSTNAME_STRICT + value: "false" + - name: KC_PROXY_HEADERS + value: "xforwarded" + - name: KC_HTTPS_CERTIFICATE_FILE + value: "/etc/x509/https/tls.crt" + - name: KC_HTTPS_CERTIFICATE_KEY_FILE + value: "/etc/x509/https/tls.key" + volumeMounts: + - name: https-certs + mountPath: /etc/x509/https + readOnly: true + ports: + - name: http + containerPort: 8080 + - name: https + containerPort: 8443 + readinessProbe: + httpGet: + path: /realms/master + port: 8080 + volumes: + - name: https-certs + secret: + secretName: keycloak-tls-secret diff --git a/tests/data/oidc/keycloak-ca-secret.yaml b/tests/data/oidc/keycloak-ca-secret.yaml new file mode 120000 index 0000000000..7df8b527cf --- /dev/null +++ b/tests/data/oidc/keycloak-ca-secret.yaml @@ -0,0 +1 @@ +../common-secrets/keycloak-ca-secret.yaml \ No newline at end of file diff --git a/tests/data/oidc/keycloak-tls-secret.yaml b/tests/data/oidc/keycloak-tls-secret.yaml new file mode 120000 index 0000000000..4865abf55d --- /dev/null +++ b/tests/data/oidc/keycloak-tls-secret.yaml @@ -0,0 +1 @@ +../common-secrets/keycloak-tls-secret.yaml \ No newline at end of file diff --git a/tests/data/oidc/oidc-tls.yaml b/tests/data/oidc/oidc-tls.yaml new file mode 100644 index 0000000000..2fb16f3dfa --- /dev/null +++ b/tests/data/oidc/oidc-tls.yaml @@ -0,0 +1,16 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: oidc-policy +spec: + oidc: + clientID: nginx-plus + clientSecret: oidc-secret + authEndpoint: https://keycloak.example.com/realms/master/protocol/openid-connect/auth + tokenEndpoint: http://keycloak.default.svc.cluster.local:8080/realms/master/protocol/openid-connect/token + jwksURI: http://keycloak.default.svc.cluster.local:8080/realms/master/protocol/openid-connect/certs + endSessionEndpoint: https://keycloak.example.com/realms/master/protocol/openid-connect/logout + scope: openid+profile+email + accessTokenEnable: true + sslVerify: true + trustedCertSecret: keycloak-ca-secret diff --git a/tests/data/oidc/pkce-tls.yaml b/tests/data/oidc/pkce-tls.yaml new file mode 100644 index 0000000000..e4e2a91c83 --- /dev/null +++ b/tests/data/oidc/pkce-tls.yaml @@ -0,0 +1,16 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: oidc-policy +spec: + oidc: + clientID: nginx-plus-pkce + authEndpoint: https://keycloak.example.com/realms/master/protocol/openid-connect/auth + tokenEndpoint: http://keycloak.default.svc.cluster.local:8080/realms/master/protocol/openid-connect/token + jwksURI: http://keycloak.default.svc.cluster.local:8080/realms/master/protocol/openid-connect/certs + endSessionEndpoint: https://keycloak.example.com/realms/master/protocol/openid-connect/logout + scope: openid+profile+email + accessTokenEnable: true + pkceEnable: true + sslVerify: true + trustedCertSecret: keycloak-ca-secret diff --git a/tests/suite/test_oidc.py b/tests/suite/test_oidc.py index 59d7f12910..27aa961eff 100644 --- a/tests/suite/test_oidc.py +++ b/tests/suite/test_oidc.py @@ -29,8 +29,8 @@ password = secrets.token_hex(8) keycloak_vs_src = f"{TEST_DATA}/oidc/virtual-server-idp.yaml" oidc_secret_src = f"{TEST_DATA}/oidc/client-secret.yaml" -oidc_pol_src = f"{TEST_DATA}/oidc/oidc.yaml" -pkce_pol_src = f"{TEST_DATA}/oidc/pkce.yaml" +oidc_pol_src = {"http": f"{TEST_DATA}/oidc/oidc.yaml", "https": f"{TEST_DATA}/oidc/oidc-tls.yaml"} +pkce_pol_src = {"http": f"{TEST_DATA}/oidc/pkce.yaml", "https": f"{TEST_DATA}/oidc/pkce-tls.yaml"} oidc_vs_src = f"{TEST_DATA}/oidc/virtual-server.yaml" orig_vs_src = f"{TEST_DATA}/virtual-server-tls/standard/virtual-server.yaml" cm_src = f"{TEST_DATA}/oidc/nginx-config.yaml" @@ -43,10 +43,12 @@ class KeycloakSetup: """ Attributes: secret (str): + secure (bool): """ - def __init__(self, secret): + def __init__(self, secret, secure): self.secret = secret + self.secure = secure @pytest.fixture(scope="class") @@ -54,11 +56,23 @@ def keycloak_setup(request, kube_apis, test_namespace, ingress_controller_endpoi # Create Keycloak resources and setup Keycloak idp - secret_name = create_secret_from_yaml( + vs_secret_name = create_secret_from_yaml( kube_apis.v1, virtual_server_setup.namespace, f"{TEST_DATA}/virtual-server-tls/tls-secret.yaml" ) keycloak_address = "keycloak.example.com" - create_example_app(kube_apis, "keycloak", test_namespace) + backend_app = "keycloak" + backend_secret_name = "" + backend_ca_secret_name = "" + if request.param.get("secure") is True: + backend_app = "keycloak-secure" + backend_secret_name = create_secret_from_yaml( + kube_apis.v1, test_namespace, f"{TEST_DATA}/oidc/keycloak-tls-secret.yaml" + ) + backend_ca_secret_name = create_secret_from_yaml( + kube_apis.v1, test_namespace, f"{TEST_DATA}/oidc/keycloak-ca-secret.yaml" + ) + + create_example_app(kube_apis, backend_app, test_namespace) wait_before_test() wait_until_all_pods_are_ready(kube_apis.v1, test_namespace) keycloak_vs_name = create_virtual_server_from_yaml(kube_apis.custom_objects, keycloak_vs_src, test_namespace) @@ -120,17 +134,21 @@ def fin(): print("Delete Keycloak resources") delete_virtual_server(kube_apis.custom_objects, keycloak_vs_name, test_namespace) delete_common_app(kube_apis, "keycloak", test_namespace) - delete_secret(kube_apis.v1, secret_name, test_namespace) + if backend_secret_name != "": + delete_secret(kube_apis.v1, backend_secret_name, test_namespace) + if backend_ca_secret_name != "": + delete_secret(kube_apis.v1, backend_ca_secret_name, test_namespace) + delete_secret(kube_apis.v1, vs_secret_name, test_namespace) request.addfinalizer(fin) - return KeycloakSetup(encoded_secret) + return KeycloakSetup(encoded_secret, request.param.get("secure")) @pytest.mark.oidc @pytest.mark.skip_for_nginx_oss @pytest.mark.parametrize( - "crd_ingress_controller, virtual_server_setup", + "crd_ingress_controller, virtual_server_setup, keycloak_setup", [ ( { @@ -140,13 +158,14 @@ def fin(): ], }, {"example": "virtual-server-tls", "app_type": "simple"}, + {"secure": False}, ) ], indirect=True, ) -class TestOIDC: +class TestOIDCHttp: @pytest.mark.parametrize("configmap", [cm_src, cm_zs_src]) - @pytest.mark.parametrize("oidcYaml", [oidc_pol_src, pkce_pol_src]) + @pytest.mark.parametrize("oidcYaml", ["standard", "pkce"]) def test_oidc( self, request, @@ -160,59 +179,124 @@ def test_oidc( configmap, oidcYaml, ): - print(f"Create oidc secret") - with open(oidc_secret_src) as f: - secret_data = yaml.safe_load(f) - secret_data["data"]["client-secret"] = keycloak_setup.secret - secret_name = create_secret(kube_apis.v1, test_namespace, secret_data) - - print(f"Create oidc policy") - with open(oidcYaml) as f: - doc = yaml.safe_load(f) - pol = doc["metadata"]["name"] - doc["spec"]["oidc"]["tokenEndpoint"] = doc["spec"]["oidc"]["tokenEndpoint"].replace("default", test_namespace) - doc["spec"]["oidc"]["jwksURI"] = doc["spec"]["oidc"]["jwksURI"].replace("default", test_namespace) - kube_apis.custom_objects.create_namespaced_custom_object("k8s.nginx.org", "v1", test_namespace, "policies", doc) - print(f"Policy created with name {pol}") - wait_before_test() - - print(f"Create virtual server") - patch_virtual_server_from_yaml( - kube_apis.custom_objects, virtual_server_setup.vs_name, oidc_vs_src, test_namespace - ) - wait_before_test() - print(f"Update nginx configmap") - replace_configmap_from_yaml( - kube_apis.v1, - ingress_controller_prerequisites.config_map["metadata"]["name"], - ingress_controller_prerequisites.namespace, + run_test( + kube_apis, + ingress_controller_endpoint, + ingress_controller_prerequisites, + test_namespace, + virtual_server_setup, + keycloak_setup, configmap, + oidcYaml, ) - wait_before_test() - if configmap == cm_src: - print(f"Create headless service") - create_items_from_yaml(kube_apis, svc_src, ingress_controller_prerequisites.namespace) - with sync_playwright() as playwright: - run_oidc(playwright.chromium, ingress_controller_endpoint.public_ip, ingress_controller_endpoint.port_ssl) - - replace_configmap_from_yaml( - kube_apis.v1, - ingress_controller_prerequisites.config_map["metadata"]["name"], - ingress_controller_prerequisites.namespace, - cm_src, +@pytest.mark.oidc +@pytest.mark.skip_for_nginx_oss +@pytest.mark.parametrize( + "crd_ingress_controller, virtual_server_setup, keycloak_setup", + [ + ( + { + "type": "complete", + "extra_args": [ + f"-enable-oidc", + ], + }, + {"example": "virtual-server-tls", "app_type": "simple"}, + {"secure": True}, ) - delete_secret(kube_apis.v1, secret_name, test_namespace) - delete_policy(kube_apis.custom_objects, pol, test_namespace) - patch_virtual_server_from_yaml( - kube_apis.custom_objects, virtual_server_setup.vs_name, orig_vs_src, test_namespace + ], + indirect=True, +) +class TestOIDCHttps: + @pytest.mark.parametrize("configmap", [cm_src, cm_zs_src]) + @pytest.mark.parametrize("oidcYaml", ["standard", "pkce"]) + def test_oidc( + self, + request, + kube_apis, + ingress_controller_endpoint, + ingress_controller_prerequisites, + crd_ingress_controller, + test_namespace, + virtual_server_setup, + keycloak_setup, + configmap, + oidcYaml, + ): + run_test( + kube_apis, + ingress_controller_endpoint, + ingress_controller_prerequisites, + test_namespace, + virtual_server_setup, + keycloak_setup, + configmap, + oidcYaml, ) - if configmap == cm_src: - with open(svc_src) as f: - headless_svc = yaml.safe_load(f) - headless_name = headless_svc["metadata"]["name"] - delete_service(kube_apis.v1, headless_name, ingress_controller_prerequisites.namespace) + + +def run_test( + kube_apis, + ingress_controller_endpoint, + ingress_controller_prerequisites, + test_namespace, + virtual_server_setup, + keycloak_setup, + configmap, + oidcYaml, +): + print(f"Create oidc secret") + with open(oidc_secret_src) as f: + secret_data = yaml.safe_load(f) + secret_data["data"]["client-secret"] = keycloak_setup.secret + secret_name = create_secret(kube_apis.v1, test_namespace, secret_data) + + policy_file = get_oidc_policy_file(keycloak_setup, oidcYaml) + print(f"Create oidc policy from file {policy_file}") + with open(policy_file) as f: + doc = yaml.safe_load(f) + pol = doc["metadata"]["name"] + doc["spec"]["oidc"]["tokenEndpoint"] = doc["spec"]["oidc"]["tokenEndpoint"].replace("default", test_namespace) + doc["spec"]["oidc"]["jwksURI"] = doc["spec"]["oidc"]["jwksURI"].replace("default", test_namespace) + kube_apis.custom_objects.create_namespaced_custom_object("k8s.nginx.org", "v1", test_namespace, "policies", doc) + print(f"Policy created with name {pol}") + wait_before_test() + + print(f"Create virtual server") + patch_virtual_server_from_yaml(kube_apis.custom_objects, virtual_server_setup.vs_name, oidc_vs_src, test_namespace) + wait_before_test() + print(f"Update nginx configmap") + replace_configmap_from_yaml( + kube_apis.v1, + ingress_controller_prerequisites.config_map["metadata"]["name"], + ingress_controller_prerequisites.namespace, + configmap, + ) + wait_before_test() + + if configmap == cm_src: + print(f"Create headless service") + create_items_from_yaml(kube_apis, svc_src, ingress_controller_prerequisites.namespace) + + with sync_playwright() as playwright: + run_oidc(playwright.chromium, ingress_controller_endpoint.public_ip, ingress_controller_endpoint.port_ssl) + + replace_configmap_from_yaml( + kube_apis.v1, + ingress_controller_prerequisites.config_map["metadata"]["name"], + ingress_controller_prerequisites.namespace, + cm_src, + ) + delete_secret(kube_apis.v1, secret_name, test_namespace) + delete_policy(kube_apis.custom_objects, pol, test_namespace) + patch_virtual_server_from_yaml(kube_apis.custom_objects, virtual_server_setup.vs_name, orig_vs_src, test_namespace) + if configmap == cm_src: + with open(svc_src) as f: + headless_svc = yaml.safe_load(f) + headless_name = headless_svc["metadata"]["name"] + delete_service(kube_apis.v1, headless_name, ingress_controller_prerequisites.namespace) def run_oidc(browser_type, ip_address, port): @@ -247,3 +331,15 @@ def run_oidc(browser_type, ip_address, port): finally: context.close() browser.close() + + +def get_oidc_policy_file(keycloak_setup, oidcYaml): + if oidcYaml == "standard" and keycloak_setup.secure is False: + policy_file = oidc_pol_src["http"] + elif oidcYaml == "standard" and keycloak_setup.secure is True: + policy_file = oidc_pol_src["https"] + elif oidcYaml == "pkce" and keycloak_setup.secure is False: + policy_file = pkce_pol_src["http"] + else: + policy_file = pkce_pol_src["https"] + return policy_file From 2782d3a8f08091df2647b1af65d685c2ea961a17 Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Fri, 21 Nov 2025 11:05:49 +0000 Subject: [PATCH 10/17] Update PKCE example --- examples/custom-resources/oidc/README.md | 14 ++++++++++++-- examples/custom-resources/oidc/oidc-pkce.yaml | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 examples/custom-resources/oidc/oidc-pkce.yaml diff --git a/examples/custom-resources/oidc/README.md b/examples/custom-resources/oidc/README.md index 26d3e98bb6..e578cf04f4 100644 --- a/examples/custom-resources/oidc/README.md +++ b/examples/custom-resources/oidc/README.md @@ -129,7 +129,17 @@ Create a Secret containing the Keycloak CA, this used in the OIDC Policy to veri kubectl apply -f keycloak-ca-secret.yaml ``` -## Step 8 - Deploy the OIDC Policy +## Step 8a - Deploy the OIDC Policy - PKCE + +**Note**: This step only applies if you have PKCE enabled in Keycloak. + +Create a policy with the name `oidc-policy` that references the secret from the previous step: + +```shell +kubectl apply -f oidc-pkce.yaml +``` + +## Step 8b - Deploy the OIDC Policy - Client secret Create a policy with the name `oidc-policy` that references the secret from the previous step: @@ -145,7 +155,7 @@ Create a VirtualServer resource for the web application: kubectl apply -f virtual-server.yaml ``` -Note that the VirtualServer references the policy `oidc-policy` created in Step 6. +Note that the VirtualServer references the policy `oidc-policy` created in Step 8. ## Step 10 - Test the Configuration diff --git a/examples/custom-resources/oidc/oidc-pkce.yaml b/examples/custom-resources/oidc/oidc-pkce.yaml new file mode 100644 index 0000000000..e67ce0d285 --- /dev/null +++ b/examples/custom-resources/oidc/oidc-pkce.yaml @@ -0,0 +1,16 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: oidc-policy +spec: + oidc: + clientID: nginx-plus + pkceEnable: true + authEndpoint: https://keycloak.example.com/realms/master/protocol/openid-connect/auth + tokenEndpoint: https://keycloak.default.svc.cluster.local:8443/realms/master/protocol/openid-connect/token + jwksURI: https://keycloak.default.svc.cluster.local:8443/realms/master/protocol/openid-connect/certs + endSessionEndpoint: https://keycloak.example.com/realms/master/protocol/openid-connect/logout + scope: openid+profile+email + accessTokenEnable: true + sslVerify: true + trustedCertSecret: keycloak-ca From d1a95b5edb0bf56bce47f3cb59209c41353a245e Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Fri, 21 Nov 2025 12:29:10 +0000 Subject: [PATCH 11/17] update snap tests to set vs namespace/name in oidc config file import --- .../version2/__snapshots__/templates_test.snap | 16 ++++++++-------- internal/configs/version2/templates_test.go | 6 +++++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/internal/configs/version2/__snapshots__/templates_test.snap b/internal/configs/version2/__snapshots__/templates_test.snap index 66ca0ff35b..e99657d174 100644 --- a/internal/configs/version2/__snapshots__/templates_test.snap +++ b/internal/configs/version2/__snapshots__/templates_test.snap @@ -1481,15 +1481,15 @@ server { server_name example.com; status_zone example.com; set $resource_type "virtualserver"; - set $resource_name ""; - set $resource_namespace ""; - include oidc-conf.d/oidc__.conf; + set $resource_name "exampleVS"; + set $resource_namespace "default"; + include oidc-conf.d/oidc_default_exampleVS.conf; set $oidc_debug true; set $oidc_pkce_enable 1; set $oidc_client_auth_method "client_secret_post"; set $oidc_logout_redirect ""; - set $oidc_hmac_key ""; + set $oidc_hmac_key "exampleVS"; set $zone_sync_leeway 0; set $oidc_authz_endpoint ""; @@ -1530,14 +1530,14 @@ server { server_name example.com; status_zone example.com; set $resource_type "virtualserver"; - set $resource_name ""; - set $resource_namespace ""; - include oidc-conf.d/oidc__.conf; + set $resource_name "exampleVS"; + set $resource_namespace "default"; + include oidc-conf.d/oidc_default_exampleVS.conf; set $oidc_pkce_enable 1; set $oidc_client_auth_method "client_secret_post"; set $oidc_logout_redirect ""; - set $oidc_hmac_key ""; + set $oidc_hmac_key "exampleVS"; set $zone_sync_leeway 0; set $oidc_authz_endpoint ""; diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go index 813d22931c..151bea4c59 100644 --- a/internal/configs/version2/templates_test.go +++ b/internal/configs/version2/templates_test.go @@ -33,7 +33,7 @@ func newTmplExecutorNGINXPlus(t *testing.T) *TemplateExecutor { func newTmplExecutorNGINX(t *testing.T) *TemplateExecutor { t.Helper() - executor, err := NewTemplateExecutor("nginx.virtualserver.tmpl", "nginx.transportserver.tmpl", "oidc.tmpl") + executor, err := NewTemplateExecutor("nginx.virtualserver.tmpl", "nginx.transportserver.tmpl", "") if err != nil { t.Fatal(err) } @@ -2816,6 +2816,8 @@ var ( ServerName: "example.com", StatusZone: "example.com", ProxyProtocol: true, + VSNamespace: "default", + VSName: "exampleVS", OIDC: &OIDC{ PKCEEnable: true, }, @@ -2833,6 +2835,8 @@ var ( ServerName: "example.com", StatusZone: "example.com", ProxyProtocol: true, + VSNamespace: "default", + VSName: "exampleVS", OIDC: &OIDC{ PKCEEnable: true, }, From 3b004625a3b4c0f1a3af193d099dc5208eeaa904 Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Mon, 24 Nov 2025 10:05:09 +0000 Subject: [PATCH 12/17] Ensure snap output contains valid config --- .../configs/version2/__snapshots__/templates_test.snap | 4 ++-- internal/configs/version2/templates_test.go | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/configs/version2/__snapshots__/templates_test.snap b/internal/configs/version2/__snapshots__/templates_test.snap index e99657d174..e55bec46de 100644 --- a/internal/configs/version2/__snapshots__/templates_test.snap +++ b/internal/configs/version2/__snapshots__/templates_test.snap @@ -2991,8 +2991,8 @@ server { default_type text/plain; # In case we throw an error } - set $redir_location ""; - location = { + set $redir_location "/_codexch"; + location = /_codexch { # This location is called by the IdP after successful authentication status_zone "OIDC code exchange"; js_content oidc.codeExchange; diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go index 151bea4c59..9c261c4d61 100644 --- a/internal/configs/version2/templates_test.go +++ b/internal/configs/version2/templates_test.go @@ -2872,9 +2872,11 @@ var ( StatusZone: "example.com", ProxyProtocol: true, OIDC: &OIDC{ - TLSVerify: true, - VerifyDepth: 1, - CAFile: "/etc/ssl/certs/ca-certificate.crt", + TLSVerify: true, + VerifyDepth: 1, + CAFile: "/etc/ssl/certs/ca-certificate.crt", + RedirectURI: "/_codexch", + PostLogoutRedirectURI: "/_logout", }, NGINXDebugLevel: "error", Locations: []Location{ From 9d49e35dde8bc9b4fcdf543c7b2c1c52c4f7e6f4 Mon Sep 17 00:00:00 2001 From: Paul Abel <128620221+pdabelf5@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:19:53 +0000 Subject: [PATCH 13/17] Update tests/suite/test_oidc.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Paul Abel <128620221+pdabelf5@users.noreply.github.com> --- tests/suite/test_oidc.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/tests/suite/test_oidc.py b/tests/suite/test_oidc.py index 27aa961eff..7ded849edc 100644 --- a/tests/suite/test_oidc.py +++ b/tests/suite/test_oidc.py @@ -334,12 +334,5 @@ def run_oidc(browser_type, ip_address, port): def get_oidc_policy_file(keycloak_setup, oidcYaml): - if oidcYaml == "standard" and keycloak_setup.secure is False: - policy_file = oidc_pol_src["http"] - elif oidcYaml == "standard" and keycloak_setup.secure is True: - policy_file = oidc_pol_src["https"] - elif oidcYaml == "pkce" and keycloak_setup.secure is False: - policy_file = pkce_pol_src["http"] - else: - policy_file = pkce_pol_src["https"] - return policy_file + policy_src = oidc_pol_src if oidcYaml == "standard" else pkce_pol_src + return policy_src["https" if keycloak_setup.secure else "http"] From 35bf74761cded2a154a193296a68528959ec2142 Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Mon, 24 Nov 2025 10:05:09 +0000 Subject: [PATCH 14/17] Ensure snap output contains valid config --- .../configs/version2/__snapshots__/templates_test.snap | 4 ++-- internal/configs/version2/templates_test.go | 8 +++++--- internal/nginx/manager.go | 7 ++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/configs/version2/__snapshots__/templates_test.snap b/internal/configs/version2/__snapshots__/templates_test.snap index e99657d174..e55bec46de 100644 --- a/internal/configs/version2/__snapshots__/templates_test.snap +++ b/internal/configs/version2/__snapshots__/templates_test.snap @@ -2991,8 +2991,8 @@ server { default_type text/plain; # In case we throw an error } - set $redir_location ""; - location = { + set $redir_location "/_codexch"; + location = /_codexch { # This location is called by the IdP after successful authentication status_zone "OIDC code exchange"; js_content oidc.codeExchange; diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go index 151bea4c59..9c261c4d61 100644 --- a/internal/configs/version2/templates_test.go +++ b/internal/configs/version2/templates_test.go @@ -2872,9 +2872,11 @@ var ( StatusZone: "example.com", ProxyProtocol: true, OIDC: &OIDC{ - TLSVerify: true, - VerifyDepth: 1, - CAFile: "/etc/ssl/certs/ca-certificate.crt", + TLSVerify: true, + VerifyDepth: 1, + CAFile: "/etc/ssl/certs/ca-certificate.crt", + RedirectURI: "/_codexch", + PostLogoutRedirectURI: "/_logout", }, NGINXDebugLevel: "error", Locations: []Location{ diff --git a/internal/nginx/manager.go b/internal/nginx/manager.go index 716cd8f108..db2fcfcd93 100644 --- a/internal/nginx/manager.go +++ b/internal/nginx/manager.go @@ -50,6 +50,8 @@ const ( appProtectDosAgentInstallCmd = "/usr/bin/adminstall" appProtectDosAgentStartCmd = "/usr/bin/admd -d --standalone" appProtectDosAgentStartDebugCmd = "/usr/bin/admd -d --standalone --log debug" + + defaultCAPath = "/etc/ssl/certs/ca-certificates.crt" ) var ( @@ -777,8 +779,7 @@ func readOSRelease() ([]byte, error) { func (lm *LocalManager) GetOSCABundlePath() (string, error) { sBytes, err := readOSRelease() if err != nil { - // Default to Debian path if unable to read the file. - return "", err + nl.Warnf(lm.logger, "Failed to read /etc/os-release: %v, using default CA path %s", err, defaultCAPath) } s := string(sBytes) caFilePath := getOSCABundlePath(s) @@ -794,7 +795,7 @@ func getOSCABundlePath(s string) string { alpineRegex := regexp.MustCompile(`ID=\"?alpine\"?`) rhelRegex := regexp.MustCompile(`ID=\"?rhel\"?`) // Logic to get the OS CA bundle path. - caFilePath := "/etc/ssl/certs/ca-certificates.crt" // Default for Debian, the default image base + caFilePath := defaultCAPath // Default for Debian, the default image base if alpineRegex.MatchString(s) { caFilePath = "/etc/ssl/cert.pem" From b9827ac1903cab54e5216b5f2a6494ae0d92bfc2 Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Mon, 24 Nov 2025 14:04:16 +0000 Subject: [PATCH 15/17] Update template test with VS name/namespace --- .../configs/version2/__snapshots__/templates_test.snap | 8 ++++---- internal/configs/version2/templates_test.go | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/configs/version2/__snapshots__/templates_test.snap b/internal/configs/version2/__snapshots__/templates_test.snap index e55bec46de..7ed0299c1a 100644 --- a/internal/configs/version2/__snapshots__/templates_test.snap +++ b/internal/configs/version2/__snapshots__/templates_test.snap @@ -1578,14 +1578,14 @@ server { server_name example.com; status_zone example.com; set $resource_type "virtualserver"; - set $resource_name ""; - set $resource_namespace ""; - include oidc-conf.d/oidc__.conf; + set $resource_name "exampleVS"; + set $resource_namespace "default"; + include oidc-conf.d/oidc_default_exampleVS.conf; set $oidc_pkce_enable 1; set $oidc_client_auth_method "client_secret_post"; set $oidc_logout_redirect ""; - set $oidc_hmac_key ""; + set $oidc_hmac_key "exampleVS"; set $zone_sync_leeway 0; set $oidc_authz_endpoint ""; diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go index 9c261c4d61..1d734858b3 100644 --- a/internal/configs/version2/templates_test.go +++ b/internal/configs/version2/templates_test.go @@ -2799,6 +2799,8 @@ var ( Server: Server{ ServerName: "example.com", StatusZone: "example.com", + VSNamespace: "default", + VSName: "exampleVS", ProxyProtocol: true, OIDC: &OIDC{ PKCEEnable: true, From 63a97f3903d26ba4407135eed080eddc74eb1a51 Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Wed, 26 Nov 2025 15:11:39 +0000 Subject: [PATCH 16/17] Move policy validation to the CRD --- pkg/apis/configuration/v1/types.go | 1 + pkg/apis/configuration/validation/policy.go | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/pkg/apis/configuration/v1/types.go b/pkg/apis/configuration/v1/types.go index 41356b7bb4..8161b522a2 100644 --- a/pkg/apis/configuration/v1/types.go +++ b/pkg/apis/configuration/v1/types.go @@ -786,6 +786,7 @@ type PolicySpec struct { // The EgressMTLS policy configures upstreams authentication and certificate verification. EgressMTLS *EgressMTLS `json:"egressMTLS"` // 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. + // +kubebuilder:validation:XValidation:rule="(self.sslVerify == true) || (self.sslVerify == false && !has(self.trustedCertSecret))",message="trustedCertSecret can be set only if sslVerify is true" OIDC *OIDC `json:"oidc"` // The WAF policy configures WAF and log configuration policies for NGINX AppProtect WAF *WAF `json:"waf"` diff --git a/pkg/apis/configuration/validation/policy.go b/pkg/apis/configuration/validation/policy.go index 31075292e4..af0e2bc298 100644 --- a/pkg/apis/configuration/validation/policy.go +++ b/pkg/apis/configuration/validation/policy.go @@ -337,14 +337,6 @@ func validateOIDC(oidc *v1.OIDC, fieldPath *field.Path) field.ErrorList { allErrs = append(allErrs, validateQueryString(strings.Join(oidc.AuthExtraArgs, "&"), fieldPath.Child("authExtraArgs"))...) } - if oidc.TrustedCertSecret != "" { - allErrs = append(allErrs, validateSecretName(oidc.TrustedCertSecret, fieldPath.Child("trustedCertSecret"))...) - // If trustedCertSecret is set but sslVerify is false, warn user - if !oidc.SSLVerify { - allErrs = append(allErrs, field.Invalid(fieldPath.Child("sslVerify"), oidc.SSLVerify, "sslVerify should be enabled when trustedCertSecret is specified")) - } - } - allErrs = append(allErrs, validateURL(oidc.AuthEndpoint, fieldPath.Child("authEndpoint"))...) allErrs = append(allErrs, validateURL(oidc.TokenEndpoint, fieldPath.Child("tokenEndpoint"))...) allErrs = append(allErrs, validateURL(oidc.JWKSURI, fieldPath.Child("jwksURI"))...) From aa3e4a75785a81f92221c68dd2c02f085dd2b6bc Mon Sep 17 00:00:00 2001 From: Paul Abel Date: Wed, 26 Nov 2025 15:17:23 +0000 Subject: [PATCH 17/17] update crds --- config/crd/bases/k8s.nginx.org_policies.yaml | 3 +++ deploy/crds.yaml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/config/crd/bases/k8s.nginx.org_policies.yaml b/config/crd/bases/k8s.nginx.org_policies.yaml index 5204c01a8b..ea9fabca37 100644 --- a/config/crd/bases/k8s.nginx.org_policies.yaml +++ b/config/crd/bases/k8s.nginx.org_policies.yaml @@ -419,6 +419,9 @@ spec: Controller pods. The default is 200. type: integer type: object + x-kubernetes-validations: + - message: trustedCertSecret can be set only if sslVerify is true + rule: (self.sslVerify == true) || (self.sslVerify == false && !has(self.trustedCertSecret)) rateLimit: description: The rate limit policy controls the rate of processing requests per a defined key. diff --git a/deploy/crds.yaml b/deploy/crds.yaml index 85a5b6cfa2..1e05d8c514 100644 --- a/deploy/crds.yaml +++ b/deploy/crds.yaml @@ -590,6 +590,9 @@ spec: Controller pods. The default is 200. type: integer type: object + x-kubernetes-validations: + - message: trustedCertSecret can be set only if sslVerify is true + rule: (self.sslVerify == true) || (self.sslVerify == false && !has(self.trustedCertSecret)) rateLimit: description: The rate limit policy controls the rate of processing requests per a defined key.