Use Client Assertion With Kong OIDC Plugin and Keycloak

When users are designing APIs, there might be some security requirements that they must follow. For example, for Financial-grade API (FAPI), users must use client_assertion (client_secret_jwt or private_key_jwt) to send their request to IDP token endpoint.

As client_secret_jwt is not supported in FAPI Part II, I would only talk about how to use private_key_jwt with Kong OIDC plugin as it satisfy both FAPI Part I ad Part II.

Prerequisites:

  • Kong Gateway (Enterprise)
  • OIDC server is running. (Keycloak in my example) If you are not sure how to use keycloa, you can check my previous post
  • I highly recommend reading this article to understand how client authentication works.

Create testing Service and Route

We need a service and route for our testing.

Create service

I am using httpbin as upstream server.

1
2
3
4
curl -X POST \
--url "http://localhost:8001/services" \
--data "name=oidc-demo" \
--data "url=http://httpbin.org/anything"

Create Route

Next we will create a path /demo to access our service.

1
2
3
curl -X POST \
--url "http://localhost:8001/services/oidc-demo/routes" \
--data 'paths[]=/demo'

Use KONG JWK with Keycloak

When Kong Enterprise started, it will publish its JWK at <admin_api>/openid-connect/jwks. We need to let Keycloak know this public key in order to verify client_assertion kong will send to it. Keycloak supports loading JWKs urls which means it need to either havce access to <admin_api>/openid-connect/jwks or users export Kong JWKs and publish this JWK somewhere else.

In reality it might not be possible to expose admin api endpoint directly to Keycloak, hence I would use a json-server to host the exported JWKs.

Export JWKs

Below command saves Kong JWKs to a jwk.json file.

1
2
curl -X GET -s\
--url "http://localhost:8001/openid-connect/jwks" > jwk.json

Host JWK file with json-server

This is for demonstration purpose only, you can use whatever server to host your JWK file.

Firstly I put my jwk.json file inside a jwks folder on my server. Then I start this server with below docker-compose file. This service is behind reverse proxy Traefik, I use it to handle TLS.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
version: '2.1'
services:
json-server:
image: williamyeh/json-server
container_name: json-server
volumes:
- ./jwks/:/data
command: ["--watch","jwk.json"]
networks:
- proxy
restart: always
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.json-server-secure.entrypoints=websecure"
- "traefik.http.routers.json-server-secure.rule=Host(`jwk.yourdomain`)"
- "traefik.http.routers.json-server-secure.service=jwk-service"
- "traefik.http.services.jwk-service.loadbalancer.server.port=3000"

networks:
proxy:
external: true

Enable Signed JWT on Keycloak

  1. Click Clients on left sidebar.
  2. Choose the client you want to use.
  3. Go to Credentials tab and select Signed Jwt as Client Authenticator.
  4. Put Use JWKS URL to ON and enter https://jwk.yourdomain in JWKS URL field.
  5. Select PS256 as Signature Algorithm. (FAPI Part II allows ES256 or PS256 only)

Enable OIDC plugin

I only enable password flow in this example. If you want to know more about different flows, please check my previous post. As you can see we don’t need client_secret in our config. Kong will generate client_assertion automatically when it sends request to keycloak.

1
2
3
4
5
6
7
8
curl --request POST \
--url http://localhost:8001/plugins \
--data name=openid-connect \
--data config.issuer=https://<keycloak>/auth/realms/demo \
--data config.client_id=<client_id> \
--data config.auth_methods=password \
--data config.client_auth=private_key_jwt \
--data config.client_alg=PS256

Now we can visit the protected route.

1
2
3
curl --request GET \
--url https://<PROXY_NODE>/demo/anything \
--header 'Authorization: Basic base64(username:password)'

Generate self-signed certificate and JWK

Not sure if it is a bug, but I could only get RS256 signature algorithm working for this method. If you are designing FAPI, this is not for you.
You also need third party tools for generating JWK from a private key. If you don’t want to use third party tool, this method is not for you.

If you prefer using your own certificate, you can either use keycloak or third party software like openssl to generate key and cert pair.

Generate Cert from Keycloak

  1. Click Clients on left sidebar.
  2. Choose the client you want to use.
  3. Go to Credentials tab and select Signed Jwt as Client Authenticator.
  4. Select RS256 as Signature Algorithm.
  5. Click Generate new keys and certificate button.
  6. Select PKCS12 as Archive Format
  7. Enter a simple Key Password and Store Password. We need this password to extract private key later.
  8. Click Generate and Download button.

Now you should have file keystore.p12 downloaded, we will use openssl to extract private key.

  • Extract private key out from PKCS12.
    You will be prompted to enter password we set in step 7. Just keep input the same password. You need to enter 3 times, the last two time was to set password for private key file.

    1
    openssl pkcs12 -in keystore.p12 -nocerts -out privatekey.pem
  • Remove password from private key file.
    You will be asked to enter password again. Enter it, and the new privatekey.pem will NOT be protected by password.

    1
    openssl rsa -in privateKey.pem -out privatekey.pem

Generate cert with OpenSSL

Use openssl to generate self-sign certificate is pretty straight forward. Enter below command and you will be prompted to enter some detail like country code, state, locality etc. After that you will get a self-sign certificate with 1 year validity.

1
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout privatekey.pem -out self-signed-cert.pem

If you generate certificate with third party tool, you need to upload self-signed-cert.pem to keycloak.

Convert private key to JWK

You need to use third party tool to convert privatekey.pem to JWK. I am using python library called authlib in my example. Let’s say the JWK we got was below:

1
2
3
4
5
6
7
8
9
10
11
{
"n": "q3F6nagTFRYqOqUejv7BL9M6VcJqryw7Tge-Yhtw9rQ1J9zLsXAWGbfTmA3AUN59cyMs3OhOfHDZ2QXZPrgo75XCZvveTbr6tLtBRHD7VfErBbwSDW7K2FqrGTkQfbkuXQ8XOExrczm9aOC54oiAozwSlecH2ElP6pF1UwE0mjeJ3HrWAWFrJ_ac3af7PtwOK8mVrhNONqGpJ5qnEwTsHaGMI-J8a913uJyZqtYgQ0zRxhnZBLJjh2bhloIhlekSV_4XeivntoFcIJZGoZOkRZ-siM96pb0f6J7Cur8UBLe_fuURB-tD5RGUtY7owPo4WWrIeUYpof2yJcl_4wGnmw",
"e": "AQAB",
"d": "k8t7Cchn0ujNtMq9okYp4oG0q1OzxplZ8rWoQUkow_SsUlX8X1WiJ7-aMF3CabSz7vrm5PV7G7npgzaQhooZ-Bhhg4gjp1zGXeGDrW28reT-0q3D3kKhqYvYLiFMfyWRu3WT7durUaT4DR6WAJzuUEosN3_3-lORCkUlIE5Mu5g1naYeSMtC9G9DS_D5j1MwgozR_kxVPo78AF6iaiOsB9vV3mV-OYzvlb1Dmwq8b7VrFlwyyIeA4M_cnTu7WvHdg6-JCLSK1GzeBYDm9h3YI30bZZovTm8YMy8GNBKq0sJT0ihcaEInG6MLgSHgey7FuxofpGRsZXXSEARa0FrReQ",
"p": "3E6_KtrFxmi-_MFSiHdS1PDpxOd-CXMR44RUwgXRwOryHqZsDD_QhUYwAR0S93BrbIuJ_X1jm2zuNFWUXq53wb4Evq2nBsOdDy16GKIE1XNcyvDkou2RUwCQFtL4_6oLVnO5FYcs9VVR447Rc9QcTkjqR_Colm_1zwt10ZPvw0U",hv z
"q": "xzgV1LmgElltVvQLp797DPhi4zL33rG8GlaR_i_uZdIH0b8mZ7GoP9B73CmhSVZ1YHHeX67BVDKzcv_6DUa_JTsQHzzggICGe_tiaVD5v3fTf_4NhTwkEToTZ6WPSLS2aaFobR_0QPypDaxi8WW-sdQAeUpaELze6dwPw2cp_V8",
"dp": "DiixeJue4mWRAMWb_FFO7YiePZ1zKEBRAgJbQC0HkhKHhRjFEPR4_lfIdgncUjLTdKJzN-t7H14c4Rgu9PbZk4YW3_phJbokufj-Q98F2IIVkKVyzFXgZSlAGXdtsWDIYcIGPy_037-gB7QCGiOjvhRJml4JN8HyVmAyLkoHutE",
"dq": "HthFMQmIii7EahkhENjp0WlHzkue6yDzsdRDiGqda7BoO4ZwCNAN78t00fK0ISm8xLD8sC3bixDKjfyqF5IjmD0b0upXDC5aJCoY45uA_68q1P9d0oosP2qRhJOyqpwoPhSamYLAC6vS9OYC78NEEj5z0FO9vCeyD91dp3B6pNs",
"qi": "j9QpIlU2nloONVeUlBo9_Z-sMWjRuYT9bZpFvrilEDztfC75MCSO-nH8ER53kn5QziEUpwCRnsUKOG9fPTIAX-6s-NNzpi9F57eyqg3WYCzsVaJ6kHoWt9XWRw6Av1vYoewRvUA6bl4NNVMsPHZbfYCFEreO1pcmJ8i4s3y9yZk",
"kty": "RSA"
}

Enable Kong OIDC plugin

We will put our key in config.client_jwk parameter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
curl --request POST \
--url http://localhost:8001/plugins \
--header 'Content-Type: application/json' \
--data '{
"name": "openid-connect",
"config": {
"issuer": "https://<keycloak>/auth/realms/demo",
"client_id": [
"<client_id>"
],
"auth_methods": [
"password"
],
"client_auth": [
"private_key_jwt"
],
"client_jwk": [
{
"n": "q3F6nagTFRYqOqUejv7BL9M6VcJqryw7Tge-Yhtw9rQ1J9zLsXAWGbfTmA3AUN59cyMs3OhOfHDZ2QXZPrgo75XCZvveTbr6tLtBRHD7VfErBbwSDW7K2FqrGTkQfbkuXQ8XOExrczm9aOC54oiAozwSlecH2ElP6pF1UwE0mjeJ3HrWAWFrJ_ac3af7PtwOK8mVrhNONqGpJ5qnEwTsHaGMI-J8a913uJyZqtYgQ0zRxhnZBLJjh2bhloIhlekSV_4XeivntoFcIJZGoZOkRZ-siM96pb0f6J7Cur8UBLe_fuURB-tD5RGUtY7owPo4WWrIeUYpof2yJcl_4wGnmw",
"e": "AQAB",
"d": "k8t7Cchn0ujNtMq9okYp4oG0q1OzxplZ8rWoQUkow_SsUlX8X1WiJ7-aMF3CabSz7vrm5PV7G7npgzaQhooZ-Bhhg4gjp1zGXeGDrW28reT-0q3D3kKhqYvYLiFMfyWRu3WT7durUaT4DR6WAJzuUEosN3_3-lORCkUlIE5Mu5g1naYeSMtC9G9DS_D5j1MwgozR_kxVPo78AF6iaiOsB9vV3mV-OYzvlb1Dmwq8b7VrFlwyyIeA4M_cnTu7WvHdg6-JCLSK1GzeBYDm9h3YI30bZZovTm8YMy8GNBKq0sJT0ihcaEInG6MLgSHgey7FuxofpGRsZXXSEARa0FrReQ",
"p": "3E6_KtrFxmi-_MFSiHdS1PDpxOd-CXMR44RUwgXRwOryHqZsDD_QhUYwAR0S93BrbIuJ_X1jm2zuNFWUXq53wb4Evq2nBsOdDy16GKIE1XNcyvDkou2RUwCQFtL4_6oLVnO5FYcs9VVR447Rc9QcTkjqR_Colm_1zwt10ZPvw0U",hv z
"q": "xzgV1LmgElltVvQLp797DPhi4zL33rG8GlaR_i_uZdIH0b8mZ7GoP9B73CmhSVZ1YHHeX67BVDKzcv_6DUa_JTsQHzzggICGe_tiaVD5v3fTf_4NhTwkEToTZ6WPSLS2aaFobR_0QPypDaxi8WW-sdQAeUpaELze6dwPw2cp_V8",
"dp": "DiixeJue4mWRAMWb_FFO7YiePZ1zKEBRAgJbQC0HkhKHhRjFEPR4_lfIdgncUjLTdKJzN-t7H14c4Rgu9PbZk4YW3_phJbokufj-Q98F2IIVkKVyzFXgZSlAGXdtsWDIYcIGPy_037-gB7QCGiOjvhRJml4JN8HyVmAyLkoHutE",
"dq": "HthFMQmIii7EahkhENjp0WlHzkue6yDzsdRDiGqda7BoO4ZwCNAN78t00fK0ISm8xLD8sC3bixDKjfyqF5IjmD0b0upXDC5aJCoY45uA_68q1P9d0oosP2qRhJOyqpwoPhSamYLAC6vS9OYC78NEEj5z0FO9vCeyD91dp3B6pNs",
"qi": "j9QpIlU2nloONVeUlBo9_Z-sMWjRuYT9bZpFvrilEDztfC75MCSO-nH8ER53kn5QziEUpwCRnsUKOG9fPTIAX-6s-NNzpi9F57eyqg3WYCzsVaJ6kHoWt9XWRw6Av1vYoewRvUA6bl4NNVMsPHZbfYCFEreO1pcmJ8i4s3y9yZk",
"kty": "RSA"
}
],
"client_alg": [
"RS256"
]
}
}'

Now we can visit the protected route.

1
2
3
curl --request GET \
--url https://<PROXY_NODE>/demo/anything \
--header 'Authorization: Basic base64(username:password)'