JWT authorization with NGINX Ingress Controller

Summary

JWT validation, authentication, and authorization using NGINX Plus is a great method for offloading JWT authentication at a proxy before your web application and API server receives a request. This article discusses how to achieve the same configuration when NGINX Plus is running as an Ingress Controller in Kubernetes (K8s).

Introduction

I've had two customers with similar requirements recently, and after searching the Internet and not finding any documentation on how to configure this in K8s, this article was born. I'll start with my customers' requirements, and then show how to achieve their requirements in K8s.

Requirements

In the cases of both of my customers, they had an external system that would issue a JWT to a client. That JWT would then be presented by the client to NGINX. Both customers wanted to verify the JWT, extract arbitrary claims, and forward the values of those claims as headers to the upstream servers. One of them wanted to also allow/disallow traffic based on whether the user was a member of a certain group, and the other customer wanted to insert a cookie in the request to the web server that contained some of the values extracted from the JWT claims. So we'll perform both of those things with our example today. 

To summarize, the requirements from my customers were to:

  1. Validate the signed JWT presented by a client
  2. Extract some of the values of the claims in the JWT
  3. Insert a request header that contained a value of one of these claims
  4. Insert a request header that contained all values from a claim that was an array (groups).
  5. Allow/disallow access based on the value of one of these claims

     

NGINX Plus and the Kubernetes requirement

Firstly, why NGINX Plus and not open source NGINX? Simple: JWT authentication is a feature that comes with NGINX Plus. Open source does not have this feature.

Next, why K8s? My customers wanted to use NGINX Plus as an Ingress Controller and apply JWT auth there, as opposed to an installation of NGINX Plus on, for example, a Linux VM or in a standalone container. This was a challenge, and the reason behind this article. We achieve this functionality using the tools that K8s provides, mainly ConfigMaps and CRD's.

Me figuring out how to apply Liam and Alan's advice to Kubernetes

 JWT authentication in NGINX (outside of K8s)

The official documentation is Setting up JWT Authentication | NGINX Plus, but I leaned heavily on two very good articles with examples from Liam Crilly and Alan Murphy. Liam's example uses JWT's for logging and rate limiting. Alan's example performs authorization (allows access to a site only if the JWT claim of uid is 222). Either of these two articles should be enough for anyone looking to perform JWT auth using NGINX Plus outside of K8s.

Solution for JWT auth using NGINX Ingress Controller

NGINX Ingress Controller is an implementation of a Kubernetes Ingress Controller for NGINX and NGINX Plus. But you don't configure NGINX with typical config files as outlined in the previously linked articles. Rather, you configure NGINX with K8s resources.

Which resources in K8s? Traditionally it was an Ingress resource, optionally with annotations, and a ConfigMap. These resources would be monitored by NGINX I.C. and then NGINX would be configured based on the values in these resources. 

As an alternative to the Ingress, NGINX Ingress Controller supports CRD's, two of which are the VirtualServer and VirtualServerRoute resources. They enable use cases not supported with the Ingress resource, such as traffic splitting and advanced content-based routing. Since CRD's are newer, I'll demonstrate this solution using those.

My JWT

This is a JWT I generated using https://jwt.io. An example JWT generated at jwt.io. JWT's should be kept secure in production. Never share your JWT.

The payload of this JWT, which is the section containing the claims I am interested in, is below. Note there are multiple claims. One of them, groups, is an array, and the others are strings. The exp claim is a reserved claim with NumericDate value (epoch time, extra points if you can tell me what date is represented by 1924991999 ).

{
"exp": "1924991999",
"name": "Michael O'Leary",
"groups": [
"F5Employees",
"DevCentralAuthors"
]
}

You can see from my screenshot that the secret for the signature of this JWT is nginx123. This is something NGINX will need to know if we want to validate the signature of the JWT, so keep this in mind for later.

The base64 encoded value of this JWT is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOiIxOTI0OTkxOTk5IiwibmFtZSI6Ik1pY2hhZWwgTydMZWFyeSIsImdyb3VwcyI6WyJGNUVtcGxveWVlcyIsIkRldkNlbnRyYWxBdXRob3JzIl19.blqHw-gRXoPQfAtPgyPuFOozUp-MZWmpCXfQtkIJCFo

Solving for our requirements

Non-K8s implementations

As you can see from Liam's example and Alan's example, use the following lines in NGINX config to set up JWT auth.

  • In the http context, we'll add a map directive. This creates a new variable ($valid_user) whose value depends on values of one or more of the source variables specified in the first parameter.
  • Because one of our claims (groups) is an array, we also need to add the directive auth_jwt_claim_set which will set a variable to hold the value of a claim. We don't need to do this for each claim, but for values that are an array, the variable keeps a list of array elements separated by commas.
    map $jwt_claim_name $valid_user { 
          "Michael O'Leary" 1; 
        }
    auth_jwt_claim_set $jwt_groups groups; # this translates the array value into a comma-separated single string​
    
  • in the location context, you need to add a few lines, depending on what you're looking to achieve
    auth_jwt "hello";
    auth_jwt_key_file /etc/nginx/jwt_secret.jwk ;
    proxy_set_header name $jwt_claim_name ;
    proxy_set_header groups $jwt_groups ;​
    auth_jwt_require $valid_user;
  • given the config above, create a file at /etc/nginx/jwt_scret.jwk with this content. Note that bmdpbngxMjM is the base64-encoded value for nginx123.
    {"keys":
        [{
            "k":"bmdpbngxMjM",
            "kty":"oct"
        }]
    }

K8s implementation

Here's how to do the same, using K8s resources. I will heavily comment a few lines for the sake of explaining their importance.

My demo app is "demo.my-f5.com" where "/headers" is a page that displays all request headers received by the server. I want to only allow authorized users (those with the name Michael O'Leary) to see the "/headers" location, and I also want to add two headers to these requests: X-jwt-claim-name and X-jwt-claim-groups. The values of these will come from the JWT.

Firstly, we'll use a VirtualServer CRD, which roughly translates to a NGINX server context. Here is an example of my VirtualServer CRD.

apiVersion: k8s.nginx.org/v1
kind: VirtualServer
metadata:
  name: demo
  namespace: f5demoapp
spec:
  host: demo.my-f5.com
  upstreams:
  - name: demo
    service: f5-demo-httpd
    port: 8080
  routes:
  - path: /
    action:
      proxy:
        upstream: demo
  - path: /headers
    route: headers # this line tells NGINX to expect a VirtualServerRoute called headers and that it should include a location for /headers. This links our VirtualServer and our VirtualServerRoute

We'll also use a VirtualServerRoute CRD, which roughly translates to a NGINX location context. Here is an example:

apiVersion: k8s.nginx.org/v1
kind: VirtualServerRoute
metadata:
  name: headers
  namespace: f5demoapp
spec:
  host: demo.my-f5.com
  upstreams:
  - name: demo
    service: f5-demo-httpd
    port: 8080
  subroutes:
  - path: /headers
    policies:
    - name: jwtpolicy
    action:
      proxy:
        upstream: demo
        requestHeaders:
          set:
          - name: X-jwt-claim-name
            value: ${jwt_claim_name}
    location-snippets: | 
      auth_jwt_require $valid_user;
      #with the line above, only requests where the JWT claim name is Michael O'Leary will be allowed to access this location. This is configured in the ConfigMap resource.
      proxy_set_header X-jwt-claim-groups $jwt_groups;
      #in the line above, I am using a location-snippet to set a header, instead of the Action.Proxy.RequestHeaders.Set.Header value. This is because the values for these headers that can be inserted using Action.Proxy.RequestHeaders.Set are limited to supported NGINX variables. https://docs.nginx.com/nginx-ingress-controller/configuration/virtualserver-and-virtualserverroute-resources/#actionproxyrequestheaderssetheader

We also will use a ConfigMap, which is referenced when running the NGINX I.C. and configures NGINX, including the http context. Here is an example.

kind: ConfigMap
apiVersion: v1
metadata:
  name: nginx-plus-config
  namespace: nginx-plus-ingress
data:
  proxy-connect-timeout: "10s"
 #proxy-protocol: "True"
 #real-ip-header: "proxy_protocol"
 #set-real-ip-from: "0.0.0.0/0"
  http-snippets: |
    map $jwt_claim_name $valid_user { 
      "Michael O'Leary" 1; 
    }
    #with the line above, we have configured $valid_user to only be 1 if the JWT claim name is Michael O'Leary. We could add additional lines to add more users also. We could also require the group claim contains a given group. This is then enforced in the VirtualServerRoute resource, linked by the $valid_user variable.
    auth_jwt_claim_set $jwt_groups groups; 
    #in the line above, we are setting a variable called $jwt_groups that will be a string, separated by commas. It is populated by the groups claim from the JWT, which is an array. This variable, $jwt_groups, is used to populate a header value in the VirtualServerRoute resource.

Notice that above we used snippets, which allow us to insert raw NGINX config into these CRD's, in cases where the YAML-based CRD doesn't meet our requirement. So, http-snippets, server-snippets, and location-snippets insert config into the http, server,  the location contexts. Here's an example that uses all three. As we see from our example above, we need to use http-snippets and location-snippets.

See above that the VirtualServerRoute referenced a CRD of type Policy to enforce JWT authentication. Here is an example of the Policy CRD that we will use.

apiVersion: k8s.nginx.org/v1
kind: Policy
metadata:
  name: jwtpolicy
  namespace: f5demoapp
spec:
  jwt:
    secret: jwk-secret
    realm: MyDemo
    token: $cookie_jwt

The Policy resource must reference a Secret, which must be of type: nginx.org/jwk. The following is an example of the Secret. I found this very difficult to research, so please note the value is a base64-encoded version of the secret file referenced earlier. The type of nginx.org/jwk was critical but hard for me to uncover when researching. (Although upon reading again, it is documented.)

apiVersion: v1
kind: Secret
metadata:
  name: jwk-secret
  namespace: f5demoapp
type: nginx.org/jwk
data:
  jwk: eyJrZXlzIjoKICAgIFt7CiAgICAgICAgImsiOiJibWRwYm5neE1qTSIsCiAgICAgICAgImt0eSI6Im9jdCIKICAgIH1dCn0K

This should be all you need to achieve the same thing that Liam and Alan have documented, but in Kubernetes! Now we've configured NGINX Ingress Controller in the same was as the earlier example, but in Kubernetes:

Let's revisit our requirements, now with our solutions in green:

  1. Validate the signed JWT presented by a client. Achieved by the Policy and Secret resources.
  2. Extract some of the values of the claims in the JWT. Done with ConfigMap and other resources.
  3. Insert a request header that contained a value of one of these claims. Done in VirtualServerRoute.
  4. Insert a request header that contained all values from a claim that was an array (groups). Also done in VirtualServerRoute, but using the location-snippets.
  5. Allow/disallow access based on the value of one of these claims. Done in VirtualServerRoute with auth_jwt_require and ConfigMap.

I've used the files from the article to deploy the docker image at f5devcental/f5-hello-world and display these headers using the JWT from this article:

Summary

NGINX Ingress Controller using NGINX Plus allows JWT authentication for your web apps and API's running inside K8s. I hope this article helps anyone looking to achieve this. If you need help, please reach out in comments or to your F5/NGINX account team. Thanks for reading!

Related articles

 

Published Jul 12, 2023
Version 1.0
No CommentsBe the first to comment