Beta

Create a tenant

A new tenant starts with its own users, OAuth settings, audit history and logs. You are its first Tenant Admin.

BTL Admin

OAUTH 2.0 · LAB

Bind the phone app's tokens to a key it generated

Act as lab-printer-app: generate a P-256 key and its thumbprint, ask for DPoP-bound tokens, and look for the key in cnf.jkt. Today, see the bearer behavior that binding removes.

PlannedUses your lab tenant

The lesson

Builds on: Pushing an authorization request.

New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.

Planned. The core of this lab waits on platform features that are not built yet. The planned walkthrough shows exactly how it will run; Do today is a real exercise you can do now.

Request console

Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.

Setup

  1. Use lab-printer-app (public, redirect URI http://127.0.0.1:8765/callback, scopes openid profile offline_access photos.read) and export APP_ID=<its client_id>. Set the access token manager it uses to the jwt format, so cnf would be visible in the token.

  2. Make sure lab-photo-api exists (Machine to machine preset with Resource server on). Run btl-lab env to see the variables btl-lab introspect expects for it.

  3. Add the Base64url helper and start btl-lab callback in a second terminal before each sign-in.

b64url() { base64 | tr '+/' '-_' | tr -d '=\n'; }
  1. Planned (G14): in OAuth > Flow policy, set DPoP to Allowed (proposed options Off, Allowed, Required), proof algorithms ES256 and RS256, proof age 60 seconds with 5 seconds of clock tolerance.

Note: the lab toolkit has no command yet that builds and signs a DPoP proof with your own key. The planned steps say exactly what each proof contains; they need that command before they can run.

Planned walkthrough

  1. Generate the installation's key pair and write its public half as a JWK. This step runs today.

openssl ecparam -name prime256v1 -genkey -noout -out app-key.pem
X=$(openssl ec -in app-key.pem -pubout -outform DER 2>/dev/null | tail -c 64 | head -c 32 | b64url)
Y=$(openssl ec -in app-key.pem -pubout -outform DER 2>/dev/null | tail -c 32 | b64url)
printf '{"kty":"EC","crv":"P-256","x":"%s","y":"%s"}' "$X" "$Y" > app-key.jwk
btl-lab thumbprint app-key.jwk     # remember this value as JKT

Why it matters: "Giving the app a key pair". Nothing is registered in advance; each installation makes its own key, which is why DPoP suits public clients.

  1. Run the code flow for lab-printer-app with scope=openid profile offline_access photos.read. Redeem the code with a proof for POST $ISSUER/oauth/token, signed with app-key.pem, saved as PROOF.

curl -s "$ISSUER/oauth/token" -H "DPoP: $PROOF" -d grant_type=authorization_code -d code=<code> \
  --data-urlencode redirect_uri=$REDIRECT -d code_verifier=$VERIFIER -d client_id=$APP_ID | jq .

Expect "token_type": "DPoP" with access_token and refresh_token. Save them as TOKEN and REFRESH.

Why it matters: "Asking for a bound token". token_type is the signal; a client that relies on binding must discard a Bearer answer.

  1. Decode the access token with btl-lab decode "$TOKEN". Its cnf claim is {"jkt": "<your thumbprint>"}.

Why it matters: "Recording the key in the token". The resource learns the key from the token, without asking the app.

  1. Introspect it as the photo API with btl-lab introspect "$TOKEN". The response carries the same cnf.jkt.

Why it matters: an opaque token carries the binding through introspection instead.

  1. Call UserInfo the bound way: Authorization: DPoP $TOKEN, plus a new proof for GET $ISSUER/oidc/userinfo that includes ath for this token.

curl -s "$ISSUER/oidc/userinfo" -H "Authorization: DPoP $TOKEN" -H "DPoP: $USERINFO_PROOF" | jq .

Ava's profile claims come back.

Why it matters: "A token that needs its key". The token is one half of a credential; the other half stays in the key file.

  1. Refresh with a proof from the same key. The new access token's cnf.jkt is unchanged.

Why it matters: a public client's refresh token is bound to the key too, so a copied refresh token is useless without it.

Do today

  1. Run Planned walkthrough step 1. The thumbprint is real and stays stable for this key.

  2. Run the code flow for lab-printer-app, then redeem the code with a DPoP header. The tenant ignores the header whatever it holds, so a stand-in value shows the same thing a real proof would.

curl -s "$ISSUER/oauth/token" -H "DPoP: demo-proof" -d grant_type=authorization_code -d code=<code> \
  --data-urlencode redirect_uri=$REDIRECT -d code_verifier=$VERIFIER -d client_id=$APP_ID | jq '{token_type, scope}'

The answer says "token_type": "Bearer". Per the lesson, a client that depends on binding must stop here. Save TOKEN and REFRESH for the next steps.

  1. Decode the access token with btl-lab decode "$TOKEN": there is no cnf. Introspect it with btl-lab introspect "$TOKEN": active is true, and there is no cnf either.

  2. Call UserInfo with the token from a second environment that never saw your key: another machine, a container or a cloud shell.

curl -s "$ISSUER/oidc/userinfo" -H "Authorization: Bearer $TOKEN" | jq .

It succeeds. A bearer token works for whoever presents it, the property the lesson's cnf binding removes. Audit shows an oidc.userinfo succeeded userinfo_served event for each call.

  1. Try the DPoP scheme at UserInfo.

curl -si "$ISSUER/oidc/userinfo" -H "Authorization: DPoP $TOKEN" | grep -i -E '^HTTP|www-authenticate'

The answer is 400 with WWW-Authenticate: Bearer error="invalid_request": the tenant does not know the scheme.

  1. Read the metadata.

GET$ISSUER/.well-known/openid-configuration Open in console
GET $ISSUER/.well-known/openid-configuration

There is no dpop_signing_alg_values_supported, so a client reading metadata knows not to expect bound tokens here.

Break it

These run once G14 exists. Each is a client mistake the tenant must catch.

  1. Send the bound token as Authorization: Bearer $TOKEN with no proof. Expect 401 with WWW-Authenticate: DPoP error="invalid_token". A server that accepted it would make the binding optional.

  2. Delete app-key.pem, generate a new key and refresh with a proof from it. The refresh is refused: the app must start a new authorization.

Check your work

Today, Audit shows oidc.userinfo succeeded userinfo_served for lab-printer-app from both environments. The DPoP scheme was refused before the tenant looked at the token, so it appears only in Logs, as oidc.userinfo rejected invalid_request. Once G14 exists, Audit shows oauth.token succeeded with token type DPoP and the key thumbprint, and the refusals from Break it.

Cleanup

  1. Revoke the refresh token. A public client revokes with only its client_id.

curl -s "$ISSUER/oauth/revoke" -d token=$REFRESH -d client_id=$APP_ID
  1. Keep app-key.pem and app-key.jwk for the next DPoP labs.

Missing infrastructure

  • G14: DPoP proof validation at the token endpoint, token_type: DPoP, cnf.jkt in JWT access tokens and introspection, bound refresh tokens for public clients, the DPoP scheme at UserInfo, dpop_signing_alg_values_supported in discovery, and tenant and per-client DPoP settings. Once these exist and the toolkit can build proofs, the Planned walkthrough runs as written.

Back to all labs

We value your privacy

We use cookies and similar technologies to enhance your browsing experience, and analytics to understand our traffic. By clicking "Allow All", you consent to optional analytics. Cookie Policy

The Lab