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

Read what an access token says about how and when Ava signed in

Put auth_time into lab-printer-app's access tokens, see it survive a refresh and reach the API through introspection, and see that nothing in the token says whether Ava used a password or a passkey.

Partly readyUses your lab tenant

The lesson

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.

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. Sign Ava in with her password at lab-printer-app

    Recorded as oauth.authorize succeeded (user_signed_in) for lab-printer-app.

  2. Exchange the code as a public client

    Recorded as oauth.token succeeded for lab-printer-app.

  3. Let the photo API read auth_time by introspection

    Recorded as oauth.introspect succeeded (other_client_token_found) for lab-photo-api.

  4. Enroll a passkey for Ava

    Recorded as account.security succeeded (method_enrolled) about [email protected].

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

In these labs lab-printer-app, the printer's public browser and mobile twin, plays the lesson's photo editor: it can delete photos, and deleting needs a recent sign-in.

  1. In Lab Photos, confirm OAuth > Flow policy allows authorization_code and refresh_token, and that the exclusive scope photos.delete exists (the Lab Photos preset creates it).

  2. In OAuth > Access token managers, create lab-tmp-at-signin (JWT, ES256) and add the claim mapping auth_time from the attribute "Sign-in time". The step-up labs share this manager; the last one deletes it.

  3. In OAuth > Clients, configure lab-printer-app (create it with the Native or SPA preset if needed): public, redirect URI http://127.0.0.1:8765/callback, PKCE required, grants authorization_code and refresh_token, scopes openid, photos.read, photos.delete and offline_access, consent mode Always, access token manager lab-tmp-at-signin.

  4. In Authentication, set passkey to optional and allow passwordless passkey sign-in. Make sure Ava has a password.

  5. Confirm lab-photo-api has Resource server on, and store what the toolkit needs:

export APP_ID='<lab-printer-app client id>'
export API_ID='<lab-photo-api client id>'; read -rs API_SECRET; export API_SECRET
  1. Press Start on this page with Lab Photos selected.

Walkthrough

  1. A password sign-in. Start btl-lab callback, then request a fresh sign-in with prompt=login:

eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
echo "$ISSUER/oauth/authorize?response_type=code&client_id=$APP_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=openid%20photos.read%20photos.delete%20offline_access&prompt=login&state=$STATE&code_challenge=$CHALLENGE&code_challenge_method=S256"

Sign in as Ava with her password and approve. Check state and iss, then exchange the code. The app is a public client, so it sends its client ID and no secret:

read -rs CODE
RESP=$(curl -s "$ISSUER/oauth/token" -d grant_type=authorization_code --data-urlencode "client_id=$APP_ID" --data-urlencode "code=$CODE" \
  --data-urlencode redirect_uri=http://127.0.0.1:8765/callback --data-urlencode "code_verifier=$VERIFIER")
TOKEN=$(jq -r .access_token <<<"$RESP"); REFRESH=$(jq -r .refresh_token <<<"$RESP"); ID_TOKEN=$(jq -r .id_token <<<"$RESP"); unset CODE RESP
btl-lab decode "$TOKEN"
btl-lab decode "$ID_TOKEN"

The access token carries auth_time, equal to or just before iat, and no acr or amr. The ID token, which is for the app, shows amr with the password value.

Why it matters: auth_time records when Ava last actively authenticated, not when the token was issued. It is a claim for the API; the app treats the access token as opaque.

  1. Wait a minute, then refresh, and save the rotated refresh token at once:

RESP=$(curl -s "$ISSUER/oauth/token" -d grant_type=refresh_token --data-urlencode "client_id=$APP_ID" --data-urlencode "refresh_token=$REFRESH")
TOKEN=$(jq -r .access_token <<<"$RESP"); REFRESH=$(jq -r .refresh_token <<<"$RESP"); unset RESP
btl-lab decode "$TOKEN"

iat moved; auth_time did not.

Why it matters: a token obtained by refreshing describes the original sign-in, because no new authentication happened. Yesterday's sign-in can sit behind this morning's token.

  1. The API sees the same value. Introspect as lab-photo-api:

btl-lab introspect "$TOKEN"

The response includes active: true and auth_time, and has no acr or amr.

Why it matters: an API that relies on introspection can apply a freshness rule today, but not a strength rule.

  1. A passkey sign-in. Open $ISSUER/account/security, sign in as Ava and add a passkey. Then repeat step 1, choosing the passkey on the sign-in page. The ID token's amr changes to the passkey method; the access token still carries only auth_time.

Why it matters: the API, which decides whether photos may be deleted, cannot tell a password from a passkey. Carrying that difference in the access token is exactly what acr is for.

  1. Look for the server's list of authentication context values:

GET$ISSUER/.well-known/openid-configuration Open in console
GET $ISSUER/.well-known/openid-configuration

acr_values_supported is absent. Your tenant also ignores an acr_values parameter on an authorization request rather than rejecting it, which the specifications permit.

Why it matters: a client checks this list before sending a user off to satisfy a requirement, and an API can only ask for values the server defines.

Planned walkthrough

These steps need tenant-defined acr values (G20) and authentication context in access tokens (G48).

  1. In Authentication (planned), the administrator defines two values: urn:btl:acr:basic for a password sign-in and urn:btl:acr:phishing-resistant for a passkey or security key. acr_values_supported in discovery then lists both.

  2. Repeat steps 1 and 4. The access token itself now carries "acr": "urn:btl:acr:basic" after the password and "acr": "urn:btl:acr:phishing-resistant" after the passkey, set by the authentication engine. acr is already a reserved claim name, so no claim mapping can write it.

  3. Repeat step 3: introspection returns acr and amr alongside auth_time.

Break it

  1. A missing claim never meets a requirement. In lab-tmp-at-signin, remove the auth_time mapping and save. Repeat step 1. In its own terminal, start a local photo API whose deletion policy needs a sign-in within the last 120 seconds, then call it from your main terminal:

btl-lab resource --port 8766 --mode introspect --audience "$ISSUER/resource" --delete-max-age 120
curl -s -i -X DELETE -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8766/photos/9137 | grep -i -E '^HTTP|www-authenticate'

The API answers 401 with WWW-Authenticate: Bearer error="insufficient_user_authentication", max_age="120", although Ava signed in seconds ago.

Restore: add the auth_time mapping back to lab-tmp-at-signin and save. Stop the local API.

Why it matters: without the claim the API cannot know when Ava signed in, so it must refuse rather than assume.

Check your work

Press Check my progress. The checks look in Lab Photos for Ava's password sign-in and code exchange at lab-printer-app, the photo API's introspection and Ava's passkey enrollment. Audit also shows tenant.oauth.managers.create and the manager updates from Break it.

Cleanup

Keep lab-tmp-at-signin, lab-printer-app, Ava's passkey and the REFRESH value for the next lab, and confirm the auth_time mapping is restored. If you stop here, assign lab-printer-app back to the tenant default manager, delete lab-tmp-at-signin, and run unset TOKEN REFRESH ID_TOKEN.

Missing infrastructure

  • G20 acr and acr_values: tenant-defined acr values mapped to sign-in methods in the authentication policy, acr_values honored as a requirement at the authorization endpoint, acr_values_supported in discovery, and acr in ID tokens.

  • G48 Authentication context in access tokens: acr and amr set by the protocol in access tokens and introspection responses. Today only auth_time can be mapped, so step 4 cannot show the difference to the API.

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