OAUTH 2.0 · LAB
Get a machine-to-machine token and check it every way
Create a confidential client with one exclusive scope, request a token with client credentials, decode it, verify it against the JWKS, then introspect it as the photo API.
ReadyUses your lab tenant
The lesson
Builds on: Trust boundaries.
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.
Request a client credentials token
Recorded as
oauth.tokensucceeded forlab-print-orders.Introspect it as the photo API
Recorded as
oauth.introspectsucceeded (other_client_token_found) forlab-photo-api.A scope outside the registration is refused
Recorded as
oauth.tokenrejected (invalid_scope) forlab-print-orders.The client revokes its own token
Recorded as
oauth.revokesucceeded (access_token_found) forlab-print-orders.A wrong client secret is refused
Recorded as
oauth.tokenrejected (invalid_client) forlab-print-orders.A public client cannot act as itself
Recorded as
oauth.tokenrejected (client_authentication_required) forlab-printer-app.
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
Press Start on this page.
Open OAuth > Flow policy, tick Client credentials under Allowed grants, and save.
In OAuth > Scopes, confirm that
prints.createexists with access Exclusive. The lesson'sprint-jobs.writeplays the same part.Open OAuth > Clients > Create client and start from Machine to machine. Name it
lab-print-orders, type Confidential, keep Restrict scopes ticked, assignprints.create, tick Enabled and save.Open Access Token Management > Client authentication secret, choose
lab-print-ordersand Rotate client secret. The secret is shown once.Set the shell variables. In this lab
CLIENT_IDandCLIENT_SECRETholdlab-print-orders.
export ISSUER="https://tenant-<id>.beyondthelogin.dev"
export CLIENT_ID="<lab-print-orders client ID>"; read -rs CLIENT_SECRET
export API_ID="<lab-photo-api client ID>"; read -rs API_SECRET && export API_SECRET
export APP_ID="<lab-printer-app client ID>"
Walkthrough
Request a token as the client itself. In the request console, type the
lab-print-ordersclient ID and secret when asked.
POST$ISSUER/oauth/token
Open in console
POST $ISSUER/oauth/token HTTP/1.1
Authorization: Basic base64($CLIENT_ID:$CLIENT_SECRET)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=prints.createFrom a shell:
RESPONSE=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" -d grant_type=client_credentials -d scope=prints.create "$ISSUER/oauth/token")
echo "$RESPONSE" | jq '{token_type, expires_in, scope, refresh_token}'
TOKEN=$(echo "$RESPONSE" | jq -r .access_token)
The answer has token_type: "Bearer", an expires_in, scope: "prints.create" and no refresh token.
Why it matters: there is no code, redirect URI or verifier, because there was no browser interaction to connect to. The client's authentication is the whole request, and it can simply repeat it, so a refresh token would add risk without use.
Decode it.
btl-lab decode "$TOKEN"
The header shows alg: ES256, typ: at+jwt and a kid. The payload shows sub and client_id both equal to $CLIENT_ID, scope: "prints.create", aud: "$ISSUER/resource", iat, exp and jti. The toolkit reminds you that decoding is not validating.
Why it matters: the token represents the printer, not a person. Nothing in it can tell an API which user approved anything, because no user did.
Validate it against the issuer's published keys.
btl-lab verify "$TOKEN" --issuer "$ISSUER" --audience "$ISSUER/resource" --type at+jwt
It fetches the JWKS, finds the key with the token's kid, and prints each check: signature, alg allowlist, typ, iss, aud, and exp, iat and nbf with clock skew. All pass.
Ask the authorization server, as the photo API. The console asks for the
lab-photo-apiclient ID and secret.
POST$ISSUER/oauth/introspect
Open in console
POST $ISSUER/oauth/introspect HTTP/1.1
Authorization: Basic base64($API_ID:$API_SECRET)
Content-Type: application/x-www-form-urlencoded
token=$TOKENThe shell equivalent is btl-lab introspect "$TOKEN". Both answer active: true with client_id equal to $CLIENT_ID, the same sub and scope: "prints.create".
Why it matters: local validation proves where the token came from and that it is in date. Introspection also tells the API whether the authorization server still considers it active.
Reuse it rather than requesting a new one for every job. Take
expfrom step 2's output:echo $(( <exp> - $(date +%s) ))shows the seconds left. The evening batch keeps this token until shortly before then.
Keep the access narrow. Repeat step 1's shell request with different scopes.
-d scope=photos.readanswersinvalid_scope: a restricted client may use only the exclusive scopes assigned to it.-d scope=openidanswersinvalid_scope, "...The client credentials grant has no user, so it cannot request openid."No
scopeparameter answers200with noscopein the response: the token grants no scope at all.
Use the token at an API that checks scopes. In a second terminal run
btl-lab resource --mode introspect, then submit a print job and try to read photos.
curl -s -i -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"order":"book-5561"}' http://127.0.0.1:8766/prints
curl -s -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8766/photos
The print job is accepted. Reading photos answers 403 with Bearer error="insufficient_scope".
Why it matters: the API decides what this partner may do from the access granted to the client. Anyone holding the printer's secret could get the same tokens, so the client should hold only the scopes its job needs.
Revoke the token as its own client:
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" -d "token=$TOKEN" "$ISSUER/oauth/revoke".btl-lab introspect "$TOKEN"now returns{"active": false}, whilebtl-lab verifystill passes: a resource server that checks only locally keeps accepting it untilexp.
Optional, opaque tokens. In Access Token Management, create a temporary manager
lab-tmp-opaquewith Token format Opaque reference token, assign it tolab-print-orders, and request again.btl-lab decodecannot read the token; introspection is the only way to learn what it means. Assign Default access tokens back tolab-print-orders.
Break it
A wrong secret:
curl -s -i -u "$CLIENT_ID:wrong-secret-wrong-secret-wrong-secret" -d grant_type=client_credentials "$ISSUER/oauth/token"answers401,WWW-Authenticate: Basic realm="OAuth token",{"error":"invalid_client",...}.A public client:
curl -s -d grant_type=client_credentials -d "client_id=$APP_ID" "$ISSUER/oauth/token"answers400 unauthorized_client, "Public clients cannot authenticate, so they cannot use this grant or endpoint."Tenant policy: in Flow policy, untick Client credentials and save, then repeat step 1. The answer is
400 unauthorized_client, because the tenant no longer allows the grant for anyone.
Restore: in Flow policy, tick Client credentials again and save.
Check your work
Press Check my progress. In tenant Audit, the successful oauth.token shows actor lab-print-orders with actor type OAuth client, and its subject is the client itself. You can also find tenant.oauth.clients.create, tenant.oauth.credentials.rotate and two tenant.oauth.policy.update events.
Cleanup
Delete
lab-tmp-opaqueif you created it, after confirminglab-print-ordersuses Default access tokens.Keep
lab-print-orders, its secret and Client credentials in Flow policy. The Clients labs use them.Stop the resource server.