OAUTH 2.0 · LAB
Watch an API check token, scope and object, in that order
Run a local photo API that owns albums 42 and 43, then call it as Ava, as Ben and as a back-office client to see 401, 403 and identical 404 answers.
ReadyUses your lab tenant
The lesson
Builds on: Using access tokens, Client credentials.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
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 photos.read token for Ava
Recorded as
oauth.tokensucceeded forlab-printerabout[email protected].Get a photos.read token for Ben
Recorded as
oauth.tokensucceeded forlab-printerabout[email protected].Let lab-print-orders request photos.read
Recorded as
tenant.oauth.clients.updatesucceeded.Get a client-only token for lab-print-orders
Recorded as
oauth.tokensucceeded forlab-print-orders.
Setup
Choose Lab Photos as the lab tenant and press Start.
In User Management, make sure Ava and Ben each have a password you know, and store them in your shell with
read -rs AVA_PASSWORDandread -rs BEN_PASSWORDif you like to paste them.Use the variables and the
authorizeandexchangehelpers from Present an access token correctly, forlab-printeron Default access tokens.Have
lab-print-ordersready from Client credentials, with Flow policy allowing client credentials. SetORDERS_IDandread -rs ORDERS_SECRET.
Walkthrough
Get a token for each person and learn their subjects from the authorization server, not from the URL or a header.
authorize "photos.read", sign in as Ava,exchange, thenAVA_TOKEN=$TOKEN.Sign out at
$ISSUER/account(or use a private window),authorize "photos.read", sign in as Ben,exchange, thenBEN_TOKEN=$TOKEN.
sub() { curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/introspect" --data-urlencode "token=$1" | jq -r .sub; }
AVA_SUB=$(sub "$AVA_TOKEN"); BEN_SUB=$(sub "$BEN_TOKEN"); echo "$AVA_SUB $BEN_SUB"
Start the photo API with album 42 owned by Ava and album 43 owned by Ben:
btl-lab resource --mode jwt --album "42=$AVA_SUB" --album "43=$BEN_SUB"
Its route table maps GET /photos and GET /albums/{id}/photos to photos.read, POST /photos to photos.write, POST /shares to photos.share, DELETE /photos/{id} to photos.delete and POST /prints to prints.create. Any route not in the table is refused. The table is enforced in one place, before any route handler runs.
Ava reads her own album:
curl -si http://127.0.0.1:8766/albums/42/photos -H "Authorization: Bearer $AVA_TOKEN". Returns200.
Why it matters: all three questions passed, in order: the token is valid here, its scope covers the operation, and this subject may see this object.
Ava asks for Ben's album, then for one that does not exist:
curl -si http://127.0.0.1:8766/albums/43/photos -H "Authorization: Bearer $AVA_TOKEN"
curl -si http://127.0.0.1:8766/albums/9999/photos -H "Authorization: Bearer $AVA_TOKEN"
Both return 404 with the same body. The API log records object_not_visible for 43 and not_found for 9999; the caller cannot tell them apart.
Why it matters: the token, the issuer, the audience, the expiry and the scope were identical for 42 and 43. Only the API's own data about who owns what can tell them apart, and answering 404 for both stops anyone from learning which album numbers exist.
Ben reads album 43 with his own token:
200. Ava's token on the same URL is still404.
Why it matters: the subject's own rights are the middle limit in the lesson's table. Only the API can apply it, on every request.
Call a route nobody mapped:
curl -si http://127.0.0.1:8766/albums/42/export -H "Authorization: Bearer $AVA_TOKEN". Returns404, logged asunknown_route.
Why it matters: deny by default keeps a forgotten route closed until someone decides which scope it needs.
Call with a token that lacks the scope. Get a token for Ava with
openid profileonly, then call album 42:403withWWW-Authenticate: Bearer error="insufficient_scope", scope="photos.read".
Present a client-only token. In Clients,
lab-print-orders, addphotos.readto Assigned scopes and save. Then:
JOB_TOKEN=$(curl -s -u "$ORDERS_ID:$ORDERS_SECRET" "$ISSUER/oauth/token" -d grant_type=client_credentials -d scope=photos.read | jq -r .access_token)
curl -si http://127.0.0.1:8766/albums/42/photos -H "Authorization: Bearer $JOB_TOKEN"
Returns 403, logged as client_only_token. btl-lab decode "$JOB_TOKEN" shows sub equal to client_id.
Why it matters: a token the client obtained for itself says the printer is asking and says nothing about any person. It must never satisfy an ownership check meant for Ava.
Restore: remove photos.read from lab-print-orders' Assigned scopes so it keeps only prints.create.
Read the decision log. Count refusals by reason and by
client_id. Each line holds the time, route, subject, client,jti, required scope, outcome and reason, and never the token.
Why it matters: a burst of object_not_visible from one client walking through album numbers is exactly what these records exist to show.
Break it
See why the lookup style matters, in a few lines of local code that never touch the tenant. Save this as albums.mjs and run node albums.mjs:
const albums = new Map([['42', { owner: 'ava' }], ['43', { owner: 'ben' }]]);
// Load by ID, then forget to compare the owner: broken object-level authorization.
const loadById = (user, id) => albums.get(id) ?? null;
// Search only albums this user can see: there is no path that holds someone else's album.
const findVisibleTo = (user, id) => (albums.get(id)?.owner === user ? albums.get(id) : null);
console.log('loadById(ava, 43):', loadById('ava', '43'));
console.log('findVisibleTo(ava, 43):', findVisibleTo('ava', '43'));
The first call returns Ben's album for Ava. The second returns null, which the route turns into 404. Delete albums.mjs afterwards.
Check your work
Press Check my progress. The checks look for, in order: Ava's token, Ben's token, the tenant.oauth.clients.update that let lab-print-orders request photos.read, and its client credentials token.
The API log should show allowed, object_not_visible, not_found, unknown_route, insufficient_scope and client_only_token decisions.
Cleanup
Confirm
lab-print-ordersno longer hasphotos.readassigned.Stop
btl-lab resource. Rununset AVA_TOKEN BEN_TOKEN JOB_TOKEN TOKEN RESP.