From fbcec0a8cfb529432be5fdcefedef5cb08464d8b Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 13:51:29 +0200 Subject: [PATCH 1/5] Route to services by host or path prefix A service can declare Hosts and a PathPrefix (optionally stripped and announced in X-Forwarded-Prefix) so several applications can sit behind one AuthProxy without a Service-ID header or ?service= query parameter. The precedence is stated once in ServiceRoutes and shared by the route table, the per-service authorization gate and client-credentials token resolution, and ambiguous declarations fail at startup. --- Documentation/configuration/authorization.md | 8 +- Documentation/configuration/services.md | 101 ++++++++- ...ices_are_routed_by_host_and_path_prefix.cs | 122 +++++++++++ .../given/ServiceRoutingHarness.cs | 159 ++++++++++++++ .../given/ServiceRoutingSpecCollection.cs | 14 ++ ..._to_the_only_client_credentials_service.cs | 44 ++++ ...vice_is_not_where_the_request_is_routed.cs | 46 ++++ .../when_a_service_is_reached_by_its_host.cs | 61 ++++++ ...a_single_service_declares_a_path_prefix.cs | 38 ++++ ...ervices_declare_hosts_and_path_prefixes.cs | 76 +++++++ .../given/services_routed_by_host_and_path.cs | 56 +++++ ...red_port_differs_from_the_requested_one.cs | 30 +++ ...es_a_service_that_cannot_serve_the_path.cs | 17 ++ ...en_a_host_and_a_service_header_disagree.cs | 17 ++ ..._requested_with_another_case_and_a_port.cs | 13 ++ ...a_host_is_requested_without_a_selection.cs | 13 ++ ..._only_starts_with_the_prefix_characters.cs | 13 ++ ..._a_prefix_and_a_service_header_disagree.cs | 17 ++ ...n_a_prefix_is_requested_on_another_host.cs | 13 ++ ...equested_on_the_host_it_is_declared_for.cs | 13 ++ .../when_a_request_matches_no_service.cs | 13 ++ ...hen_the_query_parameter_names_a_service.cs | 17 ++ .../given/a_service_routing_validator.cs | 20 ++ .../when_a_host_entry_is_a_url.cs | 13 ++ .../when_a_host_entry_is_a_wildcard.cs | 13 ++ .../when_a_prefix_covers_an_authproxy_path.cs | 17 ++ .../when_a_prefix_covers_the_api_path.cs | 17 ++ .../when_a_prefix_is_not_a_plain_path.cs | 17 ++ ...n_a_routed_service_has_nowhere_to_route.cs | 13 ++ ..._nested_prefixes_are_on_different_hosts.cs | 19 ++ .../when_one_prefix_is_nested_in_another.cs | 19 ++ ...vices_are_told_apart_by_host_and_prefix.cs | 26 +++ ...hen_stripping_is_asked_without_a_prefix.cs | 17 ++ .../when_two_services_claim_the_same_host.cs | 18 ++ ...en_two_services_declare_the_same_prefix.cs | 20 ++ .../ClientCredentialsServiceResolver.cs | 80 ++++--- .../AuthProxy/Authorization/AccessPolicy.cs | 48 ++--- Source/AuthProxy/Configuration/Service.cs | 47 +++- .../MicroserviceReverseProxyConfigProvider.cs | 157 ++++++++++++-- .../ReverseProxy/PathPrefixTransform.cs | 55 +++++ .../ReverseProxy/ReverseProxyExtensions.cs | 10 +- .../AuthProxy/ReverseProxy/RoutedService.cs | 13 ++ .../AuthProxy/ReverseProxy/ServiceRoutes.cs | 201 ++++++++++++++++++ .../ServiceRoutingConfigurationValidator.cs | 114 ++++++++++ 44 files changed, 1773 insertions(+), 82 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_ServiceRouting/when_services_are_routed_by_host_and_path_prefix.cs create mode 100644 Source/AuthProxy.Security.Specs/given/ServiceRoutingHarness.cs create mode 100644 Source/AuthProxy.Security.Specs/given/ServiceRoutingSpecCollection.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_a_host_routes_to_the_only_client_credentials_service.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_the_named_service_is_not_where_the_request_is_routed.cs create mode 100644 Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_service_is_reached_by_its_host.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_single_service_declares_a_path_prefix.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_services_declare_hosts_and_path_prefixes.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/given/services_routed_by_host_and_path.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_declared_port_differs_from_the_requested_one.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_header_names_a_service_that_cannot_serve_the_path.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_and_a_service_header_disagree.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_is_requested_with_another_case_and_a_port.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_host_is_requested_without_a_selection.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_path_only_starts_with_the_prefix_characters.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_and_a_service_header_disagree.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_is_requested_on_another_host.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_prefix_is_requested_on_the_host_it_is_declared_for.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_request_matches_no_service.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_the_query_parameter_names_a_service.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/given/a_service_routing_validator.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_is_a_url.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_is_a_wildcard.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_covers_an_authproxy_path.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_covers_the_api_path.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_prefix_is_not_a_plain_path.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_routed_service_has_nowhere_to_route.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_nested_prefixes_are_on_different_hosts.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_one_prefix_is_nested_in_another.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_services_are_told_apart_by_host_and_prefix.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_stripping_is_asked_without_a_prefix.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_two_services_claim_the_same_host.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_two_services_declare_the_same_prefix.cs create mode 100644 Source/AuthProxy/ReverseProxy/PathPrefixTransform.cs create mode 100644 Source/AuthProxy/ReverseProxy/RoutedService.cs create mode 100644 Source/AuthProxy/ReverseProxy/ServiceRoutes.cs create mode 100644 Source/AuthProxy/ReverseProxy/ServiceRoutingConfigurationValidator.cs diff --git a/Documentation/configuration/authorization.md b/Documentation/configuration/authorization.md index 20b4f5ab..794a758a 100644 --- a/Documentation/configuration/authorization.md +++ b/Documentation/configuration/authorization.md @@ -132,9 +132,11 @@ 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 `Service-ID` header 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 `Service-ID` header 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 service, that +service's requirements apply anyway. Otherwise only the root requirements apply. --- diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index dd2a4fa3..06003b06 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). | @@ -62,22 +65,112 @@ 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 | |-----------|---------| | `Service-ID` request header | `Service-ID: portal` | | `service` query parameter | `?service=portal` | -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. A service with only a backend receives everything. + +### 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/" } + } + } + } + } +} +``` + +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 `Service-ID` header, 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 `Service-ID`, 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 +routing rules and those checks share one implementation, so a host or prefix cannot be used to reach a +service without meeting its requirements. + +#### 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 port. URLs, paths 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` (after any prefix a trusted proxy in front of AuthProxy already +forwarded). A backend that honors forwarded headers restores it as its path base, so links and redirects it +generates still point under `/reporting`. [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. --- 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..82c3dfb7 --- /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.Reports.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/given/ServiceRoutingHarness.cs b/Source/AuthProxy.Security.Specs/given/ServiceRoutingHarness.cs new file mode 100644 index 00000000..2de99168 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/ServiceRoutingHarness.cs @@ -0,0 +1,159 @@ +// 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.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() + { + 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(); + } + + /// 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 origin of the service reached only by name. + 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(); + Admin.Clear(); + Portal.Clear(); + } + + /// + /// Creates a client that surfaces redirects as responses rather than following them. + /// + /// A configured . + public HttpClient CreateSecurityClient() => + CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (!disposing) + { + return; + } + + Reports.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"] = Reports.BaseUrl, + + [$"{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: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_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/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/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..20cc5e6c --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_services_declare_hosts_and_path_prefixes.cs @@ -0,0 +1,76 @@ +// 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"], + }, + ["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_not_mark_a_prefix_that_is_kept() => Route("tenantreports-prefix").Metadata.ShouldBeNull(); + [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_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_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_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 09d1db05..0c187c65 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,27 @@ public bool TryResolveForTokenRequest( /// The incoming HTTP request. /// The resolved service configuration. /// if a service was resolved; otherwise . + /// + /// A service resolved from the token's route prefix and the request's service selection must also be the + /// service the route table forwards the request to. A host or path prefix can route a request to a + /// different service than the one its Service-ID header names, and a token scoped to the named + /// service must not authenticate a request that goes elsewhere. + /// 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) + if (!TryResolveCandidate(request, out service)) { - service = default!; return false; } - var requestedService = request.Headers[Headers.ServiceId].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(requestedService)) + var routed = ServiceRoutes.Resolve(request, config.CurrentValue); + if (routed is not null && !string.Equals(routed.Name, service.Name, StringComparison.OrdinalIgnoreCase)) { - 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; + return true; } static Uri? CreateVerificationUri(string baseUrl, string verificationPath) @@ -164,4 +147,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 = request.Headers[Headers.ServiceId].FirstOrDefault(); + 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 3a73a25e..853beb2d 100644 --- a/Source/AuthProxy/Authorization/AccessPolicy.cs +++ b/Source/AuthProxy/Authorization/AccessPolicy.cs @@ -2,6 +2,7 @@ // 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 C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.Authorization; @@ -20,11 +21,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 @@ -74,41 +70,37 @@ public AccessDecision Evaluate(HttpContext context, C.AuthProxy config) /// /// 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. /// /// This runs before endpoint selection — the gate has to refuse a caller before anything reads a /// backend, and long before YARP picks a route — so the target is worked out from the request rather - /// than from a selected endpoint. It mirrors MicroserviceReverseProxyConfigProvider exactly: a - /// single-service deployment routes everything to that service, and beyond that a service is named by - /// the Service-ID header or the service query parameter, header first. + /// than from a selected endpoint. states the route table's precedence once, so + /// the service whose requirements apply is always the service the request is forwarded to: a host or path + /// prefix that routes a request to a service also subjects it to that service's requirements. /// - /// A request in a multi-service deployment that names no service reaches no service route either, so - /// answering costs nothing: the root requirements still apply, and the request - /// goes on to match nothing. + /// A request that matches no service route is not forwarded at all. When it still names a service in the + /// Service-ID header or the service query parameter, that service's requirements + /// apply anyway — the stricter answer costs nothing for a request that goes nowhere. A request that names + /// none gets only the root requirements. /// /// - static C.Service? ResolveService(HttpContext context, C.AuthProxy config) - { - if (config.Services.Count == 1) - { - return config.Services.Values.First(); - } + static C.Service? ResolveService(HttpContext context, C.AuthProxy config) => + ServiceRoutes.Resolve(context.Request, config)?.Service ?? NamedService(context, config); + static C.Service? NamedService(HttpContext context, C.AuthProxy config) + { var serviceId = context.Request.Headers[Headers.ServiceId].FirstOrDefault(); if (string.IsNullOrWhiteSpace(serviceId)) { - serviceId = context.Request.Query[ServiceQueryParameter].FirstOrDefault(); - } - - if (string.IsNullOrWhiteSpace(serviceId)) - { - return null; + serviceId = context.Request.Query[ServiceRoutes.ServiceQueryParameter].FirstOrDefault(); } - return config.Services - .Where(_ => string.Equals(_.Key, serviceId.Trim(), StringComparison.OrdinalIgnoreCase)) - .Select(_ => _.Value) - .FirstOrDefault(); + return string.IsNullOrWhiteSpace(serviceId) + ? null + : config.Services + .Where(_ => string.Equals(_.Key, serviceId.Trim(), StringComparison.OrdinalIgnoreCase)) + .Select(_ => _.Value) + .FirstOrDefault(); } /// diff --git a/Source/AuthProxy/Configuration/Service.cs b/Source/AuthProxy/Configuration/Service.cs index 37b79981..6851bbb1 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 @@ -71,10 +109,11 @@ 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 Service-ID header or the - /// service query parameter. A request in a multi-service deployment that names no service - /// matches no service route either, so only the root requirements apply to it. + /// The service a request targets is resolved the way the route table resolves it: by + /// and , by the Service-ID header 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. /// /// public Authorization? Authorization { get; set; } diff --git a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs index ee74a61d..fc5f5b7c 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; static readonly ClusterConfig _baseCluster = new() { @@ -85,7 +103,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 @@ -100,6 +118,8 @@ static List BuildRoutes(C.AuthProxy config, ILogger logger) ReportRefusedAnonymousPaths(key, ms, logger); routes.AddRange(AnonymousRoutes(key, ms, claimedAnonymousPaths, logger)); + routes.AddRange(PathPrefixRoutes(key, ms)); + routes.AddRange(HostRoutes(key, ms)); if (ms.Backend is not null) { @@ -113,7 +133,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(); @@ -240,6 +261,13 @@ 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 prefix = ServiceRoutes.PathPrefixOf(service); + var stripsPrefix = service.StripPathPrefix + && prefix is not null + && new PathString(path).StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase); + yield return new RouteConfig { RouteId = $"{microserviceKey}-anonymous-{index}", @@ -247,12 +275,113 @@ static IEnumerable AnonymousRoutes( AuthorizationPolicy = AnonymousAuthorizationPolicy, Match = new RouteMatch { Path = $"{path}/{{**catch-all}}" }, Order = 0, + Metadata = stripsPrefix ? StripMetadata(prefix!) : null, }; index++; } } + /// + /// Builds the routes for a service's declared . + /// + /// The lower-cased service key. + /// 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 microserviceKey, C.Service service) + { + if (ServiceRoutes.PathPrefixOf(service) is not { } prefix) + { + yield break; + } + + var hosts = ServiceRoutes.HostsOf(service).Select(_ => _.Value!).ToArray(); + var onHosts = hosts.Length > 0; + var metadata = service.StripPathPrefix ? StripMetadata(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 lower-cased service key. + /// 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 microserviceKey, C.Service service) + { + 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", + 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", + 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 Dictionary StripMetadata(string prefix) => new() { [ServiceRoutes.StripPathPrefixMetadataKey] = prefix }; + static IEnumerable BackendRoutes(string microserviceKey, bool isSingle) { // Header-matched API route @@ -275,7 +404,7 @@ static IEnumerable BackendRoutes(string microserviceKey, bool isSin } ], }, - Order = 1, + Order = HeaderApiOrder, }; // Query-parameter–matched API route (adds the header for downstream). @@ -296,14 +425,14 @@ static IEnumerable BackendRoutes(string microserviceKey, bool isSin [ 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. @@ -342,7 +471,7 @@ static IEnumerable FrontendRoutes(string microserviceKey) } ], }, - Order = 10, + Order = HeaderOrder, }; // Query-parameter–matched frontend route, ordered behind the header-matched one for the same @@ -359,14 +488,14 @@ static IEnumerable FrontendRoutes(string microserviceKey) [ new RouteQueryParameter { - Name = "service", + Name = ServiceRoutes.ServiceQueryParameter, Mode = QueryParameterMatchMode.Exact, IsCaseSensitive = false, Values = [microserviceKey], } ], }, - Order = 11, + Order = QueryOrder, }; } diff --git a/Source/AuthProxy/ReverseProxy/PathPrefixTransform.cs b/Source/AuthProxy/ReverseProxy/PathPrefixTransform.cs new file mode 100644 index 00000000..006ea970 --- /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, so it stays correct when +/// AuthProxy itself is served below a prefix by a trusted proxy in front of it. +/// +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 6fa40949..c51995c1 100644 --- a/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs +++ b/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs @@ -2,7 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.AuthProxy.Identity; +using Microsoft.Extensions.Options; using Yarp.ReverseProxy.Configuration; +using C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.ReverseProxy; @@ -32,7 +34,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..a98c6bbe --- /dev/null +++ b/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs @@ -0,0 +1,201 @@ +// 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 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 before the route table runs. +/// +/// +/// The route table orders its service routes, and walks the same order: +/// +/// Anonymous paths (), which are not services' authenticated routes and are not resolved here. +/// Host and path prefix together ( and ). +/// Path prefix alone. +/// The Service-ID header, 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) + { + return false; + } + + var parsed = new HostString(trimmed.ToLowerInvariant()); + if (Uri.CheckHostName(parsed.Host.Trim('[', ']')) == UriHostNameType.Unknown + || parsed.Port is <= 0 or > 65535 + || (parsed.Port is null && trimmed.EndsWith(':'))) + { + return false; + } + + host = parsed; + 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 service a request targets, the same way the route table does. + /// + /// The request. + /// The configuration. + /// The targeted service, or when the request matches no service route. + public static RoutedService? Resolve(HttpRequest request, C.AuthProxy config) + { + 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; + + return ByPathPrefix(services, path, _ => HostsOf(_).Any(host => Matches(host, request))) + ?? ByPathPrefix(services, path, _ => _.Hosts.Count == 0) + ?? ByName(services, path, request.Headers[Headers.ServiceId], 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; + } + } + } + } +} From 321867f72d335b8b6438d46c7f746d999029a706 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 17:06:24 +0200 Subject: [PATCH 2/5] Fix authorization and token resolution for selected service routes --- Documentation/configuration/services.md | 28 ++++--- ...a_service_header_uses_yarp_value_syntax.cs | 47 +++++++++++ ...nonymous_api_is_under_a_stripped_prefix.cs | 26 ++++++ ...ices_are_routed_by_host_and_path_prefix.cs | 2 +- ..._streaming_protocols_use_service_routes.cs | 80 +++++++++++++++++++ .../given/RecordingBackend.cs | 17 ++++ .../given/ServiceRoutingHarness.cs | 30 ++++++- ...ish_services_with_the_same_token_prefix.cs | 58 ++++++++++++++ ...ervices_declare_hosts_and_path_prefixes.cs | 3 +- ...ry_cannot_be_parsed_by_endpoint_routing.cs | 22 +++++ .../ClientCredentialsServiceResolver.cs | 23 +++--- .../AuthProxy/Authorization/AccessPolicy.cs | 9 +-- .../MicroserviceReverseProxyConfigProvider.cs | 10 ++- .../ReverseProxy/PathPrefixTransform.cs | 4 +- .../AuthProxy/ReverseProxy/ServiceRoutes.cs | 26 ++++-- 15 files changed, 342 insertions(+), 43 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_ServiceRouting/when_a_service_header_uses_yarp_value_syntax.cs create mode 100644 Source/AuthProxy.Security.Specs/for_ServiceRouting/when_an_anonymous_api_is_under_a_stripped_prefix.cs create mode 100644 Source/AuthProxy.Security.Specs/for_ServiceRouting/when_streaming_protocols_use_service_routes.cs create mode 100644 Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_hosts_distinguish_services_with_the_same_token_prefix.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_cannot_be_parsed_by_endpoint_routing.cs diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 06003b06..b5a9ea99 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -82,7 +82,9 @@ path prefix (see below), or when the client names the service with one of: | `service` query parameter | `?service=portal` | Routes are matched case-insensitively. Within a service, `/api/...` goes to the backend and everything else -goes to the frontend. A service with only a backend receives everything. +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 @@ -104,7 +106,8 @@ sign-in, give each service a host, a path prefix, or both: "PathPrefix": "/reporting", "StripPathPrefix": true, "Frontend": { "BaseUrl": "http://reporting-web:3000/" }, - "Backend": { "BaseUrl": "http://reporting-api:8080/" } + "Backend": { "BaseUrl": "http://reporting-api:8080/" }, + "ClientCredentials": { "RoutePrefix": "/reporting/api" } } } } @@ -133,8 +136,9 @@ still call another service's backend by naming it in `Service-ID`, as Arc fronte 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 -routing rules and those checks share one implementation, so a host or prefix cannot be used to reach a -service without meeting its requirements. +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 @@ -144,8 +148,8 @@ AuthProxy refuses to start, and names the services involved, when: `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 port. URLs, paths and wildcards (`*.example.com`) are - refused; +- 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` @@ -160,8 +164,8 @@ prefix. In ASP.NET Core that is `app.UsePathBase("/reporting")`, and a single-pa same base path. With `StripPathPrefix` the prefix is removed: the service receives `/api/sales`, and AuthProxy sends the -removed prefix in `X-Forwarded-Prefix` (after any prefix a trusted proxy in front of AuthProxy already -forwarded). A backend that honors forwarded headers restores it as its path base, so links and redirects it +removed prefix in `X-Forwarded-Prefix`. AuthProxy does not consume an upstream proxy's +`X-Forwarded-Prefix` header. A backend that honors forwarded headers restores it as its path base, so links and redirects it generates still point under `/reporting`. [Anonymous paths](#anonymous-paths) below a stripped prefix are stripped too. Declare them with the full path, for example `/reporting/public`. @@ -254,7 +258,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 @@ -479,6 +484,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_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 index 82c3dfb7..10a0cb01 100644 --- 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 @@ -41,7 +41,7 @@ public async Task InitializeAsync() || harness.Portal.ReceivedAnythingFor("/api/orders"); await client.SendAsync(ServiceRoutingHarness.Request($"{ServiceRoutingHarness.ReportsPrefix}/assets/app.js")); - _prefixedAsset = harness.Reports.LastRequestTo("/assets/app.js"); + _prefixedAsset = harness.ReportsFrontend.LastRequestTo("/assets/app.js"); harness.ClearOrigins(); _adminHostWithoutClaim = await client.SendAsync(ServiceRoutingHarness.Request("/api/users", ServiceRoutingHarness.AdminHost)); 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 index 2de99168..4e8b961b 100644 --- a/Source/AuthProxy.Security.Specs/given/ServiceRoutingHarness.cs +++ b/Source/AuthProxy.Security.Specs/given/ServiceRoutingHarness.cs @@ -3,6 +3,8 @@ 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; @@ -43,12 +45,15 @@ public class ServiceRoutingHarness : WebApplicationFactory /// 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 . @@ -57,7 +62,10 @@ public ServiceRoutingHarness() /// Gets the origin of the service reached by . public RecordingBackend Admin { get; } - /// Gets the origin of the service reached only by name. + /// Gets the frontend origin of the reports service. + public RecordingBackend ReportsFrontend { get; } + + /// Gets the origin of the portal service. public RecordingBackend Portal { get; } /// @@ -89,6 +97,7 @@ public static HttpRequestMessage Request(string pathAndQuery, string? host = nul public void ClearOrigins() { Reports.Clear(); + ReportsFrontend.Clear(); Admin.Clear(); Portal.Clear(); } @@ -97,8 +106,18 @@ public void ClearOrigins() /// Creates a client that surfaces redirects as responses rather than following them. /// /// A configured . - public HttpClient CreateSecurityClient() => - CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); + 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) @@ -111,6 +130,7 @@ protected override void Dispose(bool disposing) } Reports.DisposeAsync().AsTask().GetAwaiter().GetResult(); + ReportsFrontend.DisposeAsync().AsTask().GetAwaiter().GetResult(); Admin.DisposeAsync().AsTask().GetAwaiter().GetResult(); Portal.DisposeAsync().AsTask().GetAwaiter().GetResult(); @@ -130,7 +150,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) [$"{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"] = 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, @@ -138,6 +159,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) [$"{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:Backend:BaseUrl"] = Portal.BaseUrl, [$"{C.AuthProxy.SectionKey}:Services:portal:Frontend:BaseUrl"] = Portal.BaseUrl, 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/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 index 20cc5e6c..a51dad2d 100644 --- 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 @@ -26,7 +26,7 @@ void Establish() StripPathPrefix = true, Backend = new C.ServiceEndpoint { BaseUrl = "http://reports-api/" }, Frontend = new C.ServiceEndpoint { BaseUrl = "http://reports-web/" }, - AnonymousPaths = ["/reports/public"], + AnonymousPaths = ["/reports/public", "/reports/api/health"], }, ["TenantReports"] = new() { @@ -62,6 +62,7 @@ void Establish() [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.ShouldBeNull(); [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() => 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..c9f67320 --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_a_host_entry_cannot_be_parsed_by_endpoint_routing.cs @@ -0,0 +1,22 @@ +// 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")] + public void should_refuse_the_configuration_at_startup(string host) + { + _services["one"] = Routable(host); + + Validate(); + + _result.Failed.ShouldBeTrue(); + } +} diff --git a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs index 0c187c65..8e69f59a 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs @@ -78,26 +78,23 @@ public bool TryResolveForTokenRequest( /// The resolved service configuration. /// if a service was resolved; otherwise . /// - /// A service resolved from the token's route prefix and the request's service selection must also be the - /// service the route table forwards the request to. A host or path prefix can route a request to a - /// different service than the one its Service-ID header names, and a token scoped to the named - /// service must not authenticate a request that goes elsewhere. + /// 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) { - if (!TryResolveCandidate(request, out service)) - { - return false; - } - var routed = ServiceRoutes.Resolve(request, config.CurrentValue); - if (routed is not null && !string.Equals(routed.Name, service.Name, StringComparison.OrdinalIgnoreCase)) + if (routed is null) { - service = default!; - return false; + return TryResolveCandidate(request, out service); } - return true; + service = GetConfiguredServices().FirstOrDefault(_ => + string.Equals(_.Name, routed.Name, StringComparison.OrdinalIgnoreCase) + && request.Path.StartsWithSegments(new PathString(_.RoutePrefix), StringComparison.OrdinalIgnoreCase))!; + + return service is not null; } static Uri? CreateVerificationUri(string baseUrl, string verificationPath) diff --git a/Source/AuthProxy/Authorization/AccessPolicy.cs b/Source/AuthProxy/Authorization/AccessPolicy.cs index 853beb2d..98fa922d 100644 --- a/Source/AuthProxy/Authorization/AccessPolicy.cs +++ b/Source/AuthProxy/Authorization/AccessPolicy.cs @@ -72,11 +72,10 @@ public AccessDecision Evaluate(HttpContext context, C.AuthProxy config) /// The auth proxy configuration to read. /// The targeted service, or when the request matches no service route. /// - /// This runs before endpoint selection — the gate has to refuse a caller before anything reads a - /// backend, and long before YARP picks a route — so the target is worked out from the request rather - /// than from a selected endpoint. states the route table's precedence once, so - /// the service whose requirements apply is always the service the request is forwarded to: a host or path - /// prefix that routes a request to a service also subjects it to that service's requirements. + /// This runs after endpoint selection but before forwarding. resolves the + /// selected proxy route's cluster first, so YARP's interpretation of service-selection headers cannot + /// forward a request to a service other than the one whose requirements apply. Without a selected proxy + /// endpoint, it falls back to the request's routing declarations. /// /// A request that matches no service route is not forwarded at all. When it still names a service in the /// Service-ID header or the service query parameter, that service's requirements diff --git a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs index fc5f5b7c..102862b2 100644 --- a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs +++ b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs @@ -239,7 +239,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), @@ -263,7 +270,6 @@ static IEnumerable AnonymousRoutes( // 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 prefix = ServiceRoutes.PathPrefixOf(service); var stripsPrefix = service.StripPathPrefix && prefix is not null && new PathString(path).StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase); diff --git a/Source/AuthProxy/ReverseProxy/PathPrefixTransform.cs b/Source/AuthProxy/ReverseProxy/PathPrefixTransform.cs index 006ea970..c908e4f6 100644 --- a/Source/AuthProxy/ReverseProxy/PathPrefixTransform.cs +++ b/Source/AuthProxy/ReverseProxy/PathPrefixTransform.cs @@ -12,8 +12,8 @@ namespace Cratis.AuthProxy.ReverseProxy; /// /// The removed prefix. /// -/// The announced prefix is the request's own path base followed by the removed prefix, so it stays correct when -/// AuthProxy itself is served below a prefix by a trusted proxy in front of it. +/// 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 { diff --git a/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs b/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs index a98c6bbe..1fe3c4d7 100644 --- a/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs +++ b/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs @@ -2,18 +2,20 @@ // 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 before the route table runs. +/// know which service a request targets. /// /// -/// The route table orders its service routes, and walks the same order: +/// After endpoint selection, uses the selected proxy cluster. Without a proxy endpoint, +/// it follows the declared route order: /// -/// Anonymous paths (), which are not services' authenticated routes and are not resolved here. +/// 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 Service-ID header, then the service query parameter. @@ -73,7 +75,8 @@ public static bool TryParseHost(string? candidate, out HostString host) { host = default; var trimmed = candidate?.Trim() ?? string.Empty; - if (trimmed.Length == 0 || trimmed.IndexOfAny(['/', '*', '?', '#', '@', ' ']) >= 0) + if (trimmed.Length == 0 || trimmed.IndexOfAny(['/', '*', '?', '#', '@', ' ', '[', ']']) >= 0 + || trimmed.Count(_ => _ == ':') > 1) { return false; } @@ -81,7 +84,7 @@ public static bool TryParseHost(string? candidate, out HostString host) var parsed = new HostString(trimmed.ToLowerInvariant()); if (Uri.CheckHostName(parsed.Host.Trim('[', ']')) == UriHostNameType.Unknown || parsed.Port is <= 0 or > 65535 - || (parsed.Port is null && trimmed.EndsWith(':'))) + || (parsed.Port is null && trimmed.Contains(':'))) { return false; } @@ -145,13 +148,24 @@ public static bool UsesSingleServiceDefaults(C.AuthProxy config) => && string.IsNullOrWhiteSpace(service.PathPrefix); /// - /// Resolves the service a request targets, the same way the route table does. + /// 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}-backend-cluster", StringComparison.OrdinalIgnoreCase) + || string.Equals(route.Config.ClusterId, $"{_.Key}-frontend-cluster", StringComparison.OrdinalIgnoreCase)) + .Select(_ => new RoutedService(_.Key, _.Value)) + .FirstOrDefault(); + } + if (config.Services.Count == 1) { // Every route in a single-service table leads to that service. From 2a0808822539d73d188887d1291ef441c50b5cb5 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 18:18:36 +0200 Subject: [PATCH 3/5] Fail closed when selected proxy clusters cannot resolve a service --- .../given/a_selected_proxy_route.cs | 56 +++++++++++++++++++ ...ckend_cluster_has_a_unicode_service_key.cs | 14 +++++ ...ected_cluster_has_no_configured_service.cs | 13 +++++ ...ntend_cluster_has_a_unicode_service_key.cs | 18 ++++++ ..._service_has_its_requirements_satisfied.cs | 17 ++++++ ...cluster_also_names_a_configured_service.cs | 18 ++++++ .../AuthProxy/Authorization/AccessPolicy.cs | 28 +++++----- .../AuthProxy/ReverseProxy/ServiceRoutes.cs | 4 +- 8 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 Source/AuthProxy.Specs/Authorization/for_AccessPolicy/given/a_selected_proxy_route.cs create mode 100644 Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_backend_cluster_has_a_unicode_service_key.cs create mode 100644 Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_cluster_has_no_configured_service.cs create mode 100644 Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_frontend_cluster_has_a_unicode_service_key.cs create mode 100644 Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_selected_unicode_service_has_its_requirements_satisfied.cs create mode 100644 Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_an_unmapped_selected_cluster_also_names_a_configured_service.cs 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_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/Authorization/AccessPolicy.cs b/Source/AuthProxy/Authorization/AccessPolicy.cs index 98fa922d..0a210853 100644 --- a/Source/AuthProxy/Authorization/AccessPolicy.cs +++ b/Source/AuthProxy/Authorization/AccessPolicy.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using Cratis.AuthProxy.ReverseProxy; +using Yarp.ReverseProxy.Model; using C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.Authorization; @@ -29,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)) { @@ -43,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; @@ -66,26 +75,17 @@ 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 matches no service route. /// - /// This runs after endpoint selection but before forwarding. resolves the - /// selected proxy route's cluster first, so YARP's interpretation of service-selection headers cannot - /// forward a request to a service other than the one whose requirements apply. Without a selected proxy - /// endpoint, it falls back to the request's routing declarations. - /// /// A request that matches no service route is not forwarded at all. When it still names a service in the /// Service-ID header or the service query parameter, that service's requirements /// apply anyway — the stricter answer costs nothing for a request that goes nowhere. A request that names /// none gets only the root requirements. - /// /// - static C.Service? ResolveService(HttpContext context, C.AuthProxy config) => - ServiceRoutes.Resolve(context.Request, config)?.Service ?? NamedService(context, config); - static C.Service? NamedService(HttpContext context, C.AuthProxy config) { var serviceId = context.Request.Headers[Headers.ServiceId].FirstOrDefault(); diff --git a/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs b/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs index 1fe3c4d7..7dad7a9c 100644 --- a/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs +++ b/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs @@ -160,8 +160,8 @@ public static bool UsesSingleServiceDefaults(C.AuthProxy config) => if (request.HttpContext.GetEndpoint()?.Metadata.GetMetadata() is { } route) { return config.Services - .Where(_ => string.Equals(route.Config.ClusterId, $"{_.Key}-backend-cluster", StringComparison.OrdinalIgnoreCase) - || string.Equals(route.Config.ClusterId, $"{_.Key}-frontend-cluster", StringComparison.OrdinalIgnoreCase)) + .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(); } From ed2dd224e6b50d3c18a2ad273e1de1c76f6cc616 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 18:18:36 +0200 Subject: [PATCH 4/5] Clarify forwarded prefix replacement and backend path base --- Documentation/configuration/services.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index b5a9ea99..258cdb43 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -164,9 +164,9 @@ prefix. In ASP.NET Core that is `app.UsePathBase("/reporting")`, and a single-pa same base path. With `StripPathPrefix` the prefix is removed: the service receives `/api/sales`, and AuthProxy sends the -removed prefix in `X-Forwarded-Prefix`. AuthProxy does not consume an upstream proxy's -`X-Forwarded-Prefix` header. A backend that honors forwarded headers restores it as its path base, so links and redirects it -generates still point under `/reporting`. [Anonymous paths](#anonymous-paths) below a stripped prefix are +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 From 11cfb6deef9494fd3ab13333a8401a5ac9aa1545 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 21:38:35 +0200 Subject: [PATCH 5/5] Normalize internationalized routing hosts (#150) --- .../when_a_punycode_host_is_requested.cs | 34 +++++++++++++++++++ .../given/ServiceRoutingHarness.cs | 1 + ...ervices_declare_hosts_and_path_prefixes.cs | 5 ++- .../when_a_punycode_host_is_requested.cs | 19 +++++++++++ ...ry_cannot_be_parsed_by_endpoint_routing.cs | 2 ++ ...e_equivalent_unicode_and_punycode_hosts.cs | 18 ++++++++++ .../AuthProxy/ReverseProxy/ServiceRoutes.cs | 14 ++++++-- 7 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 Source/AuthProxy.Security.Specs/for_ServiceRouting/when_a_punycode_host_is_requested.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutes/when_a_punycode_host_is_requested.cs create mode 100644 Source/AuthProxy.Specs/ReverseProxy/for_ServiceRoutingConfigurationValidator/when_services_declare_equivalent_unicode_and_punycode_hosts.cs 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/given/ServiceRoutingHarness.cs b/Source/AuthProxy.Security.Specs/given/ServiceRoutingHarness.cs index 4e8b961b..a1342406 100644 --- a/Source/AuthProxy.Security.Specs/given/ServiceRoutingHarness.cs +++ b/Source/AuthProxy.Security.Specs/given/ServiceRoutingHarness.cs @@ -160,6 +160,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) [$"{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, 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 index a51dad2d..5d03a8f4 100644 --- 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 @@ -63,7 +63,10 @@ void Establish() [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.ShouldBeNull(); + [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(_ => ( 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_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 index c9f67320..97880656 100644 --- 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 @@ -11,6 +11,8 @@ public class when_a_host_entry_cannot_be_parsed_by_endpoint_routing : given.a_se [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); 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/ReverseProxy/ServiceRoutes.cs b/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs index 46517d42..a3912bb0 100644 --- a/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs +++ b/Source/AuthProxy/ReverseProxy/ServiceRoutes.cs @@ -81,7 +81,17 @@ public static bool TryParseHost(string? candidate, out HostString host) return false; } - var parsed = new HostString(trimmed.ToLowerInvariant()); + 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(':'))) @@ -89,7 +99,7 @@ public static bool TryParseHost(string? candidate, out HostString host) return false; } - host = parsed; + host = new HostString(parsed.Value!.ToLowerInvariant()); return true; }