Beta

Create a tenant

A new tenant starts with its own users, OAuth settings, audit history and logs. You are its first Tenant Admin.

BTL Admin

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.

  1. Get a photos.read token for Ava

    Recorded as oauth.token succeeded for lab-printer about [email protected].

  2. Get a photos.read token for Ben

    Recorded as oauth.token succeeded for lab-printer about [email protected].

  3. Let lab-print-orders request photos.read

    Recorded as tenant.oauth.clients.update succeeded.

  4. Get a client-only token for lab-print-orders

    Recorded as oauth.token succeeded for lab-print-orders.

Setup

  1. Choose Lab Photos as the lab tenant and press Start.

  2. In User Management, make sure Ava and Ben each have a password you know, and store them in your shell with read -rs AVA_PASSWORD and read -rs BEN_PASSWORD if you like to paste them.

  3. Use the variables and the authorize and exchange helpers from Present an access token correctly, for lab-printer on Default access tokens.

  4. Have lab-print-orders ready from Client credentials, with Flow policy allowing client credentials. Set ORDERS_ID and read -rs ORDERS_SECRET.

Walkthrough

  1. 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, then AVA_TOKEN=$TOKEN.

    • Sign out at $ISSUER/account (or use a private window), authorize "photos.read", sign in as Ben, exchange, then BEN_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"
  1. 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.

  1. Ava reads her own album: curl -si http://127.0.0.1:8766/albums/42/photos -H "Authorization: Bearer $AVA_TOKEN". Returns 200.

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.

  1. 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.

  1. Ben reads album 43 with his own token: 200. Ava's token on the same URL is still 404.

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.

  1. Call a route nobody mapped: curl -si http://127.0.0.1:8766/albums/42/export -H "Authorization: Bearer $AVA_TOKEN". Returns 404, logged as unknown_route.

Why it matters: deny by default keeps a forgotten route closed until someone decides which scope it needs.

  1. Call with a token that lacks the scope. Get a token for Ava with openid profile only, then call album 42: 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="photos.read".

  1. Present a client-only token. In Clients, lab-print-orders, add photos.read to 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.

  1. 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

  1. Confirm lab-print-orders no longer has photos.read assigned.

  2. Stop btl-lab resource. Run unset AVA_TOKEN BEN_TOKEN JOB_TOKEN TOKEN RESP.

Back to all labs

We value your privacy

We use cookies and similar technologies to enhance your browsing experience, and analytics to understand our traffic. By clicking "Allow All", you consent to optional analytics. Cookie Policy

The Lab