Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions docs/DIRECTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions docs/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/TEACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
14 changes: 8 additions & 6 deletions docs/concepts/honest-seams.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)

## <a id="2"></a>2. mini-kms data plane ships an allow-all policy

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/oauth-and-oidc-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
130 changes: 130 additions & 0 deletions docs/concepts/what-a-passkey-is.md
Original file line number Diff line number Diff line change
@@ -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).
63 changes: 63 additions & 0 deletions docs/examples/passkey-enroll.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading