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; + } + } + } + } +}