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

IDENTITY FUNDAMENTALS · LAB

Let the photo printer read Ava's photos, and nothing more

Ava grants lab-printer photos.read, you check the token as the photo API would, refresh and revoke it, and run the printer's back-office job with its own credentials.

Partly readyUses your lab tenant

The lesson

Builds on: Protecting credentials and messages.

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.

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. Ava approves read-only access for the printer

    Recorded as oauth.authorize succeeded (code_issued) for lab-printer about [email protected].

  2. Check the printer's token as the photo API

    Recorded as oauth.introspect succeeded (other_client_token_found) for lab-photo-api.

  3. Run the back-office job with client credentials

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

  4. See another client's revocation refused

    Recorded as oauth.revoke rejected (other_client_token) for lab-print-orders.

  5. Reuse a rotated refresh token

    Recorded as oauth.token rejected (refresh_replayed) for lab-printer.

Setup

The four OAuth roles from the lesson map onto your tenant: Ava is the resource owner, lab-printer is the client, Lab Photos is the authorization server, and lab-photo-api plays the resource server.

  1. On this lab page choose Lab Photos and press Start.

  2. Scopes: confirm that the preset created photos.read (common) and photos.write (exclusive). Read their descriptions; they are what Ava will see.

  3. Flow policy: allow the Refresh token grant. Save.

  4. Clients > lab-printer: restrict its scopes to openid, profile, offline_access and photos.read, and allow the refresh token grant. Saving revokes the client's existing tokens and consents, which is expected.

  5. Clients > Create client lab-photo-api: confidential, no redirect URIs, Resource server turned on.

  6. Set the variables and helpers. Each secret is shown once.

ISSUER=https://tenant-<id>.beyondthelogin.dev
CLIENT_ID=<lab-printer client ID>
read -rs CLIENT_SECRET
API_ID=<lab-photo-api client ID>
read -rs API_SECRET
ORDERS_ID=<lab-print-orders client ID>
read -rs ORDERS_SECRET
REDIRECT=http://127.0.0.1:8765/callback
authz() { echo "$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$(node -p 'encodeURIComponent(process.argv[1])' "$REDIRECT")&scope=$1&state=$STATE&nonce=$NONCE&code_challenge=$CHALLENGE&code_challenge_method=S256"; }
redeem() { curl -s -u "$CLIENT_ID:$CLIENT_SECRET" -d grant_type=authorization_code --data-urlencode "code=$CODE" --data-urlencode "redirect_uri=$REDIRECT" -d code_verifier="$VERIFIER" "$ISSUER/oauth/token"; }
refresh() { curl -s -u "$CLIENT_ID:$CLIENT_SECRET" -d grant_type=refresh_token --data-urlencode "refresh_token=$1" -d scope=photos.read "$ISSUER/oauth/token"; }
introspect() { curl -s -u "$API_ID:$API_SECRET" --data-urlencode "token=$1" "$ISSUER/oauth/introspect"; }
fresh() { eval "$(btl-lab pkce)"; eval "$(btl-lab state)"; }

Walkthrough

  1. Run fresh; authz openid%20photos.read%20offline_access and open the URL in a private window. Sign in as Ava. The consent screen names lab-printer and lists viewing photos and offline access. Approve.

Why it matters: the resource owner authorizes a limited area of access, described in the service's own words, and her password never reaches the printer.

  1. Redeem the code and read the access token:

CODE=<code from the callback>
redeem > printer.json
jq '{scope, expires_in, has_refresh: (.refresh_token != null)}' printer.json
TOKEN=$(jq -r .access_token printer.json); REFRESH=$(jq -r .refresh_token printer.json)
btl-lab decode "$TOKEN"

Expected: sub is Ava's ID, client_id is the printer, aud is $ISSUER/resource, and scope is openid photos.read offline_access.

Why it matters: the token states who granted what to which client.

  1. Act as the photo API and ask the authorization server about the token:

introspect "$TOKEN" | jq

Expected: "active": true with the scope, client_id and sub. Audit shows oauth.introspect with reason other_client_token_found.

Why it matters: the API, not the printer, decides whether a request is allowed. photos.read would still let the printer read every album; limiting it to one album is the service's own authorization job (G22).

  1. Ask for more than the printer may have: run fresh; authz openid%20photos.write and open it. Expected: a redirect back with error=invalid_scope.

  2. Refresh at the authorization server, never at the API, and narrow the scope:

refresh "$REFRESH" > printer2.json
jq '{scope, rotated: (.refresh_token != null)}' printer2.json
AT2=$(jq -r .access_token printer2.json); RT2=$(jq -r .refresh_token printer2.json)

Expected: a new access token with "scope": "photos.read" and a new refresh token.

Why it matters: the refresh token is sent only to the authorization server. Refreshing can narrow access but never widen it, and each use rotates the refresh token.

  1. Revoke the grant, then check the last access token two ways:

curl -s -o /dev/null -w '%{http_code}\n' -u "$CLIENT_ID:$CLIENT_SECRET" --data-urlencode "token=$RT2" "$ISSUER/oauth/revoke"
introspect "$AT2" | jq .active
btl-lab verify "$AT2" --issuer "$ISSUER" --audience "$ISSUER/resource" --type at+jwt

Expected: 200, then false from introspection, while local verification still passes because the token is correctly signed and not yet expired.

Why it matters: this is the lesson's caveat made real. An API that asks the authorization server sees the revocation; one that only validates the JWT locally keeps accepting the token until it expires.

  1. The back-office job, with no user:

curl -s -u "$ORDERS_ID:$ORDERS_SECRET" -d grant_type=client_credentials -d scope=prints.create "$ISSUER/oauth/token" > job.json
jq 'keys' job.json
btl-lab decode "$(jq -r .access_token job.json)"

Expected: sub equals client_id (the job itself), scope is prints.create, and there is no refresh token or ID token.

Why it matters: the client credentials grant gives access to the application itself, with no resource owner involved.

Planned walkthrough

These steps need the hosted photo library API (G3) and per-album authorization (G22).

  1. Call the API with the printer's token. Expected: 200 with Ava's photos in album 42.

curl -i "$ISSUER/lab-api/photos/albums/42/photos" -H "Authorization: Bearer $TOKEN"
  1. Try to delete a photo with the same token. Expected: 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="photos.write".

  2. Revoke the grant as in step 6. In introspection mode the API returns 401 at once; in local validation mode it keeps returning 200 until the token expires.

  3. With G22, restrict the printer's grant to album 42 only and see album 43 refused with the same token.

Break it

  1. Another client cannot revoke the printer's tokens. Get a new grant by repeating steps 1 and 2 (keep the values as TOKEN and REFRESH), then let the job try:

curl -s -u "$ORDERS_ID:$ORDERS_SECRET" --data-urlencode "token=$TOKEN" "$ISSUER/oauth/revoke" | jq

Expected: an unauthorized_client error, and Audit shows oauth.revoke rejected with reason other_client_token.

  1. Reuse a rotated refresh token. Run refresh "$REFRESH" once, then run it again with the same $REFRESH. Expected: 400 with invalid_grant and the description "The refresh token was already used, so every token from the same sign-in has been revoked."

Note: each access token manager has a Refresh token reuse grace of 0 to 60 seconds, 0 by default, for clients that lose a response and retry. With the default, any reuse ends the whole token family.

  1. Deny consent: run step 1 with &prompt=consent added and choose Deny. Expected: a redirect with error=access_denied.

  2. Wrong verifier: get a fresh code, then run VERIFIER=$(node -p 'require("crypto").randomBytes(32).toString("hex")'); redeem. Expected: 400 with invalid_grant, and Audit shows reason pkce_failed.

  3. Ask the job for a user: repeat step 7 with scope=openid. Expected: 400 with invalid_scope, because there is no user to sign in.

Check your work

Press Check my progress. It looks for:

  • oauth.authorize succeeded with reason code_issued for lab-printer and Ava.

  • oauth.introspect succeeded with reason other_client_token_found for lab-photo-api.

  • oauth.token succeeded for lab-print-orders.

  • oauth.revoke rejected with reason other_client_token for lab-print-orders.

  • oauth.token rejected with reason refresh_replayed for lab-printer.

Cleanup

  1. Delete printer.json, printer2.json and job.json, and run unset TOKEN REFRESH AT2 RT2.

  2. Keep the scopes and all four clients. The Identity and trust labs reuse them.

Missing infrastructure

  • G3 Sample protected resource API. There is no hosted photo API that accepts the printer's token and enforces photos.read, and no protected resource metadata. Once it exists, the planned walkthrough shows real 200, 403 and 401 answers and the difference between introspection and local validation.

  • G22 End-user authorization engine. Nothing can restrict a grant to one album today, so step 3's point about album-level access stays a design note.

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