diff --git a/CLAUDE.md b/CLAUDE.md
index 3f62333..515edc7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -264,7 +264,7 @@ This is the only path override in round one. Other Critter Stack reference archi
### Architectural non-negotiables
-- Four separate services (Catalog, Inventory, Orders, Identity); Identity is a real EF-Core-backed customer registry (Open-Host Service + Published Language) but carries no authN/authZ — the frontend still sends a hardcoded `X-Customer-Id` (ADR 009)
+- Four separate services (Catalog, Inventory, Orders, Identity); Identity is a real EF-Core-backed customer registry (Open-Host Service + Published Language) **and** the system's auth issuer — it uses ASP.NET Core Identity + mints a self-validated JWT the other services verify offline against a config-distributed public key (ADR 023). The frontend now authenticates and sends `Authorization: Bearer`; the `sub` claim is the trust boundary. `X-Customer-Id` survives only as a dev-only fallback on Orders (DEBT-tracked for removal). AuthZ (roles/policies) is still deferred (ADR 009/023)
- Cross-service communication via Wolverine over RabbitMQ; no synchronous service-to-service HTTP
- Shared PostgreSQL with schema-per-service
- Process Manager via Handlers pattern for the Order aggregate (ADR 007); convention `Wolverine.Saga` is an additive counterpart used elsewhere (Inventory's `Replenishment`, slices 2.5–2.7), not a replacement
diff --git a/Directory.Packages.props b/Directory.Packages.props
index c86647c..8cdb881 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -40,11 +40,20 @@
+ (the persistence-agnostic teaching beat; a data store per ADR 009). .NET 10 / EF Core 10 line. -->
+
+
+
+
+
diff --git a/client/src/api/client.test.ts b/client/src/api/client.test.ts
index b2618aa..9853174 100644
--- a/client/src/api/client.test.ts
+++ b/client/src/api/client.test.ts
@@ -3,43 +3,58 @@ import { z } from "zod";
import {
ApiError,
- CUSTOMER_ID_HEADER,
NotFoundError,
deleteCommand,
fetchParsed,
postCommand,
+ type RequestContext,
} from "@/api/client";
const schema = z.object({ id: z.string() });
-const ctx = { customerId: "customer-7" };
+// An authenticated context carries the JWT (for the Authorization header) + the id (for cache keys).
+const ctx: RequestContext = { token: "jwt-abc", customerId: "customer-7" };
+// A logged-out context (e.g. Catalog browsing) carries no token — no auth header is sent.
+const anonCtx: RequestContext = { token: null, customerId: "" };
+
+function authHeader(init: RequestInit): string | undefined {
+ return (init.headers as Record).Authorization;
+}
afterEach(() => {
vi.restoreAllMocks();
});
describe("fetchParsed", () => {
- it("sets the X-Customer-Id header from the request context and parses the body", async () => {
+ it("sets the Authorization: Bearer header from the token and parses the body", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id: "crit-001" }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
- const result = await fetchParsed("http://orders/carts/mine", schema, {
- customerId: "customer-7",
- });
+ const result = await fetchParsed("http://orders/carts/mine", schema, ctx);
expect(result).toEqual({ id: "crit-001" });
- const init = fetchMock.mock.calls[0][1] as RequestInit;
- expect((init.headers as Record)[CUSTOMER_ID_HEADER]).toBe("customer-7");
+ expect(authHeader(fetchMock.mock.calls[0][1] as RequestInit)).toBe("Bearer jwt-abc");
+ });
+
+ it("sends no Authorization header when the context has no token (public reads)", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(new Response(JSON.stringify({ id: "crit-001" }), { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await fetchParsed("http://catalog/products", schema, anonCtx);
+
+ expect(authHeader(fetchMock.mock.calls[0][1] as RequestInit)).toBeUndefined();
});
it("throws NotFoundError on 404 so callers can treat it as a domain-empty state", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 404 })));
- await expect(
- fetchParsed("http://orders/carts/mine", schema, { customerId: "customer-7" }),
- ).rejects.toBeInstanceOf(NotFoundError);
+ await expect(fetchParsed("http://orders/carts/mine", schema, ctx)).rejects.toBeInstanceOf(
+ NotFoundError,
+ );
});
it("rejects when the body fails the Zod boundary parse", async () => {
@@ -48,34 +63,30 @@ describe("fetchParsed", () => {
vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 42 }), { status: 200 })),
);
- await expect(
- fetchParsed("http://orders/carts/mine", schema, { customerId: "customer-7" }),
- ).rejects.toThrow();
+ await expect(fetchParsed("http://orders/carts/mine", schema, ctx)).rejects.toThrow();
});
});
describe("postCommand", () => {
- it("POSTs the body with the identity header and parses the response through the schema", async () => {
+ it("POSTs the body with the bearer token and parses the response through the schema", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id: "cart-1" }), { status: 201 }));
vi.stubGlobal("fetch", fetchMock);
- const result = await postCommand("http://orders/carts/c-7/items", { sku: "crit-001" }, ctx, schema);
+ const result = await postCommand("http://orders/carts/mine/items", { sku: "crit-001" }, ctx, schema);
expect(result).toEqual({ id: "cart-1" });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe("POST");
- expect((init.headers as Record)[CUSTOMER_ID_HEADER]).toBe("customer-7");
+ expect(authHeader(init)).toBe("Bearer jwt-abc");
expect(JSON.parse(init.body as string)).toEqual({ sku: "crit-001" });
});
it("returns void and never reads the body when no schema is given (a 204 has none)", async () => {
- // A real 204 Response has an empty body; calling `.json()` on it would throw. Asserting the resolve
- // proves the no-schema path skips the parse entirely.
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
- const result = await postCommand("http://orders/carts/c-7/items/crit-001/quantity", { newQuantity: 3 }, ctx);
+ const result = await postCommand("http://orders/carts/mine/items/crit-001/quantity", { newQuantity: 3 }, ctx);
expect(result).toBeUndefined();
});
@@ -84,28 +95,28 @@ describe("postCommand", () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 409 })));
await expect(
- postCommand("http://orders/carts/c-7/items/crit-001/quantity", { newQuantity: 3 }, ctx),
+ postCommand("http://orders/carts/mine/items/crit-001/quantity", { newQuantity: 3 }, ctx),
).rejects.toBeInstanceOf(ApiError);
});
});
describe("deleteCommand", () => {
- it("issues a DELETE with the identity header and resolves on a 204 (no body to parse)", async () => {
+ it("issues a DELETE with the bearer token and resolves on a 204 (no body to parse)", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
- await expect(deleteCommand("http://orders/carts/c-7/items/crit-001", ctx)).resolves.toBeUndefined();
+ await expect(deleteCommand("http://orders/carts/mine/items/crit-001", ctx)).resolves.toBeUndefined();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
- expect(url).toBe("http://orders/carts/c-7/items/crit-001");
+ expect(url).toBe("http://orders/carts/mine/items/crit-001");
expect(init.method).toBe("DELETE");
- expect((init.headers as Record)[CUSTOMER_ID_HEADER]).toBe("customer-7");
+ expect(authHeader(init)).toBe("Bearer jwt-abc");
});
it("throws ApiError on a non-2xx response", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 409 })));
- await expect(deleteCommand("http://orders/carts/c-7/items/crit-001", ctx)).rejects.toBeInstanceOf(
+ await expect(deleteCommand("http://orders/carts/mine/items/crit-001", ctx)).rejects.toBeInstanceOf(
ApiError,
);
});
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 831672c..1c75a4f 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -1,22 +1,21 @@
import type { ZodType } from "zod";
-import { useCurrentCustomer } from "@/identity/useCurrentCustomer";
+import { useAuth } from "@/identity/useCurrentCustomer";
-// The shared HTTP client for the three Wolverine.Http services. Two CritterMart conventions live here:
+// The shared HTTP client for the Wolverine.Http services. Two CritterMart conventions live here:
//
-// - **Convention 4 — the X-Customer-Id header.** Every request carries the current customer's identity
-// ambiently in this header, sourced from the useCurrentCustomer seam (ADR 009). The header name is
-// the single Polecat-promotion swap point; call sites never restate identity in URLs or query params.
+// - **Convention 4 — the Authorization: Bearer header (ADR 023).** Every request that needs identity
+// carries the authenticated customer's JWT in this header, sourced from the auth seam (useAuth). This
+// replaced the round-one X-Customer-Id header: the `sub` claim of the verified token is now the trust
+// boundary (slice 5.10). Public reads (Catalog browsing) carry no token and send no auth header. The
+// header is the single cutover point; call sites never restate identity in URLs or query params.
//
// - **Convention 2 — Zod at the wire boundary.** Every response body is `parse()`d through a Zod schema
-// before the app trusts it. Three independently-deployed services (ADR 006, no BFF) are three
-// contract surfaces that can drift; the boundary parse is the only place a drift surfaces — loud and
-// located, never a silent `undefined` deep in a component.
-export const CUSTOMER_ID_HEADER = "X-Customer-Id";
+// before the app trusts it. Independently-deployed services (ADR 006, no BFF) are separate contract
+// surfaces that can drift; the boundary parse is the only place a drift surfaces — loud and located.
// A non-2xx response. `NotFoundError` is split out because `404` is frequently a *domain state*, not a
-// failure — e.g. `GET /carts/mine` 404s as "this customer has no open cart" (render an empty cart), and
-// a query can map that to data rather than an error boundary.
+// failure — e.g. `GET /carts/mine` 404s as "this customer has no open cart" (render an empty cart).
export class ApiError extends Error {
readonly status: number;
@@ -35,11 +34,20 @@ export class NotFoundError extends ApiError {
}
export interface RequestContext {
- /** The current customer's id — set on the X-Customer-Id header. Sourced from the identity seam. */
+ /** The authenticated customer's JWT for the Authorization header, or null when logged out. */
+ token: string | null;
+ /** The current customer's id (the token's `sub`), for cache keys + optimistic updates. "" when logged out. */
customerId: string;
}
-// Fetch a JSON resource, set the identity header, and parse the body through `schema`. Pure (no React),
+// The auth header for a request: a Bearer token when the customer is authenticated, nothing otherwise
+// (so Catalog's public reads work logged-out). The `sub` claim inside the token is what the resource
+// server trusts — the id never rides a header of its own anymore.
+function authHeaders(ctx: RequestContext): Record {
+ return ctx.token ? { Authorization: `Bearer ${ctx.token}` } : {};
+}
+
+// Fetch a JSON resource, attach the bearer token, and parse the body through `schema`. Pure (no React),
// so query/mutation factories can call it and tests can drive it with a literal context + mocked fetch.
// A `404` throws `NotFoundError` so callers can branch on the domain-empty case; any other non-2xx
// throws `ApiError`.
@@ -51,7 +59,7 @@ export async function fetchParsed(
const response = await fetch(url, {
headers: {
Accept: "application/json",
- [CUSTOMER_ID_HEADER]: ctx.customerId,
+ ...authHeaders(ctx),
},
});
@@ -65,17 +73,10 @@ export async function fetchParsed(
return schema.parse(await response.json());
}
-// POST a command body, set the identity header, and (when the command answers with one) parse the
-// response body through `schema`. The command-side counterpart of `fetchParsed`: same X-Customer-Id seam
-// (Convention 4) and same boundary parse (Convention 2 — a command's *response* is a wire surface that can
-// drift too), plus a JSON body and `Content-Type`. Pure (no React), so mutation factories can call it and
-// tests can drive it with a literal context + mocked fetch. Any non-2xx throws `ApiError` (a command has
-// no domain-empty 404 case).
-//
-// `schema` is **optional**, and the overloads make the return type follow it: a command that returns a body
-// (W1 `AddToCart` → 201 `{ cartId }`) passes a schema and gets the parsed `T`; a command that returns
-// `204 No Content` (slice 3.3 change-qty) omits it and gets `void`. Omitting it also skips the `.json()`
-// call — reading a body off a 204 throws "Unexpected end of JSON input", so the no-schema path must not.
+// POST a command body, attach the bearer token, and (when the command answers with one) parse the
+// response body through `schema`. `schema` is optional, and the overloads make the return type follow it:
+// a command that returns a body passes a schema and gets the parsed `T`; a `204 No Content` command omits
+// it and gets `void` (omitting it also skips the `.json()` call — reading a body off a 204 throws).
export function postCommand(
url: string,
body: unknown,
@@ -94,7 +95,7 @@ export async function postCommand(
headers: {
Accept: "application/json",
"Content-Type": "application/json",
- [CUSTOMER_ID_HEADER]: ctx.customerId,
+ ...authHeaders(ctx),
},
body: JSON.stringify(body),
});
@@ -108,15 +109,14 @@ export async function postCommand(
return schema.parse(await response.json());
}
-// DELETE a route-keyed resource, setting the identity header. The SPA's first DELETE (slice 3.2 remove —
-// both identifiers ride the route, there is no body either way). Like `postCommand` with no schema, the
+// DELETE a route-keyed resource, attaching the bearer token. Like `postCommand` with no schema, the
// contract is `204 No Content`, so there is nothing to parse; a non-2xx throws `ApiError`. Pure (no React).
export async function deleteCommand(url: string, ctx: RequestContext): Promise {
const response = await fetch(url, {
method: "DELETE",
headers: {
Accept: "application/json",
- [CUSTOMER_ID_HEADER]: ctx.customerId,
+ ...authHeaders(ctx),
},
});
@@ -125,10 +125,10 @@ export async function deleteCommand(url: string, ctx: RequestContext): Promise {
function makeWrapper(queryClient: QueryClient) {
return ({ children }: { children: ReactNode }) => (
- {children}
+
+ {children}
+
);
}
@@ -119,7 +122,7 @@ describe("useAddToCart", () => {
expect(queryClient.getQueryData(cartKey)?.lines).toHaveLength(1);
});
- it("POSTs to the header-keyed URL (/carts/mine) with the X-Customer-Id header and a `productSnapshot` body", async () => {
+ it("POSTs to the header-keyed URL (/carts/mine) with the Authorization bearer token and a `productSnapshot` body", async () => {
const queryClient = freshClient();
const fetchMock = vi
.fn()
@@ -137,7 +140,7 @@ describe("useAddToCart", () => {
expect(url).toContain("/carts/mine/items"); // header-keyed (change 032), NOT /carts/{customerId}
expect(url).not.toContain(CUSTOMER); // identity rides the header, never the path
expect(init.method).toBe("POST");
- expect((init.headers as Record)["X-Customer-Id"]).toBe(CUSTOMER);
+ expect((init.headers as Record).Authorization).toBe(`Bearer ${TOKEN}`);
const body = JSON.parse(init.body as string);
expect(body.productSnapshot).toEqual({ name: "Nebula Newt", price: 18.0 }); // the exact field name the backend binds
});
@@ -250,7 +253,7 @@ describe("useRemoveCartItem", () => {
expect(url).toContain("/carts/mine/items/crit-001"); // header-keyed (change 032); only {sku} on the path
expect(url).not.toContain(CUSTOMER);
expect(init.method).toBe("DELETE");
- expect((init.headers as Record)["X-Customer-Id"]).toBe(CUSTOMER);
+ expect((init.headers as Record).Authorization).toBe(`Bearer ${TOKEN}`);
});
});
@@ -311,7 +314,7 @@ describe("useChangeCartItemQuantity", () => {
expect(url).toContain("/carts/mine/items/crit-001/quantity"); // header-keyed (change 032); only {sku} on the path
expect(url).not.toContain(CUSTOMER);
expect(init.method).toBe("POST");
- expect((init.headers as Record)["X-Customer-Id"]).toBe(CUSTOMER);
+ expect((init.headers as Record).Authorization).toBe(`Bearer ${TOKEN}`);
expect(JSON.parse(init.body as string)).toEqual({ newQuantity: 5 });
});
});
diff --git a/client/src/cart/cartQueries.test.ts b/client/src/cart/cartQueries.test.ts
index 47951d4..b1ebad7 100644
--- a/client/src/cart/cartQueries.test.ts
+++ b/client/src/cart/cartQueries.test.ts
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { fetchMyCart, selectCartLineCount, cartKeys } from "@/cart/cartQueries";
import type { CartView } from "@/cart/cartSchema";
-const ctx = { customerId: "customer-demo" };
+const ctx = { token: "jwt-demo", customerId: "customer-demo" };
const openCart = {
id: "cart-7f3a",
diff --git a/client/src/catalog/BrowsePage.tsx b/client/src/catalog/BrowsePage.tsx
index 0e55338..a4110f3 100644
--- a/client/src/catalog/BrowsePage.tsx
+++ b/client/src/catalog/BrowsePage.tsx
@@ -1,7 +1,9 @@
import { useQuery } from "@tanstack/react-query";
+import { useNavigate } from "@tanstack/react-router";
import { useApiContext } from "@/api/client";
import { useAddToCart } from "@/cart/cartMutations";
+import { useAuth } from "@/identity/useCurrentCustomer";
import { productsQueryOptions } from "./catalogQueries";
import type { ProductCatalogView } from "./catalogSchema";
@@ -9,8 +11,8 @@ import type { ProductCatalogView } from "./catalogSchema";
// W1 — Browse / Listing (workshop § 5.1; Narrative 005 Moments 1–2). The storefront landing: the product grid
// rendered from `GET /products` (slice 1.2), each card carrying the `[ Add to cart ]` that issues `AddToCart`
// (slice 3.1) — the project's first optimistic mutation. Product *detail* folds from the list payload; there is
-// no `GET /products/{sku}` (Gap #2, deferred). The catalog is public — the X-Customer-Id header rides along but
-// gates nothing.
+// no `GET /products/{sku}` (Gap #2, deferred). The catalog is public — browsing carries no token and needs
+// none (ADR 023); only adding to the cart requires a logged-in customer.
// One $-formatter built once at module load (stable reference, cheaper than per-render). A single price is a
// display value, so Intl's 2-decimal rounding is exact enough here; the cart sums in integer cents (CartPage).
@@ -22,6 +24,26 @@ const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD"
// the header badge bumps regardless of which card fired.
function ProductCard({ product }: { product: ProductCatalogView }) {
const addToCart = useAddToCart();
+ const { isAuthenticated } = useAuth();
+ const navigate = useNavigate();
+
+ // Browse is public, but the cart is customer-keyed (ADR 023: browse anonymously, checkout requires login).
+ // A logged-out shopper who taps "Add to cart" is sent to log in rather than firing a command the resource
+ // server would 401/400 (Narrative 010 Moment 3). A logged-in shopper adds as before.
+ function onAdd() {
+ if (!isAuthenticated) {
+ void navigate({ to: "/login" });
+ return;
+ }
+ // Snapshot name + price from the loaded listing into the command — product data reaches the cart only
+ // via this SPA snapshot, never a Catalog↔Orders call (Narrative 005 Moment 2). `productSnapshot` is the
+ // exact field name the backend binds (retro 018).
+ addToCart.mutate({
+ sku: product.sku,
+ quantity: 1,
+ productSnapshot: { name: product.name, price: product.price },
+ });
+ }
return (
@@ -32,21 +54,12 @@ function ProductCard({ product }: { product: ProductCatalogView }) {
{addToCart.isError && (
diff --git a/client/src/catalog/catalogQueries.test.ts b/client/src/catalog/catalogQueries.test.ts
index 4c8345b..f392027 100644
--- a/client/src/catalog/catalogQueries.test.ts
+++ b/client/src/catalog/catalogQueries.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { fetchProducts, catalogKeys } from "@/catalog/catalogQueries";
-const ctx = { customerId: "customer-demo" };
+const ctx = { token: "jwt-demo", customerId: "customer-demo" };
const wireProducts = [
{ sku: "crit-001", name: "Cosmic Critter Plush", description: "a plush gremlin", price: 24.99 },
diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx
index a70cdac..35bc18e 100644
--- a/client/src/components/AppShell.tsx
+++ b/client/src/components/AppShell.tsx
@@ -1,11 +1,20 @@
-import { Link, Outlet } from "@tanstack/react-router";
+import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import { CartBadge } from "@/cart/CartBadge";
+import { useAuth } from "@/identity/useCurrentCustomer";
// The storefront's outer chrome — header + routed content. A pure layout component: it fetches nothing
// itself (the cart badge owns its own query subscription, so only the badge re-renders on a count change).
// The header links are the type-safe TanStack Router `Link` (code-based router, src/router.tsx).
export function AppShell() {
+ const { isAuthenticated, customerId, logout } = useAuth();
+ const navigate = useNavigate();
+
+ function onLogout() {
+ logout(); // client-side token discard (slice 5.11)
+ void navigate({ to: "/" });
+ }
+
return (
@@ -14,13 +23,42 @@ export function AppShell() {
CritterMart
diff --git a/client/src/components/RequireAuth.tsx b/client/src/components/RequireAuth.tsx
new file mode 100644
index 0000000..23d4f78
--- /dev/null
+++ b/client/src/components/RequireAuth.tsx
@@ -0,0 +1,16 @@
+import { Navigate } from "@tanstack/react-router";
+import type { ReactNode } from "react";
+
+import { useAuth } from "@/identity/useCurrentCustomer";
+
+// Route gate for the customer-keyed screens (ADR 023: browse anonymously, checkout requires login). An
+// unauthenticated visitor to a gated route is redirected to /login; an authenticated one sees the content.
+// The resource server is the real enforcement (a request with no valid token is 401'd server-side, slice
+// 5.10) — this is the UX layer that sends the customer to log in rather than showing them a broken screen.
+export function RequireAuth({ children }: { children: ReactNode }) {
+ const { isAuthenticated } = useAuth();
+ if (!isAuthenticated) {
+ return ;
+ }
+ return <>{children}>;
+}
diff --git a/client/src/config.ts b/client/src/config.ts
index 43ba1f0..bb4cedb 100644
--- a/client/src/config.ts
+++ b/client/src/config.ts
@@ -15,12 +15,16 @@ const DEV_FALLBACKS = {
catalog: "http://localhost:5101",
inventory: "http://localhost:5102",
orders: "http://localhost:5103",
+ // Identity is browser-facing as of the auth slices (register/login/logout — ADR 023). Its dev HTTP
+ // port is 5105 (Properties/launchSettings.json), injected in the AppHost as VITE_IDENTITY_URL.
+ identity: "http://localhost:5105",
} as const;
const serviceUrlsSchema = z.object({
catalogUrl: z.url(),
inventoryUrl: z.url(),
ordersUrl: z.url(),
+ identityUrl: z.url(),
});
export type ServiceUrls = z.infer;
@@ -35,4 +39,5 @@ export const serviceUrls: ServiceUrls = serviceUrlsSchema.parse({
catalogUrl: stripTrailingSlash(import.meta.env.VITE_CATALOG_URL ?? DEV_FALLBACKS.catalog),
inventoryUrl: stripTrailingSlash(import.meta.env.VITE_INVENTORY_URL ?? DEV_FALLBACKS.inventory),
ordersUrl: stripTrailingSlash(import.meta.env.VITE_ORDERS_URL ?? DEV_FALLBACKS.orders),
+ identityUrl: stripTrailingSlash(import.meta.env.VITE_IDENTITY_URL ?? DEV_FALLBACKS.identity),
});
diff --git a/client/src/identity/LoginPage.tsx b/client/src/identity/LoginPage.tsx
new file mode 100644
index 0000000..3269c65
--- /dev/null
+++ b/client/src/identity/LoginPage.tsx
@@ -0,0 +1,85 @@
+import { useState, type FormEvent } from "react";
+import { Link, useNavigate } from "@tanstack/react-router";
+
+import { AuthError, useAuth } from "@/identity/useCurrentCustomer";
+
+// The Login screen (Narrative 010 Moment 2). Posts LogIn { email, password } to Identity; on success the
+// auth seam holds the returned JWT and the customer is sent to the storefront. A 401 surfaces as one
+// message — "Incorrect email or password." — for a wrong password OR an unknown email alike (no user
+// enumeration, slice 5.9).
+export function LoginPage() {
+ const { login } = useAuth();
+ const navigate = useNavigate();
+
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ async function onSubmit(e: FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setSubmitting(true);
+ try {
+ await login(email, password);
+ await navigate({ to: "/" });
+ } catch (err) {
+ setError(err instanceof AuthError ? err.message : "Something went wrong. Please try again.");
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
Log in
+
+
+
+ No account?{" "}
+
+ Create one
+
+
+
+ );
+}
diff --git a/client/src/identity/RegisterPage.tsx b/client/src/identity/RegisterPage.tsx
new file mode 100644
index 0000000..62e574f
--- /dev/null
+++ b/client/src/identity/RegisterPage.tsx
@@ -0,0 +1,101 @@
+import { useState, type FormEvent } from "react";
+import { Link, useNavigate } from "@tanstack/react-router";
+
+import { AuthError, useAuth } from "@/identity/useCurrentCustomer";
+
+// The Register screen (Narrative 010 Moment 1). Posts RegisterWithCredentials { email, displayName,
+// password } to Identity; on success it logs the new customer straight in (register() chains a login to
+// obtain a token) and lands them on the storefront. A duplicate email (409) or a weak password (400)
+// surfaces the server's ProblemDetails message (slice 5.8).
+export function RegisterPage() {
+ const { register } = useAuth();
+ const navigate = useNavigate();
+
+ const [email, setEmail] = useState("");
+ const [displayName, setDisplayName] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ async function onSubmit(e: FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setSubmitting(true);
+ try {
+ await register(email, displayName, password);
+ await navigate({ to: "/" });
+ } catch (err) {
+ setError(err instanceof AuthError ? err.message : "Something went wrong. Please try again.");
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
Create your account
+
+
+
+ Already have an account?{" "}
+
+ Log in
+
+
+
+ );
+}
diff --git a/client/src/identity/authStore.ts b/client/src/identity/authStore.ts
new file mode 100644
index 0000000..fde69d3
--- /dev/null
+++ b/client/src/identity/authStore.ts
@@ -0,0 +1,71 @@
+// Token persistence + JWT decoding for the storefront's auth (ADR 023, slices 5.9/5.11). The JWT minted by
+// Identity is a STATELESS bearer credential: the SPA holds it, sends it as `Authorization: Bearer …` to the
+// resource servers, and discards it on logout (there is no server-side session — slice 5.11). We persist it
+// in sessionStorage so a page refresh within the tab keeps the customer logged in, but it does NOT survive
+// closing the tab (a deliberately conservative default for a bearer token with no refresh flow).
+//
+// The decoding here is for DISPLAY / cache-keying only — it reads the `sub` claim without verifying the
+// signature. The SPA never trusts the token; the resource servers verify it offline (slice 5.10). A tampered
+// token would still be rejected server-side with 401; decoding it client-side just tells the UI who it
+// *claims* to be so it can render "logged in" state and key the cart/order caches by customer.
+
+const STORAGE_KEY = "crittermart.auth.token";
+
+function readStored(): string | null {
+ try {
+ return sessionStorage.getItem(STORAGE_KEY);
+ } catch {
+ return null; // sessionStorage can throw in some sandboxed contexts — treat as logged out.
+ }
+}
+
+export function persistToken(token: string | null): void {
+ try {
+ if (token) {
+ sessionStorage.setItem(STORAGE_KEY, token);
+ } else {
+ sessionStorage.removeItem(STORAGE_KEY);
+ }
+ } catch {
+ // Non-fatal: the in-memory token still drives the session for this page load.
+ }
+}
+
+// The initial token for a fresh page load — a still-valid persisted token, or null.
+export function initialToken(): string | null {
+ const token = readStored();
+ if (token && isExpired(token)) {
+ persistToken(null);
+ return null;
+ }
+ return token;
+}
+
+interface JwtClaims {
+ sub?: string;
+ exp?: number;
+}
+
+function decodeClaims(token: string): JwtClaims {
+ try {
+ const payload = token.split(".")[1];
+ // base64url → base64, then decode. atob is available in the browser + jsdom.
+ const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
+ return JSON.parse(json) as JwtClaims;
+ } catch {
+ return {};
+ }
+}
+
+// The customer id the token claims (its `sub`), or null. Used for the "logged in" state and cache keys.
+export function customerIdFromToken(token: string | null): string | null {
+ if (!token) return null;
+ return decodeClaims(token).sub ?? null;
+}
+
+// A token whose `exp` is in the past. Client-side expiry is a courtesy (avoid sending a known-dead token
+// and gate the UI); the resource server's lifetime check is the real enforcement.
+export function isExpired(token: string): boolean {
+ const { exp } = decodeClaims(token);
+ return typeof exp === "number" && exp * 1000 <= Date.now();
+}
diff --git a/client/src/identity/useCurrentCustomer.tsx b/client/src/identity/useCurrentCustomer.tsx
index 4b5f420..34afd6e 100644
--- a/client/src/identity/useCurrentCustomer.tsx
+++ b/client/src/identity/useCurrentCustomer.tsx
@@ -1,35 +1,138 @@
-import { createContext, useContext, type ReactNode } from "react";
+import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
-// The round-one identity seam (ADR 009). Identity is stubbed: a single hardcoded customer id, exposed
-// through one React hook so that **no component ever reads the stubbed value directly** — they call
-// useCurrentCustomer(). There is no login screen.
-//
-// This hook is the ONE place the Polecat promotion touches: today it returns the stubbed id; later it
-// reads the authenticated claim. Because the shared HTTP client (src/api/client.ts) sets the id once as
-// the X-Customer-Id header from this seam, the promotion is a localized header->Bearer-claim swap with
-// every call site unchanged — not a sweep that edits every request.
+import { serviceUrls } from "@/config";
+import { customerIdFromToken, initialToken, persistToken } from "@/identity/authStore";
+
+// The identity seam (ADR 009 → ADR 023). What was a hardcoded stub is now the real authenticated session:
+// this provider holds the JWT the customer logged in for, and exposes it through one hook so **no component
+// reads the raw token or restates identity** — they call useCurrentCustomer() for the id (cache keys, "who
+// am I" branches) or useAuth() for the token + login/logout actions. The shared HTTP client
+// (src/api/client.ts) reads this seam once to set the Authorization: Bearer header, so the header→claim
+// cutover ADR 009 engineered is a single localized swap, every call site unchanged.
//
-// The id is overridable via the provider prop purely to make the seam testable and to leave room for a
-// future dev-only customer switcher; production round one always uses the stub.
-const STUBBED_CUSTOMER_ID = "customer-demo";
+// This is still the ONE place identity is sourced. The `customerId` prop remains for testability — page and
+// mutation tests inject a fixed id (and mock fetch) without a real login round-trip; production seeds the
+// session from a persisted token (initialToken) and updates it through login/logout.
+
+// Raised by login/register so pages can render a friendly message. Deliberately NOT the client's ApiError
+// (that would couple the identity seam to the HTTP client, which imports THIS module — a cycle).
+export class AuthError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "AuthError";
+ }
+}
+
+interface AuthContextValue {
+ /** The raw JWT for the Authorization header, or null when logged out. */
+ token: string | null;
+ /** The authenticated customer's id (the token's `sub`), or null when logged out. */
+ customerId: string | null;
+ isAuthenticated: boolean;
+ login: (email: string, password: string) => Promise;
+ register: (email: string, displayName: string, password: string) => Promise;
+ logout: () => void;
+}
+
+const AuthContext = createContext(null);
-const CurrentCustomerContext = createContext(STUBBED_CUSTOMER_ID);
+async function postJson(url: string, body: unknown): Promise {
+ return fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+// Named CurrentCustomerProvider for continuity with the ADR 009 seam (and the existing test call sites).
+// `customerId`/`token` props are test/override seams; production passes neither and seeds from the persisted
+// session token.
export function CurrentCustomerProvider({
- customerId = STUBBED_CUSTOMER_ID,
+ customerId: injectedCustomerId,
+ token: injectedToken,
children,
}: {
customerId?: string;
+ token?: string;
children: ReactNode;
}) {
- return (
- {children}
+ // Lazy initializer — seed once from the injected override (tests) or the persisted session token, without
+ // re-reading sessionStorage on every render.
+ const [token, setToken] = useState(() => injectedToken ?? initialToken());
+
+ const applyToken = useCallback((next: string | null) => {
+ setToken(next);
+ persistToken(next);
+ }, []);
+
+ const login = useCallback(
+ async (email: string, password: string) => {
+ const res = await postJson(`${serviceUrls.identityUrl}/login`, { email, password });
+ if (!res.ok) {
+ // 401 with no enumeration — one message for wrong password OR unknown email (slice 5.9).
+ throw new AuthError("Incorrect email or password.");
+ }
+ const body = (await res.json()) as { token: string };
+ applyToken(body.token);
+ },
+ [applyToken],
);
+
+ const register = useCallback(
+ async (email: string, displayName: string, password: string) => {
+ const res = await postJson(`${serviceUrls.identityUrl}/register`, { email, displayName, password });
+ if (!res.ok) {
+ // 409 duplicate email or 400 weak password — surface the server's ProblemDetails detail.
+ let detail = "Registration failed. Please check your details and try again.";
+ try {
+ const problem = (await res.json()) as { detail?: string; title?: string };
+ detail = problem.detail ?? problem.title ?? detail;
+ } catch {
+ // non-JSON body — keep the generic message
+ }
+ throw new AuthError(detail);
+ }
+ // Registration does not return a token (slice 5.8 responds 201 with the id); log in to obtain one.
+ await login(email, password);
+ },
+ [login],
+ );
+
+ // Logout is client-side token discard (slice 5.11): drop the token, no server call. The already-issued
+ // token stays cryptographically valid until it expires (no revocation this increment), but the SPA no
+ // longer holds it, so subsequent requests carry no bearer and gated routes send the customer to login.
+ const logout = useCallback(() => applyToken(null), [applyToken]);
+
+ const value = useMemo(() => {
+ const customerId = injectedCustomerId ?? customerIdFromToken(token);
+ return {
+ token,
+ customerId,
+ isAuthenticated: customerId !== null,
+ login,
+ register,
+ logout,
+ };
+ }, [injectedCustomerId, token, login, register, logout]);
+
+ return {children};
+}
+
+function useAuthContext(): AuthContextValue {
+ const ctx = useContext(AuthContext);
+ if (ctx === null) {
+ throw new Error("useAuth/useCurrentCustomer must be used within a CurrentCustomerProvider.");
+ }
+ return ctx;
+}
+
+// The full auth surface: token (for the Authorization header), customerId, and the session actions.
+export function useAuth(): AuthContextValue {
+ return useAuthContext();
}
-// Returns the current customer's id. Customer-keyed reads/commands carry it ambiently via the
-// X-Customer-Id header set on the shared client; callers rarely need the raw value, but components that
-// must branch on identity read it here rather than the stub.
-export function useCurrentCustomer(): string {
- return useContext(CurrentCustomerContext);
+// The current customer's id (the token's `sub`), or null when logged out. Components that only need "who am
+// I" call this; the shared client reads the token via useAuth for the Bearer header.
+export function useCurrentCustomer(): string | null {
+ return useAuthContext().customerId;
}
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 21c974c..4c12032 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -18,8 +18,9 @@ const queryClient = new QueryClient({
},
});
-// Provider order: QueryClientProvider (the cache mutations write to) -> CurrentCustomerProvider (the
-// identity seam the shared client reads for the X-Customer-Id header) -> RouterProvider (pages).
+// Provider order: QueryClientProvider (the cache mutations write to) -> CurrentCustomerProvider (the auth
+// seam — seeds the session from the persisted JWT, ADR 023; the shared client reads its token for the
+// Authorization: Bearer header) -> RouterProvider (pages).
createRoot(document.getElementById("root")!).render(
diff --git a/client/src/orders/orderQueries.test.ts b/client/src/orders/orderQueries.test.ts
index ae6e275..dc1a881 100644
--- a/client/src/orders/orderQueries.test.ts
+++ b/client/src/orders/orderQueries.test.ts
@@ -1,9 +1,9 @@
import { describe, it, expect, vi, afterEach } from "vitest";
-import { CUSTOMER_ID_HEADER, NotFoundError } from "@/api/client";
+import { NotFoundError } from "@/api/client";
import { fetchMyOrders, fetchOrder, orderKeys } from "@/orders/orderQueries";
-const ctx = { customerId: "customer-demo" };
+const ctx = { token: "jwt-demo", customerId: "customer-demo" };
const wireOrder = {
id: "ord-7f3a",
@@ -32,7 +32,7 @@ describe("fetchOrder", () => {
expect(order.total).toBe(49.98);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/orders/ord-7f3a");
- expect((init.headers as Record)[CUSTOMER_ID_HEADER]).toBe("customer-demo");
+ expect((init.headers as Record).Authorization).toBe("Bearer jwt-demo");
});
// The contrast with the cart read: a 404 here is a GENUINE error (an order the SPA just placed must exist),
@@ -61,7 +61,7 @@ describe("fetchMyOrders", () => {
expect(orders[1].status).toBe("confirmed");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/orders/mine");
- expect((init.headers as Record)[CUSTOMER_ID_HEADER]).toBe("customer-demo");
+ expect((init.headers as Record).Authorization).toBe("Bearer jwt-demo");
});
// The no-orders domain state is a 200 [] — fetchMyOrders returns the parsed empty array (NOT a NotFoundError
diff --git a/client/src/router.tsx b/client/src/router.tsx
index 99425eb..2bcfd7b 100644
--- a/client/src/router.tsx
+++ b/client/src/router.tsx
@@ -1,9 +1,12 @@
import { createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
import { AppShell } from "@/components/AppShell";
+import { RequireAuth } from "@/components/RequireAuth";
import { RouteNotFound } from "@/components/RouteNotFound";
import { BrowsePage } from "@/catalog/BrowsePage";
import { CartPage } from "@/cart/CartPage";
+import { LoginPage } from "@/identity/LoginPage";
+import { RegisterPage } from "@/identity/RegisterPage";
import { MyOrdersPage } from "@/orders/MyOrdersPage";
import { OrderConfirmationPage } from "@/orders/OrderConfirmationPage";
import { OrderStatusPage } from "@/orders/OrderStatusPage";
@@ -28,21 +31,42 @@ const browseRoute = createRoute({
component: BrowsePage,
});
-// W2 — Cart Review. Renders the customer's open cart from GET /carts/mine.
+// Auth screens (ADR 023, Narrative 010). Public routes — the login/register forms themselves need no session.
+const loginRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/login",
+ component: LoginPage,
+});
+
+const registerRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/register",
+ component: RegisterPage,
+});
+
+// W2 — Cart Review. Renders the customer's open cart from GET /carts/mine. GATED (ADR 023): the cart is
+// customer-keyed, so an unauthenticated visitor is redirected to /login by RequireAuth.
const cartRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/cart",
- component: CartPage,
+ component: () => (
+
+
+
+ ),
});
// "My Orders" list (Narrative 005 Moment 6; workshop § 5.1 Gap #3). Reads GET /orders/mine (customer-keyed)
// and lists the customer's orders newest-first, each row linking into the W4 track screen. A literal /orders
-// segment, distinct from the /orders/$orderId param routes below — the matcher resolves the bare path here and
-// /orders/ to W4/W3.
+// segment, distinct from the /orders/$orderId param routes below. GATED (ADR 023).
const myOrdersRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/orders",
- component: MyOrdersPage,
+ component: () => (
+
+
+
+ ),
});
// W3 — Order Confirmation (Narrative 005 Moment 4). Reached by navigate() after [ Place Order ]'s POST /orders
@@ -54,7 +78,11 @@ const orderConfirmationRoute = createRoute({
path: "/orders/$orderId/confirmation",
component: function OrderConfirmationRoute() {
const { orderId } = orderConfirmationRoute.useParams();
- return ;
+ return (
+
+
+
+ );
},
});
@@ -68,12 +96,18 @@ const orderTrackingRoute = createRoute({
path: "/orders/$orderId",
component: function OrderTrackingRoute() {
const { orderId } = orderTrackingRoute.useParams();
- return ;
+ return (
+
+
+
+ );
},
});
const routeTree = rootRoute.addChildren([
browseRoute,
+ loginRoute,
+ registerRoute,
cartRoute,
myOrdersRoute,
orderConfirmationRoute,
diff --git a/docs/handoffs/identity-auth-implementation.md b/docs/handoffs/identity-auth-implementation.md
new file mode 100644
index 0000000..76be7ac
--- /dev/null
+++ b/docs/handoffs/identity-auth-implementation.md
@@ -0,0 +1,113 @@
+# CritterMart — Handoff: Identity Auth Implementation — per-slice loop next
+
+> **Durable handoff** (version-controlled at `docs/handoffs/`), authored 2026-07-07.
+> Closes out the ADR-023 design session (parent: [`identity-real-auth-direction.md`](identity-real-auth-direction.md))
+> and opens the **implementation** phase for the auth increment. This session authored the design layer
+> (ADR + workshop + context-map); the next session builds the code, per CLAUDE.md's per-slice loop.
+
+## Mission for the next session
+
+Implement the **ASP.NET Core Identity authentication increment** — Workshop 002 slices **5.8–5.11** — via the
+per-slice loop: **OpenSpec proposal + narrative → prompt → implement → retro**, consolidated into one PR per
+Erik's slice-PR preference ([[feedback-consolidate-slice-prs]]). The architecture is already decided by
+[ADR 023](../decisions/023-real-authentication-for-identity.md) — **do not re-litigate it.** Build against it.
+
+## What's true right now (2026-07-07, verified this session — don't re-derive)
+
+- `main` @ `502f5fd`, clean. That commit is the squash-merge of **PR #129** (ADR 023 design layer). The
+ post-merge sync reconciled a divergence (two prior local-only commits — the handoff and the ADR-009
+ correction — were folded into #129's squash; content preserved, local `main` reset to `origin/main`).
+- **Stale branch to delete:** `design/adr-023-real-auth-identity` (squashed into `502f5fd`). Deletion needs
+ `git branch -D`, which the git-guardrails hook blocks — run `! git branch -D design/adr-023-real-auth-identity`
+ in-session when convenient.
+- **The decided architecture (ADR 023 — build to this, don't reopen):**
+ - **ASP.NET Core Identity** for the user store + credential handling (`UserManager.CreateAsync`,
+ `SignInManager.CheckPasswordSignInAsync`), EF-Core-backed on the existing shared-Postgres `identity` schema.
+ - On login, Identity **mints a standard JWT** (customer id in the `sub` claim), signed with an **asymmetric
+ private key it alone holds**.
+ - The SPA sends the JWT as `Authorization: Bearer …` to **all four** services. Catalog/Inventory/Orders
+ configure `AddJwtBearer` with Identity's **public key distributed as config** and validate **offline** —
+ **no HTTP into Identity**, per-request or at startup (config, not JWKS). This is what keeps the
+ no-sync-HTTP non-negotiable (ADR 001) intact.
+ - The `X-Customer-Id` seam **retires as the trust boundary**; the `sub` claim replaces it. The frontend's
+ `useCurrentCustomer` seam sources the id from the authenticated session — the one-file change ADR 009's
+ amendment engineered.
+ - Auth stays **non-event-sourced** — ASP.NET Core Identity is relational user-store CRUD; **zero stream
+ events, no saga** in this increment. It extends the boring-CRUD foil (ADR 009 amendment / ADR 022).
+- **Slice breakdown** (Workshop 002 § 5–6 carry the GWT scenarios — read them):
+ - **5.8 Register with credentials** (`RegisterWithCredentials`) — creates the Identity user + the registry
+ `Customer` row in one flow, same id; `CustomerRegistered` still publishes via the outbox. Failure paths:
+ duplicate email, weak password.
+ - **5.9 Log in — issue a JWT** (`LogIn`) — password check → mint the signed JWT. Failure: bad credentials
+ (no user enumeration).
+ - **5.10 Verify a token at a resource server** *(system, code lands in Catalog/Inventory/Orders)* — the
+ "Identity → consumer" shape, like slices 5.3/5.4. Offline validation; `401` on invalid/expired, locally.
+ - **5.11 Log out** — stateless JWT → client-side token discard; server-side revocation deferred.
+
+## Forward questions ADR 023 named for THIS layer (resolve in the OpenSpec proposal / slice design — these are
+## open *implementation* questions, NOT re-openings of the architecture)
+
+1. **Entity shape + a naming collision (load-bearing).** Decide whether the ASP.NET Core Identity user and the
+ registry `Customer` row are one entity or two id-linked tables (`Customer.Id` is already a string;
+ `IdentityUser.Id` is a string by default — they can align). **Gotcha:** CritterMart's existing
+ `IdentityDbContext` (`src/CritterMart.Identity/Customers/IdentityDbContext.cs`) shares its name with ASP.NET
+ Core Identity's `Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext` base class —
+ either rename the local context or derive it from the ASP.NET base. Don't let a fresh session trip on this.
+2. **Registration: replace or layer?** Does `RegisterWithCredentials` supersede the spike's open
+ `RegisterCustomer` outright, or coexist (admin-provisioned customers)?
+3. **Token lifetime / refresh / logout.** Round one: short access token + client-side-discard logout, no
+ refresh, no server-side revocation. Consider a demo-paced TTL config knob mirroring `EmailChangeDeadline`.
+4. **Authorization is deferred.** ADR 023 settles authN only; roles/policies wait for a second actor.
+5. **Signing-key management.** Where the keypair lives (user-secrets/config dev, secret store prod) and
+ rotation cadence (config-redeploy accepted for round one).
+
+## First steps (per CLAUDE.md's per-slice loop)
+
+1. **OpenSpec proposal** — extend the `customer-registry` capability (one-capability-per-aggregate; the
+ `EmailChange` saga already sits here) with the auth SHALL deltas for 5.8–5.11. Author + validate with the
+ openspec CLI (prefer the tool over freeform — [[feedback-prefer-tool-backed-over-freeform]]).
+2. **Narrative** — a customer-auth journey (register → log in → shop authenticated → log out), NDD-informed,
+ at `docs/narratives/`. Sibling to the proposal; both must agree.
+3. **Prompt** at `docs/prompts/implementations/`, then **implement**, then **retro**. Live-verify the auth
+ flow end-to-end against the real stack before the PR ([[feedback-live-verify-after-changes]]) — boot via
+ the demo-runbook, drive a register/login/authenticated-request/logout cycle ([[feedback-drive-demo-flows]]).
+
+## Carry-forward items (unchanged, non-blocking)
+
+- **Do NOT bump Wolverine past 6.16.0** (CritterWatch 1.0.0-beta.1 coupling) or transitive JasperFx deps
+ (suppressed MessagePack CVE). Both standing.
+- **CritterWatch trial expires 2026-07-10** — check the renewal conversation before any live-console work.
+- **Five AppHost demo knobs** — still post-talk-only, do not delete yet.
+- **CLAUDE.md's non-negotiables line** still reads "frontend sends a hardcoded `X-Customer-Id`" — accurate
+ until the auth slices ship; update it in the same PR as slice 5.10.
+
+## Orientation files (read first, in order)
+
+1. This handoff.
+2. [ADR 023](../decisions/023-real-authentication-for-identity.md) — the decision; the "Forward questions"
+ section is the slice layer's input.
+3. [Workshop 002](../workshops/002-identity-event-model.md) §§ 5.8–5.11 (slice table + GWT), § 3's auth
+ architect note, § 4's auth vocabulary subsection.
+4. `docs/context-map/README.md` — the auth relationship row (OHS+PL extended, Conformist, offline / no edge).
+5. `src/CritterMart.Identity/` — the current shape (`Program.cs`, `IdentityDbContext.cs`, `Customer.cs`,
+ the `EmailChange` saga) the auth increment layers onto.
+6. `docs/decisions/023-...md`'s ctx7-verified fact: ASP.NET Core Identity's built-in tokens are NOT JWTs —
+ the standard-JWT layer is deliberate. Fetch current ASP.NET Core Identity docs via `ctx7` when wiring
+ `UserManager`/`SignInManager`/`AddJwtBearer` (per the global context7 rule — .NET 10 APIs may be recent).
+
+## Definition of done (next session)
+
+- [ ] OpenSpec proposal (`customer-registry` auth deltas, 5.8–5.11) authored + validated
+- [ ] Customer-auth narrative authored (sibling; agrees with the proposal)
+- [ ] Implementation prompt authored; slices 5.8–5.11 implemented (incl. the `IdentityDbContext` collision
+ resolved); `X-Customer-Id` → `sub` cutover; frontend `useCurrentCustomer` sourced from the session
+- [ ] Live-verified end-to-end (register/login/authenticated request/logout) against the real stack
+- [ ] Retro authored; one consolidated PR; `/post-merge → /handoff → /blurb` on merge
+
+## Suggested skills
+
+- `wolverine-handlers-efcore`, `wolverine-http-fundamentals`, `wolverine-testing-alba` (Critter Stack impl +
+ integration tests).
+- `ctx7` for ASP.NET Core Identity + JWT bearer API specifics (`UserManager`, `SignInManager`, `AddJwtBearer`,
+ `JsonWebTokenHandler`, asymmetric signing) — .NET 10, verify against current docs.
+- `frontend` skill for the `useCurrentCustomer` seam cutover and the login/logout UI.
diff --git a/docs/narratives/010-customer-authentication.md b/docs/narratives/010-customer-authentication.md
new file mode 100644
index 0000000..3f36199
--- /dev/null
+++ b/docs/narratives/010-customer-authentication.md
@@ -0,0 +1,88 @@
+---
+narrative: 010
+title: The Customer Signs In
+actor: Customer
+status: draft
+version: v1.0
+slices: [5.8, 5.9, 5.10, 5.11]
+references:
+ - docs/workshops/002-identity-event-model.md (§ 2 auth subsection, § 3 auth architect note, § 4 auth commands + the issued token, § 5 slices 5.8–5.11, § 6 GWT scenarios, § 8 items 13–17)
+ - openspec/changes/slices-5-8-5-11-identity-authentication/ (sibling OpenSpec proposal + customer-registry SHALL delta)
+ - docs/decisions/023-real-authentication-for-identity.md (the fixed architecture — issuer/resource-server, self-validated JWT)
+ - docs/decisions/001-separate-services-topology.md (the no-sync-HTTP non-negotiable this journey honors)
+ - docs/decisions/009-polecat-deferred-for-round-one.md (the X-Customer-Id seam this journey retires as the trust boundary)
+ - docs/narratives/006-customer-registration.md (the registry row this journey adds credentials to)
+ - docs/narratives/005-customer-storefront.md (the storefront screens this journey now gates behind login)
+---
+
+# Narrative 010 — The Customer Signs In
+
+Every journey before this one took the Customer's identity on faith. The storefront announced "I am `customer-demo`" in a header, and Catalog, Inventory, and Orders believed it — because in round one there was nobody else to be, and no password to prove otherwise ([ADR 009](../decisions/009-polecat-deferred-for-round-one.md)). Narrative 006 registered a customer as a *row*; it never gave them a way to *log in*. This narrative closes that gap: the Customer sets a password, proves who they are, carries that proof to every service, and lets it go when they leave.
+
+The mechanism is fixed by [ADR 023](../decisions/023-real-authentication-for-identity.md), and its shape is the teaching payoff. Identity becomes the system's sole **auth issuer**: it, and only it, holds a private signing key, so it, and only it, can *mint* a token. Catalog, Inventory, and Orders become **resource servers**: they hold the matching *public* key as configuration, so they can *verify* a token — but never forge one. The proof the Customer carries is a standard **JWT** whose `sub` claim is their customer id, and the load-bearing property is that a resource server checks it **entirely on its own** — no phone call back to Identity, per request or ever. That is the same no-synchronous-HTTP rule ([ADR 001](../decisions/001-separate-services-topology.md)) that keeps customer *data* off sync calls, now keeping customer *trust* off them too.
+
+Two things this journey deliberately is **not**. It is not event sourcing — ASP.NET Core Identity is a relational user store, so credentials are more of Identity's "boring CRUD" foil, not a new stream. And it is not authorization — the token says *who* the Customer is, never *what they may do*; roles and policies wait for a second actor who isn't just "the shopper" ([ADR 023](../decisions/023-real-authentication-for-identity.md); Workshop 002 § 8 item 16).
+
+## Journey scope
+
+The Customer's sign-in journey threads four Workshop 002 slices, consolidated into one PR:
+
+- **Slice 5.8 — Register with credentials.** Moment 1: setting a password, and the two ways that can be refused.
+- **Slice 5.9 — Log in.** Moment 2: proving the password and receiving the token.
+- **Slice 5.10 — Shop authenticated.** Moment 3: carrying the token to a resource server that trusts it offline — and what happens to a bad one.
+- **Slice 5.11 — Log out.** Moment 4: letting the token go.
+
+## Moment 1 — Setting a password
+
+**Context.** A newcomer wants a CritterMart account. Unlike Narrative 006's registration — which minted a row from an email and a display name alone — they will now also choose a password, so they can come back and prove it is them.
+
+**Interaction.** The storefront's Register screen posts `RegisterWithCredentials { email: "ada@example.com", displayName: "Ada Lovelace", password: … }` to `POST /register`.
+
+**System response.** This is Narrative 006's registration with credentials layered on top, not in place of it — the old passwordless `POST /customers` still exists for the seeder and admin-provisioned customers, but a self-registering shopper comes through `/register`. Identity does two writes as one: `UserManager.CreateAsync` creates an ASP.NET Core Identity user with the password **hashed** (Identity never stores the plaintext), and a registry `Customer` row is written with the **same** id — so the user and the row are two tables describing one person, joined by a shared key. In the same transaction, `CustomerRegistered` is enrolled in the EF-Core outbox exactly as before (Orders' local customer read model, Narrative 007, still learns about her the same way). It is all-or-nothing: both writes land, or neither does, and the response is `201 Created`.
+
+Two refusals guard the door, both before any write commits. If `ada@example.com` is already registered, the request is rejected (`CustomerAlreadyRegistered`) — the same duplicate-email guard Narrative 006 uses, now backing a user as well as a row. And if the password is too weak for the configured policy, Identity refuses it (`400`, with the policy's own complaints) and creates *nothing* — no orphaned user, no orphaned row. A shopper cannot get halfway into existence.
+
+## Moment 2 — Proving it
+
+**Context.** Ada is registered with a password. On a later visit — a new browser, no session — she needs to prove she is Ada before the storefront will treat a cart as hers.
+
+**Interaction.** The Login screen posts `LogIn { email: "ada@example.com", password: … }` to `POST /login`.
+
+**System response.** Identity runs `SignInManager.CheckPasswordSignInAsync` against the hashed credential. On success, it **mints a JWT**: a short, signed token whose `sub` claim is Ada's customer id, stamped with an issuer, an audience, and an expiry, and signed with Identity's **private** key. Ada's browser receives that token and holds onto it; nothing is written to any row, and the token itself is never persisted server-side — it lives only in transit and in the browser. It is a bearer of proof, not a session record.
+
+On failure, Identity says as little as possible: a wrong password and an email that was never registered both return the **same** `401`, with no hint which was which. Telling Ada "that email isn't registered" would tell an attacker the same thing — so the storefront learns only "those credentials don't work," never "that account exists" (Workshop 002 § 6, no user enumeration).
+
+## Moment 3 — Shopping as yourself
+
+**Context.** Ada holds a valid token. She browses CritterMart's shelves — which needs no login at all, exactly as Narrative 005 always let her — and then adds something to her cart, which now does.
+
+**Interaction.** Browsing Catalog carries no token and needs none: product listings are public, so the shelves load for anyone. The moment Ada adds to her cart, the storefront attaches her token — `Authorization: Bearer ` — to the request into Orders, and to every customer-keyed request after it.
+
+**System response.** Orders was handed Identity's **public** key as configuration at startup, so when Ada's token arrives it checks the whole thing **itself**: the signature against that public key (proving Identity minted it and nobody tampered), the issuer and audience (proving it was minted for *this* system), and the expiry (proving it is still fresh). All of that happens inside Orders — **no call into Identity**, not per request, not once at startup, because the public key is config, not something fetched. If the token is good, Orders reads Ada's id from the `sub` claim and treats the request as hers: the customer id that used to arrive on faith in a header now arrives *proven* in a claim. That claim is the trust boundary now; the old `X-Customer-Id` header is retired from that role (a dev-only fallback lingers behind the scenes so the seeder and existing tests keep working, tracked for removal — but the storefront no longer sends it, and it is not what Orders trusts).
+
+A bad token fails the same way it was checked — locally. A tampered signature fails the public-key check; an expired token fails the lifetime check; a token minted for some other audience fails that check — and every one of them is a `401` decided by Orders alone, again with **no call into Identity**. This is the whole architecture's proof in one Moment: real authentication, and still not one synchronous hop between services. Catalog and Inventory need no auth changes at all — Catalog's shelves are public, and Inventory only ever hears a customer id as ordinary payload on a RabbitMQ message from Orders (a `ReserveStock`), never as a browser's token, so it sits off this trust path entirely.
+
+## Moment 4 — Letting it go
+
+**Context.** Ada is done shopping and logs out — or simply closes the tab.
+
+**Interaction.** The storefront logs out.
+
+**System response.** Because the token is stateless — Identity kept no session, no server-side record — logging out is simply **discarding the token**. The browser drops it, the `useCurrentCustomer` seam returns to unauthenticated, and the next customer-keyed request carries no bearer and is met with `401`: Ada is a browsing stranger again until she signs back in. There is one honest asterisk, named rather than hidden: a token already handed out stays cryptographically valid until it *expires*, because nothing revokes it. A stolen token isn't un-stolen by clicking log out — which is exactly why the token's lifetime is kept short. A real revocation story — a denylist, or short access tokens refreshed against a longer-lived credential — is a modeled-but-deferred future increment (Workshop 002 § 8 item 15), not part of this pass.
+
+## The issuer and the resource servers
+
+Narrative 006 made Identity a place customers are *stored*; this narrative makes it the place customers are *trusted from*. The split is the point: one service mints because one service holds the private key, and three services verify because three services hold only the public one. Nothing about that requires an event store — it is the same "when CRUD is fine" thread Catalog runs on, applied to credentials, and it adds no new arrow to the context map, because verification never leaves the resource server. The strongest reading of "no synchronous service-to-service HTTP" doesn't just survive real authentication here; real authentication is built *to demonstrate* it.
+
+## What the Customer does *not* yet see
+
+- **No "what may I do?" — only "who am I?"** The token authenticates; it does not authorize. Every logged-in shopper has the same single role. Roles, policies, and an admin/backoffice actor are deferred until a second kind of actor exists ([ADR 023](../decisions/023-real-authentication-for-identity.md); Workshop 002 § 8 item 16).
+- **No "stay logged in."** There is no refresh token and no server-side logout; a session lasts exactly as long as the access token's lifetime, then Ada logs in again. Refresh + revocation is the named next increment (item 15).
+- **No password reset, no email verification of the login itself.** Setting a password proves nothing about controlling the inbox (as Narrative 009 also noted for email changes); a real "forgot password" or "verify your email" flow is out of scope for this pass.
+- **No token on the bus.** Where Ada's identity crosses a service boundary between backends (Orders → Inventory), it rides as ordinary message payload over RabbitMQ, never as a JWT — the broker already established that trust. The token is a browser-to-service concern only.
+
+## Document History
+
+| Version | Date | Notes |
+| ------- | ---------- | ----- |
+| v1.0 | 2026-07-07 | Initial commit. Covers Workshop 002 slices 5.8 (register with credentials — happy path plus duplicate-email and weak-password refusals), 5.9 (log in — mint a signed JWT; bad-credentials `401` with no user enumeration), 5.10 (shop authenticated — offline token verification at Orders with no HTTP into Identity, `sub` sourced as the trust boundary, invalid/expired rejected locally), and 5.11 (log out — client-side token discard, deferred revocation named). Threads CritterMart's **issuer / resource-server** split per [ADR 023](../decisions/023-real-authentication-for-identity.md): Identity mints with an asymmetric private key, the resource servers verify offline against a config-distributed public key, retiring `X-Customer-Id` as the trust boundary in favor of the `sub` claim. Load-bearing framings held: auth is still non-event-sourced (relational user store extending the boring-CRUD foil) and the no-sync-HTTP non-negotiable ([ADR 001](../decisions/001-separate-services-topology.md)) survives real auth. Sibling of the `slices-5-8-5-11-identity-authentication` OpenSpec change; consolidated with the implementation into one PR. |
diff --git a/docs/narratives/README.md b/docs/narratives/README.md
index 1dcc274..cfd9af5 100644
--- a/docs/narratives/README.md
+++ b/docs/narratives/README.md
@@ -43,7 +43,7 @@ For each workshop slice the narrative threads, a sibling **OpenSpec proposal** a
## Current population
-**Nine narratives.**
+**Ten narratives.**
- [`001-seller-manage-catalog.md`](001-seller-manage-catalog.md) — the Seller's catalog-management journey (v1.1, Catalog BC), covering slices 1.1 (Publish a product) and 1.3 (Change a product's price).
- [`002-customer-browse-catalog.md`](002-customer-browse-catalog.md) — the Customer's catalog-browsing journey (Catalog BC), covering slice 1.2 (Browse and view products), a read-only query slice. First Customer-actor narrative.
@@ -59,4 +59,6 @@ For each workshop slice the narrative threads, a sibling **OpenSpec proposal** a
- [`009-customer-changes-email.md`](009-customer-changes-email.md) — the Customer's email-change journey (v1.0, Identity BC), covering slices 5.5 (request — with unknown-customer and duplicate-email guards), 5.6 (confirm — with the stays-open-on-conflict rule), and 5.7 (timeout — drop, no escalation). Threads CritterMart's **second convention `Wolverine.Saga`** — an EF-Core-backed `EmailChange` saga, the direct counterpart to Narrative 008's Marten-backed `Replenishment`, proving the identical saga programming model rides on either store (ADR 022). Sibling of the `slices-5-5-5-7-email-change-saga` OpenSpec change.
+- [`010-customer-authentication.md`](010-customer-authentication.md) — the Customer's sign-in journey (v1.0, Identity BC as **auth issuer**), covering slices 5.8 (register with credentials — duplicate-email + weak-password refusals), 5.9 (log in — mint a signed JWT, no user enumeration), 5.10 (shop authenticated — offline token verification at Orders, `sub` retires `X-Customer-Id` as the trust boundary, no HTTP into Identity), and 5.11 (log out — client-side token discard). Threads the **issuer / resource-server** split per [ADR 023](../decisions/023-real-authentication-for-identity.md): Identity mints with an asymmetric private key, the resource servers verify offline against a config-distributed public key. Load-bearing: auth is still non-event-sourced (relational user store extending the boring-CRUD foil) and the no-sync-HTTP non-negotiable survives real auth. Sibling of the `slices-5-8-5-11-identity-authentication` OpenSpec change.
+
**Two lenses on the Customer journey.** Narratives 002 + 004 are the *behavior* lens (streams, events, cross-BC hops); 005 is the *screen* lens (wireframes, what the actor sees and touches). They share slices and must agree, but answer different questions — useful precedent if a future actor's journey also splits behavior from UI. (Narrative 006 is a separate Customer journey — registration into the Identity registry — not another lens on the purchase journey.)
diff --git a/docs/prompts/README.md b/docs/prompts/README.md
index c1916cb..329a65c 100644
--- a/docs/prompts/README.md
+++ b/docs/prompts/README.md
@@ -51,4 +51,4 @@ New prompts should mirror this shape unless a session has a deliberate reason to
## Current population
-Kinds populated for round one: `rules/` (2 — round-one structural-constraints synthesis, encode bundle), `workshops/` (1), `research/` (2 — ecommerce engineering lessons survey, frontend stack landscape), `docs/` (15 — folder-READMEs, README overhaul, housekeeping sweep, slice 3.1/4.1/4.2/4.6/4.7/3.4 and slices 3.2+3.3 doc follow-ups, round-one close reconciliation, slice 3.5 close, the design-return reconciliation of workshop § 5.1 to the shipped W3/W4 wire, and the v1.11 design-return flipping § 5.1's W4 placed-at + per-reason cancel copy from aspirational to shipped (slice 025) and archiving the `enrich-order-status-view` change, and the v1.13 design-return adding the workshop § 6 slice-3.1 add-to-cart malformed-snapshot faithfulness note (#69) and archiving both `harden-add-to-cart-snapshot` (shopping-cart 8→9) and `list-my-orders` (order-lifecycle 9→10), returning the workspace to 0 active changes), `narratives/` (4 — Seller catalog-management, Customer browse, Customer purchase, Customer storefront), `specs/` (2 — slice 1.1 + 1.2 OpenSpec proposals; from slice 1.3 on, proposals ride consolidated slice PRs without a standalone spec-prompt), `chore/` (3 — Critter Stack 2026 upgrade, infra bundle, pre-frontend hardening), `implementations/` (36 — slices 1.1, 1.2, 1.3, 2.1, 2.2, 3.1, 4.1, 4.2, 4.3, 4.6, 4.7, 3.2+3.3, 3.4, 2.4, 3.5, the frontend bootstrap — the round-two SPA skeleton — the Wolverine health-check exposure, a cross-cutting infra slice salvaged from the research-003 spike, the W2 cart-review screen — the first storefront screen — the W1 browse + add-to-cart screen, the first optimistic mutation, the W2 cart-edits screen — remove + change-quantity, the cart's first DELETE and 204-No-Content commands, and the W3 place-order screen — checkout to Order Confirmation, the storefront's first non-optimistic mutation and the OpenTelemetry trace front-door, and the ADR 020/021 Order read/write split — the `Order` write aggregate + `Ordering/` verb folder, the Cart pilot's rollout to the Order BC; and the W4 order-tracking screen — the storefront's first `refetchInterval` poll, converging `OrderStatusView` to its terminal status and completing the W1→W4 storefront spine; and the ADR 020 Stock read/write split — the `StockLevel` write aggregate + the `StockLevelView` read projection in Inventory, the third and final ADR 020 rollout with no folder change (`StockLevel` ≠ `…Stock`), completing the Cart→Order→Stock split; and the `OrderStatusView` enrichment — `placedAt` + `cancelReason` added to the order read model, binding W4's placed-at line and per-reason cancel copy, the first round-two slice to change a read contract; and the `AddToCart` snapshot hardening — a `Validate` guard rejecting a malformed `productSnapshot` with `400` at the boundary instead of a `500` NRE in the shared cart fold, closing the round-one pre-frontend audit's only open defect; and the OpenTelemetry teaching pass — Marten verbose connection tracking + event-append metrics wired across all three services and the metrics meters registered in ServiceDefaults, completing ADR 005's deferred half and documenting the cross-service trace visual owed since chore/002; and the **"My Orders" list** — a customer-keyed order list (`GET /orders/mine`) over the existing inline `OrderStatusView` with no new event, command, projection, or aggregate, closing the round-two storefront's last named deferral (Gap #3) and extending the journey from tracking one order to seeing them all; and the **payment-decline demo toggle** — a config-gated `Payment:DeclineOverAmount` threshold that makes the already-built slice-4.6 payment-decline → cancel → `ReleaseStock` path triggerable in a live demo, no new domain behavior and no spec delta; and **seed automation** — a one-shot `CritterMart.Seeding` console wired as an Aspire `seeder` resource that auto-seeds the canonical demo products + stock on boot through the real HTTP endpoints, closing demo-runbook Known Gap #1, dev/demo infrastructure with no domain or spec change; and **deferred-timeout linked traces** — the fired payment- / cart-timeout now runs in its own span-linked root trace (suppress Wolverine's parented span + emit a new root linked back via the originating envelope's traceparent) instead of parenting into the placement / add-to-cart trace, so the demo-centerpiece `POST /orders` waterfall never balloons to the timeout window, observability-only with no domain or spec change; and **cart command identity harmonization** — the three cart commands (`POST /carts/mine/items`, `…/items/{sku}/quantity`, `DELETE …/items/{sku}`) move from route-keyed to **header-keyed** identity (`X-Customer-Id`), matching the cart read and closing the divergence slices 3.1–3.3 logged as a future tidy; a `shopping-cart` transport change with no event, projection, index, or domain-rule change, place-order's body key deferred as a fast-follow; and the **Identity customer registry** — slices 5.1 (register) + 5.2 (resolve) consolidated into one PR, the first `customer-registry` capability and CritterMart's 4th deployed service (Wolverine over EF Core / Npgsql — the one non-event-sourced BC, the `Customer` row *is* the read model and `CustomerRegistered` is an outbox notification), re-landing the `spike/efcore-identity` reference implementation on `main` plus the duplicate-email guard the spike skipped (normalized email, `ValidateAsync` 409 + DB unique index); and **customer data in Orders** — slices 5.3 + 5.4 consolidated: `CustomerRegistered` graduates to `CritterMart.Contracts` (Published Language), Orders subscribes and upserts a consumer-local `LocalCustomerView`, order endpoints enriched with `customerName?` at read time, seeder extended to register `customer-demo` closing the demo's X-Customer-Id loop; and the **Inventory replenishment saga** (slices 2.5–2.7) — CritterMart's **first convention `Wolverine.Saga`**, a SKU-keyed `Replenishment` saga that opens on a `ReserveStock` shortfall, resolves on a covering restock (reduce-and-stay-open on a partial), and escalates on a config-driven timeout, with saga state in Marten saga storage rather than on the `Stock` stream — the saga-vs-PMvH contrast and the repo's first saga; and the **Identity email-change saga** (slices 5.5–5.7) — CritterMart's **second convention `Wolverine.Saga`**, an EF-Core-backed `EmailChange` saga in Identity that opens on `RequestEmailChange`, applies on a timely `ConfirmEmailChange` (or rejects a conflict without completing), and drops on timeout, proving the saga store itself is swappable (Marten vs. EF Core) — fully consolidated with its workshop amendment, OpenSpec proposal, and narrative in one session/PR), `decisions/` (3 — round-two frontend decisions: stack + full-pipeline UI modeling, the first session whose primary deliverable is the ADRs themselves; ADR 020 domain write-models vs. `*View` read models, the first decisions session shipped with a code change — the Cart read/write split pilot; and the Saga #2 gate — an ADR-009 amendment (the "boring CRUD" stance survives hosting a saga) + new ADR 022 formalizing convention sagas as additive to PMvH, retroactively covering Saga #1 and clearing the way for Saga #2's Session 2). The remaining unused kind (`skills/`) appears as its first session of that kind lands.
+Kinds populated for round one: `rules/` (2 — round-one structural-constraints synthesis, encode bundle), `workshops/` (1), `research/` (2 — ecommerce engineering lessons survey, frontend stack landscape), `docs/` (15 — folder-READMEs, README overhaul, housekeeping sweep, slice 3.1/4.1/4.2/4.6/4.7/3.4 and slices 3.2+3.3 doc follow-ups, round-one close reconciliation, slice 3.5 close, the design-return reconciliation of workshop § 5.1 to the shipped W3/W4 wire, and the v1.11 design-return flipping § 5.1's W4 placed-at + per-reason cancel copy from aspirational to shipped (slice 025) and archiving the `enrich-order-status-view` change, and the v1.13 design-return adding the workshop § 6 slice-3.1 add-to-cart malformed-snapshot faithfulness note (#69) and archiving both `harden-add-to-cart-snapshot` (shopping-cart 8→9) and `list-my-orders` (order-lifecycle 9→10), returning the workspace to 0 active changes), `narratives/` (4 — Seller catalog-management, Customer browse, Customer purchase, Customer storefront), `specs/` (2 — slice 1.1 + 1.2 OpenSpec proposals; from slice 1.3 on, proposals ride consolidated slice PRs without a standalone spec-prompt), `chore/` (3 — Critter Stack 2026 upgrade, infra bundle, pre-frontend hardening), `implementations/` (37 — slices 1.1, 1.2, 1.3, 2.1, 2.2, 3.1, 4.1, 4.2, 4.3, 4.6, 4.7, 3.2+3.3, 3.4, 2.4, 3.5, the frontend bootstrap — the round-two SPA skeleton — the Wolverine health-check exposure, a cross-cutting infra slice salvaged from the research-003 spike, the W2 cart-review screen — the first storefront screen — the W1 browse + add-to-cart screen, the first optimistic mutation, the W2 cart-edits screen — remove + change-quantity, the cart's first DELETE and 204-No-Content commands, and the W3 place-order screen — checkout to Order Confirmation, the storefront's first non-optimistic mutation and the OpenTelemetry trace front-door, and the ADR 020/021 Order read/write split — the `Order` write aggregate + `Ordering/` verb folder, the Cart pilot's rollout to the Order BC; and the W4 order-tracking screen — the storefront's first `refetchInterval` poll, converging `OrderStatusView` to its terminal status and completing the W1→W4 storefront spine; and the ADR 020 Stock read/write split — the `StockLevel` write aggregate + the `StockLevelView` read projection in Inventory, the third and final ADR 020 rollout with no folder change (`StockLevel` ≠ `…Stock`), completing the Cart→Order→Stock split; and the `OrderStatusView` enrichment — `placedAt` + `cancelReason` added to the order read model, binding W4's placed-at line and per-reason cancel copy, the first round-two slice to change a read contract; and the `AddToCart` snapshot hardening — a `Validate` guard rejecting a malformed `productSnapshot` with `400` at the boundary instead of a `500` NRE in the shared cart fold, closing the round-one pre-frontend audit's only open defect; and the OpenTelemetry teaching pass — Marten verbose connection tracking + event-append metrics wired across all three services and the metrics meters registered in ServiceDefaults, completing ADR 005's deferred half and documenting the cross-service trace visual owed since chore/002; and the **"My Orders" list** — a customer-keyed order list (`GET /orders/mine`) over the existing inline `OrderStatusView` with no new event, command, projection, or aggregate, closing the round-two storefront's last named deferral (Gap #3) and extending the journey from tracking one order to seeing them all; and the **payment-decline demo toggle** — a config-gated `Payment:DeclineOverAmount` threshold that makes the already-built slice-4.6 payment-decline → cancel → `ReleaseStock` path triggerable in a live demo, no new domain behavior and no spec delta; and **seed automation** — a one-shot `CritterMart.Seeding` console wired as an Aspire `seeder` resource that auto-seeds the canonical demo products + stock on boot through the real HTTP endpoints, closing demo-runbook Known Gap #1, dev/demo infrastructure with no domain or spec change; and **deferred-timeout linked traces** — the fired payment- / cart-timeout now runs in its own span-linked root trace (suppress Wolverine's parented span + emit a new root linked back via the originating envelope's traceparent) instead of parenting into the placement / add-to-cart trace, so the demo-centerpiece `POST /orders` waterfall never balloons to the timeout window, observability-only with no domain or spec change; and **cart command identity harmonization** — the three cart commands (`POST /carts/mine/items`, `…/items/{sku}/quantity`, `DELETE …/items/{sku}`) move from route-keyed to **header-keyed** identity (`X-Customer-Id`), matching the cart read and closing the divergence slices 3.1–3.3 logged as a future tidy; a `shopping-cart` transport change with no event, projection, index, or domain-rule change, place-order's body key deferred as a fast-follow; and the **Identity customer registry** — slices 5.1 (register) + 5.2 (resolve) consolidated into one PR, the first `customer-registry` capability and CritterMart's 4th deployed service (Wolverine over EF Core / Npgsql — the one non-event-sourced BC, the `Customer` row *is* the read model and `CustomerRegistered` is an outbox notification), re-landing the `spike/efcore-identity` reference implementation on `main` plus the duplicate-email guard the spike skipped (normalized email, `ValidateAsync` 409 + DB unique index); and **customer data in Orders** — slices 5.3 + 5.4 consolidated: `CustomerRegistered` graduates to `CritterMart.Contracts` (Published Language), Orders subscribes and upserts a consumer-local `LocalCustomerView`, order endpoints enriched with `customerName?` at read time, seeder extended to register `customer-demo` closing the demo's X-Customer-Id loop; and the **Inventory replenishment saga** (slices 2.5–2.7) — CritterMart's **first convention `Wolverine.Saga`**, a SKU-keyed `Replenishment` saga that opens on a `ReserveStock` shortfall, resolves on a covering restock (reduce-and-stay-open on a partial), and escalates on a config-driven timeout, with saga state in Marten saga storage rather than on the `Stock` stream — the saga-vs-PMvH contrast and the repo's first saga; and the **Identity email-change saga** (slices 5.5–5.7) — CritterMart's **second convention `Wolverine.Saga`**, an EF-Core-backed `EmailChange` saga in Identity that opens on `RequestEmailChange`, applies on a timely `ConfirmEmailChange` (or rejects a conflict without completing), and drops on timeout, proving the saga store itself is swappable (Marten vs. EF Core) — fully consolidated with its workshop amendment, OpenSpec proposal, and narrative in one session/PR; and **Identity authentication** (slices 5.8–5.11) — real auth per ADR 023: Identity becomes the sole **auth issuer** (ASP.NET Core Identity user store + a self-validated JWT signed with an asymmetric private key), Orders becomes a **resource server** verifying the token **offline** against Identity's config-distributed public key with zero HTTP into Identity, and the `sub` claim retires `X-Customer-Id` as the trust boundary (layered cutover — header kept as a dev-only fallback); still non-event-sourced, no saga, consolidated with its OpenSpec proposal + narrative in one session/PR), `decisions/` (3 — round-two frontend decisions: stack + full-pipeline UI modeling, the first session whose primary deliverable is the ADRs themselves; ADR 020 domain write-models vs. `*View` read models, the first decisions session shipped with a code change — the Cart read/write split pilot; and the Saga #2 gate — an ADR-009 amendment (the "boring CRUD" stance survives hosting a saga) + new ADR 022 formalizing convention sagas as additive to PMvH, retroactively covering Saga #1 and clearing the way for Saga #2's Session 2). The remaining unused kind (`skills/`) appears as its first session of that kind lands.
diff --git a/docs/prompts/implementations/037-slices-5-8-5-11-identity-authentication.md b/docs/prompts/implementations/037-slices-5-8-5-11-identity-authentication.md
new file mode 100644
index 0000000..63248d8
--- /dev/null
+++ b/docs/prompts/implementations/037-slices-5-8-5-11-identity-authentication.md
@@ -0,0 +1,74 @@
+# Prompt: Implementations 037 — Slices 5.8–5.11 Identity Authentication (ASP.NET Core Identity + self-validated JWT)
+
+**Kind**: per-slice implementation (Identity as auth issuer + Orders as resource server + frontend cutover), consolidated into one PR per [[feedback-consolidate-slice-prs]]
+**Files touched**: `openspec/changes/slices-5-8-5-11-identity-authentication/{proposal.md,specs/customer-registry/spec.md,design.md,tasks.md}` (new, this session, `openspec validate --strict` green); `docs/narratives/010-customer-authentication.md` (new) + `docs/narratives/README.md` (count 9→10); `docs/prompts/implementations/037-…md` (this file); `Directory.Packages.props` (+3 packages); `src/CritterMart.Identity/*` (rename `IdentityDbContext`→`CustomerDbContext`; new `Auth/*`, `Features/{RegisterWithCredentials,LogIn,LogOut}.cs`; `Program.cs`, `.csproj`); `src/CritterMart.Orders/*` (`Program.cs`, `Auth/*`, customer-keyed endpoints, `.csproj`); `src/CritterMart.AppHost/Program.cs` (key/issuer config to Orders); `client/src/{identity,api}/*` + login/register pages; tests (Identity + Orders); CLAUDE.md non-negotiables line; `docs/skills/DEBT.md` (fallback-header removal row); `docs/retrospectives/implementations/037-…md` (at close)
+**Mode**: solo implementation, fully consolidated — this session runs OpenSpec proposal/specs/design/tasks → narrative → this prompt → code → tests → live-verify → retro
+**Commit subject**: `feat: add real authentication for Identity (ASP.NET Core Identity + self-validated JWT) — slices 5.8–5.11`
+
+## Framing
+
+ADR 023 already decided the architecture — **do not re-litigate it.** Identity becomes the system's sole **auth issuer** (ASP.NET Core Identity user store + a JWT it signs with an asymmetric private key only it holds); Catalog/Inventory/Orders are **resource servers** that verify the token **offline** against Identity's config-distributed public key. The `sub` claim retires `X-Customer-Id` as the trust boundary. Three session-start forks were resolved via AskUserQuestion (design.md decisions 1/5/6): **entity shape** = two id-linked tables, one `CustomerDbContext : IdentityUserContext` (this also resolves the `IdentityDbContext` name collision the handoff flagged); **cutover** = layered (Bearer is the trust boundary, `X-Customer-Id` kept as a dev-only fallback so the seeder/existing tests/demo-traffic stay green — DEBT-tracked for removal); **gate** = browse Catalog anonymously, checkout requires login.
+
+**The load-bearing demonstration** is slice 5.10: a resource server validates a token with **zero HTTP into Identity**, per request or at startup (the public key is config, not a fetched JWKS). That is the auth analogue of Workshop 002's OHS/PL split and the proof ADR 001 survives real auth. Keep it literal in the code (no `Authority`/`MetadataAddress` on `AddJwtBearer`).
+
+**Still non-event-sourced.** Auth is relational user-store CRUD — zero stream events, no saga, no projection. It extends the boring-CRUD foil (ADR 009 amendment / ADR 022), it does not reverse it.
+
+## Goal
+
+- Identity mints: `POST /register` (`RegisterWithCredentials` — user + `Customer` row + `CustomerRegistered`, one transaction; `409` duplicate, `400` weak password) and `POST /login` (`LogIn` — `SignInManager` check → signed JWT with `sub`=customer id; `401` no-enumeration on bad creds). `POST /logout` is informational (stateless client discard).
+- Orders verifies: `AddJwtBearer` (public key from config, `MapInboundClaims = false`, offline); customer-keyed endpoints source the id from `sub` ?? `X-Customer-Id` fallback; invalid/expired → `401`.
+- Frontend cuts to Bearer: login/register/logout UI, session token store, `useCurrentCustomer` from `sub`, `client.ts` sends `Authorization: Bearer`, cart/checkout gated, Catalog anonymous.
+- `dotnet build` (whole repo) zero errors; `dotnet test` green incl. **all** pre-existing tests (layered cutover = no regressions); frontend Vitest green. Live-verified end-to-end against the real Aspire stack.
+
+## Spec delta
+
+This session satisfies the **four ADDED requirements** in the `customer-registry` capability — *register with credentials* (5.8), *log in and issue a JWT* (5.9), *verify a token at a resource server* (5.10), *log out* (5.11) — in `openspec/changes/slices-5-8-5-11-identity-authentication/specs/customer-registry/spec.md`, authored this session. Workshop 002 v1.3 § 6 carries the GWT scenarios; Narrative 010 is the human companion. The retro confirms closure.
+
+## Orientation files
+
+1. **`docs/handoffs/identity-auth-implementation.md`** — the mission + the "forward questions" this layer resolves.
+2. **`docs/decisions/023-real-authentication-for-identity.md`** — the fixed architecture; build to it.
+3. **`docs/workshops/002-identity-event-model.md` §§ 2/3/4/5/6 (auth subsections) + § 8 items 13–17** — the model + open questions this session resolves.
+4. **`openspec/changes/slices-5-8-5-11-identity-authentication/{proposal.md,design.md}`** — the build map + the implementation decisions (with ctx7 grounding), authored this session.
+5. **`src/CritterMart.Identity/{Program.cs,Customers/IdentityDbContext.cs,Features/RegisterCustomer.cs}`** — the wiring the auth layer extends: the `AddDbContextWithWolverineIntegration` one-transaction outbox, the lowercase-column discipline, the `RegisterCustomer.ValidateAsync` duplicate-email railway idiom + email normalization to reuse.
+6. **`src/CritterMart.Orders/{Program.cs,Features/AddToCart.cs}`** — the resource server; `AddToCart.cs:73` is the `[FromHeader("X-Customer-Id")]` binding the cutover helper replaces (prefer `sub`, fall back to header).
+7. **`client/src/api/client.ts` + `client/src/identity/useCurrentCustomer.tsx`** — the one-seam header→Bearer swap point ADR 009 engineered; `docs/skills/frontend/SKILL.md` for the SPA conventions.
+
+## Working pattern
+
+1. **Branch** `feat/identity-auth-slices-5-8-5-11` (already created).
+2. **ctx7 first for any API you touch** (ASP.NET Core Identity `AddIdentityCore`/`SignInManager`, `JsonWebTokenHandler`, `AddJwtBearer` — .NET 10; already fetched once this session, re-fetch if a signature surprises you).
+3. **Identity issuer** — rename the context; add `Auth/{JwtSettings,JwtSigningKeys,JwtTokenIssuer}.cs`; wire `Program.cs`; `RegisterWithCredentials`, `LogIn`, `LogOut`. Unit/integration-test as you go. **Verify Weasel creates the Identity tables** (design.md decision 1's flagged risk) early — boot a test host and check `register`+`login` round-trips before building further.
+4. **Orders resource server** — `AddJwtBearer` + `AddAuthorization` + `UseAuthentication/UseAuthorization`; the `sub ?? header` resolution helper; update the customer-keyed endpoints; AppHost config. Add `TokenAuthTests` (valid → sub sourced, invalid/expired → 401, header fallback still works). **Confirm existing Orders tests stay green** (they use the header path).
+5. **Frontend** — auth store, seam, `client.ts` Bearer, login/register/logout pages + route gating; Vitest.
+6. **Whole-repo build + test green**; then **live-verify** against the real Aspire stack (demo-runbook): register → login → authenticated Orders call (200, sub sourced) → drop token → 401 → confirm no HTTP into Identity; then drive it from the SPA.
+7. **Retro** at close — spec-delta closure (4 ADDED requirements), the Weasel-index + fallback-header findings, the CLAUDE.md line update, the DEBT row.
+
+## Deliverable plan (in order)
+
+| File | Status |
+|---|---|
+| `openspec/changes/slices-5-8-5-11-identity-authentication/*` | new (this session, landed, `--strict` green) |
+| `docs/narratives/010-customer-authentication.md` + README count 9→10 | new (this session, landed) |
+| `docs/prompts/implementations/037-…md` | new (this file) |
+| `Directory.Packages.props` | +`Microsoft.AspNetCore.Identity.EntityFrameworkCore`, `Microsoft.IdentityModel.JsonWebTokens`, `Microsoft.AspNetCore.Authentication.JwtBearer` |
+| `src/CritterMart.Identity/Customers/IdentityDbContext.cs` → `CustomerDbContext.cs` | rename + derive `IdentityUserContext` |
+| `src/CritterMart.Identity/Auth/{JwtSettings,JwtSigningKeys,JwtTokenIssuer}.cs` | new |
+| `src/CritterMart.Identity/Features/{RegisterWithCredentials,LogIn,LogOut}.cs` | new |
+| `src/CritterMart.Identity/{Program.cs,CritterMart.Identity.csproj}` | modify |
+| `src/CritterMart.Orders/Auth/*` + `Program.cs` + customer-keyed endpoints + `.csproj` | modify |
+| `src/CritterMart.AppHost/Program.cs` | modify (Jwt config to Orders) |
+| `client/src/identity/*`, `client/src/api/client.ts`, login/register pages, routing | modify/new |
+| tests (Identity `RegisterWithCredentialsTests`/`LogInTests`; Orders `TokenAuthTests`; Vitest) | new/modify |
+| CLAUDE.md non-negotiables line; `docs/skills/DEBT.md` row | modify |
+| `docs/retrospectives/implementations/037-…md` | new (at close) |
+
+## Out of scope
+
+- **Authorization** (roles/policies/`[Authorize(Policy=…)]`) — open Q16; the token authenticates, never authorizes.
+- **Refresh tokens + server-side revocation/denylist** — open Q15; logout is client-side discard.
+- **A JWKS endpoint** — ADR 023 chose config-distributed public key; a JWKS move stays open (open Q17), not taken.
+- **Catalog/Inventory browser-facing auth** — no customer-keyed browser endpoints there; Inventory's customer id rides RabbitMQ payload, off the trust path.
+- **Removing the `X-Customer-Id` fallback header / migrating seeder+tests+demo-traffic to JWTs** — the hard-cutover follow-up; DEBT-tracked, not this PR.
+- **Real secret-store key management** — dev keypair in config; prod override + rotation cadence is open Q17.
+- **Any new `CritterMart.Contracts` type, cross-BC message, stream event, saga, or projection** — auth adds none by design; if implementation finds a reason to, stop and raise it.
diff --git a/docs/retrospectives/implementations/037-slices-5-8-5-11-identity-authentication.md b/docs/retrospectives/implementations/037-slices-5-8-5-11-identity-authentication.md
new file mode 100644
index 0000000..6dd2c02
--- /dev/null
+++ b/docs/retrospectives/implementations/037-slices-5-8-5-11-identity-authentication.md
@@ -0,0 +1,52 @@
+---
+retrospective: 037
+kind: implementations
+prompt: docs/prompts/implementations/037-slices-5-8-5-11-identity-authentication.md
+deliverable: openspec/changes/slices-5-8-5-11-identity-authentication/ (proposal + specs/customer-registry/spec.md + design.md + tasks.md, all new, openspec validate --strict green), docs/narratives/010-customer-authentication.md (new) + docs/narratives/README.md (9→10), docs/prompts/implementations/037-slices-5-8-5-11-identity-authentication.md (new) + docs/prompts/README.md (impl 36→37), Directory.Packages.props (+3 package refs), src/CritterMart.ServiceDefaults/DevJwtDefaults.cs (new), src/CritterMart.Identity/Customers/CustomerDbContext.cs (renamed from IdentityDbContext.cs, derives IdentityUserContext), src/CritterMart.Identity/Auth/{JwtSettings.cs,JwtTokenIssuer.cs} (new), src/CritterMart.Identity/Features/{RegisterWithCredentials.cs,LogIn.cs,LogOut.cs} (new), src/CritterMart.Identity/{Program.cs,CritterMart.Identity.csproj} (modified), src/CritterMart.Orders/Auth/CustomerIdentity.cs (new), src/CritterMart.Orders/{Program.cs,CritterMart.Orders.csproj} (modified), src/CritterMart.Orders/Features/{AddToCart,ViewMyCart,ListMyOrders,PlaceOrder,RemoveCartItem,ChangeCartItemQuantity}.cs (modified — sub-first identity), src/CritterMart.AppHost/Program.cs (modified — VITE_IDENTITY_URL, Identity CORS), client/src/config.ts + client/src/identity/{authStore.ts,useCurrentCustomer.tsx,LoginPage.tsx,RegisterPage.tsx} + client/src/api/client.ts + client/src/components/{AppShell.tsx,RequireAuth.tsx} + client/src/router.tsx + client/src/catalog/BrowsePage.tsx + client/src/main.tsx (auth cutover), tests/CritterMart.Identity.Tests/{RegisterWithCredentialsTests.cs,LogInTests.cs} (new), tests/CritterMart.Orders.Tests/TokenAuthTests.cs (new), client test updates (client.test.ts, cartMutations.test.tsx, cartQueries/catalogQueries/orderQueries.test.ts), CLAUDE.md (non-negotiables line), docs/retrospectives/implementations/037-…md (this file)
+date: 2026-07-07
+mode: solo implementation, fully consolidated (OpenSpec proposal through code + live-verify, one session/PR, per Erik's slice-PR preference)
+session-runner: Claude (Opus 4.8)
+---
+
+# Retrospective — Implementations 037: Slices 5.8–5.11 — Identity authentication (ASP.NET Core Identity + self-validated JWT)
+
+## Outcome summary
+
+Closed the round-one auth stub (ADR 023). Identity is now the system's sole **auth issuer**: ASP.NET Core Identity (user store + password handling) on the existing EF-Core/`identity` schema, minting a standard **RS256 JWT** (`sub` = customer id) signed with an asymmetric private key it alone holds. Orders became a **resource server**, validating that token **fully offline** against Identity's config-distributed public key — zero HTTP into Identity — and sourcing the authenticated customer id from `sub`. The `X-Customer-Id` header is retired as the trust boundary (the `sub` claim replaces it), kept only as a dev-only fallback under the layered cutover. The frontend cut fully to `Authorization: Bearer` with login/register/logout UI, session-token persistence, and browse-anon / checkout-gated routing.
+
+Three load-bearing forks were resolved with Erik at session start via `AskUserQuestion` (entity shape = two id-linked tables / one `CustomerDbContext : IdentityUserContext`; cutover = layered; gate = browse-anon/checkout-gated), then baked into the OpenSpec proposal + design.md and built to. The full loop ran in one sitting: OpenSpec change (proposal + `customer-registry` spec delta with 4 ADDED requirements + design.md + tasks.md, `openspec validate --strict` green), Narrative 010, this prompt, the code, and a live-verify against the real Aspire stack. **All 172 backend tests green** (9 Catalog + 31 Inventory + 26 Identity + 103 Orders + 3 CrossBc) and **112 frontend unit tests green**, zero regressions from the layered cutover.
+
+## What worked
+
+- **Resolving the load-bearing forks up front with `AskUserQuestion` + rich previews.** Entity shape, cutover breadth, and the auth gate each ripple through the proposal, the spec delta, and dozens of files. Presenting concrete option previews (class sketches, blast-radius tables, UX flows) and getting three decisions before writing the proposal meant the whole downstream chain was built once, not re-litigated mid-implementation. All three of Erik's choices matched the recommended option, but the previews made the tradeoffs (e.g. hard vs. layered cutover blast radius) explicit rather than assumed.
+- **`ctx7` before wiring the framework APIs.** Fetching current ASP.NET Core Identity + JWT docs (.NET 10) surfaced two decisions that materially shaped the code before a line was written: `AddIdentityCore` + `AddSignInManager` (not `AddIdentity`, which drags in cookie auth ADR 023 rejects) and `JsonWebTokenHandler` + `MapInboundClaims = false` (not the legacy `JwtSecurityTokenHandler`, which silently remaps `sub`). Guessing either would have produced subtly wrong behavior that tests might not have caught (a remapped `sub` still "works" until you assert the claim name).
+- **Running the Identity host early, before building the resource-server layer on top.** The very first integration-test run caught the biggest risk (design.md decision 1's flagged Weasel-casing hazard) as a live `column a.Id does not exist / Perhaps you meant a.id` failure. Finding it after the issuer was proven but before Orders + the frontend depended on it kept the fix (a scoped lowercase naming convention) cheap and localized.
+- **A single shared dev keypair in ServiceDefaults, so the demo needs zero key wiring.** Both the issuer (private) and the resource server (public) fall back to `DevJwtDefaults`, so register→login→verify works out of the box in Alba tests and the Aspire stack alike, while `Jwt:PrivateKey`/`Jwt:PublicKey` config overrides for prod. Live-verify confirmed the end-to-end token round-trip with no per-environment key setup.
+- **The layered cutover kept every pre-existing test green.** Because Orders sources `sub ?? X-Customer-Id` (rather than blanket-`[Authorize]`), all 99 prior Orders tests + the cross-BC smoke tests + the seeder path stayed green untouched — the cutover shipped without a big-bang rewrite of the whole test/seed surface, exactly the tradeoff the layered choice bought.
+- **Live-verify against the real stack found the wiring works end to end.** Booting the full Aspire stack and driving register (`201`) → login (`200`, a real RS256 JWT with `sub` = the id) → authenticated AddToCart+read (`200`, `customerId` == `sub`) → no-identity (`400`) → garbage token (`401`, decided locally) proved the ADR-023 behavior in the deployed topology, plus a CORS preflight confirming the SPA can reach Identity cross-origin.
+
+## What was harder / notable
+
+- **The Weasel-casing gotcha scales badly to framework-owned tables.** The existing `Customer`/`EmailChange` entities hand-map every column to lowercase (Weasel emits unquoted DDL that Postgres folds to lowercase; EF quotes PascalCase and misses it). ASP.NET Identity owns ~20 columns across `AspNetUsers` and its satellites — too many to hand-map — so the fix is a scoped lowercase naming **convention** applied only to `Microsoft.AspNetCore.Identity` entity types after `base.OnModelCreating`, leaving the Wolverine envelope tables and the CritterMart-owned tables (explicit maps) untouched. This is the same class of bug the repo already documents, now met with a convention instead of per-column mapping.
+- **`UserManager.CreateAsync` fights the transactional outbox.** Its EF `UserStore` defaults `AutoSaveChanges = true`, so `CreateAsync` commits the user row mid-handler — *before* Wolverine's `AutoApplyTransactions` commits the `Customer` row + the `CustomerRegistered` outbox message, splitting the write in two. To keep register-with-credentials genuinely all-or-nothing, the handler uses Identity's building blocks directly (`PasswordValidators` for the policy check, `PasswordHasher` to hash, `KeyNormalizer` for the lookup keys) and `db.Users.Add(user)` alongside `db.Customers.Add(...)`, so all three writes ride one Wolverine commit. A nice teaching beat (integrating a framework user store into an event-driven outbox), but it required knowing *not* to reach for the obvious `CreateAsync`.
+- **A test-only crypto-cache trap.** IdentityModel's global `CryptoProviderFactory` caches a `SignatureProvider` keyed by the key material and reuses it across calls with the same dev key — so a per-call `using var rsa` in the token-minting test helper left a cached provider holding a disposed key, throwing `ObjectDisposedException` on the *next* mint. The fix (don't dispose the per-call RSA; let the GC reclaim it) is test-only; the production `JwtTokenIssuer` holds its RSA for the process lifetime and never hit it.
+- **AppHost can't reference ServiceDefaults for the "distributed public key" story.** The plan was to have the AppHost literally pass `Jwt:PublicKey` from `DevJwtDefaults` to Orders (the visible config-distribution beat). But Aspire's AppHost gets only generated `Projects.*` metadata references, not transitive compile references, so `DevJwtDefaults` isn't in scope — and adding a direct ServiceDefaults reference would drag the ASP.NET framework into the host. Resolved by keeping the config-distribution mechanism in Orders' `Program.cs` (`Jwt:PublicKey` ?? dev default) with a pointer comment in the AppHost — honest ("distributed as configuration, with a dev default"), robust (no multiline-PEM env var through the DCP launcher), and no framework leak.
+- **The browser-UI drive was not performed (extension not connected).** The Claude-in-Chrome extension wasn't connected in this environment, so register→login→shop→logout was **not** driven through the actual SPA. Mitigated: the identical HTTP endpoints were driven directly (register/login/authenticated-request/401/400), the storefront was confirmed up (`200`) with a working cross-origin preflight to Identity, and the 112 frontend unit tests cover the client contract, the auth seam, and the mutation/query wiring. The visual UI flow is the one unverified surface — flagged, not silently skipped.
+
+## Methodology refinements
+
+- **Fetch framework docs before choosing the registration primitive, not after.** The `AddIdentity` vs `AddIdentityCore` and `CreateAsync` vs building-blocks decisions both turned on framework behavior (cookie coupling; `AutoSaveChanges`) that isn't obvious from the API names. A design pass that integrates a third-party framework into an existing transactional model should budget a ctx7 pass specifically on "what does this convenience method do under the hood" before committing the design.
+- **When a fork's answer changes the blast radius, quantify the blast radius in the option preview.** The layered-vs-hard cutover preview listed exactly which files/tests each option touched. That made "layered keeps the seeder/tests/demo-traffic green" a visible, decidable tradeoff rather than a claim — and the choice paid off precisely as the preview described (zero prior-test churn).
+- **A shared dev default beats per-environment secret wiring for a teaching demo.** Committing a loudly-marked dev-only keypair in one shared place (both halves, one file) made the asymmetric-key mechanism demoable with zero setup while keeping the prod-override path real. The honest edge (a committed dev private key) is recorded in `DevJwtDefaults`' own header and ADR 023's open Q17, not hidden.
+
+## Outstanding / next-session inputs
+
+- **The hard cutover (remove the `X-Customer-Id` fallback).** This PR is the *layered* cutover: `sub` is the trust boundary, but Orders still accepts `X-Customer-Id` when no token is presented, so the seeder, the pre-existing Orders/cross-BC tests, and `demo-traffic.ps1` keep working unchanged. The follow-up increment removes that fallback and migrates the seeder + those tests + demo-traffic to mint/carry real JWTs. **Note:** tasks.md planned to record this in `docs/skills/DEBT.md`, but that file is explicitly scoped to *skill-file* gaps; this is implementation debt, so it is recorded here (the retro's Outstanding section, the canonical home for deferred work) instead — an honest divergence from the prompt's plan.
+- **The browser-UI flow is unverified** (extension not connected) — see "What was harder." A future session with browser automation should drive register→login→add-to-cart→checkout→logout through the SPA and confirm the gating redirect + token persistence visually.
+- **Authorization (roles/policies) remains deferred** (ADR 023 open Q16) — the token authenticates, never authorizes; a second actor would open this.
+- **Refresh tokens + server-side revocation remain deferred** (open Q15) — logout is client-side discard; a token stays valid until its (short) lifetime expires.
+- **Pre-existing, unrelated:** `client/e2e/seeder.spec.ts` (a Playwright spec added in `2bc314f`) is collected by `vitest run` and fails with a Playwright-version collision — reproducible on `main`, untouched by this PR (neither `vite.config.ts` nor `e2e/` was modified). The 112 vitest unit tests all pass; the e2e/vitest collection collision is a separate repo-hygiene item.
+
+## Spec-delta — landed?
+
+**Yes.** The four ADDED `customer-registry` requirements — *register with credentials* (5.8), *log in and issue a JWT* (5.9), *verify a token at a resource server* (5.10), *log out* (5.11) — are authored in `openspec/changes/slices-5-8-5-11-identity-authentication/specs/customer-registry/spec.md` and satisfied by the code + tests this session. Workshop 002 v1.3 § 6 already carried the GWT scenarios; Narrative 010 is the human companion. `openspec archive slices-5-8-5-11-identity-authentication` is a post-merge tidy, per the customer-registry/saga precedent.
diff --git a/openspec/changes/slices-5-8-5-11-identity-authentication/design.md b/openspec/changes/slices-5-8-5-11-identity-authentication/design.md
new file mode 100644
index 0000000..b4e4fe1
--- /dev/null
+++ b/openspec/changes/slices-5-8-5-11-identity-authentication/design.md
@@ -0,0 +1,151 @@
+## Context
+
+ADR 023 fixes the architecture: ASP.NET Core Identity for the user store + credentials, a self-validated
+asymmetrically-signed JWT (`sub` = customer id) minted by Identity and verified **offline** by the resource
+servers against a config-distributed public key. This document records the *implementation* decisions that
+architecture leaves open — the ones Workshop 002 § 8 (open Q13–Q17) and the handoff delegated to this layer.
+API specifics were checked against current ASP.NET Core / Identity docs (ctx7 `/dotnet/aspnetcore.docs`,
+.NET 10) rather than assumed. See the proposal for Why/What and the `customer-registry` spec delta for the
+SHALLs.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Make Identity the sole auth issuer with the smallest honest surface: register-with-credentials, login→JWT,
+ logout (client-side), on the existing EF-Core/`identity`-schema store.
+- Verify tokens offline in Orders with zero HTTP into Identity — the load-bearing demonstration that ADR 001
+ survives real auth.
+- Ship in one consolidated PR with **green existing tests** (layered cutover), demoable end-to-end.
+
+**Non-Goals:**
+- No authorization (roles/policies) — open Q16, deferred until a second actor.
+- No refresh tokens, no server-side revocation/denylist — open Q15; logout is client discard.
+- No JWKS endpoint — ADR 023 chose config-distributed public key over a fetched JWKS (the last soft asterisk
+ on no-sync-HTTP).
+- No real secret store — dev keypair in config; open Q17.
+
+## Decisions
+
+### 1. Two id-linked tables, one `CustomerDbContext` deriving `IdentityUserContext`
+
+`Customer` stays exactly as it is — the registry row and the `CustomerRegistered` Published-Language contract
+shape, free of framework base classes. ASP.NET Core Identity's own user table (`AspNetUsers` and its
+satellites) sits alongside it, sharing the **same string id** (`IdentityUser.Id` is a string by default;
+`Customer.Id` is already a string). Register-with-credentials creates both with that shared id.
+
+The existing `IdentityDbContext` is **renamed `CustomerDbContext`** and now derives from
+`Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityUserContext`. This resolves the name
+collision the handoff flagged (a local `IdentityDbContext` next to ASP.NET's `IdentityDbContext`) by
+sidestepping the shared name entirely, and keeps **one** DbContext — so the Identity user insert, the
+`Customer` row insert, and the outbox enrollment all commit in **one** Wolverine-managed transaction (the
+all-or-nothing registration the spec requires). `IdentityUserContext` (user tables only) is chosen over
+`IdentityDbContext` (which adds `AspNetRoles`/`AspNetUserRoles`/`AspNetRoleClaims`) because authZ/roles are
+out of scope — no unused role tables. `OnModelCreating` calls `base.OnModelCreating(modelBuilder)` **first**
+(so the Identity entities are configured), then sets `HasDefaultSchema("identity")` and applies the existing
+lowercase-column mappings for `Customer`/`EmailChange`. Every rename site (`Program.cs`, endpoints, saga,
+tests) updates to `CustomerDbContext`.
+
+**Weasel + Identity tables.** The Identity tables ride the existing `UseEntityFrameworkCoreWolverineManagedMigrations`
+path. Weasel migrates tables/columns/PKs/FKs but **not secondary indexes** (the documented gotcha behind
+`ux_customers_email`). ASP.NET Identity's unique indexes on `NormalizedUserName`/`NormalizedEmail` will
+therefore be absent — **acceptable**: email uniqueness is already DB-enforced by `ux_customers_email` on the
+`customers` table (plus the app guard), and `UserManager.FindByEmailAsync` matches on the `NormalizedEmail`
+**column**, which Weasel does create (an unindexed scan, fine at demo scale). Verified live before the PR.
+
+### 2. Registration: layer, don't replace (open Q14 → layer)
+
+`RegisterWithCredentials` (`POST /register`) is a **new** endpoint; the spike's `RegisterCustomer`
+(`POST /customers`, no password) is **kept** for admin/seeder-provisioned customers. The seeder keeps
+registering `customer-demo` via `POST /customers` unchanged. Register-with-credentials reuses
+`RegisterCustomer`'s duplicate-email guard idiom (normalized-email `ValidateAsync` → `409`) and the same
+`CustomerRegistered` outbox cascade, adding the `UserManager.CreateAsync` user creation and password-policy
+rejection ahead of the row write.
+
+**Handler shape.** ASP.NET Identity's `UserManager`/`SignInManager` are async service calls, not pure
+functions — so register/login are ordinary Wolverine.HTTP endpoint methods that take `UserManager`
+/ `SignInManager` as injected parameters (the a-frame "service call in the handler" shape), not
+railway-pure handlers. The duplicate-email `ValidateAsync` guard stays a pre-handle railway check; the
+password-policy failure is surfaced from `CreateAsync`'s `IdentityResult` inside the handler (it needs the
+manager), returned as `400 ProblemDetails`. Because the user-create and the row-write must be one transaction,
+both happen in the endpoint body under `AutoApplyTransactions` — `UserManager.CreateAsync` writes through the
+same `CustomerDbContext`, and the `Customer` row + `CustomerRegistered` cascade commit with it.
+
+### 3. JWT issuance — `JsonWebTokenHandler`, RSA, clean `sub`
+
+Identity mints with the modern `Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler` +
+`SecurityTokenDescriptor`, signed with `SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256)`.
+The legacy `JwtSecurityTokenHandler` is avoided because it silently remaps `sub` → `ClaimTypes.NameIdentifier`;
+`JsonWebTokenHandler` preserves `sub` as `sub`. The descriptor carries `Subject` (a `ClaimsIdentity` with the
+`sub` claim = customer id), `Issuer`, `Audience`, and `Expires = now + JwtSettings.AccessTokenLifetime`.
+Issuance is encapsulated in a `JwtTokenIssuer` service (injected into `LogIn`), keeping token mechanics out of
+the endpoint.
+
+On the **resource server**, `AddJwtBearer` sets `TokenValidationParameters { ValidateIssuer, ValidateAudience,
+ValidateLifetime, IssuerSigningKey = }` and **`options.MapInboundClaims = false`** so
+the inbound `sub` is readable as `sub` (not remapped). No `Authority`/`MetadataAddress` is set — that is what
+keeps validation offline (no JWKS fetch).
+
+### 4. Signing keys — asymmetric RSA, config-distributed, dev-default committed
+
+One RSA keypair. Identity reads the **private** key PEM from `Jwt:PrivateKey`; Orders reads the **public** key
+PEM from `Jwt:PublicKey`. Both fall back to a **committed dev-only** keypair (a `JwtSigningKeys` helper with
+`static readonly` dev PEM constants, loudly marked DEV ONLY / regenerate-for-prod — the pattern mirrors
+`EmailChangeDeadline.Default` for "demo-workable default, config-overridable"). Consequences:
+- The Aspire demo and both services' Alba test hosts work with **zero** key wiring (they fall back to the same
+ dev pair). The AppHost additionally passes `Jwt:PublicKey`/`Jwt:Issuer`/`Jwt:Audience` to Orders as config,
+ making the "public key distributed as configuration" story **literal and visible** in `AppHost/Program.cs`
+ (a teaching beat), even though the fallback would also suffice.
+- Committing a dev private key is acceptable because it signs nothing of value in a local demo and is not a
+ real secret; prod supplies real key material via `Jwt:PrivateKey`/`Jwt:PublicKey` (user-secrets / secret
+ store). Rotation = redeploy config (ADR 023's accepted round-one tradeoff). This is the honest edge of
+ open Q17, recorded not hidden.
+
+`JwtSettings` (issuer, audience, access-token lifetime) is a config-bound singleton; `AccessTokenLifetime`
+defaults short (demo-paced, e.g. 1h) and is overridable — a smaller sibling of the `EmailChangeDeadline` /
+`PaymentDeadline` demo-knob pattern, though not one of the four demo knobs slated for post-talk deletion.
+
+### 5. The X-Customer-Id cutover — layered (Bearer wins, header is a dev fallback)
+
+Orders' customer-keyed endpoints today read `[FromHeader(Name = "X-Customer-Id")] string? customerId` and
+`400` on blank. The cutover introduces a small resolution helper that prefers the authenticated `sub` claim
+and falls back to the header: given the endpoint's `HttpContext`/`ClaimsPrincipal` and the header value, it
+returns `User.FindFirst("sub")?.Value ?? headerValue`, and the endpoint `400/401`s when neither is present.
+`AddAuthentication`/`AddJwtBearer` + `UseAuthentication`/`UseAuthorization` are added to Orders; endpoints are
+**not** blanket-`[Authorize]`d (that would break the fallback-header path the existing tests rely on) — instead
+the resolution helper enforces "must have an identity," so a request with neither a valid token nor a header is
+rejected. The header fallback is a transitional shim tracked for removal in `docs/skills/DEBT.md`; the
+**frontend stops sending it** and uses Bearer exclusively.
+
+Catalog and Inventory get **no** auth changes — Catalog's product reads carry no customer identity, and
+Inventory has no browser-facing customer-keyed endpoint (its customer id rides RabbitMQ message payloads from
+Orders, off the trust-boundary path entirely, exactly as ADR 023 notes).
+
+### 6. Frontend — Bearer, session token, gated checkout
+
+A `LoginPage`/`RegisterPage` post to Identity; the returned JWT is held in an in-memory + `sessionStorage`
+auth store; `useCurrentCustomer` decodes `sub` from the held token (returns unauthenticated when absent).
+`client.ts`'s `fetchParsed`/`postCommand`/`deleteCommand` send `Authorization: Bearer ` instead of
+`X-Customer-Id`. Browse (Catalog) works logged-out; cart/checkout/orders routes redirect to login when
+unauthenticated. Logout clears the store. Vitest coverage updates for the header→Bearer swap and the seam.
+
+## Risks / Trade-offs
+
+- **Weasel skips Identity's unique indexes** (Decision 1) — mitigated: email uniqueness already enforced on
+ `customers`; normalized-email lookup uses the column, not the index. Live-verify register+login before PR.
+- **Committed dev private key** (Decision 4) — a deliberate, loudly-marked demo affordance, not a secret;
+ prod overrides via config. Recorded in the ADR's open Q17 lineage.
+- **Layered fallback header** (Decision 5) — the trust boundary is `sub`; the header remains a bypass until
+ removed. Tracked in DEBT; the frontend no longer emits it, shrinking the real-world exposure to
+ hand-crafted requests in dev.
+- **`UserManager.CreateAsync` + row write in one transaction** — both go through `CustomerDbContext` under
+ `AutoApplyTransactions`; if `CreateAsync` fails policy/duplicate, the endpoint returns before adding the row,
+ so no partial state. Verified by the duplicate/weak-password tests.
+
+## Open questions resolved here
+
+- **Q13 entity shape + name collision** → two id-linked tables, one `CustomerDbContext : IdentityUserContext`.
+- **Q14 registration replace-or-layer** → layer (`/register` new, `/customers` kept).
+- **Q15 token lifetime / logout** → short configurable access-token lifetime; client-side-discard logout; no
+ refresh, no revocation (deferred).
+- **Q17 key management** → asymmetric RSA, config PEM with committed dev default; rotation by redeploy.
+- **Q16 authorization** → explicitly out of scope (no roles/policies this increment).
diff --git a/openspec/changes/slices-5-8-5-11-identity-authentication/proposal.md b/openspec/changes/slices-5-8-5-11-identity-authentication/proposal.md
new file mode 100644
index 0000000..07c0ad1
--- /dev/null
+++ b/openspec/changes/slices-5-8-5-11-identity-authentication/proposal.md
@@ -0,0 +1,120 @@
+## Why
+
+Identity is a customer registry with no authentication: identity arrives ambiently as a hardcoded
+`X-Customer-Id` header sourced behind the frontend's `useCurrentCustomer` seam (ADR 009). Anyone who can set
+that header is any customer. [ADR 023](../../../docs/decisions/023-real-authentication-for-identity.md) closes
+that stub: Identity becomes the system's sole **auth issuer**, built on **ASP.NET Core Identity**
+(`UserManager`/`SignInManager`, EF-Core-backed on the existing `identity` schema) — and on a successful
+password check it mints a standard, asymmetrically-signed **JWT** carrying the customer id in the `sub`
+claim. The SPA presents that JWT as `Authorization: Bearer …` to all four services; Catalog/Inventory/Orders
+verify it **fully offline** against Identity's config-distributed public key. This honors the
+[no-synchronous-service-to-service-HTTP non-negotiable](../../../docs/decisions/001-separate-services-topology.md)
+(ADR 001) — there is **no HTTP into Identity**, per-request or at startup — and retires `X-Customer-Id` as
+the trust boundary, the `sub` claim replacing it.
+
+Two framings survive unchanged and are load-bearing: **(1)** auth is still non-event-sourced — ASP.NET Core
+Identity is a relational user store, so this **extends** the boring-CRUD foil (ADR 009 amendment /
+[ADR 022](../../../docs/decisions/022-convention-sagas-additive-to-pmvh.md)), it does not reverse it; there
+are **zero stream events and no saga** in this increment. **(2)** the no-sync-HTTP rule survives real auth —
+offline JWT validation against a config public key adds no cross-BC runtime edge. Modeled in Workshop 002
+v1.3 (slices 5.8–5.11); the architecture is fixed by ADR 023 and is not re-litigated here.
+
+## What Changes
+
+Identity gains ASP.NET Core Identity (user store + password handling) and JWT issuance; the three resource
+servers gain offline JWT verification. The four slices:
+
+- **Identity (5.8 — register with credentials).** `RegisterWithCredentials { email, displayName, password }`
+ at `POST /register` creates an ASP.NET Core Identity user (`UserManager.CreateAsync`, password hashed) **and**
+ the registry `Customer` row with the **same** string id, and publishes `CustomerRegistered` via the EF-Core
+ outbox — registration is all-or-nothing. Rejects a duplicate email (`409 CustomerAlreadyRegistered`,
+ mirroring slice 5.1) and a password that fails the configured policy (`400`, Identity's validation errors).
+ The spike's open `POST /customers` (`RegisterCustomer`, no password) is **kept** for admin/seeder-provisioned
+ customers (they coexist — resolves Workshop 002 § 8 open Q14 in favor of layering, not replacement).
+- **Identity (5.9 — log in, issue a JWT).** `LogIn { email, password }` at `POST /login` runs
+ `SignInManager.CheckPasswordSignInAsync`; on success it mints a JWT (`sub` = customer id, plus issuer,
+ audience, and a configured lifetime), signed with Identity's asymmetric **private** key, and returns it. Bad
+ credentials — wrong password **or** unknown email, indistinguishably (no user enumeration) — return `401`.
+- **Resource servers (5.10 — verify a token).** *Code lands in Orders* (the customer-keyed BC; Catalog's
+ product reads carry no identity, Inventory has no browser-facing customer endpoints). Orders configures
+ `AddJwtBearer` with Identity's **public key from configuration** and validates every token **offline** —
+ signature, issuer, audience, lifetime — reading the customer id from `sub`. A valid token authenticates the
+ request as that customer; a missing/tampered/expired token is rejected **locally** with `401`, no HTTP into
+ Identity.
+- **Identity/frontend (5.11 — log out).** The JWT is stateless, so logout is **client-side token discard**:
+ the frontend drops the token and `useCurrentCustomer` returns to unauthenticated. No server-side revocation
+ (deferred — Workshop 002 § 8 open Q15).
+
+### The X-Customer-Id cutover (layered — decided this session)
+
+Slice 5.10 retires `X-Customer-Id` **as the trust boundary**: when a valid Bearer token is present, the `sub`
+claim is the authenticated customer id and wins. To keep the seeder, the existing Orders/cross-BC integration
+tests, and `demo-traffic.ps1` green in one PR, the `X-Customer-Id` header is **retained as a dev-only compat
+fallback** on Orders' customer-keyed endpoints — used only when no Bearer token is presented. This is a
+transitional shim, not the trust boundary; its removal is tracked in `docs/skills/DEBT.md`. The **frontend
+cuts fully to Bearer** (it no longer sends `X-Customer-Id`).
+
+### Design decisions carried into this change (full detail in `design.md`)
+
+- **Two id-linked tables, one DbContext.** `Customer` stays the clean registry row (and the `CustomerRegistered`
+ Published-Language contract shape); ASP.NET Core Identity's own user table sits alongside, sharing the same
+ string id. The existing `IdentityDbContext` is **renamed `CustomerDbContext`** and **derives from**
+ `IdentityUserContext` — one context, one transaction, one outbox — resolving both the entity-shape
+ question (Workshop 002 § 8 open Q13) and the `IdentityDbContext`-vs-`Microsoft.AspNetCore.Identity…IdentityDbContext`
+ name collision the handoff flagged.
+- **Asymmetric RSA keypair, config-distributed public key.** Identity holds the private key (mint); Orders holds
+ the public key as config (verify offline). A committed **dev-only** keypair is the fallback default so the
+ Aspire demo and Alba tests work with zero extra wiring; `Jwt:PrivateKey` / `Jwt:PublicKey` config overrides for
+ prod. Rotation = redeploy config (ADR 023's accepted round-one tradeoff; open Q17).
+- **Browse anonymous, checkout gated.** Catalog stays anonymous; Orders' customer-keyed endpoints require an
+ authenticated customer (via Bearer `sub` or the fallback header). The frontend gates cart/checkout behind login.
+
+## Capabilities
+
+### New Capabilities
+
+(None — Identity's single capability `customer-registry` gains authentication behavior; auth is not a new
+capability, per one-capability-per-aggregate, CLAUDE.md § 4a.)
+
+### Modified Capabilities
+
+- `customer-registry`: Identity becomes the system's auth issuer. Registration gains a credentialed variant
+ that creates an ASP.NET Core Identity user beside the `Customer` row; a new login flow mints a self-validated
+ JWT; resource servers verify that token offline and source the authenticated customer id from `sub`; logout
+ is client-side token discard. Registration/resolution (5.1/5.2) and the email-change saga (5.5–5.7) are
+ unchanged. (Four ADDED requirements: register with credentials, log in and issue a JWT, verify a token at a
+ resource server, log out.)
+
+## Impact
+
+- **Identity (issuer).** New `Features/RegisterWithCredentials.cs` (`POST /register`), `Features/LogIn.cs`
+ (`POST /login`), `Features/LogOut.cs` (`POST /logout`, informational). New `Auth/` support: `IdentityUser`
+ registration via `AddIdentityCore().AddSignInManager().AddEntityFrameworkStores()`;
+ a `JwtTokenIssuer` (mints with `JsonWebTokenHandler` + `RsaSecurityKey`); `JwtSigningKeys`/`JwtSettings`
+ config helpers (dev-default keypair, mirrors `EmailChangeDeadline.Default`). `IdentityDbContext` →
+ `CustomerDbContext` (renamed; derives `IdentityUserContext`; `base.OnModelCreating` called).
+ `Program.cs` wires Identity + issuer.
+- **Orders (resource server, slice 5.10).** `AddAuthentication().AddJwtBearer` (public key from config,
+ `MapInboundClaims = false`), `AddAuthorization`, `UseAuthentication()/UseAuthorization()`. Each customer-keyed
+ endpoint sources the customer id from `sub` when a token is present, else the `X-Customer-Id` fallback (a small
+ shared helper). `AppHost` passes the dev public key + issuer/audience to Orders as config.
+- **Frontend.** Login/register/logout UI; token held in a session store; `useCurrentCustomer` sourced from the
+ decoded token's `sub`; `client.ts` sends `Authorization: Bearer` (drops `X-Customer-Id`); cart/checkout gated
+ behind an authenticated session, Catalog browsing anonymous.
+- **Packages.** `Microsoft.AspNetCore.Identity.EntityFrameworkCore` + `Microsoft.IdentityModel.JsonWebTokens`
+ (Identity); `Microsoft.AspNetCore.Authentication.JwtBearer` (Orders) — pinned in `Directory.Packages.props`
+ on the .NET 10 line.
+- **Persistence.** ASP.NET Core Identity's user tables ride the existing `UseEntityFrameworkCoreWolverineManagedMigrations`
+ path (Weasel migrates tables/columns/PKs/FKs; its known skip of secondary indexes is acceptable — email
+ uniqueness is already enforced by `ux_customers_email` on the `customers` table + the app guard). No new
+ event, no stream, no projection.
+- **Tests.** Identity: register-with-credentials (happy, duplicate-email, weak-password), login (happy JWT
+ shape, bad-credentials 401 no-enumeration). Orders: valid token authenticates (`sub` sourced), invalid/expired
+ token 401, and the `X-Customer-Id` fallback still works. Existing Orders/cross-BC/Identity tests unchanged
+ (layered cutover).
+- **Docs (same PR).** `design.md` + `tasks.md` here; the customer-auth narrative; the implementation prompt +
+ retro; CLAUDE.md's "frontend sends a hardcoded `X-Customer-Id`" non-negotiable line updated; a `DEBT.md` row
+ for the fallback-header removal.
+- **Out of scope.** Authorization (roles/policies — open Q16); refresh tokens and server-side revocation (open
+ Q15); a JWKS endpoint (ADR 023 chose config over JWKS); Catalog/Inventory browser-facing auth (no customer-keyed
+ endpoints there); real secret-store key management (open Q17). No payment, no new BC.
diff --git a/openspec/changes/slices-5-8-5-11-identity-authentication/specs/customer-registry/spec.md b/openspec/changes/slices-5-8-5-11-identity-authentication/specs/customer-registry/spec.md
new file mode 100644
index 0000000..7fa7def
--- /dev/null
+++ b/openspec/changes/slices-5-8-5-11-identity-authentication/specs/customer-registry/spec.md
@@ -0,0 +1,116 @@
+## ADDED Requirements
+
+### Requirement: Register a customer with credentials
+
+The system SHALL expose `POST /register` accepting `RegisterWithCredentials { email, displayName, password }`.
+When the email is not already registered and the password satisfies the configured ASP.NET Core Identity
+password policy, the system SHALL create an ASP.NET Core Identity user (password hashed via
+`UserManager.CreateAsync`) **and** a registry `Customer` row carrying the **same** string id, SHALL enroll
+`CustomerRegistered { customerId, email, displayName }` in the EF-Core transactional outbox **in the same
+transaction**, and SHALL respond `201 Created` with `Location: /customers/{id}`. Email SHALL be normalized
+(`Trim().ToLowerInvariant()`) exactly as `RegisterCustomer` normalizes it. When a customer/user already exists
+for the normalized email, the system SHALL reject the command with `409 CustomerAlreadyRegistered` — no user,
+no row, no event; idempotent. When the password fails the configured policy, the system SHALL reject the
+command with `400` carrying the Identity validation errors — no user, no row, no event. Registration SHALL be
+all-or-nothing: the Identity user and the `Customer` row are created together or not at all. The pre-existing
+`POST /customers` (`RegisterCustomer`, no password) SHALL remain available for admin/seeder-provisioned
+customers.
+
+#### Scenario: Register with credentials creates a user and a customer row
+
+- **GIVEN** no customer or user exists for email `ada@example.com`
+- **WHEN** `RegisterWithCredentials { email: "ada@example.com", displayName: "Ada Lovelace", password: "" }` is issued to `POST /register`
+- **THEN** an ASP.NET Core Identity user is created with the password hashed
+- **AND** a `Customer` row is written with the same string id as the Identity user
+- **AND** `CustomerRegistered { customerId, email: "ada@example.com", displayName: "Ada Lovelace" }` is enrolled in the EF-Core outbox in the same transaction
+- **AND** the response is `201 Created` with `Location: /customers/{id}`
+
+#### Scenario: Reject a duplicate email
+
+- **GIVEN** a customer/user already exists for email `ada@example.com`
+- **WHEN** `RegisterWithCredentials { email: "Ada@Example.com", … }` is issued again
+- **THEN** the command is rejected with `409 CustomerAlreadyRegistered`
+- **AND** no Identity user is created, no `Customer` row is written, and no `CustomerRegistered` event is published
+
+#### Scenario: Reject a password that fails policy
+
+- **GIVEN** a password that fails the configured ASP.NET Core Identity password policy
+- **WHEN** `RegisterWithCredentials { email: "grace@example.com", displayName: "Grace Hopper", password: "weak" }` is issued
+- **THEN** the command is rejected with `400` carrying the Identity validation errors
+- **AND** no Identity user is created, no `Customer` row is written, and no `CustomerRegistered` event is published
+
+### Requirement: Log in and issue a JWT
+
+The system SHALL expose `POST /login` accepting `LogIn { email, password }`. When an ASP.NET Core Identity
+user exists for the normalized email and `SignInManager.CheckPasswordSignInAsync` succeeds, the system SHALL
+mint and return a standard JSON Web Token whose `sub` claim is the customer id, carrying the configured
+issuer and audience and an expiry set by the configured access-token lifetime, signed with Identity's
+asymmetric **private** key (RSA). The token SHALL NOT be persisted. When the email is unknown **or** the
+password is wrong, the system SHALL respond `401 Unauthorized` with **no** token and SHALL NOT distinguish the
+two cases (no user enumeration).
+
+#### Scenario: Successful login issues a signed JWT
+
+- **GIVEN** customer `c-1` is registered with email `ada@example.com` and a known password
+- **WHEN** `LogIn { email: "ada@example.com", password: "" }` is issued to `POST /login`
+- **THEN** the response is `200 OK` carrying a JWT whose `sub` claim is `c-1`
+- **AND** the JWT carries the configured issuer, audience, and an expiry, and is signed with Identity's private RSA key
+- **AND** no row is changed and the token is not persisted
+
+#### Scenario: Bad credentials are rejected without enumeration
+
+- **GIVEN** either no user exists for `nobody@example.com`, or `ada@example.com` exists with a different password
+- **WHEN** `LogIn { … }` is issued with those credentials
+- **THEN** the response is `401 Unauthorized` with no token
+- **AND** the response body does not distinguish "unknown email" from "wrong password"
+
+### Requirement: Verify a token at a resource server
+
+A resource server (Orders) SHALL configure JWT bearer authentication with Identity's **public key distributed
+as configuration** and SHALL validate every presented `Authorization: Bearer` token **fully offline** —
+signature against the public key, issuer, audience, and lifetime — with **no HTTP call into Identity**,
+per-request or at startup. When a request carries a valid, unexpired token, the resource server SHALL treat
+the token's `sub` claim as the authenticated customer id and SHALL use it as the trust boundary in place of
+`X-Customer-Id`. When a request to a customer-keyed endpoint carries a missing, tampered, wrong-issuer,
+wrong-audience, or expired token **and** no accepted fallback identity, the resource server SHALL reject it
+locally with `401 Unauthorized`. During the layered cutover, the resource server MAY accept an `X-Customer-Id`
+header as a **dev-only fallback** for the authenticated customer id when no Bearer token is presented; this
+fallback is transitional and is not the trust boundary.
+
+#### Scenario: A valid token authenticates the request as its subject
+
+- **GIVEN** Orders is configured with Identity's public key (config), and a request carries `Authorization: Bearer `
+- **WHEN** the request reaches a customer-keyed Orders endpoint
+- **THEN** the token is validated offline (signature, issuer, audience, lifetime)
+- **AND** the request proceeds as customer `c-1`, sourced from the token's `sub` claim
+- **AND** no HTTP call is made into Identity
+
+#### Scenario: An invalid or expired token is rejected locally
+
+- **GIVEN** a request carrying a missing, tampered, wrong-issuer/audience, or expired token, and no fallback identity
+- **WHEN** it reaches a customer-keyed Orders endpoint
+- **THEN** the response is `401 Unauthorized`, decided locally
+- **AND** no HTTP call is made into Identity
+
+#### Scenario: The dev-only X-Customer-Id fallback still resolves identity during the cutover
+
+- **GIVEN** a request to a customer-keyed Orders endpoint carrying no Bearer token but an `X-Customer-Id: c-1` header
+- **WHEN** it reaches the endpoint
+- **THEN** the request proceeds as customer `c-1` from the fallback header
+- **AND** this path is a transitional dev-only compat shim, not the trust boundary
+
+### Requirement: Log out
+
+Because the issued JWT is stateless, the system SHALL treat logout as **client-side token discard**: the
+frontend discards the held token and the `useCurrentCustomer` seam returns to unauthenticated, so subsequent
+requests carry no bearer and customer-keyed endpoints respond `401`. The system SHALL NOT maintain a
+server-side session or token denylist in this increment; a discarded token remains cryptographically valid
+until it expires (server-side revocation is a deferred future increment).
+
+#### Scenario: Logout discards the token client-side
+
+- **GIVEN** customer `c-1` holds a valid JWT and is authenticated in the SPA
+- **WHEN** the customer logs out
+- **THEN** the client discards the token and `useCurrentCustomer` returns to unauthenticated
+- **AND** subsequent requests to customer-keyed endpoints carry no bearer and respond `401`
+- **AND** no server-side session or denylist state is maintained
diff --git a/openspec/changes/slices-5-8-5-11-identity-authentication/tasks.md b/openspec/changes/slices-5-8-5-11-identity-authentication/tasks.md
new file mode 100644
index 0000000..7ea70aa
--- /dev/null
+++ b/openspec/changes/slices-5-8-5-11-identity-authentication/tasks.md
@@ -0,0 +1,100 @@
+# Tasks: slices-5-8-5-11-identity-authentication (slices 5.8–5.11)
+
+Consolidated into **one PR** (OpenSpec proposal/specs/design/tasks + narrative + prompt + code + tests +
+retro), per Erik's slice-PR preference. Architecture fixed by ADR 023; forks resolved at session start
+(entity shape = two id-linked tables/one context; cutover = layered; gate = browse-anon/checkout-gated).
+
+## Packages
+
+- [x] `Directory.Packages.props`: add `Microsoft.AspNetCore.Identity.EntityFrameworkCore`,
+ `Microsoft.IdentityModel.JsonWebTokens` (Identity), `Microsoft.AspNetCore.Authentication.JwtBearer`
+ (Orders) — .NET 10 line.
+- [x] `CritterMart.Identity.csproj`: reference the two Identity packages.
+- [x] `CritterMart.Orders.csproj`: reference JwtBearer.
+
+## Identity — issuer (slices 5.8 / 5.9 / 5.11)
+
+- [x] Rename `Customers/IdentityDbContext.cs` → `Customers/CustomerDbContext.cs`; derive
+ `IdentityUserContext`; `base.OnModelCreating` first, then `HasDefaultSchema` + existing
+ `Customer`/`EmailChange` mappings. Update all references (`Program.cs`, `RegisterCustomer`,
+ `GetCustomer`, `EmailChange`, `RequestEmailChange`, `ConfirmEmailChange`, tests).
+- [x] `Auth/JwtSettings.cs` — config-bound record (`Issuer`, `Audience`, `AccessTokenLifetime`) + demo default.
+- [x] ~~`Auth/JwtSigningKeys.cs`~~ **→ `ServiceDefaults/DevJwtDefaults.cs`** — the committed **DEV-ONLY**
+ keypair (both halves) lives in ServiceDefaults so issuer + resource server share one source; RSA is
+ loaded inline via `RSA.ImportFromPem(config["Jwt:*"] ?? DevJwtDefaults.*)` where needed, not a per-service
+ key class.
+- [x] `Auth/JwtTokenIssuer.cs` — mint via `JsonWebTokenHandler` + `SecurityTokenDescriptor` (sub, iss, aud,
+ exp), RSA-SHA256.
+- [x] `Program.cs` — `AddIdentityCore(pwd policy).AddSignInManager().AddEntityFrameworkStores()`;
+ `AddAuthentication()` (for SignInManager's scheme-provider dependency); bind `JwtSettings`; register
+ `JwtTokenIssuer`. (No cookie auth; no `[Authorize]` on the issuer — it mints, doesn't gate.)
+- [x] `Features/RegisterWithCredentials.cs` — `POST /register`: `ValidateAsync` duplicate-email guard (409);
+ **Identity building blocks** (`PasswordValidators` → 400, `PasswordHasher`, `KeyNormalizer`) + `db.Users.Add`
+ + `Customer` row + `CustomerRegistered` cascade, ONE Wolverine transaction (NOT `UserManager.CreateAsync`,
+ which self-commits mid-handler — see retro); `201 Created`.
+- [x] `Features/LogIn.cs` — `POST /login`: `SignInManager.CheckPasswordSignInAsync`; success → `JwtTokenIssuer`
+ → `{ token, … }`; failure → `401`, no enumeration.
+- [x] `Features/LogOut.cs` — `POST /logout`: informational `200` (stateless; client discards). Doc-comment the
+ client-side-discard semantics + deferred revocation.
+
+## Orders — resource server (slice 5.10)
+
+- [x] `Program.cs` — `AddAuthentication(JwtBearerDefaults).AddJwtBearer` with public `RsaSecurityKey` from
+ config (dev fallback), `ValidateIssuer/Audience/Lifetime`, `MapInboundClaims = false`;
+ `AddAuthorization`; `UseAuthentication()` + `UseAuthorization()` before `MapWolverineEndpoints`.
+- [x] `Auth/CustomerIdentity.cs` (or a small helper) — resolve customer id: `sub` claim ?? `X-Customer-Id`
+ fallback; endpoints `401`/`400` when neither present.
+- [x] Update customer-keyed endpoints (`AddToCart`, `ViewMyCart`, `PlaceOrder`, `ListMyOrders`, cart edits) to
+ source the customer id via the helper (accept `ClaimsPrincipal`/`HttpContext` + keep the header param as
+ fallback).
+- [x] Orders public-key load — inline `RSA.ImportFromPem(config["Jwt:PublicKey"] ?? DevJwtDefaults.DevPublicKeyPem)`
+ in `Program.cs`; **no** private key on the resource server (verify-only).
+
+## AppHost + config
+
+- [x] `AppHost/Program.cs` — add `VITE_IDENTITY_URL` + `.WaitFor(identity)` for the SPA, and Identity's SPA-origin
+ CORS entry. **Divergence:** the AppHost does NOT pass `Jwt:PublicKey` — it can't reference `DevJwtDefaults`
+ (Aspire gives only `Projects.*` metadata refs, not compile refs), and a multiline-PEM env var through the DCP
+ launcher is fragile. The config-distribution mechanism lives in Orders' `Program.cs` (`Jwt:PublicKey` ?? dev
+ default) with a pointer comment in the AppHost. See retro.
+- [x] Config — issuer/audience/lifetime + keys all resolve from the shared `DevJwtDefaults` unless overridden by
+ `Jwt:*` config; no per-service `appsettings` `Jwt` section was needed (dev default suffices for demo + tests).
+
+## Frontend (slices 5.9 / 5.11 + cutover)
+
+- [x] `identity/authStore.ts` (or context) — hold JWT (memory + `sessionStorage`); `login`/`register`/`logout`.
+- [x] `identity/useCurrentCustomer.tsx` — source `sub` from the held token; unauthenticated when absent.
+- [x] `api/client.ts` — send `Authorization: Bearer ` (drop `X-Customer-Id`); update `RequestContext`.
+- [x] `identity/LoginPage.tsx` / `RegisterPage.tsx` + routes; gate cart/checkout/orders behind auth (redirect to
+ login); Catalog browse stays anonymous; logout control.
+- [x] Vitest: update `client.test.ts` (Bearer), seam tests; add login/register/logout coverage.
+
+## Tests (backend)
+
+- [x] Identity: `RegisterWithCredentialsTests` (happy → user+row+outbox `201`; duplicate `409`; weak-password
+ `400`, no user/row/event); `LogInTests` (happy → JWT with `sub`=id, valid signature/claims; bad creds
+ `401` no-enumeration). Extend `IdentityAppFixture` if it needs `Jwt:*` env (dev default should suffice).
+- [x] Orders: `TokenAuthTests` — valid Bearer authenticates (`sub` sourced), invalid/expired `401`,
+ `X-Customer-Id` fallback still resolves. Test host mints tokens with the matching dev/test key.
+- [x] `dotnet build` (whole repo) zero errors; `dotnet test` (whole repo) green incl. all pre-existing
+ Catalog/Inventory/Orders/CrossBc/Identity tests (layered cutover = no regressions).
+
+## Live-verify (real Aspire stack, demo-runbook)
+
+- [x] Boot the stack; `POST /register` (201) → `POST /login` (200, RS256 JWT, `sub`=id) → authenticated Orders
+ AddToCart+read (200, `customerId`==`sub`) → no-identity (400) → garbage token (401, local). Plus storefront
+ up (200) + Identity CORS preflight echoes the SPA origin. Confirmed offline validation (no HTTP into Identity).
+- [ ] ~~Drive it from the SPA in a browser~~ — **NOT done** (Claude-in-Chrome extension not connected this
+ session). Mitigated by the HTTP drive above + 112 frontend unit tests. Flagged in the retro's Outstanding.
+
+## Artifacts (same PR)
+
+- [x] `docs/narratives/NNN-customer-authentication.md` (v1.0) + narratives README count.
+- [x] `docs/prompts/implementations/NNN-slices-5-8-5-11-identity-authentication.md` + prompts README count.
+- [x] `docs/retrospectives/implementations/NNN-…md` — spec-delta closure (4 ADDED `customer-registry`
+ requirements landed); Weasel-index + fallback-header findings.
+- [x] CLAUDE.md — update the "frontend sends a hardcoded `X-Customer-Id`" non-negotiable line (now Bearer/`sub`).
+- [x] ~~`docs/skills/DEBT.md` row~~ **→ retro Outstanding section.** The fallback-header removal is *implementation*
+ debt; `DEBT.md` is scoped to *skill-file* gaps, so the hard-cutover follow-up is recorded in the retro's
+ Outstanding/next-session-inputs instead (honest divergence — see retro).
+- [x] Post-merge: `openspec archive slices-5-8-5-11-identity-authentication -y`.
diff --git a/src/CritterMart.AppHost/Program.cs b/src/CritterMart.AppHost/Program.cs
index a23a973..39e3871 100644
--- a/src/CritterMart.AppHost/Program.cs
+++ b/src/CritterMart.AppHost/Program.cs
@@ -94,6 +94,12 @@
// OrderPaymentTimeout self-message fires and cancels it (Bruun temporal automation, slice 4.7).
// Default (when unset): 10 minutes (PaymentDeadline.Default in OrderPaymentTimeout.cs).
.WithEnvironment("Orders__PaymentTimeout", "00:07:00");
+// Auth (ADR 023, slice 5.10): Orders is a RESOURCE SERVER — it validates Identity's JWT OFFLINE against
+// Identity's PUBLIC key, "distributed as configuration" (Jwt:PublicKey), with NO HTTP into Identity. That
+// config-distribution lives in Orders' Program.cs, which reads Jwt:PublicKey and falls back to the shared
+// DEV-ONLY key (CritterMart.ServiceDefaults.DevJwtDefaults) so the demo needs zero key wiring here; prod
+// supplies Jwt__PublicKey/Jwt__Issuer/Jwt__Audience with real key material. The asymmetry is the point:
+// Orders holds only the public key (verify); Identity alone holds the private key (mint).
// Identity — the ONE service that is NOT event-sourced: a deliberately boring EF Core customer
// registry on the shared Postgres, proving Wolverine's handler model is persistence-agnostic
@@ -146,9 +152,13 @@
.WithEnvironment("VITE_CATALOG_URL", catalog.GetEndpoint("http"))
.WithEnvironment("VITE_INVENTORY_URL", inventory.GetEndpoint("http"))
.WithEnvironment("VITE_ORDERS_URL", orders.GetEndpoint("http"))
+ // Identity is now browser-facing too (ADR 023, slices 5.8/5.9/5.11): the SPA POSTs register/login/logout
+ // to it and holds the returned JWT. Injected exactly like the other service URLs (ADR 018 — no BFF).
+ .WithEnvironment("VITE_IDENTITY_URL", identity.GetEndpoint("http"))
.WaitFor(catalog)
.WaitFor(inventory)
.WaitFor(orders)
+ .WaitFor(identity)
// Hold the Vite dev server until the seeder has exited — products are in the catalog before
// any browser tab can open. WaitForCompletion waits for process exit (any exit code), so a
// seed hiccup still shows red on the dashboard but the storefront starts regardless.
@@ -164,5 +174,8 @@
catalog.WithEnvironment("Cors__AllowedOrigins__0", storefrontOrigin);
inventory.WithEnvironment("Cors__AllowedOrigins__0", storefrontOrigin);
orders.WithEnvironment("Cors__AllowedOrigins__0", storefrontOrigin);
+// Identity is browser-facing as of the auth slices (register/login/logout are cross-origin POSTs from the
+// SPA), so it joins the CORS allowlist too.
+identity.WithEnvironment("Cors__AllowedOrigins__0", storefrontOrigin);
builder.Build().Run();
diff --git a/src/CritterMart.Identity/Auth/JwtSettings.cs b/src/CritterMart.Identity/Auth/JwtSettings.cs
new file mode 100644
index 0000000..3e5a177
--- /dev/null
+++ b/src/CritterMart.Identity/Auth/JwtSettings.cs
@@ -0,0 +1,18 @@
+using CritterMart.ServiceDefaults;
+
+namespace CritterMart.Identity.Auth;
+
+// Config-bound settings for the issued JWT (ADR 023, slice 5.9): the issuer + audience the token is stamped
+// with (and the resource servers validate against) and the access-token lifetime. Bound from the `Jwt`
+// config section in Program.cs, falling back to the shared dev defaults so the demo + tests need no wiring.
+//
+// A demo-paced default lifetime (1h) keeps the login → expire → 401 path observable without a multi-day wait,
+// config-overridable for prod — the auth sibling of the EmailChangeDeadline / PaymentDeadline "demo-workable
+// default, config-overridable" pattern (though NOT one of the four AppHost demo knobs slated for post-talk
+// deletion — a short access-token TTL is realistic, not a demo hack).
+public record JwtSettings
+{
+ public string Issuer { get; init; } = DevJwtDefaults.Issuer;
+ public string Audience { get; init; } = DevJwtDefaults.Audience;
+ public TimeSpan AccessTokenLifetime { get; init; } = TimeSpan.FromHours(1);
+}
diff --git a/src/CritterMart.Identity/Auth/JwtTokenIssuer.cs b/src/CritterMart.Identity/Auth/JwtTokenIssuer.cs
new file mode 100644
index 0000000..ad74437
--- /dev/null
+++ b/src/CritterMart.Identity/Auth/JwtTokenIssuer.cs
@@ -0,0 +1,57 @@
+using System.Security.Cryptography;
+using CritterMart.ServiceDefaults;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+
+namespace CritterMart.Identity.Auth;
+
+// Mints the self-validated JWT (ADR 023, slice 5.9). Identity is the system's SOLE issuer: it alone holds
+// the RSA PRIVATE key and can sign; the resource servers hold only the public key and can only verify
+// offline. This is the "issuer" half of the issuer/resource-server split.
+//
+// Uses the MODERN Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler, deliberately NOT the legacy
+// System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler — the legacy handler silently remaps the `sub`
+// claim to ClaimTypes.NameIdentifier on the way out, which (paired with inbound claim-mapping on the
+// resource server) turns "read `sub`" into a guessing game. JsonWebTokenHandler + a raw `sub` in the claims
+// dictionary keeps `sub` readable as `sub` end-to-end. The resource server pairs this with
+// options.MapInboundClaims = false (see Orders' AddJwtBearer).
+//
+// Registered as a singleton: the RSA key + SigningCredentials are built once at startup from `Jwt:PrivateKey`
+// config (falling back to the shared dev key) and reused for every token.
+public sealed class JwtTokenIssuer
+{
+ private static readonly JsonWebTokenHandler Handler = new();
+ private readonly JwtSettings _settings;
+ private readonly SigningCredentials _credentials;
+
+ public JwtTokenIssuer(JwtSettings settings, IConfiguration config)
+ {
+ _settings = settings;
+
+ var rsa = RSA.Create();
+ // Private key = mint. Prod supplies real key material via Jwt:PrivateKey; dev falls back to the
+ // committed dev key so the demo/tests work with zero wiring (DevJwtDefaults documents the DEV-ONLY
+ // caveat). The RSA instance is held for the process lifetime by the SigningCredentials below.
+ rsa.ImportFromPem(config["Jwt:PrivateKey"] ?? DevJwtDefaults.DevPrivateKeyPem);
+ _credentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256);
+ }
+
+ // Mint a token whose `sub` is the customer id, stamped with the configured issuer/audience/lifetime.
+ public string Issue(string customerId)
+ {
+ var now = DateTime.UtcNow;
+ var descriptor = new SecurityTokenDescriptor
+ {
+ Issuer = _settings.Issuer,
+ Audience = _settings.Audience,
+ IssuedAt = now,
+ NotBefore = now,
+ Expires = now.Add(_settings.AccessTokenLifetime),
+ // The customer id as a raw `sub` claim (dictionary form avoids ClaimsIdentity's type-mapping).
+ Claims = new Dictionary { ["sub"] = customerId },
+ SigningCredentials = _credentials,
+ };
+
+ return Handler.CreateToken(descriptor);
+ }
+}
diff --git a/src/CritterMart.Identity/CritterMart.Identity.csproj b/src/CritterMart.Identity/CritterMart.Identity.csproj
index 32428ed..7582e30 100644
--- a/src/CritterMart.Identity/CritterMart.Identity.csproj
+++ b/src/CritterMart.Identity/CritterMart.Identity.csproj
@@ -3,10 +3,16 @@
+ store (ADR 009) that ALSO — per ADR 023 — becomes the system's auth issuer (below). -->
+
+
+
diff --git a/src/CritterMart.Identity/Customers/ConfirmEmailChange.cs b/src/CritterMart.Identity/Customers/ConfirmEmailChange.cs
index 9b636e2..60cf4c2 100644
--- a/src/CritterMart.Identity/Customers/ConfirmEmailChange.cs
+++ b/src/CritterMart.Identity/Customers/ConfirmEmailChange.cs
@@ -21,7 +21,7 @@ public static class ConfirmEmailChangeEndpoint
// — it falls through to EmailChange.NotFound's silent no-op, which is the more faithful reading of the
// spec's "silent no-op" than surfacing a 404 (verified empirically: IMessageBus.InvokeAsync does not
// translate a saga's NotFound into an HTTP 404 — Post below simply completes and returns 200).
- public static async Task ValidateAsync(ConfirmEmailChange command, IdentityDbContext db)
+ public static async Task ValidateAsync(ConfirmEmailChange command, CustomerDbContext db)
{
var saga = await db.EmailChanges.FindAsync(command.CustomerId);
if (saga is null)
diff --git a/src/CritterMart.Identity/Customers/CustomerDbContext.cs b/src/CritterMart.Identity/Customers/CustomerDbContext.cs
new file mode 100644
index 0000000..cbaf58e
--- /dev/null
+++ b/src/CritterMart.Identity/Customers/CustomerDbContext.cs
@@ -0,0 +1,129 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace CritterMart.Identity.Customers;
+
+// The EF Core unit of work for Identity. AddDbContextWithWolverineIntegration in
+// Program.cs registers this, maps Wolverine's inbox/outbox envelope tables into it, and pins the
+// options lifetime — so a handler's entity write and its outgoing messages commit in ONE transaction.
+//
+// RENAMED from IdentityDbContext + now derives Microsoft.AspNetCore.Identity.EntityFrameworkCore's
+// IdentityUserContext (ADR 023 / slice 5.8). Two reasons rolled into one change:
+// 1. Real auth (ADR 023) makes Identity an ASP.NET Core Identity user store — the AspNetUsers table and
+// its satellites (user claims/logins/tokens) come from the ASP.NET base context. UserManager writes
+// through THIS context, so the Identity user, the Customer row, and the outbox all share ONE
+// transaction (the all-or-nothing register-with-credentials the spec requires).
+// 2. The old name `IdentityDbContext` collided with the ASP.NET base class of the same simple name
+// (the handoff's load-bearing gotcha). Renaming to CustomerDbContext sidesteps the collision entirely.
+// IdentityUserContext (user tables only) is chosen over IdentityDbContext (which adds AspNetRoles /
+// AspNetUserRoles / AspNetRoleClaims): authorization/roles are out of scope this increment (Workshop 002
+// § 8 item 16), so no unused role tables. The Customer row (the registry / CustomerRegistered contract
+// shape) stays a plain entity, deliberately free of the framework base class — two id-linked tables, one
+// context (the session's entity-shape decision), keyed by the same string id (Customer.Id == user.Id).
+public class CustomerDbContext(DbContextOptions options)
+ : IdentityUserContext(options)
+{
+ public DbSet Customers => Set();
+ public DbSet EmailChanges => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ // MUST call base FIRST so IdentityUserContext configures AspNetUsers + its satellite entities
+ // before we layer the registry tables and the schema default on top.
+ base.OnModelCreating(modelBuilder);
+
+ // Schema-per-service (ADR 002), the EF-Core mirror of the Marten services' DatabaseSchemaName:
+ // every table this context owns — the `customers`/`email_changes` tables, the ASP.NET Identity
+ // user tables, AND the Wolverine envelope tables the integration maps in — lands in the `identity`
+ // schema, matching the schema named in PersistMessagesWithPostgresql(conn, "identity"). Set as the
+ // model default so the Identity tables (which set no explicit schema) inherit it rather than
+ // leaking into `public`.
+ modelBuilder.HasDefaultSchema("identity");
+
+ // Lowercase the ASP.NET Identity tables' names AND columns to match Weasel's DDL. This is the SAME
+ // casing reconciliation the Customer / EmailChange mappings below do by hand, but the framework owns
+ // ~20 columns across AspNetUsers + its satellites (user claims/logins/tokens) with default PascalCase
+ // names — too many to hand-map. UseEntityFrameworkCoreWolverineManagedMigrations drives Weasel, which
+ // emits table DDL with UNQUOTED identifiers that Postgres folds to lowercase (`Id` → `id`); EF Core's
+ // runtime always QUOTES (`"Id"`), which is case-sensitive and misses the folded column — a live
+ // "column a.Id does not exist / Perhaps you meant a.id" failure on the very first UserManager query.
+ // A scoped lowercase convention over just the Microsoft.AspNetCore.Identity entity types fixes it and
+ // leaves the Wolverine envelope entities (a different namespace) and the CritterMart-owned tables
+ // (mapped explicitly below) untouched.
+ //
+ // Weasel also skips SECONDARY indexes (the documented gotcha behind ux_customers_email below), so
+ // ASP.NET Identity's unique indexes on the normalized username/email are absent — acceptable: email
+ // uniqueness is already DB-enforced by ux_customers_email on `customers` (+ the app guard), and
+ // UserManager.FindByEmailAsync matches on the normalized-email COLUMN (which Weasel does create) — an
+ // unindexed scan, fine at demo scale.
+ foreach (var entityType in modelBuilder.Model.GetEntityTypes())
+ {
+ if (entityType.ClrType.Namespace != "Microsoft.AspNetCore.Identity")
+ {
+ continue;
+ }
+
+ var table = entityType.GetTableName();
+ if (table is not null)
+ {
+ entityType.SetTableName(table.ToLowerInvariant());
+ }
+
+ foreach (var property in entityType.GetProperties())
+ {
+ var column = property.GetColumnName();
+ if (column is not null)
+ {
+ property.SetColumnName(column.ToLowerInvariant());
+ }
+ }
+ }
+
+ // Explicit lowercase/snake_case column names. UseEntityFrameworkCoreWolverineManagedMigrations
+ // drives Weasel, which emits the table DDL with UNQUOTED identifiers — so Postgres folds them to
+ // lowercase (`Id` → `id`). EF Core's runtime, however, always QUOTES identifiers (`"Id"`), which
+ // is case-sensitive and would miss the folded column. Naming the columns lowercase here makes the
+ // two agree (EF emits `"id"`, Weasel creates `id`). The Marten services dodge this because Marten
+ // owns both the DDL and the queries; the moment two tools share a table, casing must be reconciled.
+ modelBuilder.Entity(e =>
+ {
+ e.ToTable("customers");
+ e.HasKey(x => x.Id);
+ e.Property(x => x.Id).HasColumnName("id").HasMaxLength(100);
+ e.Property(x => x.Email).HasColumnName("email").HasMaxLength(256).IsRequired();
+ e.Property(x => x.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired();
+ e.Property(x => x.RegisteredAt).HasColumnName("registered_at");
+
+ // NOTE: the unique index on `email` (the registry's natural key — a customer is unique by
+ // normalized email, NOT by the server-minted opaque id) is deliberately NOT declared here as
+ // `e.HasIndex(...).IsUnique()`. Wolverine's UseEntityFrameworkCoreWolverineManagedMigrations
+ // drives Weasel, which migrates tables, columns, primary keys, and foreign keys from the EF
+ // model — but NOT secondary indexes (verified against the Wolverine EF-Core migration docs and
+ // a live schema check). An EF `HasIndex` here would be silently dropped, giving a false sense of
+ // a backstop that isn't in the database. The index is therefore applied as idempotent startup
+ // DDL in Program.cs (see EnsureEmailUniqueIndex) — the single place it actually takes effect.
+ });
+
+ // EmailChange (Workshop 002 slices 5.5-5.7) — CritterMart's second convention Wolverine.Saga,
+ // EF-Core-backed. Same lowercase-column discipline as Customer above. Keyed by its own PK
+ // (CustomerId) — no secondary index needed, so the Weasel-skips-secondary-indexes gotcha above
+ // does not apply here.
+ //
+ // Version — the base Wolverine.Saga class unconditionally declares `public int Version { get; set; }`
+ // (reflection-confirmed; it backs IRevisioned-based optimistic concurrency even for sagas that don't
+ // opt into IRevisioned). EF Core's default convention picks it up regardless, and Wolverine's own
+ // generated saga-loading query references it — omitting this mapping produced a live
+ // "column e.Version does not exist" failure (Postgres has `version`, EF queried `"Version"`), the
+ // same casing mismatch Id/Email/etc. already guard against, just for an inherited property that's
+ // easy to miss. Map it explicitly rather than ignore it, since Wolverine's own query depends on it.
+ modelBuilder.Entity(e =>
+ {
+ e.ToTable("email_changes");
+ e.HasKey(x => x.Id);
+ e.Property(x => x.Id).HasColumnName("id").HasMaxLength(100);
+ e.Property(x => x.PendingEmail).HasColumnName("pending_email").HasMaxLength(256).IsRequired();
+ e.Property(x => x.Version).HasColumnName("version");
+ });
+ }
+}
diff --git a/src/CritterMart.Identity/Customers/EmailChange.cs b/src/CritterMart.Identity/Customers/EmailChange.cs
index a886f11..9414c79 100644
--- a/src/CritterMart.Identity/Customers/EmailChange.cs
+++ b/src/CritterMart.Identity/Customers/EmailChange.cs
@@ -4,7 +4,7 @@ namespace CritterMart.Identity.Customers;
// CritterMart's SECOND convention Wolverine.Saga (Workshop 002 slices 5.5-5.7), EF-Core-backed — the direct
// counterpart to Inventory's Marten-backed Replenishment (src/CritterMart.Inventory/Stock/Replenishment.cs).
-// A DbSet-mapped entity on IdentityDbContext, keyed by CustomerId, deleted on MarkCompleted(). Per ADR 022's
+// A DbSet-mapped entity on CustomerDbContext, keyed by CustomerId, deleted on MarkCompleted(). Per ADR 022's
// guard, this does relational things the Wolverine way — plain state, StartOrHandle/Handle, TimeoutMessage,
// MarkCompleted() — and never re-implements event sourcing on SQL.
//
@@ -63,7 +63,7 @@ public OutgoingMessages StartOrHandle(RequestEmailChange command, EmailChangeDea
// rejected a conflicting email before this runs, so this is the success path only. The DB unique index
// (ux_customers_email) remains the true backstop against that guard's own race, exactly as
// RegisterCustomer's guard is racy on its own.
- public async Task Handle(ConfirmEmailChange command, IdentityDbContext db)
+ public async Task Handle(ConfirmEmailChange command, CustomerDbContext db)
{
var customer = await db.Customers.FindAsync(Id);
customer!.Email = PendingEmail;
diff --git a/src/CritterMart.Identity/Customers/IdentityDbContext.cs b/src/CritterMart.Identity/Customers/IdentityDbContext.cs
deleted file mode 100644
index 7d8a990..0000000
--- a/src/CritterMart.Identity/Customers/IdentityDbContext.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-using Microsoft.EntityFrameworkCore;
-
-namespace CritterMart.Identity.Customers;
-
-// The EF Core unit of work for Identity. AddDbContextWithWolverineIntegration in
-// Program.cs registers this, maps Wolverine's inbox/outbox envelope tables into it, and pins the
-// options lifetime — so a handler's entity write and its outgoing messages commit in ONE transaction.
-public class IdentityDbContext(DbContextOptions options) : DbContext(options)
-{
- public DbSet Customers => Set();
- public DbSet EmailChanges => Set();
-
- protected override void OnModelCreating(ModelBuilder modelBuilder)
- {
- // Schema-per-service (ADR 002), the EF-Core mirror of the Marten services' DatabaseSchemaName:
- // every table this context owns — the `customers` table AND the Wolverine envelope tables the
- // integration maps in — lands in the `identity` schema, matching the schema named in
- // PersistMessagesWithPostgresql(conn, "identity"). HasDefaultSchema is set first so the
- // envelope tables inherit it rather than leaking into `public`.
- modelBuilder.HasDefaultSchema("identity");
-
- // Explicit lowercase/snake_case column names. UseEntityFrameworkCoreWolverineManagedMigrations
- // drives Weasel, which emits the table DDL with UNQUOTED identifiers — so Postgres folds them to
- // lowercase (`Id` → `id`). EF Core's runtime, however, always QUOTES identifiers (`"Id"`), which
- // is case-sensitive and would miss the folded column. Naming the columns lowercase here makes the
- // two agree (EF emits `"id"`, Weasel creates `id`). The Marten services dodge this because Marten
- // owns both the DDL and the queries; the moment two tools share a table, casing must be reconciled.
- modelBuilder.Entity(e =>
- {
- e.ToTable("customers");
- e.HasKey(x => x.Id);
- e.Property(x => x.Id).HasColumnName("id").HasMaxLength(100);
- e.Property(x => x.Email).HasColumnName("email").HasMaxLength(256).IsRequired();
- e.Property(x => x.DisplayName).HasColumnName("display_name").HasMaxLength(200).IsRequired();
- e.Property(x => x.RegisteredAt).HasColumnName("registered_at");
-
- // NOTE: the unique index on `email` (the registry's natural key — a customer is unique by
- // normalized email, NOT by the server-minted opaque id) is deliberately NOT declared here as
- // `e.HasIndex(...).IsUnique()`. Wolverine's UseEntityFrameworkCoreWolverineManagedMigrations
- // drives Weasel, which migrates tables, columns, primary keys, and foreign keys from the EF
- // model — but NOT secondary indexes (verified against the Wolverine EF-Core migration docs and
- // a live schema check). An EF `HasIndex` here would be silently dropped, giving a false sense of
- // a backstop that isn't in the database. The index is therefore applied as idempotent startup
- // DDL in Program.cs (see EnsureEmailUniqueIndex) — the single place it actually takes effect.
- });
-
- // EmailChange (Workshop 002 slices 5.5-5.7) — CritterMart's second convention Wolverine.Saga,
- // EF-Core-backed. Same lowercase-column discipline as Customer above. Keyed by its own PK
- // (CustomerId) — no secondary index needed, so the Weasel-skips-secondary-indexes gotcha above
- // does not apply here.
- //
- // Version — the base Wolverine.Saga class unconditionally declares `public int Version { get; set; }`
- // (reflection-confirmed; it backs IRevisioned-based optimistic concurrency even for sagas that don't
- // opt into IRevisioned). EF Core's default convention picks it up regardless, and Wolverine's own
- // generated saga-loading query references it — omitting this mapping produced a live
- // "column e.Version does not exist" failure (Postgres has `version`, EF queried `"Version"`), the
- // same casing mismatch Id/Email/etc. already guard against, just for an inherited property that's
- // easy to miss. Map it explicitly rather than ignore it, since Wolverine's own query depends on it.
- modelBuilder.Entity(e =>
- {
- e.ToTable("email_changes");
- e.HasKey(x => x.Id);
- e.Property(x => x.Id).HasColumnName("id").HasMaxLength(100);
- e.Property(x => x.PendingEmail).HasColumnName("pending_email").HasMaxLength(256).IsRequired();
- e.Property(x => x.Version).HasColumnName("version");
- });
- }
-}
diff --git a/src/CritterMart.Identity/Customers/RequestEmailChange.cs b/src/CritterMart.Identity/Customers/RequestEmailChange.cs
index f826007..d8c710c 100644
--- a/src/CritterMart.Identity/Customers/RequestEmailChange.cs
+++ b/src/CritterMart.Identity/Customers/RequestEmailChange.cs
@@ -20,7 +20,7 @@ public static class RequestEmailChangeEndpoint
// Guards mirroring RegisterCustomer.ValidateAsync's railway idiom. Static and self-contained — no saga
// instance exists yet on the open path, and the re-request path only needs the incoming command.
- public static async Task ValidateAsync(RequestEmailChange command, IdentityDbContext db)
+ public static async Task ValidateAsync(RequestEmailChange command, CustomerDbContext db)
{
var customerExists = await db.Customers.AnyAsync(c => c.Id == command.CustomerId);
if (!customerExists)
diff --git a/src/CritterMart.Identity/Features/GetCustomer.cs b/src/CritterMart.Identity/Features/GetCustomer.cs
index f610ead..77dc191 100644
--- a/src/CritterMart.Identity/Features/GetCustomer.cs
+++ b/src/CritterMart.Identity/Features/GetCustomer.cs
@@ -11,7 +11,7 @@ public static class GetCustomerEndpoint
// The READ side: a straight primary-key lookup against the row — no projection, no read model,
// because the row IS the read model. FindAsync hits the identity-schema `customers` table.
[WolverineGet("/customers/{id}")]
- public static async Task Get(string id, IdentityDbContext db)
+ public static async Task Get(string id, CustomerDbContext db)
{
var customer = await db.Customers.FindAsync(id);
return customer is null
diff --git a/src/CritterMart.Identity/Features/LogIn.cs b/src/CritterMart.Identity/Features/LogIn.cs
new file mode 100644
index 0000000..833dd03
--- /dev/null
+++ b/src/CritterMart.Identity/Features/LogIn.cs
@@ -0,0 +1,51 @@
+using CritterMart.Identity.Auth;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Identity;
+using Wolverine.Http;
+
+namespace CritterMart.Identity.Features;
+
+// Log in — verify the password and issue a JWT (Workshop 002 slice 5.9, ADR 023). On a successful password
+// check Identity mints a signed JWT (sub = customer id) and returns it; the token is NOT persisted.
+public record LogIn(string Email, string Password);
+
+public record LogInResponse(string Token, string CustomerId);
+
+public static class LogInEndpoint
+{
+ private static string Normalize(string email) => email.Trim().ToLowerInvariant();
+
+ // No ValidateAsync guard here — a login failure is NOT a modeled domain conflict to report richly; it is
+ // a flat 401 that must reveal nothing. Both "unknown email" and "wrong password" return the SAME 401 with
+ // no distinguishing detail (spec 5.9: no user enumeration).
+ [WolverinePost("/login")]
+ public static async Task Post(
+ LogIn command,
+ UserManager users,
+ SignInManager signIn,
+ JwtTokenIssuer issuer)
+ {
+ var email = Normalize(command.Email);
+
+ // FindByEmailAsync matches on NormalizedEmail (set at registration). A null user and a bad password
+ // are handled identically below — we still return 401, never "no such user".
+ var user = await users.FindByEmailAsync(email);
+ if (user is null)
+ {
+ return Results.Unauthorized();
+ }
+
+ // CheckPasswordSignInAsync verifies the hashed password (and honors lockout, though round one enables
+ // none). It does NOT establish a cookie/session — Identity is a bearer-token issuer, not a cookie
+ // auth server (ADR 023). lockoutOnFailure:false keeps a wrong password from counting toward a lockout
+ // we don't configure.
+ var result = await signIn.CheckPasswordSignInAsync(user, command.Password, lockoutOnFailure: false);
+ if (!result.Succeeded)
+ {
+ return Results.Unauthorized();
+ }
+
+ var token = issuer.Issue(user.Id);
+ return Results.Ok(new LogInResponse(token, user.Id));
+ }
+}
diff --git a/src/CritterMart.Identity/Features/LogOut.cs b/src/CritterMart.Identity/Features/LogOut.cs
new file mode 100644
index 0000000..5b083d0
--- /dev/null
+++ b/src/CritterMart.Identity/Features/LogOut.cs
@@ -0,0 +1,22 @@
+using Microsoft.AspNetCore.Http;
+using Wolverine.Http;
+
+namespace CritterMart.Identity.Features;
+
+// Log out (Workshop 002 slice 5.11, ADR 023). The issued JWT is STATELESS — Identity keeps no session and no
+// token store — so logout is fundamentally a CLIENT-side act: the SPA discards its held token and
+// useCurrentCustomer returns to unauthenticated (see client/src/identity/authStore.ts). This endpoint exists
+// only as an explicit, self-documenting server-side acknowledgement of that; it holds no state to clear and
+// simply returns 200.
+//
+// Deferred (Workshop 002 § 8 Q15): there is NO server-side revocation this increment. A token already handed
+// out stays cryptographically valid until it EXPIRES — clicking log out does not un-issue it. That is why the
+// access-token lifetime is kept short (JwtSettings.AccessTokenLifetime). A denylist, or short access tokens +
+// a refresh flow, is the modeled-not-built future increment.
+public record LogOut;
+
+public static class LogOutEndpoint
+{
+ [WolverinePost("/logout")]
+ public static IResult Post(LogOut command) => Results.Ok();
+}
diff --git a/src/CritterMart.Identity/Features/RegisterCustomer.cs b/src/CritterMart.Identity/Features/RegisterCustomer.cs
index 741e706..c4bb3d5 100644
--- a/src/CritterMart.Identity/Features/RegisterCustomer.cs
+++ b/src/CritterMart.Identity/Features/RegisterCustomer.cs
@@ -25,7 +25,7 @@ public static class RegisterCustomerEndpoint
// Normalize the email ONCE — trim + lowercase — and reuse the result for the uniqueness check, the
// stored row, and the published event alike. A registry whose guard `Ada@` could slip past `ada@`
// would not be a uniqueness guard at all; normalizing is what makes it honest. The DB unique index
- // on the email column (IdentityDbContext) stores this same normalized value.
+ // on the email column (CustomerDbContext) stores this same normalized value.
private static string Normalize(string email) => email.Trim().ToLowerInvariant();
// Railway-style guard mirroring PublishProduct.ValidateAsync: a duplicate email is an expected,
@@ -35,7 +35,7 @@ public static class RegisterCustomerEndpoint
// both pass it before either commits); the unique index on email is the true backstop that rejects
// the second insert. Catalog needs no such index — a product's SKU IS its Marten document id, so the
// primary key enforces uniqueness for free; Identity keys on email, NOT the id, so the index earns it.
- public static async Task ValidateAsync(RegisterCustomer command, IdentityDbContext db)
+ public static async Task ValidateAsync(RegisterCustomer command, CustomerDbContext db)
{
var email = Normalize(command.Email);
var alreadyRegistered = await db.Customers.AnyAsync(c => c.Email == email);
@@ -59,7 +59,7 @@ public static async Task ValidateAsync(RegisterCustomer command,
// CustomerRegistered now lives in CritterMart.Contracts (the shared Published-Language type) — this
// is when it graduated from Identity-internal (slice 5.4), because Orders now has a handler for it.
[WolverinePost("/customers")]
- public static (IResult, CustomerRegistered) Post(RegisterCustomer command, IdentityDbContext db)
+ public static (IResult, CustomerRegistered) Post(RegisterCustomer command, CustomerDbContext db)
{
var customer = new Customer
{
diff --git a/src/CritterMart.Identity/Features/RegisterWithCredentials.cs b/src/CritterMart.Identity/Features/RegisterWithCredentials.cs
new file mode 100644
index 0000000..c71c636
--- /dev/null
+++ b/src/CritterMart.Identity/Features/RegisterWithCredentials.cs
@@ -0,0 +1,103 @@
+using CritterMart.Contracts;
+using CritterMart.Identity.Customers;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Wolverine.Http;
+
+namespace CritterMart.Identity.Features;
+
+// Register a customer WITH credentials (Workshop 002 slice 5.8, ADR 023) — the credentialed evolution of
+// slice 5.1's RegisterCustomer. Creates an ASP.NET Core Identity user (password hashed) AND the registry
+// Customer row, keyed by the SAME string id, and publishes CustomerRegistered — all in ONE transaction.
+//
+// The spike's passwordless POST /customers (RegisterCustomer) is KEPT for admin/seeder-provisioned customers
+// (Workshop 002 § 8 Q14 → layer, not replace); a self-registering shopper comes through /register here.
+public record RegisterWithCredentials(string Email, string DisplayName, string Password);
+
+public record RegisterWithCredentialsResponse(string Id);
+
+public static class RegisterWithCredentialsEndpoint
+{
+ // Same normalization + duplicate-email railway guard as RegisterCustomer.ValidateAsync (reused, not
+ // reinvented) — a duplicate email is an expected, modeled 409, returned before the handler so no user,
+ // no row, no event. Racy on its own; ux_customers_email is the DB backstop, exactly as for RegisterCustomer.
+ private static string Normalize(string email) => email.Trim().ToLowerInvariant();
+
+ public static async Task ValidateAsync(RegisterWithCredentials command, CustomerDbContext db)
+ {
+ var email = Normalize(command.Email);
+ var alreadyRegistered = await db.Customers.AnyAsync(c => c.Email == email);
+
+ return alreadyRegistered
+ ? new ProblemDetails
+ {
+ Title = "CustomerAlreadyRegistered",
+ Detail = $"A customer with email '{email}' is already registered.",
+ Status = StatusCodes.Status409Conflict
+ }
+ : WolverineContinue.NoProblems;
+ }
+
+ // Returns the HTTP response AND a cascaded CustomerRegistered — the same tuple idiom as RegisterCustomer.
+ // ALL-OR-NOTHING (spec 5.8): the Identity user, the Customer row, and the outbox message commit in ONE
+ // Wolverine transaction. We do NOT call UserManager.CreateAsync — its EF UserStore has AutoSaveChanges=true
+ // and would SaveChanges (commit the user) mid-handler, BEFORE Wolverine's AutoApplyTransactions commits the
+ // row + enrolls the event, splitting the write in two. Instead we use UserManager's building blocks
+ // (PasswordValidators for the policy check, PasswordHasher to hash, the normalizer for lookup keys) and
+ // db.Users.Add the user alongside db.Customers.Add — so the transactional middleware commits all three
+ // together and CustomerRegistered publishes only after that single commit succeeds. Password-policy failure
+ // returns 400 (Identity's own messages) BEFORE any Add, so a weak password creates nothing.
+ [WolverinePost("/register")]
+ public static async Task<(IResult, CustomerRegistered?)> Post(
+ RegisterWithCredentials command,
+ CustomerDbContext db,
+ UserManager users)
+ {
+ var email = Normalize(command.Email);
+ var id = Guid.NewGuid().ToString();
+
+ var user = new IdentityUser
+ {
+ Id = id,
+ UserName = email,
+ NormalizedUserName = users.KeyNormalizer.NormalizeName(email),
+ Email = email,
+ NormalizedEmail = users.KeyNormalizer.NormalizeEmail(email),
+ SecurityStamp = Guid.NewGuid().ToString("N")
+ };
+
+ // ASP.NET Core Identity's configured password policy (slice 5.8's weak-password 400). Run the
+ // validators explicitly, since we bypass CreateAsync (see remarks). Any failure → 400 with the
+ // Identity error descriptions, no writes.
+ foreach (var validator in users.PasswordValidators)
+ {
+ var result = await validator.ValidateAsync(users, user, command.Password);
+ if (!result.Succeeded)
+ {
+ return (Results.Problem(
+ title: "PasswordRejected",
+ detail: string.Join(" ", result.Errors.Select(e => e.Description)),
+ statusCode: StatusCodes.Status400BadRequest), null);
+ }
+ }
+
+ user.PasswordHash = users.PasswordHasher.HashPassword(user, command.Password);
+
+ var customer = new Customer
+ {
+ Id = id,
+ Email = email,
+ DisplayName = command.DisplayName,
+ RegisteredAt = DateTimeOffset.UtcNow
+ };
+
+ db.Users.Add(user);
+ db.Customers.Add(customer);
+
+ return (
+ Results.Created($"/customers/{id}", new RegisterWithCredentialsResponse(id)),
+ new CustomerRegistered(id, email, command.DisplayName));
+ }
+}
diff --git a/src/CritterMart.Identity/Program.cs b/src/CritterMart.Identity/Program.cs
index b7e825e..09e8b13 100644
--- a/src/CritterMart.Identity/Program.cs
+++ b/src/CritterMart.Identity/Program.cs
@@ -1,5 +1,8 @@
+using CritterMart.Identity.Auth;
using CritterMart.Identity.Customers;
+using CritterMart.ServiceDefaults;
using JasperFx.Resources;
+using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Wolverine;
using Wolverine.CritterWatch;
@@ -37,18 +40,19 @@
// Identity is the ONE service that is NOT event-sourced. It is a deliberately boring EF Core CRUD
// registry on the SAME shared Postgres as the three Marten services — proof that Wolverine's
// handler model is persistence-agnostic: the same static endpoint/Handle shape and the same
- // transactional outbox, over a DbContext instead of an IDocumentSession. It is a DATA STORE, not
- // an auth provider (no Polecat, no authN/authZ) — round-one identity stays stubbed behind the
- // storefront's X-Customer-Id seam.
+ // transactional outbox, over a DbContext instead of an IDocumentSession. Per ADR 023 it is NOW
+ // ALSO the system's auth ISSUER (ASP.NET Core Identity user store + a self-validated JWT — wired on
+ // builder.Services after this block) — still relational CRUD, still no event sourcing: auth EXTENDS
+ // the boring-CRUD foil, it does not reverse it.
// Wolverine's durable inbox/outbox lives in Postgres under Identity's OWN schema — schema-per-
// service (ADR 002), the EF-Core mirror of the Marten services' opts.DatabaseSchemaName.
opts.PersistMessagesWithPostgresql(connectionString, "identity");
- // Idiomatic one-call EF Core integration: registers IdentityDbContext, maps the Wolverine envelope
+ // Idiomatic one-call EF Core integration: registers CustomerDbContext, maps the Wolverine envelope
// tables into it (so an entity write and its outgoing messages share one transaction), pins the
// options lifetime, and activates the transactional middleware.
- opts.Services.AddDbContextWithWolverineIntegration(
+ opts.Services.AddDbContextWithWolverineIntegration(
x => x.UseNpgsql(connectionString));
// Weasel diffs the DbContext (customers + envelope tables) against the live DB and applies missing
@@ -73,6 +77,52 @@
opts.Policies.AutoApplyTransactions();
});
+// ── Real authentication (ADR 023) ────────────────────────────────────────────────────────────────
+// Identity as the auth ISSUER. ASP.NET Core Identity gives the user store + credential handling
+// (UserManager for hashing/validation, SignInManager for the password check) over the SAME
+// CustomerDbContext / `identity` schema — so the Identity user, the Customer row, and the outbox all
+// share one transaction.
+//
+// AddIdentityCore (NOT AddIdentity): AddIdentity drags in cookie authentication + its UI handlers,
+// exactly what ADR 023 rejects (bearer, not cookie). AddIdentityCore gives just UserManager;
+// AddSignInManager adds SignInManager (its CheckPasswordSignInAsync is slice 5.9's password check);
+// AddEntityFrameworkStores backs both on EF Core. No roles are added — authZ is out
+// of scope (Workshop 002 § 8 Q16) — so the store is user-only, matching CustomerDbContext deriving
+// IdentityUserContext (not IdentityDbContext).
+builder.Services
+ .AddIdentityCore(options =>
+ {
+ // A demo-workable password policy: 8+ chars with a lowercase letter and a digit, no required
+ // symbol or uppercase (so a memorable demo password like "critter1" passes while "weak" fails
+ // slice 5.8's weak-password path). Tighten via config in prod.
+ options.Password.RequiredLength = 8;
+ options.Password.RequireDigit = true;
+ options.Password.RequireLowercase = true;
+ options.Password.RequireUppercase = false;
+ options.Password.RequireNonAlphanumeric = false;
+ // Emails are the login key; require them unique at the Identity layer too (the DB backstop is
+ // ux_customers_email on the customers table — see EnsureEmailUniqueIndex below).
+ options.User.RequireUniqueEmail = true;
+ })
+ .AddSignInManager()
+ .AddEntityFrameworkStores();
+
+// SignInManager's constructor depends on IAuthenticationSchemeProvider; AddAuthentication() registers it.
+// Identity issues tokens but does NOT gate ITSELF with bearer auth (no default scheme, no [Authorize]) —
+// registering the scheme provider is purely to satisfy SignInManager's DI, not to protect endpoints.
+builder.Services.AddAuthentication();
+
+// The issued-token settings (issuer/audience/lifetime), bound from the `Jwt` config section with dev
+// fallbacks, and the JwtTokenIssuer that mints with Identity's private RSA key (Jwt:PrivateKey ?? dev key).
+builder.Services.AddSingleton(new JwtSettings
+{
+ Issuer = builder.Configuration["Jwt:Issuer"] ?? DevJwtDefaults.Issuer,
+ Audience = builder.Configuration["Jwt:Audience"] ?? DevJwtDefaults.Audience,
+ AccessTokenLifetime = builder.Configuration.GetValue("Jwt:AccessTokenLifetime")
+ ?? new JwtSettings().AccessTokenLifetime
+});
+builder.Services.AddSingleton();
+
// Build all Wolverine-managed resources on startup — the identity-schema message-storage tables AND
// the EF managed-migration schema declared above. The EF-Core counterpart of the Marten services'
// ApplyAllDatabaseChangesOnStartup(); Aspire just runs the app, so the schema must self-apply here.
@@ -122,13 +172,13 @@
app.Run();
// Applies the email unique index out-of-band (Weasel migrates the EF table but not its indexes — see the
-// comment at the ApplicationStarted registration above and in IdentityDbContext). Idempotent so a restart
+// comment at the ApplicationStarted registration above and in CustomerDbContext). Idempotent so a restart
// against an existing schema is a no-op; the `identity`-schema `customers` table exists by the time this
// runs because ApplicationStarted fires after Weasel's resource-setup hosted service.
void EnsureEmailUniqueIndex()
{
using var scope = app.Services.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
+ var db = scope.ServiceProvider.GetRequiredService();
db.Database.ExecuteSqlRaw(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_customers_email ON identity.customers (email)");
}
diff --git a/src/CritterMart.Orders/Auth/CustomerIdentity.cs b/src/CritterMart.Orders/Auth/CustomerIdentity.cs
new file mode 100644
index 0000000..7bd14aa
--- /dev/null
+++ b/src/CritterMart.Orders/Auth/CustomerIdentity.cs
@@ -0,0 +1,54 @@
+using Microsoft.AspNetCore.Http;
+
+namespace CritterMart.Orders.Auth;
+
+// Resolves the authenticated customer id for a customer-keyed endpoint under the LAYERED cutover (ADR 023,
+// slice 5.10). The `sub` claim of a validated JWT is the trust boundary; the round-one X-Customer-Id header
+// survives only as a DEV-ONLY fallback so the seeder, the existing tests, and demo-traffic.ps1 keep working
+// in this PR (DEBT-tracked for removal — the frontend no longer sends it). Four cases, in priority order:
+//
+// 1. A VALID Bearer token → its `sub` claim wins (the trust boundary). AddJwtBearer validated it offline.
+// 2. An INVALID/expired Bearer token was presented (Authorization: Bearer … but authentication failed, so
+// http.User carries no `sub`) → 401, rejected LOCALLY, no fallback. This is the spec's "invalid or
+// expired token is rejected locally" — no HTTP into Identity to decide it.
+// 3. No Bearer token, but an X-Customer-Id header → the dev-only fallback identity.
+// 4. Neither → no identity; the caller returns its existing "identity required" 400.
+//
+// The endpoints are deliberately NOT blanket-[Authorize]'d — that would reject the header-fallback path the
+// existing tests rely on. This helper enforces "must have an identity" instead, distinguishing a bad token
+// (401) from no identity at all (the endpoint's 400).
+public static class CustomerIdentity
+{
+ public static bool TryResolve(
+ HttpContext http, string? headerCustomerId, out string customerId, out IResult? failure)
+ {
+ customerId = "";
+ failure = null;
+
+ // 1. A validated token's `sub` (MapInboundClaims = false keeps it readable as `sub`).
+ var sub = http.User.FindFirst("sub")?.Value;
+ if (!string.IsNullOrWhiteSpace(sub))
+ {
+ customerId = sub;
+ return true;
+ }
+
+ // 2. A Bearer token was presented but did not authenticate → reject locally with 401.
+ var authorization = http.Request.Headers.Authorization.ToString();
+ if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
+ {
+ failure = Results.Unauthorized();
+ return false;
+ }
+
+ // 3. Dev-only X-Customer-Id fallback (no token presented).
+ if (!string.IsNullOrWhiteSpace(headerCustomerId))
+ {
+ customerId = headerCustomerId;
+ return true;
+ }
+
+ // 4. No identity at all — the caller supplies the response (keeps the existing 400).
+ return false;
+ }
+}
diff --git a/src/CritterMart.Orders/CritterMart.Orders.csproj b/src/CritterMart.Orders/CritterMart.Orders.csproj
index d9869a5..d78bd0e 100644
--- a/src/CritterMart.Orders/CritterMart.Orders.csproj
+++ b/src/CritterMart.Orders/CritterMart.Orders.csproj
@@ -11,6 +11,9 @@
+
+
diff --git a/src/CritterMart.Orders/Features/AddToCart.cs b/src/CritterMart.Orders/Features/AddToCart.cs
index c559177..4148e02 100644
--- a/src/CritterMart.Orders/Features/AddToCart.cs
+++ b/src/CritterMart.Orders/Features/AddToCart.cs
@@ -1,3 +1,4 @@
+using CritterMart.Orders.Auth;
using CritterMart.Orders.Shopping;
using Marten;
using Microsoft.AspNetCore.Http;
@@ -70,16 +71,17 @@ public static ProblemDetails Validate(AddToCart command)
// checks — so they cascade null, which Wolverine skips.
[WolverinePost("/carts/mine/items")]
public static async Task<(IResult, DeliveryMessage?)> Post(
- [FromHeader(Name = "X-Customer-Id")] string? customerId,
+ HttpContext http,
+ [FromHeader(Name = "X-Customer-Id")] string? customerIdHeader,
AddToCart command, IDocumentSession session, CartActivityDeadline deadline)
{
- // Identity rides in the X-Customer-Id header — the ADR 009 useCurrentCustomer seam, the same
- // transport the cart READ (GET /carts/mine) already uses. A missing/blank header is a malformed
- // request (no identity to resolve a cart) → 400, mirroring ViewMyCart. The header is the round-one
- // stand-in for the authenticated claim Polecat will provide; the route no longer carries identity.
- if (string.IsNullOrWhiteSpace(customerId))
+ // Identity is now the authenticated JWT `sub` claim (ADR 023, slice 5.10) — validated offline by
+ // AddJwtBearer — with the round-one X-Customer-Id header surviving only as a dev-only fallback (the
+ // layered cutover). A bad/expired token → 401; no identity at all → 400 (unchanged, mirroring
+ // ViewMyCart). CustomerIdentity.TryResolve encodes that precedence; the route carries no identity.
+ if (!CustomerIdentity.TryResolve(http, customerIdHeader, out var customerId, out var failure))
{
- return (Results.BadRequest("X-Customer-Id header is required."), null);
+ return (failure ?? Results.BadRequest("X-Customer-Id header is required."), null);
}
// The Cart stream is keyed by cartId, but the command knows only the customer, so
diff --git a/src/CritterMart.Orders/Features/ChangeCartItemQuantity.cs b/src/CritterMart.Orders/Features/ChangeCartItemQuantity.cs
index bf4a283..d6e0c8d 100644
--- a/src/CritterMart.Orders/Features/ChangeCartItemQuantity.cs
+++ b/src/CritterMart.Orders/Features/ChangeCartItemQuantity.cs
@@ -1,3 +1,4 @@
+using CritterMart.Orders.Auth;
using CritterMart.Orders.Shopping;
using Marten;
using Microsoft.AspNetCore.Http;
@@ -7,24 +8,25 @@
namespace CritterMart.Orders.Features;
// The Customer changes the quantity of an item in their open cart (Workshop 001 slice 3.3).
-// Identity arrives via the X-Customer-Id header (ADR 009 seam — harmonized with the cart read);
-// the {sku} stays on the route and the new absolute quantity rides the body. The {sku}-on-the-route
+// Identity is the authenticated JWT `sub` claim (ADR 023, slice 5.10), dev-only X-Customer-Id header
+// fallback; the {sku} stays on the route and the new absolute quantity rides the body. The {sku}-on-the-route
// + body shape still mirrors Catalog's change-price (POST /products/{sku}/price) — a command-shaped
-// POST to the thing being changed (design.md decision 3).
+// POST to the thing being changed.
public record ChangeCartItemQuantity(int NewQuantity);
public static class ChangeCartItemQuantityEndpoint
{
[WolverinePost("/carts/mine/items/{sku}/quantity")]
public static async Task Post(
- [FromHeader(Name = "X-Customer-Id")] string? customerId, string sku,
+ HttpContext http,
+ [FromHeader(Name = "X-Customer-Id")] string? customerIdHeader, string sku,
ChangeCartItemQuantity command, IDocumentSession session)
{
- // Identity rides in the X-Customer-Id header (ADR 009 seam); a missing/blank header is a
- // malformed request → 400, mirroring ViewMyCart and the cart's other commands.
- if (string.IsNullOrWhiteSpace(customerId))
+ // A bad/expired token → 401; no identity at all → 400, mirroring ViewMyCart and the cart's other
+ // commands. CustomerIdentity.TryResolve prefers the token's `sub`, dev-only header fallback.
+ if (!CustomerIdentity.TryResolve(http, customerIdHeader, out var customerId, out var failure))
{
- return Results.BadRequest("X-Customer-Id header is required.");
+ return failure ?? Results.BadRequest("X-Customer-Id header is required.");
}
// Malformed input, not a state conflict: zero or negative is never a valid quantity.
diff --git a/src/CritterMart.Orders/Features/ListMyOrders.cs b/src/CritterMart.Orders/Features/ListMyOrders.cs
index 7a89b76..228d27e 100644
--- a/src/CritterMart.Orders/Features/ListMyOrders.cs
+++ b/src/CritterMart.Orders/Features/ListMyOrders.cs
@@ -1,3 +1,4 @@
+using CritterMart.Orders.Auth;
using CritterMart.Orders.Customers;
using CritterMart.Orders.Ordering;
using Marten;
@@ -26,14 +27,15 @@ public static class ListMyOrdersEndpoint
// precedence that already lets /orders/awaiting-payment win.
[WolverineGet("/orders/mine")]
public static async Task Get(
- [FromHeader(Name = "X-Customer-Id")] string? customerId, IQuerySession session)
+ HttpContext http,
+ [FromHeader(Name = "X-Customer-Id")] string? customerIdHeader, IQuerySession session)
{
- // A missing/blank header is a malformed request (no identity to resolve the orders) — 400, kept
- // distinct from the empty-list case below (a customer with no orders). Wolverine binds an absent
- // header to the parameter's default (null), hence the guard. Mirrors ViewMyCart.cs:30.
- if (string.IsNullOrWhiteSpace(customerId))
+ // A bad/expired token → 401; no identity at all → 400, kept distinct from the empty-list case below
+ // (a customer with no orders). CustomerIdentity.TryResolve prefers the token's `sub`, dev-only header
+ // fallback. Mirrors ViewMyCart.
+ if (!CustomerIdentity.TryResolve(http, customerIdHeader, out var customerId, out var failure))
{
- return Results.BadRequest("X-Customer-Id header is required.");
+ return failure ?? Results.BadRequest("X-Customer-Id header is required.");
}
// The customer-keyed query over the existing OrderStatusView documents (served by the non-unique
diff --git a/src/CritterMart.Orders/Features/PlaceOrder.cs b/src/CritterMart.Orders/Features/PlaceOrder.cs
index 7e330d5..8f4b222 100644
--- a/src/CritterMart.Orders/Features/PlaceOrder.cs
+++ b/src/CritterMart.Orders/Features/PlaceOrder.cs
@@ -1,3 +1,4 @@
+using CritterMart.Orders.Auth;
using CritterMart.Orders.Customers;
using CritterMart.Orders.Ordering;
using CritterMart.Orders.Shopping;
@@ -29,13 +30,15 @@ public static class PlaceOrderEndpoint
// order, so both cascades are null (Wolverine skips null cascading messages).
[WolverinePost("/orders")]
public static async Task<(IResult, Contracts.ReserveStock?, DeliveryMessage?)> Post(
- [FromHeader(Name = "X-Customer-Id")] string? customerId,
+ HttpContext http,
+ [FromHeader(Name = "X-Customer-Id")] string? customerIdHeader,
IDocumentSession session, [FromServices] PaymentDeadline deadline)
{
- // A missing/blank header is a malformed request — 400, consistent with GET /orders/mine and
- // GET /carts/mine (ADR 009; the Polecat promotion swaps the header for a claim).
- if (string.IsNullOrWhiteSpace(customerId))
- return (Results.BadRequest("X-Customer-Id header is required."), null, null);
+ // Identity is the authenticated JWT `sub` claim (ADR 023, slice 5.10), dev-only X-Customer-Id header
+ // fallback. A bad/expired token → 401; no identity at all → 400 (consistent with GET /orders/mine and
+ // GET /carts/mine). CustomerIdentity.TryResolve encodes the precedence.
+ if (!CustomerIdentity.TryResolve(http, customerIdHeader, out var customerId, out var failure))
+ return (failure ?? Results.BadRequest("X-Customer-Id header is required."), null, null);
// Resolve the customer's open cart — the same indexed Cart query AddToCart uses.
// A cart that was already checked out has IsOpen=false, so a repeat PlaceOrder finds no
diff --git a/src/CritterMart.Orders/Features/RemoveCartItem.cs b/src/CritterMart.Orders/Features/RemoveCartItem.cs
index 1ea7489..026faa7 100644
--- a/src/CritterMart.Orders/Features/RemoveCartItem.cs
+++ b/src/CritterMart.Orders/Features/RemoveCartItem.cs
@@ -1,3 +1,4 @@
+using CritterMart.Orders.Auth;
using CritterMart.Orders.Shopping;
using Marten;
using Microsoft.AspNetCore.Http;
@@ -6,22 +7,22 @@
namespace CritterMart.Orders.Features;
-// The Customer removes an item from their open cart (Workshop 001 slice 3.2). Identity arrives via
-// the X-Customer-Id header (ADR 009 seam — harmonized with the cart read); the {sku} rides the
-// route and there is no request body, which makes this the project's first DELETE endpoint. As with
-// AddToCart, the customer edits *their* cart; resolving which Cart stream that is, is the server's
-// business (design.md decision 3).
+// The Customer removes an item from their open cart (Workshop 001 slice 3.2). Identity is the authenticated
+// JWT `sub` claim (ADR 023, slice 5.10), dev-only X-Customer-Id header fallback; the {sku} rides the route
+// and there is no request body, which makes this the project's first DELETE endpoint. As with AddToCart, the
+// customer edits *their* cart; resolving which Cart stream that is, is the server's business.
public static class RemoveCartItemEndpoint
{
[WolverineDelete("/carts/mine/items/{sku}")]
public static async Task Delete(
- [FromHeader(Name = "X-Customer-Id")] string? customerId, string sku, IDocumentSession session)
+ HttpContext http,
+ [FromHeader(Name = "X-Customer-Id")] string? customerIdHeader, string sku, IDocumentSession session)
{
- // Identity rides in the X-Customer-Id header (ADR 009 seam); a missing/blank header is a
- // malformed request → 400, mirroring ViewMyCart and the cart's other commands.
- if (string.IsNullOrWhiteSpace(customerId))
+ // A bad/expired token → 401; no identity at all → 400, mirroring ViewMyCart and the cart's other
+ // commands. CustomerIdentity.TryResolve prefers the token's `sub`, dev-only header fallback.
+ if (!CustomerIdentity.TryResolve(http, customerIdHeader, out var customerId, out var failure))
{
- return Results.BadRequest("X-Customer-Id header is required.");
+ return failure ?? Results.BadRequest("X-Customer-Id header is required.");
}
// Resolve the customer's open cart — the same indexed Cart query AddToCart and
diff --git a/src/CritterMart.Orders/Features/ViewMyCart.cs b/src/CritterMart.Orders/Features/ViewMyCart.cs
index bd10812..45391ed 100644
--- a/src/CritterMart.Orders/Features/ViewMyCart.cs
+++ b/src/CritterMart.Orders/Features/ViewMyCart.cs
@@ -1,3 +1,4 @@
+using CritterMart.Orders.Auth;
using CritterMart.Orders.Shopping;
using Marten;
using Microsoft.AspNetCore.Http;
@@ -15,21 +16,22 @@ namespace CritterMart.Orders.Features;
// closing the pre-frontend audit's blocking Gap #1. No new event, projection, or index.
public static class ViewMyCartEndpoint
{
- // Identity rides in the X-Customer-Id header — the round-one stand-in for an authenticated claim
- // behind the useCurrentCustomer seam (design.md Decision 1; the Polecat promotion swaps the
- // header for a claim with call sites unchanged). A literal route segment, so /carts/mine wins
+ // Identity is now the authenticated JWT `sub` claim (ADR 023, slice 5.10), with the round-one
+ // X-Customer-Id header surviving only as a dev-only fallback (the layered cutover — the seam the
+ // useCurrentCustomer promotion swapped, now realized). A literal route segment, so /carts/mine wins
// over /carts/{cartId} by ASP.NET Core route precedence — the same precedence that already lets
// /carts/awaiting-activity win.
[WolverineGet("/carts/mine")]
public static async Task Get(
- [FromHeader(Name = "X-Customer-Id")] string? customerId, IQuerySession session)
+ HttpContext http,
+ [FromHeader(Name = "X-Customer-Id")] string? customerIdHeader, IQuerySession session)
{
- // A missing/blank header is a malformed request (no identity to resolve a cart) — 400, kept
- // distinct from the 404 that means "this customer has no open cart" (design.md Decision 5).
- // Wolverine binds an absent header to the parameter's default (null), hence the guard.
- if (string.IsNullOrWhiteSpace(customerId))
+ // A bad/expired token → 401; no identity at all → 400 (unchanged), kept distinct from the 404 that
+ // means "this customer has no open cart" (design.md Decision 5). CustomerIdentity.TryResolve prefers
+ // the token's `sub` and falls back to the dev-only header.
+ if (!CustomerIdentity.TryResolve(http, customerIdHeader, out var customerId, out var failure))
{
- return Results.BadRequest("X-Customer-Id header is required.");
+ return failure ?? Results.BadRequest("X-Customer-Id header is required.");
}
// The partial-unique open-cart index (Program.cs:74) guarantees at most one open cart per
diff --git a/src/CritterMart.Orders/Program.cs b/src/CritterMart.Orders/Program.cs
index e08733d..567c33e 100644
--- a/src/CritterMart.Orders/Program.cs
+++ b/src/CritterMart.Orders/Program.cs
@@ -1,9 +1,13 @@
+using System.Security.Cryptography;
using CritterMart.Orders.Ordering;
using CritterMart.Orders.Shopping;
+using CritterMart.ServiceDefaults;
using JasperFx.Events;
using JasperFx.Events.Projections;
using JasperFx.OpenTelemetry;
using Marten;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Tokens;
using Wolverine;
using Wolverine.CritterWatch;
using Wolverine.HealthChecks;
@@ -160,6 +164,41 @@
opts.Policies.UseDurableLocalQueues();
});
+// ── Resource-server token verification (ADR 023, slice 5.10) ──────────────────────────────────────
+// Orders trusts a caller's identity by validating Identity's self-signed JWT ENTIRELY OFFLINE — signature,
+// issuer, audience, lifetime — against Identity's PUBLIC key, distributed as CONFIGURATION. There is NO
+// Authority/MetadataAddress set, which is precisely what keeps validation offline: no JWKS fetch, no HTTP
+// into Identity, per-request or at startup (the load-bearing demonstration that ADR 001's no-sync-HTTP rule
+// survives real auth). The public key comes from Jwt:PublicKey (dev fallback = the shared dev key, so the
+// demo works with zero wiring); the private half stays with Identity alone — Orders can verify, never mint.
+var jwtPublicKey = RSA.Create();
+jwtPublicKey.ImportFromPem(builder.Configuration["Jwt:PublicKey"] ?? DevJwtDefaults.DevPublicKeyPem);
+
+builder.Services
+ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ // MapInboundClaims = false: keep `sub` readable AS `sub` (the default handler otherwise remaps it to
+ // ClaimTypes.NameIdentifier). Paired with Identity minting via JsonWebTokenHandler, `sub` stays `sub`
+ // end to end, so CustomerIdentity.TryResolve can read http.User.FindFirst("sub").
+ options.MapInboundClaims = false;
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new RsaSecurityKey(jwtPublicKey),
+ ValidateIssuer = true,
+ ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? DevJwtDefaults.Issuer,
+ ValidateAudience = true,
+ ValidAudience = builder.Configuration["Jwt:Audience"] ?? DevJwtDefaults.Audience,
+ ValidateLifetime = true
+ };
+ });
+
+// Registered so UseAuthorization can run; endpoints are NOT blanket-[Authorize]'d (that would reject the
+// dev-only X-Customer-Id fallback the layered cutover keeps — CustomerIdentity enforces "must have an
+// identity" per endpoint instead).
+builder.Services.AddAuthorization();
+
builder.Services.AddWolverineHttp();
// Expose the Wolverine runtime + listener health to ASP.NET health checks (ADR 019). The bus check
@@ -214,6 +253,12 @@
// Origin-less requests (e.g. Alba integration tests), so it is safe in every host.
app.UseCors();
+// Token verification (ADR 023, slice 5.10): populate http.User from a valid Bearer token so the customer-
+// keyed endpoints can source the customer id from the `sub` claim (CustomerIdentity.TryResolve). Placed
+// after CORS and before endpoint mapping, per the standard ASP.NET middleware order.
+app.UseAuthentication();
+app.UseAuthorization();
+
app.MapWolverineEndpoints();
// Map /health (all checks) and /alive (liveness-tagged). ServiceDefaults defines these but no
diff --git a/src/CritterMart.ServiceDefaults/DevJwtDefaults.cs b/src/CritterMart.ServiceDefaults/DevJwtDefaults.cs
new file mode 100644
index 0000000..281346c
--- /dev/null
+++ b/src/CritterMart.ServiceDefaults/DevJwtDefaults.cs
@@ -0,0 +1,65 @@
+namespace CritterMart.ServiceDefaults;
+
+// The DEV-ONLY JWT defaults shared across the auth issuer (Identity) and the resource servers (ADR 023).
+// Identity signs with the PRIVATE key; the resource servers validate with the PUBLIC key. Keeping both
+// halves of the SAME keypair here — the one place both sides reference — means the demo works with ZERO
+// key wiring (Identity mints with DevPrivateKeyPem, Orders verifies with DevPublicKeyPem, they match by
+// construction) and the two halves can never drift out of sync across services.
+//
+// !!! DEV ONLY — NOT A SECRET !!!
+// This private key signs nothing of value in a local demo. Production MUST override via the `Jwt:PrivateKey`
+// (Identity) and `Jwt:PublicKey` (resource servers) configuration keys with real key material from a secret
+// store / user-secrets. Rotation = redeploy that config (ADR 023's accepted round-one tradeoff — the price
+// of validating offline against a config public key instead of a fetched JWKS document; Workshop 002 § 8
+// item 17). A committed dev private key is a deliberate, loudly-marked demo affordance, the auth analogue of
+// the config-driven demo deadlines (EmailChangeDeadline / PaymentDeadline).
+public static class DevJwtDefaults
+{
+ // The issuer + audience the dev token is stamped with, and the resource servers validate against.
+ public const string Issuer = "crittermart-identity";
+ public const string Audience = "crittermart";
+
+ // RSA-2048 keypair (PKCS#8 private / SPKI public), generated once for the demo. Not sensitive.
+ public const string DevPrivateKeyPem = """
+ -----BEGIN PRIVATE KEY-----
+ MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCnXZgWHdFDC007
+ 4skH8UILqRdr18WpxfTJi8sdKxk+v9nXTSrjz89wiaMh1PheuL+0lEQ03HJeS2Hs
+ qCd/lTon+XgJhFAGcAApVb6jl/dMonR4ZkecNFpRCqSQi7W6QAgiy1vCu2K9a5WW
+ 0ouYzZFVxNzC/OKqastTsRGyunf4WGUyLz5iYzY7Hvw50uqOtijh7k93waBHXVum
+ H5mN+EHJa8zANawcpHz6FVtuYqRW35sPqaXRttq82NPkHTFU3x4fQEVrfzh5fxwJ
+ E07FGpE10ceAh6WXWDVTFm/Ne5zJS9vj+CH+0iDLtGxli57rIrhOozEcg2qPg2Oq
+ CrEAdNQBAgMBAAECggEAGk1P0g9MNwMXAir5I4cQtWW/c91BsmqVC6TX03UAefy1
+ 2WtxKsLFJjnF1Kf0Lb6kXKv9rr5Y4uS/NkKUN9gAfw5qL49cRtwMdR0v4TnH+C0t
+ Afbg7iAeyXmotGagX+122dunnLTM0a6PSwKPmatEryae+FhnBBfVwwiixWR0kic2
+ ZFUwwcxQbdSys6dhOutL436tl3tscfHNSbNnAMXFCl9I7SXHzjVjeEcvp2BoR4kA
+ gOrDwb03B11SIIs1davXPqztmqMYJwE6Y1yOfjX184krwDgiMUiqJSHbykhIb5kl
+ VpOpQ8Yb3o+AyLeCBILY93QaHkeaR2tsj2tTLUqFGQKBgQDVIs1wSkBRix5ZB+lc
+ fgLofd1uqYg6WBHil2bpaFLu98XKms44abV1z+ZbnbPQB8XkUeLAAHfhBW191/ve
+ 2RsNa/RschAOCPf9ZryvOSjMT9TKpa5JfjmVfBRZARXijDhCkuVXV3aZ9/TdHk4T
+ iNWdx9zPXwKh4BdQvZmIoOmhFwKBgQDJBlQKeECqRbk0aVKTB2yS/hN4JC8gTdMB
+ umBQ3IiPvuYjPleJeRsH867X8uIbg7ariqQ7U3NaTxksxfQRUoW4Ye4ZtcrD6hsC
+ 4VVEnvkKJKFrYTTvffKCy+wcKe/XtENZS2ofdbX6aElgpJvYq0X9wJi1CAt3Zbkp
+ PWi5mYPypwKBgQC/mNOZWAZNx4P2gPg1H0o5+buvGVPPLxCU44mt1QyIqc/yfAta
+ Bx0K1WO9hBz6q6Inx7zQ4Rri++Abuqc/A2ggPqWxPzBTjZhxAYQo+HdGg5VEvn/Y
+ rVHSoYIhKKqlx2tj3W2xgHyrmI1UoUOKp/1wIxTKjhxtrGcJPAfjHNQo7QKBgDn8
+ +lc+0yCLFl7ZFvnUxWwtoL4iafm+mWTBN7F7vGUC4249OJEufy6vC7u9k53uQ85+
+ Itv+OaNOd+ujesFYdbx3e3CtMT2MlZgiGi++UAauBGZuVw/S3BcA7i49prMpi9gB
+ Wi6TDRib5rbbJR2+YmVNnn9yP6SEkoIj9ca8UwS3AoGAIboW8LGB1ZkacQN5fcfP
+ waEv1+D4DEymBbFMjHUGRFPZN3lwGiwRxUc7HGjpvzxuORbjk5CfPQ58oxxsglW+
+ 00QiA9jmJjKHThrCLlK+cPJHSezVJ42LK0lAejhMHSIOzOJnc5j9Mk+OzWP2Tdhj
+ 7Yzn/m+VAY+UrHInb1ue7Qw=
+ -----END PRIVATE KEY-----
+ """;
+
+ public const string DevPublicKeyPem = """
+ -----BEGIN PUBLIC KEY-----
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp12YFh3RQwtNO+LJB/FC
+ C6kXa9fFqcX0yYvLHSsZPr/Z100q48/PcImjIdT4Xri/tJRENNxyXkth7Kgnf5U6
+ J/l4CYRQBnAAKVW+o5f3TKJ0eGZHnDRaUQqkkIu1ukAIIstbwrtivWuVltKLmM2R
+ VcTcwvziqmrLU7ERsrp3+FhlMi8+YmM2Ox78OdLqjrYo4e5Pd8GgR11bph+ZjfhB
+ yWvMwDWsHKR8+hVbbmKkVt+bD6ml0bbavNjT5B0xVN8eH0BFa384eX8cCRNOxRqR
+ NdHHgIell1g1UxZvzXucyUvb4/gh/tIgy7RsZYue6yK4TqMxHINqj4NjqgqxAHTU
+ AQIDAQAB
+ -----END PUBLIC KEY-----
+ """;
+}
diff --git a/tests/CritterMart.Identity.Tests/EmailChangeSagaIntegrationTests.cs b/tests/CritterMart.Identity.Tests/EmailChangeSagaIntegrationTests.cs
index 8617204..6f40354 100644
--- a/tests/CritterMart.Identity.Tests/EmailChangeSagaIntegrationTests.cs
+++ b/tests/CritterMart.Identity.Tests/EmailChangeSagaIntegrationTests.cs
@@ -26,7 +26,7 @@ public class EmailChangeSagaIntegrationTests
private async Task ResetAsync()
{
using var scope = _fixture.Host.Services.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
+ var db = scope.ServiceProvider.GetRequiredService();
await db.EmailChanges.ExecuteDeleteAsync();
await db.Customers.ExecuteDeleteAsync();
}
@@ -45,14 +45,14 @@ private async Task RegisterAsync(string email, string displayName)
private async Task LoadSagaAsync(string customerId)
{
using var scope = _fixture.Host.Services.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
+ var db = scope.ServiceProvider.GetRequiredService();
return await db.EmailChanges.FindAsync(customerId);
}
private async Task ReadEmailAsync(string customerId)
{
using var scope = _fixture.Host.Services.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
+ var db = scope.ServiceProvider.GetRequiredService();
return (await db.Customers.SingleAsync(c => c.Id == customerId)).Email;
}
diff --git a/tests/CritterMart.Identity.Tests/EmailChangeSagaTests.cs b/tests/CritterMart.Identity.Tests/EmailChangeSagaTests.cs
index f15f702..ffa98a9 100644
--- a/tests/CritterMart.Identity.Tests/EmailChangeSagaTests.cs
+++ b/tests/CritterMart.Identity.Tests/EmailChangeSagaTests.cs
@@ -5,7 +5,7 @@
namespace CritterMart.Identity.Tests;
// Pure unit tests over the EmailChange saga's StartOrHandle/Handle(EmailChangeTimeout) methods — no
-// database, no host. Mirrors ReplenishmentSagaTests.cs. Handle(ConfirmEmailChange, IdentityDbContext) needs
+// database, no host. Mirrors ReplenishmentSagaTests.cs. Handle(ConfirmEmailChange, CustomerDbContext) needs
// a real DbContext (it reads/writes the Customer row), so its behavior is covered by
// EmailChangeSagaIntegrationTests instead — this class covers only what is genuinely pure.
[Trait("Category", "Unit")]
diff --git a/tests/CritterMart.Identity.Tests/LogInTests.cs b/tests/CritterMart.Identity.Tests/LogInTests.cs
new file mode 100644
index 0000000..17a1b5b
--- /dev/null
+++ b/tests/CritterMart.Identity.Tests/LogInTests.cs
@@ -0,0 +1,108 @@
+using System.Security.Cryptography;
+using Alba;
+using CritterMart.Identity.Customers;
+using CritterMart.Identity.Features;
+using CritterMart.ServiceDefaults;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+using Shouldly;
+using Xunit;
+
+namespace CritterMart.Identity.Tests;
+
+// Slice 5.9 — log in and issue a JWT (ADR 023). Password check → mint a signed JWT (sub = customer id).
+[Collection("identity")]
+[Trait("Category", "Integration")]
+public class LogInTests
+{
+ private readonly IdentityAppFixture _fixture;
+
+ public LogInTests(IdentityAppFixture fixture) => _fixture = fixture;
+
+ private const string ValidPassword = "critter1";
+
+ private async Task ResetAsync()
+ {
+ using var scope = _fixture.Host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Users.ExecuteDeleteAsync();
+ await db.Customers.ExecuteDeleteAsync();
+ }
+
+ private async Task RegisterAsync(string email, string displayName)
+ {
+ var result = await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new RegisterWithCredentials(email, displayName, ValidPassword)).ToUrl("/register");
+ s.StatusCodeShouldBe(201);
+ });
+ return result.ReadAsJson()!.Id;
+ }
+
+ // Happy path: a correct password returns a JWT whose sub is the customer id, and whose signature +
+ // issuer + audience + lifetime validate offline against Identity's PUBLIC key — the exact check a
+ // resource server performs (slice 5.10), done here against the dev public key.
+ [Fact]
+ public async Task logging_in_issues_a_jwt_whose_sub_is_the_customer_id()
+ {
+ await ResetAsync();
+ var id = await RegisterAsync("ada@example.com", "Ada Lovelace");
+
+ var result = await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new LogIn("ada@example.com", ValidPassword)).ToUrl("/login");
+ s.StatusCodeShouldBe(200);
+ });
+
+ var body = result.ReadAsJson();
+ body.ShouldNotBeNull();
+ body.CustomerId.ShouldBe(id);
+ body.Token.ShouldNotBeNullOrWhiteSpace();
+
+ // Validate the token exactly as a resource server would — offline, against the public key.
+ using var rsa = RSA.Create();
+ rsa.ImportFromPem(DevJwtDefaults.DevPublicKeyPem);
+ var handler = new JsonWebTokenHandler();
+ var validation = await handler.ValidateTokenAsync(body.Token, new TokenValidationParameters
+ {
+ ValidIssuer = DevJwtDefaults.Issuer,
+ ValidAudience = DevJwtDefaults.Audience,
+ IssuerSigningKey = new RsaSecurityKey(rsa),
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidateLifetime = true
+ });
+
+ validation.IsValid.ShouldBeTrue();
+ validation.Claims["sub"].ShouldBe(id);
+ }
+
+ // Failure path — wrong password → 401, no token, no enumeration.
+ [Fact]
+ public async Task logging_in_with_a_wrong_password_returns_401()
+ {
+ await ResetAsync();
+ await RegisterAsync("ada@example.com", "Ada Lovelace");
+
+ await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new LogIn("ada@example.com", "wrong-password")).ToUrl("/login");
+ s.StatusCodeShouldBe(401);
+ });
+ }
+
+ // Failure path — unknown email → the SAME 401 as a wrong password (no user enumeration).
+ [Fact]
+ public async Task logging_in_with_an_unknown_email_returns_401()
+ {
+ await ResetAsync();
+
+ await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new LogIn("nobody@example.com", ValidPassword)).ToUrl("/login");
+ s.StatusCodeShouldBe(401);
+ });
+ }
+}
diff --git a/tests/CritterMart.Identity.Tests/RegisterCustomerTests.cs b/tests/CritterMart.Identity.Tests/RegisterCustomerTests.cs
index bc9303c..3851149 100644
--- a/tests/CritterMart.Identity.Tests/RegisterCustomerTests.cs
+++ b/tests/CritterMart.Identity.Tests/RegisterCustomerTests.cs
@@ -25,7 +25,7 @@ public class RegisterCustomerTests
private async Task ResetAsync()
{
using var scope = _fixture.Host.Services.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
+ var db = scope.ServiceProvider.GetRequiredService();
await db.Customers.ExecuteDeleteAsync();
}
@@ -73,7 +73,7 @@ public async Task registering_persists_a_row_readable_from_a_fresh_scope()
var id = await RegisterAsync("grace@example.com", "Grace Hopper");
using var scope = _fixture.Host.Services.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
+ var db = scope.ServiceProvider.GetRequiredService();
var row = await db.Customers.SingleAsync(c => c.Id == id);
row.Email.ShouldBe("grace@example.com");
row.DisplayName.ShouldBe("Grace Hopper");
@@ -112,7 +112,7 @@ public async Task registering_a_duplicate_email_is_rejected_case_insensitively()
problem.Title.ShouldBe("CustomerAlreadyRegistered");
using var scope = _fixture.Host.Services.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
+ var db = scope.ServiceProvider.GetRequiredService();
var count = await db.Customers.CountAsync(c => c.Email == "ada@example.com");
count.ShouldBe(1);
}
@@ -132,7 +132,7 @@ public async Task the_email_unique_index_rejects_a_duplicate_inserted_directly()
async Task InsertAsync(string displayName)
{
using var scope = _fixture.Host.Services.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
+ var db = scope.ServiceProvider.GetRequiredService();
db.Customers.Add(new Customer
{
Id = Guid.NewGuid().ToString(),
diff --git a/tests/CritterMart.Identity.Tests/RegisterWithCredentialsTests.cs b/tests/CritterMart.Identity.Tests/RegisterWithCredentialsTests.cs
new file mode 100644
index 0000000..a1cecc8
--- /dev/null
+++ b/tests/CritterMart.Identity.Tests/RegisterWithCredentialsTests.cs
@@ -0,0 +1,134 @@
+using Alba;
+using CritterMart.Contracts;
+using CritterMart.Identity.Customers;
+using CritterMart.Identity.Features;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Wolverine.Tracking;
+using Xunit;
+
+namespace CritterMart.Identity.Tests;
+
+// Slice 5.8 — register with credentials (ADR 023). The credentialed evolution of RegisterCustomer: creates
+// an ASP.NET Core Identity user (password hashed) AND the Customer row, same id, one transaction.
+[Collection("identity")]
+[Trait("Category", "Integration")]
+public class RegisterWithCredentialsTests
+{
+ private readonly IdentityAppFixture _fixture;
+
+ public RegisterWithCredentialsTests(IdentityAppFixture fixture) => _fixture = fixture;
+
+ // Clear BOTH tables — the Identity user table and the registry customers table — between tests.
+ private async Task ResetAsync()
+ {
+ using var scope = _fixture.Host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Users.ExecuteDeleteAsync();
+ await db.Customers.ExecuteDeleteAsync();
+ }
+
+ private const string ValidPassword = "critter1"; // 8 chars, lowercase + digit — passes the demo policy
+
+ // Happy path: a user row + a customer row with the SAME id, and CustomerRegistered on the outbox — the
+ // whole all-or-nothing register committed as one transaction. Also proves Weasel created the ASP.NET
+ // Identity tables (design.md decision 1's flagged risk): if it hadn't, db.Users.Add would fail here.
+ [Fact]
+ public async Task registering_with_credentials_creates_user_and_customer_with_same_id()
+ {
+ await ResetAsync();
+
+ var result = await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new RegisterWithCredentials("ada@example.com", "Ada Lovelace", ValidPassword))
+ .ToUrl("/register");
+ s.StatusCodeShouldBe(201);
+ });
+
+ var body = result.ReadAsJson();
+ body.ShouldNotBeNull();
+ body.Id.ShouldNotBeNullOrWhiteSpace();
+
+ using var scope = _fixture.Host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var customer = await db.Customers.SingleAsync(c => c.Id == body.Id);
+ customer.Email.ShouldBe("ada@example.com");
+ customer.DisplayName.ShouldBe("Ada Lovelace");
+
+ var user = await db.Users.SingleAsync(u => u.Id == body.Id);
+ user.Email.ShouldBe("ada@example.com");
+ user.PasswordHash.ShouldNotBeNullOrWhiteSpace(); // hashed, never plaintext
+ }
+
+ // The outbox half: RegisterWithCredentials cascades CustomerRegistered enrolled in the same transaction.
+ [Fact]
+ public async Task registering_with_credentials_publishes_customer_registered_through_the_outbox()
+ {
+ await ResetAsync();
+
+ var tracked = await _fixture.Host.TrackActivity()
+ .Timeout(TimeSpan.FromSeconds(10))
+ .ExecuteAndWaitAsync(_ => _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new RegisterWithCredentials("grace@example.com", "Grace Hopper", ValidPassword))
+ .ToUrl("/register");
+ s.StatusCodeShouldBe(201);
+ }));
+
+ var published = tracked.Sent.SingleMessage();
+ published.Email.ShouldBe("grace@example.com");
+ published.DisplayName.ShouldBe("Grace Hopper");
+ }
+
+ // Failure path — duplicate email (case-insensitive), rejected before any write.
+ [Fact]
+ public async Task registering_a_duplicate_email_is_rejected()
+ {
+ await ResetAsync();
+ await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new RegisterWithCredentials("ada@example.com", "Ada Lovelace", ValidPassword))
+ .ToUrl("/register");
+ s.StatusCodeShouldBe(201);
+ });
+
+ var result = await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new RegisterWithCredentials(" Ada@Example.com ", "Ada L.", ValidPassword))
+ .ToUrl("/register");
+ s.StatusCodeShouldBe(409);
+ });
+
+ var problem = result.ReadAsJson();
+ problem.ShouldNotBeNull();
+ problem.Title.ShouldBe("CustomerAlreadyRegistered");
+
+ using var scope = _fixture.Host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Customers.CountAsync(c => c.Email == "ada@example.com")).ShouldBe(1);
+ (await db.Users.CountAsync(u => u.Email == "ada@example.com")).ShouldBe(1);
+ }
+
+ // Failure path — a password that fails policy creates NOTHING (no user, no row, no event).
+ [Fact]
+ public async Task registering_with_a_weak_password_is_rejected_and_writes_nothing()
+ {
+ await ResetAsync();
+
+ await _fixture.Host.Scenario(s =>
+ {
+ s.Post.Json(new RegisterWithCredentials("weak@example.com", "Weak Password", "weak"))
+ .ToUrl("/register");
+ s.StatusCodeShouldBe(400);
+ });
+
+ using var scope = _fixture.Host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Customers.AnyAsync(c => c.Email == "weak@example.com")).ShouldBeFalse();
+ (await db.Users.AnyAsync(u => u.Email == "weak@example.com")).ShouldBeFalse();
+ }
+}
diff --git a/tests/CritterMart.Orders.Tests/TokenAuthTests.cs b/tests/CritterMart.Orders.Tests/TokenAuthTests.cs
new file mode 100644
index 0000000..6a3c6df
--- /dev/null
+++ b/tests/CritterMart.Orders.Tests/TokenAuthTests.cs
@@ -0,0 +1,145 @@
+using System.Security.Cryptography;
+using Alba;
+using CritterMart.Orders.Features;
+using CritterMart.Orders.Shopping;
+using CritterMart.ServiceDefaults;
+using Marten;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+using Shouldly;
+using Xunit;
+
+namespace CritterMart.Orders.Tests;
+
+// Slice 5.10 — verify a token at a resource server (ADR 023). Orders validates Identity's JWT OFFLINE against
+// the config-distributed public key (here the dev key) — signature/issuer/audience/lifetime — and sources the
+// customer id from `sub`. The layered cutover keeps the X-Customer-Id header as a dev-only fallback.
+[Collection("orders")]
+[Trait("Category", "Integration")]
+public class TokenAuthTests
+{
+ private static readonly ProductSnapshot CosmicCritterPlush = new("Cosmic Critter Plush", 24.99m);
+
+ private readonly OrdersAppFixture _fixture;
+
+ public TokenAuthTests(OrdersAppFixture fixture) => _fixture = fixture;
+
+ private async Task ResetOrdersAsync()
+ {
+ var store = _fixture.Host.Services.GetRequiredService();
+ await store.Advanced.Clean.DeleteAllDocumentsAsync();
+ await store.Advanced.Clean.DeleteAllEventDataAsync();
+ }
+
+ // Mint a token exactly as Identity does — dev PRIVATE key, dev issuer/audience — so the Orders host
+ // (falling back to the dev PUBLIC key) validates it offline. `signingKeyPem`/`issuer` are overridable so
+ // the failure tests can produce a wrong-signature / wrong-issuer token.
+ private static string MintToken(
+ string customerId, TimeSpan lifetime, string? signingKeyPem = null, string? issuer = null)
+ {
+ // NOT disposed: IdentityModel's CryptoProviderFactory caches a SignatureProvider keyed by the key
+ // material and reuses it across calls with the same dev key — disposing this RSA (via `using`) would
+ // leave that cached provider holding a disposed key, throwing ObjectDisposedException on the NEXT
+ // mint with the same key. The production JwtTokenIssuer likewise holds its RSA for the process
+ // lifetime. Test-lived instances are fine to let the GC reclaim.
+ var rsa = RSA.Create();
+ rsa.ImportFromPem(signingKeyPem ?? DevJwtDefaults.DevPrivateKeyPem);
+ var now = DateTime.UtcNow;
+ var descriptor = new SecurityTokenDescriptor
+ {
+ Issuer = issuer ?? DevJwtDefaults.Issuer,
+ Audience = DevJwtDefaults.Audience,
+ IssuedAt = now,
+ NotBefore = now,
+ Expires = now.Add(lifetime),
+ Claims = new Dictionary { ["sub"] = customerId },
+ SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256)
+ };
+ return new JsonWebTokenHandler().CreateToken(descriptor);
+ }
+
+ // Happy path: a valid Bearer token authenticates the request as its `sub`. No X-Customer-Id header is
+ // sent — the cart is created and read purely off the token's claim, proving `sub` is the trust boundary.
+ [Fact]
+ public async Task a_valid_token_sources_the_customer_id_from_the_sub_claim()
+ {
+ await ResetOrdersAsync();
+ var token = MintToken("customer-tok", TimeSpan.FromHours(1));
+
+ await _fixture.Host.Scenario(_ =>
+ {
+ _.Post.Json(new AddToCart("crit-001", 2, CosmicCritterPlush)).ToUrl("/carts/mine/items");
+ _.WithRequestHeader("Authorization", $"Bearer {token}");
+ _.StatusCodeShouldBe(201);
+ });
+
+ var result = await _fixture.Host.Scenario(_ =>
+ {
+ _.Get.Url("/carts/mine");
+ _.WithRequestHeader("Authorization", $"Bearer {token}");
+ _.StatusCodeShouldBe(200);
+ });
+
+ var view = result.ReadAsJson();
+ view.ShouldNotBeNull();
+ view.CustomerId.ShouldBe("customer-tok"); // sourced from `sub`, not any header
+ }
+
+ // Failure path — a token signed with the WRONG key fails the offline signature check → 401, decided
+ // locally (no HTTP into Identity). A throwaway RSA key stands in for a forged/tampered token.
+ [Fact]
+ public async Task a_token_with_a_bad_signature_is_rejected_locally_with_401()
+ {
+ await ResetOrdersAsync();
+ using var wrongKey = RSA.Create(2048);
+ var forged = MintToken("customer-tok", TimeSpan.FromHours(1), wrongKey.ExportPkcs8PrivateKeyPem());
+
+ await _fixture.Host.Scenario(_ =>
+ {
+ _.Get.Url("/carts/mine");
+ _.WithRequestHeader("Authorization", $"Bearer {forged}");
+ _.StatusCodeShouldBe(401);
+ });
+ }
+
+ // Failure path — an expired token fails the lifetime check → 401. Expired by an hour, well beyond
+ // JwtBearer's default 5-minute clock skew.
+ [Fact]
+ public async Task an_expired_token_is_rejected_locally_with_401()
+ {
+ await ResetOrdersAsync();
+ var expired = MintToken("customer-tok", TimeSpan.FromHours(-1)); // expires before now
+
+ await _fixture.Host.Scenario(_ =>
+ {
+ _.Get.Url("/carts/mine");
+ _.WithRequestHeader("Authorization", $"Bearer {expired}");
+ _.StatusCodeShouldBe(401);
+ });
+ }
+
+ // Layered cutover: with NO Bearer token, the dev-only X-Customer-Id header still resolves identity.
+ // (This is the fallback the seeder, the existing tests, and demo-traffic depend on this PR.)
+ [Fact]
+ public async Task the_x_customer_id_fallback_still_resolves_identity()
+ {
+ await ResetOrdersAsync();
+
+ await _fixture.Host.Scenario(_ =>
+ {
+ _.Post.Json(new AddToCart("crit-001", 1, CosmicCritterPlush)).ToUrl("/carts/mine/items");
+ _.WithRequestHeader("X-Customer-Id", "customer-hdr");
+ _.StatusCodeShouldBe(201);
+ });
+
+ var result = await _fixture.Host.Scenario(_ =>
+ {
+ _.Get.Url("/carts/mine");
+ _.WithRequestHeader("X-Customer-Id", "customer-hdr");
+ _.StatusCodeShouldBe(200);
+ });
+
+ result.ReadAsJson()!.CustomerId.ShouldBe("customer-hdr");
+ }
+}