OAUTH 2.0 · LAB
Approve two APIs once and get one token per API
Approve the photo and sharing APIs in one authorization, take a photo API token from the code and a sharing API token from the refresh token, and see why one token for both is refused.
PlannedUses your lab tenant
The lesson
Builds on: Selecting the intended resource.
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.
- G16 Resource indicators
- G55 Tenant resource (API) registry
Setup
These steps are real today.
In Lab Photos, open OAuth > Flow policy and allow
refresh_token.On
lab-printer, allow therefresh_tokengrant and assign the scopesphotos.read,photos.share(exclusive) andoffline_access.Keep
CLIENT_IDandCLIENT_SECRETforlab-printerin the shell, as in the previous lab.
Planned walkthrough
This walkthrough runs once the tenant accepts resource (G16) and has the resource registry from the previous lab's planned setup (G55): the photo API at $ISSUER/resource owning photos.read, and the sharing API at https://share.lab.example owning photos.share, with "Allow several resources in one token request" off.
One authorization naming both APIs. Start
btl-lab callback, then:
eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
enc() { jq -rn --arg v "$1" '$v|@uri'; }
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=$(enc 'photos.read photos.share offline_access')&resource=$(enc "$ISSUER/resource")&resource=$(enc https://share.lab.example)&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"
One consent screen lists both APIs and both kinds of access. Approve as Ava, check state and iss, then read -rs CODE.
Why it matters: the authorization covers both resources and both scopes. That is what the server records, and what later token requests may ask for.
Exchange the code for the photo API only:
RESP=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=authorization_code --data-urlencode "code=$CODE" \
--data-urlencode redirect_uri=http://127.0.0.1:8765/callback --data-urlencode "code_verifier=$VERIFIER" --data-urlencode "resource=$ISSUER/resource")
PHOTO_TOKEN=$(jq -r .access_token <<<"$RESP"); REFRESH=$(jq -r .refresh_token <<<"$RESP"); jq '{scope}' <<<"$RESP"; unset CODE RESP
The response says "scope": "photos.read offline_access", and the token's aud is the photo API alone.
Why it matters: photos.share means nothing at the photo API, so the server downscopes and says so in scope. The photo API also does not learn which other services Ava uses.
Refresh for the sharing API, and save the rotated refresh token before anything else:
RESP=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=refresh_token --data-urlencode "refresh_token=$REFRESH" \
--data-urlencode resource=https://share.lab.example)
REFRESH=$(jq -r .refresh_token <<<"$RESP"); SHARE_TOKEN=$(jq -r .access_token <<<"$RESP"); jq '{scope}' <<<"$RESP"; unset RESP
The response says "scope": "photos.share" and the token's aud is https://share.lab.example.
Why it matters: the refresh token stays tied to the whole authorization, and each access token goes to one API. The client stores each token with the API it was issued for.
Ask for an API Ava never approved: refresh with
resource=https://prints.lab.example. The result is{"error":"invalid_target"}, recorded asoauth.tokenrejectedinvalid_target(planned reason).
Why it matters: the refresh token cannot reach further than the authorization behind it. A third API needs a new consent screen that names it.
Ask for one token for both: refresh with both
resourcevalues in one request. The result isinvalid_targetunder the tenant's "several resources: off" policy.
Why it matters: a token valid at both APIs lets a leak at the sharing API expose Ava's photo library, and its scopes become hard to interpret. Naming several resources belongs in the authorization request, not in one token request.
Do today
Downscoping by scope alone is real. Run step 1 without the two
resourceparameters, approve, and exchange the code as in step 2 withoutresource. Then narrow the next access token:
RESP=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=refresh_token --data-urlencode "refresh_token=$REFRESH" -d scope=photos.read)
REFRESH=$(jq -r .refresh_token <<<"$RESP"); PHOTO_TOKEN=$(jq -r .access_token <<<"$RESP"); jq '{scope}' <<<"$RESP"
RESP=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=refresh_token --data-urlencode "refresh_token=$REFRESH" -d scope=photos.share)
REFRESH=$(jq -r .refresh_token <<<"$RESP"); SHARE_TOKEN=$(jq -r .access_token <<<"$RESP"); jq '{scope}' <<<"$RESP"; unset RESP
The first prints "photos.read", the second "photos.share". The rotated refresh token keeps the full grant, so narrowing one access token does not narrow the next.
See what stays broken without resource indicators. Run
btl-lab decode "$PHOTO_TOKEN"andbtl-lab decode "$SHARE_TOKEN": both carry the sameaud(the tenant default$ISSUER/resource, or whateverlab-printer's access token manager sets). Each would pass the other API's audience check. The next lab shows the API side of that problem.
Try a third scope Ava never approved: refresh with
-d scope=prints.create. The result isinvalid_scope, recorded in Audit asoauth.tokenrejectedscope_not_granted, the scope-only version of step 4.
Break it
Real today: refresh twice with the same refresh token, as two refreshes running at once would.
OLD=$REFRESH
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=refresh_token --data-urlencode "refresh_token=$OLD" | jq '{scope}'
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=refresh_token --data-urlencode "refresh_token=$OLD" | jq .
With the access token manager's Refresh token reuse grace at its default of 0 seconds, the second call returns {"error":"invalid_grant",...}, Audit records oauth.token rejected refresh_replayed, and every token from that sign-in is revoked, including the newest refresh token. If your manager has a grace period and you were quick, the second call succeeds and records refresh_reuse_grace instead. Start a new authorization to continue.
Why it matters: both APIs share one refresh token, so only one refresh for the connection may run at a time, even when both access tokens need replacing at once.
Check your work
There are no automated checks while this lab is planned. For Do today and Break it, look in Lab Photos' Audit for oauth.token succeeded for lab-printer with refresh, oauth.token rejected scope_not_granted, and oauth.token rejected refresh_replayed.
Cleanup
Run unset REFRESH OLD PHOTO_TOKEN SHARE_TOKEN. Keep the refresh settings on lab-printer for the step-up labs. The revoked tokens expire on their own.
Missing infrastructure
G16 Resource indicators: everything in the previous lab, plus approving several resources in one authorization and choosing one per token request at the token and refresh grants, with downscoping and
invalid_targetfor an unapproved or combined resource.G55 Tenant resource (API) registry: which scopes belong to which API, so the server can downscope per resource, and the "several resources in one token request" policy step 5 relies on.