Sitemap

Using Kong to authorise requests and verify tokens

--

Introduction

Authentication, token validation, access control are typical cross functional requirements that don’t add any immediate value to services — offloading them to an API Gateway like Kong for on-premise or Kubernetes-based deployments allows the backend services to concentrate on business functionality and increases separation of concerns. In addition to that, Kong allows users to develop their own plugins in Lua or Go which enables all kinds of custom use cases to be handled in the API Gateway before a request even reaches the backend. In the following, Kong will be used to secure access to an NGINX web-server deployed in a Kubernetes cluster in combination with an existing JWT plugin to verify token correctness (in the following provided by Keycloak deployed into the same cluster) and a custom plugin that extracts the Keycloak roles from the JWT allowing the backend to get access to the roles without the need to parse the tokens at all.

Preconditions

The following has been tested in a Kubernetes cluster provided by Docker Desktop locally with an Ingress controller deployed making the cluster available under http://localhost and a Docker registry running under http://localhost:5000.

Phase 1: Keycloak and Kong (preparation)

Before we can secure Kong and make use of the JWT plugin we need to set up the source of the JWT: Keycloak. For this, we build a preliminary custom Kong image (for all referenced files see below “Appendix”) with

(cd kong-setup \
&& docker build . -t localhost:5000/kong-local \
&& docker push localhost:5000/kong-local)

and deploy Keycloak and Kong into the cluster

kubectl apply \
-f kong.deployment.yaml \
-f kong.service.yaml \
-f kong.ingress.yaml \
-f keycloak.deployment.yaml \
-f keycloak.service.yaml

Now we can access Keycloak under http://localhost/auth using the credentials set in keycloak.deployment.yaml and create a new client with “Client authentication” and “Authorization” enabled (taking note of the Client ID and Secret for later). Now to finish the Kong setup, we run

cat <<EOF | openssl x509 -pubkey -noout > key.pem
-----BEGIN CERTIFICATE-----
$(curl http://localhost/auth/realms/master/protocol/openid-connect/certs \
| jq -r '.keys[] \
| select(.alg == "RS256").x5c[0]')
-----END CERTIFICATE-----
EOF

to extract Keycloak’s public key (which we need to verify that a JWT is signed by Keycloak as Keycloak is using the respective private key counterpart to sign the JWT) followed by

RSA_PUBLIC_KEY=$(awk '{printf "%s\\n", $0}' key.pem) \
envsubst < kong/kong.template.yaml > kong/kong.yaml

to prepare the finalised Kong setup.

Phase 2: Kong and NGINX

Now that Keycloak is set up and Kong is configured we can compile the custom Kong plugin and build the final Kong image

(cd kong/keycloak-resource_access-roles-plugin \
&& env GOOS=linux GOARCH=amd64 go build .) \
&& (cd kong \
&& docker build . -t localhost:5000/kong-local \
&& docker push localhost:5000/kong-local)

and both restart Kong and also install NGINX as our “backend”

kubectl delete po $(kubectl get pods -o custom-columns=PodName:.metadata.name,PodUID:.metadata.uid \
| grep kong \
| awk '{print $1}') \
&& kubectl apply -f nginx.deployment.yaml \
-f nginx.service.yaml

Phase 3: Demo

As Keycloak, Kong and NGINX are now running to demonstrate how the set up is working we first connect to NGINX and run tcpdump to capture the incoming HTTP requests including all headers

kubectl exec $(kubectl get pods -o custom-columns=PodName:.metadata.name,PodUID:.metadata.uid \
| grep nginx \
| awk '{print $1}') \
-- bash -c "apt-get update; apt-get install tcpdump -y; tcpdump -A 'tcp port 80'"

Then, without an access token perform a request to Kong

curl -v http://localhost/api

which is expected to fail with a HTTP 401 (Unauthorized) and the response {“message”:”Unauthorized”}. So we obtain a valid access token via

CLIENT_ID=... CLIENT_SECRET=... URL=http://localhost/auth/realms/master/protocol/openid-connect/token \
ACCESS_TOKEN=$(curl -d "grant_type=client_credentials&client_id="$CLIENT_ID"&client_secret="$CLIENT_SECRET $URL | jq -r ."access_token")

(use Client ID and Secret from earlier) followed by

curl -v -H "Authorization: Bearer "$ACCESS_TOKEN http://localhost/api

Get Johannes Geiger’s stories in your inbox

Join Medium for free to get updates from this writer.

and the console window running tcpdump should display the incoming HTTP header Keycloak-Resource-Access-Client-Roles: uma_protection as the default role. To further verify that the Go plugin is working as intended we can create a new role for our Client in Keycloak and assign it via Service account roles and on requesting a new access token and repeating the authenticated request above we should see the new role logged as well.

Summary

Running Kong as an API Gateway in front of other services in Kubernetes is a great way to separate concerns and reduce cognitive load from the backend services. Instead of every service having to verify tokens, extract roles from tokens or perform other cross-functional tasks like adding Correlation ID, logging certain information or masking data on their own, Kong can be leveraged and the ability to easily add custom plugins written in Lua or Go make it even more flexible.

Appendix

kong-setup/Dockerfile:

FROM kong/kong-gateway:latest

COPY kong.yaml /

RUN cp /etc/kong/kong.conf.default /etc/kong/kong.conf

kong-setup/kong.yaml:

_format_version: "1.1"
services:
- name: keycloak
url: http://keycloak
routes:
- name: keycloak
paths:
- /auth

kong.deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: kong
labels:
app: kong
spec:
replicas: 1
selector:
matchLabels:
app: kong
template:
metadata:
labels:
app: kong
spec:
containers:
- name: kong
image: localhost:5000/kong-local
env:
- name: KONG_DATABASE
value: "off"
- name: KONG_DECLARATIVE_CONFIG
value: "kong.yaml"
ports:
- containerPort: 8000

kong.service.yaml:

apiVersion: v1
kind: Service
metadata:
name: kong
spec:
selector:
app: kong
ports:
- protocol: TCP
port: 80
targetPort: 8000

kong.ingress.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kong
spec:
ingressClassName: nginx
rules:
- host: localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: kong
port:
number: 80

keycloak.deployment.yaml:

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
env:
- name: KC_HOSTNAME
value: http://localhost/auth
- name: KEYCLOAK_ADMIN
value: admin
- name: KEYCLOAK_ADMIN_PASSWORD
value: admin
ports:
- containerPort: 8080
args:
- start-dev

keycloak.service.yaml:

apiVersion: v1
kind: Service
metadata:
name: keycloak
spec:
selector:
app: keycloak
ports:
- protocol: TCP
port: 80
targetPort: 8080

kong/keycloak-resource_access-roles-plugin/go.mod:

module keycloak-resource_access-roles-plugin

go 1.22.1

require (
github.com/Kong/go-pdk v0.11.0
github.com/golang-jwt/jwt v3.2.2+incompatible
)

require (
github.com/ugorji/go/codec v1.2.12 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)

kong/keycloak-resource_access-roles-plugin/main.go:

package main

import (
"github.com/Kong/go-pdk"
"github.com/Kong/go-pdk/server"
"github.com/golang-jwt/jwt"
"log"
)

type Configuration struct {
HeaderName string `json:"header_name"`
}

func New() interface{} {
return &Configuration{}
}

const Version = "1.0.0"
const Priority = 1

func getToken(kong *pdk.PDK) (*jwt.Token, error) {
header, err := kong.Request.GetHeader(`Authorization`)
if err != nil {
return nil, err
}

tokenString := header[len(`Bearer `):]
parser := jwt.Parser{
ValidMethods: nil,
UseJSONNumber: false,
SkipClaimsValidation: true,
}
token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{})
if err != nil {
return nil, err
}

return token, nil
}

func (conf *Configuration) clearHeader(kong *pdk.PDK) error {
err := kong.ServiceRequest.ClearHeader(conf.HeaderName)
if err != nil {
return err
}

return nil
}

func (conf *Configuration) setHeaders(kong *pdk.PDK, claims jwt.MapClaims) error {
clientId := claims[`client_id`].(string)
resourceAccess := claims[`resource_access`].(map[string]interface{})
resourceAccessClient := resourceAccess[clientId].(map[string]interface{})
resourceAccessClientRoles := resourceAccessClient[`roles`].([]interface{})

for _, role := range resourceAccessClientRoles {
err := kong.ServiceRequest.AddHeader(conf.HeaderName, role.(string))
if err != nil {
return err
}
}

return nil
}

func (conf *Configuration) Access(kong *pdk.PDK) {
token, err := getToken(kong)
if err != nil {
log.Println(err)
kong.Response.ExitStatus(500)
}

err = conf.clearHeader(kong)
if err != nil {
log.Println(err)
kong.Response.ExitStatus(500)
}

if claims, ok := token.Claims.(jwt.MapClaims); ok {
err = conf.setHeaders(kong, claims)
if err != nil {
log.Println(err)
kong.Response.ExitStatus(500)
}
}
}

func main() {
err := server.StartServer(New, Version, Priority)
if err != nil {
panic(err)
}
}

kong/Dockerfile:

FROM kong/kong-gateway:latest

COPY keycloak-resource_access-roles-plugin/keycloak-resource_access-roles-plugin /usr/local/kong/keycloak-resource_access-roles-plugin
ENV KONG_PLUGINS="bundled,keycloak-resource_access-roles-plugin"
ENV KONG_PLUGINSERVER_NAMES="keycloak-resource_access-roles-plugin"
ENV KONG_PLUGINSERVER_KEYCLOAK_RESOURCE_ACCESS_ROLES_PLUGIN_START_CMD="/usr/local/kong/keycloak-resource_access-roles-plugin"
ENV KONG_PLUGINSERVER_KEYCLOAK_RESOURCE_ACCESS_ROLES_PLUGIN_QUERY_CMD="/usr/local/kong/keycloak-resource_access-roles-plugin -dump"

COPY kong.yaml /

RUN cp /etc/kong/kong.conf.default /etc/kong/kong.conf

kong/kong.template.yaml:

_format_version: "1.1"
consumers:
- username: api
jwt_secrets:
- consumer: api
key: http://localhost/auth/realms/master
algorithm: RS256
rsa_public_key: "$RSA_PUBLIC_KEY"
services:
- name: keycloak
url: http://keycloak
routes:
- name: keycloak
paths:
- /auth
strip_path: true
- name: api
url: http://api
routes:
- name: api
paths:
- /api
plugins:
- name: jwt
service: api
enabled: true
config:
key_claim_name: iss
claims_to_verify:
- exp
- name: keycloak-resource_access-roles-plugin
service: api
enabled: true
config:
header_name: Keycloak-Resource-Access-Client-Roles

nginx.deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
labels:
app: nginx
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx
ports:
- containerPort: 80
protocol: TCP

nginx.service.yaml:

apiVersion: v1
kind: Service
metadata:
name: api
spec:
selector:
app: nginx
ports:
- protocol: TCP
port: 80
targetPort: 80

--

--