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..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 { @@ -541,11 +549,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 +576,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/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/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..e578cf04f4 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,25 @@ 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 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: @@ -123,7 +147,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: @@ -131,9 +155,9 @@ 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 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 +166,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-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 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 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/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..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/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 ""; @@ -1500,7 +1500,6 @@ server { set $oidc_scopes ""; set $oidc_client ""; set $oidc_client_secret ""; - set $redir_location ""; server_tokens ""; @@ -1531,14 +1530,14 @@ server { server_name example.com; status_zone example.com; set $resource_type "virtualserver"; - set $resource_name ""; - set $resource_namespace ""; - include oidc/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 ""; @@ -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 ""; @@ -2847,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/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 83% rename from internal/configs/oidc/oidc.conf rename to internal/configs/version2/oidc.tmpl index 67e1a84f2f..435a117ac4 100644 --- a/internal/configs/oidc/oidc.conf +++ b/internal/configs/version2/oidc.tmpl @@ -12,7 +12,14 @@ 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 + + {{- 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 proxy_set_header Content-Length ""; # '' proxy_pass $oidc_jwt_keyfile; # Expecting to find a URI here @@ -25,8 +32,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; @@ -42,7 +49,13 @@ # 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 + {{- 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; proxy_pass $oidc_token_endpoint; @@ -57,7 +70,13 @@ # 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 + {{- 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; proxy_pass $oidc_token_endpoint; 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..151bea4c59 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", "") 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(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) { @@ -849,7 +874,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) @@ -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", @@ -2792,6 +2816,8 @@ var ( ServerName: "example.com", StatusZone: "example.com", ProxyProtocol: true, + VSNamespace: "default", + VSName: "exampleVS", OIDC: &OIDC{ PKCEEnable: true, }, @@ -2809,6 +2835,8 @@ var ( ServerName: "example.com", StatusZone: "example.com", ProxyProtocol: true, + VSNamespace: "default", + VSName: "exampleVS", OIDC: &OIDC{ PKCEEnable: true, }, @@ -2821,6 +2849,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{ { diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go index 8dc42d007a..6594aae067 100644 --- a/internal/configs/virtualserver.go +++ b/internal/configs/virtualserver.go @@ -303,6 +303,7 @@ type virtualServerConfigurator struct { isIPV6Disabled bool DynamicSSLReloadEnabled bool StaticSSLPath string + CABundlePath string DynamicWeightChangesReload bool bundleValidator bundleValidator IngressControllerReplicas int @@ -353,6 +354,7 @@ func newVirtualServerConfigurator( isIPV6Disabled: staticParams.DisableIPV6, DynamicSSLReloadEnabled: staticParams.DynamicSSLReload, StaticSSLPath: staticParams.StaticSSLPath, + CABundlePath: staticParams.DefaultCABundle, DynamicWeightChangesReload: staticParams.DynamicWeightChangesReload, bundleValidator: bundleValidator, } @@ -426,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{ @@ -1047,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 { @@ -1430,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( @@ -1500,6 +1505,44 @@ func (p *policiesCfg) addOIDCConfig( authExtraArgs = strings.Join(oidc.AuthExtraArgs, "&") } + 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] + + // 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] + } + } + + sslVerifyDepth := 1 + if oidc.SSLVerifyDepth != nil { + sslVerifyDepth = *oidc.SSLVerifyDepth + } + oidcPolCfg.oidc = &version2.OIDC{ AuthEndpoint: oidc.AuthEndpoint, AuthExtraArgs: authExtraArgs, @@ -1514,6 +1557,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 } @@ -1811,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) diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index bf5f8d78d1..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", @@ -14764,6 +14765,7 @@ func TestGeneratePoliciesFails(t *testing.T) { PostLogoutRedirectURI: "/_logout", ZoneSyncLeeway: 200, AccessTokenEnable: true, + VerifyDepth: 1, }, "default/oidc-policy", }, @@ -22964,3 +22966,441 @@ func TestGenerateVirtualServerConfigWithForeignNamespaceServiceInVSR(t *testing. t.Error(cmp.Diff(expected, result)) } } + +func TestGenerateVirtualServerConfigWithOIDCTLSVerifyOn(t *testing.T) { + t.Parallel() + + tests := []struct { + msg string + virtualServerEx VirtualServerEx + expected version2.VirtualServerConfig + }{ + { + msg: "oidc at vs spec level with TLSVerify & 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", + }, + }, + }, + }, + } + + 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) + } + } +} + +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) + } + } +} 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/internal/nginx/fake_manager.go b/internal/nginx/fake_manager.go index bc008d1d79..8042f8720d 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) @@ -197,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 4eb8bb121c..716cd8f108 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) @@ -98,6 +100,7 @@ type Manager interface { AgentQuit() AgentVersion() string GetSecretsDir() string + GetOSCABundlePath() (string, error) UpsertSplitClientsKeyVal(zoneName string, key string, value string) DeleteKeyValStateFiles(virtualServerName string) } @@ -114,6 +117,7 @@ type LocalManager struct { debug bool dhparamFilename string tlsPassthroughHostsFilename string + oidcConfPath string verifyConfigGenerator *verifyConfigGenerator verifyClient *verifyClient configVersion int @@ -147,6 +151,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 +184,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 +206,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 +223,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 { @@ -749,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) + } + }) + } +} 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. 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 } 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"))...) 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