From e93f5c2eb997c87796b1c498441b31734ac8232a Mon Sep 17 00:00:00 2001 From: emeraldleaf Date: Sat, 13 Jun 2026 08:17:30 -0600 Subject: [PATCH 1/5] fix(auth): wire Keycloak authority from injected keys + restore sub/aud claims (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-layer fix for authed endpoints returning 409 (not 401) locally: 1. AddDefaultAuthentication read 'Keycloak:Url', but the Keycloak.AuthServices Aspire hosting integration injects 'Keycloak:AuthServerUrl' + 'Keycloak:Realm' separately. Authority was therefore null → no JWT bearer scheme registered → '.RequireAuthorization()' had no DefaultChallengeScheme → InvalidOperationException → mapped to 409. Now builds authority = {AuthServerUrl}/realms/{Realm} from the keys Aspire actually injects (issuer then matches the token: localhost:8080). 2. Token had no 'sub' claim. In Keycloak 25+ 'sub' moved into the 'basic' client scope; storefront's defaultClientScopes listed a bogus 'openid' (not a real client scope) and omitted 'basic'. Fixed to [basic, profile, email, roles]. (profile/email already resolved — proof built-in scopes are seeded — so basic is too.) 3. Token had no 'aud: nextaurora-api' (ValidateAudience=true requires it). Added an oidc-audience-mapper to the storefront client. Verified: token issuer already matches; this restores the scheme + sub + aud so the buyer flow validates. Closes the auth half of the local full-stack run (#147). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../realms/nextaurora-realm.json | 12 ++++++++++- NextAurora.ServiceDefaults/Extensions.cs | 21 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/NextAurora.AppHost/realms/nextaurora-realm.json b/NextAurora.AppHost/realms/nextaurora-realm.json index 48031ab9..8e044bb0 100644 --- a/NextAurora.AppHost/realms/nextaurora-realm.json +++ b/NextAurora.AppHost/realms/nextaurora-realm.json @@ -20,7 +20,7 @@ "attributes": { "pkce.code.challenge.method": "S256" }, "redirectUris": ["http://localhost:*/*", "https://localhost:*/*"], "webOrigins": ["http://localhost:*", "https://localhost:*"], - "defaultClientScopes": ["openid", "profile", "email"], + "defaultClientScopes": ["basic", "profile", "email", "roles"], "protocolMappers": [ { "name": "realm-roles", @@ -33,6 +33,16 @@ "access.token.claim": "true", "userinfo.token.claim": "true" } + }, + { + "name": "nextaurora-api-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "nextaurora-api", + "id.token.claim": "false", + "access.token.claim": "true" + } } ] }, diff --git a/NextAurora.ServiceDefaults/Extensions.cs b/NextAurora.ServiceDefaults/Extensions.cs index 674d5663..ddb85b6b 100644 --- a/NextAurora.ServiceDefaults/Extensions.cs +++ b/NextAurora.ServiceDefaults/Extensions.cs @@ -204,8 +204,25 @@ private static void AddFrontendCors(this TBuilder builder) where TBuil private static void AddDefaultAuthentication(this TBuilder builder) where TBuilder : IHostApplicationBuilder { - var authority = builder.Configuration["Authentication:Authority"] - ?? builder.Configuration["Keycloak:Url"]; + // Authority resolution, in priority order: + // 1. Authentication:Authority — explicit override (prod / appsettings). + // 2. Keycloak:AuthServerUrl + Keycloak:Realm — what the Aspire Keycloak.AuthServices + // hosting integration injects from `WithReference(realm, configurationPrefix: "Keycloak")`. + // It does NOT inject a single `Keycloak:Url`; it injects the base server URL and the + // realm name separately, and the OIDC authority is `{AuthServerUrl}/realms/{Realm}`. + // Reading the injected keys (not a hardcoded URL) keeps this correct across Aspire's + // dynamic endpoints. Both the SPA and the API resolve to the same base, so the token's + // issuer matches the validated authority (Keycloak's issuer is host-reflective). See CLAUDE.md. + var authority = builder.Configuration["Authentication:Authority"]; + if (string.IsNullOrEmpty(authority)) + { + var authServerUrl = builder.Configuration["Keycloak:AuthServerUrl"]?.TrimEnd('/'); + var realm = builder.Configuration["Keycloak:Realm"]; + if (!string.IsNullOrEmpty(authServerUrl) && !string.IsNullOrEmpty(realm)) + { + authority = $"{authServerUrl}/realms/{realm}"; + } + } if (string.IsNullOrEmpty(authority)) { From c551a1799b164bd5b92746b79ee18df648306495 Mon Sep 17 00:00:00 2001 From: emeraldleaf Date: Sat, 13 Jun 2026 09:22:14 -0600 Subject: [PATCH 2/5] fix(auth): storefront redirect URIs use trailing-wildcard form Keycloak accepts (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storefront client's redirectUris were 'http://localhost:*/*' — but Keycloak only allows wildcards at the END of a URI, so a wildcard in the port position never matches and the SPA's 'http://localhost:5173/auth/callback' was rejected with 'Invalid parameter: redirect_uri' at the login redirect. Fixed to ['http://localhost:5173/*', 'http://localhost:5173'] (Vite's stable dev port) + matching webOrigins (for the token-endpoint CORS) + post.logout.redirect.uris. Applied the same change to the running instance via the admin API for the live session. Part of the #147 local auth fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- NextAurora.AppHost/realms/nextaurora-realm.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/NextAurora.AppHost/realms/nextaurora-realm.json b/NextAurora.AppHost/realms/nextaurora-realm.json index 8e044bb0..bc3cd835 100644 --- a/NextAurora.AppHost/realms/nextaurora-realm.json +++ b/NextAurora.AppHost/realms/nextaurora-realm.json @@ -17,9 +17,12 @@ "publicClient": true, "standardFlowEnabled": true, "directAccessGrantsEnabled": true, - "attributes": { "pkce.code.challenge.method": "S256" }, - "redirectUris": ["http://localhost:*/*", "https://localhost:*/*"], - "webOrigins": ["http://localhost:*", "https://localhost:*"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:5173/*##http://localhost:5173" + }, + "redirectUris": ["http://localhost:5173/*", "http://localhost:5173"], + "webOrigins": ["http://localhost:5173"], "defaultClientScopes": ["basic", "profile", "email", "roles"], "protocolMappers": [ { From ea3b6d460c55f97d2548651ba046df17aca7da2a Mon Sep 17 00:00:00 2001 From: emeraldleaf Date: Sat, 13 Jun 2026 09:28:54 -0600 Subject: [PATCH 3/5] fix(frontend): guard AuthCallback against StrictMode double code-exchange (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React 19 StrictMode double-invokes effects in dev, so signinRedirectCallback ran twice → the single-use authorization code was consumed by the first exchange and the second failed with invalid_code (a 400 at the token endpoint, surfaced during #147 live verification). A useRef guard ensures the exchange runs exactly once. Dev-only symptom (StrictMode), but the guard is correct regardless. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/features/auth/AuthCallback.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/auth/AuthCallback.tsx b/frontend/src/features/auth/AuthCallback.tsx index fc824da7..8deab3c0 100644 --- a/frontend/src/features/auth/AuthCallback.tsx +++ b/frontend/src/features/auth/AuthCallback.tsx @@ -1,5 +1,5 @@ import { useNavigate } from '@tanstack/react-router' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { userManager } from '@/core/auth' @@ -8,12 +8,20 @@ import { userManager } from '@/core/auth' * routes home. Completing the code exchange is synchronizing with an external system — * a legitimate effect. The redirect-away is done via the router after success, not in the * effect's render path. + * + * The `exchanged` ref guards against React StrictMode's intentional double-invocation of + * effects in dev: the authorization code is single-use, so a second `signinRedirectCallback` + * would fail with `invalid_code`. The ref persists across StrictMode's setup→cleanup→setup, + * so the exchange runs exactly once. */ export function AuthCallback() { const navigate = useNavigate() const [error, setError] = useState(null) + const exchanged = useRef(false) useEffect(() => { + if (exchanged.current) return + exchanged.current = true userManager .signinRedirectCallback() .then(() => navigate({ to: '/' })) From 26c3b2ca7e7936bf8d9d7045ee1398cae9e5d423 Mon Sep 17 00:00:00 2001 From: emeraldleaf Date: Sat, 13 Jun 2026 11:05:36 -0600 Subject: [PATCH 4/5] test: update JWT tests to configure authority via injected Keycloak keys (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ServiceDefaultsJwtTests configured the authority via 'Keycloak:Url' — the dead key this PR replaced. AddDefaultAuthentication now builds the authority from 'Keycloak:AuthServerUrl' + 'Keycloak:Realm' (what Aspire actually injects), so the test setup must use those keys or no JWT scheme registers and the option assertions fail. Resolves to the same authority value; the assertions are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Application/ServiceDefaultsJwtTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/OrderService.Tests.Unit/Application/ServiceDefaultsJwtTests.cs b/tests/OrderService.Tests.Unit/Application/ServiceDefaultsJwtTests.cs index 875cabb0..8de951fa 100644 --- a/tests/OrderService.Tests.Unit/Application/ServiceDefaultsJwtTests.cs +++ b/tests/OrderService.Tests.Unit/Application/ServiceDefaultsJwtTests.cs @@ -66,10 +66,13 @@ private static IHost BuildHostWithAuthority() // AddDefaultAuthentication branches on whether an authority is configured. // With one, it wires AddJwtBearer with the TokenValidationParameters we're // testing. Without one, it registers no-op auth and the JwtBearerOptions - // doesn't get configured. + // doesn't get configured. The authority is built from the keys the Aspire + // Keycloak.AuthServices integration actually injects (AuthServerUrl + Realm) — + // see AddDefaultAuthentication; resolves to https://example.keycloak.test/realms/nextaurora. builder.Configuration.AddInMemoryCollection(new Dictionary(StringComparer.Ordinal) { - ["Keycloak:Url"] = "https://example.keycloak.test/realms/nextaurora", + ["Keycloak:AuthServerUrl"] = "https://example.keycloak.test", + ["Keycloak:Realm"] = "nextaurora", }); builder.AddServiceDefaults(); From 663482883417d2e80b83137b622422276d302cd1 Mon Sep 17 00:00:00 2001 From: emeraldleaf Date: Sat, 13 Jun 2026 11:07:05 -0600 Subject: [PATCH 5/5] docs(architecture): update auth section for new authority resolution (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the AddDefaultAuthentication change (CodeRabbit doc-discipline catch): - authority now resolves from Authentication:Authority, else built from the Aspire-injected Keycloak:AuthServerUrl + Keycloak:Realm (was the dead Keycloak:Url) - no-op fallback note now explains the 401-challenge→409 symptom (#147) - fixed stale method name AddJwtBearerAuthentication → AddDefaultAuthentication The request-flow diagram is unchanged — middleware order + authenticate→authorize are identical; only the authority's config source changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6ddf6ad4..7ed06d7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -438,12 +438,12 @@ All services inherit shared infrastructure configuration: - gRPC calls benefit from HTTP client resilience via service discovery ### Authentication & Authorization -- **JWT Bearer** wired in `NextAurora.ServiceDefaults.AddJwtBearerAuthentication()`. Validates issuer, audience, lifetime; reads authority from `Authentication:Authority` (fallback `Keycloak:Url`), audience from `Authentication:Audience` (default `nextaurora-api`). +- **JWT Bearer** wired in `NextAurora.ServiceDefaults.AddDefaultAuthentication()`. Validates issuer, audience, lifetime; resolves the authority from `Authentication:Authority` (explicit override), else builds it from the keys the Aspire Keycloak integration injects — `Keycloak:AuthServerUrl` + `Keycloak:Realm` → `{AuthServerUrl}/realms/{Realm}`. Audience from `Authentication:Audience` (default `nextaurora-api`). The request *flow* (middleware order, authenticate → authorize) is unchanged by this resolution detail — see `docs/service-request-flow.svg`. - **Keycloak** runs as an Aspire-managed container; `nextaurora-realm` is imported from `realms/nextaurora-realm.json` and injected into each service via `WithReference(realm, configurationPrefix: "Keycloak")`. - **Claim mapping:** `NameClaimType = "preferred_username"`, `RoleClaimType = "realm_access.roles"`. - **Endpoint protection:** `.RequireAuthorization()` on Catalog writes (`POST`/`PUT /api/v1/products`), the entire `/api/v1/orders` group, `/api/v1/payments/process`, the entire `/api/v1/shipments` group. Public reads (`GET /api/v1/products`) remain anonymous. - **Buyer-scope checks:** `POST /api/v1/orders` and `GET /api/v1/orders/buyer/{buyerId}` reject when the JWT `sub` claim doesn't match the route/body buyer ID (returns 403). -- **No-op fallback:** if no `Authentication:Authority` and no `Keycloak:Url` are present, ServiceDefaults registers vanilla `AddAuthentication()`/`AddAuthorization()` so middleware doesn't crash, but `.RequireAuthorization()` endpoints will return 401 (no scheme to validate against). +- **No-op fallback:** if neither `Authentication:Authority` nor the `Keycloak:AuthServerUrl` + `Keycloak:Realm` pair is present, ServiceDefaults registers vanilla `AddAuthentication()`/`AddAuthorization()` so middleware doesn't crash — but with no default challenge scheme, `.RequireAuthorization()` endpoints throw on the 401 challenge (surfacing as a 409 via GlobalExceptionHandler). This is exactly what happened locally before the authority keys were wired correctly (#147). ### Input Validation - **FluentValidation:** All commands have corresponding validator classes (e.g., `CreateProductCommandValidator`, `PlaceOrderCommandValidator`, `ProcessPaymentCommandValidator`) @@ -537,7 +537,7 @@ Read paths never load tracked entities (would over-read columns + materialize en - **Transactional Outbox** - Wolverine transactional outbox in Order, Payment, Shipping. Outgoing events persist to a `wolverine` schema in the same DB transaction as the entity write; background dispatcher flushes to Service Bus. Concurrency-retry policy on `DbUpdateConcurrencyException` (3 attempts, 50/100/250ms backoff). See [docs/performance-and-data-correctness.md](performance-and-data-correctness.md). - **Optimistic Concurrency Tokens** - Postgres `xmin` (Catalog Product/Category, Shipping Shipment) and SQL Server `RowVersion` (Order, Payment, Refund) shadow properties. Last-write-wins is no longer possible. - **EF Core Migrations** - Initial migrations for all four DB services (Catalog, Order, Payment, Shipping). `IDesignTimeDbContextFactory` per context for `dotnet ef` tooling. `MigrateDatabaseAsync()` runs at app startup in development; production should run as a separate deploy step. -- **Authentication & Authorization** - JWT Bearer authentication wired in `NextAurora.ServiceDefaults` (`AddJwtBearerAuthentication()`); identity provider is **Keycloak** (Aspire-managed container, `nextaurora-realm` imported from `realms/nextaurora-realm.json`). `AddJwtBearer` validates issuer, audience, lifetime; claim mapping uses `preferred_username` → name and `realm_access.roles` → role. `.RequireAuthorization()` on every state-changing endpoint and buyer-scoped reads (Catalog write endpoints, all of `/api/v1/orders`, `/api/v1/payments/process`, all of `/api/v1/shipments`). Buyer-scope endpoints additionally verify the JWT `sub` claim matches the route/body buyer ID. `GET /api/v1/products` remains anonymous. +- **Authentication & Authorization** - JWT Bearer authentication wired in `NextAurora.ServiceDefaults` (`AddDefaultAuthentication()`); identity provider is **Keycloak** (Aspire-managed container, `nextaurora-realm` imported from `realms/nextaurora-realm.json`). `AddJwtBearer` validates issuer, audience, lifetime; claim mapping uses `preferred_username` → name and `realm_access.roles` → role. `.RequireAuthorization()` on every state-changing endpoint and buyer-scoped reads (Catalog write endpoints, all of `/api/v1/orders`, `/api/v1/payments/process`, all of `/api/v1/shipments`). Buyer-scope endpoints additionally verify the JWT `sub` claim matches the route/body buyer ID. `GET /api/v1/products` remains anonymous. - **API Versioning** - URL-segment versioning via `Asp.Versioning.Http`. Routes follow `/api/v{version:apiVersion}/...` with the version required in the URL (`AssumeDefaultVersionWhenUnspecified = false`). Default version is `1.0`; `Asp.Versioning.Mvc.ApiExplorer` integrates with OpenAPI so versioned endpoints show up under group `v1` in the OpenAPI spec (rendered in Scalar). Configured globally in `AddServiceDefaults()` so every service inherits the same policy. gRPC is versioned separately via `.proto` `package` (out of scope here). - **Dead Letter Queue Processing** - `messages.abandoned` metric counter on all processors. Replay/audit available via Wolverine's `IMessageStore` API or by querying the `wolverine` schema directly. - **Distributed Caching (Catalog)** - `IProductCache` (read-side, factory-based `GetOrLoadAsync` + `InvalidateAsync`) backed by `Microsoft.Extensions.Caching.Hybrid` 10.5.0: **L1 in-process MemoryCache + L2 Redis**, stampede protection (concurrent misses for the same key invoke the factory once), and tag-based invalidation that clears both layers atomically. `GetProductByIdHandler` reads through the cache; `UpdateProductHandler` and `ReserveStockHandler` call `InvalidateAsync` in the write path. 5-min absolute TTL on both tiers as the safety net for missed invalidations. Cache stores the `ProductDto` projection (not the EF entity) — see [IProductCache.cs](../CatalogService/Domain/IProductCache.cs) and [HybridProductCache.cs](../CatalogService/Infrastructure/Caching/HybridProductCache.cs). List queries (`GetAllProducts`, `SearchProducts`) are intentionally not cached — paginated reads are less hot than single-product lookups, and cross-page invalidation is harder. Full rationale and trade-offs: [docs/performance-and-data-correctness.md "Decision: distributed read caching with HybridCache"](performance-and-data-correctness.md#decision-distributed-read-caching-with-hybridcache).