OAUTH 2.0 · LAB
Present an access token correctly and read every refusal
Get a real token for Ava, send it the right way and three wrong ways, and read each RFC 6750 answer from UserInfo and a local photo API.
ReadyUses your lab tenant
The lesson
Builds on: Public and confidential clients.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
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.
Get an access token for Ava as lab-printer
Recorded as
oauth.tokensucceeded forlab-printerabout[email protected].Read Ava's profile with the token in the header
Recorded as
oidc.userinfosucceeded (userinfo_served) forlab-printerabout[email protected].Present a token without openid to UserInfo
Recorded as
oidc.userinforejected (insufficient_scope) forlab-printer.Use a reference token from the temporary manager
Recorded as
oidc.userinfosucceeded (userinfo_served) forlab-printer.
Request console
Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.
Setup
Choose Lab Photos as the lab tenant on this page and press Start.
Confirm in the tenant portal that
lab-printerexists (Confidential, redirect URIhttp://127.0.0.1:8765/callback, authorization code with PKCE) and uses the Default access tokens manager. If it does not exist, complete Register the printer twice first.Confirm Ava has a password you know. In User Management, set one if needed.
Load the variables. The secret is read without echo and never placed in a URL or a file.
export ISSUER="https://tenant-<id>.beyondthelogin.dev" # Lab Photos issuer, from Overview
export CLIENT_ID="<lab-printer client ID>"
read -rs CLIENT_SECRET
btl-lab env
Define two helpers for this lab.
authorizeprints a fresh authorization URL with PKCE andstate;exchangereads the code you paste and keeps the tokens in shell variables only.
authorize() {
eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=$(jq -rn --arg s "$1" '$s|@uri')&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"
}
exchange() {
read -r CODE
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" -d "code_verifier=$VERIFIER")
TOKEN=$(jq -r .access_token <<<"$RESP"); jq 'del(.access_token, .refresh_token, .id_token)' <<<"$RESP"
}
In a second terminal, run
btl-lab callback. It listens on127.0.0.1:8765, printscode,stateandiss, then exits. Run it again before each sign-in.
Walkthrough
Get a token for Ava. Run
authorize "openid profile photos.read", open the URL, sign in as[email protected]and approve. Check that the printedstateequals$STATE, then runexchangeand paste the code.
{ "token_type": "Bearer", "expires_in": 3600, "scope": "openid profile photos.read" }
Why it matters: the client learns the granted scope and the lifetime from the token response, never by reading the token.
Turn
expires_ininto a time by your own clock, and plan to replace the token a minute early.
EXPIRES_AT=$(( $(date +%s) + $(jq .expires_in <<<"$RESP") )); REPLACE_AT=$(( EXPIRES_AT - 60 ))
date -d "@$REPLACE_AT" 2>/dev/null || date -r "$REPLACE_AT"
Why it matters: early replacement avoids a request that starts just before expiry and arrives just after it. It is an optimization; the client still handles a rejected token on every call.
Present the token in the Authorization header. Open this request in the console, or run the same call with curl:
curl -si "$ISSUER/oidc/userinfo" -H "Authorization: Bearer $TOKEN".
GET$ISSUER/oidc/userinfo
Open in console
GET $ISSUER/oidc/userinfo HTTP/1.1
Authorization: Bearer $TOKENReturns 200 with Ava's sub and profile claims.
Why it matters: the header is the one place every bearer API must support, and it keeps the token out of the address.
Send no token at all:
curl -si "$ISSUER/oidc/userinfo". Returns401with a bareWWW-Authenticate: Bearerand no error code.
Why it matters: with no credentials in the request there was nothing to evaluate, so the server adds no error details. The client's next step is to check that it really sent the token.
Put the token in the query string:
curl -si "$ISSUER/oidc/userinfo?access_token=$TOKEN". Returns400witherror="invalid_request". The tenant refuses tokens in addresses outright.
Why it matters: an address travels into server logs, browser history, caches and Referer headers, each one a copy of the token nobody treats as secret.
Send the token two ways at once:
curl -si -X POST "$ISSUER/oidc/userinfo" -H "Authorization: Bearer $TOKEN" --data-urlencode "access_token=$TOKEN". Returns400 invalid_request.
Why it matters: a request carrying the token more than one way is malformed. It is a bug to fix, and sending it again will not help.
Send a string that is not a token:
curl -si "$ISSUER/oidc/userinfo" -H "Authorization: Bearer not-a-real-token". Returns401witherror="invalid_token".
Why it matters: this is the one answer that means "get a new token and retry once". If the new token is refused too, the client stops and marks the connection as needing attention.
Get a token without
openid:authorize "photos.read", sign in,exchange, then call UserInfo with it. Returns403witherror="insufficient_scope".
Why it matters: the token is valid but does not cover this operation. A fresh token for the same grant would fail the same way, so the client does not retry.
Read the scope an API asks for. Start the local photo API in a third terminal with
btl-lab resource --mode jwt. It trusts$ISSUER, expects the tenant's default audience$ISSUER/resource, and prints one decision line per request without the token. Call a route that needsphotos.writewith the same token:
curl -si -X POST http://127.0.0.1:8766/photos -H "Authorization: Bearer $TOKEN"
Returns 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="photos.write". GET /photos with the same token returns 200.
Why it matters: the scope attribute names what the operation needs, for the program to act on. Getting it means sending Ava through authorization again, where she can refuse; a refresh token can never add scope.
Treat the format as private. In Access Token Management, create a manager
lab-tmp-opaque(Token format: Opaque reference token) and assign it tolab-printerunder Assign a client. Get a new token withopenid profile photos.read. Call UserInfo as in step 3: still200. Now runbtl-lab decode "$TOKEN": there is nothing to decode.
Why it matters: the format is an agreement between the authorization server and the API. A client that read claims out of the JWT would have broken the moment the provider switched formats, while a client that relied on the token response kept working.
Restore: assign Default access tokens back to lab-printer.
Keep the token where it belongs. Before following a link from a response, compare origins exactly, not by prefix:
node -e 'const api = new URL("https://api.photos.example"); for (const next of process.argv.slice(1)) { const u = new URL(next); console.log(u.origin === api.origin ? "send token" : "do not send", next, "| prefix check says", next.startsWith("https://api.photos.example")); }' \
"https://api.photos.example/albums/42/photos?page=2" "https://api.photos.example.attacker.example/albums"
The second link passes a prefix check and fails the origin check.
Why it matters: a token goes only to the API it was issued for, at the configured address. Following a suggested link or redirect blindly is the easiest way to hand it to someone else.
Break it
Send the scheme in lower case:
-H "Authorization: bearer $TOKEN". It succeeds, because the scheme name is case-insensitive. That is not a weakness; only the scheme's spelling varies.Look at the local API's decision lines after steps 9 and 10. They show route, outcome, reason,
client_idandjti, never the token. Compare with a request log that captures headers: that log would be a copy of every token.
Check your work
Press Check my progress. The checks look for, in order:
oauth.tokensucceeded forlab-printerwith Ava as subject.oidc.userinfosucceeded (userinfo_served) for Ava throughlab-printer.oidc.userinforejected withinsufficient_scopefor the token withoutopenid.oidc.userinfosucceeded again with the reference token.
The invalid_request and invalid_token refusals from steps 5 to 7 appear in Logs as protocol summaries, not in Audit, because an unknown or misplaced token identifies no client or user.
Cleanup
Confirm
lab-printeruses Default access tokens.Delete the
lab-tmp-opaquemanager.Stop
btl-lab resourceandbtl-lab callback. Unset the token:unset TOKEN RESP CODE.