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

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.

Download btl-lab.mjs

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

  1. Download it: curl -fsSLO https://beyondthelogin.dev/lab/toolkit/btl-lab.mjs

  2. Compare its SHA-256 with the value shown on /lab/toolkit/. Do not run it if they differ.

    • Linux: sha256sum btl-lab.mjs

    • macOS: shasum -a 256 btl-lab.mjs

    • PowerShell: Get-FileHash btl-lab.mjs -Algorithm SHA256

  3. 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. none and HS* are never accepted.

  • --nonce v: the nonce your request sent. An ID token that carries a nonce is refused without it. --no-nonce skips the check on purpose, for a token whose request you did not send.

  • --access-token t and --code c: compare at_hash and c_hash.

  • --trusted-audience aud: accept an ID token that also names this audience. Any other extra audience is refused.

  • --max-age s: require auth_time within 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 known kid needs 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, alg and 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
RouteScope
GET /photos, GET /albums/{id}/photos, GET /objects/{id}the read scope, photos.read unless --read-scope changes it
POST /photosphotos.write
GET /shares, POST /sharesphotos.share
DELETE /photos/{id}photos.delete
POST /printsprints.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 answer 404.

  • --delete-max-age s: DELETE needs a sign-in within that many seconds, or it answers 401 with error="insufficient_user_authentication" and max_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.

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