OAUTH 2.0 · LAB
Validate a signed authorization response in the right order
Write a client-side check that tests issuer, audience and expiry, then the signature with keys from configuration, and only then uses state and code. Practice it today on the tenant's signed ID token response.
PlannedUses your lab tenant
The lesson
Builds on: Following a JARM exchange.
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.
- G13 JARM
Setup
Use
lab-printer, the shell variables, theb64urlhelper andnc -l 8765from the earlier labs.Add a helper that reads a JWT's claims without trusting them. It only decodes; it checks nothing.
claims() { local p; p=$(printf %s "$1" | cut -d. -f2 | tr '_-' '/+'); while [ $(( ${#p} % 4 )) -ne 0 ]; do p="$p="; done; printf %s "$p" | base64 -d; }
Planned (G13): allow
form_post.jwtonlab-printerwith the signing algorithmES256.
Planned walkthrough
Record the pending attempt before sending the request.
btl-lab pkce; btl-lab state # export VERIFIER, CHALLENGE, STATE and NONCE
export PENDING_ISS=$ISSUER PENDING_CLIENT=$CLIENT_ID
Run a code flow with
&response_mode=form_post.jwtwhilenc -l 8765listens. Copy theresponsevalue from the POST body intoRESPONSE. Read it without trusting it:claims "$RESPONSE" | jq .Check in order and stop at the first failure.
C=$(claims "$RESPONSE")
[ "$(jq -r .iss <<<"$C")" = "$PENDING_ISS" ] || echo "reject: unexpected_issuer"
[ "$(jq -r .aud <<<"$C")" = "$PENDING_CLIENT" ] || echo "reject: wrong_audience"
[ "$(jq -r .exp <<<"$C")" -gt $(( $(date +%s) - 30 )) ] || echo "reject: expired_response"
btl-lab verify "$RESPONSE" --issuer "$PENDING_ISS" --audience "$PENDING_CLIENT" --type jarm >/dev/null || echo "reject: bad_signature"
[ "$(jq -r .state <<<"$C")" = "$STATE" ] || echo "reject: state_mismatch"
Why it matters: "Checks before the code is used". The issuer is checked first against your own configuration because it decides which key set to use, and no branch reaches the code exchange without passing every check.
Only now exchange the code with
$VERIFIER.
Why it matters: the code is the last thing the client touches, never the first.
Start a new attempt and deny consent. The
responseJWT carrieserror: access_deniedandstate. Run it through the same checks before recording a refusal.
Why it matters: "Errors inside the JWT". A verified error is reliable information; an unverified one, or a plain error parameter when you asked for a signed response, is not.
If the tenant also sends a plain
issparameter beside the JWT, compare it with the claim. They must match exactly, or the client rejects the response.
Why it matters: "The iss parameter and the iss claim". Two issuer values that differ are never resolved by picking one.
Do today
The tenant already signs one kind of authorization response: an ID token returned from the authorization endpoint with response_type=code id_token. That ID token can be validated in exactly the order the lesson teaches.
Widen the tenant for this exercise only. In OAuth > Flow policy, allow the
implicitgrant and the response typecode id_token, and keep theform_postmode. Onlab-printer, allowcode id_tokenandform_post.Save the tenant's current key set as your client's cached copy, then record a pending attempt.
curl -s "$ISSUER/oauth/jwks" > jwks-cached.json
btl-lab pkce; btl-lab state # export VERIFIER, CHALLENGE, STATE and NONCE
export PENDING_ISS=$ISSUER PENDING_CLIENT=$CLIENT_ID
echo "$ISSUER/oauth/authorize?response_type=$(urlenc 'code id_token')&response_mode=form_post&client_id=$CLIENT_ID&redirect_uri=$(urlenc $REDIRECT)&scope=openid&state=$STATE&nonce=$NONCE&code_challenge=$CHALLENGE&code_challenge_method=S256"
Start
nc -l 8765, open the address and approve as Ava. The POST body holdscode,id_token,stateandiss. Save them asCODEandID_TOKEN.Run Planned walkthrough step 3's checks on
ID_TOKENinstead ofRESPONSE, then the ID token specific ones.
btl-lab verify "$ID_TOKEN" --issuer "$PENDING_ISS" --audience "$PENDING_CLIENT" --type id --nonce "$NONCE"
printf %s "$CODE" | openssl dgst -sha256 -binary | head -c 16 | b64url; echo # must equal c_hash
claims "$ID_TOKEN" | jq -r .c_hash
The ID token is the tenant's signature over this response, and c_hash binds this code to it. This is the protection FAPI 1.0 Advanced accepted as an alternative to JARM.
Only now compare the POST body's
statewith$STATE, then redeemCODEwith$VERIFIER. Audit showsoauth.tokensucceeded.Wrong audience. Sign in through the Token Decoder at
$ISSUER/token-decoder, copy its ID token and run the step 4btl-lab verifycommand on it. It stops at the audience check, because that token was issued to a different client.Unknown key. In OAuth > Signing keys, generate a new RS256 key and point the ID token manager at it; the old key moves to retiring. Run steps 2 to 4 again, but before verifying, look for the new token's
kidin your cached copy.
KID=$(printf %s "$ID_TOKEN" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null | jq -r .kid)
jq -e --arg k "$KID" '.keys[] | select(.kid == $k)' jwks-cached.json >/dev/null || echo "unknown kid: fetch the key set once, then verify again"
This is the lesson's first failure row: the cached set is old. Fetch it again once (curl -s "$ISSUER/oauth/jwks" > jwks-cached.json), and the kid is found.
Restore: remove code id_token from lab-printer and, unless other labs need it, form_post. Remove code id_token and the implicit grant from the Flow policy.
Break it
Expired response. Once G13 exists, hold a
responseJWT past itsexp(about five minutes), then run the step 3 checks:reject: expired_response. Offer a fresh attempt rather than retrying the same response.A response that cannot be trusted. Paste a plain
error=access_deniedcallback into your client while it expects a signed response. Treat it as "failed for an unknown reason", not as the person's refusal.
Check your work
Today, Audit shows oauth.token succeeded for lab-printer, tenant.oauth.keys.generate and tenant.oauth.id_token_managers.update for the key change, and tenant.oauth.policy.update for the widening and its restore. Your checks print no reject: line for the good response and stop at the audience check for the Token Decoder's token. Once G13 exists, the same checks run on JARM responses.
Cleanup
Leave the new ID token key active. Retire the old key only after the tokens it signed have expired.
Confirm the Flow policy no longer allows
implicitorcode id_token.Delete
jwks-cached.jsonand stopnc.
Missing infrastructure
G13: JARM response modes and per-client response signing. Once they exist, the Planned walkthrough runs as written, and the same order of checks applies to every signed response the client receives.