OPENID CONNECT · LAB
Compare CIBA delivery modes
Plan the same approval delivered by poll and by ping, with push refused, and today practise the two client-side checks the notification modes depend on: the notification token and at_hash.
PlannedIncludes a simulationUses your lab tenant
The lesson
Builds on: Following a CIBA exchange.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Planned. The core of this lab waits on platform features that are not built yet. The planned walkthrough shows exactly how it will run; Do today is a real exercise you can do now.
- G32 CIBA
- G4 Hosted lab callback page / demo relying party beyond Token Decoder
Setup
CIBA is not implemented yet (G32). Ping and push also need an HTTPS notification endpoint the tenant can reach, such as a hosted lab receiver (G4) or your own tunnel.
source ~/btl-oidc.shand runbtl-lab callbackbefore each sign-in.Once G32 exists: set
lab-tmp-kiosk's delivery mode topingwith the notification endpointhttps://<your receiver>/ciba/notify.
Planned walkthrough
Poll mode. Run the exchange from Run a CIBA poll-mode exchange and measure the delay between Ava's approval and the kiosk's next poll. Poll faster than
intervaland seeslow_down; add five seconds to the interval for the rest of the attempt.Ping mode. Send a fresh
client_notification_tokenwith the request:
NOTIFY=$(openssl rand -base64 24 | tr '+/' '-_' | tr -d '=')
curl -s -u "$KIOSK_ID:$KIOSK_SECRET" "$ISSUER/oidc/backchannel_authentication" -d scope=openid --data-urlencode "[email protected]" -d binding_message=H4PX --data-urlencode "client_notification_token=$NOTIFY"
On approval, the tenant posts {"auth_req_id":"..."} to your endpoint with Authorization: Bearer and your token. Answer 204, then make one token request.
Why it matters: the ping says only that a result is ready. Tokens still come only from the token endpoint, after client authentication.
A ping for a declined request: the token request returns
access_denied.Discovery lists
backchannel_token_delivery_modes_supportedas["poll","ping"], and registeringpushis refused, the FAPI position the lesson describes.
Do today
Recompute
at_hash, the check push mode relies on. A normallab-collagetoken response carries an access token and an ID token whoseat_hashcovers it:
signin
redeem '<code>'
COMPUTED=$(printf %s "$TOKEN" | openssl dgst -sha256 -binary | head -c 16 | openssl base64 -A | tr '+/' '-_' | tr -d '=')
[ "$COMPUTED" = "$(part "$ID_TOKEN" | jq -r .at_hash)" ] && echo "at_hash matches" || echo "at_hash mismatch: do not use the access token"
at_hash matches: the left half of the SHA-256 hash, Base64url-encoded, because the ID token is signed with RS256.
Why it matters: in push mode the tokens arrive at an endpoint the client exposes, and the signed ID token's at_hash is what vouches for the access token in that unauthenticated delivery.
Build the notification endpoint's one rule: accept only the bearer value you issued for that request. Save as
notify.mjsand run it:
import { createServer } from 'node:http';
import { randomBytes, timingSafeEqual } from 'node:crypto';
const issued = new Map([['demo-auth-req-21', randomBytes(24).toString('base64url')]]);
console.log('Notification token for demo-auth-req-21:', issued.get('demo-auth-req-21'));
createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/ciba/notify') return res.writeHead(404).end();
let body = '';
for await (const chunk of req) { body += chunk; if (body.length > 4096) return res.writeHead(413).end(); }
let id = null;
try { id = JSON.parse(body).auth_req_id; } catch { id = null; }
const expected = issued.get(id), given = Buffer.from((req.headers.authorization ?? '').replace(/^Bearer /, ''));
const ok = Boolean(expected) && given.length === expected.length && timingSafeEqual(given, Buffer.from(expected));
console.log(JSON.stringify({stage: 'ciba_ping', known_request: Boolean(expected), result: ok ? 'accepted' : 'refused'}));
res.writeHead(ok ? 204 : 401).end();
}).listen(8767, '127.0.0.1');
Call it as the provider would, once with the value it printed and once with a value it never issued.
Simulation. no tenant sends pings yet (G32), so you play the provider's notification with curl. Your endpoint's decision is real code you would deploy.
read -rs NOTIFY_TOKEN
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8767/ciba/notify -H "Authorization: Bearer $NOTIFY_TOKEN" -H 'Content-Type: application/json' -d '{"auth_req_id":"demo-auth-req-21"}'
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8767/ciba/notify -H "Authorization: Bearer not-the-issued-value" -H 'Content-Type: application/json' -d '{"auth_req_id":"demo-auth-req-21"}'
204, then 401, and the log lines contain no token.
Why it matters: the client issues this token and the provider presents it, the reverse of the usual direction. A forged ping does little in ping mode, and in push mode this check is the first gate before any token is used.
Break it
Planned, once G32 exists: your notification endpoint receives a ping whose bearer value it did not issue. It answers 401, makes no token request, and records the refusal.
Check your work
Today: at_hash matches for a real token response, and your endpoint answering 204 for the issued value and 401 for any other.
Once G32 exists, Audit and Logs show each ping delivered with its status, and tokens issued only at the token endpoint.
Cleanup
Stop notify.mjs. Once G32 exists, remove the notification endpoint from lab-tmp-kiosk.
Missing infrastructure
G32 (CIBA). Poll and ping delivery, the notification token, and delivery events.
G4 (hosted lab receiver). A hosted notification receiver for learners without public HTTPS, since the tenant cannot reach
127.0.0.1.