OAUTH 2.0 · LAB
Build a request object and send it by value or by reference
Turn lab-printer's authorization request into request object claims, measure the signed URL, and plan the switch to private_key_jwt, by reference delivery and encryption.
PlannedUses your lab tenant
The lesson
Builds on: The problem JAR solves.
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.
- G12 JAR
- G8 Client authentication beyond `client_secret_basic` and `none`
- G56 Client JWKS (`jwks` / `jwks_uri` per client)
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
Keep
printer-2026-10.pemandprinter-2026-10.pub.pemfrom Compare a server's record with a signature anyone can verify, and the shell variables from the PAR labs.Add a Base64url helper to your shell.
b64url() { base64 | tr '+/' '-_' | tr -d '=\n'; }
Planned (G56, G8): on
lab-printer, registerprinter-2026-10under Keys, set the authentication method toprivate_key_jwtand the request object signing algorithm toES256.Planned (G12): in OAuth > Flow policy, allow request objects by value and, optionally, by reference from registered HTTPS addresses.
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 the claims: every authorization parameter plus the claims that protect the object. This step runs today.
btl-lab pkce; btl-lab state # export VERIFIER, CHALLENGE and STATE
NOW=$(date +%s)
CLAIMS=$(jq -nc --arg iss "$CLIENT_ID" --arg aud "$ISSUER" --arg ru "$REDIRECT" --arg st "$STATE" --arg cc "$CHALLENGE" \
--argjson now $NOW --arg jti "$(openssl rand 16 | b64url)" \
'{iss:$iss, aud:$aud, response_type:"code", client_id:$iss, redirect_uri:$ru, scope:"openid photos.read",
state:$st, code_challenge:$cc, code_challenge_method:"S256", iat:$now, nbf:$now, exp:($now+300), jti:$jti}')
echo "$CLAIMS" | jq .
Why it matters: "From parameters to claims". The names are the same, the values are plain JSON, the return address is no longer URL-encoded, and there is no sub.
Sign
CLAIMSas ES256 withprinter-2026-10.pemand the header{"alg":"ES256","kid":"printer-2026-10","typ":"oauth-authz-req+jwt"}. Save the result asOBJand decode it withbtl-lab decode "$OBJ".
Why it matters: "Claims that protect the object". iss, aud, exp and jti bind the object to one client, one server and five minutes, and typ says what kind of JWT it is.
Send it by value, open the address and approve as Ava. The listener prints
code,stateandiss.
URL="$ISSUER/oauth/authorize?client_id=$CLIENT_ID&request=$OBJ"; printf %s "$URL" | wc -c
Why it matters: the signed URL is roughly three times longer than the plain one, which is why the other routes exist.
Redeem the code with a client assertion instead of a secret. Sign the claims
{"iss":"$CLIENT_ID","sub":"$CLIENT_ID","aud":"$ISSUER","iat":<now>,"exp":<now+60>,"jti":"<random>"}with the same key and save the result asASSERTION.
curl -s "$ISSUER/oauth/token" -d grant_type=authorization_code -d code=<code> --data-urlencode redirect_uri=$REDIRECT \
-d code_verifier=$VERIFIER -d client_id=$CLIENT_ID \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -d client_assertion=$ASSERTION | jq '{token_type, scope}'
Why it matters: one key pair now covers request signing and client authentication. The assertion's sub and the object's typ keep the two kinds of JWT apart.
Optional, by reference: serve
OBJat an HTTPS address under the prefix you registered, withContent-Type: application/oauth-authz-req+jwt, and open$ISSUER/oauth/authorize?client_id=$CLIENT_ID&request_uri=<url-encoded address>.
Why it matters: the URL stays short, but the tenant now fetches from an address the client hosts, so the address needs a long random part and a short life.
Optional, encrypted: encrypt the signed object to the tenant's
use: enckey from its JWKS (algRSA-OAEP-256,encA256GCM,ctyJWT), send it by value, and count five dot-separated parts.
Why it matters: sign first, then encrypt. Encryption alone proves nothing about who made the object.
Do today
Run Planned walkthrough step 1. It is local and works now.
Measure what signing would cost in URL length, without signing anything. The header and claims are Base64url-encoded, and an ES256 signature always adds 86 characters.
HEADER='{"alg":"ES256","kid":"printer-2026-10","typ":"oauth-authz-req+jwt"}'
INPUT="$(printf %s "$HEADER" | b64url).$(printf %s "$CLAIMS" | b64url)"
echo "plain: $(printf %s "$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$(urlenc $REDIRECT)&scope=$(urlenc 'openid photos.read')&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256" | wc -c)"
echo "signed: $(( $(printf %s "$ISSUER/oauth/authorize?client_id=$CLIENT_ID&request=" | wc -c) + ${#INPUT} + 87 ))"
Compare the two numbers with the lesson's 270 and 770.
Check the object rules by hand: confirm that
CLAIMScontains neitherrequestnorrequest_uri, and has nosub.Send an authorization request with a
requestparameter. The tenant refuses on its presence, so a stand-in value is enough.
curl -s -o /dev/null -w '%{redirect_url}\n' "$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$(urlenc $REDIRECT)&state=jar-2&request=demo-request-object"
The redirect carries error=request_not_supported with state and iss; Audit shows oauth.authorize rejected request_not_supported.
Try a client assertion at the token endpoint. Again the tenant refuses on the parameter's presence.
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 with a description naming client_secret_basic, and Audit shows oauth.token rejected unsupported_auth_method.
Read the algorithms the tenant advertises.
GET$ISSUER/.well-known/oauth-authorization-server
Open in console
GET $ISSUER/.well-known/oauth-authorization-servertoken_endpoint_auth_methods_supported is ["client_secret_basic","none"], and there is no request_object_signing_alg_values_supported.
Break it
These run once G12 exists.
Put
request_uriinside the claims and send the signed object:error=invalid_request_object. An object cannot point to another object.Sign a correct object with a fresh key that is not registered for
lab-printer:error=invalid_request_object.
Check your work
Today, Audit shows oauth.authorize rejected request_not_supported and oauth.token rejected unsupported_auth_method for lab-printer. Once the gaps close, Audit also shows oauth.authorize code_issued with the object's jti, and oauth.token succeeded with the authentication method private_key_jwt.
Cleanup
Remove any request object file you served.
Keep
printer-2026-10.pemfor the next lab.
Missing infrastructure
G12: request object parsing and policy, by value, by reference from registered HTTPS addresses with fetch limits, and decryption with a tenant
enckey.G8:
private_key_jwtclient authentication.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.