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

Play the printer's backend and keep every token off the browser

Run the confidential web flow with your shell as the backend, keep an encrypted connection record, and show that signing out ends a session but not the connection.

ReadyUses your lab tenant

The lesson

Builds on: Refresh tokens and rotation.

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.

  1. Connect Ava's photos to the printer backend

    Recorded as oauth.token succeeded for lab-printer about [email protected].

  2. Sign Ava out of the tenant

    Recorded as account.sign_out succeeded (signed_out) about [email protected].

  3. Run the December job after sign-out

    Recorded as oauth.token succeeded for lab-printer about [email protected].

  4. Disconnect by revoking the refresh token

    Recorded as oauth.revoke succeeded (refresh_token_found) for lab-printer.

  5. Lock Ava's account

    Recorded as tenant.users.lock succeeded.

  6. See the job's refresh refused after the lock

    Recorded as oauth.token rejected (refresh_revoked) for lab-printer.

Setup

  1. Choose Lab Photos as the lab tenant and press Start.

  2. In Clients, open lab-printer and compare it with the Web application preset: Confidential, authorization code and refresh token, PKCE for confidential clients Required. Flow policy must allow the refresh token grant.

  3. Load ISSUER, CLIENT_ID and CLIENT_SECRET (with read -rs) for lab-printer, and the authorize helper from Present an access token correctly.

  4. Create a key that stands in for the printer's key management service. It lives only in this shell, never in a file:

RECORD_KEY=$(openssl rand -hex 32); export RECORD_KEY
seal() { openssl enc -aes-256-cbc -pbkdf2 -a -A -pass env:RECORD_KEY; }
unseal() { openssl enc -d -aes-256-cbc -pbkdf2 -a -A -pass env:RECORD_KEY; }
mkdir -p -m 700 ~/lab-printer-connections

Walkthrough

  1. Watch what the browser carries. Open developer tools, Network, with Preserve log on. Run btl-lab callback in a second terminal, then authorize "openid photos.read offline_access", open the URL, sign in as Ava and approve. The redirect chain shows the authorization request going out and code, state and iss coming back. No response to the browser contains a token.

Why it matters: the browser carries the request out and the code back. Every token arrives in a direct response to the backend.

  1. Look at what the browser does hold. In developer tools, Application, Cookies for your tenant host: the session cookie is HttpOnly and Secure, with a SameSite attribute. Run document.cookie in the console on the tenant page: the session cookie is not listed.

Why it matters: a script injected into the page could misuse the session while the page is open, but it finds no token to carry away and replay against an API from somewhere else.

  1. Exchange the code in the shell, the backend, and save a connection record tied to the printer account. The refresh token is sealed with the key; nothing is printed.

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")
jq -n --arg iss "$ISSUER" --arg cid "$CLIENT_ID" --arg scope "$(jq -r .scope <<<"$RESP")" --arg rt "$(jq -r .refresh_token <<<"$RESP" | seal)" \
  '{account: "ava", issuer: $iss, client_id: $cid, granted_scope: $scope, refresh_token: $rt, status: "active"}' > ~/lab-printer-connections/ava.json
chmod 600 ~/lab-printer-connections/ava.json; unset RESP CODE; jq 'del(.refresh_token)' ~/lab-printer-connections/ava.json

Why it matters: the record holds the scope as the token response reported it, which may be narrower than requested. Unlike an authorization server, the printer cannot store only a hash, because it must send the original value back, so it encrypts it with a key held elsewhere.

  1. Sign out. In the browser, open $ISSUER/account and select Sign out.

Why it matters: this ends Ava's session at the tenant, which stands in for her printer session here. The connection is a separate thing.

  1. Run the December job with no browser. It loads the record by the printer account it works for, refreshes, saves the replacement before using the new access token, then reads the profile:

REC=~/lab-printer-connections/ava.json
RESP=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=refresh_token \
  --data-urlencode "refresh_token=$(jq -r .refresh_token "$REC" | unseal)")
jq --arg rt "$(jq -r .refresh_token <<<"$RESP" | seal)" '.refresh_token = $rt' "$REC" > "$REC.new" && mv "$REC.new" "$REC"
curl -s -o /dev/null -w 'UserInfo %{http_code}\n' "$ISSUER/oidc/userinfo" -H "Authorization: Bearer $(jq -r .access_token <<<"$RESP")"; unset RESP

Returns UserInfo 200, after Ava signed out.

Why it matters: the connection outlives the session that created it. Only one job refreshes a connection at a time, and the job finds the connection through the account it is working for, never from an identifier carried in a queue message.

  1. Disconnect. Revoke the refresh token from the record, and delete the record only after the server answers 200:

curl -s -o /dev/null -w '%{http_code}\n' -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/revoke" \
  --data-urlencode "token=$(jq -r .refresh_token "$REC" | unseal)" -d token_type_hint=refresh_token
rm "$REC"

Why it matters: Disconnect ends the grant; signing out did not. Neither touched the other.

  1. Reconnect (steps 1 and 3), then lock Ava in User Management. Run the job from step 5: the refresh returns invalid_grant. Instead of retrying, mark the record:

jq '.status = "needs_attention"' "$REC" > "$REC.new" && mv "$REC.new" "$REC"; jq '.status' "$REC"

Why it matters: when the provider ends access, the job learns it at its next refresh. It should notify the person rather than loop.

Restore: unlock Ava in User Management.

Break it

Exchange a code without the client secret, as a public client would, with -d client_id=$CLIENT_ID instead of -u. Returns 401 invalid_client, Audit reason unsupported_auth_method. A confidential client's code is useless to anyone who lacks its credentials.

Check your work

Press Check my progress. The checks look for, in order: the code exchange for Ava, account.sign_out for Ava, a refresh after the sign-out, oauth.revoke with refresh_token_found, tenant.users.lock, and the job's refresh refused with refresh_revoked.

Cleanup

  1. Confirm Ava is unlocked.

  2. Delete the connection records: rm -rf ~/lab-printer-connections. Run unset RECORD_KEY REC.

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