OAUTH 2.0 · LAB
Follow Ava's request through a two-hop token exchange chain
Carry Ava's request from the photo API to storage to the archive with two exchanges, read the nested act chain and the capped expiry, and build the archive's decision on the current actor only.
PlannedIncludes a simulationUses your lab tenant
The lesson
Builds on: Trust and validation.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Planned. The core of this lab waits on platform features that are not built yet. The planned walkthrough shows exactly how it will run; Do today is a real exercise you can do now.
- G6 Token exchange (RFC 8693)
- G55 Tenant resource (API) registry
- G3 Sample protected resource API; no RFC 9728 protected resource metadata
Setup
These steps are real today. You need lab-printer and lab-photo-api with their credentials in the shell, and a fresh printer token AT for Ava from a lab-printer code flow for photos.read.
Planned walkthrough
This walkthrough runs once your tenant supports chained token exchange with nested act (G6), the planned resources and exchange policies for lab-photo-api and lab-tmp-photo-storage from the earlier labs (G55), and, ideally, hosted services (G3). Until then the three services are local btl-lab resource instances.
Planned setup
In three terminals, start the services.
--read-scopeand--actor(the only current actor each service accepts) are planned options:
btl-lab resource --port 8766 --mode introspect --audience "$ISSUER/resource"
btl-lab resource --port 8767 --mode introspect --audience https://storage.lab.example --read-scope storage.read --actor <lab-photo-api ID>
btl-lab resource --port 8768 --mode introspect --audience https://archive.lab.example --read-scope archive.read --actor <lab-tmp-photo-storage ID>
Store the storage client's credentials as
STORAGE_IDandSTORAGE_SECRETwithread -rs.
Steps
Token A is Ava's printer token:
audis the photo API,client_idthe printer, noact. The photo API on port 8766 accepts it.The first exchange, as
lab-photo-api, gives token B for storage:
B=$(curl -s -u "$API_ID:$API_SECRET" "$ISSUER/oauth/token" \
--data-urlencode grant_type=urn:ietf:params:oauth:grant-type:token-exchange --data-urlencode "subject_token=$AT" \
--data-urlencode subject_token_type=urn:ietf:params:oauth:token-type:access_token \
--data-urlencode resource=https://storage.lab.example -d scope=storage.read | jq -r .access_token)
Token B has act: {"sub": "<lab-photo-api ID>"} and lives 60 seconds.
The second exchange, as
lab-tmp-photo-storage, with token B as the subject token, gives token C for the archive:
C=$(curl -s -u "$STORAGE_ID:$STORAGE_SECRET" "$ISSUER/oauth/token" \
--data-urlencode grant_type=urn:ietf:params:oauth:grant-type:token-exchange --data-urlencode "subject_token=$B" \
--data-urlencode subject_token_type=urn:ietf:params:oauth:token-type:access_token \
--data-urlencode resource=https://archive.lab.example -d scope=archive.read | jq -r .access_token)
btl-lab decode "$C"
Token C's claims include:
{"sub": "<Ava's ID>", "aud": "https://archive.lab.example", "client_id": "<lab-tmp-photo-storage ID>", "scope": "archive.read",
"act": {"sub": "<lab-tmp-photo-storage ID>", "act": {"sub": "<lab-photo-api ID>"}}}
Its exp equals token B's, so expires_in is under 60.
Why it matters: the outer act names the current actor and the nested one is history. No token in the chain can outlast the one before it, though each may end sooner.
The archive allows token C: the current actor is the storage service, the only caller it expects. The nested photo API goes into its log only.
Follow the chain back. Lab Photos' Audit shows two
token_exchangedevents: token C's subjectjtiis token B'sjti, and token B's subjectjtiis token A's. The printer appears only as token A'sclient_id.
Why it matters: the archive's operators learn that the printer started all this by following jti values through the authorization server's records, not from token C.
Variations from the lesson: storage asks for
archive.delete(invalid_scope); the printer exchanges token A itself (unauthorized_client); storage waits until token B has expired (invalid_request), and the photo API, still holding a valid token A, exchanges once more and retries.
Do today
Build the archive's decision rule and test it on a real token. Save as
actor-check.mjs:
import { readFileSync } from 'node:fs';
const claims = JSON.parse(readFileSync(0, 'utf8')); // validated claims; decoding alone is not validating
const current = claims.act?.sub ?? null; // the outermost act; nested ones are history
const ok = claims.aud === 'https://archive.lab.example' && (claims.scope ?? '').split(' ').includes('archive.read') && current === process.argv[2];
console.log(JSON.stringify({decision: ok ? 'allow' : 'refuse', current_actor: current, history: claims.act?.act ?? null}));
Feed it the claims of Ava's real printer token:
node -e 'console.log(Buffer.from(process.argv[1].split(".")[1], "base64url").toString())' "$AT" | node actor-check.mjs lab-tmp-photo-storage
The decision is refuse: no actor and the wrong audience.
Test it on the lesson's token C.
Simulation. no tenant issues act yet, so these are the lesson's claims written by hand, not a token from your tenant.
echo '{"sub":"user-2048","aud":"https://archive.lab.example","client_id":"photo-storage","scope":"archive.read","act":{"sub":"photo-storage","act":{"sub":"photo-api"}}}' > c.json
node actor-check.mjs photo-storage < c.json
node actor-check.mjs photo-api < c.json
The first allows. The second refuses, because a nested actor never decides access.
Check your work
There are no automated checks while this lab is planned. For Do today, your terminal shows refuse for the real token and allow, then refuse, for the hand-written claims.
Cleanup
This is the last token exchange lab. On lab-photo-api, remove the client_credentials grant and the photos.read scope and assign the tenant default access token manager, then delete lab-tmp-at-storage and, if you created it, lab-tmp-photo-storage. Delete c.json and actor-check.mjs if you do not want them, and run unset AT B C.
Missing infrastructure
G6 Token exchange with chained exchanges, nested
act, expiry capped by the subject token, and linked exchange records.G55 Tenant resource (API) registry for the storage and archive targets.
G3 Sample protected resource API, so the three services can be hosted rather than run on loopback.