diff --git a/deployments/common/crds/k8s.nginx.org_policies.yaml b/deployments/common/crds/k8s.nginx.org_policies.yaml index 802f351423..2130b4f4b9 100644 --- a/deployments/common/crds/k8s.nginx.org_policies.yaml +++ b/deployments/common/crds/k8s.nginx.org_policies.yaml @@ -100,6 +100,10 @@ spec: description: JWTAuth holds JWT authentication configuration. type: object properties: + jwksURI: + type: string + keyCache: + type: string realm: type: string secret: diff --git a/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml b/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml index 802f351423..2130b4f4b9 100644 --- a/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml +++ b/deployments/helm-chart/crds/k8s.nginx.org_policies.yaml @@ -100,6 +100,10 @@ spec: description: JWTAuth holds JWT authentication configuration. type: object properties: + jwksURI: + type: string + keyCache: + type: string realm: type: string secret: diff --git a/docs/content/configuration/policy-resource.md b/docs/content/configuration/policy-resource.md index b5c54a6986..f533a1506a 100644 --- a/docs/content/configuration/policy-resource.md +++ b/docs/content/configuration/policy-resource.md @@ -163,13 +163,13 @@ policies: ``` In this example the Ingress Controller will use the configuration from the first policy reference `basic-auth-policy-one`, and ignores `basic-auth-policy-two`. -### JWT +### JWT Using Local Kubernetes Secret > Note: This feature is only available in NGINX Plus. The JWT policy configures NGINX Plus to authenticate client requests using JSON Web Tokens. -For example, the following policy will reject all requests that do not include a valid JWT in the HTTP header `token`: +The following example policy will reject all requests that do not include a valid JWT in the HTTP header `token`: ```yaml jwt: secret: jwk-secret @@ -194,7 +194,7 @@ We use the `requestHeaders` of the [Action.Proxy](/nginx-ingress-controller/conf The value of the `${jwt_claim_user}` variable is the `user` claim of a JWT. For other claims, use `${jwt_claim_name}`, where `name` is the name of the claim. Note that nested claims and claims that include a period (`.`) are not supported. Similarly, use `${jwt_header_name}` where `name` is the name of a header. In our example, we use the `alg` header. -> Note: The feature is implemented using the NGINX Plus [ngx_http_auth_jwt_module](https://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html). +> Note: This feature is implemented using the NGINX Plus [ngx_http_auth_jwt_module](https://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html). {{% table %}} |Field | Description | Type | Required | @@ -206,7 +206,43 @@ The value of the `${jwt_claim_user}` variable is the `user` claim of a JWT. For #### JWT Merging Behavior -A VirtualServer/VirtualServerRoute can reference multiple JWT policies. However, only one can be applied. Every subsequent reference will be ignored. For example, here we reference two policies: +A VirtualServer/VirtualServerRoute can reference multiple JWT policies. However, only one can be applied: every subsequent reference will be ignored. For example, here we reference two policies: +```yaml +policies: +- name: jwt-policy-one +- name: jwt-policy-two +``` +In this example the Ingress Controller will use the configuration from the first policy reference `jwt-policy-one`, and ignores `jwt-policy-two`. + +### JWT Using JWKS From Remote Location + +> Note: This feature is only available in NGINX Plus. + +The JWT policy configures NGINX Plus to authenticate client requests using JSON Web Tokens, allowing import of the keys (JWKS) for JWT policy by means of a URL (for a remote server or an identity provider) as a result they don't have to be copied and updated to the IC pod. + +The following example policy will reject all requests that do not include a valid JWT in the HTTP header fetched from the identity provider: +```yaml +jwt: + realm: MyProductAPI + token: $http_token + jwksURI: + keyCache: 1h +``` + +> Note: This feature is implemented using the NGINX Plus directive [auth_jwt_key_request](http://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html#auth_jwt_key_request) under [ngx_http_auth_jwt_module](https://nginx.org/en/docs/http/ngx_http_auth_jwt_module.html). + +{{% table %}} +|Field | Description | Type | Required | +| ---| ---| ---| --- | +|``jwksURI`` | The remote URI where the request will be sent to retrieve JSON Web Key set| ``string`` | Yes | +|``keyCache`` | Enables the caching of keys that are obtained from the ``jwksURI`` and sets a valid time for expiration | ``string`` | Yes | +|``realm`` | The realm of the JWT. | ``string`` | Yes | +|``token`` | The token specifies a variable that contains the JSON Web Token. By default the JWT is passed in the ``Authorization`` header as a Bearer Token. JWT may be also passed as a cookie or a part of a query string, for example: ``$cookie_auth_token``. Accepted variables are ``$http_``, ``$arg_``, ``$cookie_``. | ``string`` | No | +{{% /table %}} + +#### JWT Merging Behavior + +This behavior is similar to using a local Kubernetes secret where a VirtualServer/VirtualServerRoute can reference multiple JWT policies. However, only one can be applied: every subsequent reference will be ignored. For example, here we reference two policies: ```yaml policies: - name: jwt-policy-one diff --git a/examples/custom-resources/jwks/README.md b/examples/custom-resources/jwks/README.md new file mode 100644 index 0000000000..17862716ad --- /dev/null +++ b/examples/custom-resources/jwks/README.md @@ -0,0 +1,181 @@ +# JWKS + +In this example we deploy a web application, configure load balancing with a VirtualServer, and apply a JWT policy. +Instead of using a local secret to verify the client request such as in the [jwt](https://github.com/nginxinc/kubernetes-ingress/tree/main/examples/custom-resources/jwt) example, we will define an external Identity Provider (IdP) using the `JwksURI` field. + +We will be using a deployment of [KeyCloak](https://www.keycloak.org/) to work as our IdP in this example. +In this example, KeyCloak is deployed as a single container for the purpose of exposing it with an Ingress Controller. + +## Prerequisites + +1. Follow the [installation](https://docs.nginx.com/nginx-ingress-controller/installation/installation-with-manifests/) instructions to deploy the Ingress Controller. + +2. Save the public IP address of the Ingress Controller into `/etc/hosts` of your machine: + ``` + ... + + XXX.YYY.ZZZ.III webapp.example.com + XXX.YYY.ZZZ.III keycloak.example.com + ``` + Here `webapp.example.com` is the domain for the web application and `keycloak.example.com` is the domain for Keycloak. + +## Step 1 - Deploy a TLS Secret + +Create a secret with the TLS certificate and key that will be used for TLS termination of the web application and Keycloak: +``` +$ kubectl apply -f tls-secret.yaml +``` + +## Step 2 - Deploy a Web Application + +Create the application deployment and service: +``` +$ kubectl apply -f webapp.yaml +``` + +## Step 3 - Deploy Keycloak + +1. Create the Keycloak deployment and service: + ``` + $ kubectl apply -f keycloak.yaml + ``` +1. Create a VirtualServer resource for Keycloak: + ``` + $ kubectl apply -f virtual-server-idp.yaml + ``` + +## Step 4 - Configure Keycloak + +To set up Keycloak: +1. To connect to Keycloak, use `https://keycloak.example.com`. + +2. Create a new Realm. We will use `jwks-example` for this example. This can be done by selecting the dropdown menu on the left and selecting `Create Realm` + +3. Create a new Client called `jwks-client`. This can be done by selecting the `Client`s tab on the left and then selecting `Create client`. + - When creating the Client, ensure both `Client authentication` and `Authorization` are enabled. + +4. Once the client is created, navigate to the `Credentials` tab for that client and copy the client secret. + - This can be saved in the `SECRET` shell variable for later: + ``` + export SECRET= + ``` + +5. Create a new User called `jwks-user` by selecting the Users tab on the left and then selecting Create client. + +6. Once the user is created, navigate to the `Credentials` tab for that user and select `Set password`. For this example the password can be whatever you want. + - This can be saved in the `PASSWORD` shell variable for later: + ``` + export PASSWORD= + ``` + +## Step 5 - Deploy the JWT Policy + +1. Create a policy with the name `jwt-policy` and configure the `JwksURI` field so that it only permits requests to our web application that contain a valid JWT. +In the example policy below, replace `` with the realm created in Step 4. We used `jwks-example` as our realm name. +The value of `spec.jwt.token` is set to `$http_token` in this example as we are sending the client token in an HTTP header. +``` +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: jwt-policy +spec: + jwt: + realm: MyProductAPI + token: $http_token + jwksURI: http://keycloak.default.svc.cluster.local:8080/realms//protocol/openid-connect/certs + keyCache: 1h +``` + +2. Deploy the policy: +``` +$ kubectl apply -f jwks.yaml +``` + +## Step 6 - Deploy a config map with a resolver + +If the value of `jwksURI` uses a hostname, the Ingress Controller will need to reference a resolver. +This can be done by deploying a ConfigMap with the `resolver-addresses` data field +``` +kind: ConfigMap +apiVersion: v1 +metadata: + name: nginx-config + namespace: nginx-ingress +data: + resolver-addresses: +``` +In this example, we create a ConfigMap using Kubernetes' default DNS `kube-dns.kube-system.svc.cluster.local` for the resolver address. For more information on `resolver-addresses` and other related ConfigMap keys, please refer to our documentation [ConfigMap Resource](https://docs.nginx.com/nginx-ingress-controller/configuration/global-configuration/configmap-resource/#summary-of-configmap-keys) and our blog post [Using DNS for Service Discovery with NGINX and NGINX Plus](https://www.nginx.com/blog/dns-service-discovery-nginx-plus) + +NOTE: When setting the value of `jwksURI` in Step 5, the response will differ depending on the IDP used. In some cases the response will be too large for NGINX to properly handle. +If this occurs you will need to configure the [subrequest_output_buffer_size](https://nginx.org/en/docs/http/ngx_http_core_module.html#subrequest_output_buffer_size) directive in the http context. +This can currently be done using `http-snippets`. Please refer to our document on [snippets and custom templates](https://docs.nginx.com/nginx-ingress-controller/configuration/global-configuration/configmap-resource/#snippets-and-custom-templates) for details on how to configure this directive. + +The code block below is an example of the updated configmap which adds `subrequest_output_buffer_size` under the http context in the nginx.conf. + +NOTE: The value of `subrequest_output_buffer_size` is only an example value and should be changed to suite your environment. +``` +kind: ConfigMap +apiVersion: v1 +metadata: + name: nginx-config + namespace: nginx-ingress +data: + resolver-addresses: + http-snippets: | + subrequest_output_buffer_size 64k; +``` + +``` +$ kubectl apply -f nginx-config.yaml +``` + +## Step 7 - Configure Load Balancing + +Create a VirtualServer resource for the web application: +``` +$ kubectl apply -f virtual-server.yaml +``` + +Note that the VirtualServer references the policy `jwt-policy` created in Step 5. + +## Step 8 - Get the client token + +For the client to have permission to send requests to the web application they must send a Bearer token to the application. +To get this token, run the following `curl` command: +``` +$ export TOKEN=$(curl -k -L -X POST 'https://keycloak.example.com/realms/jwks-example/protocol/openid-connect/token' \ +-H 'Content-Type: application/x-www-form-urlencoded' \ +--data-urlencode grant_type=password \ +--data-urlencode scope=openid \ +--data-urlencode client_id=jwks-client \ +--data-urlencode client_secret=$SECRET \ +--data-urlencode username=jwks-user \ +--data-urlencode password=$PASSWORD \ +| jq -r .access_token) +``` + +This command will save the token in the `TOKEN` shell variable. + +## Step 9 - Test the Configuration + +If you attempt to access the application without providing the bearer token, NGINX will reject your requests for that VirtualServer: +``` +$ curl -H 'Accept: application/json' webapp.example.com + +401 Authorization Required + +

401 Authorization Required

+
nginx/1.23.2
+ + +``` + +If a valid bearer token is provided, the request will succeed: +``` +$ curl -H 'Accept: application/json' -H "token: ${TOKEN}" webapp.example.com +Server address: 10.42.0.7:8080 +Server name: webapp-5c6fdbcbf9-pt9tp +Date: 13/Dec/2022:14:50:33 +0000 +URI: / +Request ID: f1241390ac51318afa4fcc39d2341359 +``` diff --git a/examples/custom-resources/jwks/jwks.yaml b/examples/custom-resources/jwks/jwks.yaml new file mode 100644 index 0000000000..a9594cbe06 --- /dev/null +++ b/examples/custom-resources/jwks/jwks.yaml @@ -0,0 +1,10 @@ +apiVersion: k8s.nginx.org/v1 +kind: Policy +metadata: + name: jwt-policy +spec: + jwt: + realm: MyProductAPI + token: $http_token + jwksURI: http://keycloak.default.svc.cluster.local:8080/realms/jwks-example/protocol/openid-connect/certs + keyCache: 1h diff --git a/examples/custom-resources/jwks/keycloak.yaml b/examples/custom-resources/jwks/keycloak.yaml new file mode 100644 index 0000000000..524f115946 --- /dev/null +++ b/examples/custom-resources/jwks/keycloak.yaml @@ -0,0 +1,51 @@ +apiVersion: v1 +kind: Service +metadata: + name: keycloak + labels: + app: keycloak +spec: + ports: + - name: http + port: 8080 + targetPort: 8080 + selector: + app: keycloak +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak + namespace: default + labels: + app: keycloak +spec: + replicas: 1 + selector: + matchLabels: + app: keycloak + template: + metadata: + labels: + app: keycloak + spec: + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:20.0.1 + args: ["start-dev"] + env: + - name: KEYCLOAK_ADMIN + value: "admin" + - name: KEYCLOAK_ADMIN_PASSWORD + value: "admin" + - name: KC_PROXY + value: "edge" + ports: + - name: http + containerPort: 8080 + - name: https + containerPort: 8443 + readinessProbe: + httpGet: + path: /realms/master + port: 8080 diff --git a/examples/custom-resources/jwks/nginx-config.yaml b/examples/custom-resources/jwks/nginx-config.yaml new file mode 100644 index 0000000000..06be76485c --- /dev/null +++ b/examples/custom-resources/jwks/nginx-config.yaml @@ -0,0 +1,7 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: nginx-config + namespace: nginx-ingress +data: + resolver-addresses: "kube-dns.kube-system.svc.cluster.local" diff --git a/examples/custom-resources/jwks/tls-secret.yaml b/examples/custom-resources/jwks/tls-secret.yaml new file mode 100644 index 0000000000..6cb10b54ad --- /dev/null +++ b/examples/custom-resources/jwks/tls-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: tls-secret +type: kubernetes.io/tls +data: + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURFVENDQWZtZ0F3SUJBZ0lVS2hTQzBBcnhUblYrbjBhVnNENkFVTE5VQWhZd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0dERVdNQlFHQTFVRUF3d05LaTVsZUdGdGNHeGxMbU52YlRBZUZ3MHlNVEF4TVRZd01qSXpNekZhRncwegpNVEF4TVRRd01qSXpNekZhTUJneEZqQVVCZ05WQkFNTURTb3VaWGhoYlhCc1pTNWpiMjB3Z2dFaU1BMEdDU3FHClNJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUURGeU1DSlhlSm9tMTdhcUVQc01NbTNlVzlpQzFHdlI4YW8KaDJhNmgvZWRXTUFndEtWSERmR2tPQ2V5NDBEdGtXTDN3U0NvZE1McnhPcnN2Lzhuc1VablFwQmNBekxBbzBJVgptYnhoS21WaS9EMkJpb2pBcDlqVXlsMjNma2RWMFdYM3NYV0JQekhSa3RyK0ozaW83YVcvNUl0WVBNWWFYM3dmCkZYRWFXVmQ4QmJDQ0hyVlZ3ckMvem9aTEF3dFE0d1I5NUI2NHdtd2d4TEhNZDlWZDRSZ1l2U0ppc1QzWi9IRkkKTGpaTGdMa0FlMGlDci9xdmFsdnVhU3BNVmJUd1lQZ2l6YWhXSVFTYjVyd29JeUhnYXFBWnRYSEhjNSsydDVoZQpMMDc2RjgrOE84b0hpdDR6WGpsR1V4TFNjTWFPTnI2ZHI0Q256NmlXZzJNTGlJcno0VnR4QWdNQkFBR2pVekJSCk1CMEdBMVVkRGdRV0JCUTdCSGpyZHlicnpWNHIwVkRrc2k3TXFPNWRKREFmQmdOVkhTTUVHREFXZ0JRN0JIanIKZHlicnpWNHIwVkRrc2k3TXFPNWRKREFQQmdOVkhSTUJBZjhFQlRBREFRSC9NQTBHQ1NxR1NJYjNEUUVCQ3dVQQpBNElCQVFDdm5TdUY4dUFUWFl2VHVjVGhEcG9jKzI5RU1LVFp2VDBmSmJrNWZMaWQzYjhFTDQxdk5tTjRwUTUrCmJtSFh1bkhLL29aSm43bWVNTngwc0ZQMW1Pa1U5MXBqZVJLWmoxOXVNQjlvTVBreXdXRENuQ1BHYWtFUHpxOS8KWjFwcERKQ0FJc2cvME8wZ1BCMDdFSm9RcU0wdDlZc3BuMlJ4djMwUGdBZ3ZuSXduUlNzUWpvOEpxQ1VuemZJLwpPdXovNVl1UkhJRHQzY0RpdTdzWG1DTW01cFJ5eUd2WGZiWEsrSVFWOHZDRTZlZS9FTlNFcnB0NUdzeVNURjZKCk5LdDhXM1VwNkUvL2dwMkRvTXBxS0tGQkE0aG5OQXVzQVphTkNQdi9EY0xueG9xQUp4S0V5cmpxelJBeTlCRXkKRzBhSTJ5bitKWW5yVW8wMmc1OWFXalZMTzg4RwotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2Z0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktnd2dnU2tBZ0VBQW9JQkFRREZ5TUNKWGVKb20xN2EKcUVQc01NbTNlVzlpQzFHdlI4YW9oMmE2aC9lZFdNQWd0S1ZIRGZHa09DZXk0MER0a1dMM3dTQ29kTUxyeE9ycwp2Lzhuc1VablFwQmNBekxBbzBJVm1ieGhLbVZpL0QyQmlvakFwOWpVeWwyM2ZrZFYwV1gzc1hXQlB6SFJrdHIrCkozaW83YVcvNUl0WVBNWWFYM3dmRlhFYVdWZDhCYkNDSHJWVndyQy96b1pMQXd0UTR3Ujk1QjY0d213Z3hMSE0KZDlWZDRSZ1l2U0ppc1QzWi9IRklMalpMZ0xrQWUwaUNyL3F2YWx2dWFTcE1WYlR3WVBnaXphaFdJUVNiNXJ3bwpJeUhnYXFBWnRYSEhjNSsydDVoZUwwNzZGOCs4TzhvSGl0NHpYamxHVXhMU2NNYU9OcjZkcjRDbno2aVdnMk1MCmlJcno0VnR4QWdNQkFBRUNnZ0VBQXhBcjR6VEFCK3k0R0Z6WXlIU3MreGwzWHlaYnVvSTdFbXNlYlM4ajU1enoKUk01bmJPVkxZOGEyM3E5a1Z3bVVaYy9vNkpMK1hkWnI2UVRFTitJbisvdHM3dS9odmxnSTh2cXhqek92NUV1Ugp6RXJQK1dQZ0dOT1ZoZnovcjlXUlpiZXE0VGlRVmZXWFRLNWgwUVAxT0RhYTdkL3JGWWQ3RGFRd1h6OFkrc080CnhqV0dNNFprOW1oWm1PbG9nZjNtYyszUFhYTWV6RFRMY2kzRWNpZVlaTkhTeXIzWkg2NU8rSkdsOFZ2bkZUWS8KQytQZi9tYmJKL282dlNWWDNWQUVIM29BY05qd1dqMkdBNUhiRk5RTnV0ckhRcnNkR0ZqUVB5aHNBYjNOV1h2bwo2M3hoS1NNbHpxSWd2WXZMbENOS0VjZmJsVjRuelJ4NVhhM0dzZjJkUFFLQmdRRDlYeEs4ekhpN2g4WjlQV2sxCktDZFlvZDFVa2ViWktYUVQvOUtNcmhrOE9abG1oV2hFK1lBY3lJRElVeFZuZ2xkR0d3RVViTFcyWEVnVStQVmEKM1ZlaUNCTlRWM3FwV3lYWXdIdG9yYm5WbGtlMGh4eE9WakhvSmpZWitmV0h6MDU0algvYkdsdWp5bVJGMWpoWApuMnhNUW5RUkV0S2FGN0R2d2FGK083dGExd0tCZ1FESDFndWRlVCsvQ3M1R3g3eEkwUnhwRUt4c0FtcUV3blBECklsaHoxZHJqbGZFaTRPZ25wK0ZOK05acGJiMHRaWmUyTTM2QXpMVENIUURmQVNJTlBDMkxzOHEvTjAyR2xzcG8KalVTd3M4cWc2N2ZjcG1UN1FVVTVMZmZuaDE3S1A5ZEdCdlRuK3Vza1MwVjRFZ2M0Ti9lS2pUQi9xcjYzYWRHUwp4dmRaYzdnNjl3S0JnRE9CQWdRUzVHL3FkN1M1cVFzL01GQmFCdTNNQXNzZUhCUjhxa1lpbGNxaVFzYU9VOVhCCmlnTlAxcTNpQmJYV3p2clhQbTd5Y2pXeHFJMXExaVUwWFQzNHVrVDB3V0J2d00vQXdOVlVpelFacWxYT0tUamIKV0tYQ0xyazFFRzRjKyt5Umh1MzQrNnZkMW1oRDFZd3FRZzkyYXJXVngrMis1eDYxazZoZmFBUmRBb0dCQU1Kcgp0QmM4VE5IQVlKb3FYenYwL3BBVm9icmZ5dVJwRHhsdFErTkd6OVFXSUduUHFPNVQvZmJQUDBPSmVjRStFeEU0CkhqNlBhdGxrUUdHMmgzdWE3YkQ2ZGluOVV4YTdoQ2VlTVpNOUNNbnhLNHVuODUwampvYW4rNFd0aFlKK0JDSmsKU0VlZUxzRzczZFdJcks5OGZBQzNodFRldVBoWElvZUx2a0N3UGpCWEFvR0JBUFBteVJJRGs5bUF5M2ZINnBtVwplRWlqYlBWbFdDd3FjalI5ZjQ0L3duVEpha0h4cVVxRk04cTVLNnJJejdPMmMvcDdmTm83andrVHc0R0hIVWcrCjQyVkpGOXRrdnRDbEhOZ3l6cXNjT3FjN0p2ZDNyYnBFbGVpNGgyTHo4Z0RDNFo4WldqWDBBKzVTaTlQd3RMaFEKN3pBZEJUMHk5WjZuNGYxMVg0UWhKSkR1Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K diff --git a/examples/custom-resources/jwks/virtual-server-idp.yaml b/examples/custom-resources/jwks/virtual-server-idp.yaml new file mode 100644 index 0000000000..9bfb9a5f41 --- /dev/null +++ b/examples/custom-resources/jwks/virtual-server-idp.yaml @@ -0,0 +1,18 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: keycloak +spec: + host: keycloak.example.com + tls: + secret: tls-secret + redirect: + enable: true + upstreams: + - name: keycloak + service: keycloak + port: 8080 + routes: + - path: / + action: + pass: keycloak diff --git a/examples/custom-resources/jwks/virtual-server.yaml b/examples/custom-resources/jwks/virtual-server.yaml new file mode 100644 index 0000000000..4631e4f844 --- /dev/null +++ b/examples/custom-resources/jwks/virtual-server.yaml @@ -0,0 +1,16 @@ +apiVersion: k8s.nginx.org/v1 +kind: VirtualServer +metadata: + name: webapp +spec: + host: webapp.example.com + policies: + - name: jwt-policy + upstreams: + - name: webapp + service: webapp-svc + port: 80 + routes: + - path: / + action: + pass: webapp diff --git a/examples/custom-resources/jwks/webapp.yaml b/examples/custom-resources/jwks/webapp.yaml new file mode 100644 index 0000000000..31fde92a6e --- /dev/null +++ b/examples/custom-resources/jwks/webapp.yaml @@ -0,0 +1,32 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: webapp +spec: + replicas: 1 + selector: + matchLabels: + app: webapp + template: + metadata: + labels: + app: webapp + spec: + containers: + - name: webapp + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: webapp-svc +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: webapp diff --git a/internal/configs/version2/http.go b/internal/configs/version2/http.go index 9faf6634be..2097488685 100644 --- a/internal/configs/version2/http.go +++ b/internal/configs/version2/http.go @@ -350,9 +350,19 @@ func (rl LimitReqOptions) String() string { // JWTAuth holds JWT authentication configuration. type JWTAuth struct { - Secret string - Realm string - Token string + Secret string + Realm string + Token string + KeyCache string + JwksURI JwksURI +} + +// JwksURI defines the components of a JwksURI +type JwksURI struct { + JwksScheme string + JwksHost string + JwksPort string + JwksPath string } // BasicAuth refers to basic HTTP authentication mechanism options diff --git a/internal/configs/version2/nginx-plus.virtualserver.tmpl b/internal/configs/version2/nginx-plus.virtualserver.tmpl index a6ddb6de98..3459b1f2cc 100644 --- a/internal/configs/version2/nginx-plus.virtualserver.tmpl +++ b/internal/configs/version2/nginx-plus.virtualserver.tmpl @@ -57,6 +57,16 @@ match {{ $m.Name }} { {{ end }} {{ $s := .Server }} +{{ with $s.JWTAuth }} +{{ if .KeyCache }}proxy_cache_path /var/cache/nginx/jwk levels=1 keys_zone=jwk:1m max_size=10m; {{ end }} +{{ end }} + +{{ range $l := $s.Locations }} +{{ with $l.JWTAuth }} +{{ if .KeyCache }}proxy_cache_path /var/cache/nginx{{ $l.Path }}_jwks_uri levels=1 keys_zone={{ $l.Path }}_jwks_uri:1m max_size=10m; {{ end }} +{{ end }} +{{ end }} + server { listen 80{{ if $s.ProxyProtocol }} proxy_protocol{{ end }}; {{ if not $s.DisableIPV6 }}listen [::]:80{{ if $s.ProxyProtocol }} proxy_protocol{{ end }};{{ end }} @@ -163,7 +173,26 @@ server { {{ with $s.JWTAuth }} auth_jwt "{{ .Realm }}"{{ if .Token }} token={{ .Token }}{{ end }}; - auth_jwt_key_file {{ .Secret }}; + {{ if .Secret}}auth_jwt_key_file {{ .Secret }};{{ end }} + {{ if .JwksURI.JwksHost }} + {{ if .KeyCache }}auth_jwt_key_cache {{ .KeyCache }};{{ end }} + auth_jwt_key_request /_jwks_uri_server; + + location = /_jwks_uri_server { + internal; + proxy_method GET; + {{ if .KeyCache }} + proxy_cache jwk; + proxy_cache_valid 200 12h; + {{ end }} + {{ with $s.JWTAuth.JwksURI }} + proxy_set_header Host {{ .JwksHost }}; + set $idp_backend {{ .JwksHost }}; + proxy_pass {{ .JwksScheme}}://$idp_backend{{ if .JwksPort }}:{{ .JwksPort }}{{ end }}{{ .JwksPath }}; + {{ end }} + } + + {{ end }} {{ end }} {{ with $s.BasicAuth }} @@ -340,7 +369,25 @@ server { {{ with $l.JWTAuth }} auth_jwt "{{ .Realm }}"{{ if .Token }} token={{ .Token }}{{ end }}; - auth_jwt_key_file {{ .Secret }}; + {{ if .Secret}}auth_jwt_key_file {{ .Secret }};{{ end }} + {{ if .JwksURI.JwksHost }} + {{ if .KeyCache }}auth_jwt_key_cache {{ .KeyCache }};{{ end }} + auth_jwt_key_request {{ $l.Path }}_jwks_uri; + + location = {{ $l.Path }}_jwks_uri { + internal; + proxy_method GET; + {{ if .KeyCache }} + proxy_cache {{ $l.Path }}_jwks_uri; + proxy_cache_valid 200 12h; + {{ end }} + {{ with $l.JWTAuth.JwksURI }} + proxy_set_header Host {{ .JwksHost }}; + set $idp_backend {{ .JwksHost }}; + proxy_pass {{ .JwksScheme}}://$idp_backend{{ if .JwksPort }}:{{ .JwksPort }}{{ end }}{{ .JwksPath }}; + {{ end }} + } + {{ end }} {{ end }} {{ with $l.BasicAuth }} diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go index 46c37caa99..3f8687368a 100644 --- a/internal/configs/virtualserver.go +++ b/internal/configs/virtualserver.go @@ -2,6 +2,7 @@ package configs import ( "fmt" + "net/url" "strconv" "strings" @@ -813,27 +814,46 @@ func (p *policiesCfg) addJWTAuthConfig( res.addWarningf("Multiple jwt policies in the same context is not valid. JWT policy %s will be ignored", polKey) return res } + if jwtAuth.Secret != "" { + jwtSecretKey := fmt.Sprintf("%v/%v", polNamespace, jwtAuth.Secret) + secretRef := secretRefs[jwtSecretKey] + var secretType api_v1.SecretType + if secretRef.Secret != nil { + secretType = secretRef.Secret.Type + } + if secretType != "" && secretType != secrets.SecretTypeJWK { + res.addWarningf("JWT policy %s references a secret %s of a wrong type '%s', must be '%s'", polKey, jwtSecretKey, secretType, secrets.SecretTypeJWK) + res.isError = true + return res + } else if secretRef.Error != nil { + res.addWarningf("JWT policy %s references an invalid secret %s: %v", polKey, jwtSecretKey, secretRef.Error) + res.isError = true + return res + } - jwtSecretKey := fmt.Sprintf("%v/%v", polNamespace, jwtAuth.Secret) - secretRef := secretRefs[jwtSecretKey] - var secretType api_v1.SecretType - if secretRef.Secret != nil { - secretType = secretRef.Secret.Type - } - if secretType != "" && secretType != secrets.SecretTypeJWK { - res.addWarningf("JWT policy %s references a secret %s of a wrong type '%s', must be '%s'", polKey, jwtSecretKey, secretType, secrets.SecretTypeJWK) - res.isError = true - return res - } else if secretRef.Error != nil { - res.addWarningf("JWT policy %s references an invalid secret %s: %v", polKey, jwtSecretKey, secretRef.Error) - res.isError = true + p.JWTAuth = &version2.JWTAuth{ + Secret: secretRef.Path, + Realm: jwtAuth.Realm, + Token: jwtAuth.Token, + } return res - } + } else if jwtAuth.JwksURI != "" { + uri, _ := url.Parse(jwtAuth.JwksURI) + + JwksURI := &version2.JwksURI{ + JwksScheme: uri.Scheme, + JwksHost: uri.Hostname(), + JwksPort: uri.Port(), + JwksPath: uri.Path, + } - p.JWTAuth = &version2.JWTAuth{ - Secret: secretRef.Path, - Realm: jwtAuth.Realm, - Token: jwtAuth.Token, + p.JWTAuth = &version2.JWTAuth{ + JwksURI: *JwksURI, + Realm: jwtAuth.Realm, + Token: jwtAuth.Token, + KeyCache: jwtAuth.KeyCache, + } + return res } return res } diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index 4d193abd75..7b93b36ee7 100644 --- a/internal/configs/virtualserver_test.go +++ b/internal/configs/virtualserver_test.go @@ -2953,6 +2953,78 @@ func TestGeneratePolicies(t *testing.T) { }, msg: "jwt reference", }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "jwt-policy-2", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/jwt-policy-2": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "jwt-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + JWTAuth: &conf_v1.JWTAuth{ + Realm: "My Test API", + JwksURI: "https://idp.example.com:443/keys", + KeyCache: "1h", + }, + }, + }, + }, + expected: policiesCfg{ + JWTAuth: &version2.JWTAuth{ + Realm: "My Test API", + JwksURI: version2.JwksURI{ + JwksScheme: "https", + JwksHost: "idp.example.com", + JwksPort: "443", + JwksPath: "/keys", + }, + KeyCache: "1h", + }, + }, + msg: "Basic jwks example", + }, + { + policyRefs: []conf_v1.PolicyReference{ + { + Name: "jwt-policy-2", + Namespace: "default", + }, + }, + policies: map[string]*conf_v1.Policy{ + "default/jwt-policy-2": { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "jwt-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + JWTAuth: &conf_v1.JWTAuth{ + Realm: "My Test API", + JwksURI: "https://idp.example.com/keys", + KeyCache: "1h", + }, + }, + }, + }, + expected: policiesCfg{ + JWTAuth: &version2.JWTAuth{ + Realm: "My Test API", + JwksURI: version2.JwksURI{ + JwksScheme: "https", + JwksHost: "idp.example.com", + JwksPort: "", + JwksPath: "/keys", + }, + KeyCache: "1h", + }, + }, + msg: "Basic jwks example, no port in JwksURI", + }, { policyRefs: []conf_v1.PolicyReference{ { diff --git a/internal/k8s/controller.go b/internal/k8s/controller.go index 382365ae6d..3ceae7e5f4 100644 --- a/internal/k8s/controller.go +++ b/internal/k8s/controller.go @@ -3223,6 +3223,10 @@ func (lbc *LoadBalancerController) addJWTSecretRefs(secretRefs map[string]*secre continue } + if pol.Spec.JWTAuth.JwksURI != "" { + continue + } + secretKey := fmt.Sprintf("%v/%v", pol.Namespace, pol.Spec.JWTAuth.Secret) secretRef := lbc.secretStore.GetSecret(secretKey) diff --git a/internal/k8s/controller_test.go b/internal/k8s/controller_test.go index 4a308c27c1..4629e2f22f 100644 --- a/internal/k8s/controller_test.go +++ b/internal/k8s/controller_test.go @@ -2006,6 +2006,26 @@ func TestAddJWTSecrets(t *testing.T) { wantErr: false, msg: "test getting valid secret", }, + { + policies: []*conf_v1.Policy{ + { + ObjectMeta: meta_v1.ObjectMeta{ + Name: "jwt-policy", + Namespace: "default", + }, + Spec: conf_v1.PolicySpec{ + JWTAuth: &conf_v1.JWTAuth{ + Realm: "My API", + JwksURI: "https://idp.com/token", + KeyCache: "1h", + }, + }, + }, + }, + expectedSecretRefs: map[string]*secrets.SecretReference{}, + wantErr: false, + msg: "test getting valid policy using JwksUri", + }, { policies: []*conf_v1.Policy{}, expectedSecretRefs: map[string]*secrets.SecretReference{}, diff --git a/pkg/apis/configuration/v1/types.go b/pkg/apis/configuration/v1/types.go index 01c42d385c..669af9e273 100644 --- a/pkg/apis/configuration/v1/types.go +++ b/pkg/apis/configuration/v1/types.go @@ -438,9 +438,11 @@ type RateLimit struct { // JWTAuth holds JWT authentication configuration. type JWTAuth struct { - Realm string `json:"realm"` - Secret string `json:"secret"` - Token string `json:"token"` + Realm string `json:"realm"` + Secret string `json:"secret"` + Token string `json:"token"` + JwksURI string `json:"jwksURI"` + KeyCache string `json:"keyCache"` } // BasicAuth holds HTTP Basic authentication configuration diff --git a/pkg/apis/configuration/validation/policy.go b/pkg/apis/configuration/validation/policy.go index 846fb03f6a..2a282c8582 100644 --- a/pkg/apis/configuration/validation/policy.go +++ b/pkg/apis/configuration/validation/policy.go @@ -159,16 +159,32 @@ func validateJWT(jwt *v1.JWTAuth, fieldPath *field.Path) field.ErrorList { allErrs = append(allErrs, validateRealm(jwt.Realm, fieldPath.Child("realm"))...) } - if jwt.Secret == "" { - return append(allErrs, field.Required(fieldPath.Child("secret"), "")) + if jwt.Secret == "" && jwt.JwksURI == "" { + return append(allErrs, field.Required(fieldPath.Child("secret"), "either Secret or JwksURI must be present")) + } + + if jwt.Secret != "" && jwt.JwksURI != "" { + return append(allErrs, field.Forbidden(fieldPath.Child("secret"), "only either of Secret or JwksURI can be used")) } + + if jwt.KeyCache != "" && jwt.JwksURI == "" { + return append(allErrs, field.Required(fieldPath.Child("jwksURI"), "jwksURI must be present when keyCache is used.")) + } + allErrs = append(allErrs, validateSecretName(jwt.Secret, fieldPath.Child("secret"))...) allErrs = append(allErrs, validateJWTToken(jwt.Token, fieldPath.Child("token"))...) + if jwt.JwksURI != "" { + allErrs = append(allErrs, validateURL(jwt.JwksURI, fieldPath.Child("jwksURI"))...) + } + + if jwt.KeyCache != "" { + allErrs = append(allErrs, validateTime(jwt.KeyCache, fieldPath.Child("keyCache"))...) + } + return allErrs } - func validateBasic(basic *v1.BasicAuth, fieldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} diff --git a/pkg/apis/configuration/validation/policy_test.go b/pkg/apis/configuration/validation/policy_test.go index 47b063a76a..3b890147b7 100644 --- a/pkg/apis/configuration/validation/policy_test.go +++ b/pkg/apis/configuration/validation/policy_test.go @@ -420,6 +420,15 @@ func TestValidateJWT(t *testing.T) { }, msg: "jwt with token", }, + { + jwt: &v1.JWTAuth{ + Realm: "My Product API", + Token: "$cookie_auth_token", + JwksURI: "https://idp.com/token", + KeyCache: "1h", + }, + msg: "jwt with jwksURI", + }, } for _, test := range tests { allErrs := validateJWT(test.jwt, field.NewPath("jwt")) @@ -439,7 +448,15 @@ func TestValidateJWTFails(t *testing.T) { jwt: &v1.JWTAuth{ Realm: "My Product API", }, - msg: "missing secret", + msg: "missing secret and jwksURI", + }, + { + jwt: &v1.JWTAuth{ + Realm: "My Product API", + Secret: "my-jwk", + JwksURI: "https://idp.com/token", + }, + msg: "both secret and jwksURI present", }, { jwt: &v1.JWTAuth{ @@ -483,6 +500,38 @@ func TestValidateJWTFails(t *testing.T) { }, msg: "invalid variable use in realm without curly braces", }, + { + jwt: &v1.JWTAuth{ + Realm: "My Product api", + Secret: "my-jwk", + KeyCache: "1h", + }, + msg: "using KeyCache with Secret", + }, + { + jwt: &v1.JWTAuth{ + Realm: "My Product api", + JwksURI: "https://idp.com/token", + KeyCache: "1k", + }, + msg: "invalid suffix for KeyCache", + }, + { + jwt: &v1.JWTAuth{ + Realm: "My Product api", + JwksURI: "https://idp.com/token", + KeyCache: "oneM", + }, + msg: "invalid unit for KeyCache", + }, + { + jwt: &v1.JWTAuth{ + Realm: "My Product api", + JwksURI: "myidp", + KeyCache: "1h", + }, + msg: "invalid JwksURI", + }, } for _, test := range tests { allErrs := validateJWT(test.jwt, field.NewPath("jwt"))