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

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.

Setup

These steps are real today.

  1. In Lab Photos, open OAuth > Flow policy and allow refresh_token.

  2. On lab-printer, allow the refresh_token grant and assign the scopes photos.read, photos.share (exclusive) and offline_access.

  3. Keep CLIENT_ID and CLIENT_SECRET for lab-printer in 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.

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

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

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

  1. Ask for an API Ava never approved: refresh with resource=https://prints.lab.example. The result is {"error":"invalid_target"}, recorded as oauth.token rejected invalid_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.

  1. Ask for one token for both: refresh with both resource values in one request. The result is invalid_target under 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

  1. Downscoping by scope alone is real. Run step 1 without the two resource parameters, approve, and exchange the code as in step 2 without resource. 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.

  1. See what stays broken without resource indicators. Run btl-lab decode "$PHOTO_TOKEN" and btl-lab decode "$SHARE_TOKEN": both carry the same aud (the tenant default $ISSUER/resource, or whatever lab-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.

  1. Try a third scope Ava never approved: refresh with -d scope=prints.create. The result is invalid_scope, recorded in Audit as oauth.token rejected scope_not_granted, the scope-only version of step 4.

Break it

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

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