OAUTH 2.0 · LAB
End every step-up attempt cleanly, with photos deleted or clearly kept
Build a delete action that steps up at most once, never refreshes to satisfy a challenge, keeps everyday and stepped-up tokens apart, and tells Ava plainly when photos were not deleted.
Partly readyUses your lab tenant
The lesson
Builds on: Following a step-up challenge.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Partly ready. Most of this lab runs today. Steps that wait on platform features are marked, and Missing infrastructure says what they need.
- G20 `acr` / `acr_values` and acr-driven step-up
- G48 Authentication context (`amr`, request context) in the token policy script and in access tokens and introspection
Your progress
Press Start before you begin. Only events your tenant records after that count, in the order below. Checking reads your tenant's Audit, so you need Audit read access in it.
Sign in to start this lab and check your progress. Log in or create an account.
Refresh the everyday token
Recorded as
oauth.tokensucceeded forlab-printer-app.Step up once for a deletion Ava chose
Recorded as
oauth.authorizesucceeded (user_signed_in) forlab-printer-app.Cancel a step-up at the consent screen
Recorded as
oauth.authorizerejected (access_denied) forlab-printer-app.
Setup
You need lab-printer-app with lab-tmp-at-signin, APP_ID, API_ID, API_SECRET, the everyday TOKEN and REFRESH from Follow a step-up challenge from challenge to retry.
In its own terminal, start the local photo API with a two-minute deletion policy:
btl-lab resource --port 8766 --mode introspect --audience "$ISSUER/resource" --delete-max-age 120
Save the app's delete action. It runs only when Ava chooses to delete, makes at most one step-up attempt, asks for
photos.deletealone, and never refreshes in response to a challenge:
cat > delete-photo.sh <<'EOF'
try_delete() { # try_delete TOKEN PHOTO: prints the status and keeps the headers for a challenge
curl -s -o /dev/null -D hdr.txt -w '%{http_code}' -X DELETE -H "Authorization: Bearer $1" "http://127.0.0.1:8766/photos/$2"
}
delete_photo() { # delete_photo PHOTO: one user action, at most one step-up
local status; status=$(try_delete "${STEP_TOKEN:-$TOKEN}" "$1")
[ "$status" = 204 ] && { echo "Deleted $1."; return 0; }
grep -qi insufficient_user_authentication hdr.txt || { echo "Not deleted: the photo API refused the request ($status)."; return 1; }
local maxage; maxage=$(sed -n 's/.*max_age="\([0-9]*\)".*/\1/p' hdr.txt)
eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
echo "Deleting photos needs a recent sign-in. Open this, sign in, then paste what the callback shows:"
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$APP_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=photos.delete&max_age=$maxage&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"
local code st iss err
read -rsp 'code: ' code; echo; read -rp 'state: ' st; read -rp 'iss: ' iss; read -rp 'error: ' err
[ "$iss" = "$ISSUER" ] && [ "$st" = "$STATE" ] || { echo "Not deleted: the sign-in response could not be verified."; return 1; }
[ -z "$err" ] || { echo "Not deleted: sign-in was not completed. Your photos are unchanged."; return 1; }
STEP_TOKEN=$(curl -s "$ISSUER/oauth/token" -d grant_type=authorization_code --data-urlencode "client_id=$APP_ID" --data-urlencode "code=$code" \
--data-urlencode redirect_uri=http://127.0.0.1:8765/callback --data-urlencode "code_verifier=$VERIFIER" | jq -r .access_token)
status=$(try_delete "$STEP_TOKEN" "$1")
[ "$status" = 204 ] && { echo "Deleted $1."; return 0; }
echo "Not deleted: the new sign-in did not meet the requirement. Not retrying."; return 1
}
EOF
. ./delete-photo.sh
Press Start on this page with Lab Photos selected.
Walkthrough
Refreshing does not help. Refresh the everyday token, then try a deletion with it once Ava's sign-in is more than two minutes old:
RESP=$(curl -s "$ISSUER/oauth/token" -d grant_type=refresh_token --data-urlencode "client_id=$APP_ID" --data-urlencode "refresh_token=$REFRESH")
TOKEN=$(jq -r .access_token <<<"$RESP"); REFRESH=$(jq -r .refresh_token <<<"$RESP"); unset RESP
try_delete "$TOKEN" 9137; echo
The status is 401: the refreshed token carries the original auth_time, so it fails the same check.
Why it matters: the delete action never refreshes to satisfy a challenge. Only a new sign-in changes auth_time.
One step-up, then success. Start
btl-lab callback, then:
unset STEP_TOKEN; delete_photo 9137
Open the printed URL, sign in as Ava with her password, approve and paste the listener's values. The action prints Deleted 9137.
Why it matters: the step-up started from an action Ava chose, and the request asked only for photos.delete, so the stepped-up token carries only the access that needed it.
Two kinds of token. Browse with the everyday token and look at what each one carries:
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8766/photos
btl-lab introspect "$STEP_TOKEN"
Browsing returns 200. The stepped-up token's scope is photos.delete alone. The app sends TOKEN for reading and STEP_TOKEN for deleting.
Why it matters: the app chooses by remembering why it obtained each token, not by reading acr or auth_time inside it. The token is opaque to the client.
No loop. Restart the API with an impossible window, then try again with a fresh step-up:
btl-lab resource --port 8766 --mode introspect --audience "$ISSUER/resource" --delete-max-age 5
unset STEP_TOKEN; delete_photo 9137
By the time you sign in and paste the values, more than five seconds have passed. The API challenges the new token too, and the action stops with Not retrying.
Why it matters: one step-up attempt per action. A second challenge means trying again will not meet the requirement, and the app explains instead of redirecting forever.
Ava cancels. Restart the API with
--delete-max-age 120, rununset STEP_TOKEN; delete_photo 9137once the sign-in is more than two minutes old, and choose Deny on the consent screen. Paste the values, includingaccess_deniedas the error. The issuer and state checks pass, and the action printsNot deleted: sign-in was not completed. Your photos are unchanged.
Why it matters: Ava knows the photos were kept, and nothing in the message hints at a way around the requirement.
The API's order of checks:
curl -s -i -X DELETE http://127.0.0.1:8766/photos/9137 | grep -i www-authenticate
curl -s -i -X DELETE -H 'Authorization: Bearer not-a-token' http://127.0.0.1:8766/photos/9137 | grep -i www-authenticate
curl -s -i -X DELETE -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8766/photos/9137 | grep -i www-authenticate
No token gets a plain Bearer challenge, a value that is not a token gets error="invalid_token", and only a valid token with an old sign-in gets the insufficient_user_authentication challenge, built from the API's own policy.
Why it matters: requirements are revealed only to holders of a valid token. A challenge is a request for a better token, never a source of authority, and a missing claim counts as not meeting the requirement.
Planned walkthrough
These steps need tenant-defined acr values (G20) and acr in access tokens (G48).
Remove Ava's passkey at
$ISSUER/account/securityand send a step-up request withacr_values=urn:btl:acr:phishing-resistant. Instead of signing her in with a password and issuing a token the API would refuse, Lab Photos returnserror=unmet_authentication_requirementswithstateandiss, and Audit recordsoauth.authorizerejectedunmet_authentication_requirements(planned reason). The delete action tells Ava that deleting needs a passkey and points to her account settings.Before redirecting, the action checks that the requested value appears in
acr_values_supportedin discovery, and stops if it does not.The administrator decides which methods satisfy each
acrvalue and can count unmet requirements in Audit, a sign of an enrollment gap rather than a protocol problem.
Break it
Steps 1, 4 and 5 are the failures: a refresh, an impossible window and a cancellation each end with the photo kept and a clear message.
Check your work
Press Check my progress. The checks look in Lab Photos for the refresh in step 1, Ava's step-up sign-in in step 2 and the cancelled step-up in step 5, all for lab-printer-app. The photo API's log shows challenged after the refresh, allowed once, and challenged after the five-second window.
Cleanup
Stop the photo API and delete
hdr.txtanddelete-photo.shif you do not want them.Assign
lab-printer-appback to the tenant default access token manager and deletelab-tmp-at-signin.Optionally remove Ava's passkey at
$ISSUER/account/securityand set passkey back to how you found it in Authentication.Run
unset TOKEN STEP_TOKEN REFRESH.
Missing infrastructure
G20
acrandacr_values:unmet_authentication_requirementswhen the requested context cannot be met,acr_values_supportedfor the precheck, and the tenant's mapping fromacrvalues to methods.G48 Authentication context in access tokens:
acrin tokens and introspection, so the API's check includes strength as well as freshness.