OAUTH 2.0 · LAB
Validate, approve and enforce authorization details end to end
Trigger each invalid_authorization_details rule, approve one album of two, narrow a token to part of the grant, and watch an API enforce exactly what Ava approved. Today, practice the same rules with scopes.
PlannedIncludes a simulationUses your lab tenant
The lesson
Builds on: Writing authorization details.
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.
- G15 RAR (`authorization_details`)
- G3 Sample protected resource API; no RFC 9728 protected resource metadata
Setup
These steps are real today.
In Lab Photos, open OAuth > Flow policy and allow
refresh_token. Onlab-printer, allow therefresh_tokengrant and assign the scopesphotos.read,photos.deleteandoffline_access.Keep
CLIENT_ID,CLIENT_SECRET,AD,PAYand theenchelper from Write album and payment authorization details. For introspection, exportAPI_IDandAPI_SECRETforlab-photo-api, which has Resource server on.
Planned walkthrough
This walkthrough runs once your tenant supports tenant-defined authorization details types (G15), with the photo_album and payment_initiation types from the previous lab, and hosts a sample photo API with per-user albums (G3).
An unknown field. Send this through the browser as in the previous lab:
[{"type": "photo_album", "actions": ["read"], "identifer": "42"}]
The listener receives error=invalid_authorization_details with state and iss. Audit records oauth.authorize rejected invalid_authorization_details (planned reason).
Why it matters: ignoring a field the client thought limited access would grant something nobody approved, so the whole request is refused.
The other four rules, one request each: an unknown type (
account_information), a field of the wrong kind ("identifier": 42), a value the type does not allow ("actions": ["delete"]), and a missing required field (photo_albumwithoutidentifier). Each gets the same error before Ava sees anything.Approve less than was asked. Send
ADwith its two albums, untick album 57 on the consent screen, and exchange the code. The token response'sauthorization_detailsholds only album 42. Compare it with the request and continue with what was granted.
Why it matters: the server stores what Ava approved with the grant, and later token requests and refreshes are judged against it, as with granted scopes.
Narrow at the token endpoint. Redeem a two-album code with one album named:
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=authorization_code --data-urlencode "code=$CODE" \
--data-urlencode redirect_uri=http://127.0.0.1:8765/callback --data-urlencode "code_verifier=$VERIFIER" \
--data-urlencode 'authorization_details=[{"type":"photo_album","actions":["read"],"identifier":"42"}]' | jq .authorization_details
The token covers album 42 only. Asking for album 99 instead returns invalid_authorization_details.
Why it matters: a token can carry part of the grant, never more.
The API's copy.
btl-lab introspect "$TOKEN"shows the same approvedauthorization_details, filtered to what the photo API needs.Enforcement at the sample API:
GET $ISSUER/resource/albums/42/photosreturns200;.../albums/57/photos,GET $ISSUER/resource/photosand anyDELETEreturn403.
Why it matters: the API looks for an object of a type it understands whose action and identifier match the request, compared exactly, then still checks that album 42 is Ava's.
Escaping on the consent screen. Push a payment whose
creditorNameis<b>Photo Printer</b>. The consent screen shows the angle brackets as text.
Why it matters: every value on that screen came from the client, so it is data, never markup.
Do today
The scope version of step 4 is real. Start
btl-lab callback, then requestphotos.read photos.delete offline_access:
eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=$(enc 'photos.read photos.delete offline_access')&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"
Approve as Ava, check state and iss, read -rs CODE, exchange the code and keep the refresh token in REFRESH. Then ask for part of the grant, and then for more than it:
RESP=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=refresh_token --data-urlencode "refresh_token=$REFRESH" -d scope=photos.read)
REFRESH=$(jq -r .refresh_token <<<"$RESP"); jq '{scope}' <<<"$RESP"; unset RESP
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=refresh_token --data-urlencode "refresh_token=$REFRESH" \
-d 'scope=photos.read photos.share' | jq .
The first returns "scope": "photos.read". The second returns invalid_scope, and Audit records oauth.token rejected scope_not_granted. The rotated refresh token still carries the full grant, just as a narrowed details token leaves the stored approval intact.
Build the API's matching rule now.
Simulation. no tenant issues authorization_details yet, so these claims are the lesson's hand-written token, not one from your tenant.
Save as match.mjs:
const claims = {sub: 'user-2048', authorization_details: [{type: 'photo_album', actions: ['read'], identifier: '42'}]};
const [action, album] = process.argv.slice(2);
const ok = (claims.authorization_details ?? []).some(d => d.type === 'photo_album' && d.actions?.includes(action) && d.identifier === album);
console.log(ok ? 'allow, then check that the album is the subject\'s' : '403 Forbidden');
Run node match.mjs read 42 (allow), node match.mjs read 57 and node match.mjs delete 42 (both 403 Forbidden).
Break it
Planned: steps 1, 2 and 4 are the failures. Each is a decision, and repeating the same request changes nothing.
Check your work
There are no automated checks while this lab is planned. For Do today, look in Lab Photos' Audit for oauth.token succeeded for lab-printer with the refresh grant and oauth.token rejected scope_not_granted.
Cleanup
Delete match.mjs if you do not want it and run unset REFRESH CODE.
Missing infrastructure
G15 Rich Authorization Requests: the five validation rules, partial approval on the consent screen, enrichment where a type allows it, storage with the grant, narrowing at the token endpoint, and the token, JWT and introspection members.
G3 Sample protected resource API: a hosted photo API with per-user sample albums, so the refusal for an album that belongs to someone else is real. Your tenant itself has no albums to check.