diff --git a/CLAUDE.md b/CLAUDE.md
index 59843a6..6961458 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -169,8 +169,10 @@ mini-auth/
don't rename them. Preserve this mapping in mini-token / mini-policy work. **Note this is a
*designed* contract, not a wired runtime path:** `KmsRequestHandler` still authenticates with a
shared per-plane token + fixed principals and ships `AllowAllPolicyEngine` — it does not yet parse a JWT
- or consume `grants` (`GrantsClaim.toAuthorization()` has no production caller). The token → mini-kms
- authorization step is a future seam; see DIRECTION.md's "Wired vs. designed" note.
+ or consume `grants`. (`GrantsClaim.toAuthorization()` now *does* have a production caller, but it is
+ **mini-gateway**'s `BearerAuthenticator` mapping a machine token's `grants` into forward-auth scopes —
+ **not** mini-kms.) The token → mini-kms authorization step is a future seam; see DIRECTION.md's
+ "Wired vs. designed" note.
- **Don't duplicate foundation code.** Argon2 hashing, the atomic-`0600` JSON store, base64url,
constant-time compare, and the `ServerConfig` env/file token pattern exist in *both* shipping
services today. They are catalogued as **`mini-common` extraction candidates** in
diff --git a/docs/DIRECTION.md b/docs/DIRECTION.md
index ddaaa76..29714dc 100644
--- a/docs/DIRECTION.md
+++ b/docs/DIRECTION.md
@@ -172,10 +172,12 @@ Principal.admin`, `grants.groups[]` → a per-key-group `PolicyEngine` decision
mini-policy is where it is evaluated; mini-directory is where the grants originate.
> **Wired vs. designed — read this before tracing the token → mini-kms path.** The mapping above is
-> the *designed* contract, and the verifier half (`GrantsClaim.toAuthorization()`) exists — but it is
-> **not yet the live runtime path**. Today mini-kms authenticates callers with a shared per-plane
-> bearer token and two fixed principals (`KmsRequestHandler`); it does not parse a JWT or consume a
-> `grants` claim, and ships `AllowAllPolicyEngine` on the data plane. So a learner who picks this headline
+> the *designed* contract, and the verifier half (`GrantsClaim.toAuthorization()`) exists and now has
+> a production caller — **mini-gateway**'s `BearerAuthenticator`, which maps a machine token's `grants`
+> into `keyGroup:OPERATION` scopes for a forward-auth `SCOPE` decision. But it is **not yet the live
+> runtime path into mini-kms**. Today mini-kms authenticates callers with a shared per-plane bearer
+> token and two fixed principals (`KmsRequestHandler`); it does not parse a JWT or consume a `grants`
+> claim, and ships `AllowAllPolicyEngine` on the data plane. So a learner who picks this headline
> relationship to trace through running code will not find the bridge — it is a documented future
> seam, not current behavior. **What is wired today:** mini-directory → mini-oidc/mini-idp identity
> resolution; mini-policy decisions inside mini-oidc (scopes) and mini-gateway (routes); and the
diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index d2f2c37..81f3511 100644
--- a/docs/GLOSSARY.md
+++ b/docs/GLOSSARY.md
@@ -120,6 +120,41 @@ The token plane lives in **mini-token** (`libs/mini-token`); the issuers are **m
---
+## Passkeys: WebAuthn
+
+Human authentication lives in **mini-oidc** (`services/mini-oidc`), which embeds **pk-auth** to run
+the ceremonies. A passkey replaces a shared secret with a key pair, so there is nothing secret on the
+server to phish or breach.
+
+- **WebAuthn** — the W3C browser standard (exposed via `navigator.credentials.create`/`get`) that lets
+ a site register and use public-key credentials, paired with CTAP (the protocol to the authenticator
+ device). It is the mechanism behind passkeys.
+- **Passkey** — the consumer-friendly name for a WebAuthn credential: a per-site key pair whose
+ **private** half stays in the device's secure hardware and whose **public** half is all the server
+ stores. mini-oidc's humans authenticate with one (`auth/PkAuthHumanAuthenticator`).
+- **Challenge** — a fresh random value the server issues at the start of each ceremony; the
+ authenticator must sign *it* (challenge–response), so a captured response can't be replayed. (The
+ same anti-replay idea as a crypto nonce, but issued per login attempt.)
+- **Assertion** — the signed response produced at **login** (`navigator.credentials.get`): a signature
+ over the challenge (plus context) proving possession of the private key. mini-oidc reads the
+ authenticated user handle off the *verified* assertion and nothing else (`finishAssertion`).
+- **Attestation** — the signed statement produced once at **registration**
+ (`navigator.credentials.create`): it certifies the new key and the authenticator that made it.
+ (Distinct from an assertion, which is produced every login.)
+- **RP-ID (Relying Party ID)** — the domain a credential is bound to (e.g. `oidc.example`). The browser
+ fires a credential only when the page's origin matches the RP-ID, and the server checks it on
+ verification — the binding that makes a credential unusable on a look-alike site. mini-oidc sets it
+ with `--rp-id`.
+- **Origin** — the scheme + host + port of the page running the ceremony (e.g.
+ `https://oidc.example`), stamped into the signed data and verified server-side against the
+ configured `--rp-origin`. The origin binding (with the RP-ID) is what makes WebAuthn **phishing
+ resistant**: a forwarded ceremony fails because the signed origin is wrong.
+- **pk-auth** — the external passkey library (`com.codeheadsystems:pk-auth-core`, WebAuthn4J under the
+ hood) mini-oidc embeds to run both ceremonies and store credentials behind swappable SPIs. It is
+ *not* vendored — see `auth/PasskeyStack`.
+
+---
+
## Identity & authorization
The decision model is **mini-policy** (`libs/mini-policy`); the identity source of truth is
diff --git a/docs/TEACHING.md b/docs/TEACHING.md
index 757bca5..4d97ed4 100644
--- a/docs/TEACHING.md
+++ b/docs/TEACHING.md
@@ -56,6 +56,7 @@ proves it. Do them in order and each one earns the next. (This is a different or
| **1** | See a decision as a pure function; explain deny-by-default | [`authorization-model`](concepts/authorization-model.md) | [`01-resolve-a-principal`](tutorials/01-resolve-a-principal.md) |
| **2** | Say what a signed token *is* and verify one offline, by hand | [`what-a-token-is`](concepts/what-a-token-is.md) | [`02-build-and-verify-a-token-by-hand`](tutorials/02-build-and-verify-a-token-by-hand.md) ← **keystone** |
| **3** | Trace a machine identity end to end | [`oauth-and-oidc-flows`](concepts/oauth-and-oidc-flows.md) | [`03-machine-identity-end-to-end`](tutorials/03-machine-identity-end-to-end.md) |
+| **3.5** | Explain how a passkey proves identity without a shared secret | [`what-a-passkey-is`](concepts/what-a-passkey-is.md) | *(see it in [`04`](tutorials/04-human-sso-end-to-end.md))* |
| **4** | Run a human SSO login: PKCE, passkeys, sessions, refresh | [`sessions-vs-tokens`](concepts/sessions-vs-tokens.md) | [`04-human-sso-end-to-end`](tutorials/04-human-sso-end-to-end.md) |
| **5** | Gate a no-auth app via forward-auth | *(reuse stage 4)* | [`05-gate-a-no-auth-app`](tutorials/05-gate-a-no-auth-app.md) |
| **6** | Explain how the family protects its own keys | [`envelope-encryption-and-kms`](concepts/envelope-encryption-and-kms.md) | [`06-protect-the-signing-keys`](tutorials/06-protect-the-signing-keys.md) |
diff --git a/docs/concepts/honest-seams.md b/docs/concepts/honest-seams.md
index d6bf118..bed2ef7 100644
--- a/docs/concepts/honest-seams.md
+++ b/docs/concepts/honest-seams.md
@@ -31,12 +31,14 @@ authorization model (`sub → Principal.id`, `grants.control → Principal.admin
**shared per-plane bearer token** and two fixed principals. It does **not** parse a JWT and does
**not** read `grants`.
- `GrantsClaim.toAuthorization()` (`libs/mini-token/.../token/GrantsClaim.java`) — the method that
- would bridge a token claim into the authorization model — has **no production caller.** Its only
- caller is a test (`mini-idp` `TokenLifecycleTest`).
-
-**Don't teach:** "a token from mini-idp authorizes a mini-kms operation." It can't today; the bridge
-exists only as a designed seam. (Stage 7's optional design exercise walks *what it would take* — and
-labels itself a design exercise.)
+ bridges a token claim into the authorization model — *does* now have a production caller, but it is
+ **mini-gateway**, not mini-kms: `BearerAuthenticator` maps a machine token's `grants` into
+ `keyGroup:OPERATION` scopes for a forward-auth `SCOPE` decision. mini-kms still does **not** call it.
+
+**Don't teach:** "a token from mini-idp authorizes a mini-**kms** operation." It can't today; the
+mini-kms half of the bridge exists only as a designed seam (the gateway consumes `grants`, but
+mini-kms does not). (Stage 7's optional design exercise walks *what it would take* — and labels itself
+a design exercise.)
## 2. mini-kms data plane ships an allow-all policy
diff --git a/docs/concepts/oauth-and-oidc-flows.md b/docs/concepts/oauth-and-oidc-flows.md
index ede56d4..7659b70 100644
--- a/docs/concepts/oauth-and-oidc-flows.md
+++ b/docs/concepts/oauth-and-oidc-flows.md
@@ -13,7 +13,7 @@ actor.**
| | **client-credentials** | **authorization-code + PKCE** |
| --- | --- | --- |
| Actor | a machine (service account) | a human, via a browser |
-| Proves identity with | `client_secret` | a passkey (WebAuthn) |
+| Proves identity with | `client_secret` | a passkey (WebAuthn) — see [`what-a-passkey-is`](what-a-passkey-is.md) |
| Steps | one POST | a multi-step browser redirect dance |
| Issuer | mini-idp | mini-oidc |
| Returns | access token | **ID token** + access token (+ refresh) |
diff --git a/docs/concepts/what-a-passkey-is.md b/docs/concepts/what-a-passkey-is.md
new file mode 100644
index 0000000..aa025d9
--- /dev/null
+++ b/docs/concepts/what-a-passkey-is.md
@@ -0,0 +1,130 @@
+# What a passkey *is* — login without a shared secret
+
+> **Concept doc (explanation).** Stage 3.5 (the human-authentication half of stage 4). Anchored on
+> **mini-oidc** (which embeds **pk-auth**). New terms link to [`GLOSSARY.md`](../GLOSSARY.md); the
+> rationale for embedding pk-auth lives in [`DIRECTION.md`](../DIRECTION.md). Pairs with the lab
+> [`tutorials/04-human-sso-end-to-end.md`](../tutorials/04-human-sso-end-to-end.md), where a person
+> logs in with a passkey for real. Read [`what-a-token-is.md`](what-a-token-is.md) first — a passkey
+> proves *who you are*; the token is what mini-oidc mints *after* that proof.
+
+If you take one idea from this doc, take this:
+
+> **A passkey is a key pair. The private half never leaves your device; the server only ever stores
+> the public half. "Logging in" means the server sends a random challenge, your device signs it with
+> the private key, and the server checks the signature against the public key it stored at sign-up —
+> the same offline signature check a token uses, pointed at *authentication* instead of
+> *authorization*.**
+
+There is no shared secret to phish, reuse, or leak from a breached database. That single property is
+why passkeys exist.
+
+---
+
+## The problem passkeys solve
+
+A password is a **shared secret**: you know it, the server stores a hash of it, and anyone who learns
+it *is* you. That makes passwords vulnerable to the whole zoo — reuse across sites, phishing pages
+that capture what you type, and database breaches that leak millions of hashes to crack offline.
+
+A passkey replaces the shared secret with **public-key cryptography** ([asymmetric
+crypto](../GLOSSARY.md#cryptographic-foundations)). At sign-up your device generates a fresh key pair
+*for this one site*. It keeps the **private key** (in secure hardware — a TPM, a Secure Enclave, a
+security key) and hands the site only the **public key**. The site stores that public key against
+your account. There is now **nothing secret on the server** to steal: a breach leaks public keys,
+which are useless to an attacker.
+
+This is [WebAuthn](../GLOSSARY.md#passkeys-webauthn) — the browser standard (W3C) that exposes this
+to web pages via `navigator.credentials` — together with CTAP, the protocol to the authenticator
+device. "Passkey" is the consumer-friendly name for a WebAuthn credential.
+
+---
+
+## Two ceremonies: registration and authentication
+
+WebAuthn has exactly two flows, and they mirror each other. Both are **challenge–response**: the
+server issues a fresh random [challenge](../GLOSSARY.md#passkeys-webauthn) and the device answers in a
+way only the right private key can.
+
+### Registration (sign-up) — `navigator.credentials.create()`
+
+1. The server sends **creation options**: a random challenge, the [RP-ID](../GLOSSARY.md#passkeys-webauthn)
+ (which site this credential is for), and your user id.
+2. Your authenticator **generates a new key pair**, stores the private key, and returns the **public
+ key** plus an **[attestation](../GLOSSARY.md#passkeys-webauthn)** — a signed statement, *"I, a
+ genuine authenticator, just created this key for this challenge and this site."*
+3. The server verifies the attestation and **stores the public key** against your account.
+
+### Authentication (login) — `navigator.credentials.get()`
+
+1. The server sends a fresh random challenge.
+2. Your authenticator **signs the challenge** (plus some context) with the stored private key,
+ producing an **[assertion](../GLOSSARY.md#passkeys-webauthn)**.
+3. The server **verifies the signature** against the public key it stored at registration. Match →
+ you're authenticated.
+
+> **Attestation vs. assertion** — the two are easy to confuse. *Attestation* is produced once, at
+> **registration**, and certifies *the authenticator and the new key*. *Assertion* is produced every
+> **login**, and proves *possession of the private key for this challenge*. mini-oidc reads the
+> verified assertion's user handle to learn who just logged in — and nothing else from pk-auth.
+
+---
+
+## Why a passkey resists phishing
+
+This is the part passwords can never match, and it comes from two bindings the browser enforces — not
+the user's vigilance.
+
+- **Origin/RP-ID binding.** The browser will only invoke a credential whose RP-ID matches the
+ [origin](../GLOSSARY.md#passkeys-webauthn) of the page actually in the address bar, and it stamps
+ that origin into the signed data. A look-alike phishing page at `οidc.example` (a Cyrillic
+ homograph) has a *different* origin, so the genuine credential simply will not fire there — and even
+ a forwarded ceremony fails verification because the origin in the signature is wrong. There is no
+ secret for the user to be tricked into typing into the wrong box, because **there is no secret to
+ type at all.**
+- **Challenge binding.** Each login signs a *fresh server-issued challenge*, so a captured assertion
+ can't be replayed — it answered a one-time question. (Same idea as a token's short `exp`, enforced
+ cryptographically per attempt.)
+
+Compare this to a token (stage 2): a token's signature is checked by the *resource server* to trust a
+*claim set*; a passkey's signature is checked by the *identity provider* to trust *the person at the
+keyboard*. Same primitive — a signature over reproducible bytes, verified against a stored public key
+— aimed at a different job.
+
+---
+
+## Where pk-auth fits (and where mini-oidc takes over)
+
+mini-oidc does **not** hand-roll WebAuthn — the verification (attestation formats, signature counters,
+origin checks via WebAuthn4J under the hood) is exactly the kind of crypto the "mini" ethos says to
+get from a vetted library. So mini-oidc **embeds [pk-auth](../GLOSSARY.md#passkeys-webauthn)** to run
+both ceremonies and to store credentials behind swappable SPIs.
+
+The boundary is deliberate: pk-auth proves *the human is who they claim*, and mini-oidc reads only the
+authenticated **user handle** off the verified assertion — it then resolves that human in
+mini-directory and mints its **own** ID/access tokens through mini-token. mini-oidc never consumes
+pk-auth's own JWT. So the passkey is the *front door*; everything past it is the token plane you
+already know.
+
+> **Honest seam.** In mini-oidc today, passkey **enrolment** (`/register/passkey/**`) is
+> *unauthenticated self-enrolment* — anyone can enrol a credential for a username. A real deployment
+> gates enrolment behind an existing session or an invite. See
+> [`honest-seams.md`](honest-seams.md#3); the lab calls this out where you hit it.
+
+---
+
+## Now read it
+
+- **The human-authentication seam:** `services/mini-oidc` → `auth/HumanAuthenticator` (the SPI: a
+ `startRegistration`/`startAssertion` → `finish` pair returning a `Challenge`),
+ `auth/PkAuthHumanAuthenticator` (the pk-auth implementation — note it reads the authenticated
+ `userHandle` off the verified assertion, never pk-auth's JWT), and `auth/PasskeyStack` (assembles
+ the embedded pk-auth stack over its in-memory SPIs — the documented swap point for persistent
+ credential storage).
+- **Recovery:** `auth/RecoveryAuthenticator` (backup codes, for a lost authenticator).
+- **The browser side:** `services/mini-oidc` → `server/LoginPages` (the minimal login page whose
+ inline JS does the base64url ↔ ArrayBuffer plumbing and calls `navigator.credentials.get`).
+
+Now do the lab — [`04-human-sso-end-to-end.md`](../tutorials/04-human-sso-end-to-end.md): enrol a
+passkey with a virtual authenticator, log in for real, and watch mini-oidc mint the tokens from
+stage 2 *after* the passkey proves who you are. Then continue to stage 4,
+[`sessions-vs-tokens.md`](sessions-vs-tokens.md).
diff --git a/docs/examples/passkey-enroll.js b/docs/examples/passkey-enroll.js
new file mode 100644
index 0000000..b6cb81c
--- /dev/null
+++ b/docs/examples/passkey-enroll.js
@@ -0,0 +1,63 @@
+// passkey-enroll.js — enrol a passkey for mini-oidc from the browser DevTools Console.
+//
+// WHY a console snippet (and not a standalone .html file): a WebAuthn ceremony is bound to the page's
+// ORIGIN, and mini-oidc's server verifies that origin against its configured --rp-origin. Code pasted
+// into the DevTools Console runs *in the page's origin*, so running this while a mini-oidc page is
+// open makes the origin match automatically — a static file opened from disk (origin "null") or a
+// different port would fail server-side verification. (This is exactly why mini-oidc serves its own
+// login page; enrolment just has no shipped UI yet — an honest seam.)
+//
+// HOW TO USE (lab 04, step "Enrol a passkey"):
+// 1. Start mini-oidc (see the lab). Open ANY mini-oidc page in Chrome at its origin, e.g.
+// http://127.0.0.1:8477/docs
+// 2. Open DevTools → ⋮ (More tools) → WebAuthn → "Enable virtual authenticator environment",
+// then "Add authenticator" (defaults are fine: ctap2 / internal / resident-key + user-verif on).
+// The virtual authenticator stands in for real hardware, so the ceremony needs no security key.
+// 3. Open the Console, paste this whole file, and call: enrolPasskey('alice', 'Alice')
+// A "registered: true" log means the passkey is stored for that username. Now do the browser
+// login at the /authorize URL the lab builds.
+//
+// NOTE: this mirrors the base64url ↔ ArrayBuffer plumbing in mini-oidc's own login page
+// (server/LoginPages). A real deployment uses pk-auth's published browser SDK
+// (@pk-auth/passkeys-browser) instead of hand-rolling it.
+
+async function enrolPasskey(username = 'alice', displayName = 'Alice') {
+ const b64uToBuf = s =>
+ Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)).buffer;
+ const bufToB64u = b =>
+ btoa(String.fromCharCode(...new Uint8Array(b)))
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+
+ // 1. Ask mini-oidc for creation options (a fresh challenge + this user's id, RP-ID, etc.).
+ const started = await (await fetch('/register/passkey/start', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, displayName }),
+ })).json();
+
+ // 2. Decode the server's base64url fields into the ArrayBuffers the WebAuthn API wants.
+ const pk = started.publicKey;
+ pk.challenge = b64uToBuf(pk.challenge);
+ pk.user.id = b64uToBuf(pk.user.id);
+ (pk.excludeCredentials || []).forEach(c => { c.id = b64uToBuf(c.id); });
+
+ // 3. The authenticator generates a key pair and returns the public key + attestation.
+ const cred = await navigator.credentials.create({ publicKey: pk });
+
+ // 4. Re-encode the attestation response and hand it back for verification + storage.
+ const registration = {
+ id: cred.id,
+ rawId: bufToB64u(cred.rawId),
+ type: cred.type,
+ response: {
+ clientDataJSON: bufToB64u(cred.response.clientDataJSON),
+ attestationObject: bufToB64u(cred.response.attestationObject),
+ },
+ clientExtensionResults: cred.getClientExtensionResults(),
+ };
+ const res = await fetch('/register/passkey/finish', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ challengeId: started.challengeId, username, registration }),
+ });
+ console.log('enrol', username, '→', res.status, await res.json());
+ return res.ok;
+}
diff --git a/docs/tutorials/04-human-sso-end-to-end.md b/docs/tutorials/04-human-sso-end-to-end.md
index b62fc1a..7f2bd51 100644
--- a/docs/tutorials/04-human-sso-end-to-end.md
+++ b/docs/tutorials/04-human-sso-end-to-end.md
@@ -4,14 +4,16 @@
> **mini-oidc** with a **passkey**, gets ID + access tokens, and establishes a browser **SSO session**
> the next lab reuses.
>
-> **Concepts:** [`oauth-and-oidc-flows.md`](../concepts/oauth-and-oidc-flows.md) +
+> **Concepts:** [`what-a-passkey-is.md`](../concepts/what-a-passkey-is.md) +
+> [`oauth-and-oidc-flows.md`](../concepts/oauth-and-oidc-flows.md) +
> [`sessions-vs-tokens.md`](../concepts/sessions-vs-tokens.md). **Diagram:**
> [`auth-code-pkce`](../diagrams/auth-code-pkce.md).
>
> **⚠ One step needs a real browser.** The passkey ceremony (WebAuthn) cannot be done with `curl` —
> it needs a browser with a **platform authenticator or a virtual authenticator** (Chrome DevTools →
> *WebAuthn* tab works well). Everything *around* it is shown with `curl` below; the ceremony itself
-> is a browser step. This lab is honest about that seam rather than faking an assertion.
+> is a browser step, driven by the virtual authenticator + the helper script in step 3 — so the lab is
+> still **completable end to end**. This lab is honest about the seam rather than faking an assertion.
## 1. Start mini-oidc (with a directory)
@@ -36,6 +38,15 @@ services/mini-oidc/build/install/mini-oidc/bin/mini-oidc \
--directory-url http://127.0.0.1:8466 &
O="http://127.0.0.1:8477"
+
+# Register a PUBLIC (PKCE-only) relying-party client and capture its id. Public means no client
+# secret — the PKCE verifier is what proves the token request came from the app that started the flow.
+REDIRECT="http://127.0.0.1:8477/" # any registered URI; the browser lands here with ?code=…
+CLIENT_ID=$(curl -fsS -X POST "$O/admin/clients" \
+ -H "Authorization: Bearer $MINIOIDC_ADMIN_TOKEN" -H 'Content-Type: application/json' \
+ -d "{\"name\":\"Demo App\",\"redirectUris\":[\"$REDIRECT\"],\"scopes\":[\"openid\",\"profile\",\"email\"],\"confidential\":false}" \
+ | python3 -c 'import json,sys; print(json.load(sys.stdin)["clientId"])')
+echo "client_id = $CLIENT_ID"
```
> If you omit `--directory-url`, mini-oidc prints *"No --directory-url configured: using an empty
@@ -69,35 +80,53 @@ curl -fsS "$O/jwks.json" # { "keys": [ { "kty":"OKP","crv":"Ed25519","x":"…"
## 3. The browser flow (with a passkey)
Now the part that needs a browser. Walk the [auth-code+PKCE diagram](../diagrams/auth-code-pkce.md)
-alongside these steps.
+alongside these steps. Use **Chrome** (or any Chromium browser) for the DevTools virtual authenticator.
+
+**First, generate the PKCE pair** (you'll need the verifier again at the token step):
+
+```bash
+VERIFIER=$(openssl rand -base64 60 | tr '+/' '-_' | tr -d '=\n')
+CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | base64 | tr '+/' '-_' | tr -d '=\n')
+echo "verifier saved; challenge = $CHALLENGE"
+```
-1. **Enrol a passkey** (first time only). Open `POST /register/passkey/start` / `/finish` from a small
- page, or use the `/docs` UI. With Chrome DevTools' *WebAuthn* tab, enable a **virtual
- authenticator** first so you don't need real hardware.
+1. **Turn on a virtual authenticator and enrol a passkey for `alice`.** Open a mini-oidc page in
+ Chrome — `$O/docs` works — then **DevTools → ⋮ More tools → WebAuthn →** *Enable virtual
+ authenticator environment* → *Add authenticator* (the defaults are fine). The virtual authenticator
+ replaces real hardware. Now open the **Console**, paste
+ [`examples/passkey-enroll.js`](../examples/passkey-enroll.js), and run:
+ ```js
+ enrolPasskey('alice', 'Alice') // logs: enrol alice → 201 {registered: true}
+ ```
+ The script must run **on a mini-oidc page** so the WebAuthn ceremony uses mini-oidc's origin (the
+ script's header comment explains why a standalone file would fail).
> Enrolment here is *unauthenticated self-enrolment* — honesty seam
> [#3](../concepts/honest-seams.md#3). A real deployment gates it.
-2. **Start the flow.** Navigate the browser to `/authorize` with a PKCE challenge. Generate a
- verifier/challenge pair first:
+2. **Start the flow.** Build the authorize URL with your `$CLIENT_ID`, `$REDIRECT`, and `$CHALLENGE`,
+ then open it in the same browser tab:
```bash
- VERIFIER=$(openssl rand -base64 60 | tr '+/' '-_' | tr -d '=\n')
- CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | base64 | tr '+/' '-_' | tr -d '=\n')
- echo "$O/authorize?client_id=&redirect_uri=&response_type=code&scope=openid%20profile&state=xyz&nonce=n1&code_challenge=$CHALLENGE&code_challenge_method=S256"
+ echo "$O/authorize?client_id=$CLIENT_ID&redirect_uri=$REDIRECT&response_type=code&scope=openid%20profile&state=xyz&nonce=n1&code_challenge=$CHALLENGE&code_challenge_method=S256"
```
- (Register a client first via `POST /admin/clients` with the admin token; see `/docs` for the body.)
-3. mini-oidc serves a **login page** (no session yet) → you complete the **passkey** ceremony
- (`/login/passkey/start` → `/login/passkey/finish`). On success it **sets the session cookie**
- `mioidc_session` (HttpOnly, SameSite=Lax) and redirects to `/authorize/continue`.
-4. **Consent** → `POST /authorize/decision` → you're redirected to your `redirect_uri?code=…&state=xyz`.
+3. mini-oidc serves a **login page** (no session yet). Enter `alice` and click **Sign in with
+ passkey** — the page's JS runs the assertion ceremony (`/login/passkey/start` →
+ `/login/passkey/finish`) and the **virtual authenticator answers automatically**. On success
+ mini-oidc **sets the session cookie** `mioidc_session` (HttpOnly, SameSite=Lax) and continues to
+ `/authorize/continue`.
+4. **Consent** → click **Allow** (`POST /authorize/decision`) → the browser redirects to
+ `$REDIRECT?code=…&state=xyz`. The target page may show a 404 — that's fine; **copy the `code`
+ value straight out of the address bar.**
## 4. Exchange the code (back to curl)
The browser handed your app a **code**. The app redeems it on the back-channel with the **PKCE
-verifier** from step 2:
+verifier** from step 3. Paste the code from the address bar:
```bash
+CODE=""
curl -fsS -X POST "$O/token" \
- -d "grant_type=authorization_code&code=&redirect_uri=&code_verifier=$VERIFIER" \
- -u ":" # confidential client; public clients omit -u and rely on PKCE
+ -d "grant_type=authorization_code&code=$CODE&redirect_uri=$REDIRECT&client_id=$CLIENT_ID&code_verifier=$VERIFIER" \
+ | python3 -m json.tool
+# (Public/PKCE client: no -u. A confidential client would instead authenticate with -u "id:secret".)
```
```json
{ "access_token": "eyJ…", "id_token": "eyJ…", "refresh_token": "rt_…", "token_type": "Bearer",
@@ -117,10 +146,11 @@ curl -fsS -X POST "$O/token" \
Trade the refresh token for a fresh pair, then **replay the old one** and predict what happens:
```bash
+RT_OLD=""
# rotate once — get a NEW refresh_token back
-curl -fsS -X POST "$O/token" -d "grant_type=refresh_token&refresh_token=" -u ":"
+curl -fsS -X POST "$O/token" -d "grant_type=refresh_token&refresh_token=$RT_OLD&client_id=$CLIENT_ID" | python3 -m json.tool
# now replay the SAME old token again:
-curl -s -w "\nHTTP %{http_code}\n" -X POST "$O/token" -d "grant_type=refresh_token&refresh_token=" -u ":"
+curl -s -w "\nHTTP %{http_code}\n" -X POST "$O/token" -d "grant_type=refresh_token&refresh_token=$RT_OLD&client_id=$CLIENT_ID"
```
**Predict:** the replay is rejected — *and* it revokes the **whole family**, so even the *new* refresh
diff --git a/services/mini-console/README.md b/services/mini-console/README.md
index 5e08484..60a8759 100644
--- a/services/mini-console/README.md
+++ b/services/mini-console/README.md
@@ -9,9 +9,9 @@ of curling six admin APIs by hand. It has **two faces in one process**:
services already expose (each backed by a per-service client library).
2. **Exercise harness** — scripted end-to-end flows that drive the family the way a real client
would (issue an m2m token, run OIDC code+PKCE, mint/renew/revoke a cert, rotate a signing key, hit
- the gateway's forward-auth) and **verify the result offline** — a signature against the JWKS, a
- cert chain to the CA root, the gateway's allow/deny decision — reporting pass/fail/skip without
- ever logging a secret.
+ the gateway's forward-auth, or run the whole chain identity→token→gateway in one go) and **verify
+ the result offline** — a signature against the JWKS, a cert chain to the CA root, the gateway's
+ allow/deny decision — reporting pass/fail/skip without ever logging a secret.
It adds **no new authority.** Every mutation it performs is one the operator could perform by curling
an admin API with the same downstream token; the console invents no new trust boundary and stores no
@@ -71,9 +71,14 @@ cryptographic/state assertion verified **offline**, not just an HTTP 200. A flow
| **Certificate lifecycle** | local CSR → `issue` → `renew` → `revoke` | leaf chains to the CA root; revoked serial appears in the list | `--ca-url` |
| **OIDC code + PKCE** | build `/authorize` (S256) → `exchangeCode` → `userinfo` → `refresh` | id_token verifies offline; refresh rotates (old refused). *Passkey login → SKIP unless a code is supplied.* | `--oidc-url` |
| **Gateway forward-auth** | `gateway.verify` with (a) no creds, (b) a bearer, (c) insufficient scope | (a) → 302/401, (b) → 200, (c) → 403 | `--gateway-url` (the bearer branches need an access token) |
+| **Full chain (identity → token → gateway)** | `directory.resolve` → `idp.token` → `gateway.verify` with the minted token | identity resolves; the gateway authorizes the same token it was just issued (verified offline) | `--directory-url` + `--idp-url` + `--gateway-url` + a service-account id/secret |
+
+The full-chain flow is the one runnable thing that demonstrates the headline goal — **identity →
+token → gateway verifies → resource** — in a single exercise, with no manual copy-paste in the seam.
The Harness page can **run all** the no-credential flows at once and show a `X passed, Y skipped, Z
-failed` tally; the credential-needing token flows are reported SKIP (run those individually).
+failed` tally; the credential-needing token flows (m2m, signing-key rotation, full chain) are
+reported SKIP (run those individually).
## The `/api` surface
diff --git a/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/harness/flows/FullChainFlow.java b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/harness/flows/FullChainFlow.java
new file mode 100644
index 0000000..a2a1896
--- /dev/null
+++ b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/harness/flows/FullChainFlow.java
@@ -0,0 +1,165 @@
+package com.codeheadsystems.miniconsole.harness.flows;
+
+import com.codeheadsystems.miniclient.common.ClientException;
+import com.codeheadsystems.miniconsole.harness.Exercise;
+import com.codeheadsystems.miniconsole.harness.ExerciseResult;
+import com.codeheadsystems.miniconsole.harness.ExerciseResult.Status;
+import com.codeheadsystems.miniconsole.harness.ExerciseResult.Step;
+import com.codeheadsystems.minidirectory.client.MiniDirectoryClient;
+import com.codeheadsystems.minidirectory.client.model.Resolution;
+import com.codeheadsystems.minigateway.client.MiniGatewayClient;
+import com.codeheadsystems.minigateway.client.VerifyOutcome;
+import com.codeheadsystems.minigateway.client.VerifyRequest;
+import com.codeheadsystems.miniidp.client.MiniIdpClient;
+import com.codeheadsystems.miniidp.client.model.TokenResponse;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The full-chain exercise: the one runnable thing that demonstrates the headline learning goal —
+ * identity → token → gateway verifies → resource — end to end, with no manual copy-paste in the
+ * seam. Every other harness flow exercises a single hop; this one threads all three services so a
+ * learner can watch a request earn its way to a protected upstream.
+ *
+ *
Steps, in order:
+ *
+ *
Resolve the identity in mini-directory — prove the service account exists in the source
+ * of truth and see its fully-expanded grants (the thing a policy decision is made over).
+ *
Mint a client-credentials token from mini-idp — exchange the service account's
+ * credentials for a short-lived, Ed25519-signed access token.
+ *
Present that SAME token to mini-gateway's {@code /verify} — exactly as a reverse proxy
+ * would before forwarding to a no-auth upstream. The gateway verifies the JWS offline
+ * against the OP's JWKS (no call back to mini-idp) and answers {@code AUTHORIZED}. That answer
+ * is the headline assertion: the resource is protected by the chain, not by the upstream.
+ *
+ *
+ *
Honest SKIP. The first two steps need the operator to supply the service account's id and
+ * secret; without them the whole flow is reported SKIP, never a misleading PASS (mirroring the
+ * other credential-needing flows).
+ *
+ *
No secrets escape. The client secret and the minted access token are used only for the
+ * calls above and are NEVER placed in a step, the summary, or a log. Steps report only non-secret
+ * facts: the resolved subject, the grant count, the token type/expiry, and the gateway's decision.
+ */
+public final class FullChainFlow implements Exercise {
+
+ /** The stable exercise id (used in the route and the result). */
+ public static final String ID = "full-chain";
+
+ @Override
+ public String id() {
+ return ID;
+ }
+
+ @Override
+ public String title() {
+ return "Full chain: identity → token → gateway";
+ }
+
+ @Override
+ public String description() {
+ return "Resolve a service account in mini-directory, mint a client-credentials token from "
+ + "mini-idp, then present that token to mini-gateway's /verify and assert it is authorized "
+ + "to reach a protected resource.";
+ }
+
+ /**
+ * Run the flow.
+ *
+ * @param directory the mini-directory client (the identity source of truth).
+ * @param idp the mini-idp client (the machine token issuer).
+ * @param gateway the mini-gateway client (the forward-auth endpoint).
+ * @param inputs the operator-supplied inputs (a service-account id + secret, and the gated path);
+ * the secret is used for the single token call and never retained.
+ * @return the result: SKIP when no credentials were supplied, else PASS only if the gateway
+ * authorizes the minted token.
+ */
+ public ExerciseResult run(final MiniDirectoryClient directory, final MiniIdpClient idp,
+ final MiniGatewayClient gateway, final Inputs inputs) {
+ final List steps = new ArrayList<>();
+ final String clientId = blankToNull(inputs.clientId());
+ final String clientSecret = blankToNull(inputs.clientSecret());
+ final String path = blankTo(inputs.path(), "/");
+
+ if (clientId == null || clientSecret == null) {
+ steps.add(new Step("Resolve identity (mini-directory)", Status.SKIP,
+ "no service-account id + secret supplied — provide both to drive the whole chain"));
+ steps.add(new Step("Mint client-credentials token (mini-idp)", Status.SKIP,
+ "needs the service-account credentials above"));
+ steps.add(new Step("Gateway verifies the token (mini-gateway)", Status.SKIP,
+ "needs a token from the step above"));
+ return ExerciseResult.ofWithSkips(this, steps,
+ "Supply a service-account id + secret to drive identity → token → gateway end to end.");
+ }
+
+ // Step 1 — the identity exists in the source of truth and resolves to its effective grants.
+ try {
+ final Resolution resolution = directory.resolve(clientId);
+ steps.add(new Step("Resolve identity (mini-directory)", Status.PASS,
+ "resolved " + resolution.id() + " (admin=" + resolution.admin() + ", "
+ + resolution.grants().size() + " effective grant(s))"));
+ } catch (final ClientException e) {
+ // No oracle: an unknown account and an unreachable directory look the same.
+ steps.add(new Step("Resolve identity (mini-directory)", Status.FAIL,
+ "the service account did not resolve or the directory was unreachable"));
+ return ExerciseResult.of(this, steps, "The identity did not resolve in mini-directory.");
+ }
+
+ // Step 2 — mini-idp mints a client-credentials token for that identity.
+ final TokenResponse token;
+ try {
+ token = idp.token(clientId, clientSecret);
+ } catch (final ClientException e) {
+ // No oracle: a wrong secret, an unknown client, and an unreachable IDP all look the same.
+ steps.add(new Step("Mint client-credentials token (mini-idp)", Status.FAIL,
+ "the token request was refused or the IDP was unreachable"));
+ return ExerciseResult.of(this, steps, "mini-idp did not issue a token.");
+ }
+ if (token.accessToken() == null || token.accessToken().isBlank()) {
+ steps.add(new Step("Mint client-credentials token (mini-idp)", Status.FAIL,
+ "the response carried no access token"));
+ return ExerciseResult.of(this, steps, "mini-idp did not issue a token.");
+ }
+ steps.add(new Step("Mint client-credentials token (mini-idp)", Status.PASS,
+ "received a " + token.tokenType() + " token (expires in " + token.expiresIn() + "s)"));
+
+ // Step 3 — present that SAME token to the gateway, the way a reverse proxy would. The gateway
+ // verifies the JWS offline against the OP's JWKS and answers AUTHORIZED — the headline of the
+ // whole chain: the resource is protected by the chain, not by the (no-auth) upstream.
+ try {
+ final VerifyOutcome outcome =
+ gateway.verify(VerifyRequest.withBearer("GET", path, token.accessToken()));
+ steps.add(new Step("Gateway verifies the token (mini-gateway)",
+ outcome == VerifyOutcome.AUTHORIZED ? Status.PASS : Status.FAIL,
+ "verify(" + path + ") with the minted token -> " + outcome
+ + (outcome == VerifyOutcome.AUTHORIZED ? "" : " (expected AUTHORIZED)")));
+ } catch (final ClientException e) {
+ steps.add(new Step("Gateway verifies the token (mini-gateway)", Status.FAIL,
+ "the gateway was unreachable or returned an unexpected status"));
+ return ExerciseResult.of(this, steps, "The gateway did not verify the minted token.");
+ }
+
+ return ExerciseResult.of(this, steps,
+ "Identity resolved, token minted, and the gateway authorized it — the chain protects the "
+ + "resource end to end.");
+ }
+
+ private static String blankTo(final String value, final String fallback) {
+ return value == null || value.isBlank() ? fallback : value;
+ }
+
+ private static String blankToNull(final String value) {
+ return value == null || value.isBlank() ? null : value;
+ }
+
+ /**
+ * The operator-supplied inputs for a run. The id + secret drive the directory resolution and the
+ * token mint (the secret is never retained); {@code path} is the gated path probed at the gateway.
+ *
+ * @param clientId the service-account id to resolve and authenticate as (blank → the flow SKIPs).
+ * @param clientSecret the service-account secret — used for the one token call, never retained.
+ * @param path the gated path to verify the minted token against (blank → {@code /}).
+ */
+ public record Inputs(String clientId, String clientSecret, String path) {
+ }
+}
diff --git a/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/pages/HarnessPages.java b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/pages/HarnessPages.java
index bad3a9c..b2cdeff 100644
--- a/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/pages/HarnessPages.java
+++ b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/pages/HarnessPages.java
@@ -4,6 +4,7 @@
import com.codeheadsystems.miniconsole.harness.ExerciseRegistry;
import com.codeheadsystems.miniconsole.harness.ExerciseResult;
import com.codeheadsystems.miniconsole.harness.flows.CertLifecycleFlow;
+import com.codeheadsystems.miniconsole.harness.flows.FullChainFlow;
import com.codeheadsystems.miniconsole.harness.flows.GatewayVerifyFlow;
import com.codeheadsystems.miniconsole.harness.flows.KeyRotationFlow;
import com.codeheadsystems.miniconsole.harness.flows.OidcCodePkceFlow;
@@ -30,17 +31,20 @@ private HarnessPages() {
* input (it generates its own CSR). Each exercise's form is shown only when its backend is wired,
* else a note pointing at the flag that wires it. The mutating flows carry an explicit warning.
*
- * @param registry the registered exercises.
- * @param idpAvailable whether a mini-idp client is wired (gates the idp flows).
- * @param caAvailable whether a mini-ca client is wired (gates the certificate flow).
- * @param oidcAvailable whether a mini-oidc client is wired (gates the OIDC flow).
- * @param gatewayAvailable whether a mini-gateway client is wired (gates the gateway flow).
- * @param csrf the CSRF token for the run form(s) and the nav (escaped here).
+ * @param registry the registered exercises.
+ * @param idpAvailable whether a mini-idp client is wired (gates the idp flows).
+ * @param caAvailable whether a mini-ca client is wired (gates the certificate flow).
+ * @param oidcAvailable whether a mini-oidc client is wired (gates the OIDC flow).
+ * @param gatewayAvailable whether a mini-gateway client is wired (gates the gateway flow).
+ * @param fullChainAvailable whether the directory, idp, AND gateway are all wired (gates the
+ * full-chain flow, which spans all three).
+ * @param csrf the CSRF token for the run form(s) and the nav (escaped here).
* @return a complete HTML document.
*/
public static String list(final ExerciseRegistry registry, final boolean idpAvailable,
final boolean caAvailable, final boolean oidcAvailable,
- final boolean gatewayAvailable, final String csrf) {
+ final boolean gatewayAvailable, final boolean fullChainAvailable,
+ final String csrf) {
final StringBuilder body = new StringBuilder();
body.append("
Run an end-to-end flow against the wired services and verify the "
+ "result. Credentials you enter are used for the single run and never stored.
");
@@ -59,6 +63,7 @@ public static String list(final ExerciseRegistry registry, final boolean idpAvai
final boolean isCert = CertLifecycleFlow.ID.equals(exercise.id());
final boolean isOidc = OidcCodePkceFlow.ID.equals(exercise.id());
final boolean isGateway = GatewayVerifyFlow.ID.equals(exercise.id());
+ final boolean isFullChain = FullChainFlow.ID.equals(exercise.id());
if (KeyRotationFlow.ID.equals(exercise.id())) {
body.append("
This exercise rotates a real mini-idp signing key.