Skip to content

Commit 7dc5ccf

Browse files
committed
Prompt 7
1 parent 016b063 commit 7dc5ccf

35 files changed

Lines changed: 1085 additions & 1123 deletions

File tree

CLAUDE.md

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,13 @@ services/mini-kms/client/build/install/client/bin/kms-admin --tcp 127.0.0.1:9123
233233

234234
# Service: mini-idp (`services/mini-idp`)
235235

236-
mini-idp is an **educational** standalone identity provider in Java. It registers clients, issues
237-
short-lived Ed25519-signed JWT access tokens via the OAuth 2.0 **client-credentials** grant, and
238-
publishes its public signing keys (JWKS) so a verifier can validate tokens **offline**. Real crypto
239-
(Argon2id for secrets, Ed25519/EdDSA for signatures), not production-audited. **It does not depend
240-
on mini-kms** at the code level — it mirrors its conventions and targets its eventual verification
241-
contract.
236+
mini-idp is an **educational** identity provider in Java. It issues short-lived Ed25519-signed JWT
237+
access tokens via the OAuth 2.0 **client-credentials** grant and publishes its public signing keys
238+
(JWKS) so a verifier can validate tokens **offline**. It is a pure token issuer: **service accounts
239+
(the OAuth clients) live in mini-directory**, and mini-idp resolves a client's credentials and grants
240+
from there at token issuance (over the `ServiceAccountDirectory` SPI). Real crypto (Ed25519/EdDSA),
241+
not production-audited. Optionally wraps its signing keys under mini-kms (the recursive integration);
242+
otherwise no code-level dependency on mini-kms.
242243

243244
**Run it locally.** The bootstrap admin token comes from an env var or a file, **never a CLI arg,
244245
and is never logged**.
@@ -253,20 +254,25 @@ services/mini-idp/server/build/install/server/bin/server --port 8455 --data-dir
253254
**Architecture.** Two modules under base package `com.codeheadsystems.miniidp` (paths
254255
`:services:mini-idp:core/server`):
255256

256-
- **`core`** — the IDP-specific identity layer, **no HTTP/transport code**. Owns the client
257-
registry model (`model/ClientRecord`) and `service/ClientService`, Argon2id client-secret hashing
258-
(`secret/*`), and the atomic-`0600` `store/JsonStore` (which implements mini-token's
259-
`DocumentStore` SPI). The **token plane it used to own was extracted to `:libs:mini-token`** (see
260-
below) and is consumed as an `api` dependency.
257+
- **`core`** — now just the atomic-`0600` `store/JsonStore` (which implements mini-token's
258+
`DocumentStore` SPI), backing the signing-key / revocation / audit documents. The token plane was
259+
extracted to `:libs:mini-token`; the client registry + Argon2 hashing moved to mini-directory. Both
260+
are consumed as dependencies.
261261
- **`server`** — the HTTP daemon (`ServerMain`, JDK `com.sun.net.httpserver.HttpServer` on
262-
loopback), router, handlers, config, and the OpenAPI spec + vendored Swagger UI. Depends on `core`.
263-
262+
loopback), router, handlers, config, the `directory/` package (the `ServiceAccountDirectory` SPI +
263+
`HttpServiceAccountDirectory`/`InMemoryServiceAccountDirectory` + grant reassembly), and the
264+
OpenAPI spec + vendored Swagger UI. Depends on `core`, mini-token, and mini-kms:client.
265+
266+
- **Service accounts come from mini-directory.** At `/oauth/token`, mini-idp resolves the client via
267+
the `ServiceAccountDirectory` SPI — production `HttpServiceAccountDirectory` POSTs to
268+
mini-directory's `/admin/service-accounts/authenticate` (verification happens there; the secret
269+
hash never leaves the directory), tests use `InMemoryServiceAccountDirectory`. The resolved grants
270+
are reassembled into the per-key-group `grants` claim, so the token is identical to the old
271+
registry's output. `--directory-url` + a directory admin token are required.
264272
- **The token plane lives in `mini-token`.** The Ed25519 keys, the hand-rolled JWS/JWT, the JWKS
265273
model, the `grants` claim, the auth model the claim maps onto, and the signing-key /
266-
revocation / audit services are all in `:libs:mini-token` (`com.codeheadsystems.minitoken.*`) —
267-
see the mini-token notes in the umbrella section above. mini-idp wires them together in
268-
`server/IdpServer` and exposes them over HTTP; the issuer takes a `(subject, Authorization)`, so
269-
`core` keeps its `ClientRecord` to itself.
274+
revocation / audit services are all in `:libs:mini-token` (`com.codeheadsystems.minitoken.*`).
275+
mini-idp wires them in `server/IdpServer`; the issuer takes a `(subject, Authorization)`.
270276
- **The token contract.** `server/src/main/resources/openapi.yaml` (served at `/openapi.yaml`,
271277
`/openapi.json`, `/docs`) is authoritative. The `grants` claim (mini-token's
272278
`token/GrantsClaim` over `auth/Authorization`) maps to mini-kms: `sub → Principal.id`,
@@ -319,9 +325,10 @@ services/mini-directory/build/install/mini-directory/bin/mini-directory --port 8
319325
`Group`, `Role`, the flat `GrantSpec` (`{action, resource}`, the JSON-friendly mirror of a
320326
mini-policy `Grant`), and `ResolvedPrincipal` (a mini-policy `Principal` + expanded `Grant`s).
321327
- **`service/DirectoryService`** — the I/O-free heart: CRUD for accounts/groups/roles, assignment,
322-
`resolve(id)` (the role/group → grant expansion), and `authenticate(id, secret)` (no-oracle,
323-
constant-time, dummy-hash for unknown — mirrors mini-idp's `ClientService`). All methods
324-
`synchronized`; persisted on every mutation.
328+
`resolve(id)` (the role/group → grant expansion), `authenticate(id, secret)` (no-oracle,
329+
constant-time, dummy-hash for unknown — the family's credential-check pattern), and
330+
`importServiceAccount(...)` (the migration entry point). All methods `synchronized`; persisted on
331+
every mutation. mini-idp now reads service accounts from here (via the authenticate endpoint).
325332
- **`secret`**`Argon2SecretHasher`/`SecretHash`/`Argon2Settings`: the family's Argon2id pattern
326333
(a `mini-common` candidate), **replicated** here so the service stays standalone. Only
327334
service accounts carry a hash; humans carry none.

docs/DIRECTION.md

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ understand the whole, read this one.
1414
- [System architecture](#system-architecture)
1515
- [Runtime relationships (what the names don't show)](#runtime-relationships)
1616
- [Wrapping the signing keys under mini-kms](#wrapping-the-signing-keys-under-mini-kms)
17-
- [Open design decision: the client registry](#open-design-decision-the-client-registry)
17+
- [Resolved design decision: the client registry folded into mini-directory](#resolved-design-decision-the-client-registry-folded-into-mini-directory)
1818
- [Toward a mini-common library](#toward-a-mini-common-library)
1919
- [Roadmap](#roadmap)
2020
- [Build aggregation](#build-aggregation)
@@ -251,28 +251,34 @@ operator-supplied secret. The default (no-KMS) path skips steps 1–2 entirely.
251251

252252
---
253253

254-
## Open design decision: the client registry
255-
256-
mini-idp owns a **client registry** today: registered OAuth clients, their Argon2id-hashed
257-
secrets, and their grants, in its own JSON store. mini-directory is meant to be the single source
258-
of truth for *all* identities — including **service accounts**, which is essentially what an
259-
mini-idp OAuth client *is*.
260-
261-
**The open question:** should mini-idp's client registry fold into mini-directory later?
262-
263-
- **For folding in.** One identity model, one place to manage grants, no drift between "a service
264-
account" and "an OAuth client." mini-idp becomes a pure token issuer reading from mini-directory,
265-
matching how mini-oidc is intended to read human users from it.
266-
- **Against (or: not yet).** mini-idp is *shipping* and self-contained; its registry doubles as
267-
its credential store (hashed secrets), which is issuer-specific concern, not directory data.
268-
Coupling a working service to a still-scaffolded one is a regression risk. The token *claim*
269-
contract already aligns regardless of where the data lives, so there is no urgency.
270-
271-
**Current lean:** keep mini-idp's registry in place while mini-directory matures, and treat the
272-
directory's service-account model as the eventual home for the *grant mappings* first, with the
273-
*credential* (hashed secret) possibly staying in the issuer. Revisit once mini-directory has a
274-
real read API. This decision is deliberately left open and recorded here rather than pre-committed
275-
in code.
254+
## Resolved design decision: the client registry folded into mini-directory
255+
256+
mini-idp used to own a **client registry**: registered OAuth clients, their Argon2id-hashed secrets,
257+
and their grants, in its own JSON store. That registry is **gone** — service accounts (which is what
258+
an mini-idp OAuth client *is*) now live in **mini-directory**, the single source of service-account
259+
identity, and mini-idp resolves a client's credentials and grants from it at token issuance.
260+
261+
**How it works.** A service account is a mini-directory {@code SERVICE_ACCOUNT} account: its id is
262+
the token {@code sub}, its Argon2id secret hash stays *inside* mini-directory, and its grants are the
263+
flat {@code (action, resource)} form (a key-group operation is {@code action = KeyOperation},
264+
{@code resource = keyGroup}; the control flag is the account's {@code admin}). At {@code
265+
/oauth/token}, mini-idp POSTs the presented credentials to mini-directory's
266+
{@code /admin/service-accounts/authenticate} (over the `ServiceAccountDirectory` SPI —
267+
`HttpServiceAccountDirectory` in production, an in-memory fake in tests). mini-directory verifies the
268+
secret (constant-time, no oracle) and returns the resolved principal + grants, which mini-idp
269+
reassembles into the same per-key-group `grants` claim. **The token endpoint, claim schema, and
270+
single-`invalid_client` behavior are unchanged** — an end-to-end test issues a token sourced from a
271+
real mini-directory and verifies its `sub`/`grants` are identical to the old registry's output.
272+
273+
Why this resolved the way it did: the credential (hashed secret) verification was the one thing the
274+
"keep it in the issuer" camp worried about — and it is handled cleanly by keeping the hash in
275+
mini-directory and exposing a *verification* endpoint (the secret is checked there; the hash never
276+
leaves), so mini-idp is now a pure token issuer with no identity store of its own.
277+
278+
**Migration.** Existing `clients.json` files are imported by `ClientRegistryMigration` (a
279+
mini-directory CLI): each client record becomes a service account, preserving its id, secret hash,
280+
enabled flag, and grants. It is idempotent and reads `clients.json` as plain JSON (no mini-idp
281+
dependency). See `services/mini-directory/README.md`.
276282

277283
---
278284

services/mini-directory/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,32 @@ All `/admin/**` endpoints require `Authorization: Bearer <admin-token>`. Full co
6969
| `POST /admin/groups` · `GET /admin/groups` · `GET/PUT/DELETE /admin/groups/{id}` | Group CRUD. |
7070
| `POST /admin/humans` | Create a human (operator-chosen id, no secret). |
7171
| `POST /admin/service-accounts` | Create a service account (generated id + **one-time** secret). |
72+
| `POST /admin/service-accounts/authenticate` | Verify a service-account secret and return its resolution — the read API a token issuer (mini-idp) calls. The secret is verified here; the hash never leaves. |
7273
| `GET /admin/principals` · `GET/DELETE /admin/principals/{id}` | List / read / delete accounts. |
7374
| `PUT /admin/principals/{id}/assignment` | Replace an account's enabled flag, admin capability, group memberships, roles, and direct grants. |
7475
| `GET /admin/principals/{id}/resolution` | Resolve to a mini-policy principal + effective grants. |
7576

77+
## Migrating mini-idp's client registry
78+
79+
mini-idp no longer keeps its own client registry — it reads service accounts from here. To bring
80+
existing mini-idp clients across, run the one-time migration, which turns each client record into a
81+
`SERVICE_ACCOUNT` (preserving its id, Argon2id secret hash, enabled flag, and grants — a key-group
82+
operation becomes a `{action: <KeyOperation>, resource: <keyGroup>}` grant, and the control flag
83+
becomes `admin`):
84+
85+
```bash
86+
# Stop mini-idp first. Then, against the directory's data dir:
87+
java -cp services/mini-directory/build/install/mini-directory/lib/'*' \
88+
com.codeheadsystems.minidirectory.migration.ClientRegistryMigration \
89+
--clients-file ~/.mini-idp/clients.json --data-dir ~/.mini-directory
90+
# -> "Migrated N client(s) into …/directory.json; skipped M already present."
91+
```
92+
93+
It is **idempotent** (a re-run skips ids already present), reads `clients.json` as plain JSON (no
94+
mini-idp dependency), and logs only ids + counts — never secrets. After migrating, point mini-idp at
95+
this directory with `--directory-url`; existing client secrets keep working (the hash is imported
96+
verbatim), and issued tokens are unchanged. Once verified, delete `clients.json`.
97+
7698
## Security notes
7799

78100
- **Argon2id** for service-account secrets (Bouncy Castle); each secret is returned exactly once at
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package com.codeheadsystems.minidirectory.migration;
2+
3+
import com.codeheadsystems.minidirectory.model.GrantSpec;
4+
import com.codeheadsystems.minidirectory.secret.Argon2Settings;
5+
import com.codeheadsystems.minidirectory.secret.Argon2SecretHasher;
6+
import com.codeheadsystems.minidirectory.secret.SecretHash;
7+
import com.codeheadsystems.minidirectory.service.DirectoryService;
8+
import com.codeheadsystems.minidirectory.store.DirectoryDocument;
9+
import com.codeheadsystems.minidirectory.store.JsonStore;
10+
import com.codeheadsystems.minidirectory.util.RandomIds;
11+
import java.io.IOException;
12+
import java.nio.file.Files;
13+
import java.nio.file.Path;
14+
import java.nio.file.Paths;
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
import tools.jackson.databind.JsonNode;
18+
import tools.jackson.databind.ObjectMapper;
19+
20+
/**
21+
* One-time migration of mini-idp's old client registry ({@code clients.json}) into mini-directory as
22+
* service accounts. Each client record becomes a {@code SERVICE_ACCOUNT} {@code Account}, preserving
23+
* its id (so issued token subjects are unchanged), its Argon2id secret hash (so existing client
24+
* secrets keep working — the hash is imported verbatim and verified later under its own recorded
25+
* parameters), its enabled flag, and its authorization mapped to mini-directory grants: the
26+
* control-plane flag becomes {@code admin}, and each {@code groups[].operations[]} becomes a
27+
* {@code GrantSpec(action = operation, resource = keyGroup)} — the generalized form mini-policy and
28+
* mini-idp's reconstruction agree on.
29+
*
30+
* <p>Idempotent: an id already present in the directory is skipped (so a re-run after a partial
31+
* migration is safe). Reads {@code clients.json} as plain JSON — no dependency on mini-idp — and
32+
* never logs secret material (only ids and counts).
33+
*
34+
* <pre>
35+
* mini-directory-migrate --clients-file ~/.mini-idp/clients.json --data-dir ~/.mini-directory
36+
* </pre>
37+
*/
38+
public final class ClientRegistryMigration {
39+
40+
private static final ObjectMapper MAPPER = new ObjectMapper();
41+
42+
private ClientRegistryMigration() {
43+
}
44+
45+
/** @param args {@code --clients-file <clients.json> --data-dir <directory data dir>}. */
46+
public static void main(final String[] args) {
47+
try {
48+
final Path clientsFile = required(args, "--clients-file");
49+
final Path dataDir = required(args, "--data-dir");
50+
final Result result = run(clientsFile, dataDir);
51+
System.out.println("Migrated " + result.migrated() + " client(s) into "
52+
+ dataDir.resolve("directory.json") + "; skipped " + result.skipped() + " already present.");
53+
} catch (final IllegalArgumentException e) {
54+
System.err.println("Migration error: " + e.getMessage());
55+
System.exit(64);
56+
} catch (final IOException e) {
57+
System.err.println("I/O error: " + e.getMessage());
58+
System.exit(74);
59+
}
60+
}
61+
62+
/**
63+
* Run the migration.
64+
*
65+
* @param clientsFile the mini-idp {@code clients.json}.
66+
* @param dataDir the mini-directory data dir (holds/creates {@code directory.json}).
67+
* @return how many records were migrated and how many were skipped (already present).
68+
*/
69+
public static Result run(final Path clientsFile, final Path dataDir) throws IOException {
70+
final DirectoryService directory = new DirectoryService(
71+
new JsonStore<>(dataDir.resolve("directory.json"), DirectoryDocument.class),
72+
new Argon2SecretHasher(Argon2Settings.defaults()), new RandomIds());
73+
74+
final JsonNode root = MAPPER.readTree(Files.readAllBytes(clientsFile));
75+
final JsonNode clients = root.get("clients");
76+
int migrated = 0;
77+
int skipped = 0;
78+
if (clients != null) {
79+
for (final JsonNode client : clients) {
80+
if (importOne(directory, client)) {
81+
migrated++;
82+
} else {
83+
skipped++;
84+
}
85+
}
86+
}
87+
return new Result(migrated, skipped);
88+
}
89+
90+
private static boolean importOne(final DirectoryService directory, final JsonNode client) {
91+
final String clientId = text(client, "clientId");
92+
if (clientId == null) {
93+
return false;
94+
}
95+
final JsonNode authorization = client.get("authorization");
96+
final boolean admin = authorization != null && authorization.has("controlPlane")
97+
&& authorization.get("controlPlane").asBoolean();
98+
final List<GrantSpec> grants = grantsOf(authorization);
99+
final SecretHash secretHash = secretHashOf(client.get("secretHash"));
100+
final boolean enabled = !client.has("enabled") || client.get("enabled").asBoolean();
101+
try {
102+
directory.importServiceAccount(clientId, text(client, "displayName"), admin, enabled,
103+
List.of(), List.of(), grants, secretHash);
104+
return true;
105+
} catch (final IllegalStateException alreadyExists) {
106+
return false; // idempotent re-run
107+
}
108+
}
109+
110+
/** Map a mini-idp {@code Authorization} ({control + groups[].operations}) to flat GrantSpecs. */
111+
private static List<GrantSpec> grantsOf(final JsonNode authorization) {
112+
final List<GrantSpec> grants = new ArrayList<>();
113+
if (authorization == null || authorization.get("grants") == null) {
114+
return grants;
115+
}
116+
for (final JsonNode group : authorization.get("grants")) {
117+
final String keyGroup = text(group, "keyGroup");
118+
final JsonNode operations = group.get("operations");
119+
if (keyGroup == null || operations == null) {
120+
continue;
121+
}
122+
for (final JsonNode op : operations) {
123+
grants.add(new GrantSpec(op.asString(), keyGroup));
124+
}
125+
}
126+
return grants;
127+
}
128+
129+
private static SecretHash secretHashOf(final JsonNode node) {
130+
if (node == null) {
131+
return null;
132+
}
133+
return new SecretHash(text(node, "algorithm"), text(node, "saltBase64"), text(node, "hashBase64"),
134+
node.get("memoryKiB").asInt(), node.get("iterations").asInt(), node.get("parallelism").asInt());
135+
}
136+
137+
private static String text(final JsonNode node, final String field) {
138+
return node.has(field) && !node.get(field).isNull() ? node.get(field).asString() : null;
139+
}
140+
141+
private static Path required(final String[] args, final String flag) {
142+
for (int i = 0; i < args.length - 1; i++) {
143+
if (args[i].equals(flag)) {
144+
return Paths.get(args[i + 1]);
145+
}
146+
}
147+
throw new IllegalArgumentException("missing required flag " + flag);
148+
}
149+
150+
/**
151+
* The migration outcome.
152+
*
153+
* @param migrated how many client records were imported as service accounts.
154+
* @param skipped how many were skipped because their id already existed (idempotent re-run).
155+
*/
156+
public record Result(int migrated, int skipped) {
157+
}
158+
}

0 commit comments

Comments
 (0)