diff --git a/Documentation/configuration/authorization.md b/Documentation/configuration/authorization.md
index 7b73e0dc..3245ac89 100644
--- a/Documentation/configuration/authorization.md
+++ b/Documentation/configuration/authorization.md
@@ -132,9 +132,12 @@ an extra check would quietly drop the organization check, and a service added la
would be the way in.
Which service a request targets is worked out the same way the [route table](services.md) works it out: the
-single configured service when there is only one, otherwise the `x-cratis-microservice` header (or the legacy `Service-ID`) or the `service` query
-parameter. A request in a multi-service deployment that names neither reaches no service route either, so
-only the root requirements apply to it.
+service whose [host or path prefix](services.md#routing-by-host-or-path-prefix) the request matches, the
+service named by the `x-cratis-microservice` header (or the legacy `Service-ID`) or the `service` query
+parameter, or the single configured service when there is only one, in the precedence the route table uses.
+A request in a multi-service deployment that matches no service reaches no service route either. When such
+a request still names a configured service, that service's requirements apply anyway. An unknown header
+does not hide a query-selected service's requirements. Otherwise only the root requirements apply.
---
diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md
index 3db46e10..aa661689 100644
--- a/Documentation/configuration/services.md
+++ b/Documentation/configuration/services.md
@@ -37,6 +37,9 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n
|----------|------|---------|-------------|
| `Backend` | `ServiceEndpointConfig` | `null` | API backend endpoint. |
| `Frontend` | `ServiceEndpointConfig` | `null` | SPA / static-asset frontend endpoint. |
+| `Hosts` | `string[]` | `[]` | Host names (with an optional port) that route to this service. See [Routing by host or path prefix](#routing-by-host-or-path-prefix). |
+| `PathPrefix` | `string` | `""` | Path prefix that routes to this service, for example `/reporting`. See [Routing by host or path prefix](#routing-by-host-or-path-prefix). |
+| `StripPathPrefix` | `bool` | `false` | Remove `PathPrefix` from the forwarded path and send it in `X-Forwarded-Prefix`. |
| `ResolveIdentityDetails` | `bool?` | `true` when Backend is set | Whether to call `/.cratis/me` on this service **at all**. See [Identity enrichment](#identity-enrichment). |
| `IdentityVerification` | `BestEffort` \| `Required` | `BestEffort` | What that call's answer **means**. See [Identity enrichment](#identity-enrichment). |
| `IdentityVerificationTimeout` | `TimeSpan` | `00:00:10` under `Required`, unbounded under `BestEffort` | How long to wait for the answer. Zero or negative leaves the wait unbounded. See [Two settings, two questions](#two-settings-two-questions). |
@@ -64,15 +67,16 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n
### Single service
-When only one service is configured, AuthProxy adds a plain catch-all route so the service
-is reachable without any special routing header or query parameter.
+When only one service is configured, and it declares neither `Hosts` nor a `PathPrefix`, AuthProxy adds a
+plain catch-all route so the service is reachable without any special routing header or query parameter.
- `/{**path}` → frontend
- `/api/{**path}` → backend
### Multiple services
-With more than one service, clients must indicate the target using one of:
+With more than one service, a request reaches a service when the service declares the request's host or
+path prefix (see below), or when the client names the service with one of:
| Mechanism | Example |
|-----------|---------|
@@ -91,7 +95,101 @@ The selected identifier is forwarded under both header names, including when sel
Forwarding `Service-ID` is deprecated and will be removed in a future major release; move backends to
`x-cratis-microservice`.
-Routes are matched case-insensitively.
+Routes are matched case-insensitively. Within a service, `/api/...` goes to the backend and everything else
+goes to the frontend. With host routing, path-prefix routing or the unrestricted single-service catch-all,
+a service with only a backend receives every path within that route. Header and query selection of a
+backend-only service only match `/api/...`.
+
+### Routing by host or path prefix
+
+A browser cannot put a header on a top-level navigation, and adding `?service=` to every URL (assets, deep
+links, bookmarks) is impractical. To put several applications behind one AuthProxy, and so behind one
+sign-in, give each service a host, a path prefix, or both:
+
+```json
+{
+ "Cratis": {
+ "AuthProxy": {
+ "Services": {
+ "portal": {
+ "Hosts": [ "portal.example.com" ],
+ "Frontend": { "BaseUrl": "http://portal-web:3000/" },
+ "Backend": { "BaseUrl": "http://portal-api:8080/" }
+ },
+ "reporting": {
+ "PathPrefix": "/reporting",
+ "StripPathPrefix": true,
+ "Frontend": { "BaseUrl": "http://reporting-web:3000/" },
+ "Backend": { "BaseUrl": "http://reporting-api:8080/" },
+ "ClientCredentials": { "RoutePrefix": "/reporting/api" }
+ }
+ }
+ }
+ }
+}
+```
+
+Here `https://portal.example.com/orders` goes to the portal frontend, `https://portal.example.com/api/orders` to
+the portal backend, `https://portal.example.com/reporting/api/sales` to the reporting backend as `/api/sales`,
+and `https://any-host/reporting/dashboard` to the reporting frontend as `/dashboard`.
+
+#### Precedence
+
+When more than one rule could match a request, the first one in this list wins:
+
+1. [Anonymous paths](#anonymous-paths).
+2. A `PathPrefix` on one of the service's `Hosts`.
+3. A `PathPrefix` on a service without `Hosts`, which matches on every host.
+4. The `x-cratis-microservice` header (or legacy `Service-ID`), then the `service` query parameter.
+5. `Hosts` on a service without a `PathPrefix`.
+6. The single-service catch-all routes.
+
+A path prefix claims its part of the URL, so it wins over a header or query parameter naming another
+service. A host is only a default for the requests on it: a frontend served from `portal.example.com` can
+still call another service's backend by naming it in `x-cratis-microservice`, as Arc frontends do.
+
+The service a request is routed to is also the service whose [authorization requirements](authorization.md)
+apply to it, and the only service whose [client-credentials](#client-credentials) tokens it accepts. The
+checks use the selected proxy route, so a host or prefix cannot be used to reach a service without
+meeting its requirements. Client-credentials `RoutePrefix` must include the external path prefix; see
+[Client credentials](#client-credentials).
+
+#### Ambiguous matches fail at startup
+
+AuthProxy refuses to start, and names the services involved, when:
+
+- two services without a `PathPrefix` declare the same host (`example.com` without a port overlaps every
+ `example.com:port`);
+- two services declare equal or nested path prefixes (`/reports` and `/reports/archive`) on the same hosts, or
+ both on every host;
+- a `Hosts` entry is not a host name with an optional numeric port. URLs, paths, IPv6 literals and wildcards
+ (`*.example.com`) are refused;
+- a `PathPrefix` is not a rooted path of literal segments, is `/api` or below it, or covers a path AuthProxy
+ reserves for itself (`/.cratis`, `/_pages`, `/invite`, `/register`, `/signin-*`);
+- a service declares `Hosts` or a `PathPrefix` but has no `Backend` or `Frontend`, or sets `StripPathPrefix`
+ without a `PathPrefix`.
+
+A prefix on some hosts and a prefix on every host may overlap. The host-specific one wins on its hosts.
+
+#### Keeping or stripping the prefix
+
+By default the service receives the path as requested, `/reporting/api/sales`, and serves itself under the
+prefix. In ASP.NET Core that is `app.UsePathBase("/reporting")`, and a single-page frontend builds with the
+same base path.
+
+With `StripPathPrefix` the prefix is removed: the service receives `/api/sales`, and AuthProxy sends the
+removed prefix in `X-Forwarded-Prefix`. A backend that honors forwarded headers restores the removed prefix
+as its path base, so links and redirects it generates still point under `/reporting`. An `X-Forwarded-Prefix`
+sent by a proxy in front of AuthProxy is replaced, not combined. [Anonymous paths](#anonymous-paths) below a stripped prefix are
+stripped too. Declare them with the full path, for example `/reporting/public`.
+
+AuthProxy's own endpoints (`/.cratis/login`, `/.cratis/select-provider`, `/.cratis/logout` and the other
+`/.cratis/*` paths it answers itself) stay at the root on every host. A frontend served under a prefix calls
+them at the root. `/reporting/.cratis/me` is forwarded to the reporting service like any other path under
+its prefix.
+
+WebSocket upgrades and server-sent events follow the same routes as any other request.
+
---
@@ -256,7 +354,8 @@ that still returns the selection page can be diagnosed from the log rather than
`/api` chooses the endpoint the same way the authenticated routes do: a prefix under `/api` is served by
the service's `Backend`, anything else by its `Frontend`, falling back to whichever endpoint the service
-actually declares.
+actually declares. Under a service's `PathPrefix`, the same split applies relative to that prefix: an
+anonymous `/reporting/api/webhook` goes to the reporting backend.
### What it does and does not change
@@ -482,6 +581,11 @@ That endpoint forwards the supplied client credentials to the service's verifica
on success, issues a bearer token scoped to the configured `RoutePrefix`, along with a refresh token
that can later be exchanged for a new access token without resupplying the client credentials.
+`RoutePrefix` defaults to `/api` and is checked against the incoming path, before `StripPathPrefix` removes
+anything. For a service with `PathPrefix: /reporting`, set `ClientCredentials.RoutePrefix` to
+`/reporting/api` to accept bearer tokens on its API routes (or another explicitly permitted external
+prefix). Host routing can distinguish services that share the same `RoutePrefix`.
+
This creates a one-to-one relationship between:
- the proxied service
diff --git a/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_a_punycode_host_is_requested.cs b/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_a_punycode_host_is_requested.cs
new file mode 100644
index 00000000..530a9cbc
--- /dev/null
+++ b/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_a_punycode_host_is_requested.cs
@@ -0,0 +1,34 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Security.for_ServiceRouting;
+
+///
+/// A punycode declaration must match the Unicode host ASP.NET exposes from the wire-format Host header.
+///
+/// The running proxy and its origins.
+[Collection(ServiceRoutingSpecCollection.Name)]
+public class when_a_punycode_host_is_requested(ServiceRoutingHarness harness) : IAsyncLifetime
+{
+ HttpResponseMessage? _response;
+ ForwardedRequest? _forwarded;
+
+ public async Task InitializeAsync()
+ {
+ using var client = harness.CreateSecurityClient();
+ harness.ClearOrigins();
+ using var request = ServiceRoutingHarness.Request("/api/books", "xn--bcher-kva.example.test");
+ _response = await client.SendAsync(request);
+ _forwarded = harness.Portal.LastRequestTo("/api/books");
+ }
+
+ public Task DisposeAsync()
+ {
+ _response?.Dispose();
+ return Task.CompletedTask;
+ }
+
+ [Fact] public void should_match_the_host_route() => Assert.Equal(HttpStatusCode.OK, _response!.StatusCode);
+ [Fact] public void should_forward_to_the_declaring_service() => Assert.NotNull(_forwarded);
+ [Fact] public void should_forward_the_selected_service_identifier() => Assert.Equal("portal", _forwarded!.Value(Headers.ServiceId));
+}
diff --git a/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_a_service_header_uses_yarp_value_syntax.cs b/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_a_service_header_uses_yarp_value_syntax.cs
new file mode 100644
index 00000000..e799c6ff
--- /dev/null
+++ b/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_a_service_header_uses_yarp_value_syntax.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Security.for_ServiceRouting;
+
+///
+/// YARP splits and unquotes header values. Authorization must apply to the selected route, not the raw header
+/// or the less restricted service whose host was requested.
+///
+/// The running proxy and its origins.
+[Collection(ServiceRoutingSpecCollection.Name)]
+public class when_a_service_header_uses_yarp_value_syntax(ServiceRoutingHarness harness)
+{
+ [Theory]
+ [InlineData("\"admin\"")]
+ [InlineData("admin,")]
+ [InlineData("unknown, admin")]
+ public async Task should_require_the_claim_of_the_selected_service(string header)
+ {
+ using var client = harness.CreateSecurityClient();
+ harness.ClearOrigins();
+ using var request = ServiceRoutingHarness.Request("/api/users", "portal.example.test");
+ request.Headers.TryAddWithoutValidation(Headers.ServiceId, header);
+ using var response = await client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.False(harness.Admin.ReceivedAnythingFor("/api/users"));
+ Assert.False(harness.Portal.ReceivedAnythingFor("/api/users"));
+ }
+
+ [Theory]
+ [InlineData("\"admin\"")]
+ [InlineData("admin,")]
+ [InlineData("unknown, admin")]
+ public async Task should_forward_a_qualified_caller_to_the_selected_service(string header)
+ {
+ using var client = harness.CreateSecurityClient();
+ harness.ClearOrigins();
+ using var request = ServiceRoutingHarness.Request("/api/users", "portal.example.test", withAdminClaim: true);
+ request.Headers.TryAddWithoutValidation(Headers.ServiceId, header);
+ using var response = await client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.True(harness.Admin.ReceivedAnythingFor("/api/users"));
+ Assert.False(harness.Portal.ReceivedAnythingFor("/api/users"));
+ }
+}
diff --git a/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_an_anonymous_api_is_under_a_stripped_prefix.cs b/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_an_anonymous_api_is_under_a_stripped_prefix.cs
new file mode 100644
index 00000000..394329e2
--- /dev/null
+++ b/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_an_anonymous_api_is_under_a_stripped_prefix.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Security.for_ServiceRouting;
+
+///
+/// Anonymous API paths use the same backend and stripped path as authenticated API paths.
+///
+/// The running proxy and its distinct backend and frontend origins.
+[Collection(ServiceRoutingSpecCollection.Name)]
+public class when_an_anonymous_api_is_under_a_stripped_prefix(ServiceRoutingHarness harness)
+{
+ [Fact]
+ public async Task should_forward_to_the_backend_without_a_session()
+ {
+ using var client = harness.CreateSecurityClient();
+ harness.ClearOrigins();
+ using var response = await client.GetAsync($"{ServiceRoutingHarness.ReportsPrefix}/api/health");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var forwarded = harness.Reports.LastRequestTo("/api/health");
+ Assert.NotNull(forwarded);
+ Assert.Equal(ServiceRoutingHarness.ReportsPrefix, forwarded.Value("X-Forwarded-Prefix"));
+ Assert.False(harness.ReportsFrontend.ReceivedAnythingFor("/api/health"));
+ }
+}
diff --git a/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_services_are_routed_by_host_and_path_prefix.cs b/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_services_are_routed_by_host_and_path_prefix.cs
new file mode 100644
index 00000000..10a0cb01
--- /dev/null
+++ b/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_services_are_routed_by_host_and_path_prefix.cs
@@ -0,0 +1,122 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Security.for_ServiceRouting;
+
+///
+/// OWASP A01 — Broken Access Control. Routing by host or path prefix must send each request to the service that
+/// claims it, and the service whose authorization requirements were checked must be the service that receives it.
+/// A host that reached a service without its requirements applying would be a way around them.
+///
+/// The running proxy and its three origins.
+[Collection(ServiceRoutingSpecCollection.Name)]
+public class when_services_are_routed_by_host_and_path_prefix(ServiceRoutingHarness harness) : IAsyncLifetime
+{
+ ForwardedRequest? _prefixedApi;
+ ForwardedRequest? _prefixedAsset;
+ bool _portalSawThePrefixedRequest;
+
+ HttpResponseMessage? _adminHostWithoutClaim;
+ bool _adminSawTheUnqualifiedCaller;
+ HttpResponseMessage? _adminHostWithClaim;
+ bool _adminSawTheQualifiedCaller;
+
+ bool _portalSawTheExplicitSelection;
+ bool _adminSawTheExplicitSelection;
+
+ HttpResponseMessage? _unrouted;
+ HttpResponseMessage? _proxyOwnedPathOnAHost;
+ bool _adminSawTheProxyOwnedPath;
+
+ public async Task InitializeAsync()
+ {
+ using var client = harness.CreateSecurityClient();
+
+ harness.ClearOrigins();
+ var prefixed = ServiceRoutingHarness.Request($"{ServiceRoutingHarness.ReportsPrefix}/api/orders?page=2");
+ prefixed.Headers.TryAddWithoutValidation(Headers.ServiceId, "portal");
+ await client.SendAsync(prefixed);
+ _prefixedApi = harness.Reports.LastRequestTo("/api/orders");
+ _portalSawThePrefixedRequest = harness.Portal.ReceivedAnythingFor($"{ServiceRoutingHarness.ReportsPrefix}/api/orders")
+ || harness.Portal.ReceivedAnythingFor("/api/orders");
+
+ await client.SendAsync(ServiceRoutingHarness.Request($"{ServiceRoutingHarness.ReportsPrefix}/assets/app.js"));
+ _prefixedAsset = harness.ReportsFrontend.LastRequestTo("/assets/app.js");
+
+ harness.ClearOrigins();
+ _adminHostWithoutClaim = await client.SendAsync(ServiceRoutingHarness.Request("/api/users", ServiceRoutingHarness.AdminHost));
+ _adminSawTheUnqualifiedCaller = harness.Admin.ReceivedAnythingFor("/api/users");
+
+ harness.ClearOrigins();
+ _adminHostWithClaim = await client.SendAsync(ServiceRoutingHarness.Request("/api/users", ServiceRoutingHarness.AdminHost, withAdminClaim: true));
+ _adminSawTheQualifiedCaller = harness.Admin.ReceivedAnythingFor("/api/users");
+
+ harness.ClearOrigins();
+ var explicitSelection = ServiceRoutingHarness.Request("/api/customers", ServiceRoutingHarness.AdminHost);
+ explicitSelection.Headers.TryAddWithoutValidation(Headers.ServiceId, "portal");
+ await client.SendAsync(explicitSelection);
+ _portalSawTheExplicitSelection = harness.Portal.ReceivedAnythingFor("/api/customers");
+ _adminSawTheExplicitSelection = harness.Admin.ReceivedAnythingFor("/api/customers");
+
+ harness.ClearOrigins();
+ _unrouted = await client.SendAsync(ServiceRoutingHarness.Request("/dashboard"));
+
+ harness.ClearOrigins();
+ _proxyOwnedPathOnAHost = await client.SendAsync(ServiceRoutingHarness.Request(WellKnownPaths.Providers, ServiceRoutingHarness.AdminHost, withAdminClaim: true));
+ _adminSawTheProxyOwnedPath = harness.Admin.ReceivedAnythingFor(WellKnownPaths.Providers);
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public void should_forward_a_prefixed_api_request_to_the_service_claiming_the_prefix() =>
+ Assert.NotNull(_prefixedApi);
+
+ [Fact]
+ public void should_strip_the_prefix_and_keep_the_query() =>
+ Assert.Equal("?page=2", _prefixedApi!.QueryString);
+
+ [Fact]
+ public void should_announce_the_stripped_prefix() =>
+ Assert.Equal(ServiceRoutingHarness.ReportsPrefix, _prefixedApi!.Value("X-Forwarded-Prefix"));
+
+ [Fact]
+ public void should_let_the_prefix_win_over_a_service_header() =>
+ Assert.False(_portalSawThePrefixedRequest);
+
+ [Fact]
+ public void should_forward_assets_under_the_prefix_to_the_service() =>
+ Assert.NotNull(_prefixedAsset);
+
+ [Fact]
+ public void should_refuse_a_caller_lacking_the_requirements_of_the_service_the_host_routes_to() =>
+ Assert.Equal(HttpStatusCode.Forbidden, _adminHostWithoutClaim!.StatusCode);
+
+ [Fact]
+ public void should_not_reach_the_host_routed_service_for_a_refused_caller() =>
+ Assert.False(_adminSawTheUnqualifiedCaller);
+
+ [Fact]
+ public void should_forward_a_qualified_caller_to_the_service_the_host_routes_to() =>
+ Assert.True(_adminSawTheQualifiedCaller);
+
+ [Fact]
+ public void should_let_an_explicit_selection_win_over_a_host() =>
+ Assert.True(_portalSawTheExplicitSelection);
+
+ [Fact]
+ public void should_not_forward_an_explicit_selection_to_the_host_service() =>
+ Assert.False(_adminSawTheExplicitSelection);
+
+ [Fact]
+ public void should_route_nothing_that_no_service_claims() =>
+ Assert.Equal(HttpStatusCode.NotFound, _unrouted!.StatusCode);
+
+ [Fact]
+ public void should_answer_proxy_owned_paths_itself_on_a_routed_host() =>
+ Assert.Equal(HttpStatusCode.OK, _proxyOwnedPathOnAHost!.StatusCode);
+
+ [Fact]
+ public void should_not_forward_proxy_owned_paths_on_a_routed_host() =>
+ Assert.False(_adminSawTheProxyOwnedPath);
+}
diff --git a/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_streaming_protocols_use_service_routes.cs b/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_streaming_protocols_use_service_routes.cs
new file mode 100644
index 00000000..67a94981
--- /dev/null
+++ b/Source/AuthProxy.Security.Specs/for_ServiceRouting/when_streaming_protocols_use_service_routes.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Net.WebSockets;
+using System.Text;
+
+namespace Cratis.AuthProxy.Security.for_ServiceRouting;
+
+///
+/// WebSocket upgrades and SSE reach the same origins and receive the same path transforms as ordinary requests.
+///
+/// The running proxy and its origins.
+[Collection(ServiceRoutingSpecCollection.Name)]
+public class when_streaming_protocols_use_service_routes(ServiceRoutingHarness harness)
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task should_forward_a_websocket_upgrade(bool byHost)
+ {
+ harness.ClearOrigins();
+ var path = $"{(byHost ? string.Empty : ServiceRoutingHarness.ReportsPrefix)}/api/routing-websocket";
+ var host = byHost ? ServiceRoutingHarness.AdminHost : "localhost";
+ using var request = ServiceRoutingHarness.Request(path, host, withAdminClaim: byHost);
+ using var client = harness.CreateSecurityClient();
+ using var socket = new ClientWebSocket();
+ foreach (var header in request.Headers)
+ {
+ socket.Options.SetRequestHeader(header.Key, string.Join(',', header.Value));
+ }
+
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var uri = new UriBuilder(new Uri(client.BaseAddress!, path)) { Scheme = "ws" }.Uri;
+ await socket.ConnectAsync(uri, timeout.Token);
+ var buffer = new byte[32];
+ var received = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token);
+
+ Assert.Equal(WebSocketMessageType.Text, received.MessageType);
+ Assert.Equal("origin", Encoding.UTF8.GetString(buffer, 0, received.Count));
+ await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", timeout.Token);
+ AssertForwarded(byHost, "/api/routing-websocket");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task should_forward_server_sent_events(bool byHost)
+ {
+ harness.ClearOrigins();
+ using var client = harness.CreateSecurityClient();
+ var path = $"{(byHost ? string.Empty : ServiceRoutingHarness.ReportsPrefix)}/api/routing-events";
+ using var request = ServiceRoutingHarness.Request(path, byHost ? ServiceRoutingHarness.AdminHost : null, withAdminClaim: byHost);
+ request.Headers.Accept.ParseAdd("text/event-stream");
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("text/event-stream", response.Content.Headers.ContentType!.MediaType);
+ using var reader = new StreamReader(await response.Content.ReadAsStreamAsync(timeout.Token));
+ Assert.Equal("data: origin", await reader.ReadLineAsync(timeout.Token));
+ Assert.Equal(string.Empty, await reader.ReadLineAsync(timeout.Token));
+ AssertForwarded(byHost, "/api/routing-events");
+ }
+
+ void AssertForwarded(bool byHost, string path)
+ {
+ var target = byHost ? harness.Admin : harness.Reports;
+ var other = byHost ? harness.Reports : harness.Admin;
+ var forwarded = target.LastRequestTo(path);
+ Assert.NotNull(forwarded);
+ if (!byHost)
+ {
+ Assert.Equal(ServiceRoutingHarness.ReportsPrefix, forwarded.Value("X-Forwarded-Prefix"));
+ }
+
+ Assert.False(other.ReceivedAnythingFor(path));
+ Assert.False(harness.Portal.ReceivedAnythingFor(path));
+ Assert.False(harness.ReportsFrontend.ReceivedAnythingFor(path));
+ }
+}
diff --git a/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs b/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs
index f5818c1f..6d618e7e 100644
--- a/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs
+++ b/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs
@@ -2,6 +2,8 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System.Collections.Concurrent;
+using System.Net.WebSockets;
+using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
@@ -101,6 +103,7 @@ public static async Task Start()
var state = app.Services.GetRequiredService();
var identityResponder = app.Services.GetRequiredService();
+ app.UseWebSockets();
app.Use(async (context, next) =>
{
state.Record(context);
@@ -120,6 +123,20 @@ public static async Task Start()
return Results.Ok();
});
+ app.MapGet("/api/routing-websocket", async context =>
+ {
+ using var socket = await context.WebSockets.AcceptWebSocketAsync();
+ await socket.SendAsync(Encoding.UTF8.GetBytes("origin"), WebSocketMessageType.Text, true, context.RequestAborted);
+ await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", context.RequestAborted);
+ });
+
+ app.MapGet("/api/routing-events", async context =>
+ {
+ context.Response.ContentType = "text/event-stream";
+ await context.Response.WriteAsync("data: origin\n\n", context.RequestAborted);
+ await context.Response.Body.FlushAsync(context.RequestAborted);
+ });
+
app.MapFallback(() => Results.Text("origin", "text/plain"));
await app.StartAsync();
diff --git a/Source/AuthProxy.Security.Specs/given/ServiceRoutingHarness.cs b/Source/AuthProxy.Security.Specs/given/ServiceRoutingHarness.cs
new file mode 100644
index 00000000..a1342406
--- /dev/null
+++ b/Source/AuthProxy.Security.Specs/given/ServiceRoutingHarness.cs
@@ -0,0 +1,182 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Cratis.AuthProxy.Security.given;
+
+///
+/// A running AuthProxy in front of three services told apart by path prefix, by host, and by name only, each with
+/// its own recording origin.
+///
+///
+/// End to end because the route table and the authorization gate are separate components that have to agree on
+/// which service a request targets. Only a running proxy shows that the service whose requirements were checked
+/// is the service that received the request.
+///
+public class ServiceRoutingHarness : WebApplicationFactory
+{
+ /// The path prefix of the reports service, which strips it.
+ public const string ReportsPrefix = "/reports";
+
+ /// The host of the admin service, which requires .
+ public const string AdminHost = "admin.example.test";
+
+ /// The claim the admin service requires.
+ public const string AdminClaim = "urn:github:team";
+
+ /// The value of the admin service accepts.
+ public const string AdminClaimValue = "Cratis/operations";
+
+ /// The tenant every request resolves to.
+ public const string TenantId = "33333333-3333-3333-3333-333333333333";
+
+ readonly string _pagesPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ServiceRoutingHarness()
+ {
+ // Protocol specs need a real HTTP upgrade feature, not TestServer's in-memory WebSocket feature.
+ UseKestrel(0);
+ Directory.CreateDirectory(_pagesPath);
+ File.WriteAllText(Path.Combine(_pagesPath, WellKnownPageNames.SelectProvider), "Select Provider");
+
+ Reports = RecordingBackend.Start().GetAwaiter().GetResult();
+ Admin = RecordingBackend.Start().GetAwaiter().GetResult();
+ Portal = RecordingBackend.Start().GetAwaiter().GetResult();
+ ReportsFrontend = RecordingBackend.Start().GetAwaiter().GetResult();
+ }
+
+ /// Gets the origin of the service reached by .
+ public RecordingBackend Reports { get; }
+
+ /// Gets the origin of the service reached by .
+ public RecordingBackend Admin { get; }
+
+ /// Gets the frontend origin of the reports service.
+ public RecordingBackend ReportsFrontend { get; }
+
+ /// Gets the origin of the portal service.
+ public RecordingBackend Portal { get; }
+
+ ///
+ /// Builds a request from an authenticated caller, optionally for a host and carrying the admin claim.
+ ///
+ /// The path and query to request.
+ /// The host to request, or for the default.
+ /// Whether the caller carries the claim the admin service requires.
+ /// The request.
+ public static HttpRequestMessage Request(string pathAndQuery, string? host = null, bool withAdminClaim = false)
+ {
+ var request = SecurityHarness.Authenticated(HttpMethod.Get, pathAndQuery, SecurityHarness.UniqueUser("routing"));
+ if (host is not null)
+ {
+ request.Headers.Host = host;
+ }
+
+ if (withAdminClaim)
+ {
+ request.Headers.TryAddWithoutValidation(HeaderAuthenticationHandler.ClaimsHeader, $"{AdminClaim}={AdminClaimValue}");
+ }
+
+ return request;
+ }
+
+ ///
+ /// Forgets what every origin received.
+ ///
+ public void ClearOrigins()
+ {
+ Reports.Clear();
+ ReportsFrontend.Clear();
+ Admin.Clear();
+ Portal.Clear();
+ }
+
+ ///
+ /// Creates a client that surfaces redirects as responses rather than following them.
+ ///
+ /// A configured .
+ public HttpClient CreateSecurityClient()
+ {
+ StartServer();
+ var address = Services.GetRequiredService().Features.Get()!.Addresses.Single();
+
+ return CreateClient(new WebApplicationFactoryClientOptions
+ {
+ BaseAddress = new Uri(address),
+ AllowAutoRedirect = false,
+ HandleCookies = false,
+ });
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+
+ if (!disposing)
+ {
+ return;
+ }
+
+ Reports.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ ReportsFrontend.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ Admin.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ Portal.DisposeAsync().AsTask().GetAwaiter().GetResult();
+
+ if (Directory.Exists(_pagesPath))
+ {
+ Directory.Delete(_pagesPath, recursive: true);
+ }
+ }
+
+ ///
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder
+ .UseEnvironment("Production")
+ .ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary
+ {
+ [$"{C.AuthProxy.SectionKey}:Services:reports:PathPrefix"] = ReportsPrefix,
+ [$"{C.AuthProxy.SectionKey}:Services:reports:StripPathPrefix"] = "true",
+ [$"{C.AuthProxy.SectionKey}:Services:reports:Backend:BaseUrl"] = Reports.BaseUrl,
+ [$"{C.AuthProxy.SectionKey}:Services:reports:Frontend:BaseUrl"] = ReportsFrontend.BaseUrl,
+ [$"{C.AuthProxy.SectionKey}:Services:reports:AnonymousPaths:0"] = $"{ReportsPrefix}/api/health",
+
+ [$"{C.AuthProxy.SectionKey}:Services:admin:Hosts:0"] = AdminHost,
+ [$"{C.AuthProxy.SectionKey}:Services:admin:Backend:BaseUrl"] = Admin.BaseUrl,
+ [$"{C.AuthProxy.SectionKey}:Services:admin:Frontend:BaseUrl"] = Admin.BaseUrl,
+ [$"{C.AuthProxy.SectionKey}:Services:admin:Authorization:RequiredClaims:0:Claim"] = AdminClaim,
+ [$"{C.AuthProxy.SectionKey}:Services:admin:Authorization:RequiredClaims:0:AnyOf:0"] = AdminClaimValue,
+
+ [$"{C.AuthProxy.SectionKey}:Services:portal:Hosts:0"] = "portal.example.test",
+ [$"{C.AuthProxy.SectionKey}:Services:portal:Hosts:1"] = "xn--bcher-kva.example.test",
+ [$"{C.AuthProxy.SectionKey}:Services:portal:Backend:BaseUrl"] = Portal.BaseUrl,
+ [$"{C.AuthProxy.SectionKey}:Services:portal:Frontend:BaseUrl"] = Portal.BaseUrl,
+
+ [$"{C.AuthProxy.SectionKey}:PagesPath"] = _pagesPath,
+
+ [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Specified),
+ [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:TenantId"] = TenantId,
+
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Provider One",
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.example.test/one",
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-one",
+ }))
+ .ConfigureTestServices(services => services
+ .AddAuthentication(HeaderAuthenticationHandler.Scheme)
+ .AddScheme(
+ HeaderAuthenticationHandler.Scheme,
+ _ => { }));
+ }
+}
diff --git a/Source/AuthProxy.Security.Specs/given/ServiceRoutingSpecCollection.cs b/Source/AuthProxy.Security.Specs/given/ServiceRoutingSpecCollection.cs
new file mode 100644
index 00000000..18dc930e
--- /dev/null
+++ b/Source/AuthProxy.Security.Specs/given/ServiceRoutingSpecCollection.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Security.given;
+
+///
+/// Shares one across the host and path-prefix routing specs.
+///
+[CollectionDefinition(Name, DisableParallelization = true)]
+public class ServiceRoutingSpecCollection : ICollectionFixture
+{
+ /// The collection name every service-routing spec joins.
+ public const string Name = "ServiceRouting";
+}
diff --git a/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_a_host_routes_to_the_only_client_credentials_service.cs b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_a_host_routes_to_the_only_client_credentials_service.cs
new file mode 100644
index 00000000..1fd19385
--- /dev/null
+++ b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_a_host_routes_to_the_only_client_credentials_service.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Authentication.for_ClientCredentialsServiceResolver;
+
+public class when_a_host_routes_to_the_only_client_credentials_service : Specification
+{
+ ClientCredentialsServiceResolver _resolver;
+ DefaultHttpContext _context;
+ bool _resolved;
+ ConfiguredClientCredentialsService _service;
+
+ void Establish()
+ {
+ var config = new C.AuthProxy
+ {
+ Services = new Dictionary
+ {
+ ["billing"] = new()
+ {
+ Hosts = ["billing.example.com"],
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://billing.test/" },
+ ClientCredentials = new C.ServiceClientCredentials(),
+ },
+ ["portal"] = new()
+ {
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://portal.test/" },
+ },
+ },
+ };
+ var monitor = Substitute.For>();
+ monitor.CurrentValue.Returns(config);
+ _resolver = new(monitor, Substitute.For>());
+
+ _context = new DefaultHttpContext();
+ _context.Request.Host = new HostString("billing.example.com");
+ _context.Request.Path = "/api/invoices";
+ }
+
+ void Because() => _resolved = _resolver.TryResolveForRequest(_context.Request, out _service);
+
+ [Fact] void should_resolve_the_service() => _resolved.ShouldBeTrue();
+ [Fact] void should_resolve_the_service_the_host_routes_to() => _service.Name.ShouldEqual("billing");
+}
diff --git a/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_hosts_distinguish_services_with_the_same_token_prefix.cs b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_hosts_distinguish_services_with_the_same_token_prefix.cs
new file mode 100644
index 00000000..b0ba088b
--- /dev/null
+++ b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_hosts_distinguish_services_with_the_same_token_prefix.cs
@@ -0,0 +1,58 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Authentication.for_ClientCredentialsServiceResolver;
+
+public class when_hosts_distinguish_services_with_the_same_token_prefix : Specification
+{
+ ClientCredentialsServiceResolver _resolver;
+ DefaultHttpContext _context;
+
+ void Establish()
+ {
+ var config = new C.AuthProxy
+ {
+ Services = new Dictionary
+ {
+ ["billing"] = new()
+ {
+ Hosts = ["billing.example.com"],
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://billing.test/" },
+ ClientCredentials = new C.ServiceClientCredentials(),
+ },
+ ["reports"] = new()
+ {
+ Hosts = ["reports.example.com"],
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://reports.test/" },
+ ClientCredentials = new C.ServiceClientCredentials(),
+ },
+ },
+ };
+ var monitor = Substitute.For>();
+ monitor.CurrentValue.Returns(config);
+ _resolver = new(monitor, Substitute.For>());
+ _context = new DefaultHttpContext();
+ }
+
+ [Theory]
+ [InlineData("billing")]
+ [InlineData("reports")]
+ public void should_resolve_only_the_host_routed_service(string name)
+ {
+ _context.Request.Host = new HostString($"{name}.example.com");
+ _context.Request.Path = "/api/orders";
+
+ _resolver.TryResolveForRequest(_context.Request, out var service).ShouldBeTrue();
+
+ service.Name.ShouldEqual(name);
+ }
+
+ [Fact]
+ public void should_not_accept_a_path_outside_the_token_prefix()
+ {
+ _context.Request.Host = new HostString("billing.example.com");
+ _context.Request.Path = "/dashboard";
+
+ _resolver.TryResolveForRequest(_context.Request, out _).ShouldBeFalse();
+ }
+}
diff --git a/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_the_named_service_is_not_where_the_request_is_routed.cs b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_the_named_service_is_not_where_the_request_is_routed.cs
new file mode 100644
index 00000000..02cca79c
--- /dev/null
+++ b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_the_named_service_is_not_where_the_request_is_routed.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Authentication.for_ClientCredentialsServiceResolver;
+
+///
+/// A path prefix can route a request to a different service than the one its Service-ID header names. A token
+/// scoped to the named service must not authenticate a request the route table sends somewhere else.
+///
+public class when_the_named_service_is_not_where_the_request_is_routed : Specification
+{
+ ClientCredentialsServiceResolver _resolver;
+ DefaultHttpContext _context;
+ bool _resolved;
+
+ void Establish()
+ {
+ var config = new C.AuthProxy
+ {
+ Services = new Dictionary
+ {
+ ["reports"] = new()
+ {
+ PathPrefix = "/reports",
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://reports.test/" },
+ },
+ ["portal"] = new()
+ {
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://portal.test/" },
+ ClientCredentials = new C.ServiceClientCredentials { RoutePrefix = "/reports/api" },
+ },
+ },
+ };
+ var monitor = Substitute.For>();
+ monitor.CurrentValue.Returns(config);
+ _resolver = new(monitor, Substitute.For>());
+
+ _context = new DefaultHttpContext();
+ _context.Request.Path = "/reports/api/orders";
+ _context.Request.Headers[Headers.ServiceId] = "portal";
+ }
+
+ void Because() => _resolved = _resolver.TryResolveForRequest(_context.Request, out _);
+
+ [Fact] void should_not_resolve_the_named_service() => _resolved.ShouldBeFalse();
+}
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/given/a_selected_proxy_route.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/given/a_selected_proxy_route.cs
new file mode 100644
index 00000000..4df7ea77
--- /dev/null
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/given/a_selected_proxy_route.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Yarp.ReverseProxy.Configuration;
+using Yarp.ReverseProxy.Forwarder;
+using Yarp.ReverseProxy.Model;
+
+namespace Cratis.AuthProxy.Authorization.for_AccessPolicy.given;
+
+///
+/// A selected proxy endpoint targeting a service whose key uses a non-ASCII lowercase mapping.
+///
+public class a_selected_proxy_route : an_access_policy
+{
+ protected C.AuthProxy _config;
+ protected AccessDecision _decision;
+
+ void Establish()
+ {
+ CallerCarrying();
+ _config = new C.AuthProxy
+ {
+ Services = new Dictionary
+ {
+ ["\u212Aey"] = new()
+ {
+ Hosts = ["admin.example.com"],
+ PathPrefix = "/admin",
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://backend.test/" },
+ Frontend = new C.ServiceEndpoint { BaseUrl = "http://frontend.test/" },
+ Authorization = new C.Authorization { RequiredClaims = [Claiming("role", "admin")] }
+ }
+ }
+ };
+ _context.Request.Host = new HostString("admin.example.com");
+ _context.Request.Path = "/admin/api/users";
+ }
+
+ ///
+ /// Selects a proxy endpoint with the given cluster.
+ ///
+ /// The selected cluster identifier.
+ protected void SelectCluster(string clusterId)
+ {
+ var route = new RouteModel(
+ new RouteConfig
+ {
+ RouteId = "route",
+ ClusterId = clusterId,
+ Match = new RouteMatch { Path = "/admin/{**catch-all}" }
+ },
+ new ClusterState(clusterId),
+ HttpTransformer.Default);
+ _context.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(route), "proxied"));
+ }
+}
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_backend_cluster_has_a_unicode_service_key.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_backend_cluster_has_a_unicode_service_key.cs
new file mode 100644
index 00000000..c1a074fd
--- /dev/null
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_backend_cluster_has_a_unicode_service_key.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Authorization.for_AccessPolicy;
+
+public class when_a_selected_backend_cluster_has_a_unicode_service_key : given.a_selected_proxy_route
+{
+ void Establish() => SelectCluster("key-backend-cluster");
+
+ void Because() => _decision = _policy.Evaluate(_context, _config);
+
+ [Fact] void should_deny_access_without_the_service_claim() => _decision.IsGranted.ShouldBeFalse();
+ [Fact] void should_identify_the_service_requirement() => _decision.UnsatisfiedClaim.ShouldEqual("role");
+}
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_cluster_has_no_configured_service.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_cluster_has_no_configured_service.cs
new file mode 100644
index 00000000..979d3d3e
--- /dev/null
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_cluster_has_no_configured_service.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Authorization.for_AccessPolicy;
+
+public class when_a_selected_cluster_has_no_configured_service : given.a_selected_proxy_route
+{
+ void Establish() => SelectCluster("removed-backend-cluster");
+
+ void Because() => _decision = _policy.Evaluate(_context, _config);
+
+ [Fact] void should_deny_access_even_without_root_requirements() => _decision.IsGranted.ShouldBeFalse();
+}
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_frontend_cluster_has_a_unicode_service_key.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_frontend_cluster_has_a_unicode_service_key.cs
new file mode 100644
index 00000000..1812535d
--- /dev/null
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_frontend_cluster_has_a_unicode_service_key.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Authorization.for_AccessPolicy;
+
+public class when_a_selected_frontend_cluster_has_a_unicode_service_key : given.a_selected_proxy_route
+{
+ void Establish()
+ {
+ _context.Request.Path = "/admin/users";
+ SelectCluster("key-frontend-cluster");
+ }
+
+ void Because() => _decision = _policy.Evaluate(_context, _config);
+
+ [Fact] void should_deny_access_without_the_service_claim() => _decision.IsGranted.ShouldBeFalse();
+ [Fact] void should_identify_the_service_requirement() => _decision.UnsatisfiedClaim.ShouldEqual("role");
+}
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_unicode_service_has_its_requirements_satisfied.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_unicode_service_has_its_requirements_satisfied.cs
new file mode 100644
index 00000000..ea2878aa
--- /dev/null
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_unicode_service_has_its_requirements_satisfied.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Authorization.for_AccessPolicy;
+
+public class when_a_selected_unicode_service_has_its_requirements_satisfied : given.a_selected_proxy_route
+{
+ void Establish()
+ {
+ CallerCarrying(new Claim("role", "admin"));
+ SelectCluster("key-backend-cluster");
+ }
+
+ void Because() => _decision = _policy.Evaluate(_context, _config);
+
+ [Fact] void should_grant_access() => _decision.IsGranted.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_service_is_reached_by_its_host.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_service_is_reached_by_its_host.cs
new file mode 100644
index 00000000..41a3ee08
--- /dev/null
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_service_is_reached_by_its_host.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Authorization.for_AccessPolicy;
+
+///
+/// A host or path prefix that routes a request to a service also subjects it to that service's requirements, so
+/// declaring a host is not a way around them.
+///
+public class when_a_service_is_reached_by_its_host : given.an_access_policy
+{
+ C.AuthProxy _config;
+ AccessDecision _byHost;
+ AccessDecision _byPrefix;
+ AccessDecision _byHostNamingAnother;
+
+ void Establish() => _config = new C.AuthProxy
+ {
+ Services = new Dictionary
+ {
+ ["admin"] = new()
+ {
+ Hosts = ["admin.example.com"],
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://admin.test/" },
+ Authorization = new C.Authorization { RequiredClaims = [Claiming("urn:github:team", "Cratis/operations")] },
+ },
+ ["audit"] = new()
+ {
+ PathPrefix = "/audit",
+ Frontend = new C.ServiceEndpoint { BaseUrl = "http://audit.test/" },
+ Authorization = new C.Authorization { RequiredClaims = [Claiming("urn:github:team", "Cratis/operations")] },
+ },
+ ["portal"] = new()
+ {
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://portal.test/" },
+ },
+ },
+ };
+
+ void Because()
+ {
+ CallerCarrying(new Claim("urn:github:organization", "Cratis"));
+
+ _context.Request.Host = new HostString("admin.example.com");
+ _context.Request.Path = "/api/users";
+ _byHost = _policy.Evaluate(_context, _config);
+
+ _context.Request.Host = new HostString("www.example.com");
+ _context.Request.Path = "/audit/log";
+ _context.Request.Headers[Headers.ServiceId] = "portal";
+ _byPrefix = _policy.Evaluate(_context, _config);
+
+ _context.Request.Host = new HostString("admin.example.com");
+ _context.Request.Path = "/api/users";
+ _byHostNamingAnother = _policy.Evaluate(_context, _config);
+ }
+
+ [Fact] void should_apply_the_requirements_of_the_service_declaring_the_host() => _byHost.IsGranted.ShouldBeFalse();
+ [Fact] void should_apply_the_requirements_of_the_service_declaring_the_prefix_whatever_the_header_says() => _byPrefix.IsGranted.ShouldBeFalse();
+ [Fact] void should_apply_the_requirements_of_the_explicitly_named_service_on_a_host() => _byHostNamingAnother.IsGranted.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_an_unmapped_selected_cluster_also_names_a_configured_service.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_an_unmapped_selected_cluster_also_names_a_configured_service.cs
new file mode 100644
index 00000000..4ccba281
--- /dev/null
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_an_unmapped_selected_cluster_also_names_a_configured_service.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Authorization.for_AccessPolicy;
+
+public class when_an_unmapped_selected_cluster_also_names_a_configured_service : given.a_selected_proxy_route
+{
+ void Establish()
+ {
+ CallerCarrying(new Claim("role", "admin"));
+ _context.Request.Headers[Headers.ServiceId] = "\u212Aey";
+ SelectCluster("removed-backend-cluster");
+ }
+
+ void Because() => _decision = _policy.Evaluate(_context, _config);
+
+ [Fact] void should_deny_access_instead_of_applying_the_named_service_requirements() => _decision.IsGranted.ShouldBeFalse();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_single_service_declares_a_path_prefix.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_single_service_declares_a_path_prefix.cs
new file mode 100644
index 00000000..760ec75b
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_single_service_declares_a_path_prefix.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_MicroserviceReverseProxyConfigProvider;
+
+///
+/// A single service normally gets plain catch-all routes, but one that declares a path prefix asked to be reached
+/// through it, so nothing outside the prefix is routed to it.
+///
+public class when_a_single_service_declares_a_path_prefix : Specification
+{
+ MicroserviceReverseProxyConfigProvider _provider;
+
+ void Establish()
+ {
+ var authProxy = new C.AuthProxy
+ {
+ Services = new Dictionary
+ {
+ ["Reports"] = new()
+ {
+ PathPrefix = "/reports",
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://reports-api/" },
+ Frontend = new C.ServiceEndpoint { BaseUrl = "http://reports-web/" },
+ },
+ },
+ };
+
+ var monitor = Substitute.For>();
+ monitor.CurrentValue.Returns(authProxy);
+ _provider = new MicroserviceReverseProxyConfigProvider(monitor, Substitute.For>());
+ }
+
+ [Fact] void should_not_add_the_single_service_catch_all_routes() =>
+ _provider.GetConfig().Routes.Any(_ => _.RouteId.EndsWith("-default", StringComparison.OrdinalIgnoreCase)).ShouldBeFalse();
+
+ [Fact] void should_route_the_prefix() => _provider.GetConfig().Routes.Any(_ => _.RouteId == "reports-prefix").ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_services_declare_hosts_and_path_prefixes.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_services_declare_hosts_and_path_prefixes.cs
new file mode 100644
index 00000000..5d03a8f4
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_services_declare_hosts_and_path_prefixes.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Yarp.ReverseProxy.Configuration;
+
+namespace Cratis.AuthProxy.ReverseProxy.for_MicroserviceReverseProxyConfigProvider;
+
+///
+/// Hosts and path prefixes become routes, and route order states their precedence: a prefix on its hosts, a prefix
+/// on every host, an explicit Service-ID header or service query parameter, then a host.
+///
+public class when_services_declare_hosts_and_path_prefixes : Specification
+{
+ MicroserviceReverseProxyConfigProvider _provider;
+ IReadOnlyList _routes;
+
+ void Establish()
+ {
+ var authProxy = new C.AuthProxy
+ {
+ Services = new Dictionary
+ {
+ ["Reports"] = new()
+ {
+ PathPrefix = "/reports/",
+ StripPathPrefix = true,
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://reports-api/" },
+ Frontend = new C.ServiceEndpoint { BaseUrl = "http://reports-web/" },
+ AnonymousPaths = ["/reports/public", "/reports/api/health"],
+ },
+ ["TenantReports"] = new()
+ {
+ Hosts = ["tenant.example.com"],
+ PathPrefix = "/reports",
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://tenant-reports-api/" },
+ },
+ ["Billing"] = new()
+ {
+ Hosts = ["Billing.Example.com"],
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://billing-api/" },
+ Frontend = new C.ServiceEndpoint { BaseUrl = "http://billing-web/" },
+ },
+ },
+ };
+
+ var monitor = Substitute.For>();
+ monitor.CurrentValue.Returns(authProxy);
+ _provider = new MicroserviceReverseProxyConfigProvider(monitor, Substitute.For>());
+ }
+
+ void Because() => _routes = _provider.GetConfig().Routes;
+
+ RouteConfig Route(string id) => _routes.Single(_ => _.RouteId == id);
+
+ [Fact] void should_route_the_prefixed_api_to_the_backend() => Route("reports-prefix-api").Match.Path.ShouldEqual("/reports/api/{**catch-all}");
+ [Fact] void should_route_the_rest_of_the_prefix_to_the_frontend() => Route("reports-prefix").ClusterId.ShouldEqual("reports-frontend-cluster");
+ [Fact] void should_route_the_rest_of_a_backend_only_prefix_to_the_backend() => Route("tenantreports-prefix").ClusterId.ShouldEqual("tenantreports-backend-cluster");
+ [Fact] void should_restrict_a_host_specific_prefix_to_its_hosts() => Route("tenantreports-prefix").Match.Hosts.ShouldContainOnly("tenant.example.com");
+ [Fact] void should_order_a_host_specific_prefix_ahead_of_a_prefix_on_every_host() => Route("tenantreports-prefix").Order.ShouldBeLessThan(Route("reports-prefix-api").Order!.Value);
+ [Fact] void should_order_a_prefix_ahead_of_an_explicit_selection() => Route("reports-prefix").Order.ShouldBeLessThan(Route("billing-backend-header-api").Order!.Value);
+ [Fact] void should_order_an_explicit_selection_ahead_of_a_host() => Route("billing-frontend-query").Order.ShouldBeLessThan(Route("billing-host-api").Order!.Value);
+ [Fact] void should_route_a_host_to_its_service() => Route("billing-host").Match.Hosts.ShouldContainOnly("billing.example.com");
+ [Fact] void should_mark_a_stripped_prefix_on_its_routes() => Route("reports-prefix").Metadata![ServiceRoutes.StripPathPrefixMetadataKey].ShouldEqual("/reports");
+ [Fact] void should_mark_an_anonymous_path_below_a_stripped_prefix() => Route("reports-anonymous-0").Metadata![ServiceRoutes.StripPathPrefixMetadataKey].ShouldEqual("/reports");
+ [Fact] void should_route_an_anonymous_prefixed_api_to_the_backend() => Route("reports-anonymous-1").ClusterId.ShouldEqual("reports-backend-cluster");
+ [Fact] void should_not_mark_a_prefix_that_is_kept() => Route("tenantreports-prefix").Metadata!.ContainsKey(ServiceRoutes.StripPathPrefixMetadataKey).ShouldBeFalse();
+ [Fact] void should_identify_the_service_on_prefix_routes() => Route("tenantreports-prefix").Metadata![ServiceSelection.RouteMetadataKey].ShouldEqual("TenantReports");
+ [Fact] void should_identify_the_service_on_host_routes() => Route("billing-host").Metadata![ServiceSelection.RouteMetadataKey].ShouldEqual("Billing");
+ [Fact] void should_identify_the_service_on_stripped_anonymous_routes() => Route("reports-anonymous-0").Metadata![ServiceSelection.RouteMetadataKey].ShouldEqual("Reports");
+ [Fact] void should_keep_routing_by_service_header() => _routes.Any(_ => _.RouteId == "reports-frontend-header").ShouldBeTrue();
+ [Fact] void should_leave_no_two_routes_with_the_same_template_hosts_and_order() =>
+ _routes.GroupBy(_ => (
+ _.Match.Path?.ToUpperInvariant(),
+ string.Join(',', _.Match.Hosts ?? []),
+ _.Order,
+ string.Join(',', _.Match.Headers?.SelectMany(header => header.Values ?? []) ?? []),
+ string.Join(',', _.Match.QueryParameters?.SelectMany(query => query.Values ?? []) ?? [])))
+ .Any(_ => _.Count() > 1)
+ .ShouldBeFalse();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/given/services_routed_by_host_and_path.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/given/services_routed_by_host_and_path.cs
new file mode 100644
index 00000000..558459f9
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/given/services_routed_by_host_and_path.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes.given;
+
+///
+/// A deployment with a service at each routing step: a prefix on one host, the same prefix on every host, a host,
+/// and a service reached only by name.
+///
+public class services_routed_by_host_and_path : Specification
+{
+ protected C.AuthProxy _config;
+ protected DefaultHttpContext _context;
+ protected RoutedService? _result;
+
+ void Establish()
+ {
+ _config = new C.AuthProxy
+ {
+ Services = new Dictionary
+ {
+ ["tenant-reports"] = new()
+ {
+ Hosts = ["tenant.example.com"],
+ PathPrefix = "/reports",
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://tenant-reports/" },
+ },
+ ["reports"] = new()
+ {
+ PathPrefix = "/reports",
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://reports-api/" },
+ Frontend = new C.ServiceEndpoint { BaseUrl = "http://reports-web/" },
+ },
+ ["billing"] = new()
+ {
+ Hosts = ["billing.example.com"],
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://billing-api/" },
+ Frontend = new C.ServiceEndpoint { BaseUrl = "http://billing-web/" },
+ },
+ ["portal"] = new()
+ {
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://portal-api/" },
+ },
+ },
+ };
+ _context = new DefaultHttpContext();
+ }
+
+ protected void Request(string host, string path)
+ {
+ _context.Request.Host = new HostString(host);
+ _context.Request.Path = path;
+ }
+
+ protected void Resolve() => _result = ServiceRoutes.Resolve(_context.Request, _config);
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_declared_port_differs_from_the_requested_one.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_declared_port_differs_from_the_requested_one.cs
new file mode 100644
index 00000000..777063e6
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_declared_port_differs_from_the_requested_one.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_a_declared_port_differs_from_the_requested_one : Specification
+{
+ bool _onDeclaredPort;
+ bool _onDefaultPort;
+ bool _onDefaultHttpsPortWhenDeclared;
+
+ void Because()
+ {
+ var declared = new HostString("billing.example.com:8443");
+ var declaredDefault = new HostString("billing.example.com:443");
+
+ var request = new DefaultHttpContext().Request;
+ request.Scheme = "https";
+ request.Host = new HostString("billing.example.com:8443");
+ _onDeclaredPort = ServiceRoutes.Matches(declared, request);
+
+ request.Host = new HostString("billing.example.com");
+ _onDefaultPort = ServiceRoutes.Matches(declared, request);
+ _onDefaultHttpsPortWhenDeclared = ServiceRoutes.Matches(declaredDefault, request);
+ }
+
+ [Fact] void should_match_the_declared_port() => _onDeclaredPort.ShouldBeTrue();
+ [Fact] void should_not_match_another_port() => _onDefaultPort.ShouldBeFalse();
+ [Fact] void should_treat_a_request_without_a_port_as_on_its_scheme_default() => _onDefaultHttpsPortWhenDeclared.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_header_names_a_service_that_cannot_serve_the_path.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_header_names_a_service_that_cannot_serve_the_path.cs
new file mode 100644
index 00000000..45fb5462
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_header_names_a_service_that_cannot_serve_the_path.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_a_header_names_a_service_that_cannot_serve_the_path : given.services_routed_by_host_and_path
+{
+ void Establish()
+ {
+ Request("billing.example.com", "/dashboard");
+ _context.Request.Headers[Headers.ServiceId] = "portal";
+ }
+
+ void Because() => Resolve();
+
+ [Fact] void should_fall_through_to_the_host_like_the_route_table() => _result!.Name.ShouldEqual("billing");
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_and_a_service_header_disagree.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_and_a_service_header_disagree.cs
new file mode 100644
index 00000000..0a251b48
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_and_a_service_header_disagree.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_a_host_and_a_service_header_disagree : given.services_routed_by_host_and_path
+{
+ void Establish()
+ {
+ Request("billing.example.com", "/api/invoices");
+ _context.Request.Headers[Headers.ServiceId] = "portal";
+ }
+
+ void Because() => Resolve();
+
+ [Fact] void should_let_the_explicit_selection_win() => _result!.Name.ShouldEqual("portal");
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_is_requested_with_another_case_and_a_port.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_is_requested_with_another_case_and_a_port.cs
new file mode 100644
index 00000000..c48560f3
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_is_requested_with_another_case_and_a_port.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_a_host_is_requested_with_another_case_and_a_port : given.services_routed_by_host_and_path
+{
+ void Establish() => Request("Billing.Example.com:8443", "/invoices/42");
+
+ void Because() => Resolve();
+
+ [Fact] void should_match_the_host_case_insensitively_on_any_port() => _result!.Name.ShouldEqual("billing");
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_is_requested_without_a_selection.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_is_requested_without_a_selection.cs
new file mode 100644
index 00000000..0a27ac98
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_is_requested_without_a_selection.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_a_host_is_requested_without_a_selection : given.services_routed_by_host_and_path
+{
+ void Establish() => Request("billing.example.com", "/invoices/42");
+
+ void Because() => Resolve();
+
+ [Fact] void should_route_to_the_service_declaring_the_host() => _result!.Name.ShouldEqual("billing");
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_path_only_starts_with_the_prefix_characters.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_path_only_starts_with_the_prefix_characters.cs
new file mode 100644
index 00000000..8f85265b
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_path_only_starts_with_the_prefix_characters.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_a_path_only_starts_with_the_prefix_characters : given.services_routed_by_host_and_path
+{
+ void Establish() => Request("www.example.com", "/reportsx/dashboard");
+
+ void Because() => Resolve();
+
+ [Fact] void should_not_treat_it_as_under_the_prefix() => _result.ShouldBeNull();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_and_a_service_header_disagree.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_and_a_service_header_disagree.cs
new file mode 100644
index 00000000..e6617b95
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_and_a_service_header_disagree.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_a_prefix_and_a_service_header_disagree : given.services_routed_by_host_and_path
+{
+ void Establish()
+ {
+ Request("www.example.com", "/reports/api/orders");
+ _context.Request.Headers[Headers.ServiceId] = "portal";
+ }
+
+ void Because() => Resolve();
+
+ [Fact] void should_let_the_prefix_claim_its_path() => _result!.Name.ShouldEqual("reports");
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_is_requested_on_another_host.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_is_requested_on_another_host.cs
new file mode 100644
index 00000000..5f050eb6
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_is_requested_on_another_host.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_a_prefix_is_requested_on_another_host : given.services_routed_by_host_and_path
+{
+ void Establish() => Request("www.example.com", "/reports/dashboard");
+
+ void Because() => Resolve();
+
+ [Fact] void should_route_to_the_prefix_on_every_host() => _result!.Name.ShouldEqual("reports");
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_is_requested_on_the_host_it_is_declared_for.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_is_requested_on_the_host_it_is_declared_for.cs
new file mode 100644
index 00000000..0495ed91
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_is_requested_on_the_host_it_is_declared_for.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_a_prefix_is_requested_on_the_host_it_is_declared_for : given.services_routed_by_host_and_path
+{
+ void Establish() => Request("tenant.example.com", "/reports/api/orders");
+
+ void Because() => Resolve();
+
+ [Fact] void should_prefer_the_host_specific_prefix() => _result!.Name.ShouldEqual("tenant-reports");
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_punycode_host_is_requested.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_punycode_host_is_requested.cs
new file mode 100644
index 00000000..5d9d4eec
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_punycode_host_is_requested.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_a_punycode_host_is_requested : given.services_routed_by_host_and_path
+{
+ void Establish()
+ {
+ _config.Services["billing"].Hosts = ["XN--BCHER-KVA.example:8443"];
+ _context.Request.Host = HostString.FromUriComponent("xn--bcher-kva.example:8443");
+ _context.Request.Path = "/invoices/42";
+ }
+
+ void Because() => Resolve();
+
+ [Fact] void should_resolve_the_service() => _result!.Name.ShouldEqual("billing");
+ [Fact] void should_normalize_the_declared_host_to_unicode() => ServiceRoutes.HostsOf(_config.Services["billing"]).Single().Value.ShouldEqual("bücher.example:8443");
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_request_matches_no_service.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_request_matches_no_service.cs
new file mode 100644
index 00000000..58abfdc6
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_request_matches_no_service.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_a_request_matches_no_service : given.services_routed_by_host_and_path
+{
+ void Establish() => Request("www.example.com", "/dashboard");
+
+ void Because() => Resolve();
+
+ [Fact] void should_resolve_no_service() => _result.ShouldBeNull();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_the_query_parameter_names_a_service.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_the_query_parameter_names_a_service.cs
new file mode 100644
index 00000000..ce16dda7
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_the_query_parameter_names_a_service.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutes;
+
+public class when_the_query_parameter_names_a_service : given.services_routed_by_host_and_path
+{
+ void Establish()
+ {
+ Request("www.example.com", "/api/customers");
+ _context.Request.QueryString = new QueryString("?service=portal");
+ }
+
+ void Because() => Resolve();
+
+ [Fact] void should_route_to_the_named_service() => _result!.Name.ShouldEqual("portal");
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/given/a_service_routing_validator.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/given/a_service_routing_validator.cs
new file mode 100644
index 00000000..15204170
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/given/a_service_routing_validator.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator.given;
+
+public class a_service_routing_validator : Specification
+{
+ protected Dictionary _services;
+ protected ValidateOptionsResult _result;
+
+ void Establish() => _services = [];
+
+ protected static C.Service Routable(params string[] hosts) => new()
+ {
+ Hosts = hosts,
+ Backend = new C.ServiceEndpoint { BaseUrl = "http://backend/" },
+ };
+
+ protected void Validate() => _result = new ServiceRoutingConfigurationValidator().Validate(null, new C.AuthProxy { Services = _services });
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_cannot_be_parsed_by_endpoint_routing.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_cannot_be_parsed_by_endpoint_routing.cs
new file mode 100644
index 00000000..97880656
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_cannot_be_parsed_by_endpoint_routing.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_a_host_entry_cannot_be_parsed_by_endpoint_routing : given.a_service_routing_validator
+{
+ [Theory]
+ [InlineData("example.com:abc")]
+ [InlineData("example.com:")]
+ [InlineData("[::1]")]
+ [InlineData("[::1]:8443")]
+ [InlineData("::1")]
+ [InlineData("xn--a.example")]
+ [InlineData("xn--.example")]
+ public void should_refuse_the_configuration_at_startup(string host)
+ {
+ _services["one"] = Routable(host);
+
+ Validate();
+
+ _result.Failed.ShouldBeTrue();
+ }
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_is_a_url.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_is_a_url.cs
new file mode 100644
index 00000000..8a456538
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_is_a_url.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_a_host_entry_is_a_url : given.a_service_routing_validator
+{
+ void Establish() => _services["one"] = Routable("https://billing.example.com/");
+
+ void Because() => Validate();
+
+ [Fact] void should_fail() => _result.Failed.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_is_a_wildcard.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_is_a_wildcard.cs
new file mode 100644
index 00000000..42e53e3e
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_is_a_wildcard.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_a_host_entry_is_a_wildcard : given.a_service_routing_validator
+{
+ void Establish() => _services["one"] = Routable("*.example.com");
+
+ void Because() => Validate();
+
+ [Fact] void should_fail() => _result.Failed.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_covers_an_authproxy_path.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_covers_an_authproxy_path.cs
new file mode 100644
index 00000000..cfd552fb
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_covers_an_authproxy_path.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_a_prefix_covers_an_authproxy_path : given.a_service_routing_validator
+{
+ void Establish()
+ {
+ _services["one"] = Routable();
+ _services["one"].PathPrefix = "/.cratis";
+ }
+
+ void Because() => Validate();
+
+ [Fact] void should_fail() => _result.Failed.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_covers_the_api_path.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_covers_the_api_path.cs
new file mode 100644
index 00000000..37caa1e6
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_covers_the_api_path.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_a_prefix_covers_the_api_path : given.a_service_routing_validator
+{
+ void Establish()
+ {
+ _services["one"] = Routable();
+ _services["one"].PathPrefix = "/api/reports";
+ }
+
+ void Because() => Validate();
+
+ [Fact] void should_fail() => _result.Failed.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_is_not_a_plain_path.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_is_not_a_plain_path.cs
new file mode 100644
index 00000000..5b757167
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_is_not_a_plain_path.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_a_prefix_is_not_a_plain_path : given.a_service_routing_validator
+{
+ void Establish()
+ {
+ _services["one"] = Routable();
+ _services["one"].PathPrefix = "reports/{id}";
+ }
+
+ void Because() => Validate();
+
+ [Fact] void should_fail() => _result.Failed.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_routed_service_has_nowhere_to_route.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_routed_service_has_nowhere_to_route.cs
new file mode 100644
index 00000000..6a5220e9
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_routed_service_has_nowhere_to_route.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_a_routed_service_has_nowhere_to_route : given.a_service_routing_validator
+{
+ void Establish() => _services["one"] = new() { Hosts = ["one.example.com"] };
+
+ void Because() => Validate();
+
+ [Fact] void should_fail() => _result.Failed.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_nested_prefixes_are_on_different_hosts.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_nested_prefixes_are_on_different_hosts.cs
new file mode 100644
index 00000000..d1c744d9
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_nested_prefixes_are_on_different_hosts.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_nested_prefixes_are_on_different_hosts : given.a_service_routing_validator
+{
+ void Establish()
+ {
+ _services["one"] = Routable("one.example.com");
+ _services["one"].PathPrefix = "/reports";
+ _services["two"] = Routable("two.example.com");
+ _services["two"].PathPrefix = "/reports";
+ }
+
+ void Because() => Validate();
+
+ [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_one_prefix_is_nested_in_another.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_one_prefix_is_nested_in_another.cs
new file mode 100644
index 00000000..f8d4b3bb
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_one_prefix_is_nested_in_another.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_one_prefix_is_nested_in_another : given.a_service_routing_validator
+{
+ void Establish()
+ {
+ _services["one"] = Routable();
+ _services["one"].PathPrefix = "/reports";
+ _services["two"] = Routable();
+ _services["two"].PathPrefix = "/reports/archive";
+ }
+
+ void Because() => Validate();
+
+ [Fact] void should_fail() => _result.Failed.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_services_are_told_apart_by_host_and_prefix.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_services_are_told_apart_by_host_and_prefix.cs
new file mode 100644
index 00000000..acc393c6
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_services_are_told_apart_by_host_and_prefix.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_services_are_told_apart_by_host_and_prefix : given.a_service_routing_validator
+{
+ void Establish()
+ {
+ var tenantReports = Routable("tenant.example.com");
+ tenantReports.PathPrefix = "/reports";
+ _services["tenant-reports"] = tenantReports;
+
+ var reports = Routable();
+ reports.PathPrefix = "/reports/";
+ reports.StripPathPrefix = true;
+ _services["reports"] = reports;
+
+ _services["billing"] = Routable("billing.example.com", "billing.example.com:8443");
+ _services["portal"] = Routable();
+ }
+
+ void Because() => Validate();
+
+ [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_services_declare_equivalent_unicode_and_punycode_hosts.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_services_declare_equivalent_unicode_and_punycode_hosts.cs
new file mode 100644
index 00000000..e0f2712f
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_services_declare_equivalent_unicode_and_punycode_hosts.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_services_declare_equivalent_unicode_and_punycode_hosts : given.a_service_routing_validator
+{
+ void Establish()
+ {
+ _services["one"] = Routable("bücher.example");
+ _services["two"] = Routable("xn--bcher-kva.example:8443");
+ }
+
+ void Because() => Validate();
+
+ [Fact] void should_refuse_the_overlapping_hosts() => _result.Failed.ShouldBeTrue();
+ [Fact] void should_name_the_normalized_host() => _result.FailureMessage.ShouldContain("bücher.example");
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_stripping_is_asked_without_a_prefix.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_stripping_is_asked_without_a_prefix.cs
new file mode 100644
index 00000000..174770c5
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_stripping_is_asked_without_a_prefix.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_stripping_is_asked_without_a_prefix : given.a_service_routing_validator
+{
+ void Establish()
+ {
+ _services["one"] = Routable();
+ _services["one"].StripPathPrefix = true;
+ }
+
+ void Because() => Validate();
+
+ [Fact] void should_fail() => _result.Failed.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_two_services_claim_the_same_host.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_two_services_claim_the_same_host.cs
new file mode 100644
index 00000000..81b483de
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_two_services_claim_the_same_host.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_two_services_claim_the_same_host : given.a_service_routing_validator
+{
+ void Establish()
+ {
+ _services["one"] = Routable("billing.example.com");
+ _services["two"] = Routable("BILLING.example.com:8443");
+ }
+
+ void Because() => Validate();
+
+ [Fact] void should_fail() => _result.Failed.ShouldBeTrue();
+ [Fact] void should_name_the_host() => _result.FailureMessage.ShouldContain("billing.example.com");
+}
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_two_services_declare_the_same_prefix.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_two_services_declare_the_same_prefix.cs
new file mode 100644
index 00000000..93ce3e0a
--- /dev/null
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_two_services_declare_the_same_prefix.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.ReverseProxy.for_ServiceRoutingConfigurationValidator;
+
+public class when_two_services_declare_the_same_prefix : given.a_service_routing_validator
+{
+ void Establish()
+ {
+ _services["one"] = Routable();
+ _services["one"].PathPrefix = "/reports";
+ _services["two"] = Routable();
+ _services["two"].PathPrefix = "/Reports";
+ }
+
+ void Because() => Validate();
+
+ [Fact] void should_fail() => _result.Failed.ShouldBeTrue();
+ [Fact] void should_name_both_services() => _result.FailureMessage.ShouldContain("'one' and 'two'");
+}
diff --git a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs
index fb0c7713..738df797 100644
--- a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs
+++ b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs
@@ -1,6 +1,7 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+using Cratis.AuthProxy.ReverseProxy;
using Microsoft.Extensions.Options;
using C = Cratis.AuthProxy.Configuration;
@@ -76,45 +77,24 @@ public bool TryResolveForTokenRequest(
/// The incoming HTTP request.
/// The resolved service configuration.
/// if a service was resolved; otherwise .
+ ///
+ /// The routed service must enable client credentials and permit the incoming path. A host or path prefix
+ /// can route a request to a different service than the one its Service-ID header names,
+ /// and only a token scoped to the routed service can authenticate that request.
+ ///
public bool TryResolveForRequest(HttpRequest request, out ConfiguredClientCredentialsService service)
{
- var candidates = GetConfiguredServices()
- .Where(_ => request.Path.StartsWithSegments(new PathString(_.RoutePrefix), StringComparison.OrdinalIgnoreCase))
- .ToArray();
-
- if (candidates.Length == 0)
+ var routed = ServiceRoutes.Resolve(request, config.CurrentValue);
+ if (routed is null)
{
- service = default!;
- return false;
+ return TryResolveCandidate(request, out service);
}
- var requestedService = ServiceSelection.FromHeaders(request.Headers);
- if (string.IsNullOrWhiteSpace(requestedService))
- {
- requestedService = request.Query["service"].FirstOrDefault();
- }
+ service = GetConfiguredServices().FirstOrDefault(_ =>
+ string.Equals(_.Name, routed.Name, StringComparison.OrdinalIgnoreCase)
+ && request.Path.StartsWithSegments(new PathString(_.RoutePrefix), StringComparison.OrdinalIgnoreCase))!;
- if (!string.IsNullOrWhiteSpace(requestedService))
- {
- var namedService = candidates.FirstOrDefault(_ => string.Equals(_.Name, requestedService, StringComparison.OrdinalIgnoreCase));
- if (namedService is not null)
- {
- service = namedService;
- return true;
- }
-
- service = default!;
- return false;
- }
-
- if (candidates.Length == 1)
- {
- service = candidates[0];
- return true;
- }
-
- service = default!;
- return false;
+ return service is not null;
}
static Uri? CreateVerificationUri(string baseUrl, string verificationPath)
@@ -164,4 +144,45 @@ IEnumerable GetConfiguredServices()
yield return new ConfiguredClientCredentialsService(name, routePrefix, verificationUri);
}
}
+
+ bool TryResolveCandidate(HttpRequest request, out ConfiguredClientCredentialsService service)
+ {
+ var candidates = GetConfiguredServices()
+ .Where(_ => request.Path.StartsWithSegments(new PathString(_.RoutePrefix), StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+
+ if (candidates.Length == 0)
+ {
+ service = default!;
+ return false;
+ }
+
+ var requestedService = ServiceSelection.FromHeaders(request.Headers);
+ if (string.IsNullOrWhiteSpace(requestedService))
+ {
+ requestedService = request.Query["service"].FirstOrDefault();
+ }
+
+ if (!string.IsNullOrWhiteSpace(requestedService))
+ {
+ var namedService = candidates.FirstOrDefault(_ => string.Equals(_.Name, requestedService, StringComparison.OrdinalIgnoreCase));
+ if (namedService is not null)
+ {
+ service = namedService;
+ return true;
+ }
+
+ service = default!;
+ return false;
+ }
+
+ if (candidates.Length == 1)
+ {
+ service = candidates[0];
+ return true;
+ }
+
+ service = default!;
+ return false;
+ }
}
diff --git a/Source/AuthProxy/Authorization/AccessPolicy.cs b/Source/AuthProxy/Authorization/AccessPolicy.cs
index b4e53601..d09d8c55 100644
--- a/Source/AuthProxy/Authorization/AccessPolicy.cs
+++ b/Source/AuthProxy/Authorization/AccessPolicy.cs
@@ -2,6 +2,8 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System.Security.Claims;
+using Cratis.AuthProxy.ReverseProxy;
+using Yarp.ReverseProxy.Model;
using C = Cratis.AuthProxy.Configuration;
namespace Cratis.AuthProxy.Authorization;
@@ -20,11 +22,6 @@ namespace Cratis.AuthProxy.Authorization;
///
public class AccessPolicy : IAccessPolicy
{
- ///
- /// The query-string parameter naming the target service, mirrored from the reverse-proxy route table.
- ///
- const string ServiceQueryParameter = "service";
-
///
public bool IsConfigured(C.AuthProxy config) =>
config.Authorization.HasRequirements
@@ -33,7 +30,16 @@ public bool IsConfigured(C.AuthProxy config) =>
///
public AccessDecision Evaluate(HttpContext context, C.AuthProxy config)
{
- foreach (var requirement in RequirementsFor(context, config))
+ var service = ServiceRoutes.Resolve(context.Request, config)?.Service;
+ if (service is null && context.GetEndpoint()?.Metadata.GetMetadata() is not null)
+ {
+ // A selected proxy endpoint can still forward the request. Never apply only root requirements
+ // or a named service's requirements when its authoritative cluster cannot be resolved.
+ return AccessDecision.Denied(string.Empty);
+ }
+
+ service ??= NamedService(context, config);
+ foreach (var requirement in RequirementsFor(service, config))
{
if (!IsSatisfied(requirement, context.User))
{
@@ -47,17 +53,16 @@ public AccessDecision Evaluate(HttpContext context, C.AuthProxy config)
///
/// Gets every requirement that applies to a request: the root's, then the target service's.
///
- /// The current .
+ /// The targeted service, if any.
/// The auth proxy configuration to read.
/// The applicable requirements, root-first.
- static IEnumerable RequirementsFor(HttpContext context, C.AuthProxy config)
+ static IEnumerable RequirementsFor(C.Service? service, C.AuthProxy config)
{
foreach (var requirement in config.Authorization.RequiredClaims)
{
yield return requirement;
}
- var service = ResolveService(context, config);
if (service?.Authorization is null)
{
yield break;
@@ -70,31 +75,22 @@ public AccessDecision Evaluate(HttpContext context, C.AuthProxy config)
}
///
- /// Resolves the service a request targets, the same way the route table does.
+ /// Resolves a service named by a request without a selected proxy endpoint.
///
/// The current .
/// The auth proxy configuration to read.
- /// The targeted service, or when the request names none.
+ /// The targeted service, or when the request matches no service route.
///
- /// Endpoint selection runs before the gate, so route metadata is authoritative when present. For
- /// callers evaluating a request without a selected endpoint, a single-service deployment selects its
- /// only service; otherwise a configured header target wins, with the service query
- /// parameter as the fallback. An unknown header must not hide a query-selected service's requirements.
+ /// A request that matches no service route is not forwarded at all. When it still names a service in the
+ /// x-cratis-microservice header (or legacy Service-ID) or the
+ /// service query parameter, that service's requirements apply anyway — the stricter
+ /// answer costs nothing for a request that goes nowhere. An unknown header does not hide a query-selected
+ /// service's requirements. A request that names none gets only the root requirements.
///
- static C.Service? ResolveService(HttpContext context, C.AuthProxy config)
+ static C.Service? NamedService(HttpContext context, C.AuthProxy config)
{
- if (ServiceSelection.FromRoute(context) is { } selectedService)
- {
- return FindService(config, selectedService);
- }
-
- if (config.Services.Count == 1)
- {
- return config.Services.Values.First();
- }
-
return FindService(config, ServiceSelection.FromHeaders(context.Request.Headers))
- ?? FindService(config, context.Request.Query[ServiceQueryParameter].FirstOrDefault());
+ ?? FindService(config, context.Request.Query[ServiceRoutes.ServiceQueryParameter].FirstOrDefault());
}
static C.Service? FindService(C.AuthProxy config, string? serviceId) =>
diff --git a/Source/AuthProxy/Configuration/Service.cs b/Source/AuthProxy/Configuration/Service.cs
index 4f00fbed..98535fb7 100644
--- a/Source/AuthProxy/Configuration/Service.cs
+++ b/Source/AuthProxy/Configuration/Service.cs
@@ -28,6 +28,44 @@ public class Service
///
public ServiceEndpoint? Frontend { get; set; }
+ ///
+ /// Gets or sets the host names that route requests to this service, without a Service-ID header
+ /// or service query parameter. An entry is a host name with an optional port
+ /// (reporting.example.com or reporting.example.com:8443); an entry without a
+ /// port matches every port. Matching is case-insensitive.
+ ///
+ ///
+ /// A host match is the default for that host: an explicit Service-ID header or
+ /// service query parameter still selects another service, so a frontend can keep naming the
+ /// backend it calls. Combined with , the service only answers for the prefix on these
+ /// hosts. No two services may claim the same host without a path prefix telling them apart.
+ ///
+ public IList Hosts { get; set; } = [];
+
+ ///
+ /// Gets or sets the path prefix that routes requests to this service, for example /reporting.
+ /// Every request under the prefix goes to this service: {PathPrefix}/api/... to the backend, and
+ /// everything else under the prefix to the frontend.
+ ///
+ ///
+ /// A path prefix claims its part of the URL: it takes precedence over the Service-ID header and
+ /// the service query parameter. It must be a rooted path of literal segments, must not overlap
+ /// another service's prefix on the same hosts, and must not cover AuthProxy's own paths or /api.
+ ///
+ public string PathPrefix { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether is removed from the forwarded path.
+ /// Defaults to : the service receives the path exactly as requested and serves itself
+ /// under the prefix (for example with UsePathBase).
+ ///
+ ///
+ /// When , /reporting/api/orders is forwarded as /api/orders and
+ /// the removed prefix is sent in X-Forwarded-Prefix, so a backend that honors forwarded headers
+ /// can restore it as its path base.
+ ///
+ public bool StripPathPrefix { get; set; }
+
///
/// Gets or sets the registration endpoint for this service.
/// This is currently used by the lobby configuration to identify where new users should be sent
@@ -85,9 +123,10 @@ public class Service
/// These are applied in addition to any declared at the root — a service can narrow who gets
/// in, never widen it. Leave unset to require only what the root requires.
///
- /// The service a request targets is resolved the way the route table resolves it: the single
- /// configured service when there is only one, otherwise the x-cratis-microservice header (or the legacy Service-ID) or the
- /// service query parameter. A request in a multi-service deployment that names no service
+ /// The service a request targets is resolved the way the route table resolves it: by
+ /// and , by the x-cratis-microservice header
+ /// (or the legacy Service-ID) or the service query parameter,
+ /// or as the single configured service. A request in a multi-service deployment that matches no service
/// matches no service route either, so only the root requirements apply to it.
///
///
diff --git a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs
index e4a92990..42b56bdc 100644
--- a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs
+++ b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs
@@ -12,15 +12,18 @@ namespace Cratis.AuthProxy.ReverseProxy;
/// configuration section.
///
///
-/// Each microservice generates routes that are matched by either:
+/// Each microservice generates routes that are matched by:
///
-/// - An Microservice-ID HTTP header set to the microservice name, or
-/// - A microservice query-string parameter set to the microservice name.
+/// - its declared , optionally on its declared ,
+/// - a Service-ID HTTP header set to the microservice name,
+/// - a service query-string parameter set to the microservice name, or
+/// - its declared .
///
+/// The precedence between them is stated once, in , and is expressed here as route order.
///
///
-/// When only a single microservice is configured the header / query parameter is
-/// optional and a plain catch-all route is also registered so that the single
+/// When only a single microservice is configured, and it declares neither hosts nor a path prefix, the
+/// header / query parameter is optional and a plain catch-all route is also registered so that the single
/// microservice works without any special client configuration.
///
///
@@ -40,7 +43,22 @@ public class MicroserviceReverseProxyConfigProvider : IProxyConfigProvider, IDis
///
/// The path prefix served by a service's backend rather than its frontend.
///
- const string ApiPathPrefix = "/api";
+ const string ApiPathPrefix = ServiceRoutes.ApiPathPrefix;
+
+ ///
+ /// The route order of a host-and-prefix backend route. Route orders run lowest first, anonymous paths are 0,
+ /// and explains why the steps that follow are in this order.
+ ///
+ const int HostAndPrefixApiOrder = 1;
+ const int HostAndPrefixOrder = 2;
+ const int PrefixApiOrder = 3;
+ const int PrefixOrder = 4;
+ const int HeaderApiOrder = 10;
+ const int QueryApiOrder = 11;
+ const int HeaderOrder = 20;
+ const int QueryOrder = 21;
+ const int HostApiOrder = 30;
+ const int HostOrder = 31;
readonly InMemoryConfigProvider _inner;
readonly ILogger _logger;
@@ -80,7 +98,7 @@ static List BuildRoutes(C.AuthProxy config, ILogger logger)
{
var routes = new List();
var services = config.Services;
- var isSingleMicroservice = services.Count == 1;
+ var isSingleMicroservice = ServiceRoutes.UsesSingleServiceDefaults(config);
// A declared prefix is matched without any service-selection header or query parameter, so two
// services declaring the same prefix would emit two routes with an identical template and an
@@ -95,6 +113,8 @@ static List BuildRoutes(C.AuthProxy config, ILogger logger)
ReportRefusedAnonymousPaths(key, ms, logger);
routes.AddRange(AnonymousRoutes(name, ms, claimedAnonymousPaths, logger));
+ routes.AddRange(PathPrefixRoutes(name, ms));
+ routes.AddRange(HostRoutes(name, ms));
if (ms.Backend is not null)
{
@@ -108,7 +128,8 @@ static List BuildRoutes(C.AuthProxy config, ILogger logger)
}
// In a single-microservice deployment also add a plain catch-all so the
- // frontend is reachable without any routing header or query parameter.
+ // frontend is reachable without any routing header or query parameter. A single service that declares
+ // hosts or a path prefix asked to be reached only through them, so it gets no catch-all.
if (isSingleMicroservice)
{
var (name, ms) = services.First();
@@ -216,7 +237,14 @@ static IEnumerable AnonymousRoutes(
// Mirror the authenticated split: /api goes to the backend, anything else to the frontend,
// falling back to whichever endpoint the service actually declares.
- var prefersBackend = new PathString(path).StartsWithSegments(ApiPathPrefix);
+ var prefix = ServiceRoutes.PathPrefixOf(service);
+ var relativePath = new PathString(path);
+ if (prefix is not null && relativePath.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase, out var remaining))
+ {
+ relativePath = remaining;
+ }
+
+ var prefersBackend = relativePath.StartsWithSegments(ApiPathPrefix, StringComparison.OrdinalIgnoreCase);
var clusterId = (prefersBackend, service.Backend, service.Frontend) switch
{
(true, not null, _) => BackendClusterId(microserviceKey),
@@ -238,20 +266,128 @@ static IEnumerable AnonymousRoutes(
claimedPaths[path] = microserviceKey;
+ // An anonymous path below a stripped prefix is forwarded the way the rest of the prefix is, so the
+ // service sees one consistent path shape whether or not the caller has a session.
+ var stripsPrefix = service.StripPathPrefix
+ && prefix is not null
+ && new PathString(path).StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase);
+
yield return new RouteConfig
{
RouteId = $"{microserviceKey}-anonymous-{index}",
- Metadata = ServiceMetadata(serviceName),
ClusterId = clusterId,
AuthorizationPolicy = AnonymousAuthorizationPolicy,
Match = new RouteMatch { Path = $"{path}/{{**catch-all}}" },
Order = 0,
+ Metadata = ServiceMetadata(serviceName, stripsPrefix ? prefix : null),
};
index++;
}
}
+ ///
+ /// Builds the routes for a service's declared .
+ ///
+ /// The configured service name.
+ /// The service configuration.
+ /// The prefix routes: {prefix}/api to the backend, the rest of the prefix to the frontend.
+ ///
+ /// A prefix declared together with hosts only answers on those hosts, and is ordered ahead of a prefix that
+ /// answers on every host, so a host-specific declaration wins where both apply.
+ ///
+ static IEnumerable PathPrefixRoutes(string serviceName, C.Service service)
+ {
+ var microserviceKey = serviceName.ToLowerInvariant();
+ if (ServiceRoutes.PathPrefixOf(service) is not { } prefix)
+ {
+ yield break;
+ }
+
+ var hosts = ServiceRoutes.HostsOf(service).Select(_ => _.Value!).ToArray();
+ var onHosts = hosts.Length > 0;
+ var metadata = ServiceMetadata(serviceName, service.StripPathPrefix ? prefix : null);
+
+ if (service.Backend is not null)
+ {
+ yield return new RouteConfig
+ {
+ RouteId = $"{microserviceKey}-prefix-api",
+ ClusterId = BackendClusterId(microserviceKey),
+ AuthorizationPolicy = "default",
+ Match = new RouteMatch { Path = $"{prefix}{ApiPathPrefix}/{{**catch-all}}", Hosts = onHosts ? hosts : null },
+ Order = onHosts ? HostAndPrefixApiOrder : PrefixApiOrder,
+ Metadata = metadata,
+ };
+ }
+
+ if (CatchAllClusterId(microserviceKey, service) is { } clusterId)
+ {
+ yield return new RouteConfig
+ {
+ RouteId = $"{microserviceKey}-prefix",
+ ClusterId = clusterId,
+ AuthorizationPolicy = "default",
+ Match = new RouteMatch { Path = $"{prefix}/{{**catch-all}}", Hosts = onHosts ? hosts : null },
+ Order = onHosts ? HostAndPrefixOrder : PrefixOrder,
+ Metadata = metadata,
+ };
+ }
+ }
+
+ ///
+ /// Builds the routes for a service's declared , when it declares no path prefix.
+ ///
+ /// The configured service name.
+ /// The service configuration.
+ /// The host routes: /api to the backend, everything else to the frontend.
+ ///
+ /// Ordered behind the header- and query-selected routes: a host names the service a request goes to when the
+ /// request does not say, and a frontend on that host can still name another service's backend.
+ ///
+ static IEnumerable HostRoutes(string serviceName, C.Service service)
+ {
+ var microserviceKey = serviceName.ToLowerInvariant();
+ var hosts = ServiceRoutes.HostsOf(service).Select(_ => _.Value!).ToArray();
+ if (hosts.Length == 0 || ServiceRoutes.PathPrefixOf(service) is not null)
+ {
+ yield break;
+ }
+
+ if (service.Backend is not null)
+ {
+ yield return new RouteConfig
+ {
+ RouteId = $"{microserviceKey}-host-api",
+ Metadata = ServiceMetadata(serviceName),
+ ClusterId = BackendClusterId(microserviceKey),
+ AuthorizationPolicy = "default",
+ Match = new RouteMatch { Path = $"{ApiPathPrefix}/{{**catch-all}}", Hosts = hosts },
+ Order = HostApiOrder,
+ };
+ }
+
+ if (CatchAllClusterId(microserviceKey, service) is { } clusterId)
+ {
+ yield return new RouteConfig
+ {
+ RouteId = $"{microserviceKey}-host",
+ Metadata = ServiceMetadata(serviceName),
+ ClusterId = clusterId,
+ AuthorizationPolicy = "default",
+ Match = new RouteMatch { Path = "/{**catch-all}", Hosts = hosts },
+ Order = HostOrder,
+ };
+ }
+ }
+
+ static string? CatchAllClusterId(string microserviceKey, C.Service service) => (service.Frontend, service.Backend) switch
+ {
+ (not null, _) => FrontendClusterId(microserviceKey),
+ (null, not null) => BackendClusterId(microserviceKey),
+ _ => null,
+ };
+
static IEnumerable BackendRoutes(string serviceName, bool isSingle)
{
var microserviceKey = serviceName.ToLowerInvariant();
@@ -277,7 +413,7 @@ static IEnumerable BackendRoutes(string serviceName, bool isSingle)
}
],
},
- Order = 1,
+ Order = HeaderApiOrder,
};
// Query-parameter–matched API route (adds the header for downstream).
@@ -299,14 +435,14 @@ static IEnumerable BackendRoutes(string serviceName, bool isSingle)
[
new RouteQueryParameter
{
- Name = "service",
+ Name = ServiceRoutes.ServiceQueryParameter,
Mode = QueryParameterMatchMode.Exact,
IsCaseSensitive = false,
Values = [microserviceKey],
}
],
},
- Order = 2,
+ Order = QueryApiOrder,
};
// Plain /api catch-all when there is only one microservice.
@@ -349,7 +485,7 @@ static IEnumerable FrontendRoutes(string serviceName)
}
],
},
- Order = 10,
+ Order = HeaderOrder,
};
// Query-parameter–matched frontend route, ordered behind the header-matched one for the same
@@ -367,14 +503,14 @@ static IEnumerable FrontendRoutes(string serviceName)
[
new RouteQueryParameter
{
- Name = "service",
+ Name = ServiceRoutes.ServiceQueryParameter,
Mode = QueryParameterMatchMode.Exact,
IsCaseSensitive = false,
Values = [microserviceKey],
}
],
},
- Order = 11,
+ Order = QueryOrder,
};
}
@@ -437,7 +573,16 @@ static List BuildClusters(C.AuthProxy config)
},
};
- static Dictionary ServiceMetadata(string serviceName) => new() { [ServiceSelection.RouteMetadataKey] = serviceName };
+ static Dictionary ServiceMetadata(string serviceName, string? stripPrefix = null)
+ {
+ var metadata = new Dictionary { [ServiceSelection.RouteMetadataKey] = serviceName };
+ if (stripPrefix is not null)
+ {
+ metadata[ServiceRoutes.StripPathPrefixMetadataKey] = stripPrefix;
+ }
+
+ return metadata;
+ }
static string BackendClusterId(string key) => $"{key}-backend-cluster";
static string FrontendClusterId(string key) => $"{key}-frontend-cluster";
diff --git a/Source/AuthProxy/ReverseProxy/PathPrefixTransform.cs b/Source/AuthProxy/ReverseProxy/PathPrefixTransform.cs
new file mode 100644
index 00000000..c908e4f6
--- /dev/null
+++ b/Source/AuthProxy/ReverseProxy/PathPrefixTransform.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Yarp.ReverseProxy.Transforms;
+using Yarp.ReverseProxy.Transforms.Builder;
+
+namespace Cratis.AuthProxy.ReverseProxy;
+
+///
+/// Removes a service's path prefix from the forwarded path and announces it in X-Forwarded-Prefix,
+/// for routes of a service that sets .
+///
+/// The removed prefix.
+///
+/// The announced prefix is the request's own path base followed by the removed prefix. An incoming
+/// X-Forwarded-Prefix header is replaced, not consumed as a path base.
+///
+public sealed class PathPrefixTransform(string prefix) : RequestTransform
+{
+ ///
+ /// The header that carries the removed prefix to the service.
+ ///
+ public const string ForwardedPrefixHeader = "X-Forwarded-Prefix";
+
+ ///
+ /// Adds the transforms a route needs when its metadata asks for the prefix to be stripped.
+ ///
+ /// The transform builder context of the route.
+ public static void Apply(TransformBuilderContext context)
+ {
+ if (context.Route.Metadata?.TryGetValue(ServiceRoutes.StripPathPrefixMetadataKey, out var prefix) != true
+ || string.IsNullOrEmpty(prefix))
+ {
+ return;
+ }
+
+ context.AddPathRemovePrefix(prefix);
+
+ // YARP appends its default X-Forwarded-* transforms after every custom one, and its prefix transform
+ // would overwrite this one with the bare path base. Add the same defaults explicitly, ahead of this one.
+ context.UseDefaultForwarders = false;
+ context.AddXForwarded();
+ context.RequestTransforms.Add(new PathPrefixTransform(prefix));
+ }
+
+ ///
+ public override ValueTask ApplyAsync(RequestTransformContext context)
+ {
+ var forwardedPrefix = context.HttpContext.Request.PathBase.Add(new PathString(prefix));
+ RemoveHeader(context, ForwardedPrefixHeader);
+ AddHeader(context, ForwardedPrefixHeader, forwardedPrefix.ToUriComponent());
+
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs b/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs
index c79d5bb2..5a1478e6 100644
--- a/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs
+++ b/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs
@@ -35,7 +35,13 @@ public static WebApplicationBuilder SetupReverseProxy(this WebApplicationBuilder
handler.KeepAlivePingTimeout = TimeSpan.FromMinutes(2);
handler.ConnectTimeout = TimeSpan.FromMinutes(3);
})
- .AddTransforms(ctx => ctx.RequestTransforms.Add(new InjectIdentityHeadersTransform()));
+ .AddTransforms(ctx =>
+ {
+ ctx.RequestTransforms.Add(new InjectIdentityHeadersTransform());
+ PathPrefixTransform.Apply(ctx);
+ });
+
+ builder.Services.AddSingleton, ServiceRoutingConfigurationValidator>();
return builder;
}
diff --git a/Source/AuthProxy/ReverseProxy/RoutedService.cs b/Source/AuthProxy/ReverseProxy/RoutedService.cs
new file mode 100644
index 00000000..ed7631f4
--- /dev/null
+++ b/Source/AuthProxy/ReverseProxy/RoutedService.cs
@@ -0,0 +1,13 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using C = Cratis.AuthProxy.Configuration;
+
+namespace Cratis.AuthProxy.ReverseProxy;
+
+///
+/// Represents the service a request is routed to.
+///
+/// The configured service key.
+/// The service configuration.
+public sealed record RoutedService(string Name, C.Service Service);
diff --git a/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs b/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs
new file mode 100644
index 00000000..a3912bb0
--- /dev/null
+++ b/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs
@@ -0,0 +1,228 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Extensions.Primitives;
+using Yarp.ReverseProxy.Model;
+using C = Cratis.AuthProxy.Configuration;
+
+namespace Cratis.AuthProxy.ReverseProxy;
+
+///
+/// States how a request is matched to a service, once, for both the route table and every component that has to
+/// know which service a request targets.
+///
+///
+/// After endpoint selection, uses the selected proxy cluster. Without a proxy endpoint,
+/// it follows the declared route order:
+///
+/// - Anonymous paths (), which do not require service authentication and are resolved only from a selected proxy endpoint.
+/// - Host and path prefix together ( and ).
+/// - Path prefix alone.
+/// - The x-cratis-microservice header (or legacy Service-ID), then the service query parameter.
+/// - Host alone.
+/// - The single configured service, when it declares neither hosts nor a path prefix.
+///
+/// A path prefix claims its part of the URL, so it comes before an explicit selection; a host is a default an
+/// explicit selection can override. Startup validation refuses configurations where two services could claim the
+/// same request at the same step, so each step yields at most one service.
+///
+public static class ServiceRoutes
+{
+ ///
+ /// The path prefix served by a service's backend rather than its frontend.
+ ///
+ public const string ApiPathPrefix = "/api";
+
+ ///
+ /// The query-string parameter naming the target service.
+ ///
+ public const string ServiceQueryParameter = "service";
+
+ ///
+ /// The route metadata key carrying the path prefix to remove from the forwarded path.
+ ///
+ public const string StripPathPrefixMetadataKey = "Cratis.AuthProxy.StripPathPrefix";
+
+ ///
+ /// Gets the normalized path prefix of a service, when it declares a usable one.
+ ///
+ /// The service.
+ /// The normalized prefix, or when none is declared or it is unusable.
+ public static string? PathPrefixOf(C.Service service) =>
+ !string.IsNullOrWhiteSpace(service.PathPrefix)
+ && AnonymousPaths.TryNormalize(service.PathPrefix, out var prefix)
+ && !IsUnderApi(prefix)
+ ? prefix
+ : null;
+
+ ///
+ /// Gets the usable host entries of a service.
+ ///
+ /// The service.
+ /// The normalized host entries.
+ public static IEnumerable HostsOf(C.Service service) =>
+ service.Hosts
+ .Select(_ => TryParseHost(_, out var host) ? host : (HostString?)null)
+ .OfType();
+
+ ///
+ /// Parses a declared host entry.
+ ///
+ /// The declared entry.
+ /// The normalized host, lower-cased, with its port when one is declared.
+ /// when the entry is a host name with an optional port; otherwise .
+ public static bool TryParseHost(string? candidate, out HostString host)
+ {
+ host = default;
+ var trimmed = candidate?.Trim() ?? string.Empty;
+ if (trimmed.Length == 0 || trimmed.IndexOfAny(['/', '*', '?', '#', '@', ' ', '[', ']']) >= 0
+ || trimmed.Count(_ => _ == ':') > 1)
+ {
+ return false;
+ }
+
+ HostString parsed;
+ try
+ {
+ // ASP.NET decodes IDNs when reading the Host header; route declarations must use the same form.
+ parsed = HostString.FromUriComponent(trimmed.ToLowerInvariant());
+ }
+ catch (ArgumentException)
+ {
+ // Invalid punycode is an unusable declaration, not a startup exception outside validation.
+ return false;
+ }
+ if (Uri.CheckHostName(parsed.Host.Trim('[', ']')) == UriHostNameType.Unknown
+ || parsed.Port is <= 0 or > 65535
+ || (parsed.Port is null && trimmed.Contains(':')))
+ {
+ return false;
+ }
+
+ host = new HostString(parsed.Value!.ToLowerInvariant());
+ return true;
+ }
+
+ ///
+ /// Gets whether a declared host matches the host of a request, the way ASP.NET host matching does: an entry
+ /// without a port matches every port, and a request without a port is on its scheme's default port.
+ ///
+ /// The declared host.
+ /// The request.
+ /// when the request is for the declared host.
+ public static bool Matches(HostString declared, HttpRequest request)
+ {
+ if (!string.Equals(declared.Host, request.Host.Host, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (declared.Port is null)
+ {
+ return true;
+ }
+
+ var port = request.Host.Port ?? (request.IsHttps ? 443 : 80);
+ return port == declared.Port;
+ }
+
+ ///
+ /// Gets whether two declared hosts can match the same request.
+ ///
+ /// The first host.
+ /// The second host.
+ /// when some request matches both.
+ public static bool Overlap(HostString first, HostString second) =>
+ string.Equals(first.Host, second.Host, StringComparison.OrdinalIgnoreCase)
+ && (first.Port is null || second.Port is null || first.Port == second.Port);
+
+ ///
+ /// Gets whether two path prefixes can match the same request path.
+ ///
+ /// The first normalized prefix.
+ /// The second normalized prefix.
+ /// when one prefix equals the other or lies below it.
+ public static bool Overlap(string first, string second) =>
+ new PathString(first).StartsWithSegments(second, StringComparison.OrdinalIgnoreCase)
+ || new PathString(second).StartsWithSegments(first, StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Gets whether a service is reached through the plain catch-all routes of a single-service deployment.
+ ///
+ /// The configuration.
+ /// when there is exactly one service and it declares neither hosts nor a path prefix.
+ public static bool UsesSingleServiceDefaults(C.AuthProxy config) =>
+ config.Services.Count == 1
+ && config.Services.Values.First() is var service
+ && service.Hosts.Count == 0
+ && string.IsNullOrWhiteSpace(service.PathPrefix);
+
+ ///
+ /// Resolves the selected proxy route's service, falling back to routing declarations without a proxy endpoint.
+ ///
+ /// The request.
+ /// The configuration.
+ /// The targeted service, or when the request matches no service route.
+ public static RoutedService? Resolve(HttpRequest request, C.AuthProxy config)
+ {
+ // Endpoint routing has already applied YARP's header parsing and route precedence. Its selected
+ // cluster is authoritative: independently comparing raw headers can authorize a different service.
+ if (request.HttpContext.GetEndpoint()?.Metadata.GetMetadata() is { } route)
+ {
+ return config.Services
+ .Where(_ => string.Equals(route.Config.ClusterId, $"{_.Key.ToLowerInvariant()}-backend-cluster", StringComparison.Ordinal)
+ || string.Equals(route.Config.ClusterId, $"{_.Key.ToLowerInvariant()}-frontend-cluster", StringComparison.Ordinal))
+ .Select(_ => new RoutedService(_.Key, _.Value))
+ .FirstOrDefault();
+ }
+
+ if (config.Services.Count == 1)
+ {
+ // Every route in a single-service table leads to that service.
+ var (name, service) = config.Services.First();
+ return new(name, service);
+ }
+
+ var services = config.Services
+ .Where(_ => _.Value.Backend is not null || _.Value.Frontend is not null)
+ .Select(_ => new RoutedService(_.Key, _.Value))
+ .ToArray();
+ var path = request.Path;
+ var header = !string.IsNullOrWhiteSpace(request.Headers[Headers.ServiceId].FirstOrDefault())
+ ? request.Headers[Headers.ServiceId]
+ : request.Headers[Headers.LegacyServiceId];
+
+ return ByPathPrefix(services, path, _ => HostsOf(_).Any(host => Matches(host, request)))
+ ?? ByPathPrefix(services, path, _ => _.Hosts.Count == 0)
+ ?? ByName(services, path, header, request.Query[ServiceQueryParameter])
+ ?? services.FirstOrDefault(_ => PathPrefixOf(_.Service) is null && HostsOf(_.Service).Any(host => Matches(host, request)));
+ }
+
+ static RoutedService? ByPathPrefix(IEnumerable services, PathString path, Func hostMatches) =>
+ services
+ .Select(_ => (Routed: _, Prefix: PathPrefixOf(_.Service)))
+ .Where(_ => _.Prefix is not null && hostMatches(_.Routed.Service) && path.StartsWithSegments(_.Prefix, StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(_ => _.Prefix!.Length)
+ .Select(_ => _.Routed)
+ .FirstOrDefault();
+
+ static RoutedService? ByName(RoutedService[] services, PathString path, StringValues header, StringValues query)
+ {
+ RoutedService? Named(StringValues values, Func serves) =>
+ services.FirstOrDefault(_ => serves(_.Service) && values.Any(value => string.Equals(value, _.Name, StringComparison.OrdinalIgnoreCase)));
+
+ // The backend routes only take /api, and come first; the frontend routes take every path.
+ if (path.StartsWithSegments(ApiPathPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var backend = Named(header, _ => _.Backend is not null) ?? Named(query, _ => _.Backend is not null);
+ if (backend is not null)
+ {
+ return backend;
+ }
+ }
+
+ return Named(header, _ => _.Frontend is not null) ?? Named(query, _ => _.Frontend is not null);
+ }
+
+ static bool IsUnderApi(string prefix) => new PathString(prefix).StartsWithSegments(ApiPathPrefix, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Source/AuthProxy/ReverseProxy/ServiceRoutingConfigurationValidator.cs b/Source/AuthProxy/ReverseProxy/ServiceRoutingConfigurationValidator.cs
new file mode 100644
index 00000000..8b371ad9
--- /dev/null
+++ b/Source/AuthProxy/ReverseProxy/ServiceRoutingConfigurationValidator.cs
@@ -0,0 +1,114 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Extensions.Options;
+using C = Cratis.AuthProxy.Configuration;
+
+namespace Cratis.AuthProxy.ReverseProxy;
+
+///
+/// Validates the host and path-prefix routing of every service at startup.
+///
+///
+/// A routing declaration that cannot be honored, or two services that could claim the same request at the same
+/// step of , stop the host with a message naming them. Two services claiming one
+/// request would otherwise surface as an ambiguous-match error on that request, or as traffic quietly reaching
+/// the wrong service.
+///
+public class ServiceRoutingConfigurationValidator : IValidateOptions
+{
+ ///
+ public ValidateOptionsResult Validate(string? name, C.AuthProxy options)
+ {
+ var failures = new List();
+ var declared = new List<(string Name, string? Prefix, HostString[] Hosts)>();
+
+ foreach (var (serviceName, service) in options.Services)
+ {
+ var hosts = new List();
+ foreach (var entry in service.Hosts)
+ {
+ if (ServiceRoutes.TryParseHost(entry, out var host))
+ {
+ hosts.Add(host);
+ continue;
+ }
+
+ failures.Add($"Service '{serviceName}': Hosts entry '{entry}' is not a host name with an optional port, such as reporting.example.com or reporting.example.com:8443. Wildcards are not supported.");
+ }
+
+ var prefix = ValidatePathPrefix(serviceName, service, failures);
+
+ if ((hosts.Count > 0 || prefix is not null) && service.Backend is null && service.Frontend is null)
+ {
+ failures.Add($"Service '{serviceName}' declares Hosts or a PathPrefix but has no Backend or Frontend to route them to.");
+ }
+
+ if (service.StripPathPrefix && string.IsNullOrWhiteSpace(service.PathPrefix))
+ {
+ failures.Add($"Service '{serviceName}' sets StripPathPrefix but declares no PathPrefix to strip.");
+ }
+
+ if (hosts.Count > 0 || prefix is not null)
+ {
+ declared.Add((serviceName, prefix, [.. hosts]));
+ }
+ }
+
+ failures.AddRange(Conflicts(declared));
+
+ return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures);
+ }
+
+ static string? ValidatePathPrefix(string serviceName, C.Service service, List failures)
+ {
+ if (string.IsNullOrWhiteSpace(service.PathPrefix))
+ {
+ return null;
+ }
+
+ var rejection = AnonymousPathPolicy.Evaluate(service.PathPrefix, out var prefix);
+ if (rejection != AnonymousPathRejection.None)
+ {
+ failures.Add($"Service '{serviceName}': PathPrefix '{service.PathPrefix}' is refused ({rejection}). Declare a rooted path of plain literal segments, such as /reporting, that is not one of the paths AuthProxy reserves for itself.");
+ return null;
+ }
+
+ if (new PathString(prefix).StartsWithSegments(ServiceRoutes.ApiPathPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ failures.Add($"Service '{serviceName}': PathPrefix '{service.PathPrefix}' would take {ServiceRoutes.ApiPathPrefix} from every other service. Choose a prefix outside {ServiceRoutes.ApiPathPrefix}.");
+ return null;
+ }
+
+ return prefix;
+ }
+
+ static IEnumerable Conflicts(List<(string Name, string? Prefix, HostString[] Hosts)> declared)
+ {
+ for (var i = 0; i < declared.Count; i++)
+ {
+ for (var j = i + 1; j < declared.Count; j++)
+ {
+ var (first, second) = (declared[i], declared[j]);
+ var sharedHost = first.Hosts.SelectMany(a => second.Hosts.Where(b => ServiceRoutes.Overlap(a, b)).Select(_ => a)).FirstOrDefault();
+ var bothOnEveryHost = first.Hosts.Length == 0 && second.Hosts.Length == 0;
+
+ // The same route step only: host+prefix with host+prefix on a shared host, prefix alone with
+ // prefix alone, host alone with host alone on a shared host. Across steps, route order decides.
+ var conflict = (first.Prefix, second.Prefix) switch
+ {
+ ({ } a, { } b) when (bothOnEveryHost || sharedHost.HasValue) && ServiceRoutes.Overlap(a, b) =>
+ $"Services '{first.Name}' and '{second.Name}' declare overlapping path prefixes '{a}' and '{b}'{(sharedHost.HasValue ? $" on host '{sharedHost.Value}'" : string.Empty)}. Path prefixes on the same hosts may not be equal or nested.",
+ (null, null) when sharedHost.HasValue =>
+ $"Services '{first.Name}' and '{second.Name}' both claim host '{sharedHost.Value}'. Give each its own host, or a PathPrefix to tell them apart.",
+ _ => null
+ };
+
+ if (conflict is not null)
+ {
+ yield return conflict;
+ }
+ }
+ }
+ }
+}