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.
- G14 DPoP
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
Use
lab-printer-app(public, redirect URIhttp://127.0.0.1:8765/callback, scopesopenid profile offline_access photos.read) andexport APP_ID=<its client_id>. Set the access token manager it uses to thejwtformat, socnfwould be visible in the token.Make sure
lab-photo-apiexists (Machine to machine preset with Resource server on). Runbtl-lab envto see the variablesbtl-lab introspectexpects for it.Add the Base64url helper and start
btl-lab callbackin a second terminal before each sign-in.
b64url() { base64 | tr '+/' '-_' | tr -d '=\n'; }
Planned (G14): in OAuth > Flow policy, set DPoP to Allowed (proposed options Off, Allowed, Required), proof algorithms
ES256andRS256, 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
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.
Run the code flow for
lab-printer-appwithscope=openid profile offline_access photos.read. Redeem the code with a proof forPOST $ISSUER/oauth/token, signed withapp-key.pem, saved asPROOF.
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.
Decode the access token with
btl-lab decode "$TOKEN". Itscnfclaim 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.
Introspect it as the photo API with
btl-lab introspect "$TOKEN". The response carries the samecnf.jkt.
Why it matters: an opaque token carries the binding through introspection instead.
Call UserInfo the bound way:
Authorization: DPoP $TOKEN, plus a new proof forGET $ISSUER/oidc/userinfothat includesathfor 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.
Refresh with a proof from the same key. The new access token's
cnf.jktis 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
Run Planned walkthrough step 1. The thumbprint is real and stays stable for this key.
Run the code flow for
lab-printer-app, then redeem the code with aDPoPheader. 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.
Decode the access token with
btl-lab decode "$TOKEN": there is nocnf. Introspect it withbtl-lab introspect "$TOKEN":activeistrue, and there is nocnfeither.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.
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.
Read the metadata.
GET$ISSUER/.well-known/openid-configuration
Open in console
GET $ISSUER/.well-known/openid-configurationThere 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.
Send the bound token as
Authorization: Bearer $TOKENwith no proof. Expect401withWWW-Authenticate: DPoP error="invalid_token". A server that accepted it would make the binding optional.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
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
Keep
app-key.pemandapp-key.jwkfor the next DPoP labs.
Missing infrastructure
G14: DPoP proof validation at the token endpoint,
token_type: DPoP,cnf.jktin JWT access tokens and introspection, bound refresh tokens for public clients, the DPoP scheme at UserInfo,dpop_signing_alg_values_supportedin discovery, and tenant and per-client DPoP settings. Once these exist and the toolkit can build proofs, the Planned walkthrough runs as written.