|
1 | 1 | using Warehouse.Gateway.Auth; |
2 | 2 | using Warehouse.Gateway.Bff; |
| 3 | +using Warehouse.ServiceDefaults; |
3 | 4 |
|
4 | 5 | var builder = WebApplication.CreateBuilder(args); |
5 | 6 |
|
6 | 7 | builder.AddServiceDefaults(); |
7 | 8 |
|
8 | | -// Identity: validate the Keycloak JWTs (issued by the 'keycloak' resource, realm 'warehouse'). Token |
9 | | -// validation lives here at the edge; downstream services trust the gateway (blog #11 architecture). The |
10 | | -// authority resolves through Aspire service discovery (the JwtBearer backchannel inherits ServiceDefaults' |
11 | | -// handlers). Issuer validation is off in dev because Keycloak's advertised issuer differs from the internal |
12 | | -// service-discovery host — pin a ValidIssuer once a stable public URL is in front. |
13 | | -builder.Services.AddAuthentication(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerDefaults.AuthenticationScheme) |
14 | | - .AddJwtBearer(options => |
15 | | - { |
16 | | - options.Authority = builder.Configuration["Keycloak:Authority"] ?? "http://keycloak/realms/warehouse"; |
17 | | - options.RequireHttpsMetadata = false; |
18 | | - // The realm's badge Direct-Grant tokens carry no `aud` claim yet, so audience validation is off in |
19 | | - // dev (alongside issuer). For production, add an audience mapper to the realm and pin both here. |
20 | | - options.TokenValidationParameters.ValidateIssuer = false; |
21 | | - options.TokenValidationParameters.ValidateAudience = false; |
22 | | - // DEV-ONLY shim. In the Aspire stack Keycloak advertises its issuer/JWKS on a dynamic host port the |
23 | | - // gateway's metadata lookup can't align with, so signature validation fails ("signature key not |
24 | | - // found") — the open "stable Keycloak URL" follow-up (src/Identity/README.md). In Development we |
25 | | - // accept the brokered token without re-checking its signature; production keeps full validation. |
26 | | - if (builder.Environment.IsDevelopment()) |
27 | | - { |
28 | | - options.TokenValidationParameters.SignatureValidator = |
29 | | - (token, _) => new Microsoft.IdentityModel.JsonWebTokens.JsonWebToken(token); |
30 | | - } |
31 | | - }); |
32 | | -builder.Services.AddAuthorization(); |
33 | | - |
34 | | -// Badge sign-in broker → Keycloak token endpoint (keeps the confidential client secret server-side). |
35 | | -builder.Services.AddHttpClient(AuthBroker.KeycloakClient, c => c.BaseAddress = new Uri("http://keycloak/")); |
| 9 | +// Identity: validate the Keycloak JWTs (signature + issuer + audience) and register the Desk/Terminal/Staff |
| 10 | +// role policies — shared wiring in ServiceDefaults so the gateway and the backend services agree. No |
| 11 | +// fallback policy here: the gateway gates explicitly (per-endpoint below + per-route in appsettings.json), |
| 12 | +// and /api/auth/login stays anonymous. The authority is the resolved Keycloak URL the AppHost injects; the |
| 13 | +// badge broker mints tokens from that same authority, so issuer + signature validation line up. |
| 14 | +builder.AddWarehouseJwtAuth(); |
| 15 | + |
| 16 | +// The BFF fan-out forwards the caller's bearer to the services (which now validate it themselves), so it |
| 17 | +// needs the current request's HttpContext. |
| 18 | +builder.Services.AddHttpContextAccessor(); |
| 19 | + |
| 20 | +// Badge sign-in broker → Keycloak token endpoint (keeps the confidential client secret server-side). The |
| 21 | +// broker posts to the same Keycloak__Authority the JwtBearer validates against (absolute URL), so the token |
| 22 | +// it mints and the token the gateway validates share one issuer. |
| 23 | +builder.Services.AddHttpClient(AuthBroker.KeycloakClient); |
36 | 24 | builder.Services.AddScoped<AuthBroker>(); |
37 | 25 |
|
38 | 26 | // YARP reverse proxy. Routes/clusters come from configuration; cluster destinations are logical |
|
70 | 58 | return result is null ? Results.Unauthorized() : Results.Ok(result); |
71 | 59 | }).AllowAnonymous(); |
72 | 60 |
|
| 61 | +// Silent renew — anonymous (the refresh token is the credential). The api seam calls this when a call |
| 62 | +// 401s on an expired access token, then retries; a rejected refresh token (expired/revoked) → 401. |
| 63 | +app.MapPost("/api/auth/refresh", async (RefreshRequest request, AuthBroker broker, CancellationToken ct) => |
| 64 | +{ |
| 65 | + var result = await broker.RefreshAsync(request.RefreshToken, ct); |
| 66 | + return result is null ? Results.Unauthorized() : Results.Ok(result); |
| 67 | +}).AllowAnonymous(); |
| 68 | + |
| 69 | +// Sign-out — ends the Keycloak session (revokes the refresh token) so it can't be renewed after logout. |
| 70 | +// Anonymous and best-effort: possessing the refresh token authorises revoking it. |
| 71 | +app.MapPost("/api/auth/logout", async (RefreshRequest request, AuthBroker broker, CancellationToken ct) => |
| 72 | +{ |
| 73 | + await broker.LogoutAsync(request.RefreshToken, ct); |
| 74 | + return Results.NoContent(); |
| 75 | +}).AllowAnonymous(); |
| 76 | + |
73 | 77 | // Work-queue landing — "what needs me now" aggregated across Inventory + Logistics (admin-10). |
74 | 78 | app.MapGet("/api/worklist", async (HttpRequest request, WorklistService worklist, CancellationToken ct) => |
75 | 79 | { |
76 | 80 | var warehouseId = request.Headers["X-Warehouse-Id"].FirstOrDefault(); |
77 | 81 | return Results.Ok(await worklist.BuildAsync(warehouseId, ct)); |
78 | | -}).RequireAuthorization(); |
| 82 | +}).RequireAuthorization(AppRoles.DeskPolicy); |
79 | 83 |
|
80 | 84 | // Terminal Task hub — the handheld operator's open work, aggregated across Inventory + Logistics and |
81 | 85 | // scoped to the operator's warehouse (the terminal sends it as X-Warehouse-Id at the api seam). |
82 | 86 | app.MapGet("/api/terminal/tasks", async (HttpRequest request, TerminalTasksService tasks, CancellationToken ct) => |
83 | 87 | { |
84 | 88 | var warehouseId = request.Headers["X-Warehouse-Id"].FirstOrDefault(); |
85 | 89 | return Results.Ok(await tasks.BuildAsync(warehouseId, ct)); |
86 | | -}).RequireAuthorization(); |
| 90 | +}).RequireAuthorization(AppRoles.TerminalPolicy); |
87 | 91 |
|
88 | 92 | // Global search — "where is X" across products, stock, inbound, orders and locations. |
89 | 93 | app.MapGet("/api/search", async (string? q, HttpRequest request, SearchService search, CancellationToken ct) => |
90 | 94 | { |
91 | 95 | var warehouseId = request.Headers["X-Warehouse-Id"].FirstOrDefault(); |
92 | 96 | return Results.Ok(await search.SearchAsync(q ?? string.Empty, warehouseId, ct)); |
93 | | -}).RequireAuthorization(); |
| 97 | +}).RequireAuthorization(AppRoles.DeskPolicy); |
94 | 98 |
|
95 | 99 | // Desk profile — identity from the token + editable prefs (admin Profile screen). The desk reads and |
96 | 100 | // writes only its OWN profile, so the route id must match the token subject (else 404). |
97 | 101 | app.MapGet("/api/profile/{id}", (string id, HttpContext http, ProfileService profiles) => |
98 | 102 | { |
99 | 103 | var profile = profiles.Build(BearerToken(http), id); |
100 | 104 | return profile is null ? Results.NotFound() : Results.Ok(profile); |
101 | | -}).RequireAuthorization(); |
| 105 | +}).RequireAuthorization(AppRoles.StaffPolicy); |
102 | 106 |
|
103 | 107 | app.MapPost("/api/profile/{id}", (string id, ProfilePrefsDto prefs, HttpContext http, ProfileService profiles) => |
104 | 108 | { |
105 | 109 | var profile = profiles.Update(BearerToken(http), id, prefs); |
106 | 110 | return profile is null ? Results.NotFound() : Results.Ok(profile); |
107 | | -}).RequireAuthorization(); |
| 111 | +}).RequireAuthorization(AppRoles.StaffPolicy); |
108 | 112 |
|
109 | | -// Everything proxied to the services requires a valid token (the desk is authenticated at the edge). |
| 113 | +// Everything proxied to the services requires a valid token (baseline floor); each route additionally |
| 114 | +// pins a role policy via its "AuthorizationPolicy" in appsettings.json — shared services (inventory, |
| 115 | +// logistics) allow either hub (Staff), desk-only services (catalog, topology, dispatch) require Desk. |
110 | 116 | app.MapReverseProxy().RequireAuthorization(); |
111 | 117 |
|
112 | 118 | app.Run(); |
|
0 commit comments