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.
- G3 Sample protected resource API; no RFC 9728 protected resource metadata
- G22 End-user authorization engine (PDP, RBAC/ABAC/ReBAC for application data)
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.
Ava approves read-only access for the printer
Recorded as
oauth.authorizesucceeded (code_issued) forlab-printerabout[email protected].Check the printer's token as the photo API
Recorded as
oauth.introspectsucceeded (other_client_token_found) forlab-photo-api.Run the back-office job with client credentials
Recorded as
oauth.tokensucceeded forlab-print-orders.See another client's revocation refused
Recorded as
oauth.revokerejected (other_client_token) forlab-print-orders.Reuse a rotated refresh token
Recorded as
oauth.tokenrejected (refresh_replayed) forlab-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.
On this lab page choose Lab Photos and press Start.
Scopes: confirm that the preset created
photos.read(common) andphotos.write(exclusive). Read their descriptions; they are what Ava will see.Flow policy: allow the Refresh token grant. Save.
Clients >
lab-printer: restrict its scopes toopenid,profile,offline_accessandphotos.read, and allow the refresh token grant. Saving revokes the client's existing tokens and consents, which is expected.Clients > Create client
lab-photo-api: confidential, no redirect URIs, Resource server turned on.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
Run
fresh; authz openid%20photos.read%20offline_accessand open the URL in a private window. Sign in as Ava. The consent screen nameslab-printerand 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.
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.
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).
Ask for more than the printer may have: run
fresh; authz openid%20photos.writeand open it. Expected: a redirect back witherror=invalid_scope.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.
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.
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).
Call the API with the printer's token. Expected:
200with Ava's photos in album 42.
curl -i "$ISSUER/lab-api/photos/albums/42/photos" -H "Authorization: Bearer $TOKEN"
Try to delete a photo with the same token. Expected:
403withWWW-Authenticate: Bearer error="insufficient_scope", scope="photos.write".Revoke the grant as in step 6. In introspection mode the API returns
401at once; in local validation mode it keeps returning200until the token expires.With G22, restrict the printer's grant to album 42 only and see album 43 refused with the same token.
Break it
Another client cannot revoke the printer's tokens. Get a new grant by repeating steps 1 and 2 (keep the values as
TOKENandREFRESH), 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.
Reuse a rotated refresh token. Run
refresh "$REFRESH"once, then run it again with the same$REFRESH. Expected:400withinvalid_grantand 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.
Deny consent: run step 1 with
&prompt=consentadded and choose Deny. Expected: a redirect witherror=access_denied.Wrong verifier: get a fresh code, then run
VERIFIER=$(node -p 'require("crypto").randomBytes(32).toString("hex")'); redeem. Expected:400withinvalid_grant, and Audit shows reasonpkce_failed.Ask the job for a user: repeat step 7 with
scope=openid. Expected:400withinvalid_scope, because there is no user to sign in.
Check your work
Press Check my progress. It looks for:
oauth.authorizesucceeded with reasoncode_issuedforlab-printerand Ava.oauth.introspectsucceeded with reasonother_client_token_foundforlab-photo-api.oauth.tokensucceeded forlab-print-orders.oauth.revokerejected with reasonother_client_tokenforlab-print-orders.oauth.tokenrejected with reasonrefresh_replayedforlab-printer.
Cleanup
Delete
printer.json,printer2.jsonandjob.json, and rununset TOKEN REFRESH AT2 RT2.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 real200,403and401answers 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.