diff --git a/NextAurora.AppHost/realms/nextaurora-realm.json b/NextAurora.AppHost/realms/nextaurora-realm.json index 48031ab9..bc3cd835 100644 --- a/NextAurora.AppHost/realms/nextaurora-realm.json +++ b/NextAurora.AppHost/realms/nextaurora-realm.json @@ -17,10 +17,13 @@ "publicClient": true, "standardFlowEnabled": true, "directAccessGrantsEnabled": true, - "attributes": { "pkce.code.challenge.method": "S256" }, - "redirectUris": ["http://localhost:*/*", "https://localhost:*/*"], - "webOrigins": ["http://localhost:*", "https://localhost:*"], - "defaultClientScopes": ["openid", "profile", "email"], + "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": [ { "name": "realm-roles", @@ -33,6 +36,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)) { 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). 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: '/' })) 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();