OAUTH 2.0 · LAB
Run a production and a staging API and keep their facts correct
Pin each API to one issuer, follow a key rotation, rehearse clock drift, migrate a scope in stages without breaking a client, and fail closed through an introspection outage.
Partly readyUses both lab tenants
The lesson
Builds on: Token introspection.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Partly ready. Most of this lab runs today. Steps that wait on platform features are marked, and Missing infrastructure says what they need.
- G3 Sample protected resource API; no RFC 9728 protected resource metadata
- G66 Second lab tenant for every learner: additional tenants need a paid subscription or a BTL grant, so labs that use Lab Mail cannot be completed by an ordinary learner yet
Needs a second tenant. This lab also uses Lab Mail, a second tenant. Additional tenants currently need a paid subscription or a BTL grant, so you may not be able to do the Lab Mail steps yet (gap G66).
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.
Publish a new signing key in Lab Photos
Recorded as
tenant.oauth.keys.generatesucceeded.Add the old scope for the migration rehearsal
Recorded as
tenant.oauth.scopes.createsucceeded.Get a token that still carries the old scope
Recorded as
oauth.tokensucceeded forlab-printerabout[email protected].Stop new grants of the old scope
Recorded as
tenant.oauth.scopes.updatesucceeded.See a new request for the old scope refused
Recorded as
oauth.authorizerejected (invalid_scope) forlab-printer.Move the introspection endpoint to rehearse an outage
Recorded as
tenant.oauth.metadata.updatesucceeded.
Setup
Choose Lab Photos as the lab tenant and press Start. Lab Mail plays the staging authorization server.
Load
ISSUER,CLIENT_IDandCLIENT_SECRETforlab-printerwith the helpers from Present an access token correctly;API_IDandAPI_SECRETforlab-photo-api, exported; andISSUER2,MAIL_COLLAGE_IDandMAIL_COLLAGE_SECRETforlab-mail-collagein Lab Mail.Start the two deployments in their own terminals, each logging to a file. Each trusts exactly one issuer from its own configuration and reads the key set address from that issuer's metadata:
production:
btl-lab resource --mode jwt --issuer "$ISSUER" | tee ~/lab-prod-api.logstaging:
btl-lab resource --mode jwt --issuer "$ISSUER2" --port 8770 | tee ~/lab-staging-api.log
Walkthrough
Run the deployment check. Get a production token from Lab Photos (
authorize "photos.read", sign in as Ava,exchange, keepPROD=$TOKEN) and a staging token from Lab Mail withlab-mail-collage(keepSTAGING). Then:
curl -s -o /dev/null -w 'staging token at production: %{http_code}\n' http://127.0.0.1:8766/photos -H "Authorization: Bearer $STAGING"
curl -s -o /dev/null -w 'production token at staging: %{http_code}\n' http://127.0.0.1:8770/photos -H "Authorization: Bearer $PROD"
Both return 401. The production log shows unknown_kid.
Why it matters: staging servers have test accounts, relaxed policies and more administrators. A production API that accepted their correctly signed tokens would open real photo libraries to all of them.
Follow a key rotation without a restart. In Lab Photos, Key Management, Generate key (ES256), then edit Default access tokens to sign with it. Get a new production token and call production:
200. The log shows one key set fetch for the unfamiliarkid.
Why it matters: the key set address came from metadata, not a pasted key, so the new key is found by the ordinary mechanism. A wave of unknown_kid just after a rotation would mean an API is not seeing the new key.
Count rejections for an alert threshold:
jq -r 'select(.outcome == "rejected") | "\(.reason) \(.client_id)"' ~/lab-prod-api.log | sort | uniq -c
Decide how many unknown_kid refusals in five minutes should page someone.
Rehearse clock drift. Restart production with its clock an hour and two minutes ahead, past the default one-hour token lifetime:
btl-lab resource --mode jwt --issuer "$ISSUER" --clock-offset 3720. A fresh token is refused asexpired.
Why it matters: one drifting server rejects fresh tokens while its neighbours accept them, so the fault looks random. Monitoring each server's clock offset turns it into an ordinary alert.
Restore: restart production without --clock-offset.
Migrate a scope in stages. The rehearsal uses a temporary scope as the "old" broad scope.
Add. In Scopes, create
lab-tmp-photos(Common). Restart production so it still accepts the old scope wherever the new one is needed:btl-lab resource --mode jwt --issuer "$ISSUER" --alias-scope lab-tmp-photos=photos.read.Measure. Get a token for
lab-tmp-photosonly and keep it asOLD. CallGET /photoswith it:200, and the log records the alias use with theclient_id.Stop new grants. Change
lab-tmp-photosto Exclusive, assigned to no client. A newauthorize "lab-tmp-photos"request now fails withinvalid_scope, whileOLDstill works at the API.Remove. After the announced date, restart production without
--alias-scope.OLDnow gets403 insufficient_scope.
Why it matters: tokens and refresh tokens outlive deployments. Adding, measuring, stopping new grants and announcing come before removal, so no client breaks without warning.
Rehearse an authorization server outage. Start the introspecting deployment too:
btl-lab resource --mode introspect --port 8768. In Metadata Management, change the Introspection endpoint path to/oauth/introspect-movedand save. With a fresh production token:the introspecting deployment answers
503, logged asintrospection_unavailablethe JWT deployment keeps answering
200with its cached keys
Why it matters: decide in advance which routes depend on the authorization server, keep timeouts short, and fail closed only where you must.
Restore: set the Introspection endpoint path back to /oauth/introspect and save.
Break it
Step 4 is the clock failure and step 6 the outage, each restored in place. Step 5's removal deliberately breaks a client that kept using the old scope, which is why it comes last.
Check your work
Press Check my progress. The checks look for, in order: the new signing key, the creation of lab-tmp-photos, a token carrying it, the change that stopped new grants, the refused request for it, and the moved introspection endpoint.
The API logs should show unknown_kid, expired, the alias use, insufficient_scope after removal, and introspection_unavailable.
Cleanup
Confirm the Introspection endpoint path is
/oauth/introspect.Delete the scope
lab-tmp-photos. Deleting a scope also revokes tokens that carry it.Keep the new key active on Default access tokens, and disable the old key after an hour.
Stop all three deployments and delete their logs:
rm ~/lab-prod-api.log ~/lab-staging-api.log. Rununset PROD STAGING OLD TOKEN.
Missing infrastructure
G3 Hosted protected resource with RFC 9728 metadata. The deployments run locally from the toolkit. With a hosted photo API publishing
/.well-known/oauth-protected-resource, step 5 would also update itsscopes_supportedin the same release as the scope change, generated from the configuration the API enforces, and a client could discover the API's authorization server from it.G66 Second lab tenant: this lab uses Lab Mail, a second tenant. Additional tenants currently need a paid subscription or a BTL grant, so an ordinary learner can do only the Lab Photos steps until every learner can have a second lab tenant.