Skip to content

Commit 387c774

Browse files
feat(identity): validate Keycloak JWTs end-to-end — role policies, per-service zero-trust, session refresh/logout (#49)
## What & why Completes the deferred Identity work: the gateway and every backend service now fully validate Keycloak JWTs, authorize by role, and the desk/terminal sessions renew silently and revoke on sign-out. Previously the gateway skipped issuer/audience/signature checks in dev and the services blindly trusted the edge. ## Changes **Token validation (gateway + services)** - Validate **signature + issuer + audience**; removed the dev signature shim. - Authority is the *resolved* Keycloak realm URL injected by the AppHost (`Keycloak__Authority`), because JwtBearer's metadata backchannel doesn't use Aspire service discovery. - Realm `oidc-audience-mapper` stamps `warehouse-admin` into `aud`. **Role-based authorization** - Roles grouped into **Desk / Terminal / Staff** policies (`AppRoles`, shared in ServiceDefaults). - `KeycloakRolesClaimsTransformation` flattens `realm_access.roles` → role claims. - Gateway pins policies per BFF endpoint and per YARP route (`appsettings.json`). **Zero-trust per-service validation** - Shared `AddWarehouseJwtAuth` — services opt into a **fallback policy** (any warehouse role); infra endpoints (`/health`, `/alive`, `/version`, `/`) stay anonymous. - BFF fan-out (`BffFetch`) forwards the caller's bearer so services validate it themselves. **Session lifecycle (admin + terminal)** - Broker `RefreshAsync` / `LogoutAsync` + `/api/auth/refresh` and `/api/auth/logout`. - Api seam silently renews on `401` (single-flight, retry once); AuthContext rotates the refresh token and revokes the Keycloak session on sign-out. **Build / tests** - Integration tests for the authz stack (policies, transformation, fallback, anonymous infra). - Suppressed unrelated NuGet advisory NU1903 (Microsoft.OpenApi 2.0.0 — latest, no fix available). ## Verification - Live against real Keycloak 26.0.7: token `iss`/`aud`, signature (tampered → 401), role matrix (desk↔terminal 403), zero-trust floor, anonymous infra, and refresh→logout→revoke (400 after logout). - Gateway tests **31/31**; admin frontend **78/78**; both frontends typecheck; full solution builds clean. ## Not in scope (follow-ups) - Production hardening: externalize secrets + KC admin creds, persist Keycloak, HTTPS/`KC_HOSTNAME`. - MSW removal from admin continues separately (`remove-msw-keycloak`).
1 parent 42c9792 commit 387c774

29 files changed

Lines changed: 752 additions & 93 deletions

File tree

Directory.Build.props

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@
2323
<IncludeSourceRevisionInInformationalVersion>true</IncludeSourceRevisionInInformationalVersion>
2424
</PropertyGroup>
2525

26+
<!-- Suppress one NuGet audit advisory (NU1903) on Microsoft.OpenApi 2.0.0, pulled transitively by
27+
Microsoft.AspNetCore.OpenApi. 2.0.0 is the latest release, so there is no fixed version to move to,
28+
and TreatWarningsAsErrors would otherwise fail the build. Targeted by advisory URL so auditing stays
29+
on for everything else; remove once a patched Microsoft.OpenApi ships. -->
30+
<ItemGroup>
31+
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-v5pm-xwqc-g5wc" />
32+
</ItemGroup>
33+
2634
<!-- Test projects: relax doc/analyzer noise (covers *.Tests and ArchitectureTests) -->
2735
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
2836
<IsPackable>false</IsPackable>

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
4848
<!-- Tests -->
4949
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
50+
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.9" />
5051
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
5152
<PackageVersion Include="NetArchTest.Rules" Version="1.3.2" />
5253
<PackageVersion Include="NSubstitute" Version="5.3.0" />

src/AppHost/Warehouse.AppHost/AppHost.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,25 +34,43 @@
3434
// not merely until the container is running.
3535
.WithHttpHealthCheck("/realms/warehouse/.well-known/openid-configuration", endpointName: "http");
3636

37+
// Each service validates the Keycloak JWT itself (zero-trust), so it needs the same resolved realm URL
38+
// (Keycloak__Authority) + audience (Keycloak__ClientId) the gateway uses, and must wait for Keycloak so
39+
// its OIDC metadata/JWKS is reachable.
40+
var keycloakAuthority = ReferenceExpression.Create($"{keycloak.GetEndpoint("http")}/realms/warehouse");
41+
3742
var masterData = builder.AddProject<Projects.Warehouse_MasterData_Api>("masterdata-api")
3843
.WithReference(masterDataDb).WaitFor(masterDataDb)
39-
.WithReference(rabbitmq).WaitFor(rabbitmq);
44+
.WithReference(rabbitmq).WaitFor(rabbitmq)
45+
.WithEnvironment("Keycloak__Authority", keycloakAuthority)
46+
.WithEnvironment("Keycloak__ClientId", "warehouse-admin")
47+
.WaitFor(keycloak);
4048

4149
var warehousing = builder.AddProject<Projects.Warehouse_Warehousing_Api>("warehousing-api")
4250
.WithReference(warehouseDb).WaitFor(warehouseDb)
43-
.WithReference(rabbitmq).WaitFor(rabbitmq);
51+
.WithReference(rabbitmq).WaitFor(rabbitmq)
52+
.WithEnvironment("Keycloak__Authority", keycloakAuthority)
53+
.WithEnvironment("Keycloak__ClientId", "warehouse-admin")
54+
.WaitFor(keycloak);
4455

4556
var logistics = builder.AddProject<Projects.Warehouse_Logistics_Api>("logistics-api")
4657
.WithReference(logisticsDb).WaitFor(logisticsDb)
47-
.WithReference(rabbitmq).WaitFor(rabbitmq);
58+
.WithReference(rabbitmq).WaitFor(rabbitmq)
59+
.WithEnvironment("Keycloak__Authority", keycloakAuthority)
60+
.WithEnvironment("Keycloak__ClientId", "warehouse-admin")
61+
.WaitFor(keycloak);
4862

4963
// API gateway (YARP) fronts the three services and validates the Keycloak JWTs.
5064
var gateway = builder.AddProject<Projects.Warehouse_Gateway>("gateway")
5165
.WithReference(masterData)
5266
.WithReference(warehousing)
5367
.WithReference(logistics)
5468
.WithReference(keycloak.GetEndpoint("http")).WaitFor(keycloak)
55-
.WithEnvironment("Keycloak__Realm", "warehouse")
69+
// The *resolved* realm URL (host-reachable), not the logical 'http://keycloak' name: the gateway's
70+
// JwtBearer metadata backchannel doesn't run through service discovery, and the broker mints tokens
71+
// from this same URL so issuer + signature validation line up.
72+
.WithEnvironment("Keycloak__Authority",
73+
ReferenceExpression.Create($"{keycloak.GetEndpoint("http")}/realms/warehouse"))
5674
.WithEnvironment("Keycloak__ClientId", "warehouse-admin")
5775
.WithEnvironment("Keycloak__ClientSecret", "warehouse-admin-secret-dev");
5876

src/Gateway/Warehouse.Gateway/Auth/AuthBroker.cs

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,93 @@
44
namespace Warehouse.Gateway.Auth;
55

66
/// <summary>
7-
/// Brokers the desk's badge sign-in: posts the scanned badge to Keycloak's token endpoint (the custom
8-
/// <c>badge-authenticator</c> direct-grant flow resolves the user — no password), keeping the confidential
9-
/// client secret server-side, and returns the bearer token plus the desk user shaped from its claims.
7+
/// Brokers the desk's badge sign-in and the token lifecycle against Keycloak, keeping the confidential
8+
/// client secret server-side: <see cref="LoginAsync"/> posts the scanned badge to the custom
9+
/// <c>badge-authenticator</c> direct-grant flow (no password); <see cref="RefreshAsync"/> renews an expiring
10+
/// session from its refresh token; <see cref="LogoutAsync"/> ends the Keycloak session. Login/refresh return
11+
/// the bearer token plus the desk user shaped from its claims.
1012
/// </summary>
1113
public sealed class AuthBroker(IHttpClientFactory httpClientFactory, IConfiguration configuration)
1214
{
1315
public const string KeycloakClient = "keycloak";
1416

17+
// The realm URL the gateway also validates against (Keycloak__Authority), so a minted token's issuer
18+
// lines up with the JwtBearer metadata issuer.
19+
private string Authority =>
20+
(configuration["Keycloak:Authority"] ?? "http://localhost:8080/realms/warehouse").TrimEnd('/');
21+
22+
private string ClientId => configuration["Keycloak:ClientId"] ?? "warehouse-admin";
23+
private string ClientSecret => configuration["Keycloak:ClientSecret"] ?? string.Empty;
24+
1525
public async Task<LoginResponse?> LoginAsync(string badge, CancellationToken cancellationToken)
1626
{
1727
if (string.IsNullOrWhiteSpace(badge))
1828
{
1929
return null;
2030
}
2131

22-
var realm = configuration["Keycloak:Realm"] ?? "warehouse";
23-
var form = new Dictionary<string, string>
32+
return await TokenAsync(new Dictionary<string, string>
2433
{
2534
["grant_type"] = "password",
26-
["client_id"] = configuration["Keycloak:ClientId"] ?? "warehouse-admin",
27-
["client_secret"] = configuration["Keycloak:ClientSecret"] ?? string.Empty,
2835
["scope"] = "openid",
2936
// The badge authenticator reads either field; sending both keeps the token endpoint's
3037
// password-grant pre-checks happy.
3138
["badge"] = badge.Trim(),
3239
["username"] = badge.Trim(),
40+
}, cancellationToken);
41+
}
42+
43+
/// <summary>Renews the session from its refresh token; null when the refresh token is expired/revoked.</summary>
44+
public async Task<LoginResponse?> RefreshAsync(string refreshToken, CancellationToken cancellationToken)
45+
{
46+
if (string.IsNullOrWhiteSpace(refreshToken))
47+
{
48+
return null;
49+
}
50+
51+
return await TokenAsync(new Dictionary<string, string>
52+
{
53+
["grant_type"] = "refresh_token",
54+
["refresh_token"] = refreshToken,
55+
}, cancellationToken);
56+
}
57+
58+
/// <summary>Ends the Keycloak session (revokes the refresh token). Best-effort — a blank or already
59+
/// invalid token is treated as success (there is nothing left to revoke).</summary>
60+
public async Task<bool> LogoutAsync(string refreshToken, CancellationToken cancellationToken)
61+
{
62+
if (string.IsNullOrWhiteSpace(refreshToken))
63+
{
64+
return true;
65+
}
66+
67+
var form = new Dictionary<string, string>
68+
{
69+
["client_id"] = ClientId,
70+
["client_secret"] = ClientSecret,
71+
["refresh_token"] = refreshToken,
3372
};
3473

3574
var client = httpClientFactory.CreateClient(KeycloakClient);
3675
using var response = await client.PostAsync(
37-
$"realms/{realm}/protocol/openid-connect/token", new FormUrlEncodedContent(form), cancellationToken);
76+
$"{Authority}/protocol/openid-connect/logout", new FormUrlEncodedContent(form), cancellationToken);
77+
78+
return response.IsSuccessStatusCode;
79+
}
80+
81+
/// <summary>Posts a grant to the token endpoint (client credentials added) and shapes the response.</summary>
82+
private async Task<LoginResponse?> TokenAsync(Dictionary<string, string> form, CancellationToken cancellationToken)
83+
{
84+
form["client_id"] = ClientId;
85+
form["client_secret"] = ClientSecret;
86+
87+
var client = httpClientFactory.CreateClient(KeycloakClient);
88+
using var response = await client.PostAsync(
89+
$"{Authority}/protocol/openid-connect/token", new FormUrlEncodedContent(form), cancellationToken);
3890

3991
if (!response.IsSuccessStatusCode)
4092
{
41-
return null; // unknown/disabled badge → 401 at the endpoint
93+
return null; // unknown/disabled badge or expired refresh token → 401 at the endpoint
4294
}
4395

4496
var token = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken);

src/Gateway/Warehouse.Gateway/Auth/AuthClaims.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Text;
22
using System.Text.Json;
3+
using Warehouse.ServiceDefaults;
34

45
namespace Warehouse.Gateway.Auth;
56

@@ -10,8 +11,6 @@ namespace Warehouse.Gateway.Auth;
1011
/// </summary>
1112
internal static class AuthClaims
1213
{
13-
private static readonly string[] DeskRoles = ["manager", "coordinator", "inspector"];
14-
1514
public static CurrentUserDto ToUser(string accessToken)
1615
{
1716
var payload = DecodePayload(accessToken);
@@ -45,7 +44,8 @@ string Claim(string name) =>
4544
/// (e.g. the profile's phone and last-login time). Same no-signature-check caveat as <see cref="ToUser"/>.</summary>
4645
internal static JsonElement Payload(string accessToken) => DecodePayload(accessToken);
4746

48-
/// <summary>The first realm role that is one of the desk roles (Keycloak adds default roles too).</summary>
47+
/// <summary>The caller's app role — the first realm role that is one of the app's known roles, desk or
48+
/// terminal (Keycloak also adds default roles like <c>offline_access</c>, which we ignore).</summary>
4949
private static string ExtractRole(JsonElement payload)
5050
{
5151
if (payload.TryGetProperty("realm_access", out var realmAccess) &&
@@ -54,7 +54,7 @@ private static string ExtractRole(JsonElement payload)
5454
{
5555
foreach (var role in roles.EnumerateArray())
5656
{
57-
if (role.GetString() is { } value && DeskRoles.Contains(value))
57+
if (role.GetString() is { } value && AppRoles.All.Contains(value))
5858
{
5959
return value;
6060
}

src/Gateway/Warehouse.Gateway/Auth/AuthContracts.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ namespace Warehouse.Gateway.Auth;
33
/// <summary>The badge the desk scanned at sign-in.</summary>
44
public sealed record LoginRequest(string Badge);
55

6+
/// <summary>Carries the refresh token for a silent renew (<c>/api/auth/refresh</c>) or a session end
7+
/// (<c>/api/auth/logout</c>).</summary>
8+
public sealed record RefreshRequest(string RefreshToken);
9+
610
/// <summary>What the broker returns to the SPA: the bearer token (+ refresh) and the desk user derived
711
/// from its claims, so the admin's existing <c>CurrentUser</c> shape is preserved.</summary>
812
public sealed record LoginResponse(string AccessToken, string? RefreshToken, int ExpiresIn, CurrentUserDto User);

src/Gateway/Warehouse.Gateway/Bff/BffFetch.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,23 @@ namespace Warehouse.Gateway.Bff;
88
/// warehouse, and reads a JSON list best-effort (a failing source returns empty rather than throwing, so
99
/// one slow service never fails the whole aggregate).
1010
/// </summary>
11-
public sealed class BffFetch(IHttpClientFactory httpClientFactory, ILogger<BffFetch> logger)
11+
public sealed class BffFetch(
12+
IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor, ILogger<BffFetch> logger)
1213
{
1314
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
1415

1516
public HttpClient Client(string name, string? warehouseId)
1617
{
1718
var client = httpClientFactory.CreateClient(name);
19+
20+
// Forward the caller's bearer so the service can validate it itself (zero-trust); the incoming
21+
// request is authenticated (the BFF endpoints RequireAuthorization), so the header is present.
22+
var authorization = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
23+
if (!string.IsNullOrEmpty(authorization))
24+
{
25+
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", authorization);
26+
}
27+
1828
if (!string.IsNullOrWhiteSpace(warehouseId))
1929
{
2030
client.DefaultRequestHeaders.Add("X-Warehouse-Id", warehouseId);

src/Gateway/Warehouse.Gateway/Program.cs

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,26 @@
11
using Warehouse.Gateway.Auth;
22
using Warehouse.Gateway.Bff;
3+
using Warehouse.ServiceDefaults;
34

45
var builder = WebApplication.CreateBuilder(args);
56

67
builder.AddServiceDefaults();
78

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);
3624
builder.Services.AddScoped<AuthBroker>();
3725

3826
// YARP reverse proxy. Routes/clusters come from configuration; cluster destinations are logical
@@ -70,43 +58,61 @@
7058
return result is null ? Results.Unauthorized() : Results.Ok(result);
7159
}).AllowAnonymous();
7260

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+
7377
// Work-queue landing — "what needs me now" aggregated across Inventory + Logistics (admin-10).
7478
app.MapGet("/api/worklist", async (HttpRequest request, WorklistService worklist, CancellationToken ct) =>
7579
{
7680
var warehouseId = request.Headers["X-Warehouse-Id"].FirstOrDefault();
7781
return Results.Ok(await worklist.BuildAsync(warehouseId, ct));
78-
}).RequireAuthorization();
82+
}).RequireAuthorization(AppRoles.DeskPolicy);
7983

8084
// Terminal Task hub — the handheld operator's open work, aggregated across Inventory + Logistics and
8185
// scoped to the operator's warehouse (the terminal sends it as X-Warehouse-Id at the api seam).
8286
app.MapGet("/api/terminal/tasks", async (HttpRequest request, TerminalTasksService tasks, CancellationToken ct) =>
8387
{
8488
var warehouseId = request.Headers["X-Warehouse-Id"].FirstOrDefault();
8589
return Results.Ok(await tasks.BuildAsync(warehouseId, ct));
86-
}).RequireAuthorization();
90+
}).RequireAuthorization(AppRoles.TerminalPolicy);
8791

8892
// Global search — "where is X" across products, stock, inbound, orders and locations.
8993
app.MapGet("/api/search", async (string? q, HttpRequest request, SearchService search, CancellationToken ct) =>
9094
{
9195
var warehouseId = request.Headers["X-Warehouse-Id"].FirstOrDefault();
9296
return Results.Ok(await search.SearchAsync(q ?? string.Empty, warehouseId, ct));
93-
}).RequireAuthorization();
97+
}).RequireAuthorization(AppRoles.DeskPolicy);
9498

9599
// Desk profile — identity from the token + editable prefs (admin Profile screen). The desk reads and
96100
// writes only its OWN profile, so the route id must match the token subject (else 404).
97101
app.MapGet("/api/profile/{id}", (string id, HttpContext http, ProfileService profiles) =>
98102
{
99103
var profile = profiles.Build(BearerToken(http), id);
100104
return profile is null ? Results.NotFound() : Results.Ok(profile);
101-
}).RequireAuthorization();
105+
}).RequireAuthorization(AppRoles.StaffPolicy);
102106

103107
app.MapPost("/api/profile/{id}", (string id, ProfilePrefsDto prefs, HttpContext http, ProfileService profiles) =>
104108
{
105109
var profile = profiles.Update(BearerToken(http), id, prefs);
106110
return profile is null ? Results.NotFound() : Results.Ok(profile);
107-
}).RequireAuthorization();
111+
}).RequireAuthorization(AppRoles.StaffPolicy);
108112

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.
110116
app.MapReverseProxy().RequireAuthorization();
111117

112118
app.Run();

0 commit comments

Comments
 (0)