Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions NextAurora.AppHost/realms/nextaurora-realm.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
]
},
Expand Down
21 changes: 19 additions & 2 deletions NextAurora.ServiceDefaults/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,25 @@ private static void AddFrontendCors<TBuilder>(this TBuilder builder) where TBuil

private static void AddDefaultAuthentication<TBuilder>(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}";
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (string.IsNullOrEmpty(authority))
{
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 crashbut 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`)
Expand Down Expand Up @@ -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<T>` per context for `dotnet ef` tooling. `MigrateDatabaseAsync<T>()` 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).
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/features/auth/AuthCallback.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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<string | null>(null)
const exchanged = useRef(false)

useEffect(() => {
if (exchanged.current) return
exchanged.current = true
userManager
.signinRedirectCallback()
.then(() => navigate({ to: '/' }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string?>(StringComparer.Ordinal)
{
["Keycloak:Url"] = "https://example.keycloak.test/realms/nextaurora",
["Keycloak:AuthServerUrl"] = "https://example.keycloak.test",
["Keycloak:Realm"] = "nextaurora",
});

builder.AddServiceDefaults();
Expand Down
Loading