OAUTH 2.0 · LAB
Validate metadata before the collage app uses a single endpoint
Harden the collage app's metadata loader to the lesson's rules, refuse Lab Mail's genuine document when it is fetched for Lab Photos, rotate a key without touching configuration, and keep the last good copy.
Partly readyUses both lab tenants
The lesson
Builds on: Reading the metadata document.
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.
- 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.
Generate a new signing key in Lab Photos
Recorded as
tenant.oauth.keys.generatesucceeded.Retire it and see it stay in the key set
Recorded as
tenant.oauth.keys.retiresucceeded.Disable it and see it leave the key set
Recorded as
tenant.oauth.keys.disablesucceeded.
Setup
You need collage.sh, servers.json and the shell variables from the issuer labs. In ~/btl-issuer, append a hardened add_server that applies the lesson's loadMetadata rules and keeps the client's own expectations on refresh. Defined later in the file, it replaces the first version:
cat >> collage.sh <<'EOF'
add_server() { # add_server NAME ISSUER CLIENT_ID [CALLBACK], hardened
case "$2" in https://*) ;; *) echo "refused: invalid_issuer"; return 1;; esac
case "$2" in *'?'*|*'#'*) echo "refused: invalid_issuer"; return 1;; esac
local rest=${2#https://}; local host=${rest%%/*}; local path=${rest#"$host"}; path=${path%/}
local address=${METADATA_URL:-https://$host/.well-known/oauth-authorization-server$path} # inserted, never appended
local meta; meta=$(curl -sf --proto =https "$address") || { echo "refused: metadata_unavailable, last validated copy kept"; return 1; }
[ "$(jq -r .issuer <<<"$meta")" = "$2" ] || { echo "refused: issuer_mismatch, nothing in this document is used"; return 1; }
jq -e '[.authorization_endpoint, .token_endpoint, .jwks_uri, .revocation_endpoint, .introspection_endpoint]
| map(select(. != null)) | all(startswith("https://"))' <<<"$meta" >/dev/null || { echo "refused: insecure_endpoint"; return 1; }
[ -f servers.json ] || echo '{}' > servers.json
if jq -e --arg n "$1" --arg i "$2" 'any(to_entries[]; .key != $n and .value.issuer == $i)' servers.json >/dev/null; then
echo "refused: duplicate_issuer"; return 1; fi
local sends; sends=$(jq -r '.authorization_response_iss_parameter_supported == true' <<<"$meta")
if [ "$(jq -r --arg n "$1" '.[$n].sends_iss // false' servers.json)" = true ] && [ "$sends" != true ]; then
echo "alert: $2 stopped advertising iss; the requirement is kept"; sends=true; fi
jq --arg n "$1" --arg c "$3" --arg cb "${4:-http://127.0.0.1:8765/callback}" --argjson m "$meta" --argjson s "$sends" \
'.[$n] = {issuer: $m.issuer, client_id: $c, callback: $cb, authorization_endpoint: $m.authorization_endpoint,
token_endpoint: $m.token_endpoint, sends_iss: $s}' servers.json > servers.new && mv servers.new servers.json && echo "saved $1"
}
EOF
. ./collage.sh
Press Start on this page with Lab Photos selected.
Walkthrough
Compare the function with the lesson's
loadMetadata: an HTTPS issuer without query or fragment, the well-known name inserted before any path, a certificate checked by the HTTPS client (curlchecks it unless told not to, and you never tell it not to), status 200, an exactissuercomparison, and HTTPS endpoints. Then refresh both records:
add_server photos "$ISSUER" "$CLIENT_ID"; add_server mail "$ISSUER2" "$MAIL_CLIENT_ID"
Both print saved.
Why it matters: nothing in a document is used until two questions are settled. Where it came from (the address built from the trusted issuer, over verified HTTPS) and which server it claims to describe (its issuer).
An issuer that is not an HTTPS URL without query or fragment never gets as far as a request:
add_server test-http "http://${ISSUER#https://}" x
add_server test-query "$ISSUER?tenant=photos" x
Both print refused: invalid_issuer.
Why it matters: the issuer and every endpoint that carries credentials must use TLS.
The issuer must match. A deployment that lets an operator type the metadata address separately from the issuer is where this goes wrong. Point Lab Photos' record at Lab Mail's genuine document:
METADATA_URL="$ISSUER2/.well-known/oauth-authorization-server" add_server photos "$ISSUER" "$CLIENT_ID"
The result is refused: issuer_mismatch. Lab Mail's document honestly names Lab Mail, so it cannot speak for Lab Photos, and none of its endpoints are used.
Why it matters: this is the check that stops a document from borrowing another server's identity. If a document could name one issuer while listing another server's endpoints, the response iss check would pass and the code would go to the wrong token endpoint: the mix-up through configuration.
An exact comparison. Configure the issuer with a trailing slash:
add_server photos "$ISSUER/" "$CLIENT_ID"
The result is refused: issuer_mismatch. The fix is the configured value, never a tidied comparison. Once accepted, the stored string is what the client expects in every iss parameter and token iss claim.
The issuer never comes from a message. Search the client for every use of the response's issuer:
grep -n 'RESP_ISS' collage.sh
RESP_ISS appears only in comparisons. Nothing passes a value from a callback, an error or a redirect to add_server.
Why it matters: a client that fetched metadata for whatever issuer a response named would carry out the mix-up on the attacker's behalf, and validation would pass, because the attacker's document would honestly name the attacker's issuer. Trusted configuration decides which issuer belongs in the exchange; validation only proves a document matches it.
Keys have their own rhythm. In Lab Photos, open OAuth > Signing keys and generate a new ES256 key. Then:
curl -s "$ISSUER/oauth/jwks" | jq '[.keys[].kid]'
curl -s "$ISSUER/.well-known/oauth-authorization-server" | jq .jwks_uri
The key set lists the new key; the metadata document is unchanged, because jwks_uri is the same address. Now Retire the new key: the key set still lists it, so tokens it might have signed stay verifiable. Then Disable it: it leaves the key set. No access token or ID token manager uses this key, so nothing is revoked.
Why it matters: verifiers find new keys through the published key set, fetching it again when they meet a key ID they do not have, without anyone changing a setting.
A refresh that fails keeps the last validated copy:
sha256sum servers.json; METADATA_URL=https://127.0.0.1:9/unreachable add_server photos "$ISSUER" "$CLIENT_ID"; sha256sum servers.json
The fetch fails and the checksum is unchanged.
Why it matters: while it retries, a client keeps using its last validated copy. It never falls back to guesses or to a copy it could not validate.
Break it
A server stops advertising a protection. In Lab Mail, open OAuth > Flow policy, clear
authorization_codeand save. Refresh the record:
add_server mail "$ISSUER2" "$MAIL_CLIENT_ID"; jq .mail.sends_iss servers.json
The function prints alert: ... stopped advertising iss; the requirement is kept, and the record still says true.
Restore: re-enable authorization_code in Lab Mail's Flow policy, save, and run add_server mail "$ISSUER2" "$MAIL_CLIENT_ID" again. No alert this time.
Why it matters: a document that validates can still deserve attention. A careful client keeps its own expectations and alerts its operators rather than quietly turning off a check it relies on.
Check your work
Press Check my progress. The checks look for tenant.oauth.keys.generate, tenant.oauth.keys.retire and tenant.oauth.keys.disable in Lab Photos, in that order. Your terminal shows invalid_issuer twice, issuer_mismatch twice, an unchanged checksum and the capability alert. Lab Mail's Audit shows the two tenant.oauth.policy.update events.
Cleanup
Confirm Lab Mail's Flow policy allows authorization_code and that jq . servers.json shows both records with "sends_iss": true. Keep collage.sh if you continue to the protected resource metadata labs.
Missing infrastructure
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.