31 May 2021 ~ 4 min read

Part II - Integrating Keycloak OIDC with our Envoy API Gateway

See Part I - Implementing API Gateway using Envoy for creation of the initial API gateway.

This post will demonstrate:

  • Integration with Keycloak using the OpenId Connect protocol to allow the gateway to handle user authentication.
  • Once authenticated, decode the JWT token and pull out the unique identity id (sub), add it to the request headers for backend services to use.

The Envoy configuration will be broken down to help explain it, so feel free to check out the full envoy.yaml at any point. It will be worth reviewing it at the end of the post to see how it is combined.

Three http_filters will be added. These run on the application layer (L7) so this basically means the HTTP protocol i.e. what the end user sees. We're interested in these filters because I want to manipulate the HTTP request that our upstream backend services receive. Envoy filers are hierarchical and based on ordered network layers (L4 before L7). The focus here is on http filters.

The job of each filter is as follows:

  • OAuth2 - Authenticate and return the JWT token to the client
  • JWT - When a client request is made with a token, decode it and add it to Envoy's meta data (payload_in_metadata)
  • Lua - Using Envoy's meta data, pull out the subject claim (sub) from the token and add the value to the request headers to pass to upstream services

OAuth2 filter

To configure this filter, common information about the identity endpoints such as token endpoint url, client-id and secret are required. Read the docs for more information. It's worth noting the absence of any HTTP configuration and CSRF handling. This configuration is not suitable for production and only development purposes.

There is also a forward_bearer_token config option which will pass the token to upstream services which might be useful if you want services to handle RBAC type of scenarios.

- name: envoy.filters.http.oauth2
    "@type": type.googleapis.com/envoy.extensions.filters.http.oauth2.v3alpha.OAuth2
        cluster: keycloak
        timeout: 5s
      redirect_uri: "http://%REQ(:authority)%/callback"
          exact: /callback
          exact: /signout
        client_id: "burger-shop"
          name: token
            path: "/etc/envoy/token-secret.yaml"
          name: hmac
            path: "/etc/envoy/hmac-secret.yaml"
        - openid
        - profile
        - email
        - roles

JWT filter

The purpose of using this is purely to decode the token to gain access to it's values and add data needed to request headers. An important part is - allow_missing_or_failed: {} which means we do not want to fail the request in this filter, we're letting the oauth2 filter handle that side of things.

- name: envoy.filters.http.jwt_authn
    "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
        issuer: http://kubernetes.docker.internal:8080/auth/realms/master
          - master-realm
          - account
        payload_in_metadata: jwt_payload
        forward_payload_header: x-jwt-payload
            cluster: keycloak
            timeout: 5s
      - match:
          prefix: /api
              - provider_name: oidc_provider
              - allow_missing_or_failed: {}

Lua filter

Next up is the Lua filter. This allows us to write some simple logic to pull out elements of the JWT token I added to Envoy's meta data.
As you'll see in the code below we're pulling out the "sub" field from the "jwt_payload" I stored in the previous filter (payload_in_metadata: jwt_payload).

The general idea behind behind this is to only pass information upstream that is need and to not pass the whole token around all services if they don't need it. This is preferable from a security point of view. Depending on your situation you may need to forward the whole token.

- name: envoy.filters.http.lua
    "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
    inline_code: |
      function envoy_on_request(request_handle)
        local payload = request_handle:streamInfo():dynamicMetadata():get("envoy.filters.http.jwt_authn")["jwt_payload"]
        request_handle:headers():add("jwt-extracted-sub", payload.sub)    

      function envoy_on_response(response_handle)

- name: envoy.router

Adding the Keycloak service cluster

As demonstrated in the last post, a cluster for our Keycloak service is needed. Below is an extract of that.

  - name: keycloak
    connect_timeout: 0.25s
    type: strict_dns
    lb_policy: round_robin
      cluster_name: keycloak
        - lb_endpoints:
            - endpoint:
                  socket_address: { address: keycloak, port_value: 8080 }

And that is it. You'll now be able to demonstrate an authentication mechanism and access to backend services behind the gateway and pass data to those services such as a unique user id or claims. To see a full and working example, the code is all available here.

Headshot of Jason Watson

Hi, I'm Jason. I'm a software engineer and architect. You can follow me on Twitter, see some of my work on GitHub, or read more about me on my website.