OAUTH 2.0 · LAB
Make your photo API refuse tokens meant for another API
Run a local photo API that knows its own identifier, accept a token addressed to it, and refuse an active token from the same issuer and key that names the print API.
Partly readyUses your lab tenant
The lesson
Builds on: Selecting the intended resource.
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.
- G16 Resource indicators
- G55 Tenant resource (API) registry
- 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.
Get a photo API token for Ava through lab-printer
Recorded as
oauth.tokensucceeded forlab-printer.Get a print API token through lab-print-orders
Recorded as
oauth.tokensucceeded forlab-print-orders.Let the photo API introspect a token issued to another client
Recorded as
oauth.introspectsucceeded (other_client_token_found) forlab-photo-api.Try to revoke lab-print-orders' token as lab-printer
Recorded as
oauth.revokerejected (other_client_token) forlab-printer.Revoke lab-printer's own token
Recorded as
oauth.revokesucceeded (access_token_found) forlab-printer.
Setup
In Lab Photos, confirm OAuth > Flow policy allows
authorization_codeandclient_credentials.Confirm
lab-photo-apiexists: confidential, with Resource server on, so it may introspect access tokens issued to other clients in this tenant. Create it if you skipped the OAuth core track.In OAuth > Access token managers, create
lab-tmp-at-prints(JWT, ES256) with the standardaudclaim overridden tohttps://prints.lab.example, and assign it tolab-print-orders. If you kept it from the previous lab, reuse it.lab-printerkeeps the tenant default manager, whose audience is$ISSUER/resource, the photo API's identifier in this track.Store the three clients' credentials in the shell.
btl-lab introspectandbtl-lab resourcereadAPI_IDandAPI_SECRET:
export CLIENT_ID='<lab-printer client id>'; read -rs CLIENT_SECRET; export CLIENT_SECRET
export ORDERS_ID='<lab-print-orders client id>'; read -rs ORDERS_SECRET; export ORDERS_SECRET
export API_ID='<lab-photo-api client id>'; read -rs API_SECRET; export API_SECRET
Press Start on this page with Lab Photos selected.
Walkthrough
Start the photo API with its own identifier. It validates tokens by introspection as
lab-photo-apiand logs one line per request:
btl-lab resource --port 8766 --mode introspect --audience "$ISSUER/resource"
Why it matters: the API knows its identifier from configuration, never from the request.
A token for this API. In another terminal, start
btl-lab callback, then run an ordinary code flow forlab-printer:
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=photos.read&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"
Approve as Ava, check that state and iss match, then:
read -rs CODE
TOKEN=$(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" | jq -r .access_token); unset CODE
btl-lab decode "$TOKEN"
curl -s -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8766/photos
The token's aud is <your ISSUER>/resource; the API returns 200 and logs allowed.
Why it matters: the token names this API, so it passes the exact comparison.
A token for another API, from the same issuer and the same kind of key:
ORDERS_TOKEN=$(curl -s -u "$ORDERS_ID:$ORDERS_SECRET" "$ISSUER/oauth/token" -d grant_type=client_credentials -d scope=prints.create | jq -r .access_token)
btl-lab decode "$ORDERS_TOKEN"
curl -s -i -H "Authorization: Bearer $ORDERS_TOKEN" http://127.0.0.1:8766/photos | grep -i -E '^HTTP|www-authenticate'
The header shows the same iss and an ES256 signature, aud is https://prints.lab.example, and the API answers 401 with WWW-Authenticate: Bearer error="invalid_token", logging a wrong audience.
Why it matters: there is no partial credit for a token from the same authorization server. aud may be a string or an array, and the API accepts the token only when its own identifier is in it.
Active is not enough. Ask the authorization server about the print token, as the photo API does:
btl-lab introspect "$ORDERS_TOKEN"
The response says "active": true, with aud https://prints.lab.example and the print orders client as client_id. The API still refuses it.
Why it matters: introspection says the server still honors the token, not that it was issued for this API. An API that relies on introspection compares the aud member exactly as it would the claim.
The request cannot choose the expected value:
curl -s -i -H 'Host: prints.lab.example' -H "Authorization: Bearer $ORDERS_TOKEN" http://127.0.0.1:8766/photos | head -n 1
It is still 401.
Why it matters: the caller controls the Host header, so the API never derives its identifier from it.
The second question: scopes. Change
lab-tmp-at-printsso itsaudoverride is the list["https://prints.lab.example", "<your ISSUER>/resource"]and save. Get a newORDERS_TOKENas in step 3 and call the photo API again. The audience now matches, but the API answers403witherror="insufficient_scope", becauseprints.createmeans nothing at the photo API.
Restore: set the aud override on lab-tmp-at-prints back to the single value https://prints.lab.example and save.
Why it matters: a token that names this API but carries only another API's scopes authorizes nothing here. A token with several audiences also means another API holds a working copy, which an API may refuse or accept only for less sensitive operations.
What the audience does not cover. Copy
$TOKENinto a different terminal and call the API from there. It works for anyone who holds it, until it expires or is revoked.
Why it matters: the audience limits where a token works, not who presents it. Binding, through DPoP or mutual TLS, answers who.
Planned walkthrough
These steps need request-time audiences (G16) and the tenant resource registry (G55).
With the registry from Name the API you want a token for,
lab-printernames the photo API withresource=$ISSUER/resourceandlab-print-ordersnamesresource=https://prints.lab.example, so each token's audience comes from the request instead of a per-client manager. One client can then hold tokens for both APIs.The counterfeit API. A client that lets a user type an API address sends
resource=https://photos-api.unknown.example. Lab Photos does not recognize it and refuses withinvalid_targetat the token endpoint, or through the browser withstateandissat the authorization endpoint. Audit recordsoauth.tokenrejectedinvalid_target(planned reason). Even a careless server that issued such a token would write the unknown address intoaud, and the photo API in step 3 above would refuse it.
Break it
Misconfigure the API. Stop it and restart it with a trailing slash:
btl-lab resource --port 8766 --mode introspect --audience "$ISSUER/resource/"
Ava's genuine token from step 2 is now refused as a wrong audience.
Restore: restart the API with --audience "$ISSUER/resource". The comparison is exact; fix the configuration, never add prefix or slash tolerance.
Revocation belongs to the token's own client. As
lab-printer, try to revoke the print orders token, then revoke your own:
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/revoke" --data-urlencode "token=$ORDERS_TOKEN" | jq .
curl -s -o /dev/null -w '%{http_code}\n' -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/revoke" --data-urlencode "token=$TOKEN"
curl -s -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8766/photos | head -n 1
The first is refused with unauthorized_client, the second returns 200, and the API now refuses Ava's token because introspection reports it inactive. An API validating the JWT only locally against the key set would keep accepting it until exp.
Why it matters: introspection sees revocation; local validation trades that for speed.
Check your work
Press Check my progress. The checks look in Lab Photos for the two token requests, the photo API's introspection of another client's token, the refused revocation of the print token, and the revocation of Ava's token. The photo API's own log shows allowed, a wrong audience, insufficient scope and an inactive token.
Cleanup
Stop the photo API. Confirm lab-tmp-at-prints has a single audience. Assign lab-print-orders back to the access token manager it used before and delete lab-tmp-at-prints. Run unset TOKEN ORDERS_TOKEN.
Missing infrastructure
G16 Resource indicators and G55 Tenant resource (API) registry: today the audience is fixed per client by its access token manager, so one client cannot get tokens for two APIs, and the tenant cannot refuse an unknown resource with
invalid_target. With both, the planned steps run as written.G3 Sample protected resource API: a hosted photo API at
$ISSUER/resourcewould let learners run the audience check without the localbtl-lab resource, which remains useful for seeing the check itself.