How to Use Demonstrating Proof-of-Possession (DPoP) Token With Kong and Keycloak

Last Christmas I published a blog post about using certificate bound access token with Kong and Keycloak. In the recent release of Kong Gateway 3.7.0.0, support has been added for another type of sender-constraining token called DPoP (Demonstrated Proof of Possession) tokens. In today’s post, I will explain what DPoP is, how to use it with Keycloak, and how to configure the Kong OpenID Connect plugin to authenticate DPoP tokens.

Let’s get started.

What is DPoP

I’ve already explained the reasons to use sender-constraining tokens in my last post. Compared to certificate-bound access tokens, DPoP-bound tokens are tied to a client-generated key that is held by the client, which making implementation easier since managing PKI can be challenging. DPoP helps prevent replay attacks by including the request URL, request method, and access token hash in the DPoP proof JWT payload. This informs the resource server which token the DPoP proof JWT should be used with, and specifies the exact method and URL for which this proof JWT is intended for.

The request flow is illustrated in the diagram below:

  1. Generate DPoP proof JWT for requesting a token from Keycloak.
  2. Send a request with the DPoP proof to Keycloak. If it is valid, Keycloak returns an access_token with the cfn.jkt claim, similar to the example below.
    1
    2
    3
    "cnf": {
    "jkt": "00xoEKLML0EM1d1gqB9rBabpSwAG1DzhSIkXbFs-8is"
    },
  3. Generate a new DPoP proof JWT and add ath claim based on the access_token.
  4. Request send to resource server (Kong, in this case) with both access_token and the DPoP proof JWT.

DPoP proof JWT

DPoP introduces the concept of a DPoP proof, which is a JWT created by the client and sent with an HTTP request using the DPoP header field. Each HTTP request requires a unique DPoP proof. A valid DPoP proof demonstrates to the server that the client holds the private key used to sign the DPoP proof JWT. This enables authorization servers to bind issued tokens to the corresponding public key and enables resource servers to verify the key-binding of tokens it receives, preventing unauthorized entities from using the tokens.

To assist with testing, I have prepared the following Python script. Make sure you have the required libraries set up (you can use my nix template to set up the Python development environment). Save the code below to main.py and run python main.py --url http://test to generate an RSA key pair and the first DPoP proof JWT token.

Python library required:

  • jwcrypto
  • shortuuid

The script performs the following tasks:

  • It has 3 flags
    • –url: (Mandatory) Specify the URL to send your request to.
    • –method: (Default: GET) Specify the method for your request.
    • –ath: (Optional) (Optional) Pass in the access_token for the script to calculate and add the ath claim to the payload. If omitted, the ath claim will not be added.
  • Generates RSA key pairs and saves them as private.json and public.json in the current directory if the files are not found.
  • Creates a DPoP proof JWT with essential information, such as:
    • Public key in the jwk claim in the header.
    • Request URL in the htu claim in the payload.
    • Request method in the htm claim in the payload.
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
from jwcrypto import jwk, jwt
from jwcrypto.common import json_encode
from datetime import datetime, timezone
import shortuuid
import json
import os
import argparse
import hashlib
import base64

def generate_rsa_key(key_size):
key = jwk.JWK.generate(kty='RSA', size=key_size, alg='RS256')
thumbprint = key.thumbprint()
key.update(kid=thumbprint)
return key

def export_public_key(key):
public_key = key.export_public(as_dict=True)
return public_key

def export_private_key(key):
private_key = key.export(as_dict=True)
private_key['use'] = 'sig'
return private_key

def save_jwk_keys(public_keys, private_keys, directory):
jwk_public_keys = {"keys": public_keys}
jwk_private_keys = {"keys": private_keys}

public_key_path = os.path.join(directory, "public.json")
private_key_path = os.path.join(directory, "private.json")

with open(public_key_path, "w") as f:
json.dump(jwk_public_keys, f, indent=2)

with open(private_key_path, "w") as f:
json.dump(jwk_private_keys, f, indent=2)

def generate_dpop_jwt(method, url, private_key, ath_token=None):
public_key_dict = private_key.export_public(as_dict=True)

# Make sure public key is in the header
header = {
'typ': 'dpop+jwt',
'alg': 'RS256',
'jwk': public_key_dict
}

# Create JWT payload
now = datetime.now(timezone.utc)
payload = {
'htm': method.upper(),
'htu': url,
'jti': shortuuid.uuid(),
'iat': int(now.timestamp()) + 5 # Adding 5 seconds here so I have enough time to prepare the second request to keycloak
}

if ath_token:
sha256_hash = hashlib.sha256(ath_token.encode()).digest()
ath_hash = base64.urlsafe_b64encode(sha256_hash).rstrip(b'=').decode('utf-8')
payload['ath'] = ath_hash

# Create and sign the DPoP JWT
token = jwt.JWT(header=header, claims=payload)
token.make_signed_token(private_key)

return token.serialize()

def main():
parser = argparse.ArgumentParser(description="Generate DPoP JWT")
parser.add_argument("--url", required=True, help="The URL for the DPoP token")
parser.add_argument("--method", default="GET", help="The HTTP method for the DPoP token (default: GET)")
parser.add_argument("--ath", help="The token to be hashed and added as the 'ath' claim")
args = parser.parse_args()

current_dir = os.path.dirname(os.path.abspath(__file__))

public_key_path = os.path.join(current_dir, "public.json")
private_key_path = os.path.join(current_dir, "private.json")

if not os.path.exists(public_key_path) or not os.path.exists(private_key_path):
rsa_key = generate_rsa_key(2048)

rsa_public_key = export_public_key(rsa_key)
rsa_private_key = export_private_key(rsa_key)

public_keys = [rsa_public_key]
private_keys = [rsa_private_key]

save_jwk_keys(public_keys, private_keys, current_dir)

# Load the private key from private.json
with open(private_key_path, "r") as f:
private_key_data = json.load(f)

# We only have 1 key in this example
rsa_private_key_jwk = jwk.JWK.from_json(json_encode(private_key_data["keys"][0]))

dpop_jwt = generate_dpop_jwt(args.method, args.url, rsa_private_key_jwk, args.ath)
print(dpop_jwt)

if __name__ == "__main__":
main()

Some IDPs might require a nonce in the payload returned in the DPoP-Nonce header. Make sure to add this in the payload if required.

Keycloak

Now that we know how to generate a DPoP proof, let’s set up Keycloak and obtain a token from it.

Installation

We will use Docker for easy installation. Since DPoP is still a technical preview feature, we need to enable it specifically using the --features=dpop flag.

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
35
36
37
services:
keycloak:
image: quay.io/keycloak/keycloak:24.0.4
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
command:
- start
- --db=postgres
- --db-url=jdbc:postgresql://keycloak-db/pdb
- --db-username=admin
- --db-password=admin
- --https-port=9443
- --features=dpop
- --https-certificate-file=/opt/keycloak/ssl/cert.pem
- --https-certificate-key-file=/opt/keycloak/ssl/key.pem
- --hostname-url=https://key.li.lan:9443
- --hostname-admin-url=https://key.li.lan:9443
volumes:
- ./certs:/opt/keycloak/ssl
networks:
kong-ee-net:
ipv4_address: 192.168.186.100
ports:
- "9443:9443"
keycloak-db:
image: postgres:15-alpine
container_name: keycloak-db
environment:
POSTGRES_PASSWORD: admin
POSTGRES_USER: admin
POSTGRES_DB: pdb
networks:
- kong-ee-net
networks:
kong-ee-net:
external: true

Get Token

Once Keycloak is running, let’s try to get a DPoP token. Since Keycloak currently supports only public grant, we need to set up a user and configure a redirect URL on the client.

Then let’s create some variables.

1
2
3
4
KEYCLOAK_HOST=
KEYCLOAK_REALM=
CLIENT_ID=demo
CLIENT_SECRET=

Use Oauth2c

Since we are using the authorization code flow, it’s easier to use an application to send the required requests for us. oauth2c is a handy tool that helps run different OAuth flows in the terminal. It also handles generating DPoP proof JWTs for you.

Once the CLI is installed, run the following command. Use the private key you just generated (private.json) as the signing key and specify the use of DPoP.

1
2
3
4
5
6
7
8
9
10
oauth2c "https://${KEYCLOAK_HOST}/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration" \
--client-id demo \
--client-secret "${CLIENT_SECRET}" \
--response-types code \
--response-mode query \
--grant-type authorization_code \
--auth-method client_secret_post \
--scopes openid,email,offline_access \
--signing-key "$PWD/private.json" \
--dpop --silent

This command will open your default browser for you to log in. After successfully logging in, you should receive a response similar to the one below.

1
2
3
4
5
6
7
8
{
"access_token": "eyJhbGcixxx...",
"expires_in": 600,
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCxxx...",
"refresh_token": "eyJhbGcixxxxg...",
"scope": "openid offline_access email profile",
"token_type": "DPoP"
}

Use cURL

AAlthough OAuth2c simplifies obtaining a DPoP token, let’s use cURL to understand what’s happening behind the scenes. We will reuse the command from this old post.

First, generate the authorization URL and access it in your browser:

1
echo "https://$KEYCLOAK_HOST/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth?client_id=$CLIENT_ID&response_type=code&redirect_uri=http://localhost/callback&scope=openid"

You will be redirected back to http://localhost/callback with a token in the URL, which should look like this:

1
http://localhost/callback?session_state=a603b06f-4269-462b-9c3c-492153a4e196&iss=https%3A%2F%2Fkey.li.lan%3A9443%2Frealms%2Fdemo&code=58dcbb79-e760-457c-88b6-c8e2cc27251d.a603b06f-4269-462b-9c3c-492153a4e196.dacdb04e-9b41-483a-ab20-1d2d31e475b8

Extract the authorization code from the URL and save it to an environment variable:

1
CODE=a04d2466-2475-4a28-be55-c3a29f1b2d39.a603b06f-4269-462b-9c3c-492153a4e196.dacdb04e-9b41-483a-ab20-1d2d31e475b8

Next, create the DPoP proof JWT and save it to a variable dpop.

1
dpop=$(python main.py --url https://$KEYCLOAK_HOST/realms/$KEYCLOAK_REALM/protocol/openid-connect/token --method POST)

Now, send the token request using cURL:

1
2
3
4
5
6
7
8
9
curl -sk --request POST \
--header "DPoP:$dpop" \
--url https://$KEYCLOAK_HOST/realms/$KEYCLOAK_REALM/protocol/openid-connect/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data grant_type=authorization_code \
--data redirect_uri=http://localhost/callback \
--data code=$CODE \
--data client_id=$CLIENT_ID \
--data client_secret=$CLIENT_SECRET

If everything works correctly, you should receive a 200 response similar to this:

1
2
3
4
5
6
7
8
9
10
11
{
"access_token": "eyJhbGciOiJSUzI1Nixxx...",
"expires_in": 600,
"refresh_expires_in": 1800,
"refresh_token": "eyJhbGciOixxx...",
"token_type": "DPoP",
"id_token": "eyJhbGcixxx...",
"not-before-policy": 0,
"session_state": "a603b06f-4269-462b-9c3c-492153a4e196",
"scope": "openid email profile"
}

If you receive the following response, it means your DPoP proof is not active. I have added an arbitrary 5-second buffer to the DPoP proof JWT because Keycloak has a time limit.

1
{"error":"invalid_dpop_proof","error_description":"DPoP proof is not active"}

Let’s save the access token to a variable access_token.

1
access_token=eyJhbGciOiJSUzI1Nixxx...

Kong

Now that we know how to obtain a DPoP token from Keycloak, let’s learn how to authenticate this token with the Kong OpenID Connect plugin.

Start Kong EE

For this demo, we’ll deploy Kong in DB-less mode. Assuming you have a valid Kong Enterprise license stored in the environment variable KONG_LICENSE_DATA, you can start Kong Enterprise 3.5 in DB-less mode with the following command.

1
2
3
4
5
6
7
8
9
docker run --rm --detach --name kong \
--network kong-ee-net \
-p "8001:8001" \
-p "8443:8443" \
-e "KONG_ADMIN_LISTEN=0.0.0.0:8001" \
-e "KONG_PROXY_LISTEN=0.0.0.0:8000,0.0.0.0:8443 ssl" \
-e "KONG_DATABASE=off" \
-e "KONG_LICENSE_DATA=$KONG_LICENSE_DATA" \
kong/kong-gateway:3.7

Prepare Kong config

Save the following config to /tmp/kong.yaml and push it to Kong via the <admin_api>/config endpoint.

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
_format_version: "3.0"
routes:
- name: upstream-route
paths:
- /upstream
plugins:
- config:
echo: true
status_code: 200
name: request-termination
services:
- name: test-svc
host: localhost
path: /upstream
port: 8000
protocol: http
routes:
- name: test-route
paths:
- /test
plugins:
- config:
auth_methods:
- bearer
proof_of_possession_dpop: strict
issuer: https://keycloak:9443/realms/demo/.well-known/openid-configuration
name: openid-connect

Validate DPoP

To authenticate with Kong, we need to create a new DPoP proof JWT. In the command below, I specify that the request will be sent to http://localhost:8000/test using the HTTP GET method. This token will be used with the access_token we just generated so we passed in the access_token to include the ath claim.

1
kong_dpop=$(python main.py --url http://localhost:8000/test --method GET --ath $access_token)

Now, send the request to Kong:

1
2
3
4
curl --request GET \
--header "authorization:dpop $access_token" \
--header "DPOP: $kong_dpop" \
--url http://localhost:8000/test

If everything is set up correctly, you should receive a 200 response. The upstream service should receive the DPoP token in the authorization bearer header, and the DPoP proof will be removed.

That’s all I want to share with you today, see you next time.