OAUTH 2.0 · LAB
Push a signed request object with a client assertion
Push lab-printer's signed request object to the PAR endpoint, authenticated by a client assertion from the same key, and see the tenant keep the two JWTs apart.
PlannedUses your lab tenant
The lesson
Builds on: Validating a request object.
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.
- G11 PAR
- G12 JAR
- G8 Client authentication beyond `client_secret_basic` and `none`
- G56 Client JWKS (`jwks` / `jwks_uri` per client)
Setup
Keep
printer-2026-10.pem, theb64urlhelper and the shell variables from the earlier JAR labs.Planned (G11, G12): in OAuth > Flow policy, allow pushed authorization requests and allow request objects through PAR.
Planned (G8, G56):
lab-printerusesprivate_key_jwtwith the registered keyprinter-2026-10.
Note: the lab toolkit has no command yet that signs a JWT with your own key. The planned steps say exactly what to sign; they need that command before they can run.
Planned walkthrough
Build and sign the request object (
OBJ) as in Build a request object and send it by value or by reference, and a fresh client assertion (ASSERTION) withaudset to$ISSUER.Push only the object and the client authentication.
curl -s "$ISSUER/oauth/par" -d client_id=$CLIENT_ID \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -d client_assertion=$ASSERTION \
-d request=$OBJ | jq .
Expect 201 with request_uri and expires_in.
Why it matters: "Pushing a signed request". Apart from request, the body carries only what client authentication needs. Every authorization parameter is inside the signed object.
Open
$ISSUER/oauth/authorize?client_id=$CLIENT_ID&request_uri=<url-encoded request_uri>, approve as Ava, and exchange the code with a new assertion.
Why it matters: from here it is the ordinary PAR flow, with the browser carrying only a reference.
Decode both JWTs with
btl-lab decodeand fill in the lesson's comparison table:typ,issandsub,aud, lifetime.
Why it matters: "What the server checks". One key signs both, so only the claims and type keep each from passing for the other.
Open the
oauth.parevent in Audit. It records the authentication methodprivate_key_jwtand the object'sjti, and that the authenticated client matched theclient_idclaim inside the object.
Why it matters: the tenant now holds signed evidence of the exact request, the property PAR alone cannot give.
Do today
Build both claim sets, without signing, and compare them.
NOW=$(date +%s)
jq -nc --arg c "$CLIENT_ID" --arg a "$ISSUER" --argjson n $NOW --arg j "$(openssl rand 16 | b64url)" \
'{iss:$c, sub:$c, aud:$a, iat:$n, exp:($n+60), jti:$j}' | jq . > assertion-claims.json
echo "$CLAIMS" | jq . > request-claims.json
diff assertion-claims.json request-claims.json
Confirm that the request object claims have no sub, and write down the typ each would carry: JWT (or client-authentication+jwt) for the assertion, oauth-authz-req+jwt for the object.
Push today with both parameters.
curl -si "$ISSUER/oauth/par" -d client_id=$CLIENT_ID \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -d client_assertion=demo-assertion \
-d request=demo-request-object
The answer is 501 temporarily_unavailable, and Logs show oauth.par rejected not_implemented.
Send the same client assertion parameters to the token endpoint.
curl -s "$ISSUER/oauth/token" -d grant_type=client_credentials -d client_id=$CLIENT_ID \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -d client_assertion=demo-assertion | jq .
The answer is 401 invalid_client, and Audit shows oauth.token rejected unsupported_auth_method.
Fill in the lesson's "What each part contributes" table for this tenant today: write which column (PAR alone, JAR without PAR, JAR with PAR) the tenant can offer, and which two missing capabilities, PAR and
private_key_jwt, block the last column.
Break it
These run once the gaps close.
Swap your own two JWTs: put the request object in
client_assertionand the assertion inrequest. The tenant refuses both,401 invalid_clientbecause the object has nosub, and400 invalid_request_objectbecause the assertion'stypis wrong.Add
-d scope=openidbesiderequestin the push. The tenant refuses it with400 invalid_request: authorization parameters belong only inside the object.
Check your work
Today, Logs show oauth.par rejected not_implemented, and Audit shows oauth.token rejected unsupported_auth_method for lab-printer. Once the gaps close, Audit shows oauth.par succeeded with private_key_jwt and a request object jti, and the two Break it refusals.
Cleanup
Delete
assertion-claims.jsonandrequest-claims.json.Once the gaps close, remove
printer-2026-10fromlab-printer's keys, switch it back toclient_secret_basic, and rotate its secret.Delete
printer-2026-10.pemand its public file when you finish the JAR labs.
Missing infrastructure
G11: the PAR endpoint, which must accept a
requestparameter and check that the authenticated client matches the object'sclient_idclaim.G12: request object processing for pushed objects.
G8:
private_key_jwtat the PAR and token endpoints.G56: per-client public keys.
Once these exist and the toolkit can sign with the learner's own key, the Planned walkthrough runs as written.