From 6664b40d8eb3bdc277edf02c6cf689f0afbd5cf3 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Fri, 19 Jun 2026 08:37:35 -0700 Subject: [PATCH] docs: correct stale claims, fix navigation, add lib READMEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following a multi-agent documentation review, fix doc↔code drift across the family (no behavior changes — docs, comments, and one OpenAPI enum): - mini-directory↔issuers: correct stale "future work / stands alone" claims — mini-idp is wired and required (no local client registry); mini-oidc resolves humans optionally. Touches mini-directory openapi/ServerMain/build, DIRECTION catalog+roadmap, CLAUDE.md. - Stale class names: KeyAuthorizationPolicy/AllowAllPolicy were renamed to mini-policy's PolicyEngine/AllowAllPolicyEngine — swept repo-wide (READMEs, DIRECTION, honest-seams, GLOSSARY, openapi, lib javadoc), preserving the wired-vs-designed framing; KeyOperation left intact. - Accuracy fixes: mini-oidc OpenAPI PKCE enum [S256] (drop plain); remove the nonexistent mini-oidc signing-key rotation route from a howto; drop --secure-cookies misattributed to mini-gateway (3 spots); ClientRegistryMigration grants[] field-name javadoc; mini-ca PoP string; tutorial POST->PUT; diagram return=->rd; mini-kms config rows. - Navigation: single README front door (TEACHING); relabel TEACHING-OUTLINE as a realized historical doc; GLOSSARY -> TEACHING. - Pedagogy: name least-privilege and confused-deputy; add a CSRF reflex; GLOSSARY entries for CSRF, least-privilege, confused-deputy, cnf. - Add READMEs for libs/mini-token and libs/mini-policy (were missing). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 14 +-- README.md | 15 ++- docs/DIRECTION.md | 22 +++-- docs/GLOSSARY.md | 30 +++++- docs/TEACHING-OUTLINE.md | 15 +-- docs/TEACHING.md | 4 +- docs/concepts/authorization-model.md | 7 ++ docs/concepts/honest-seams.md | 4 +- docs/concepts/secure-design-invariants.md | 22 ++++- docs/diagrams/forward-auth.md | 2 +- docs/howto/configuration-and-secrets.md | 5 +- docs/howto/rotate-signing-keys.md | 8 +- docs/howto/run-the-whole-family.md | 12 ++- docs/howto/sso-for-your-homelab.md | 3 +- docs/howto/wire-the-services-together.md | 2 +- docs/tutorials/01-resolve-a-principal.md | 2 +- libs/mini-policy/README.md | 96 +++++++++++++++++++ libs/mini-policy/build.gradle.kts | 6 +- .../minipolicy/AllowAllPolicyEngine.java | 4 +- .../minipolicy/PolicyEngine.java | 2 +- libs/mini-token/README.md | 94 ++++++++++++++++++ .../minitoken/auth/Authorization.java | 2 +- .../minitoken/auth/KeyOperation.java | 4 +- .../minitoken/token/GrantsClaim.java | 2 +- .../minitoken/token/JwtClaims.java | 4 +- services/mini-ca/README.md | 2 +- .../security/01-csr-proof-of-possession.md | 2 +- services/mini-directory/build.gradle.kts | 5 +- .../minidirectory/ServerMain.java | 7 +- .../migration/ClientRegistryMigration.java | 12 ++- .../src/main/resources/openapi.yaml | 6 +- services/mini-idp/README.md | 4 +- .../miniidp/store/JsonStore.java | 9 +- .../server/src/main/resources/openapi.yaml | 2 +- services/mini-kms/README.md | 24 +++-- .../03-loopback-tcp-local-exposure.md | 2 +- services/mini-oidc/README.md | 40 ++++---- .../mini-oidc/src/main/resources/openapi.yaml | 2 +- 38 files changed, 380 insertions(+), 118 deletions(-) create mode 100644 libs/mini-policy/README.md create mode 100644 libs/mini-token/README.md diff --git a/CLAUDE.md b/CLAUDE.md index 7c0817a..9b65860 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ mini-auth/ │ ├── mini-idp/ {core, server} (shipping) │ ├── mini-oidc/ (shipping; human SSO / OpenID Provider, embeds pk-auth) │ ├── mini-gateway/ (shipping; forward-auth for a reverse proxy) -│ ├── mini-directory/ (shipping; identity source of truth, standalone) +│ ├── mini-directory/ (shipping; identity source of truth; mini-idp reads service accounts from it) │ ├── mini-ca/ (shipping; internal CA, CA key wrapped under mini-kms) │ └── mini-console/ (roadmap placeholder) └── libs/ # shared libraries (no transport) @@ -153,7 +153,7 @@ mini-auth/ must still pass unchanged. - **The token-claim contract aligns across the family.** mini-idp's `grants` claim maps onto mini-kms's authorization model (`sub → Principal.id`, `grants.control → Principal.admin`, - `grants.groups[] → KeyAuthorizationPolicy`); `auth/KeyOperation` string values are the contract — + `grants.groups[]` → a per-key-group `PolicyEngine` decision (mini-policy)); `auth/KeyOperation` string values are the contract — 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 @@ -208,7 +208,7 @@ services/mini-kms/client/build/install/client/bin/kms-admin --tcp 127.0.0.1:9123 - **Two planes, two tokens.** Every `RequestType` is tagged `DATA` or `CONTROL` (`RequestPlane`). `KmsRequestHandler` (in `core`) routes by plane and validates the matching token — the **API token** for data ops, a **separate admin token** for control ops. Data-plane ops also pass - through `KeyAuthorizationPolicy` per key group; the shipped `AllowAllPolicyEngine` is the documented + through a per-key-group `PolicyEngine` decision (mini-policy); the shipped `AllowAllPolicyEngine` is the documented seam for per-client authz later (the thing `mini-policy` generalizes). - **The two seams.** `KmsRequestHandler` depends only on `MasterKeyProvider` (data: `wrap/unwrap/encrypt/decrypt/keyIdOf`) and `KeyringManager` (control: @@ -280,7 +280,7 @@ services/mini-idp/server/build/install/server/bin/server --port 8455 --data-dir - **The token contract.** `server/src/main/resources/openapi.yaml` (served at `/openapi.yaml`, `/openapi.json`, `/docs`) is authoritative. The `grants` claim (mini-token's `token/GrantsClaim` over `auth/Authorization`) maps to mini-kms: `sub → Principal.id`, - `grants.control → Principal.admin`, `grants.groups[] → KeyAuthorizationPolicy`. `auth/KeyOperation` + `grants.control → Principal.admin`, `grants.groups[]` → a per-key-group `PolicyEngine` decision (mini-policy). `auth/KeyOperation` is a **deliberate mirror** of mini-kms's enum — the string values are the contract, do not rename them. A `cnf` claim is reserved (RFC 7800) but not enforced yet. - **Crypto & formats (hand-rolled bits, now in mini-token).** Ed25519 via the JDK only @@ -310,8 +310,10 @@ mini-directory is the **single identity source of truth** for the family: it own job is **resolution** — turning any stored account into a **mini-policy `Principal` plus a fully-expanded, de-duplicated set of `(action, resource)` grants** (roles expand to grants; group memberships are inherited), which is exactly what a `mini-policy` decision function consumes. It is -**shipping but standalone**: the issuers do **not** read from it yet (that is Phase 3/4 work; see the -open client-registry question in `docs/DIRECTION.md`). +**shipping and wired in**: mini-idp reads its service accounts from here at every `/oauth/token` +(required — `--directory-url`; mini-idp keeps no local client registry), and mini-oidc resolves +humans from here when `--directory-url` is set (optional, in-memory fallback otherwise). See the +resolved client-registry decision in `docs/DIRECTION.md`. **Run it locally.** The bootstrap admin token comes from an env var or a file, **never a CLI arg, and is never logged**. Loopback by default. diff --git a/README.md b/README.md index e4e376a..d3ea930 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,14 @@ mini-auth is two things: of the family: the vision, the catalog, the architecture, the recursive integrations, the open design questions, and the roadmap — the full architecture reference. -> 🎓 **Want to learn how auth works (not just read the code)? Start with -> [docs/TEACHING.md](docs/TEACHING.md)** — a course-style track (concepts → hands-on labs → -> security) that teaches authentication & authorization using the family as the worked example. +> 🎓 **New here? Start with [docs/TEACHING.md](docs/TEACHING.md).** It's a course-style track +> (concepts → hands-on labs → security) that teaches authentication & authorization using the family +> as the worked example, and it routes you to the right next step in one screen. > -> 🧭 **Prefer to read the source in order? [docs/LEARNING.md](docs/LEARNING.md)** is a guided reading -> order that walks the family in dependency order, naming the concept each stop teaches. Keep -> **[docs/GLOSSARY.md](docs/GLOSSARY.md)** open for the crypto / OAuth / OIDC vocabulary, and read -> [docs/DIRECTION.md](docs/DIRECTION.md) once you want the whole architecture map. -> *(Concepts-first → `TEACHING.md`; code-first → `LEARNING.md`.)* +> It links you onward to the other entry points when you need them: **[docs/LEARNING.md](docs/LEARNING.md)** +> if you'd rather read the *source* in dependency order (code-first instead of concepts-first), +> **[docs/GLOSSARY.md](docs/GLOSSARY.md)** for the crypto / OAuth / OIDC vocabulary, and +> **[docs/DIRECTION.md](docs/DIRECTION.md)** for the whole architecture map. > ⚠️ **Educational project.** The family uses real, sound cryptographic constructions but is **not > audited** and is not a substitute for production identity infrastructure. Six services and both diff --git a/docs/DIRECTION.md b/docs/DIRECTION.md index 4dec180..3322de7 100644 --- a/docs/DIRECTION.md +++ b/docs/DIRECTION.md @@ -62,10 +62,10 @@ Every mini, its one-line purpose, whether it is a library or a service, and its | **mini-kms** | Envelope encryption / KMS: rotatable keys, the eventual vault that wraps other services' signing keys. | service (+ core/client libs) | **shipping** | | **mini-idp** | Machine-to-machine identity: OAuth2 client-credentials → Ed25519 JWT, JWKS. | service (+ core lib) | **shipping** | | **mini-token** | The shared token plane: JWS, JWKS, signing-key lifecycle, rotation, revocation, audit, the `grants` claim contract, a persistence SPI, the offline `JwsClaimsVerifier`, AND the shared browser-SSO `SessionService` (so mini-oidc and mini-gateway share one session store). | library | **shipping** | -| **mini-policy** | Generalized authorization decision function: `(principal, action, resource) → allow/deny`. Generalizes mini-kms's `KeyAuthorizationPolicy`. | library | **shipping** (minimal engine; consumed by directory/oidc/gateway/kms — family-wide grant sourcing is future) | +| **mini-policy** | Generalized authorization decision function: `(principal, action, resource) → allow/deny`. Generalizes mini-kms's per-key-group key-authorization policy into one engine. | library | **shipping** (minimal engine; consumed by directory/oidc/gateway/kms — family-wide grant sourcing is future) | | **mini-oidc** | Human SSO / OpenID Provider: authorization-code + PKCE, ID + access + refresh tokens, /userinfo, browser SSO sessions, single logout, login/consent UI. Embeds **pk-auth** (passkeys + backup-code recovery), mints via **mini-token**, authorizes scopes via **mini-policy**, resolves users from **mini-directory**. | service | **shipping** | | **mini-gateway** | Forward-auth endpoint for a reverse proxy (Traefik / Caddy / nginx `auth_request`) to gate apps with no native auth: validates the shared mini-oidc SSO session or a bearer token, decides per-route via mini-policy, returns allow / 401 / 403 / redirect-to-login. | service | **shipping** | -| **mini-directory** | The single identity source of truth: humans, groups, roles, service accounts, and their grant mappings; resolves any account to a mini-policy `Principal` + expanded grants. | service | **shipping** (standalone; issuers not yet wired to read from it) | +| **mini-directory** | The single identity source of truth: humans, groups, roles, service accounts, and their grant mappings; resolves any account to a mini-policy `Principal` + expanded grants. | service | **shipping** (mini-idp reads service accounts from it — required, no local registry; mini-oidc resolves humans from it when `--directory-url` is set) | | **pk-auth** | Passkeys-first auth library set, published on Maven Central under `com.codeheadsystems`. Consumed as a normal dependency — **not vendored**. | external library | **shipping (external)** | | **mini-ca** | Small internal certificate authority for mTLS between the minis and workload identity in the homelab. Issues/renews short-lived leaves from CSRs; its CA key is wrapped under mini-kms. | service (application) | **shipping** | | **mini-console** | Optional unified admin UI over the family. | service (future) | **roadmap** (placeholder module) | @@ -150,8 +150,8 @@ the load-bearing relationships: mini-idp wrote first, so the two issuers stop diverging. - **All services evaluate through mini-policy.** mini-gateway gating a route, mini-kms gating a key group, the issuers checking a scope — each becomes a `PolicyRequest(principal, resource, - action)` against one engine. mini-policy is the generalization of mini-kms's - `KeyAuthorizationPolicy`. + action)` against one engine. mini-policy generalizes mini-kms's former per-key-group + key-authorization policy into the shared `PolicyEngine`. - **mini-token's signing keys are wrapped by mini-kms (the recursive integration — *done*).** The default educational path still stores the Ed25519 private key locally (`0600`), but with the `--kms-*` config the auth services wrap each signing key under a mini-kms key group: only the @@ -166,7 +166,7 @@ the load-bearing relationships: The claim payload already lines up across the family. A mini-idp token's `grants` claim maps directly onto mini-kms's authorization model (`sub → Principal.id`, `grants.control → -Principal.admin`, `grants.groups[] → KeyAuthorizationPolicy`). mini-token preserves that mapping; +Principal.admin`, `grants.groups[]` → a per-key-group `PolicyEngine` decision (mini-policy)). mini-token preserves that mapping; 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 @@ -343,7 +343,7 @@ extraction only promoted `Base64Url`/`RandomIds`, which it directly needed; the clear second consumer to pin them against. **Phase 2 — Generalize authorization.** Flesh out **mini-policy** into a real engine and adopt it -behind mini-kms's `KeyAuthorizationPolicy` seam first (it already has the right shape), then behind +behind mini-kms's per-key-group `PolicyEngine` seam first (it already has the right shape), then behind the issuers' scope checks. **Phase 3 — Stand up the directory.** *Standalone service: done.* **mini-directory** now has a real @@ -351,10 +351,12 @@ identity model (humans, groups, roles, service accounts), an admin CRUD API behi bootstrap admin token, atomic-`0600` persistence, an OpenAPI spec + vendored Swagger UI, and the defining capability: resolving any account into a mini-policy `Principal` plus its fully-expanded, de-duplicated grants (roles expand to grants; group memberships are inherited). Service-account -secrets are Argon2id-hashed at rest. What remains is **wiring the issuers to read from it** — mini-idp -resolving service accounts, mini-oidc resolving humans — and the open question below about folding -mini-idp's client registry in. That integration is deliberately not done yet; the service stands -alone today. +secrets are Argon2id-hashed at rest. **The issuers now read from it.** mini-idp resolves service +accounts here at every `/oauth/token` (via `HttpServiceAccountDirectory` → the directory's +`/admin/service-accounts/authenticate`) and `--directory-url` is **required** — mini-idp no longer +keeps a local client registry, which resolved the open question about folding it in. mini-oidc +resolves humans here too, via its `UserDirectory` SPI, when `--directory-url` is set. What remains is +the token → mini-kms *authorization* seam (below) — not the directory wiring, which is done. **Phase 4 — Human SSO.** *Done.* **mini-oidc** ships: the authorization-code + PKCE flow, ID + access tokens minted via mini-token (offline-verifiable against the shared JWKS) + rotating refresh diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index c59f81c..d2f2c37 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -9,8 +9,9 @@ The vocabulary deliberately follows real systems (AWS/GCP KMS, NIST, the relevan RFCs) so the concepts carry over. Each entry says what the term is, **why it matters here**, and where it lives in the code. -> New to the project? Read this alongside [`DIRECTION.md`](DIRECTION.md), then follow -> the reading order in [`LEARNING.md`](LEARNING.md). +> New to the project? Start with the course front door [`TEACHING.md`](TEACHING.md), and keep this +> open alongside it. For the architecture map see [`DIRECTION.md`](DIRECTION.md); to read the source +> in dependency order see [`LEARNING.md`](LEARNING.md). --- @@ -107,6 +108,15 @@ The token plane lives in **mini-token** (`libs/mini-token`); the issuers are **m - **Revocation / denylist** — a list of token ids (`jti`) a verifier can consult to reject a specific token before its natural expiry (`mini-token` `RevocationService`). Short TTLs are the primary control; revocation is the early kill switch. +- **CSRF (cross-site request forgery)** — an attack where a malicious site causes a logged-in + user's browser to make an unwanted state-changing request using the user's ambient cookies. The + family defends it two ways: a **`SameSite=Lax`** session cookie (the browser won't send it on + cross-site POSTs), and a **per-pending-authorization CSRF token** required on every state-changing + browser POST in mini-oidc (`OidcHandlers`, constant-time checked). +- **`cnf` (confirmation / proof-of-possession claim, RFC 7800)** — a token claim that would bind + the token to a key the holder must prove possession of, so a *stolen* token is useless without + that key (turning a bearer token into a sender-constrained one). **Reserved but not enforced** in + the family today (`TokenIssuer` writes it null) — an honest seam, not a live control. --- @@ -136,8 +146,14 @@ The decision model is **mini-policy** (`libs/mini-policy`); the identity source by mini-idp); humans carry no secret and authenticate with passkeys (via mini-oidc). - **`grants` claim** — mini-token's per-key-group authorization claim (`token/GrantsClaim` over `auth/Authorization`) that maps onto mini-kms's model: `sub → Principal.id`, - `grants.control → Principal.admin`, `grants.groups[] → KeyAuthorizationPolicy`. The - string values of `auth/KeyOperation` are a deliberate contract — do not rename them. + `grants.control → Principal.admin`, `grants.groups[] →` a per-key-group `PolicyEngine` + decision (mini-policy). The string values of `auth/KeyOperation` are a deliberate contract — + do not rename them. +- **Least privilege** — grant each principal the *minimum* authority it needs, and no more + (narrow grants, scoped tokens, short TTLs, no blanket wildcards). It's the design discipline + behind **deny-by-default** (the mechanism that enforces it) and behind the two-plane / two-token + splits across the family. The opposite — broad standing authority — is what turns one compromise + into total compromise. --- @@ -180,6 +196,12 @@ From **mini-ca** (`services/mini-ca`), an internal CA for mTLS. endpoint before forwarding each request, to gate upstreams that have no auth of their own. **mini-gateway**'s `/verify` is that endpoint; it reuses the shared session + JWS verification and answers allow/deny. +- **Confused deputy** — a trusted component tricked into misusing its authority on behalf of a + less-privileged caller. Forward-auth is a classic setting: a client could try to *forge* the + `X-Auth-*` identity headers, or reach `/verify` directly with a spoofed `X-Forwarded-Uri`, to make + the proxy/gateway act on attacker-chosen input. mini-gateway defends it by **always overwriting** + the `X-Auth-*` headers (so injected ones are discarded) and by being reachable **only by the + trusted proxy**, never by clients directly. - **Data plane vs. control plane** — the split between per-request operations (guarded by an API token) and management operations (guarded by a separate admin token); mini-kms tags every request type with its plane. diff --git a/docs/TEACHING-OUTLINE.md b/docs/TEACHING-OUTLINE.md index b720ef4..d785351 100644 --- a/docs/TEACHING-OUTLINE.md +++ b/docs/TEACHING-OUTLINE.md @@ -1,11 +1,12 @@ -# Teaching mini-auth — documentation outline +# Teaching mini-auth — documentation outline (historical design doc) -> **What this file is.** A blueprint for a new, course-style documentation set under `docs/` -> that teaches **how authentication and authorization work in practice**, using the `mini-*` -> services as the worked example. It is the *plan* — each entry below becomes a real document. -> It does not replace the existing orientation docs; it sits on top of them. +> **What this file is.** The original *blueprint* for the course-style documentation set under +> `docs/` that teaches **how authentication and authorization work in practice**, using the `mini-*` +> services as the worked example. It is kept as a design/history record of how the set was planned. > -> **Status:** outline / proposal. Nothing here is written yet except this file. +> **Status:** ✅ realized. The set this file planned now exists — see the concepts, tutorials, howtos, +> diagrams, and security track under `docs/`. **If you're here to learn, don't start here:** the live +> syllabus is [`TEACHING.md`](TEACHING.md). This outline is for contributors curious about the design. --- @@ -114,7 +115,7 @@ them. 1. **token → mini-kms authorization is DESIGNED, not WIRED.** `KmsRequestHandler` uses a shared per-plane bearer token + two fixed principals; it does not parse a JWT or read `grants`. `GrantsClaim.toAuthorization()` has **no production caller.** -2. **mini-kms data plane ships `AllowAllPolicy`** — any authenticated caller is permitted. +2. **mini-kms data plane ships `AllowAllPolicyEngine`** — any authenticated caller is permitted. 3. **mini-oidc passkey enrolment (`/register/passkey/**`) is unauthenticated self-enrolment** — a real deployment must gate it. 4. **mini-oidc `--directory-url` is optional**; without it, an empty in-memory directory resolves diff --git a/docs/TEACHING.md b/docs/TEACHING.md index cb014de..757bca5 100644 --- a/docs/TEACHING.md +++ b/docs/TEACHING.md @@ -3,9 +3,7 @@ > **What this is.** The front door to a course-style documentation set that teaches **how > authentication and authorization actually work**, using the `mini-*` services as the worked > example. It does not replace the orientation docs — it sits on top of them. One screen, then it -> routes you. -> -> The blueprint for the whole set is [`TEACHING-OUTLINE.md`](TEACHING-OUTLINE.md). +> routes you. **This is the live syllabus; start here.** ## How this set relates to the other docs diff --git a/docs/concepts/authorization-model.md b/docs/concepts/authorization-model.md index f030ccc..9af5de7 100644 --- a/docs/concepts/authorization-model.md +++ b/docs/concepts/authorization-model.md @@ -53,6 +53,13 @@ an action name doesn't accidentally widen access — it silently fails closed. T most important security property of the model, and it's enforced by the *structure* (the loop returns DENY when nothing matches), not by a check someone has to remember to write. +Deny-by-default is the *mechanism*; the *principle* behind it is **[least privilege](../GLOSSARY.md#identity--authorization)** — give each principal the minimum authority it needs and no more. That's why grants +are narrow `(action, resource)` pairs rather than broad roles, and why you should be sparing with the +`*` wildcards and with the admin bypass below: each one is standing authority that turns a single +compromise into a wide one. Admin-bypass is a deliberate, blunt capability — fine for the family's +small surface, but in a larger system a global "admin can do anything" bit is itself a least-privilege +violation and a fat target, usually replaced by scoped admin grants. + ### The two trivial engines, and why both exist - `AllowAllPolicyEngine` — permits everything. mini-kms ships this today (a single shared data-plane diff --git a/docs/concepts/honest-seams.md b/docs/concepts/honest-seams.md index 23bcca8..d6bf118 100644 --- a/docs/concepts/honest-seams.md +++ b/docs/concepts/honest-seams.md @@ -25,7 +25,7 @@ keep a teaching codebase from quietly lying. The family documents a beautiful contract: a token's `grants` claim maps onto mini-kms's authorization model (`sub → Principal.id`, `grants.control → Principal.admin`, -`grants.groups[] → KeyAuthorizationPolicy`). **That mapping is not a runtime path.** +`grants.groups[]` → a per-key-group `PolicyEngine` decision (mini-policy)). **That mapping is not a runtime path.** - `KmsRequestHandler` (`services/mini-kms/core/.../kms/KmsRequestHandler.java`) authenticates with a **shared per-plane bearer token** and two fixed principals. It does **not** parse a JWT and does @@ -40,7 +40,7 @@ labels itself a design exercise.) ## 2. mini-kms data plane ships an allow-all policy -Every data-plane key operation passes through a `KeyAuthorizationPolicy` per key group — but the +Every data-plane key operation passes through a per-key-group `PolicyEngine` decision — but the shipped engine is **`AllowAllPolicyEngine`** (in `libs/mini-policy`): any *authenticated* caller is permitted on any key group. (`LocalKeyring.java` notes the day "a real per-group PolicyEngine replaces AllowAllPolicyEngine"; that swap is the future per-client decision.) diff --git a/docs/concepts/secure-design-invariants.md b/docs/concepts/secure-design-invariants.md index 55434c0..7011f50 100644 --- a/docs/concepts/secure-design-invariants.md +++ b/docs/concepts/secure-design-invariants.md @@ -52,8 +52,13 @@ not by remembering to add a check: - mini-gateway denies any path no route rule matches, and **normalizes the path before matching** so `..` can't smuggle a request past a rule (lab 05). +Deny-by-default is how the family enforces **least privilege** — every principal gets the minimum +authority it needs and no more (narrow grants, scoped tokens, short TTLs, no blanket wildcards). The +two-plane / two-token splits (mini-kms's data vs. control token) are the same principle: a leaked +data credential can't do control operations. + > **The reflex:** the *absence* of a rule must mean "no," never "yes." Make the default path the -> refusing path. +> refusing path, and grant the *least* that works — never standing authority "just in case." ## 4. Secrets via env/file, never argv — and never logged @@ -102,6 +107,17 @@ crypto-shredding (mini-kms `DestroyVersion`) is the irreversible nuclear option. > **The reflex:** make credentials expire on their own; treat revocation as the exception, not the > plan. +## 8. Anti-forgery on state-changing browser requests + +A browser carries the user's session cookie automatically, so any cross-site page can try to make the +user's browser POST to a sensitive endpoint — a **[CSRF](../GLOSSARY.md#tokens-jose-oauth-20-openid-connect)** +attack. mini-oidc defends every state-changing browser POST (login finish, consent decision, recovery) +two ways: a **`SameSite=Lax`** session cookie (not sent on cross-site POSTs) **and** a +per-pending-authorization **CSRF token** that must accompany the request, constant-time checked. + +> **The reflex:** if a *browser* can trigger a state change while carrying ambient credentials, it +> needs an unguessable token the attacker's site can't supply. + --- ## These compose @@ -109,7 +125,9 @@ crypto-shredding (mini-kms `DestroyVersion`) is the irreversible nuclear option. The findings in the [security track](../security/README.md) are usually *one* of these reflexes being applied (or, in the "naive version," missing). The [PKCE-downgrade](../security/README.md) finding is #3 (don't accept the weak option — fail closed); [forward-auth header trust](../security/README.md) is -#3 + a trust boundary; [keystore integrity](../security/README.md) is #5. Read a few findings with +#3 + a trust boundary — the classic **[confused-deputy](../GLOSSARY.md#architecture--patterns)** risk, +where a client forges `X-Auth-*` / `X-Forwarded-*` to make the gateway misuse its authority; +[keystore integrity](../security/README.md) is #5. Read a few findings with this list beside you and the pattern-matching becomes automatic — which is the whole point. ## Now read it diff --git a/docs/diagrams/forward-auth.md b/docs/diagrams/forward-auth.md index ee3eafb..1d11e68 100644 --- a/docs/diagrams/forward-auth.md +++ b/docs/diagrams/forward-auth.md @@ -34,7 +34,7 @@ sequenceDiagram else SCOPE route + scope satisfied (via mini-policy) GW-->>PX: 200 allow (X-Auth-* set) else unauthenticated + browser - GW-->>PX: 302 → loginUrl?return= + GW-->>PX: 302 → loginUrl?rd= (return param configurable via --return-param) else unauthenticated + API GW-->>PX: 401 + WWW-Authenticate: Bearer else forbidden (no rule / scope denied) diff --git a/docs/howto/configuration-and-secrets.md b/docs/howto/configuration-and-secrets.md index 05256b3..3df5095 100644 --- a/docs/howto/configuration-and-secrets.md +++ b/docs/howto/configuration-and-secrets.md @@ -63,8 +63,9 @@ Everything binds `127.0.0.1` unless you set `--host`. Defaults: Exposing past loopback is an **explicit decision** with a checklist: - **Put it behind a TLS reverse proxy.** Don't expose the HTTP listeners directly. -- **mini-oidc / mini-gateway:** add `--secure-cookies` so the session cookie carries `Secure`. The - cookie is already `HttpOnly` + `SameSite=Lax`. +- **mini-oidc:** add `--secure-cookies` so the session cookie carries `Secure`. The + cookie is already `HttpOnly` + `SameSite=Lax`. (mini-oidc is the only session-cookie writer; + mini-gateway sets no cookies of its own and has no such flag.) - **mini-gateway** is reached by its proxy over the loopback/Docker network and is **never** exposed to clients directly; `/verify` must be reachable only by the trusted proxy. - **mini-kms** loopback TCP is reachable by every local user (a known, documented open item) — keep it diff --git a/docs/howto/rotate-signing-keys.md b/docs/howto/rotate-signing-keys.md index 49b4c77..bd9b430 100644 --- a/docs/howto/rotate-signing-keys.md +++ b/docs/howto/rotate-signing-keys.md @@ -33,9 +33,11 @@ of the JWKS (then it's dropped). No verifier changes are needed. ## mini-oidc -Same model, same token plane (mini-token's `SigningKeyService`). Rotation is exposed through its admin -surface — see `services/mini-oidc/README.md` and `/docs` for the exact route. The JWKS at -`/jwks.json` carries overlapping `kid`s during the retention window, identically to mini-idp. +Same model, same token plane (mini-token's `SigningKeyService`). **mini-oidc does not currently expose +a signing-key rotation endpoint** — only mini-idp has `POST /admin/keys/rotate` (mini-oidc's admin +surface is just `POST`/`GET /admin/clients`, and it only rotates *refresh tokens* internally). +Rotating mini-oidc's signing keys is an honest seam today. The JWKS at `/jwks.json` still carries +overlapping `kid`s during the retention window, identically to mini-idp. ## Verify nothing broke diff --git a/docs/howto/run-the-whole-family.md b/docs/howto/run-the-whole-family.md index 3c9315d..9a27007 100644 --- a/docs/howto/run-the-whole-family.md +++ b/docs/howto/run-the-whole-family.md @@ -49,9 +49,12 @@ Secrets come from **env or file, never argv**. The cross-service calls each need | `MINIKMS_PASSPHRASE` | mini-kms | (the keystore passphrase; no-TTY fallback) | > **The two most common mistakes**, both of which fail fast at startup with a clear message: -> - Setting `--directory-url` on an issuer **without** its `*_DIRECTORY_TOKEN` → -> *"a directory URL was set but no admin token."* Each issuer authenticates to the directory's admin -> API, so it needs the directory's admin token. +> - Setting `--directory-url` **without** the matching `*_DIRECTORY_TOKEN`. Each issuer authenticates +> to the directory's admin API, so it needs the directory's admin token. mini-oidc fails with +> *"a directory URL was set but no admin token"* (and treats `--directory-url` as **optional** — +> without it, it falls back to an empty in-memory directory). mini-idp instead **requires** +> `--directory-url` and reports *"no mini-directory token: set MINIIDP_DIRECTORY_TOKEN or provide +> --directory-token-file."* > - Forgetting that `MINIIDP_DIRECTORY_TOKEN` / `MINIOIDC_DIRECTORY_TOKEN` must be the **directory's** > token, not the issuer's own. @@ -113,7 +116,8 @@ See [lab 06](../tutorials/06-protect-the-signing-keys.md) for the wrap-on-save w ## Notes - **Loopback only.** Everything binds `127.0.0.1` by default. Exposing beyond loopback is an explicit - decision — and mini-oidc/mini-gateway must then sit behind a TLS proxy with `--secure-cookies`. See + decision — and the browser-facing services must then sit behind a TLS proxy, with mini-oidc started + using `--secure-cookies` (the flag is mini-oidc's; mini-gateway sets no cookies of its own). See [`configuration-and-secrets.md`](configuration-and-secrets.md). - **Who calls whom / failure modes** are catalogued in [`wire-the-services-together.md`](wire-the-services-together.md) (e.g. directory unreachable → every diff --git a/docs/howto/sso-for-your-homelab.md b/docs/howto/sso-for-your-homelab.md index a01239c..aa602e6 100644 --- a/docs/howto/sso-for-your-homelab.md +++ b/docs/howto/sso-for-your-homelab.md @@ -56,7 +56,8 @@ and watch `whoami` become reachable only after login. **Prove it locally before Exposing to your LAN/internet is a deliberate step. Do **all** of: - [ ] **Terminate TLS at the proxy.** Never expose the services' HTTP listeners directly. -- [ ] **`--secure-cookies`** on mini-oidc (and the gateway) so the session cookie is `Secure`. +- [ ] **`--secure-cookies`** on mini-oidc so the session cookie is `Secure` (mini-oidc is the only + session-cookie writer; the gateway has no such flag). - [ ] **Shared hostname.** The session cookie is host-only, `Path=/` — mini-oidc and the gated app must share a hostname for the cookie to reach both. Use subpaths or a shared parent domain. - [ ] **`/verify` is proxy-only.** mini-gateway must be reachable *only* by the proxy (loopback/Docker diff --git a/docs/howto/wire-the-services-together.md b/docs/howto/wire-the-services-together.md index 9f18670..568f97c 100644 --- a/docs/howto/wire-the-services-together.md +++ b/docs/howto/wire-the-services-together.md @@ -30,7 +30,7 @@ | Break | Symptom | Why | | --- | --- | --- | | mini-directory **down/unreachable** | every `/oauth/token` → `401 invalid_client` (generic); JWKS still 200 | no-oracle: the issuer won't say "directory down." Verified in [lab 03](../tutorials/03-machine-identity-end-to-end.md). | -| `--directory-url` set, **token missing** | issuer **refuses to start**: *"a directory URL was set but no admin token"* | each issuer authenticates to the directory's admin API. | +| `--directory-url` set, **token missing** | issuer **refuses to start** — mini-oidc: *"a directory URL was set but no admin token"*; mini-idp: *"no mini-directory token: set MINIIDP_DIRECTORY_TOKEN or provide --directory-token-file"* (mini-idp also *requires* `--directory-url`; mini-oidc treats it as optional) | each issuer authenticates to the directory's admin API. | | Wrong `*_DIRECTORY_TOKEN` (not the directory's) | `/admin/...` calls rejected → token requests fail generic | the token must be **mini-directory's** admin token. | | mini-kms **down** at issuer startup (`--kms-*` set) | issuer fails to start / can't unwrap signing keys | the wrapped key is unwrapped at boot; no KMS, no key. | | `--kms-key-group` **doesn't exist** | unwrap/encrypt fails | create it first: `kms-admin create-key --key `. | diff --git a/docs/tutorials/01-resolve-a-principal.md b/docs/tutorials/01-resolve-a-principal.md index 99bf747..02bff49 100644 --- a/docs/tutorials/01-resolve-a-principal.md +++ b/docs/tutorials/01-resolve-a-principal.md @@ -133,7 +133,7 @@ curl -s -o /dev/null -w "%{http_code}\n" "$B/admin/principals/nobody/resolution" - Add `grafana-editor`'s grant to `alice` *directly* as well, re-resolve, and confirm dedup keeps `edit:grafana` once. -- Give Alice `"admin": true` (via `POST /admin/principals/alice/assignment`) and re-read the concept +- Give Alice `"admin": true` (via `PUT /admin/principals/alice/assignment`) and re-read the concept doc's "admin bypass" rule — what would a `GrantBasedPolicyEngine` now decide for *any* action? - Delete the `grafana-viewer` role, then re-resolve Alice. The dangling reference is **skipped, not fatal** — `view:grafana` simply disappears. (See `resolve(...)` in `DirectoryService`.) diff --git a/libs/mini-policy/README.md b/libs/mini-policy/README.md new file mode 100644 index 0000000..0537d1c --- /dev/null +++ b/libs/mini-policy/README.md @@ -0,0 +1,96 @@ +# mini-policy + +The family's **authorization decision function** — one tiny, dependency-free library that answers a +single question for every service: *may **this** principal perform **this** action on **this** +resource?* It is the shared replacement for the per-service authorization checks the family used to +hand-roll (mini-kms's former per-key-group key policy; the per-group grant checks a verifier ran +over a mini-idp token). + +> ⚠️ **Educational, un-audited.** Real authorization logic, deliberately small and heavily +> read-first. Part of the [mini-auth](../../README.md) family — see [`docs/DIRECTION.md`](../../docs/DIRECTION.md) +> for the whole map and [`docs/GLOSSARY.md`](../../docs/GLOSSARY.md#identity--authorization) for the +> vocabulary. **Library only — no transport, no HTTP, no CLI.** + +## The whole idea + +Authorization here is **one pure function**, reused everywhere: + +``` +decide(principal, action, resource) → ALLOW | DENY +``` + +No request object, no database call, no I/O. Given *who* (`Principal`), *what verb* (`Action`), and +*what thing* (`Resource`), it returns one bit. Scopes, key-group rules, and forward-auth routes are +all that same function with different strings plugged into `action` and `resource`. That is why one +engine can serve mini-directory, mini-oidc, mini-gateway, and mini-kms. + +## The model (five value types + a seam) + +Base package `com.codeheadsystems.minipolicy`. Everything is a small record or enum: + +| Type | Is | Notes | +| --- | --- | --- | +| `Principal` | the authenticated caller | `(String id, boolean admin)`. `id` is a token's `sub`; `admin` is the control capability. | +| `Action` | the verb | `(String value)`; `Action.ANY` is the `*` wildcard. | +| `Resource` | the thing | `(String value)`; `Resource.ANY` is the `*` wildcard. | +| `Grant` | one permission | `(Action, Resource)` with `permits(action, resource)` honoring wildcards. | +| `Decision` | the answer | `ALLOW` or `DENY` — binary on purpose. | +| `PolicyEngine` | the seam | `Decision decide(Principal, Action, Resource)`. | + +`Action` and `Resource` are opaque strings *on purpose*: today an action might be a key operation +(`ENCRYPT`), tomorrow an OIDC scope verb or an HTTP method — **with no change to the engine.** + +## The engines + +- **`GrantBasedPolicyEngine`** — the real one. Three rules, in order: + 1. **Admin bypass** — `if (principal.admin()) return ALLOW;` + 2. **Match a grant** — ALLOW iff some grant the principal holds `permits(action, resource)`. + 3. **Deny by default** — fall off the end → `DENY`. *Anything not explicitly granted is refused,* + enforced by the loop's structure, not by a check someone has to remember to write. +- **`DenyAllPolicyEngine`** — refuses everything; the safe default for an unconfigured generic service. +- **`AllowAllPolicyEngine`** — permits everything; safe *only* where another layer already gates the + door. mini-kms ships this on its data plane today (a single shared per-plane token ⇒ one + principal; key groups still isolate). + +The `AllowAll` / `DenyAll` contrast is itself the lesson: **opposite defaults for opposite +situations.** Picking the wrong one is how authorization bugs are born. + +## Who consumes it + +| Consumer | Uses it for | +| --- | --- | +| [mini-directory](../../services/mini-directory) | resolves an account into a `Principal` + a de-duplicated set of `Grant`s (the input a decision needs). | +| [mini-oidc](../../services/mini-oidc) | authorizes requested **scopes** via `GrantBasedPolicyEngine` (`ScopeAuthorizer`). | +| [mini-gateway](../../services/mini-gateway) | decides per-route (`SCOPE` rules) and denies by default. | +| [mini-kms](../../services/mini-kms) | the data-plane authorization seam (ships `AllowAllPolicyEngine`). | + +## Wired vs. designed + +This library is deliberately **minimal: it evaluates the grants it is handed.** *Sourcing* those +grants family-wide is integration work that lives in the consuming services, not here: + +- mini-directory → issuer grant resolution is **wired** (mini-idp resolves service accounts; mini-oidc + resolves humans). +- The **token → mini-kms** authorization path (a JWT's `grants` claim feeding a per-key-group + `GrantBasedPolicyEngine`) is **designed but not yet wired** — mini-kms still authenticates with a + shared per-plane token and ships `AllowAllPolicyEngine`. See the "Wired vs. designed" note in + [`docs/DIRECTION.md`](../../docs/DIRECTION.md#runtime-relationships) and + [`docs/concepts/honest-seams.md`](../../docs/concepts/honest-seams.md). + +## Reading order + +`Principal`, `Action`, `Resource`, `Grant` (note `Grant#permits`), `Decision`, then the +`PolicyEngine` interface, then `GrantBasedPolicyEngine#decide` (the three rules) and the trivial +`AllowAllPolicyEngine` / `DenyAllPolicyEngine`. The concept doc +[`authorization-model.md`](../../docs/concepts/authorization-model.md) builds the idea from zero; +the lab [`01-resolve-a-principal.md`](../../docs/tutorials/01-resolve-a-principal.md) makes you +predict a decision by hand. + +## Build & test + +Part of the aggregator build — run from the repo root: + +```bash +./gradlew :libs:mini-policy:test # this library's tests +./gradlew build # the whole family (the CI gate) +``` diff --git a/libs/mini-policy/build.gradle.kts b/libs/mini-policy/build.gradle.kts index f9e488d..bf428a3 100644 --- a/libs/mini-policy/build.gradle.kts +++ b/libs/mini-policy/build.gradle.kts @@ -2,9 +2,9 @@ * mini-policy - the generalized authorization decision function. * * A small, dependency-light library that answers one question for every service in the family: - * may THIS principal perform THIS action on THIS resource? It is the generalization of - * mini-kms's `KeyAuthorizationPolicy` (which answers it only for `KeyOperation` against a key - * group) and of the per-group grant checks a verifier runs over a mini-idp token. + * may THIS principal perform THIS action on THIS resource? It generalizes mini-kms's former + * per-key-group key-authorization policy (which answered it only for `KeyOperation` against a key + * group) and the per-group grant checks a verifier runs over a mini-idp token. * * Library only — no transport, no HTTP, no CLI. It ships the decision types (Principal, Action, * Resource, Grant, Decision), the PolicyEngine seam, and three engines: a SAFE deny-by-default, diff --git a/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/AllowAllPolicyEngine.java b/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/AllowAllPolicyEngine.java index 105b95e..14e36ed 100644 --- a/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/AllowAllPolicyEngine.java +++ b/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/AllowAllPolicyEngine.java @@ -1,8 +1,8 @@ package com.codeheadsystems.minipolicy; /** - * Permits everything. This is mini-kms's current default ({@code auth/AllowAllPolicy}, lifted here - * verbatim in behavior): with a single shared data-plane token there is only one principal, so any + * Permits everything. This IS mini-kms's current data-plane default (the former per-service + * allow-all policy now lives here as this engine): with a single shared data-plane token there is only one principal, so any * authenticated caller may use any key group — groups still provide isolation and independent * rotation. Swap in {@link GrantBasedPolicyEngine} once per-client identities exist. * diff --git a/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/PolicyEngine.java b/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/PolicyEngine.java index cabe493..ebd38d5 100644 --- a/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/PolicyEngine.java +++ b/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/PolicyEngine.java @@ -4,7 +4,7 @@ * The authorization decision function shared across the family: * {@code decide(principal, action, resource) -> ALLOW | DENY}. * - *

This is the generalization of mini-kms's {@code KeyAuthorizationPolicy}, whose single-purpose + *

This generalizes mini-kms's former per-key-group key-authorization policy, whose single-purpose * {@code isAllowed(Principal, keyGroupId, KeyOperation)} is exactly this question with the * key-group as the {@link Resource} and the operation name as the {@link Action}. One engine now * answers it for every service: mini-kms gating a key group, mini-gateway gating a route, diff --git a/libs/mini-token/README.md b/libs/mini-token/README.md new file mode 100644 index 0000000..8bf1dec --- /dev/null +++ b/libs/mini-token/README.md @@ -0,0 +1,94 @@ +# mini-token + +The family's shared **token plane**: the Ed25519-signed JWS/JWT machinery, the JWKS model, the +signing-key lifecycle (rotation), the revocation denylist, the audit log, the published `grants` +claim contract, a small persistence SPI, **and** the shared browser-SSO session store. It was +**extracted from mini-idp** so both issuers — [mini-idp](../../services/mini-idp) (machine +client-credentials) and [mini-oidc](../../services/mini-oidc) (human SSO) — share one implementation +instead of drifting into two. + +> ⚠️ **Educational, un-audited.** Real crypto (JDK Ed25519 only — no third-party crypto dependency), +> with a **hand-rolled** compact JWS that exists to be *read*. Part of the [mini-auth](../../README.md) +> family; see [`docs/DIRECTION.md`](../../docs/DIRECTION.md) for the map and +> [`docs/GLOSSARY.md`](../../docs/GLOSSARY.md#tokens-jose-oauth-20-openid-connect) for the JOSE/OAuth +> vocabulary. **Library only — no transport, no HTTP, no CLI** (mirrors the siblings' I/O-free `core`). + +## What's inside + +Base package `com.codeheadsystems.minitoken`. + +- **`token/`** — the hand-rolled JOSE layer. `Base64Url`, `JwsHeader` (alg/typ/kid), `JwtClaims` + (the standard claim set), and `Jws` — the keystone. **`Jws.sign` signs the + `base64url(header).base64url(payload)` *text*, never re-serialized JSON**, so verification + recomputes the exact bytes. There are two overloads: a typed `sign(JwsHeader, JwtClaims, + PrivateKey)` (mini-idp's path) and an additive generic `sign(JwsHeader, Map, + PrivateKey)` (so mini-oidc's ID/access claim sets sign with the same format + keys). `GrantsClaim` + is the stable `grants` authorization contract; `JwsClaimsVerifier` is the offline reference + verifier (pin `alg` + select key by `kid` → check signature → validate `iss`/`aud`/time; any + failure returns empty — **no oracle**). +- **`crypto/Ed25519Keys`** — Ed25519 keygen / encode / decode via the JDK only. +- **`jwks/`** — `Jwk` + `JwkSet`: the published set of **public** keys (`OKP`/`Ed25519`) so any + verifier checks signatures **offline**, with no callback to the issuer. +- **`service/`** — the lifecycle services over the persistence SPI: + - `SigningKeyService` — one active key + rotation (`rotate()`, `activeKid()`); retired keys stay + published in the JWKS until they outlive the token TTL, so in-flight tokens still verify. + - `TokenIssuer` — `issue(subject, Authorization) → IssuedToken`; signs through `Jws` with the + active key. + - `TokenVerifier` — `verify(token, jwkSet, isRevoked) → Result`: pin `alg` + select the key by + `kid`, verify the signature, then `iss`/`aud`/time, then revocation. + - `RevocationService` — a `jti` denylist (the early kill switch; short TTLs are the primary control). + - `AuditService` — an append-only issuance/rotation audit log. +- **`session/`** — `SessionService` (+ `BrowserSession`, `Sessions`): the **shared browser-SSO + session**, over the same `DocumentStore` SPI. The cookie name is the shared + `SessionService.DEFAULT_COOKIE_NAME` (`mioidc_session`); session ids are stored only as their + SHA-256. **mini-oidc is the sole writer; mini-gateway is a reader of the same file** — there is + exactly one session mechanism in the family. +- **`store/DocumentStore`** — the persistence **SPI** (`exists()` / `load()` / `save(T)`). The + services depend only on this, never on a concrete store. mini-idp backs it with an atomic, + owner-only (`0600`) JSON file; mini-kms's `KmsSigningKeyStore` decorates it to envelope-wrap + signing keys at rest. +- **`auth/`** — the authorization model the `grants` claim maps onto: `Authorization`, `Grant`, + `KeyOperation` (the deliberate string mirror of mini-kms's operations — **do not rename the + values**, they are the contract). + +## The contract + +The `grants` claim (`token/GrantsClaim` over `auth/Authorization`) is the family's stable JSON +contract; it maps onto mini-kms's authorization model: `sub → Principal.id`, `grants.control → +Principal.admin`, and `grants.groups[]` → a per-key-group `PolicyEngine` decision (mini-policy). The +string values of `auth/KeyOperation` are part of that contract. **Note this is a *designed* mapping:** +the token → mini-kms authorization step is not yet the live runtime path — see the "Wired vs. +designed" note in [`docs/DIRECTION.md`](../../docs/DIRECTION.md#runtime-relationships). + +## Who consumes it + +| Consumer | Uses | +| --- | --- | +| [mini-idp](../../services/mini-idp) | `SigningKeyService` + `TokenIssuer` (typed claims), `RevocationService`, `AuditService`, `JwkSet`. | +| [mini-oidc](../../services/mini-oidc) | the same keys + `Jws` generic-`sign` overload for ID/access tokens; `JwsClaimsVerifier`; `SessionService` (writer). | +| [mini-gateway](../../services/mini-gateway) | `JwsClaimsVerifier` against the OP's JWKS; `SessionService` (reader of the shared `sessions.json`). | +| [mini-ca](../../services/mini-ca) | persists its CA key as a one-record `SigningKeys` document through the `DocumentStore` SPI. | + +## Dependencies + +Jackson is on the **api** classpath (the JWKS document and claim records (de)serialize through it, +exactly as in mini-idp). All crypto is the JDK's Ed25519 — no third-party crypto dependency. + +## Reading order + +Walk `token/` in the package-doc order — `Base64Url` → `JwsHeader`/`JwtClaims` → **`Jws`** (read the +load-bearing comment on *what gets signed*) → `JwsClaimsVerifier` → `GrantsClaim`. Then +`crypto/Ed25519Keys`, `jwks/Jwk`+`JwkSet`, `service/SigningKeyService` (rotation), and +`session/SessionService`. The concept doc +[`what-a-token-is.md`](../../docs/concepts/what-a-token-is.md) and the keystone lab +[`02-build-and-verify-a-token-by-hand.md`](../../docs/tutorials/02-build-and-verify-a-token-by-hand.md) +make it concrete — you verify an Ed25519 signature yourself, then flip a byte and watch it fail. + +## Build & test + +Part of the aggregator build — run from the repo root: + +```bash +./gradlew :libs:mini-token:test # this library's tests +./gradlew build # the whole family (the CI gate) +``` diff --git a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/Authorization.java b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/Authorization.java index 2329704..b26aa3e 100644 --- a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/Authorization.java +++ b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/Authorization.java @@ -12,7 +12,7 @@ * This maps directly onto mini-kms's {@code Principal.admin} flag: a verified token with * {@code controlPlane = true} becomes an admin principal there. *

  • {@code grants} — the per-key-group data-plane {@link KeyOperation}s the client may - * perform. mini-kms feeds these to its {@code KeyAuthorizationPolicy}.
  • + * perform. mini-kms feeds these to a per-key-group {@code PolicyEngine} decision (mini-policy). * * *

    The clientId is NOT stored here; it is the token subject ({@code sub}) and becomes the diff --git a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/KeyOperation.java b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/KeyOperation.java index a06f20d..c1ae226 100644 --- a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/KeyOperation.java +++ b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/KeyOperation.java @@ -6,8 +6,8 @@ *

    This is a deliberate mirror of mini-kms's {@code KeyOperation} enum. mini-idp does not * perform any of these operations; it only names them so that an issued token can * carry a set of granted operations per key group. When the future mini-kms verifies one of - * our tokens it can map this set straight onto its own {@code KeyOperation} / - * {@code KeyAuthorizationPolicy} without a translation table. + * our tokens it can map this set straight onto its own {@code KeyOperation} and a per-key-group + * {@code PolicyEngine} decision (mini-policy) without a translation table. * *

    Keep these names byte-for-byte identical to mini-kms's enum constants — the contract is * the string value that travels in the JWT claim, so a rename here silently breaks the KMS's diff --git a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/GrantsClaim.java b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/GrantsClaim.java index 14325a6..b50579c 100644 --- a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/GrantsClaim.java +++ b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/GrantsClaim.java @@ -16,7 +16,7 @@ * *

    This is the on-the-wire mirror of {@link Authorization}: a {@code control} boolean (maps to * mini-kms {@code Principal.admin}) and a list of per-group operation grants (each maps to a - * {@code KeyAuthorizationPolicy} entry). It is intentionally a separate type from the domain + * per-key-group {@code PolicyEngine} decision in mini-policy). It is intentionally a separate type from the domain * {@link Authorization}/{@link Grant} so the JSON contract is explicit and stable — the field * names here ARE the published contract the mini-kms team integrates against. * diff --git a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/JwtClaims.java b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/JwtClaims.java index eca68c4..db3196e 100644 --- a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/JwtClaims.java +++ b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/JwtClaims.java @@ -12,8 +12,8 @@ *

      *
    • {@code grants} — the authorization payload ({@link GrantsClaim}): control-plane flag and * per-key-group operations. A verifier maps {@code sub} → {@code Principal.id}, - * {@code grants.control} → {@code Principal.admin}, and {@code grants.groups} → its - * {@code KeyAuthorizationPolicy}.
    • + * {@code grants.control} → {@code Principal.admin}, and {@code grants.groups} → a + * per-key-group {@code PolicyEngine} decision (mini-policy). *
    • {@code cnf} — an OPTIONAL confirmation claim (RFC 7800) reserved for future channel * binding (e.g. an mTLS certificate thumbprint {@code x5t#S256}, or a peer uid for a Unix * socket). mini-idp does not populate or enforce it yet; the placeholder is here so the diff --git a/services/mini-ca/README.md b/services/mini-ca/README.md index 7b44d41..429a6f5 100644 --- a/services/mini-ca/README.md +++ b/services/mini-ca/README.md @@ -178,7 +178,7 @@ solved. `CaService` runs once at construction. It is the only writer of these files. ``` -first run (ca-key.json or ca-cert.json absent): +first run (either ca-key.json or ca-cert.json absent; load only when both present): CaKeys.generate() -> fresh EC P-256 key pair CaKeys.selfSignedRoot(subject,…) -> self-signed v3 root (CA:true, pathLen 0) caKeyStore.save(SigningKeys[ kid="ca", PKCS#8(privateKey) ]) <- wrapped under mini-kms if configured diff --git a/services/mini-ca/docs/security/01-csr-proof-of-possession.md b/services/mini-ca/docs/security/01-csr-proof-of-possession.md index fb0981e..aaa762f 100644 --- a/services/mini-ca/docs/security/01-csr-proof-of-possession.md +++ b/services/mini-ca/docs/security/01-csr-proof-of-possession.md @@ -20,7 +20,7 @@ mini-ca verifies it before doing anything else with the CSR: ```java // CertificateAuthority.issueFromCsr — verify the CSR's self-signature against its OWN public key if (!csr.isSignatureValid(new JcaContentVerifierProviderBuilder().build(csr.getSubjectPublicKeyInfo()))) { - throw new CaIssuanceException("CSR signature is not valid"); + throw new CaIssuanceException("CSR signature is invalid"); } ``` diff --git a/services/mini-directory/build.gradle.kts b/services/mini-directory/build.gradle.kts index 50f120d..dbaed82 100644 --- a/services/mini-directory/build.gradle.kts +++ b/services/mini-directory/build.gradle.kts @@ -7,8 +7,9 @@ * * It exposes a small loopback admin API following the family's conventions (admin bearer token, * atomic 0600 JSON store, no secrets in logs) and ships an OpenAPI spec + vendored Swagger UI like - * mini-idp. mini-oidc (humans) and mini-idp (machines) are intended to READ identities/grants from - * here later; today the service stands alone. + * mini-idp. mini-idp reads service accounts from here today (it has no local client registry — + * every token request authenticates against this service); mini-oidc resolves humans from here + * when started with --directory-url (optional, in-memory fallback otherwise). */ plugins { diff --git a/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/ServerMain.java b/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/ServerMain.java index ae5424c..92ea766 100644 --- a/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/ServerMain.java +++ b/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/ServerMain.java @@ -22,8 +22,11 @@ *
    • Bind loopback and serve until interrupted; a shutdown hook stops the HTTP server.
    • * * - *

      This service does not yet wire into mini-idp or mini-oidc — it stands alone. Those - * issuers will later read identities and grants from it (see {@code docs/DIRECTION.md}). + *

      mini-idp wires into this service today: it has no local client registry and authenticates + * every {@code /oauth/token} against {@code POST /admin/service-accounts/authenticate}, so the + * directory is its required identity source. mini-oidc resolves humans from it when started with + * {@code --directory-url} (optional; otherwise it uses an empty in-memory directory). See + * {@code docs/DIRECTION.md}. */ public final class ServerMain { diff --git a/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/migration/ClientRegistryMigration.java b/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/migration/ClientRegistryMigration.java index 1ab0b3f..5f1594b 100644 --- a/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/migration/ClientRegistryMigration.java +++ b/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/migration/ClientRegistryMigration.java @@ -23,16 +23,18 @@ * its id (so issued token subjects are unchanged), its Argon2id secret hash (so existing client * secrets keep working — the hash is imported verbatim and verified later under its own recorded * parameters), its enabled flag, and its authorization mapped to mini-directory grants: the - * control-plane flag becomes {@code admin}, and each {@code groups[].operations[]} becomes a - * {@code GrantSpec(action = operation, resource = keyGroup)} — the generalized form mini-policy and - * mini-idp's reconstruction agree on. + * control-plane flag becomes {@code admin}, and each {@code grants[].operations[]} becomes a + * {@code GrantSpec(action = operation, resource = grants[].keyGroup)} — the generalized form + * mini-policy and mini-idp's reconstruction agree on. * *

      Idempotent: an id already present in the directory is skipped (so a re-run after a partial * migration is safe). Reads {@code clients.json} as plain JSON — no dependency on mini-idp — and * never logs secret material (only ids and counts). * *

      - *   mini-directory-migrate --clients-file ~/.mini-idp/clients.json --data-dir ~/.mini-directory
      + *   java -cp services/mini-directory/build/install/mini-directory/lib/'*' \
      + *     com.codeheadsystems.minidirectory.migration.ClientRegistryMigration \
      + *     --clients-file ~/.mini-idp/clients.json --data-dir ~/.mini-directory
        * 
      */ public final class ClientRegistryMigration { @@ -107,7 +109,7 @@ private static boolean importOne(final DirectoryService directory, final JsonNod } } - /** Map a mini-idp {@code Authorization} ({control + groups[].operations}) to flat GrantSpecs. */ + /** Map a mini-idp {@code Authorization} ({control + grants[].operations}) to flat GrantSpecs. */ private static List grantsOf(final JsonNode authorization) { final List grants = new ArrayList<>(); if (authorization == null || authorization.get("grants") == null) { diff --git a/services/mini-directory/src/main/resources/openapi.yaml b/services/mini-directory/src/main/resources/openapi.yaml index 0015a27..5f15aa1 100644 --- a/services/mini-directory/src/main/resources/openapi.yaml +++ b/services/mini-directory/src/main/resources/openapi.yaml @@ -15,8 +15,10 @@ info: token. Service-account secrets are Argon2id-hashed at rest and returned exactly once, at creation; no other endpoint ever returns secret material. - mini-oidc (humans) and mini-idp (service accounts) are intended to read identities and grants - from here; that wiring is deliberately future work — today mini-directory stands alone. + mini-idp reads its service accounts from here today — it has no local client registry and + authenticates every `/oauth/token` against `POST /admin/service-accounts/authenticate` + (required wiring). mini-oidc resolves humans from here when started with `--directory-url` + (optional; without it, it falls back to an empty in-memory directory and nobody resolves). servers: - url: http://127.0.0.1:8466 description: Default loopback bind address. diff --git a/services/mini-idp/README.md b/services/mini-idp/README.md index dc6a9a9..5c87cb8 100644 --- a/services/mini-idp/README.md +++ b/services/mini-idp/README.md @@ -113,7 +113,7 @@ Tokens are compact JWS with the header `{"alg":"EdDSA","typ":"JWT","kid":"" "jti": "cWmkyGso5MJdbXpVHu-MMQ", // unique id (used for revocation) "grants": { // the authorization payload "control": false, // -> mini-kms Principal.admin - "groups": [ // -> mini-kms KeyAuthorizationPolicy + "groups": [ // -> per-key-group PolicyEngine decision (mini-policy) { "keyGroup": "billing", "operations": ["ENCRYPT", "DECRYPT"] } ] } @@ -127,7 +127,7 @@ Tokens are compact JWS with the header `{"alg":"EdDSA","typ":"JWT","kid":"" ``` sub -> Principal.id grants.control -> Principal.admin -grants.groups[] -> per-key-group KeyAuthorizationPolicy decisions +grants.groups[] -> per-key-group PolicyEngine decisions (mini-policy) ``` ### How to verify a token offline diff --git a/services/mini-idp/core/src/main/java/com/codeheadsystems/miniidp/store/JsonStore.java b/services/mini-idp/core/src/main/java/com/codeheadsystems/miniidp/store/JsonStore.java index 832ac4c..a98146f 100644 --- a/services/mini-idp/core/src/main/java/com/codeheadsystems/miniidp/store/JsonStore.java +++ b/services/mini-idp/core/src/main/java/com/codeheadsystems/miniidp/store/JsonStore.java @@ -18,14 +18,15 @@ * *

      This is mini-idp's file-backed implementation of mini-token's * {@link com.codeheadsystems.minitoken.store.DocumentStore} SPI, and the storage primitive for - * every mini-idp persisted document — the client registry plus the token plane's signing-key set, - * revocation denylist, and audit log. It is a direct mirror of mini-kms's {@code Keystore}: writes + * every mini-idp persisted document — the token plane's signing-key set, revocation denylist, and + * audit log. (The client registry moved to mini-directory; mini-idp keeps no local registry.) It + * is a direct mirror of mini-kms's {@code Keystore}: writes * go to a temp file in the same directory, get their * permissions restricted, and are then swapped into place with {@link StandardCopyOption#ATOMIC_MOVE} * so a crash mid-write can never leave a half-written or world-readable file. * - *

      0600 matters most for the signing-key file (it holds private Ed25519 keys) and the client - * registry (it holds secret hashes). We apply it uniformly. A real deployment would additionally + *

      0600 matters most for the signing-key file (it holds private Ed25519 keys). We apply it + * uniformly. A real deployment would additionally * wrap the private signing keys under a KMS — that is the eventual recursive integration with * mini-kms and is intentionally out of scope here. * diff --git a/services/mini-idp/server/src/main/resources/openapi.yaml b/services/mini-idp/server/src/main/resources/openapi.yaml index f9b8851..880b3b1 100644 --- a/services/mini-idp/server/src/main/resources/openapi.yaml +++ b/services/mini-idp/server/src/main/resources/openapi.yaml @@ -338,7 +338,7 @@ components: type: object description: | The authorization payload. `control` maps to mini-kms `Principal.admin`; each `groups[]` - entry maps to a `KeyAuthorizationPolicy` decision for that key group. + entry maps to a per-key-group `PolicyEngine` decision (mini-policy) for that key group. properties: control: type: boolean diff --git a/services/mini-kms/README.md b/services/mini-kms/README.md index 2ea19c1..f9e6489 100644 --- a/services/mini-kms/README.md +++ b/services/mini-kms/README.md @@ -149,6 +149,9 @@ over. - **`MasterKeyProvider` / `KeyringManager`** — the two interfaces the request handler depends on (data plane / control plane). Swapping their implementation (e.g. for a remote HSM-backed one) needs no change to request handling. +- **`PolicyEngine`** — the authorization seam (now living in `:libs:mini-policy`); + it decides whether the authenticated `Principal` may use a given key group. The + shipped default is `AllowAllPolicyEngine`. --- @@ -203,7 +206,7 @@ flowchart LR crypto["crypto
      AesGcm, EnvelopeFormat"] keyring["keyring
      LocalKeyring, KekEnvelope,
      KeyringManager, Keystore"] kms["kms
      MasterKeyProvider, KmsService,
      KmsRequestHandler"] - auth["auth
      ApiTokenAuthenticator,
      KeyAuthorizationPolicy"] + auth["auth
      ApiTokenAuthenticator,
      PolicyEngine (:libs:mini-policy)"] protocol["protocol
      KmsRequest/Response, codec"] end server["server
      TCP + Unix daemon
      ServerMain"] @@ -232,13 +235,13 @@ tokens, a `plane()` tag on every request type): ```mermaid flowchart TD REQ["incoming request"] --> P{"type.plane()?"} - P -->|DATA| DT["validate API token"] --> POL["KeyAuthorizationPolicy
      (per key group)"] --> KS["KmsService
      (MasterKeyProvider)"] + P -->|DATA| DT["validate API token"] --> POL["PolicyEngine
      (per key group)"] --> KS["KmsService
      (MasterKeyProvider)"] P -->|CONTROL| AT["validate ADMIN token"] --> KM["KeyringManager
      (create/rotate/…)"] ``` - **Data plane** — `GenerateDataKey`, `Encrypt`, `Decrypt`, `ReEncrypt`, `Health`. Authenticated by the **API token**; each key-group access passes - through a `KeyAuthorizationPolicy`. + through a `PolicyEngine` (the authorization seam now lives in `:libs:mini-policy`). - **Control plane** — `Create/Rotate/List/Disable/Enable/DestroyVersion`. Authenticated by a **separate admin token**. - **Root/passphrase rotation** is offline-only (the `kms-admin change-passphrase` @@ -420,12 +423,13 @@ to `DecryptionFailed` to avoid an oracle. data plane; the **admin token** (`MINIKMS_ADMIN_TOKEN`) guards the control plane. Compared in **constant time**; applied to both sockets. - **Authorization seam:** every data-plane key-group access passes through a - `KeyAuthorizationPolicy`. The shipped default (`AllowAllPolicy`) lets any - authenticated client use any group — groups still provide isolation and - independent rotation. This is the explicit hook for "**KEK groups dependent on - the client**": introduce per-client tokens later (mapping each to a distinct - `Principal`) and supply a restrictive policy here — with **no change** to the - request-handling code that already calls it. + `PolicyEngine` (the seam now lives in `:libs:mini-policy`). The shipped default + (`AllowAllPolicyEngine`) lets any authenticated client use any group — groups + still provide isolation and independent rotation. This is the explicit hook for + "**KEK groups dependent on the client**": introduce per-client tokens later + (mapping each to a distinct `Principal`) and supply a restrictive engine here + (e.g. `GrantBasedPolicyEngine`) — with **no change** to the request-handling + code that already calls it. --- @@ -519,6 +523,8 @@ $A change-passphrase --keystore ~/.mini-kms/keystore.json | `--token-file PATH` | `MINIKMS_API_TOKEN_FILE` | — | file holding the API token | | `--admin-token-file PATH` | `MINIKMS_ADMIN_TOKEN_FILE` | — | file holding the admin token | | `--max-frame-bytes N` | `MINIKMS_MAX_FRAME_BYTES` | `1048576` | per-request size limit | +| `--idle-timeout-ms N` | `MINIKMS_IDLE_TIMEOUT_MS` | `30000` | drop a connection idle/stalled this long | +| `--max-connections N` | `MINIKMS_MAX_CONNECTIONS` | `256` | cap on concurrent connections | | `--no-tcp` / `--no-unix` | — | — | disable a listener | | (secret) | `MINIKMS_API_TOKEN` | — | data-plane token value (preferred over file) | | (secret) | `MINIKMS_ADMIN_TOKEN` | — | control-plane token value (preferred over file) | diff --git a/services/mini-kms/docs/security/03-loopback-tcp-local-exposure.md b/services/mini-kms/docs/security/03-loopback-tcp-local-exposure.md index 27697d7..ef8ec50 100644 --- a/services/mini-kms/docs/security/03-loopback-tcp-local-exposure.md +++ b/services/mini-kms/docs/security/03-loopback-tcp-local-exposure.md @@ -22,7 +22,7 @@ is unlike the Unix domain socket, which is a filesystem object guarded by is also no peer-credential (`SO_PEERCRED`) check on accepted connections, so the server never learns *which* local user connected. -With the shipped `AllowAllPolicy`, the shared token is therefore the **sole** +With the shipped `AllowAllPolicyEngine`, the shared token is therefore the **sole** access control for any local process reaching the TCP port. ## The threat it poses diff --git a/services/mini-oidc/README.md b/services/mini-oidc/README.md index ad7a948..d8b77f2 100644 --- a/services/mini-oidc/README.md +++ b/services/mini-oidc/README.md @@ -123,29 +123,29 @@ keys as mini-idp's typed tokens. mini-idp's path is untouched. The same flow, traced through `server/OidcHandlers.java` and the `service/` stores: -1. **`GET /authorize`** — `authorize` (`OidcHandlers.java:163`) validates the client, exact-matches +1. **`GET /authorize`** — `authorize` validates the client, exact-matches `redirect_uri`, requires `response_type=code`, the `openid` scope, and **PKCE `S256`** (a missing or non-S256 method is rejected back to the redirect URI), then parks a `PendingAuthorization` - (opaque `requestId` + CSRF token) server-side (`:192`) and returns the login page (or consent if - a session already exists). -2. **Passkey login** — `POST /login/passkey/start` (`:245`) returns the WebAuthn options; - `POST /login/passkey/finish` (`:254`) verifies the assertion via pk-auth and resolves the verified - `UserHandle` to a username; `completeLogin` (`:277`) creates the SSO session - (`sessions.create`, `:282`) and sets the cookie. (`POST /login/recovery` is the backup-code + (opaque `requestId` + CSRF token) server-side (`pending.put`) and returns the login page (or + consent if a session already exists). +2. **Passkey login** — `POST /login/passkey/start` (`passkeyStart`) returns the WebAuthn options; + `POST /login/passkey/finish` (`passkeyFinish`) verifies the assertion via pk-auth and resolves the + verified `UserHandle` to a username; `completeLogin` creates the SSO session + (`sessions.create`) and sets the cookie. (`POST /login/recovery` is the backup-code fallback.) -3. **Consent** — `POST /authorize/decision` (`:214`) checks the CSRF token (`requireCsrf`, `:220`, - constant-time), filters the requested scopes through mini-policy (`scopeAuthorizer.authorize`, - `:229`), mints a one-time `AuthorizationCode` binding client / redirect / subject / PKCE challenge - / `auth_time` (`codes.put`, `:232`), and 302s back to the redirect URI with `code` + `state` - (`:240`). -4. **`POST /token` (code grant)** — `authorizationCodeGrant` (`:302`): `authenticateClient` (`:354`) - authenticates a confidential client (or accepts a public one); `codes.consume` (`:304`) redeems - the code exactly once — a replay revokes the refresh family it first produced (`:307`); the client - id, redirect URI, and the **PKCE verifier** are re-checked (`Pkce.verify`, `:315`); then it mints - the access token (`:320`) and ID token (`:321`) on mini-token's keys and issues a rotating refresh - token (`:322`), binding the code to that refresh family (`:323`). -5. **`POST /token` (refresh grant)** — `refreshGrant` (`:327`) rotates the presented refresh token - (`refreshTokens.rotate`, `:328`): a valid token yields a fresh access / ID / refresh set (carrying +3. **Consent** — `POST /authorize/decision` (`decision`) checks the CSRF token (`requireCsrf`, + constant-time), filters the requested scopes through mini-policy (`scopeAuthorizer.authorize`), + mints a one-time `AuthorizationCode` binding client / redirect / subject / PKCE challenge + / `auth_time` (`codes.put`), and 302s back to the redirect URI with `code` + `state`. +4. **`POST /token` (code grant)** — `authorizationCodeGrant`: `authenticateClient` + authenticates a confidential client (or accepts a public one); `codes.consume` redeems + the code exactly once — a replay revokes the refresh family it first produced (`refreshTokens.revokeFamily`); + the client id, redirect URI, and the **PKCE verifier** are re-checked (`Pkce.verify`); then it + mints the access token (`tokens.mintAccessToken`) and ID token (`tokens.mintIdToken`) on + mini-token's keys and issues a rotating refresh token (`refreshTokens.issue`), binding the code to + that refresh family (`codes.bindFamily`). +5. **`POST /token` (refresh grant)** — `refreshGrant` rotates the presented refresh token + (`refreshTokens.rotate`): a valid token yields a fresh access / ID / refresh set (carrying the **original** `auth_time`), while re-using an already-rotated token scorches the whole family. ## Quick start diff --git a/services/mini-oidc/src/main/resources/openapi.yaml b/services/mini-oidc/src/main/resources/openapi.yaml index 6465907..a42e940 100644 --- a/services/mini-oidc/src/main/resources/openapi.yaml +++ b/services/mini-oidc/src/main/resources/openapi.yaml @@ -71,7 +71,7 @@ paths: - { name: state, in: query, schema: { type: string } } - { name: nonce, in: query, schema: { type: string } } - { name: code_challenge, in: query, required: true, schema: { type: string } } - - { name: code_challenge_method, in: query, schema: { type: string, enum: [S256, plain] } } + - { name: code_challenge_method, in: query, schema: { type: string, enum: [S256] } } responses: "200": { description: The login or consent HTML page. } "302": { description: Redirect to the redirect URI with an error. }