From 48044ffeaaa20c3001b0140c9eb8324a7f23b2e9 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Mon, 29 Jun 2026 16:37:24 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(mini-console):=20add=20FullChainFlow?= =?UTF-8?q?=20harness=20exercise=20(identity=20=E2=86=92=20token=20?= =?UTF-8?q?=E2=86=92=20gateway)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a single runnable exercise that demonstrates the headline learning goal end to end — resolve a service account in mini-directory, mint a client-credentials token from mini-idp, then present that same token to mini-gateway's /verify and assert it is authorized to reach a protected resource. Previously each harness flow exercised only one hop with a manual copy-paste in the seam. - New FullChainFlow reusing the mini-directory/idp/gateway client libraries, following the existing flow conventions: offline verification at the gateway, PASS/SKIP/FAIL, secret-free results, and an honest SKIP when no service-account credentials are supplied. - Wired into ExerciseRegistry/ConsoleServer/ConsoleHandlers (route, run handler, availability gating across all three backends, run-all SKIP) and HarnessPages (run form + gating note). - FullChainFlowTest covering the pass path, honest SKIP, first-step and gateway-step failures, and the no-secret-leak contract. - README harness table + overview updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/mini-console/README.md | 13 +- .../harness/flows/FullChainFlow.java | 165 +++++++++++ .../miniconsole/pages/HarnessPages.java | 40 ++- .../miniconsole/server/ConsoleHandlers.java | 44 ++- .../miniconsole/server/ConsoleServer.java | 11 +- .../harness/flows/FullChainFlowTest.java | 263 ++++++++++++++++++ 6 files changed, 519 insertions(+), 17 deletions(-) create mode 100644 services/mini-console/src/main/java/com/codeheadsystems/miniconsole/harness/flows/FullChainFlow.java create mode 100644 services/mini-console/src/test/java/com/codeheadsystems/miniconsole/harness/flows/FullChainFlowTest.java diff --git a/services/mini-console/README.md b/services/mini-console/README.md index 5e08484..60a8759 100644 --- a/services/mini-console/README.md +++ b/services/mini-console/README.md @@ -9,9 +9,9 @@ of curling six admin APIs by hand. It has **two faces in one process**: services already expose (each backed by a per-service client library). 2. **Exercise harness** — scripted end-to-end flows that drive the family the way a real client would (issue an m2m token, run OIDC code+PKCE, mint/renew/revoke a cert, rotate a signing key, hit - the gateway's forward-auth) and **verify the result offline** — a signature against the JWKS, a - cert chain to the CA root, the gateway's allow/deny decision — reporting pass/fail/skip without - ever logging a secret. + the gateway's forward-auth, or run the whole chain identity→token→gateway in one go) and **verify + the result offline** — a signature against the JWKS, a cert chain to the CA root, the gateway's + allow/deny decision — reporting pass/fail/skip without ever logging a secret. It adds **no new authority.** Every mutation it performs is one the operator could perform by curling an admin API with the same downstream token; the console invents no new trust boundary and stores no @@ -71,9 +71,14 @@ cryptographic/state assertion verified **offline**, not just an HTTP 200. A flow | **Certificate lifecycle** | local CSR → `issue` → `renew` → `revoke` | leaf chains to the CA root; revoked serial appears in the list | `--ca-url` | | **OIDC code + PKCE** | build `/authorize` (S256) → `exchangeCode` → `userinfo` → `refresh` | id_token verifies offline; refresh rotates (old refused). *Passkey login → SKIP unless a code is supplied.* | `--oidc-url` | | **Gateway forward-auth** | `gateway.verify` with (a) no creds, (b) a bearer, (c) insufficient scope | (a) → 302/401, (b) → 200, (c) → 403 | `--gateway-url` (the bearer branches need an access token) | +| **Full chain (identity → token → gateway)** | `directory.resolve` → `idp.token` → `gateway.verify` with the minted token | identity resolves; the gateway authorizes the same token it was just issued (verified offline) | `--directory-url` + `--idp-url` + `--gateway-url` + a service-account id/secret | + +The full-chain flow is the one runnable thing that demonstrates the headline goal — **identity → +token → gateway verifies → resource** — in a single exercise, with no manual copy-paste in the seam. The Harness page can **run all** the no-credential flows at once and show a `X passed, Y skipped, Z -failed` tally; the credential-needing token flows are reported SKIP (run those individually). +failed` tally; the credential-needing token flows (m2m, signing-key rotation, full chain) are +reported SKIP (run those individually). ## The `/api` surface diff --git a/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/harness/flows/FullChainFlow.java b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/harness/flows/FullChainFlow.java new file mode 100644 index 0000000..a2a1896 --- /dev/null +++ b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/harness/flows/FullChainFlow.java @@ -0,0 +1,165 @@ +package com.codeheadsystems.miniconsole.harness.flows; + +import com.codeheadsystems.miniclient.common.ClientException; +import com.codeheadsystems.miniconsole.harness.Exercise; +import com.codeheadsystems.miniconsole.harness.ExerciseResult; +import com.codeheadsystems.miniconsole.harness.ExerciseResult.Status; +import com.codeheadsystems.miniconsole.harness.ExerciseResult.Step; +import com.codeheadsystems.minidirectory.client.MiniDirectoryClient; +import com.codeheadsystems.minidirectory.client.model.Resolution; +import com.codeheadsystems.minigateway.client.MiniGatewayClient; +import com.codeheadsystems.minigateway.client.VerifyOutcome; +import com.codeheadsystems.minigateway.client.VerifyRequest; +import com.codeheadsystems.miniidp.client.MiniIdpClient; +import com.codeheadsystems.miniidp.client.model.TokenResponse; +import java.util.ArrayList; +import java.util.List; + +/** + * The full-chain exercise: the one runnable thing that demonstrates the headline learning goal — + * identity → token → gateway verifies → resource — end to end, with no manual copy-paste in the + * seam. Every other harness flow exercises a single hop; this one threads all three services so a + * learner can watch a request earn its way to a protected upstream. + * + *

Steps, in order: + *

    + *
  1. Resolve the identity in mini-directory — prove the service account exists in the source + * of truth and see its fully-expanded grants (the thing a policy decision is made over).
  2. + *
  3. Mint a client-credentials token from mini-idp — exchange the service account's + * credentials for a short-lived, Ed25519-signed access token.
  4. + *
  5. Present that SAME token to mini-gateway's {@code /verify} — exactly as a reverse proxy + * would before forwarding to a no-auth upstream. The gateway verifies the JWS offline + * against the OP's JWKS (no call back to mini-idp) and answers {@code AUTHORIZED}. That answer + * is the headline assertion: the resource is protected by the chain, not by the upstream.
  6. + *
+ * + *

Honest SKIP. The first two steps need the operator to supply the service account's id and + * secret; without them the whole flow is reported SKIP, never a misleading PASS (mirroring the + * other credential-needing flows). + * + *

No secrets escape. The client secret and the minted access token are used only for the + * calls above and are NEVER placed in a step, the summary, or a log. Steps report only non-secret + * facts: the resolved subject, the grant count, the token type/expiry, and the gateway's decision. + */ +public final class FullChainFlow implements Exercise { + + /** The stable exercise id (used in the route and the result). */ + public static final String ID = "full-chain"; + + @Override + public String id() { + return ID; + } + + @Override + public String title() { + return "Full chain: identity → token → gateway"; + } + + @Override + public String description() { + return "Resolve a service account in mini-directory, mint a client-credentials token from " + + "mini-idp, then present that token to mini-gateway's /verify and assert it is authorized " + + "to reach a protected resource."; + } + + /** + * Run the flow. + * + * @param directory the mini-directory client (the identity source of truth). + * @param idp the mini-idp client (the machine token issuer). + * @param gateway the mini-gateway client (the forward-auth endpoint). + * @param inputs the operator-supplied inputs (a service-account id + secret, and the gated path); + * the secret is used for the single token call and never retained. + * @return the result: SKIP when no credentials were supplied, else PASS only if the gateway + * authorizes the minted token. + */ + public ExerciseResult run(final MiniDirectoryClient directory, final MiniIdpClient idp, + final MiniGatewayClient gateway, final Inputs inputs) { + final List steps = new ArrayList<>(); + final String clientId = blankToNull(inputs.clientId()); + final String clientSecret = blankToNull(inputs.clientSecret()); + final String path = blankTo(inputs.path(), "/"); + + if (clientId == null || clientSecret == null) { + steps.add(new Step("Resolve identity (mini-directory)", Status.SKIP, + "no service-account id + secret supplied — provide both to drive the whole chain")); + steps.add(new Step("Mint client-credentials token (mini-idp)", Status.SKIP, + "needs the service-account credentials above")); + steps.add(new Step("Gateway verifies the token (mini-gateway)", Status.SKIP, + "needs a token from the step above")); + return ExerciseResult.ofWithSkips(this, steps, + "Supply a service-account id + secret to drive identity → token → gateway end to end."); + } + + // Step 1 — the identity exists in the source of truth and resolves to its effective grants. + try { + final Resolution resolution = directory.resolve(clientId); + steps.add(new Step("Resolve identity (mini-directory)", Status.PASS, + "resolved " + resolution.id() + " (admin=" + resolution.admin() + ", " + + resolution.grants().size() + " effective grant(s))")); + } catch (final ClientException e) { + // No oracle: an unknown account and an unreachable directory look the same. + steps.add(new Step("Resolve identity (mini-directory)", Status.FAIL, + "the service account did not resolve or the directory was unreachable")); + return ExerciseResult.of(this, steps, "The identity did not resolve in mini-directory."); + } + + // Step 2 — mini-idp mints a client-credentials token for that identity. + final TokenResponse token; + try { + token = idp.token(clientId, clientSecret); + } catch (final ClientException e) { + // No oracle: a wrong secret, an unknown client, and an unreachable IDP all look the same. + steps.add(new Step("Mint client-credentials token (mini-idp)", Status.FAIL, + "the token request was refused or the IDP was unreachable")); + return ExerciseResult.of(this, steps, "mini-idp did not issue a token."); + } + if (token.accessToken() == null || token.accessToken().isBlank()) { + steps.add(new Step("Mint client-credentials token (mini-idp)", Status.FAIL, + "the response carried no access token")); + return ExerciseResult.of(this, steps, "mini-idp did not issue a token."); + } + steps.add(new Step("Mint client-credentials token (mini-idp)", Status.PASS, + "received a " + token.tokenType() + " token (expires in " + token.expiresIn() + "s)")); + + // Step 3 — present that SAME token to the gateway, the way a reverse proxy would. The gateway + // verifies the JWS offline against the OP's JWKS and answers AUTHORIZED — the headline of the + // whole chain: the resource is protected by the chain, not by the (no-auth) upstream. + try { + final VerifyOutcome outcome = + gateway.verify(VerifyRequest.withBearer("GET", path, token.accessToken())); + steps.add(new Step("Gateway verifies the token (mini-gateway)", + outcome == VerifyOutcome.AUTHORIZED ? Status.PASS : Status.FAIL, + "verify(" + path + ") with the minted token -> " + outcome + + (outcome == VerifyOutcome.AUTHORIZED ? "" : " (expected AUTHORIZED)"))); + } catch (final ClientException e) { + steps.add(new Step("Gateway verifies the token (mini-gateway)", Status.FAIL, + "the gateway was unreachable or returned an unexpected status")); + return ExerciseResult.of(this, steps, "The gateway did not verify the minted token."); + } + + return ExerciseResult.of(this, steps, + "Identity resolved, token minted, and the gateway authorized it — the chain protects the " + + "resource end to end."); + } + + private static String blankTo(final String value, final String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private static String blankToNull(final String value) { + return value == null || value.isBlank() ? null : value; + } + + /** + * The operator-supplied inputs for a run. The id + secret drive the directory resolution and the + * token mint (the secret is never retained); {@code path} is the gated path probed at the gateway. + * + * @param clientId the service-account id to resolve and authenticate as (blank → the flow SKIPs). + * @param clientSecret the service-account secret — used for the one token call, never retained. + * @param path the gated path to verify the minted token against (blank → {@code /}). + */ + public record Inputs(String clientId, String clientSecret, String path) { + } +} diff --git a/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/pages/HarnessPages.java b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/pages/HarnessPages.java index bad3a9c..b2cdeff 100644 --- a/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/pages/HarnessPages.java +++ b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/pages/HarnessPages.java @@ -4,6 +4,7 @@ import com.codeheadsystems.miniconsole.harness.ExerciseRegistry; import com.codeheadsystems.miniconsole.harness.ExerciseResult; import com.codeheadsystems.miniconsole.harness.flows.CertLifecycleFlow; +import com.codeheadsystems.miniconsole.harness.flows.FullChainFlow; import com.codeheadsystems.miniconsole.harness.flows.GatewayVerifyFlow; import com.codeheadsystems.miniconsole.harness.flows.KeyRotationFlow; import com.codeheadsystems.miniconsole.harness.flows.OidcCodePkceFlow; @@ -30,17 +31,20 @@ private HarnessPages() { * input (it generates its own CSR). Each exercise's form is shown only when its backend is wired, * else a note pointing at the flag that wires it. The mutating flows carry an explicit warning. * - * @param registry the registered exercises. - * @param idpAvailable whether a mini-idp client is wired (gates the idp flows). - * @param caAvailable whether a mini-ca client is wired (gates the certificate flow). - * @param oidcAvailable whether a mini-oidc client is wired (gates the OIDC flow). - * @param gatewayAvailable whether a mini-gateway client is wired (gates the gateway flow). - * @param csrf the CSRF token for the run form(s) and the nav (escaped here). + * @param registry the registered exercises. + * @param idpAvailable whether a mini-idp client is wired (gates the idp flows). + * @param caAvailable whether a mini-ca client is wired (gates the certificate flow). + * @param oidcAvailable whether a mini-oidc client is wired (gates the OIDC flow). + * @param gatewayAvailable whether a mini-gateway client is wired (gates the gateway flow). + * @param fullChainAvailable whether the directory, idp, AND gateway are all wired (gates the + * full-chain flow, which spans all three). + * @param csrf the CSRF token for the run form(s) and the nav (escaped here). * @return a complete HTML document. */ public static String list(final ExerciseRegistry registry, final boolean idpAvailable, final boolean caAvailable, final boolean oidcAvailable, - final boolean gatewayAvailable, final String csrf) { + final boolean gatewayAvailable, final boolean fullChainAvailable, + final String csrf) { final StringBuilder body = new StringBuilder(); body.append("

Run an end-to-end flow against the wired services and verify the " + "result. Credentials you enter are used for the single run and never stored.

"); @@ -59,6 +63,7 @@ public static String list(final ExerciseRegistry registry, final boolean idpAvai final boolean isCert = CertLifecycleFlow.ID.equals(exercise.id()); final boolean isOidc = OidcCodePkceFlow.ID.equals(exercise.id()); final boolean isGateway = GatewayVerifyFlow.ID.equals(exercise.id()); + final boolean isFullChain = FullChainFlow.ID.equals(exercise.id()); if (KeyRotationFlow.ID.equals(exercise.id())) { body.append("

This exercise rotates a real mini-idp signing key.

"); } else if (isCert) { @@ -70,6 +75,9 @@ public static String list(final ExerciseRegistry registry, final boolean idpAvai body.append(oidcAvailable ? oidcRunForm(exercise.id(), csrf) : requires("--oidc-url")); } else if (isGateway) { body.append(gatewayAvailable ? gatewayRunForm(exercise.id(), csrf) : requires("--gateway-url")); + } else if (isFullChain) { + body.append(fullChainAvailable ? fullChainRunForm(exercise.id(), csrf) + : requires("--directory-url, --idp-url, and --gateway-url")); } else { body.append(idpAvailable ? runForm(exercise.id(), csrf) : requires("--idp-url")); } @@ -211,6 +219,24 @@

+

+

+ + + """.replace("$ID", Layout.escape(exerciseId)).replace("$CSRF", Layout.escape(csrf)); + } + /** A note shown in place of a run form when the exercise's backend is not wired. */ private static String requires(final String flag) { return "

Requires " + Layout.escape(flag) + ".

"; diff --git a/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/server/ConsoleHandlers.java b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/server/ConsoleHandlers.java index 0a81cdb..8f3cda1 100644 --- a/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/server/ConsoleHandlers.java +++ b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/server/ConsoleHandlers.java @@ -5,6 +5,7 @@ import com.codeheadsystems.miniconsole.harness.ExerciseRegistry; import com.codeheadsystems.miniconsole.harness.ExerciseResult; import com.codeheadsystems.miniconsole.harness.flows.CertLifecycleFlow; +import com.codeheadsystems.miniconsole.harness.flows.FullChainFlow; import com.codeheadsystems.miniconsole.harness.flows.GatewayVerifyFlow; import com.codeheadsystems.miniconsole.harness.flows.KeyRotationFlow; import com.codeheadsystems.miniconsole.harness.flows.M2mTokenFlow; @@ -91,6 +92,7 @@ public final class ConsoleHandlers { private final CertLifecycleFlow certLifecycleFlow; private final OidcCodePkceFlow oidcFlow; private final GatewayVerifyFlow gatewayFlow; + private final FullChainFlow fullChainFlow; private final OpenApiDocument openApi; /** @@ -111,6 +113,7 @@ public final class ConsoleHandlers { * @param certLifecycleFlow the certificate-lifecycle flow (dispatched by its run route). * @param oidcFlow the OIDC code+PKCE flow (dispatched by its run route). * @param gatewayFlow the gateway forward-auth flow (dispatched by its run route). + * @param fullChainFlow the full-chain identity→token→gateway flow (dispatched by its run route). * @param openApi the loaded OpenAPI spec for the read-only {@code /api} JSON surface. */ public ConsoleHandlers(final ConsoleSession session, final AdminAuthenticator auth, @@ -120,7 +123,8 @@ public ConsoleHandlers(final ConsoleSession session, final AdminAuthenticator au final MiniGatewayClient gateway, final ExerciseRegistry exercises, final M2mTokenFlow m2mFlow, final KeyRotationFlow keyRotationFlow, final CertLifecycleFlow certLifecycleFlow, final OidcCodePkceFlow oidcFlow, - final GatewayVerifyFlow gatewayFlow, final OpenApiDocument openApi) { + final GatewayVerifyFlow gatewayFlow, final FullChainFlow fullChainFlow, + final OpenApiDocument openApi) { this.session = session; this.auth = auth; this.cookies = cookies; @@ -138,6 +142,7 @@ public ConsoleHandlers(final ConsoleSession session, final AdminAuthenticator au this.certLifecycleFlow = certLifecycleFlow; this.oidcFlow = oidcFlow; this.gatewayFlow = gatewayFlow; + this.fullChainFlow = fullChainFlow; this.openApi = openApi; } @@ -170,6 +175,7 @@ public Router router() { .route("POST", "/harness/cert-lifecycle/run", this::runCertLifecycle) .route("POST", "/harness/oidc-pkce/run", this::runOidcPkce) .route("POST", "/harness/gateway-verify/run", this::runGatewayVerify) + .route("POST", "/harness/full-chain/run", this::runFullChain) .route("POST", "/harness/run-all", this::runAll) // mini-oidc relying-party clients (Slice 6): list + register (one-time secret banner). .route("GET", "/clients", this::clients) @@ -495,7 +501,7 @@ private HttpResponse harness(final RequestContext context) { return htmlWithCsrf(HarnessPages.notConfigured(token), token); } return htmlWithCsrf(HarnessPages.list(exercises, idp != null, ca != null, oidc != null, - gateway != null, token), token); + gateway != null, directory != null && idp != null && gateway != null, token), token); } /** Run the machine-to-machine token flow with the operator-supplied client id + secret. */ @@ -585,6 +591,33 @@ private HttpResponse runGatewayVerify(final RequestContext context) { return htmlWithCsrf(HarnessPages.result(result, token), token); } + /** + * Run the full-chain flow (identity → token → gateway, end to end). Session-required and + * CSRF-guarded. It needs all three of mini-directory, mini-idp, and mini-gateway wired; the form + * supplies the service-account id + secret (always) and an optional gated path. Without the + * credentials the flow honestly SKIPs. The secret lives only for the run and is never stored or + * logged. + */ + private HttpResponse runFullChain(final RequestContext context) { + final HttpResponse redirect = requireSession(context); + if (redirect != null) { + return redirect; + } + final String token = csrf.mint(); + if (directory == null || idp == null || gateway == null) { + return htmlWithCsrf(HarnessPages.notConfigured(token), token); + } + final Map form = context.formParams(); + if (!csrf.verify(context.cookie(Cookies.CSRF), form.get("csrf"))) { + throw ApiException.badRequest("invalid or missing CSRF token"); + } + final FullChainFlow.Inputs inputs = new FullChainFlow.Inputs( + form.getOrDefault("clientId", ""), form.getOrDefault("clientSecret", ""), + form.getOrDefault("path", "/")); + final ExerciseResult result = fullChainFlow.run(directory, idp, gateway, inputs); + return htmlWithCsrf(HarnessPages.result(result, token), token); + } + /** * Run every exercise that can run without operator-supplied credentials and render a summary line * plus each result. Session-required and CSRF-guarded. The flows that need a per-run secret (the m2m @@ -620,6 +653,9 @@ private HttpResponse runAll(final RequestContext context) { results.add(gateway != null ? gatewayFlow.run(gateway, new GatewayVerifyFlow.Inputs("GET", "/", "", "")) : skipped(gatewayFlow, "mini-gateway is not configured (set --gateway-url)")); + // The full chain needs a service-account id + secret — report SKIP, never a fake PASS. + results.add(skipped(fullChainFlow, + "needs a service-account id + secret — run it individually from the Harness page")); return htmlWithCsrf(HarnessPages.summary(results, token), token); } @@ -686,6 +722,10 @@ private boolean exerciseAvailable(final String exerciseId) { if (GatewayVerifyFlow.ID.equals(exerciseId)) { return gateway != null; } + if (FullChainFlow.ID.equals(exerciseId)) { + // The full chain spans the directory, the IDP, and the gateway — all three must be wired. + return directory != null && idp != null && gateway != null; + } // The remaining flows (m2m token, signing-key rotation) are backed by mini-idp. return idp != null; } diff --git a/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/server/ConsoleServer.java b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/server/ConsoleServer.java index 601cdde..bb4d7c0 100644 --- a/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/server/ConsoleServer.java +++ b/services/mini-console/src/main/java/com/codeheadsystems/miniconsole/server/ConsoleServer.java @@ -2,6 +2,7 @@ import com.codeheadsystems.miniconsole.harness.ExerciseRegistry; import com.codeheadsystems.miniconsole.harness.flows.CertLifecycleFlow; +import com.codeheadsystems.miniconsole.harness.flows.FullChainFlow; import com.codeheadsystems.miniconsole.harness.flows.GatewayVerifyFlow; import com.codeheadsystems.miniconsole.harness.flows.KeyRotationFlow; import com.codeheadsystems.miniconsole.harness.flows.M2mTokenFlow; @@ -162,19 +163,21 @@ public static ConsoleServer create(final ConsoleConfig config, final String cons throws IOException { final ConsoleSession session = new ConsoleSession(config.dataDir(), clock, config.sessionTtl()); // The exercise harness: the m2m token flow (Slice 3), the signing-key rotation flow (Slice 4), - // the certificate-lifecycle flow (Slice 5), the OIDC code+PKCE flow (Slice 6), and the gateway - // forward-auth flow (Slice 7). Each flow uses the matching wired client. + // the certificate-lifecycle flow (Slice 5), the OIDC code+PKCE flow (Slice 6), the gateway + // forward-auth flow (Slice 7), and the full-chain flow (identity → token → gateway, end to end). + // Each flow uses the matching wired client(s). final M2mTokenFlow m2mFlow = new M2mTokenFlow(clock); final KeyRotationFlow keyRotationFlow = new KeyRotationFlow(clock); final CertLifecycleFlow certLifecycleFlow = new CertLifecycleFlow(); final OidcCodePkceFlow oidcFlow = new OidcCodePkceFlow(clock); final GatewayVerifyFlow gatewayFlow = new GatewayVerifyFlow(); + final FullChainFlow fullChainFlow = new FullChainFlow(); final ExerciseRegistry exercises = new ExerciseRegistry( - List.of(m2mFlow, keyRotationFlow, certLifecycleFlow, oidcFlow, gatewayFlow)); + List.of(m2mFlow, keyRotationFlow, certLifecycleFlow, oidcFlow, gatewayFlow, fullChainFlow)); final ConsoleHandlers handlers = new ConsoleHandlers( session, new AdminAuthenticator(consoleToken), new Cookies(config.secureCookies()), new Csrf(), config.sessionTtl().toSeconds(), directory, idp, keys, ca, oidc, gateway, - exercises, m2mFlow, keyRotationFlow, certLifecycleFlow, oidcFlow, gatewayFlow, + exercises, m2mFlow, keyRotationFlow, certLifecycleFlow, oidcFlow, gatewayFlow, fullChainFlow, OpenApiDocument.load()); final Router router = handlers.router(); diff --git a/services/mini-console/src/test/java/com/codeheadsystems/miniconsole/harness/flows/FullChainFlowTest.java b/services/mini-console/src/test/java/com/codeheadsystems/miniconsole/harness/flows/FullChainFlowTest.java new file mode 100644 index 0000000..bb921fb --- /dev/null +++ b/services/mini-console/src/test/java/com/codeheadsystems/miniconsole/harness/flows/FullChainFlowTest.java @@ -0,0 +1,263 @@ +package com.codeheadsystems.miniconsole.harness.flows; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codeheadsystems.miniclient.common.ClientException; +import com.codeheadsystems.miniconsole.harness.ExerciseResult; +import com.codeheadsystems.miniconsole.harness.ExerciseResult.Status; +import com.codeheadsystems.miniconsole.harness.ExerciseResult.Step; +import com.codeheadsystems.minidirectory.client.MiniDirectoryClient; +import com.codeheadsystems.minidirectory.client.model.Account; +import com.codeheadsystems.minidirectory.client.model.Assignment; +import com.codeheadsystems.minidirectory.client.model.Group; +import com.codeheadsystems.minidirectory.client.model.NewGroup; +import com.codeheadsystems.minidirectory.client.model.NewHuman; +import com.codeheadsystems.minidirectory.client.model.NewRole; +import com.codeheadsystems.minidirectory.client.model.NewServiceAccount; +import com.codeheadsystems.minidirectory.client.model.Resolution; +import com.codeheadsystems.minidirectory.client.model.Role; +import com.codeheadsystems.minidirectory.client.model.ServiceAccountCreated; +import com.codeheadsystems.minigateway.client.MiniGatewayClient; +import com.codeheadsystems.minigateway.client.VerifyOutcome; +import com.codeheadsystems.minigateway.client.VerifyRequest; +import com.codeheadsystems.miniidp.client.MiniIdpClient; +import com.codeheadsystems.miniidp.client.model.DiscoveryDocument; +import com.codeheadsystems.miniidp.client.model.RotationResult; +import com.codeheadsystems.miniidp.client.model.TokenResponse; +import com.codeheadsystems.minitoken.jwks.JwkSet; +import com.codeheadsystems.minitoken.model.AuditEntry; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link FullChainFlow} — the headline identity → token → gateway exercise — driven + * against fake clients so the flow's chaining, its honest SKIP, and its redaction contract are + * exercised without booting any service. + */ +class FullChainFlowTest { + + private static final String CLIENT = "svc_demo"; + private static final String SECRET = "super-secret-value"; + private static final String TOKEN = "eyJ.header.signature"; + + private final FullChainFlow flow = new FullChainFlow(); + + @Test + void wholeChain_resolvesMintsAndIsAuthorized_passes() { + final FakeDirectory directory = new FakeDirectory(new Resolution(CLIENT, false, List.of())); + final FakeIdp idp = new FakeIdp(new TokenResponse(TOKEN, "Bearer", 300, "billing")); + final FakeGateway gateway = new FakeGateway(VerifyOutcome.AUTHORIZED); + + final ExerciseResult result = flow.run(directory, idp, gateway, + new FullChainFlow.Inputs(CLIENT, SECRET, "/app")); + + assertEquals(Status.PASS, result.status()); + assertEquals(3, result.steps().size()); + assertTrue(result.steps().stream().allMatch(s -> s.status() == Status.PASS)); + // The same minted token reached the gateway (proving the chain is connected end to end). + assertEquals(TOKEN, gateway.lastBearer); + assertTrue(lastStep(result).detail().contains("AUTHORIZED")); + } + + @Test + void noCredentials_skipsHonestly() { + final ExerciseResult result = flow.run(new FakeDirectory(null), new FakeIdp(null), + new FakeGateway(VerifyOutcome.AUTHORIZED), new FullChainFlow.Inputs("", "", "/app")); + + assertEquals(Status.SKIP, result.status()); + assertTrue(result.steps().stream().allMatch(s -> s.status() == Status.SKIP)); + } + + @Test + void identityDoesNotResolve_failsAtFirstStep_noOracle() { + final FakeDirectory directory = new FakeDirectory(null); // resolve() throws + final ExerciseResult result = flow.run(directory, new FakeIdp(null), + new FakeGateway(VerifyOutcome.AUTHORIZED), new FullChainFlow.Inputs(CLIENT, SECRET, "/app")); + + assertEquals(Status.FAIL, result.status()); + assertEquals(1, result.steps().size()); + assertEquals("Resolve identity (mini-directory)", result.steps().get(0).label()); + } + + @Test + void gatewayDoesNotAuthorize_failsAtGatewayStep() { + final FakeDirectory directory = new FakeDirectory(new Resolution(CLIENT, false, List.of())); + final FakeIdp idp = new FakeIdp(new TokenResponse(TOKEN, "Bearer", 300, "billing")); + final FakeGateway gateway = new FakeGateway(VerifyOutcome.FORBIDDEN); + + final ExerciseResult result = flow.run(directory, idp, gateway, + new FullChainFlow.Inputs(CLIENT, SECRET, "/app")); + + assertEquals(Status.FAIL, result.status()); + assertEquals("Gateway verifies the token (mini-gateway)", lastStep(result).label()); + assertTrue(lastStep(result).detail().contains("FORBIDDEN")); + } + + @Test + void resultNeverContainsTheSecretOrTheToken() { + final FakeDirectory directory = new FakeDirectory(new Resolution(CLIENT, false, List.of())); + final FakeIdp idp = new FakeIdp(new TokenResponse(TOKEN, "Bearer", 300, "billing")); + final FakeGateway gateway = new FakeGateway(VerifyOutcome.AUTHORIZED); + + final ExerciseResult result = flow.run(directory, idp, gateway, + new FullChainFlow.Inputs(CLIENT, SECRET, "/app")); + + final String rendered = result.summary() + result.steps().stream() + .map(s -> s.label() + s.detail()).reduce("", String::concat); + assertFalse(rendered.contains(SECRET), "the client secret must never appear in the result"); + assertFalse(rendered.contains(TOKEN), "the minted access token must never appear in the result"); + } + + // ---- helpers ------------------------------------------------------------------------------- + + private static Step lastStep(final ExerciseResult result) { + return result.steps().get(result.steps().size() - 1); + } + + /** A fake directory: returns the canned resolution; {@code resolve()} throws when it is null. */ + private static final class FakeDirectory implements MiniDirectoryClient { + private final Resolution resolution; + + FakeDirectory(final Resolution resolution) { + this.resolution = resolution; + } + + @Override + public Resolution resolve(final String id) { + if (resolution == null) { + throw new ClientException("not found"); + } + return resolution; + } + + @Override + public List listAccounts() { + throw new UnsupportedOperationException(); + } + + @Override + public Account getAccount(final String id) { + throw new UnsupportedOperationException(); + } + + @Override + public List listGroups() { + throw new UnsupportedOperationException(); + } + + @Override + public List listRoles() { + throw new UnsupportedOperationException(); + } + + @Override + public com.codeheadsystems.minidirectory.client.model.HealthStatus health() { + throw new UnsupportedOperationException(); + } + + @Override + public Account createHuman(final NewHuman request) { + throw new UnsupportedOperationException(); + } + + @Override + public ServiceAccountCreated createServiceAccount(final NewServiceAccount request) { + throw new UnsupportedOperationException(); + } + + @Override + public Account updateAssignment(final String id, final Assignment request) { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteAccount(final String id) { + throw new UnsupportedOperationException(); + } + + @Override + public Group createGroup(final NewGroup request) { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteGroup(final String id) { + throw new UnsupportedOperationException(); + } + + @Override + public Role createRole(final NewRole request) { + throw new UnsupportedOperationException(); + } + + @Override + public void deleteRole(final String id) { + throw new UnsupportedOperationException(); + } + } + + /** A fake idp: returns the canned token; {@code token()} throws when it is null. */ + private static final class FakeIdp implements MiniIdpClient { + private final TokenResponse token; + + FakeIdp(final TokenResponse token) { + this.token = token; + } + + @Override + public TokenResponse token(final String clientId, final String clientSecret) { + if (token == null) { + throw new ClientException("refused"); + } + return token; + } + + @Override + public JwkSet jwks() { + throw new UnsupportedOperationException(); + } + + @Override + public DiscoveryDocument discovery() { + throw new UnsupportedOperationException(); + } + + @Override + public List audit() { + throw new UnsupportedOperationException(); + } + + @Override + public RotationResult rotateSigningKey() { + throw new UnsupportedOperationException(); + } + + @Override + public com.codeheadsystems.miniidp.client.model.HealthStatus health() { + throw new UnsupportedOperationException(); + } + } + + /** A fake gateway: returns the canned outcome and records the last bearer it was handed. */ + private static final class FakeGateway implements MiniGatewayClient { + private final VerifyOutcome outcome; + private String lastBearer; + + FakeGateway(final VerifyOutcome outcome) { + this.outcome = outcome; + } + + @Override + public VerifyOutcome verify(final VerifyRequest request) { + this.lastBearer = request.bearerToken(); + return outcome; + } + + @Override + public com.codeheadsystems.minigateway.client.model.HealthStatus health() { + throw new UnsupportedOperationException(); + } + } +} From 8740aa2ed95fb444bfa3d6b00b78145b6c98550d Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Mon, 29 Jun 2026 16:42:35 -0700 Subject: [PATCH 2/3] fix(mini-gateway): honor mini-idp machine-token grants at SCOPE routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BearerAuthenticator.toUser read authority only from the mini-oidc dialect (top-level `scope` string + `admin` flag). A mini-idp machine token carries no top-level `scope` — its authority is in a `grants` claim — so a valid machine token authenticated yet carried zero scopes/admin and could never satisfy a SCOPE route. Its authority was silently dropped. Teach toUser the second dialect: map the `grants` claim through mini-token's GrantsClaim.toAuthorization() (this is that method's first production caller), surfacing each operation as a `keyGroup:OPERATION` scope and `grants.control` as admin. An unmappable grants claim fails closed (no oracle). - ForwardAuthTest: a mini-idp token satisfies a keyGroup:OPERATION SCOPE route, is forbidden without the granted operation, and a control-plane token is allowed everywhere. - Docs synced: mini-gateway README (two dialects), honest-seams.md, CLAUDE.md, and DIRECTION.md now record that toAuthorization() has a production caller (the gateway) while the token → mini-kms path remains a designed seam. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 +- docs/DIRECTION.md | 10 ++-- docs/concepts/honest-seams.md | 14 +++-- services/mini-gateway/README.md | 23 +++++--- .../minigateway/auth/BearerAuthenticator.java | 50 ++++++++++++++++- .../minigateway/server/ForwardAuthTest.java | 56 +++++++++++++++++++ 6 files changed, 139 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 59843a6..6961458 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,8 +169,10 @@ mini-auth/ don't rename them. Preserve this mapping in mini-token / mini-policy work. **Note this is a *designed* contract, not a wired runtime path:** `KmsRequestHandler` still authenticates with a shared per-plane token + fixed principals and ships `AllowAllPolicyEngine` — it does not yet parse a JWT - or consume `grants` (`GrantsClaim.toAuthorization()` has no production caller). The token → mini-kms - authorization step is a future seam; see DIRECTION.md's "Wired vs. designed" note. + or consume `grants`. (`GrantsClaim.toAuthorization()` now *does* have a production caller, but it is + **mini-gateway**'s `BearerAuthenticator` mapping a machine token's `grants` into forward-auth scopes — + **not** mini-kms.) The token → mini-kms authorization step is a future seam; see DIRECTION.md's + "Wired vs. designed" note. - **Don't duplicate foundation code.** Argon2 hashing, the atomic-`0600` JSON store, base64url, constant-time compare, and the `ServerConfig` env/file token pattern exist in *both* shipping services today. They are catalogued as **`mini-common` extraction candidates** in diff --git a/docs/DIRECTION.md b/docs/DIRECTION.md index ddaaa76..29714dc 100644 --- a/docs/DIRECTION.md +++ b/docs/DIRECTION.md @@ -172,10 +172,12 @@ Principal.admin`, `grants.groups[]` → a per-key-group `PolicyEngine` decision mini-policy is where it is evaluated; mini-directory is where the grants originate. > **Wired vs. designed — read this before tracing the token → mini-kms path.** The mapping above is -> the *designed* contract, and the verifier half (`GrantsClaim.toAuthorization()`) exists — but it is -> **not yet the live runtime path**. Today mini-kms authenticates callers with a shared per-plane -> bearer token and two fixed principals (`KmsRequestHandler`); it does not parse a JWT or consume a -> `grants` claim, and ships `AllowAllPolicyEngine` on the data plane. So a learner who picks this headline +> the *designed* contract, and the verifier half (`GrantsClaim.toAuthorization()`) exists and now has +> a production caller — **mini-gateway**'s `BearerAuthenticator`, which maps a machine token's `grants` +> into `keyGroup:OPERATION` scopes for a forward-auth `SCOPE` decision. But it is **not yet the live +> runtime path into mini-kms**. Today mini-kms authenticates callers with a shared per-plane bearer +> token and two fixed principals (`KmsRequestHandler`); it does not parse a JWT or consume a `grants` +> claim, and ships `AllowAllPolicyEngine` on the data plane. So a learner who picks this headline > relationship to trace through running code will not find the bridge — it is a documented future > seam, not current behavior. **What is wired today:** mini-directory → mini-oidc/mini-idp identity > resolution; mini-policy decisions inside mini-oidc (scopes) and mini-gateway (routes); and the diff --git a/docs/concepts/honest-seams.md b/docs/concepts/honest-seams.md index d6bf118..bed2ef7 100644 --- a/docs/concepts/honest-seams.md +++ b/docs/concepts/honest-seams.md @@ -31,12 +31,14 @@ authorization model (`sub → Principal.id`, `grants.control → Principal.admin **shared per-plane bearer token** and two fixed principals. It does **not** parse a JWT and does **not** read `grants`. - `GrantsClaim.toAuthorization()` (`libs/mini-token/.../token/GrantsClaim.java`) — the method that - would bridge a token claim into the authorization model — has **no production caller.** Its only - caller is a test (`mini-idp` `TokenLifecycleTest`). - -**Don't teach:** "a token from mini-idp authorizes a mini-kms operation." It can't today; the bridge -exists only as a designed seam. (Stage 7's optional design exercise walks *what it would take* — and -labels itself a design exercise.) + bridges a token claim into the authorization model — *does* now have a production caller, but it is + **mini-gateway**, not mini-kms: `BearerAuthenticator` maps a machine token's `grants` into + `keyGroup:OPERATION` scopes for a forward-auth `SCOPE` decision. mini-kms still does **not** call it. + +**Don't teach:** "a token from mini-idp authorizes a mini-**kms** operation." It can't today; the +mini-kms half of the bridge exists only as a designed seam (the gateway consumes `grants`, but +mini-kms does not). (Stage 7's optional design exercise walks *what it would take* — and labels itself +a design exercise.) ## 2. mini-kms data plane ships an allow-all policy diff --git a/services/mini-gateway/README.md b/services/mini-gateway/README.md index 0b7a5af..bd5c272 100644 --- a/services/mini-gateway/README.md +++ b/services/mini-gateway/README.md @@ -75,8 +75,10 @@ uses: `session` (a live SSO session, which carries **no scopes**). Surfaced as `X-Auth-Source`. - **Route rule** — one entry in `routes.json`: a `pathPrefix`, optional `methods`, an `access` mode, and (for `SCOPE`) a required `scope`. Rules are matched **in order**; the first match wins. -- **Scope** — an OIDC scope string. The gateway expresses it as a mini-policy `Grant(Action.of(scope), - Resource.of("oidc:scope"))`, exactly as mini-oidc does, so the `SCOPE` decision uses the family's +- **Scope** — the string a `SCOPE` route requires. For a human (mini-oidc) token it is an OIDC scope + (e.g. `admin`); for a machine (mini-idp) token it is a `keyGroup:OPERATION` string derived from the + token's `grants` claim (e.g. `billing:ENCRYPT`). Either way the gateway expresses it as a mini-policy + `Grant(Action.of(scope), Resource.of("oidc:scope"))`, so the `SCOPE` decision uses the family's shared decision function rather than an ad-hoc string check. ## Architecture @@ -92,8 +94,14 @@ its own — it validates the family's sessions and tokens — so there is no adm `SessionService`. A session carries no scopes, so it satisfies `PUBLIC` / `AUTHENTICATED` routes but never a `SCOPE` route. - `BearerAuthenticator` — verifies a bearer JWS **offline** with mini-token's `JwsClaimsVerifier` - (signature, then `iss` / `aud` / expiry, with a 5-second leeway), then reads `sub`, the - space-delimited `scope` claim, and an optional `admin` flag. Any failure collapses to *empty*. + (signature, then `iss` / `aud` / expiry, with a 5-second leeway), then reads `sub` and the + caller's authority. It understands **both token dialects**: a mini-oidc (human) token carries a + space-delimited `scope` claim plus an optional `admin` flag, while a mini-idp (machine) token + carries no top-level `scope` — its authority lives in a `grants` claim. The machine token's grants + are mapped through mini-token's `GrantsClaim.toAuthorization()` and each operation surfaced as a + `keyGroup:OPERATION` scope (and `grants.control` → `admin`), so a machine token can satisfy a + `SCOPE` route, not only an `AUTHENTICATED` one. An unmappable `grants` claim **fails closed** (no + authority) and any verification failure collapses to *empty*. - `JwksProvider` (SPI) + `HttpJwksProvider` — fetches mini-oidc's `/jwks.json` and caches it for **5 minutes**, so verification stays offline between refreshes but still picks up key rotation. A fetch failure reuses the last good key set, else yields an empty set (which **fails closed**). The @@ -169,9 +177,10 @@ omitted/empty means all methods. The three access modes: - **`PUBLIC`** — always allowed, with or without a caller. - **`AUTHENTICATED`** — any valid caller (an SSO session **or** a verified bearer token). - **`SCOPE`** — a valid caller that mini-policy says holds the named `scope`. Because a session - carries no scopes, a `SCOPE` route effectively requires a **bearer token** — unless the caller is an - `admin` principal, who is permitted everything. A `SCOPE` rule **must** name a `scope` (enforced at - load). + carries no scopes, a `SCOPE` route effectively requires a **bearer token** — a mini-oidc token whose + `scope` includes it, or a mini-idp token whose `grants` cover the matching `keyGroup:OPERATION` — + unless the caller is an `admin` principal (a mini-oidc `admin` flag or a mini-idp `grants.control`), + who is permitted everything. A `SCOPE` rule **must** name a `scope` (enforced at load). With **no** `--routes-file`, the default table is a single catch-all (`/` → `AUTHENTICATED`): the whole site is gated behind login. diff --git a/services/mini-gateway/src/main/java/com/codeheadsystems/minigateway/auth/BearerAuthenticator.java b/services/mini-gateway/src/main/java/com/codeheadsystems/minigateway/auth/BearerAuthenticator.java index 37e3b9d..5db5826 100644 --- a/services/mini-gateway/src/main/java/com/codeheadsystems/minigateway/auth/BearerAuthenticator.java +++ b/services/mini-gateway/src/main/java/com/codeheadsystems/minigateway/auth/BearerAuthenticator.java @@ -1,7 +1,12 @@ package com.codeheadsystems.minigateway.auth; +import com.codeheadsystems.minitoken.auth.Authorization; +import com.codeheadsystems.minitoken.auth.Grant; +import com.codeheadsystems.minitoken.auth.KeyOperation; +import com.codeheadsystems.minitoken.token.GrantsClaim; import com.codeheadsystems.minitoken.token.JwsClaimsVerifier; import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import java.time.Clock; import java.util.ArrayList; import java.util.List; @@ -16,6 +21,16 @@ *

This is the API-client path: a service calling a gated upstream presents * {@code Authorization: Bearer } instead of a browser SSO cookie. * + *

Two token dialects. The family mints two shapes of access token, and the gateway must + * understand both. mini-oidc (humans) carries authority in a top-level space-delimited {@code scope} + * string and an {@code admin} flag. mini-idp (machines) carries no top-level {@code scope}; its + * authority lives in a {@code grants} claim — a control-plane flag plus per-key-group operations. + * {@link #toUser} reads both: a machine token's grants are mapped through mini-token's + * {@link GrantsClaim#toAuthorization()} and each operation surfaced as a {@code keyGroup:OPERATION} + * scope, so a machine token can satisfy a SCOPE route written against its key-group grants (not only + * AUTHENTICATED routes). Without this, a valid machine token would authenticate yet silently carry + * zero authority. + * *

Revocation is not consulted on this path. A bearer access token is accepted until it * expires; the gateway holds no revocation denylist and {@link JwsClaimsVerifier} takes no * {@code isRevoked} hook. This is acceptable precisely because OP access tokens are short-lived — @@ -26,6 +41,9 @@ */ public final class BearerAuthenticator { + /** Reused to lift the {@code grants} subtree into a {@link GrantsClaim} (thread-safe once built). */ + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final JwksProvider jwks; private final String expectedIssuer; private final String expectedAudience; @@ -57,6 +75,9 @@ public Optional authenticate(final String authorizationHeader private static AuthenticatedUser toUser(final JsonNode claims) { final List scopes = new ArrayList<>(); + boolean admin = false; + + // mini-oidc dialect: a top-level space-delimited `scope` string + an `admin` boolean. if (claims.has("scope")) { for (final String scope : claims.get("scope").asString().trim().split("\\s+")) { if (!scope.isBlank()) { @@ -64,7 +85,34 @@ private static AuthenticatedUser toUser(final JsonNode claims) { } } } - final boolean admin = claims.has("admin") && claims.get("admin").asBoolean(); + if (claims.has("admin") && claims.get("admin").asBoolean()) { + admin = true; + } + + // mini-idp dialect: authority lives in a `grants` claim instead (control flag + per-key-group + // operations). Map it via mini-token's GrantsClaim.toAuthorization() — this method's first + // production caller — and surface each operation as a `keyGroup:OPERATION` scope so a SCOPE route + // can gate on a machine token's key-group grants. A grants claim we cannot map (a typo'd + // operation, malformed JSON) fails closed: no added scopes, no admin — never an exception out of + // this method, so the gateway stays oracle-free and fails safe. + if (claims.has("grants") && !claims.get("grants").isNull()) { + try { + final Authorization authorization = + MAPPER.treeToValue(claims.get("grants"), GrantsClaim.class).toAuthorization(); + admin = admin || authorization.controlPlane(); + for (final Grant grant : authorization.grants()) { + for (final KeyOperation operation : grant.operations()) { + scopes.add(grant.keyGroup() + ":" + operation.name()); + } + } + } catch (final RuntimeException ignored) { + // Fail closed: an unmappable grants claim (typo'd operation, malformed JSON) confers no + // grant-derived authority. The mapping throws during deserialization/toAuthorization, before + // any grant scope is added, so the oidc-dialect scopes/admin collected above stand untouched. + // We deliberately swallow the reason — surfacing it would be an oracle. + } + } + return new AuthenticatedUser(claims.get("sub").asString(), admin, scopes, "token"); } } diff --git a/services/mini-gateway/src/test/java/com/codeheadsystems/minigateway/server/ForwardAuthTest.java b/services/mini-gateway/src/test/java/com/codeheadsystems/minigateway/server/ForwardAuthTest.java index 026909f..5c42d51 100644 --- a/services/mini-gateway/src/test/java/com/codeheadsystems/minigateway/server/ForwardAuthTest.java +++ b/services/mini-gateway/src/test/java/com/codeheadsystems/minigateway/server/ForwardAuthTest.java @@ -5,11 +5,15 @@ import com.codeheadsystems.minigateway.auth.JwksProvider; import com.codeheadsystems.minigateway.store.JsonStore; +import com.codeheadsystems.minitoken.auth.Authorization; +import com.codeheadsystems.minitoken.auth.Grant; +import com.codeheadsystems.minitoken.auth.KeyOperation; import com.codeheadsystems.minitoken.service.SigningKeyService; import com.codeheadsystems.minitoken.service.SigningKeyService.Signer; import com.codeheadsystems.minitoken.session.SessionService; import com.codeheadsystems.minitoken.session.Sessions; import com.codeheadsystems.minitoken.store.TokenStoreDocuments.SigningKeys; +import com.codeheadsystems.minitoken.token.GrantsClaim; import com.codeheadsystems.minitoken.token.Jws; import com.codeheadsystems.minitoken.token.JwsHeader; import com.codeheadsystems.minitoken.util.RandomIds; @@ -24,6 +28,7 @@ import java.time.Clock; import java.time.Duration; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -58,10 +63,14 @@ void setUp(@TempDir final Path dir) throws IOException { new RandomIds(), clock, Duration.ofMinutes(30)); final Path routes = dir.resolve("routes.json"); + // The /kms route is gated on a mini-idp machine-token scope (keyGroup:OPERATION) — the dialect a + // grants claim maps to — so the SCOPE branch can be exercised by a machine token, not just an + // OIDC-shaped one. Files.writeString(routes, """ {"routes":[ {"pathPrefix":"/public","access":"PUBLIC"}, {"pathPrefix":"/admin","access":"SCOPE","scope":"admin"}, + {"pathPrefix":"/kms","access":"SCOPE","scope":"billing:ENCRYPT"}, {"pathPrefix":"/","access":"AUTHENTICATED"} ]}"""); @@ -143,6 +152,38 @@ void bearerTokenForTheWrongAudienceIsRejected() throws Exception { assertEquals(401, response.statusCode(), "a wrong-audience token does not authenticate"); } + @Test + void miniIdpMachineTokenSatisfiesAScopeRouteFromItsGrantsClaim() throws Exception { + // A mini-idp machine token carries NO top-level `scope` — its authority is in the `grants` claim. + // The gateway maps grants → keyGroup:OPERATION scopes, so this token satisfies the /kms SCOPE + // route (billing:ENCRYPT). Before the dialect fix it authenticated but carried zero scopes. + final String token = mintMachineToken("svc-kms", new Authorization(false, + List.of(Grant.of("billing", KeyOperation.ENCRYPT, KeyOperation.DECRYPT)))); + final HttpResponse response = verify("GET", "/kms/encrypt", Map.of( + "Authorization", "Bearer " + token, "Accept", "application/json")); + assertEquals(200, response.statusCode(), "the machine token's grant covers billing:ENCRYPT"); + assertEquals("svc-kms", response.headers().firstValue("X-Auth-Subject").orElse(null)); + } + + @Test + void miniIdpMachineTokenWithoutTheGrantedOperationIsForbidden() throws Exception { + // The same machine identity, but only DECRYPT on billing — it must NOT satisfy billing:ENCRYPT. + final String token = mintMachineToken("svc-kms", new Authorization(false, + List.of(Grant.of("billing", KeyOperation.DECRYPT)))); + final HttpResponse response = verify("GET", "/kms/encrypt", Map.of( + "Authorization", "Bearer " + token, "Accept", "application/json")); + assertEquals(403, response.statusCode(), "DECRYPT does not cover the route's billing:ENCRYPT"); + } + + @Test + void miniIdpControlPlaneTokenIsAllowedEverywhere() throws Exception { + // grants.control → admin: a control-plane machine token is allowed at any SCOPE route. + final String token = mintMachineToken("svc-root", new Authorization(true, List.of())); + final HttpResponse response = verify("GET", "/admin/panel", Map.of( + "Authorization", "Bearer " + token, "Accept", "application/json")); + assertEquals(200, response.statusCode(), "grants.control maps to admin, allowed everywhere"); + } + // ---- helpers ------------------------------------------------------------------------------- private String mintAccessToken(final String subject, final String scope, final String audience) { @@ -159,6 +200,21 @@ private String mintAccessToken(final String subject, final String scope, final S return Jws.sign(JwsHeader.forKid(signer.kid()), claims, signer.privateKey()); } + /** Mint a mini-idp-shaped machine token: authority in a {@code grants} claim, no top-level scope. */ + private String mintMachineToken(final String subject, final Authorization authorization) { + final long now = clock.instant().getEpochSecond(); + final Map claims = new LinkedHashMap<>(); + claims.put("iss", ISSUER); + claims.put("sub", subject); + claims.put("aud", AUDIENCE); + claims.put("grants", GrantsClaim.from(authorization)); + claims.put("iat", now); + claims.put("nbf", now); + claims.put("exp", now + 300); + final Signer signer = signingKeys.currentSigner(); + return Jws.sign(JwsHeader.forKid(signer.kid()), claims, signer.privateKey()); + } + private HttpResponse verify(final String method, final String uri, final Map headers) throws Exception { final HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + "/verify")) From e4d5dd8bda232873cca682367debf1842e486821 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Mon, 29 Jun 2026 16:49:28 -0700 Subject: [PATCH 3/3] docs: add passkeys/WebAuthn concept doc and make lab 04 completable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passkeys/WebAuthn was the thinnest-scaffolded major topic — no concept doc, no glossary entries — and lab 04 (the human-SSO payoff) degraded into prose because the ceremony can't be done with curl. - New docs/concepts/what-a-passkey-is.md, parallel in style to what-a-token-is.md: public-key credential, registration vs authentication ceremony, challenge– response, phishing resistance (origin/RP-ID + challenge binding), attestation vs assertion, where pk-auth fits, ending with a "Now read it" box into mini-oidc's auth/ package. - GLOSSARY.md: new "Passkeys: WebAuthn" section (WebAuthn, passkey, challenge, assertion, attestation, RP-ID, origin, pk-auth). - docs/examples/passkey-enroll.js: a DevTools-Console enrolment script that runs the real registration ceremony in mini-oidc's origin (a standalone page would fail the origin check) — mirrors mini-oidc's own login-page plumbing. - Lab 04 is now copy-paste completable end to end: registers a public PKCE client, enrols a passkey via the helper + a virtual authenticator, logs in on mini-oidc's served page, and exchanges the code (public-client form, no client secret). - Wired the concept doc into the TEACHING.md stage ladder (stage 3.5) and cross-linked it from oauth-and-oidc-flows.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/GLOSSARY.md | 35 ++++++ docs/TEACHING.md | 1 + docs/concepts/oauth-and-oidc-flows.md | 2 +- docs/concepts/what-a-passkey-is.md | 130 ++++++++++++++++++++++ docs/examples/passkey-enroll.js | 63 +++++++++++ docs/tutorials/04-human-sso-end-to-end.md | 72 ++++++++---- 6 files changed, 281 insertions(+), 22 deletions(-) create mode 100644 docs/concepts/what-a-passkey-is.md create mode 100644 docs/examples/passkey-enroll.js diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index d2f2c37..81f3511 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -120,6 +120,41 @@ The token plane lives in **mini-token** (`libs/mini-token`); the issuers are **m --- +## Passkeys: WebAuthn + +Human authentication lives in **mini-oidc** (`services/mini-oidc`), which embeds **pk-auth** to run +the ceremonies. A passkey replaces a shared secret with a key pair, so there is nothing secret on the +server to phish or breach. + +- **WebAuthn** — the W3C browser standard (exposed via `navigator.credentials.create`/`get`) that lets + a site register and use public-key credentials, paired with CTAP (the protocol to the authenticator + device). It is the mechanism behind passkeys. +- **Passkey** — the consumer-friendly name for a WebAuthn credential: a per-site key pair whose + **private** half stays in the device's secure hardware and whose **public** half is all the server + stores. mini-oidc's humans authenticate with one (`auth/PkAuthHumanAuthenticator`). +- **Challenge** — a fresh random value the server issues at the start of each ceremony; the + authenticator must sign *it* (challenge–response), so a captured response can't be replayed. (The + same anti-replay idea as a crypto nonce, but issued per login attempt.) +- **Assertion** — the signed response produced at **login** (`navigator.credentials.get`): a signature + over the challenge (plus context) proving possession of the private key. mini-oidc reads the + authenticated user handle off the *verified* assertion and nothing else (`finishAssertion`). +- **Attestation** — the signed statement produced once at **registration** + (`navigator.credentials.create`): it certifies the new key and the authenticator that made it. + (Distinct from an assertion, which is produced every login.) +- **RP-ID (Relying Party ID)** — the domain a credential is bound to (e.g. `oidc.example`). The browser + fires a credential only when the page's origin matches the RP-ID, and the server checks it on + verification — the binding that makes a credential unusable on a look-alike site. mini-oidc sets it + with `--rp-id`. +- **Origin** — the scheme + host + port of the page running the ceremony (e.g. + `https://oidc.example`), stamped into the signed data and verified server-side against the + configured `--rp-origin`. The origin binding (with the RP-ID) is what makes WebAuthn **phishing + resistant**: a forwarded ceremony fails because the signed origin is wrong. +- **pk-auth** — the external passkey library (`com.codeheadsystems:pk-auth-core`, WebAuthn4J under the + hood) mini-oidc embeds to run both ceremonies and store credentials behind swappable SPIs. It is + *not* vendored — see `auth/PasskeyStack`. + +--- + ## Identity & authorization The decision model is **mini-policy** (`libs/mini-policy`); the identity source of truth is diff --git a/docs/TEACHING.md b/docs/TEACHING.md index 757bca5..4d97ed4 100644 --- a/docs/TEACHING.md +++ b/docs/TEACHING.md @@ -56,6 +56,7 @@ proves it. Do them in order and each one earns the next. (This is a different or | **1** | See a decision as a pure function; explain deny-by-default | [`authorization-model`](concepts/authorization-model.md) | [`01-resolve-a-principal`](tutorials/01-resolve-a-principal.md) | | **2** | Say what a signed token *is* and verify one offline, by hand | [`what-a-token-is`](concepts/what-a-token-is.md) | [`02-build-and-verify-a-token-by-hand`](tutorials/02-build-and-verify-a-token-by-hand.md) ← **keystone** | | **3** | Trace a machine identity end to end | [`oauth-and-oidc-flows`](concepts/oauth-and-oidc-flows.md) | [`03-machine-identity-end-to-end`](tutorials/03-machine-identity-end-to-end.md) | +| **3.5** | Explain how a passkey proves identity without a shared secret | [`what-a-passkey-is`](concepts/what-a-passkey-is.md) | *(see it in [`04`](tutorials/04-human-sso-end-to-end.md))* | | **4** | Run a human SSO login: PKCE, passkeys, sessions, refresh | [`sessions-vs-tokens`](concepts/sessions-vs-tokens.md) | [`04-human-sso-end-to-end`](tutorials/04-human-sso-end-to-end.md) | | **5** | Gate a no-auth app via forward-auth | *(reuse stage 4)* | [`05-gate-a-no-auth-app`](tutorials/05-gate-a-no-auth-app.md) | | **6** | Explain how the family protects its own keys | [`envelope-encryption-and-kms`](concepts/envelope-encryption-and-kms.md) | [`06-protect-the-signing-keys`](tutorials/06-protect-the-signing-keys.md) | diff --git a/docs/concepts/oauth-and-oidc-flows.md b/docs/concepts/oauth-and-oidc-flows.md index ede56d4..7659b70 100644 --- a/docs/concepts/oauth-and-oidc-flows.md +++ b/docs/concepts/oauth-and-oidc-flows.md @@ -13,7 +13,7 @@ actor.** | | **client-credentials** | **authorization-code + PKCE** | | --- | --- | --- | | Actor | a machine (service account) | a human, via a browser | -| Proves identity with | `client_secret` | a passkey (WebAuthn) | +| Proves identity with | `client_secret` | a passkey (WebAuthn) — see [`what-a-passkey-is`](what-a-passkey-is.md) | | Steps | one POST | a multi-step browser redirect dance | | Issuer | mini-idp | mini-oidc | | Returns | access token | **ID token** + access token (+ refresh) | diff --git a/docs/concepts/what-a-passkey-is.md b/docs/concepts/what-a-passkey-is.md new file mode 100644 index 0000000..aa025d9 --- /dev/null +++ b/docs/concepts/what-a-passkey-is.md @@ -0,0 +1,130 @@ +# What a passkey *is* — login without a shared secret + +> **Concept doc (explanation).** Stage 3.5 (the human-authentication half of stage 4). Anchored on +> **mini-oidc** (which embeds **pk-auth**). New terms link to [`GLOSSARY.md`](../GLOSSARY.md); the +> rationale for embedding pk-auth lives in [`DIRECTION.md`](../DIRECTION.md). Pairs with the lab +> [`tutorials/04-human-sso-end-to-end.md`](../tutorials/04-human-sso-end-to-end.md), where a person +> logs in with a passkey for real. Read [`what-a-token-is.md`](what-a-token-is.md) first — a passkey +> proves *who you are*; the token is what mini-oidc mints *after* that proof. + +If you take one idea from this doc, take this: + +> **A passkey is a key pair. The private half never leaves your device; the server only ever stores +> the public half. "Logging in" means the server sends a random challenge, your device signs it with +> the private key, and the server checks the signature against the public key it stored at sign-up — +> the same offline signature check a token uses, pointed at *authentication* instead of +> *authorization*.** + +There is no shared secret to phish, reuse, or leak from a breached database. That single property is +why passkeys exist. + +--- + +## The problem passkeys solve + +A password is a **shared secret**: you know it, the server stores a hash of it, and anyone who learns +it *is* you. That makes passwords vulnerable to the whole zoo — reuse across sites, phishing pages +that capture what you type, and database breaches that leak millions of hashes to crack offline. + +A passkey replaces the shared secret with **public-key cryptography** ([asymmetric +crypto](../GLOSSARY.md#cryptographic-foundations)). At sign-up your device generates a fresh key pair +*for this one site*. It keeps the **private key** (in secure hardware — a TPM, a Secure Enclave, a +security key) and hands the site only the **public key**. The site stores that public key against +your account. There is now **nothing secret on the server** to steal: a breach leaks public keys, +which are useless to an attacker. + +This is [WebAuthn](../GLOSSARY.md#passkeys-webauthn) — the browser standard (W3C) that exposes this +to web pages via `navigator.credentials` — together with CTAP, the protocol to the authenticator +device. "Passkey" is the consumer-friendly name for a WebAuthn credential. + +--- + +## Two ceremonies: registration and authentication + +WebAuthn has exactly two flows, and they mirror each other. Both are **challenge–response**: the +server issues a fresh random [challenge](../GLOSSARY.md#passkeys-webauthn) and the device answers in a +way only the right private key can. + +### Registration (sign-up) — `navigator.credentials.create()` + +1. The server sends **creation options**: a random challenge, the [RP-ID](../GLOSSARY.md#passkeys-webauthn) + (which site this credential is for), and your user id. +2. Your authenticator **generates a new key pair**, stores the private key, and returns the **public + key** plus an **[attestation](../GLOSSARY.md#passkeys-webauthn)** — a signed statement, *"I, a + genuine authenticator, just created this key for this challenge and this site."* +3. The server verifies the attestation and **stores the public key** against your account. + +### Authentication (login) — `navigator.credentials.get()` + +1. The server sends a fresh random challenge. +2. Your authenticator **signs the challenge** (plus some context) with the stored private key, + producing an **[assertion](../GLOSSARY.md#passkeys-webauthn)**. +3. The server **verifies the signature** against the public key it stored at registration. Match → + you're authenticated. + +> **Attestation vs. assertion** — the two are easy to confuse. *Attestation* is produced once, at +> **registration**, and certifies *the authenticator and the new key*. *Assertion* is produced every +> **login**, and proves *possession of the private key for this challenge*. mini-oidc reads the +> verified assertion's user handle to learn who just logged in — and nothing else from pk-auth. + +--- + +## Why a passkey resists phishing + +This is the part passwords can never match, and it comes from two bindings the browser enforces — not +the user's vigilance. + +- **Origin/RP-ID binding.** The browser will only invoke a credential whose RP-ID matches the + [origin](../GLOSSARY.md#passkeys-webauthn) of the page actually in the address bar, and it stamps + that origin into the signed data. A look-alike phishing page at `οidc.example` (a Cyrillic + homograph) has a *different* origin, so the genuine credential simply will not fire there — and even + a forwarded ceremony fails verification because the origin in the signature is wrong. There is no + secret for the user to be tricked into typing into the wrong box, because **there is no secret to + type at all.** +- **Challenge binding.** Each login signs a *fresh server-issued challenge*, so a captured assertion + can't be replayed — it answered a one-time question. (Same idea as a token's short `exp`, enforced + cryptographically per attempt.) + +Compare this to a token (stage 2): a token's signature is checked by the *resource server* to trust a +*claim set*; a passkey's signature is checked by the *identity provider* to trust *the person at the +keyboard*. Same primitive — a signature over reproducible bytes, verified against a stored public key +— aimed at a different job. + +--- + +## Where pk-auth fits (and where mini-oidc takes over) + +mini-oidc does **not** hand-roll WebAuthn — the verification (attestation formats, signature counters, +origin checks via WebAuthn4J under the hood) is exactly the kind of crypto the "mini" ethos says to +get from a vetted library. So mini-oidc **embeds [pk-auth](../GLOSSARY.md#passkeys-webauthn)** to run +both ceremonies and to store credentials behind swappable SPIs. + +The boundary is deliberate: pk-auth proves *the human is who they claim*, and mini-oidc reads only the +authenticated **user handle** off the verified assertion — it then resolves that human in +mini-directory and mints its **own** ID/access tokens through mini-token. mini-oidc never consumes +pk-auth's own JWT. So the passkey is the *front door*; everything past it is the token plane you +already know. + +> **Honest seam.** In mini-oidc today, passkey **enrolment** (`/register/passkey/**`) is +> *unauthenticated self-enrolment* — anyone can enrol a credential for a username. A real deployment +> gates enrolment behind an existing session or an invite. See +> [`honest-seams.md`](honest-seams.md#3); the lab calls this out where you hit it. + +--- + +## Now read it + +- **The human-authentication seam:** `services/mini-oidc` → `auth/HumanAuthenticator` (the SPI: a + `startRegistration`/`startAssertion` → `finish` pair returning a `Challenge`), + `auth/PkAuthHumanAuthenticator` (the pk-auth implementation — note it reads the authenticated + `userHandle` off the verified assertion, never pk-auth's JWT), and `auth/PasskeyStack` (assembles + the embedded pk-auth stack over its in-memory SPIs — the documented swap point for persistent + credential storage). +- **Recovery:** `auth/RecoveryAuthenticator` (backup codes, for a lost authenticator). +- **The browser side:** `services/mini-oidc` → `server/LoginPages` (the minimal login page whose + inline JS does the base64url ↔ ArrayBuffer plumbing and calls `navigator.credentials.get`). + +Now do the lab — [`04-human-sso-end-to-end.md`](../tutorials/04-human-sso-end-to-end.md): enrol a +passkey with a virtual authenticator, log in for real, and watch mini-oidc mint the tokens from +stage 2 *after* the passkey proves who you are. Then continue to stage 4, +[`sessions-vs-tokens.md`](sessions-vs-tokens.md). diff --git a/docs/examples/passkey-enroll.js b/docs/examples/passkey-enroll.js new file mode 100644 index 0000000..b6cb81c --- /dev/null +++ b/docs/examples/passkey-enroll.js @@ -0,0 +1,63 @@ +// passkey-enroll.js — enrol a passkey for mini-oidc from the browser DevTools Console. +// +// WHY a console snippet (and not a standalone .html file): a WebAuthn ceremony is bound to the page's +// ORIGIN, and mini-oidc's server verifies that origin against its configured --rp-origin. Code pasted +// into the DevTools Console runs *in the page's origin*, so running this while a mini-oidc page is +// open makes the origin match automatically — a static file opened from disk (origin "null") or a +// different port would fail server-side verification. (This is exactly why mini-oidc serves its own +// login page; enrolment just has no shipped UI yet — an honest seam.) +// +// HOW TO USE (lab 04, step "Enrol a passkey"): +// 1. Start mini-oidc (see the lab). Open ANY mini-oidc page in Chrome at its origin, e.g. +// http://127.0.0.1:8477/docs +// 2. Open DevTools → ⋮ (More tools) → WebAuthn → "Enable virtual authenticator environment", +// then "Add authenticator" (defaults are fine: ctap2 / internal / resident-key + user-verif on). +// The virtual authenticator stands in for real hardware, so the ceremony needs no security key. +// 3. Open the Console, paste this whole file, and call: enrolPasskey('alice', 'Alice') +// A "registered: true" log means the passkey is stored for that username. Now do the browser +// login at the /authorize URL the lab builds. +// +// NOTE: this mirrors the base64url ↔ ArrayBuffer plumbing in mini-oidc's own login page +// (server/LoginPages). A real deployment uses pk-auth's published browser SDK +// (@pk-auth/passkeys-browser) instead of hand-rolling it. + +async function enrolPasskey(username = 'alice', displayName = 'Alice') { + const b64uToBuf = s => + Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)).buffer; + const bufToB64u = b => + btoa(String.fromCharCode(...new Uint8Array(b))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + + // 1. Ask mini-oidc for creation options (a fresh challenge + this user's id, RP-ID, etc.). + const started = await (await fetch('/register/passkey/start', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, displayName }), + })).json(); + + // 2. Decode the server's base64url fields into the ArrayBuffers the WebAuthn API wants. + const pk = started.publicKey; + pk.challenge = b64uToBuf(pk.challenge); + pk.user.id = b64uToBuf(pk.user.id); + (pk.excludeCredentials || []).forEach(c => { c.id = b64uToBuf(c.id); }); + + // 3. The authenticator generates a key pair and returns the public key + attestation. + const cred = await navigator.credentials.create({ publicKey: pk }); + + // 4. Re-encode the attestation response and hand it back for verification + storage. + const registration = { + id: cred.id, + rawId: bufToB64u(cred.rawId), + type: cred.type, + response: { + clientDataJSON: bufToB64u(cred.response.clientDataJSON), + attestationObject: bufToB64u(cred.response.attestationObject), + }, + clientExtensionResults: cred.getClientExtensionResults(), + }; + const res = await fetch('/register/passkey/finish', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challengeId: started.challengeId, username, registration }), + }); + console.log('enrol', username, '→', res.status, await res.json()); + return res.ok; +} diff --git a/docs/tutorials/04-human-sso-end-to-end.md b/docs/tutorials/04-human-sso-end-to-end.md index b62fc1a..7f2bd51 100644 --- a/docs/tutorials/04-human-sso-end-to-end.md +++ b/docs/tutorials/04-human-sso-end-to-end.md @@ -4,14 +4,16 @@ > **mini-oidc** with a **passkey**, gets ID + access tokens, and establishes a browser **SSO session** > the next lab reuses. > -> **Concepts:** [`oauth-and-oidc-flows.md`](../concepts/oauth-and-oidc-flows.md) + +> **Concepts:** [`what-a-passkey-is.md`](../concepts/what-a-passkey-is.md) + +> [`oauth-and-oidc-flows.md`](../concepts/oauth-and-oidc-flows.md) + > [`sessions-vs-tokens.md`](../concepts/sessions-vs-tokens.md). **Diagram:** > [`auth-code-pkce`](../diagrams/auth-code-pkce.md). > > **⚠ One step needs a real browser.** The passkey ceremony (WebAuthn) cannot be done with `curl` — > it needs a browser with a **platform authenticator or a virtual authenticator** (Chrome DevTools → > *WebAuthn* tab works well). Everything *around* it is shown with `curl` below; the ceremony itself -> is a browser step. This lab is honest about that seam rather than faking an assertion. +> is a browser step, driven by the virtual authenticator + the helper script in step 3 — so the lab is +> still **completable end to end**. This lab is honest about the seam rather than faking an assertion. ## 1. Start mini-oidc (with a directory) @@ -36,6 +38,15 @@ services/mini-oidc/build/install/mini-oidc/bin/mini-oidc \ --directory-url http://127.0.0.1:8466 & O="http://127.0.0.1:8477" + +# Register a PUBLIC (PKCE-only) relying-party client and capture its id. Public means no client +# secret — the PKCE verifier is what proves the token request came from the app that started the flow. +REDIRECT="http://127.0.0.1:8477/" # any registered URI; the browser lands here with ?code=… +CLIENT_ID=$(curl -fsS -X POST "$O/admin/clients" \ + -H "Authorization: Bearer $MINIOIDC_ADMIN_TOKEN" -H 'Content-Type: application/json' \ + -d "{\"name\":\"Demo App\",\"redirectUris\":[\"$REDIRECT\"],\"scopes\":[\"openid\",\"profile\",\"email\"],\"confidential\":false}" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["clientId"])') +echo "client_id = $CLIENT_ID" ``` > If you omit `--directory-url`, mini-oidc prints *"No --directory-url configured: using an empty @@ -69,35 +80,53 @@ curl -fsS "$O/jwks.json" # { "keys": [ { "kty":"OKP","crv":"Ed25519","x":"…" ## 3. The browser flow (with a passkey) Now the part that needs a browser. Walk the [auth-code+PKCE diagram](../diagrams/auth-code-pkce.md) -alongside these steps. +alongside these steps. Use **Chrome** (or any Chromium browser) for the DevTools virtual authenticator. + +**First, generate the PKCE pair** (you'll need the verifier again at the token step): + +```bash +VERIFIER=$(openssl rand -base64 60 | tr '+/' '-_' | tr -d '=\n') +CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | base64 | tr '+/' '-_' | tr -d '=\n') +echo "verifier saved; challenge = $CHALLENGE" +``` -1. **Enrol a passkey** (first time only). Open `POST /register/passkey/start` / `/finish` from a small - page, or use the `/docs` UI. With Chrome DevTools' *WebAuthn* tab, enable a **virtual - authenticator** first so you don't need real hardware. +1. **Turn on a virtual authenticator and enrol a passkey for `alice`.** Open a mini-oidc page in + Chrome — `$O/docs` works — then **DevTools → ⋮ More tools → WebAuthn →** *Enable virtual + authenticator environment* → *Add authenticator* (the defaults are fine). The virtual authenticator + replaces real hardware. Now open the **Console**, paste + [`examples/passkey-enroll.js`](../examples/passkey-enroll.js), and run: + ```js + enrolPasskey('alice', 'Alice') // logs: enrol alice → 201 {registered: true} + ``` + The script must run **on a mini-oidc page** so the WebAuthn ceremony uses mini-oidc's origin (the + script's header comment explains why a standalone file would fail). > Enrolment here is *unauthenticated self-enrolment* — honesty seam > [#3](../concepts/honest-seams.md#3). A real deployment gates it. -2. **Start the flow.** Navigate the browser to `/authorize` with a PKCE challenge. Generate a - verifier/challenge pair first: +2. **Start the flow.** Build the authorize URL with your `$CLIENT_ID`, `$REDIRECT`, and `$CHALLENGE`, + then open it in the same browser tab: ```bash - VERIFIER=$(openssl rand -base64 60 | tr '+/' '-_' | tr -d '=\n') - CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | base64 | tr '+/' '-_' | tr -d '=\n') - echo "$O/authorize?client_id=&redirect_uri=&response_type=code&scope=openid%20profile&state=xyz&nonce=n1&code_challenge=$CHALLENGE&code_challenge_method=S256" + echo "$O/authorize?client_id=$CLIENT_ID&redirect_uri=$REDIRECT&response_type=code&scope=openid%20profile&state=xyz&nonce=n1&code_challenge=$CHALLENGE&code_challenge_method=S256" ``` - (Register a client first via `POST /admin/clients` with the admin token; see `/docs` for the body.) -3. mini-oidc serves a **login page** (no session yet) → you complete the **passkey** ceremony - (`/login/passkey/start` → `/login/passkey/finish`). On success it **sets the session cookie** - `mioidc_session` (HttpOnly, SameSite=Lax) and redirects to `/authorize/continue`. -4. **Consent** → `POST /authorize/decision` → you're redirected to your `redirect_uri?code=…&state=xyz`. +3. mini-oidc serves a **login page** (no session yet). Enter `alice` and click **Sign in with + passkey** — the page's JS runs the assertion ceremony (`/login/passkey/start` → + `/login/passkey/finish`) and the **virtual authenticator answers automatically**. On success + mini-oidc **sets the session cookie** `mioidc_session` (HttpOnly, SameSite=Lax) and continues to + `/authorize/continue`. +4. **Consent** → click **Allow** (`POST /authorize/decision`) → the browser redirects to + `$REDIRECT?code=…&state=xyz`. The target page may show a 404 — that's fine; **copy the `code` + value straight out of the address bar.** ## 4. Exchange the code (back to curl) The browser handed your app a **code**. The app redeems it on the back-channel with the **PKCE -verifier** from step 2: +verifier** from step 3. Paste the code from the address bar: ```bash +CODE="" curl -fsS -X POST "$O/token" \ - -d "grant_type=authorization_code&code=&redirect_uri=&code_verifier=$VERIFIER" \ - -u ":" # confidential client; public clients omit -u and rely on PKCE + -d "grant_type=authorization_code&code=$CODE&redirect_uri=$REDIRECT&client_id=$CLIENT_ID&code_verifier=$VERIFIER" \ + | python3 -m json.tool +# (Public/PKCE client: no -u. A confidential client would instead authenticate with -u "id:secret".) ``` ```json { "access_token": "eyJ…", "id_token": "eyJ…", "refresh_token": "rt_…", "token_type": "Bearer", @@ -117,10 +146,11 @@ curl -fsS -X POST "$O/token" \ Trade the refresh token for a fresh pair, then **replay the old one** and predict what happens: ```bash +RT_OLD="" # rotate once — get a NEW refresh_token back -curl -fsS -X POST "$O/token" -d "grant_type=refresh_token&refresh_token=" -u ":" +curl -fsS -X POST "$O/token" -d "grant_type=refresh_token&refresh_token=$RT_OLD&client_id=$CLIENT_ID" | python3 -m json.tool # now replay the SAME old token again: -curl -s -w "\nHTTP %{http_code}\n" -X POST "$O/token" -d "grant_type=refresh_token&refresh_token=" -u ":" +curl -s -w "\nHTTP %{http_code}\n" -X POST "$O/token" -d "grant_type=refresh_token&refresh_token=$RT_OLD&client_id=$CLIENT_ID" ``` **Predict:** the replay is rejected — *and* it revokes the **whole family**, so even the *new* refresh