OAUTH 2.0 · LAB
Follow a step-up challenge from challenge to retry
Have your photo API refuse a deletion with insufficient_user_authentication, copy its max_age into a new authorization request, sign Ava in again, and retry with the new token.
Partly readyUses your lab tenant
The lesson
Builds on: When an API needs stronger authentication.
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
- G3 Sample protected resource API; no RFC 9728 protected resource metadata
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.
Sign in again because max_age ruled out the old session
Recorded as
oauth.authorizesucceeded (user_signed_in) forlab-printer-app.Receive the step-up code
Recorded as
oauth.authorizesucceeded (code_issued) forlab-printer-app.Exchange it for the stepped-up token
Recorded as
oauth.tokensucceeded forlab-printer-app.Let the photo API check the new token
Recorded as
oauth.introspectsucceeded (other_client_token_found) forlab-photo-api.
Setup
You need lab-printer-app with lab-tmp-at-signin (including the auth_time mapping), APP_ID, API_ID, API_SECRET and the REFRESH value from Read what an access token says about how and when Ava signed in. Keep Ava signed in to Lab Photos in your browser.
In its own terminal, start the local photo API with a two-minute deletion policy. It validates tokens by introspection as
lab-photo-apiand logsallowedorchallengedfor each request:
btl-lab resource --port 8766 --mode introspect --audience "$ISSUER/resource" --delete-max-age 120
Get an everyday token from your saved refresh token, as the app would each morning:
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
If the refresh token has expired or was revoked, repeat step 1 of the previous lab instead.
Press Start on this page with Lab Photos selected.
Walkthrough
Make sure Ava's last sign-in is more than two minutes old (
btl-lab decode "$TOKEN"showsauth_time), then try to delete a photo:
curl -s -D hdr.txt -o /dev/null -w '%{http_code}\n' -X DELETE -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8766/photos/9137
grep -i www-authenticate hdr.txt
The status is 401 and the header reads Bearer error="insufficient_user_authentication", max_age="120". The API logs challenged.
Why it matters: the token is valid and carries photos.delete; only the sign-in behind it is too old. The status is 401 because the remedy is a different token.
Read the requirement from the challenge exactly as received:
MAXAGE=$(sed -n 's/.*max_age="\([0-9]*\)".*/\1/p' hdr.txt); echo "$MAXAGE"
Why it matters: the app copies the API's requirement into the authorization request instead of inventing its own.
Ask for a new sign-in. Start
btl-lab callback, then:
eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$APP_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=photos.read%20photos.delete&max_age=$MAXAGE&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"
Open the URL in the browser where Ava is still signed in. Lab Photos asks her to sign in again instead of continuing silently. Sign in with her password (the planned walkthrough adds the passkey requirement), approve, check state and iss, then exchange the code:
read -rs CODE
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); unset CODE
Why it matters: max_age tells the authorization server that a session older than the limit does not count, even though it is still active. Everything else is an ordinary request, with a fresh state and PKCE challenge.
Retry the original request with the new token:
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE -H "Authorization: Bearer $STEP_TOKEN" http://127.0.0.1:8766/photos/9137
The status is 204 and the API logs allowed. For the lab, btl-lab decode "$STEP_TOKEN" shows an auth_time a few seconds old; the app itself never decodes it. It just retries and lets the API decide.
Why it matters: the API makes the final decision both times, from the token alone.
Freshness runs out. Wait two more minutes and call again with the same token.
GET /photosstill returns200, butDELETE /photos/9137is challenged again.
Why it matters: a stepped-up sign-in meets the deletion policy only for its window. After that the app must decide whether to ask Ava again or stop.
Planned walkthrough
These steps need tenant-defined acr values (G20) and acr in access tokens (G48). A hosted photo API (G3) would replace the local one.
Start the API with a strength requirement too:
--delete-acr urn:btl:acr:phishing-resistant(planned option). The challenge becomesBearer error="insufficient_user_authentication", acr_values="urn:btl:acr:phishing-resistant", max_age="120".The step-up request adds
&acr_values=urn%3Abtl%3Aacr%3Aphishing-resistant. Lab Photos, whose authentication policy maps that value to passkey sign-in, prompts for Ava's passkey rather than her password, and treats the value as a requirement, not a preference.The new access token carries
"acr": "urn:btl:acr:phishing-resistant"and a freshauth_time, introspection returns both, and the API comparesacrexactly with its accepted list.
Break it
Leave the requirement out of the step-up request. Run step 3 again without
&max_age=$MAXAGE. Lab Photos reuses Ava's existing session without a prompt, so Audit showscode_issuedwith no newuser_signed_in. The new token'sauth_timeis the old one, and the retry is challenged again.
Why it matters: a step-up request that does not carry the requirement produces another token the API refuses.
Check your work
Press Check my progress. The checks look in Lab Photos for the new sign-in forced by max_age, the step-up code and its exchange for lab-printer-app, and the photo API's introspection of the new token. The API's own log shows challenged, allowed, then challenged again after the window.
Cleanup
Stop the local API. Keep TOKEN (the everyday token), STEP_TOKEN, REFRESH and lab-tmp-at-signin for the next lab, or, if you stop here, assign lab-printer-app back to the tenant default manager, delete lab-tmp-at-signin, delete hdr.txt and unset the token variables.
Missing infrastructure
G20
acrandacr_values: the strength half of the challenge, honored as a requirement, with the tenant's own mapping fromacrvalues to sign-in methods.G48 Authentication context in access tokens:
acrin the access token and introspection response, so the API can check strength as well as freshness.G3 Sample protected resource API: a hosted photo API with a configurable deletion policy, so the challenge comes from a real tenant service instead of
btl-lab resourceon loopback.