Generate JWK to Pair With JWT Signer Plugin

If you’ve read my blog post about using JWT plugin, you would know that RSA public key is associated with a consumer object to validate JWT token passed in. Although it works perfectly fine, there are two problems you might encounter because the public keys are attached to consumer objects.

  1. Users can’t do consumer mapping which means rate limiting, ACL control is not possible.
  2. If users need to rotate keys, they must update these consumer objects with the new public keys.

If you are using Kong enterprise, one method to solve these issues is to host users information on an OIDC provider and use OIDC plugin. The other method is to use JWT singer plugin.

JWT signer plugin can be used to validate token issued by IDP (Identity Provider) and re-sign it with Kong’s private key. It can also be used to validate opaque token against IDP’s introspection endpoint and sign a JWT token with the information returned. If user does not use IDP, they can create key pairs and sign their own JWTs to validate clients. Because we have control of what’s in the payload, we can do consumer mapping as well.

In this post, I will show you how to achieve this.

Let’s begin.

If you already know how to generate JWKs and create JWTs, you can start from self host JWKs.

Generate Keys

Tools I will use:

  • OpenSSL -> for Key generation
  • pem-to-jwk -> for converting PEM file to JWK
  • jq -> for adding info to JWK

I’ve prepared a script, PLEASE REVIEW SCRIPT BEFORE RUNNING IT! This script generates both RSA and ECC key pairs.

The only reason I generate these keys this way because JWT CLI only supports PKCS8 EC keys, see this issue for more detail. If you only use RSA algorithm, you can use openssl to generate keys easily.

1
/bin/bash <(curl -fsSL 'https://gist.githubusercontent.com/liyangau/51bef7735a7da219ced69c6447353f7b/raw')

This script will generate 4 files

  • jwt-rsa-private.p8
  • jwt-rsa-public.pem
  • jwt-ecc-private.p8
  • jwt-ecc-public.pem

These file names should be self-explanatory. Files with file extension p8 are private keys in DER format. The reason I use DER format because the tool (jwt-cli) I use to generate JWT token accepts private keys in DER format only.

Once the keys are generated, let’s create our JWKs.

Create JWK

We can generate JWKs from our Public keys as below.

  • RSA key

    1
    2
    cat jwt-rsa-public.pem | docker run -i danedmunds/pem-to-jwk:latest \
    --jwks-out | jq '.keys[] += {alg:"RS256"}'

    We should get an result similar to below

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {
    "keys": [
    {
    "kty": "RSA",
    "kid": "2BuwZaYxqLhhTMX80Wt-vo4iLb4ubHPJY2oHwv6ie1c",
    "n": "15-LspeeSGJhrVz1-Q2WZmv0B8dyQ9pPEbA68WSkkJuCUEqO-E29_wCqrLJ8q0_MvC7-6silS-XkmxDMXQFwtLD-Mf4_MOJwsHjywQXDn2wQj-qT57kqsYPDVO0fh-Yaylqmf8v-nEN5vh_w87MOozBpogw1yVnLZc_SNj200LNcEnFRpESqRuwM_ZzIFF6rccDQlku_heA6n9odDDAMkR7tmN-f8NHZ1LKk4Ugxfqt9QL1qYSLH_D14x_DQ1iHSCGay4bNgyeFc-Riq-5iEmCmrPwfRg3H_OJ_MJ1Qjh2J2ErRcdPh8bc2PZvCU1t20S4nyAU39_S73PPgaviaUTQ",
    "e": "AQAB",
    "alg": "RS256"
    }
    ]
    }
  • ECC Key

    1
    2
    cat jwt-ecc-public.pem | docker run -i danedmunds/pem-to-jwk:latest \
    --jwks-out | jq '.keys[] += {alg:"ES256"}'

    We should get result similar to below

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    "keys": [
    {
    "kty": "EC",
    "kid": "sXBzQf92Err7qnFyR-ywRQ4xT9to06ptWmexn-wiFaY",
    "crv": "P-256",
    "x": "Q4K0pd4VYsQyH0j6nM5oxTJxkPcgJ0e8bQS54SHUW-k",
    "y": "Ws1hhKAVT8YlYV2wWHkkHzyiqSaEyXm3AsXviuTEAOg",
    "alg": "ES256"
    }
    ]
    }
  • Let’s combine both keys to one keyset

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    {
    "keys": [
    {
    "kty": "RSA",
    "kid": "2BuwZaYxqLhhTMX80Wt-vo4iLb4ubHPJY2oHwv6ie1c",
    "n": "15-LspeeSGJhrVz1-Q2WZmv0B8dyQ9pPEbA68WSkkJuCUEqO-E29_wCqrLJ8q0_MvC7-6silS-XkmxDMXQFwtLD-Mf4_MOJwsHjywQXDn2wQj-qT57kqsYPDVO0fh-Yaylqmf8v-nEN5vh_w87MOozBpogw1yVnLZc_SNj200LNcEnFRpESqRuwM_ZzIFF6rccDQlku_heA6n9odDDAMkR7tmN-f8NHZ1LKk4Ugxfqt9QL1qYSLH_D14x_DQ1iHSCGay4bNgyeFc-Riq-5iEmCmrPwfRg3H_OJ_MJ1Qjh2J2ErRcdPh8bc2PZvCU1t20S4nyAU39_S73PPgaviaUTQ",
    "e": "AQAB",
    "alg": "RS256"
    },
    {
    "kty": "EC",
    "kid": "sXBzQf92Err7qnFyR-ywRQ4xT9to06ptWmexn-wiFaY",
    "crv": "P-256",
    "x": "Q4K0pd4VYsQyH0j6nM5oxTJxkPcgJ0e8bQS54SHUW-k",
    "y": "Ws1hhKAVT8YlYV2wWHkkHzyiqSaEyXm3AsXviuTEAOg",
    "alg": "ES256"
    }
    ]
    }

Let’s save it as jwk.json and then we can host this file on a server.

Self host JWKs

I will use json-server to host my JWK and put this inside the same docker network as Kong.

Create Network

I will run all my containers in network called kong-net.

1
docker network create kong-net

Start JSON server container

I am mapping port 3000 of host machine to this container to make sure it works.

1
2
3
4
5
docker run --detach --name jwk \
--network kong-net \
-p "3000:3000" \
-v ${PWD}/jwk.json:/jwk.json \
williamyeh/json-server --watch /jwk.json

Start Kong Enterprise with Postgres

If you haven’t read my post about different kong deployment methods on docker, I recommend you to check it out.

I will deploy Kong with Postgres database. The commands I use are very similar to what’s on that post except adding a license and a couple security related environment variables.

Create Postgres container

Let’s create a postgres container with below command. I call this container kong-db and create database kong, user kong and user password kong when it starts.

1
2
3
4
5
6
docker run --detach --name kong-db \
--network kong-net \
-e "POSTGRES_DB=kong" \
-e "POSTGRES_USER=kong" \
-e "POSTGRES_PASSWORD=kong" \
postgres:12-alpine

Database Bootstrap

We need to bootstrap the database first. This means kong needs to create some tables in the database before it starts. I’ve set KONG_PASSWORD=kong in this process. This will create an initial super-admin kong_admin in the database with kong as its RBAC token and Kong manager admin password. Although we don’t use Kong manager today, I want to enable RBAC token for security reasons.

1
2
3
4
5
6
7
8
9
docker run --rm --name kong-migrations \
--network kong-net \
-e "KONG_DATABASE=postgres" \
-e "KONG_PASSWORD=kong" \
-e "KONG_PG_HOST=kong-db" \
-e "KONG_PG_DATABASE=kong" \
-e "KONG_PG_USER=kong" \
-e "KONG_PG_PASSWORD=kong" \
kong/kong-gateway:2.5.1.0-alpine kong migrations bootstrap

Start Kong container

I have set KONG_ENFORCE_RBAC=on here to force users to present RBAC token for making admin api calls.

1
2
3
4
5
6
7
8
9
10
11
12
13
docker run --detach --name kong \
--network kong-net \
-p "8000-8001:8000-8001" \
-e "KONG_ADMIN_LISTEN=0.0.0.0:8001" \
-e "KONG_PROXY_LISTEN=0.0.0.0:8000" \
-e "KONG_DATABASE=postgres" \
-e "KONG_ENFORCE_RBAC=on" \
-e "KONG_PG_HOST=kong-db" \
-e "KONG_PG_DATABASE=kong" \
-e "KONG_PG_USER=kong" \
-e "KONG_PG_PASSWORD=kong" \
-e "KONG_LICENSE_DATA=${KONG_LICENSE_DATA}" \
kong/kong-gateway:2.5.1.0-alpine

Test Plugin

As usual I will use httpbin as our upstream server.

Create Service

1
2
3
4
5
curl -X POST \
--url http://localhost:8001/services \
--header "Content-Type: application/json" \
--header 'Kong-Admin-Token: kong' \
--data '{"name":"test-service","url":"https://httpbin.org/anything"}'

Create Route

1
2
3
4
5
curl -X POST \
--url http://localhost:8001/services/test-service/routes \
--header 'Kong-Admin-Token: kong' \
--header "Content-Type: application/json" \
--data '{"name":"test-route","paths":["/test"]}'

Create Consumer

This will create a consumer with username demo.

1
2
3
4
5
curl -X POST \
--url http://localhost:8001/consumers \
--header 'Kong-Admin-Token: kong' \
--header "Content-Type: application/json" \
--data '{"username":"demo"}'

When we make a call, we should see 200 response.

1
curl -X GET --url http://localhost:8000/test/anything -I

Enable JWT Signer plugin

I enable JWT signer plugin with below settings.

  • config.channel_token_optional=true will make channel token optional. We are passing 1 JWT token only.
  • config.access_token_jwks_uri=http://jwk:3000/db is to load JWK (which is used to verify JWT) from our JWK server deployed earlier.
  • config.access_token_consumer_claim=username will map the value of JWK claim key username to Kong consumer’s username. Access will be denied if no match found.
1
2
3
4
5
6
7
8
curl --request POST \
--url http://localhost:8001/plugins \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Kong-Admin-Token: kong' \
--data name=jwt-signer \
--data config.channel_token_optional=true \
--data config.access_token_jwks_uri=http://jwk:3000/db \
--data config.access_token_consumer_claim=username

Let’s access the route again and our request should be rejected.

1
curl -X GET --url http://localhost:8000/test/anything -I

Generate JWT token

Because my keysets only has 1 RSA and 1 EC key, Kong knows which public to use for validating the JWT token I passed in.
If you use multiple RSA or EC keys, you need to include the kid in the jwt command to generate your JWT token.
I think it might be a good habbit to always include kid when generating tokens.

To show you the consumer mapping, I will include username=demo in payload.

  • Generate Token with RSA private key

    1
    2
    3
    4
    jwt encode --alg=RS256 \
    --kid=2BuwZaYxqLhhTMX80Wt-vo4iLb4ubHPJY2oHwv6ie1c \
    --exp=300s --iss=kong-demo \
    [email protected] '{"iss":"demo","username":"demo"}'
  • Generate Token with ECC private key

    1
    2
    3
    4
    jwt encode --alg=ES256 \
    --kid=sXBzQf92Err7qnFyR-ywRQ4xT9to06ptWmexn-wiFaY \
    --exp=300s --iss=kong-demo \
    [email protected] '{"iss":"demo","username":"demo"}'

Let’s say the JWT we got is below

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6InNYQnpRZjkyRXJyN3FuRnlSLXl3UlE0eFQ5dG8wNnB0V21leG4td2lGYVkifQ.eyJleHAiOjE2NDUwNjcyNjYsImlhdCI6MTY0NTA2Njk2NiwiaXNzIjoiZGVtbyIsInVzZXJuYW1lIjoiZGVtbyJ9.LJgS1vkTGdvKwQWwxeVP0sdQK8P_8y4HtC7YUO3oKCm49xjZPDtsBVa61EheEhay0_RvzfBqlHTedjULiRuz8g

We can make our request again with

1
2
curl -X GET --url http://localhost:8000/test/anything -I \
--header "Authorization:Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6InNYQnpRZjkyRXJyN3FuRnlSLXl3UlE0eFQ5dG8wNnB0V21leG4td2lGYVkifQ.eyJleHAiOjE2NDUwNjcyNjYsImlhdCI6MTY0NTA2Njk2NiwiaXNzIjoiZGVtbyIsInVzZXJuYW1lIjoiZGVtbyJ9.LJgS1vkTGdvKwQWwxeVP0sdQK8P_8y4HtC7YUO3oKCm49xjZPDtsBVa61EheEhay0_RvzfBqlHTedjULiRuz8g"

We should get HTTP/1.1 200 OK again.

Rotate Keys

If you need to rotate keys from time to time. You just need to repeat Generate Keys step above and put the new keyset on jwk.json file.

Once it is done, you can rotate Keyset on Kong.

  • Get JWK ID

    1
    2
    3
    curl --request GET --silent \
    --url http://localhost:8001/jwt-signer/jwks \
    --header 'Kong-Admin-Token: kong' | jq -r '.data[] | select(.name=="http://jwk:3000/db") | .id'

    Let’s say the id we get is ebe5c286-6a7e-4f95-b6cb-cc7a6463b0c7.

  • Load new JWK from server

    1
    2
    3
    curl --request POST --silent \
    --url http://localhost:8001/jwt-signer/jwks/ebe5c286-6a7e-4f95-b6cb-cc7a6463b0c7/rotate \
    --header 'Kong-Admin-Token: kong'

That’s all I want to show you today, see you on the next one.