Lab toolkit
One small Node.js script that the labs use to make PKCE values, listen for a callback, decode and fully validate tokens, introspect, and play a local photo API. It has no dependencies, never sends anything except the requests you ask for, and never creates or signs tokens for anyone.
SHA-256: abe8c0165ae94346b93f6698681e4f3da6234dda3107226ce3ab768f67b6041d
curl -fsSLO https://beyondthelogin.dev/lab/toolkit/btl-lab.mjs
sha256sum btl-lab.mjs # compare with the value above
alias btl-lab="node $PWD/btl-lab.mjs"
btl-lab help
btl-lab.mjs is one script for Node.js 22 or later, with no dependencies. The labs use it to check tokens, catch callbacks and run a small photo API on your own machine.
It only validates, relays, or acts as your own client. It never creates, signs or alters a token on anyone else's behalf. It sends no telemetry and only contacts the issuer you name, plus the JWKS and introspection endpoints that the issuer's discovery document lists (or the key set address you pass). The callback listener and the photo API listen on 127.0.0.1 only.
Install
Download it:
curl -fsSLO https://beyondthelogin.dev/lab/toolkit/btl-lab.mjsCompare its SHA-256 with the value shown on
/lab/toolkit/. Do not run it if they differ.Linux:
sha256sum btl-lab.mjsmacOS:
shasum -a 256 btl-lab.mjsPowerShell:
Get-FileHash btl-lab.mjs -Algorithm SHA256
Move it to your home directory and add an alias:
alias btl-lab="node ~/btl-lab.mjs"
Run btl-lab help for the command list, or btl-lab <command> --help for one command.
Secrets
Client secrets come from environment variables only, never from the command line, and the toolkit never prints them. Read a secret without echoing it or saving it to your shell history:
read -rs API_SECRET && export API_SECRET
introspect and resource --mode introspect act as lab-photo-api with API_ID and API_SECRET. --client-env PREFIX reads PREFIX_ID and PREFIX_SECRET instead.
Commands
env [track] shows which lab variables are set. Addresses (ISSUER, ISSUER2, REDIRECT_URI, SCIM) are printed; everything else shows as set or missing. Tracks: fundamentals, authn, governance, security, oauth, oidc.
btl-lab env oauth
discover <issuer> fetches discovery, checks that it names exactly that issuer, and lists the endpoints, supported grants, response types and modes, PKCE methods, client authentication methods, iss parameter support and the published keys.
btl-lab discover "$ISSUER"
pkce prints export VERIFIER=... and export CHALLENGE=... (S256). state prints export STATE=... and export NONCE=.... Use eval to set them in your shell.
eval "$(btl-lab pkce)"; eval "$(btl-lab state)"
callback [--port 8765] [--timeout 300] [--expect-state v] [--expect-iss v] waits for one redirect on 127.0.0.1, on any path, and prints code, state, iss, any error and the other parameters, then exits. It handles query responses, form_post bodies and fragments. The expect options print MATCH or MISMATCH, the checks a careful client makes before using a code.
btl-lab callback --expect-state "$STATE" --expect-iss "$ISSUER"
decode <jwt> shows the header and payload with readable times. Decoding is not validating: anyone can write a token that decodes.
btl-lab decode "$TOKEN"
verify <jwt> --issuer <url> --audience <aud> validates a token against the expected issuer's keys. It prints one line per check and stops at the first failure, in this order: algorithm, type, key, signature, issuer, audience, authorized party, expiry, issued-at, not-before, authentication age, required claims, nonce, at_hash, c_hash. The last line is ACCEPT or REJECT at the <check> check: <reason>, and the exit code is 0 only on ACCEPT.
btl-lab verify "$TOKEN" --issuer "$ISSUER" --audience "$ISSUER/resource" --type at+jwt
btl-lab verify "$ID_TOKEN" --type id --issuer "$ISSUER" --audience "$CLIENT_ID" --algs RS256 --nonce "$NONCE" --access-token "$TOKEN"
Options for verify:
--type at+jwt|id|jarm: an access token (RFC 9068, the default), an ID token, or a signed authorization response.--algs RS256,ES256: your own algorithm allowlist.noneandHS*are never accepted.--nonce v: the nonce your request sent. An ID token that carries a nonce is refused without it.--no-nonceskips the check on purpose, for a token whose request you did not send.--access-token tand--code c: compareat_hashandc_hash.--trusted-audience aud: accept an ID token that also names this audience. Any other extra audience is refused.--max-age s: requireauth_timewithin that many seconds.--max-iat-age s: refuse tokens issued longer ago.--skew 60: clock leeway in seconds.--clock-offset s: shift the verifier's clock to rehearse drift.--jwks-uri u: take keys from this address instead of discovery.--jwks-cache file: keep the key set in a file. A knownkidneeds no fetch; an unknown one refetches once. Delete the file to discard it.--log file: append one JSON line per rejection with the check, reason, expected issuer and audience,kid,algand token age. It never holds the token.
An empty --issuer or --audience is refused rather than skipped.
introspect <token> asks the issuer whether a token is active (RFC 7662) as lab-photo-api, with API_ID and API_SECRET. Without them it uses CLIENT_ID and CLIENT_SECRET.
btl-lab introspect "$TOKEN" --client-env API
resource runs a local photo API on port 8766. It trusts $ISSUER (or --issuer), expects the audience <issuer>/resource (or --audience), and in JWT mode accepts only ES256 (or --algs). Introspect mode (--mode introspect) asks the issuer as lab-photo-api and caches answers for up to 60 seconds (--introspection-cache), never past exp.
btl-lab resource --mode jwt | tee ~/lab-photo-api.log
curl -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8766/photos
| Route | Scope |
|---|---|
GET /photos, GET /albums/{id}/photos, GET /objects/{id} | the read scope, photos.read unless --read-scope changes it |
POST /photos | photos.write |
GET /shares, POST /shares | photos.share |
DELETE /photos/{id} | photos.delete |
POST /prints | prints.create |
Any other route is refused. Options for resource:
--album 42=<sub>(repeatable): who owns each album. Another person's album and a missing one both answer404.--delete-max-age s:DELETEneeds a sign-in within that many seconds, or it answers401witherror="insufficient_user_authentication"andmax_age(RFC 9470).--alias-scope old=new(repeatable): accept an old scope where the new one is required, during a migration.--actor id: require a delegated token whose current actor (act.sub) is this client.--clock-offset s: shift the API's clock to rehearse drift.
Album and delete routes act on a person's data, so a token a client obtained for itself is refused there. The API answers with RFC 6750 challenges and writes one JSON decision per request to standard output, with the route, subject, client, jti, required scope, outcome and reason, never the token. Status messages go to standard error, so the log file holds JSON only.
thumbprint <jwk-file> prints the RFC 7638 SHA-256 thumbprint of a public key, or of each key in a JWKS file.
btl-lab thumbprint public.jwk
Network failures and error responses are reported with the HTTP status and the error and error_description fields only.