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

OPENID CONNECT · LAB

Run a complete OpenID Connect sign-in by hand, one message at a time

Create a pending sign-in, send the authentication request, handle the callback, redeem the code and start your own session, then watch single sign-on and a replayed code.

ReadyUses your lab tenant

The lesson

Builds on: From delegated access to sign-in.

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. The authentication request reaches the provider

    Recorded as oauth.authorize succeeded (request_started) for lab-collage.

  2. Ava signs in at the provider

    Recorded as oauth.authorize succeeded (user_signed_in) for lab-collage about [email protected].

  3. The provider sends a code back to lab-collage

    Recorded as oauth.authorize succeeded (code_issued) for lab-collage about [email protected].

  4. lab-collage redeems the code for tokens

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

  5. A second redemption of the same code is refused

    Recorded as oauth.token rejected (code_replayed) for lab-collage.

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

  1. You need lab-collage, Ava and the shell variables from the first lab in this track. Keep btl-lab callback running in a second terminal.

  2. Close any private windows, so the next one has no tenant session.

  3. Press Start.

Walkthrough

  1. Create the pending sign-in. Generate fresh values and record the attempt as the lesson's printer records demo-signin-3. The verifier stays in your shell, like a value held privately on the backend.

eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
jq -n --arg s "$STATE" --arg n "$NONCE" --arg i "$ISSUER" \
  '{state: $s, nonce: $n, expected_issuer: $i, redirect_uri: "http://127.0.0.1:8765/callback", return_to: "/collages/new", status: "pending"}' > pending.json
cat pending.json

Why it matters: compared with an OAuth connection, the only new line in the record is the nonce.

  1. Send the authentication request. Open this request in a new private window (the console opens authorization requests as a browser navigation):

GET$ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=openid%20profile%20email&state=$STATE&nonce=$NONCE&code_challenge=$CHALLENGE&code_challenge_method=S256 Open in console
GET $ISSUER/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&scope=openid%20profile%20email&state=$STATE&nonce=$NONCE&code_challenge=$CHALLENGE&code_challenge_method=S256 HTTP/1.1

It asks for sign-in, the basic profile and the email address, and says nothing about photos.

  1. At the provider. There is no tenant session in the private window, so the tenant asks Ava to sign in. Then the consent page asks whether lab-collage may see her profile and email address. Allow.

Why it matters: the provider authenticates Ava and asks for her agreement. The collage app never sees her password or how she signed in.

  1. Back at the relying party. The listener prints code, state and iss. Handle the callback as the lesson's printer does, before the code goes anywhere:

CODE='<code>'; CB_STATE='<state>'; CB_ISS='<iss>'
[ "$CB_STATE" = "$(jq -r .state pending.json)" ] && [ "$CB_ISS" = "$(jq -r .expected_issuer pending.json)" ] \
  && [ "$(jq -r .status pending.json)" = pending ] && jq '.status = "claimed"' pending.json > p.tmp && mv p.tmp pending.json && echo claimed

Why it matters: finding the attempt by state, comparing iss and claiming the attempt are pure OAuth, and they happen before any token request.

  1. Exchange the code with client authentication and the verifier:

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" --data-urlencode "code_verifier=$VERIFIER")
jq 'del(.access_token, .id_token)' <<<"$RESP"; ID_TOKEN=$(jq -r .id_token <<<"$RESP"); TOKEN=$(jq -r .access_token <<<"$RESP"); FIRST_CODE=$CODE

The response now contains id_token beside the access token. This is where OpenID Connect becomes visible.

  1. Validate before believing anything:

btl-lab verify "$ID_TOKEN" --issuer "$(jq -r .expected_issuer pending.json)" --audience "$CLIENT_ID" --type id --nonce "$(jq -r .nonce pending.json)" --access-token "$TOKEN"

Every check passes and the run ends in ACCEPT. The expected issuer and nonce come from your pending record, never from the token. The Validation section of this track takes each check apart.

  1. Find the account. The lookup key is the pair ($ISSUER, sub). Write it down from the accepted token. If no collage account were linked to it, this would be Ava's first visit.

  1. Start your own session with a new random identifier, never the anonymous attempt's values, and go to the return destination:

jq -n --arg sid "$(openssl rand -hex 16)" --arg iss "$ISSUER" --arg sub '<sub from step 7>' --arg at '<auth_time>' \
  '{session_id: $sid, iss: $iss, sub: $sub, auth_time: ($at | tonumber)}' > session.json
echo "redirect to $(jq -r .return_to pending.json)"

Why it matters: until this step Ava was signed in at the provider but not at the collage app. Validation and a session of its own are the two jobs OpenID Connect adds to the relying party.

  1. Single sign-on. In the same private window, repeat steps 1, 2 and 4 to 6 with fresh values. The tenant asks nothing: its session is still valid and consent is remembered, so the callback arrives at once.

Break it

  1. Replay the first code. Send the token request from step 5 again with the code you already redeemed:

curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=authorization_code --data-urlencode "code=$FIRST_CODE" --data-urlencode "redirect_uri=http://127.0.0.1:8765/callback" --data-urlencode "code_verifier=$VERIFIER" | jq .

Expect 400 with invalid_grant. The tenant records code_replayed and revokes the tokens issued from that code. The verifier no longer matters: a used code is refused first.

  1. Feed the step 4 callback to a new attempt: run step 1 again so pending.json holds a different state, then repeat the check in step 4 with the old CB_STATE. Nothing prints claimed, so no token request is made.

Check your work

Press Check my progress. In Audit, under protocol activity, the first sign-in reads in order: oauth.authorize request_started, user_signed_in, code_issued, then oauth.token succeeded, and later oauth.token rejected with code_replayed.

The single sign-on run in step 9 shows request_started then code_issued with no user_signed_in between them. The consent page itself is counted in Logs as oauth.authorize with consent_required.

Cleanup

Delete pending.json, p.tmp and session.json, and run unset TOKEN ID_TOKEN RESP CODE FIRST_CODE.

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