diff --git a/Documentation/aspire/index.md b/Documentation/aspire/index.md index 6f05db91..79386151 100644 --- a/Documentation/aspire/index.md +++ b/Documentation/aspire/index.md @@ -131,6 +131,20 @@ identity headers on these paths and the application remains responsible for auth See [Anonymous paths](../configuration/services.md#anonymous-paths) for the full matching rules and what the flag does and does not change. +### Activity timeout + +A proxied request that sits idle — no bytes in either direction — for five minutes is cancelled, which +also ends a quiet WebSocket or Server-Sent Events stream. Raise the limit for every service, or for one: + +```csharp +authproxy.WithActivityTimeout(TimeSpan.FromMinutes(10)); +authproxy.WithServiceActivityTimeout("reporting", TimeSpan.FromMinutes(2)); +``` + +The service value wins over the global one. See +[Timeouts and streaming](../configuration/services.md#timeouts-and-streaming) for what a stream needs +beyond the timeout. + ### Trusted proxies Declare the peers directly in front of AuthProxy, so their `X-Forwarded-For` and `X-Forwarded-Proto` diff --git a/Documentation/configuration/index.md b/Documentation/configuration/index.md index 4a477fe1..a8f78b00 100644 --- a/Documentation/configuration/index.md +++ b/Documentation/configuration/index.md @@ -17,6 +17,7 @@ Cratis AuthProxy is configured entirely through the `Cratis:AuthProxy` section o "Ingress": { ... }, "Invite": { ... }, "Management": { ... }, + "ActivityTimeout": "00:05:00", "PagesPath": "", "DataProtectionKeysPath": "" } @@ -32,7 +33,7 @@ Cratis AuthProxy is configured entirely through the `Cratis:AuthProxy` section o | [Tenancy](tenancy.md) | How the auth proxy resolves the current tenant from each request, and how to verify tenant existence. | | [Tenant Selection Page](tenant-selection.md) | How selection-based tenant resolution works and how to build/override `select-tenant.html`. | | [Trusted Proxies](trusted-proxies.md) | Which callers may speak for the client through `X-Forwarded-For` and `X-Forwarded-Proto`, and how many hops to follow. | -| [Services](services.md) | Routing requests to backend and frontend services. | +| [Services](services.md) | Routing requests to backend and frontend services, and the idle timeout that applies to proxied requests, WebSockets and SSE streams. | | [Management Listener](management-listener.md) | An opt-in private listener carrying liveness and readiness endpoints, so a probe tests more than "a process accepted a socket". | | [Lobby](lobby/index.md) | Invite and registration flows that hand users off to the lobby experience. | | [Well-Known Pages](well-known-pages.md) | Built-in HTML pages (provider selection, errors, tenant not found) and how to override them via a mounted volume. | diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index d567022e..3db46e10 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -40,6 +40,7 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n | `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). | +| `ActivityTimeout` | `TimeSpan` | The root `ActivityTimeout`, then `00:05:00` | How long a request proxied to this service may sit idle before AuthProxy cancels it. See [Timeouts and streaming](#timeouts-and-streaming). | | `AnonymousPaths` | `string[]` | `[]` | Path prefixes on this service served to unauthenticated callers. See [Anonymous paths](#anonymous-paths). | | `ClientCredentials` | `ServiceClientCredentialsConfig` | `null` | Enables back-channel client-credentials verification and token minting for this service. | @@ -48,6 +49,7 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n | Property | Type | Description | |----------|------|-------------| | `BaseUrl` | `string` | Base URL of the endpoint (e.g. `http://my-service:8080/`). | +| `ActivityTimeout` | `TimeSpan` | Idle limit for this endpoint alone (`Backend` or `Frontend`). Overrides the service and root values. See [Timeouts and streaming](#timeouts-and-streaming). | ### ServiceClientCredentialsConfig properties @@ -93,6 +95,87 @@ Routes are matched case-insensitively. --- +## Timeouts and streaming + +Everything AuthProxy forwards — a plain request, a WebSocket session, a Server-Sent Events (SSE) stream — +is subject to one limit, the **activity timeout**: the longest a proxied request may sit idle, with no bytes +moving in either direction, before AuthProxy cancels it. The clock restarts whenever data is read or +written, so it is an idle limit, not a cap on how long a connection may live. The default is five minutes. + +Set it in three places. The most specific one that is set wins: + +| Setting | Applies to | +|---------|------------| +| `Cratis:AuthProxy:Services:::ActivityTimeout` | That endpoint only. | +| `Cratis:AuthProxy:Services::ActivityTimeout` | Both endpoints of that service. | +| `Cratis:AuthProxy:ActivityTimeout` | Every endpoint that states nothing narrower. | + +```json +{ + "Cratis": { + "AuthProxy": { + "ActivityTimeout": "00:10:00", + "Services": { + "portal": { + "Backend": { "BaseUrl": "http://portal-api:8080/", "ActivityTimeout": "01:00:00" }, + "Frontend": { "BaseUrl": "http://portal-web:3000/" } + }, + "reporting": { + "Backend": { "BaseUrl": "http://reporting-api:8080/" }, + "ActivityTimeout": "00:02:00" + } + } + } + } +} +``` + +Here `portal`'s backend allows an hour of silence, its frontend and anything not listed allow ten minutes, +and `reporting` allows two. As environment variables the root value is `Cratis__AuthProxy__ActivityTimeout` +and a service's is `Cratis__AuthProxy__Services__portal__ActivityTimeout`. + +A value must be at least one millisecond and at most 2,147,483,647 milliseconds (about 24 days). +AuthProxy refuses to start when a value is outside this range, and the message names the setting. +`Registration` is not a proxied endpoint: setting `Registration:ActivityTimeout` also prevents startup. +Remove that setting and configure the service's `Backend` or `Frontend` activity timeout instead. +`Invite:Lobby` does not create proxy clusters: setting `ActivityTimeout` on the lobby itself or its +`Backend`, `Frontend` or `Registration` endpoints also prevents startup with a message naming the setting. +Remove those settings and configure the proxied service under `Services` instead. +A change to the configuration file is applied to new requests without a restart. + +From Aspire: + +```csharp +authproxy.WithActivityTimeout(TimeSpan.FromMinutes(10)); +authproxy.WithServiceActivityTimeout("reporting", TimeSpan.FromMinutes(2)); +``` + +### WebSocket and Server-Sent Events + +AuthProxy forwards a WebSocket upgrade or an SSE response to the service as-is and does not buffer, compress +or rewrite the stream, so each message reaches the client as soon as the service writes and flushes it. What +to know: + +- **A quiet stream is cut.** If the service sends nothing for longer than the activity timeout, and the + client sends nothing either, AuthProxy cancels the request and the client sees the connection drop. For a + live-update feature such as an observable query, either send a heartbeat (an SSE comment line such as + `: ping`, or a WebSocket ping) more often than the timeout, or raise the timeout above the longest silence + you expect. A heartbeat at a third to a half of the timeout leaves room for one lost beat. +- **A stream is authorized when it opens.** The session cookie and any access policy are evaluated on the + upgrade or SSE request, and the identity headers are attached to it. Nothing is re-checked while the + stream stays open, so a user whose access is revoked keeps a connection that is already open until it + closes. Keep streams bounded, or have the service close them periodically, if that window matters. +- **Whatever sits in front of AuthProxy has its own idle limit.** A load balancer, gateway or hosting + platform in front of AuthProxy applies its own timeout, and the shortest limit on the path wins. Raising + the AuthProxy value alone does not help if the platform cuts the connection first, so align the two, or + rely on heartbeats that are more frequent than both. +- **The same applies behind AuthProxy.** A service or sidecar between AuthProxy and the application may + have an idle limit of its own. +- **Reconnect on the client.** Browsers reconnect an `EventSource` on their own; a WebSocket client needs + its own reconnect logic. A dropped stream is the normal way an idle one ends. + +--- + ## Anonymous paths By default every path behind AuthProxy requires a session. An unauthenticated request is answered by diff --git a/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_activity_timeouts.cs b/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_activity_timeouts.cs new file mode 100644 index 00000000..ea6b2124 --- /dev/null +++ b/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_activity_timeouts.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.Aspire.for_AuthProxyExtensions; + +public class when_declaring_activity_timeouts : given.an_auth_proxy_resource +{ + Dictionary _environment; + + void Establish() + { + _resource.WithActivityTimeout(TimeSpan.FromMinutes(30)); + _resource.WithServiceActivityTimeout("streams", TimeSpan.FromHours(2)); + } + + async Task Because() => _environment = await EnvironmentVariables(); + + [Fact] void should_declare_the_global_timeout() => _environment["Cratis__AuthProxy__ActivityTimeout"].ShouldEqual("00:30:00"); + [Fact] void should_declare_the_service_timeout() => _environment["Cratis__AuthProxy__Services__streams__ActivityTimeout"].ShouldEqual("02:00:00"); +} diff --git a/Source/Aspire/AuthProxyExtensions.cs b/Source/Aspire/AuthProxyExtensions.cs index 5175eb34..459fa086 100644 --- a/Source/Aspire/AuthProxyExtensions.cs +++ b/Source/Aspire/AuthProxyExtensions.cs @@ -126,6 +126,46 @@ public static IResourceBuilder WithIdentityVerification( return builder; } + /// + /// Sets how long a proxied request may sit idle before AuthProxy cancels it, for every service. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The longest a request may go with no bytes moving in either direction. Must be greater than zero. + /// The same for chaining. + /// + /// The limit applies to WebSocket and Server-Sent Events streams as much as to plain requests, so a stream + /// whose backend can stay quiet for longer than this is cut. Leave it alone to keep the five-minute default. + /// Use to give one service a different limit. + /// + public static IResourceBuilder WithActivityTimeout( + this IResourceBuilder builder, + TimeSpan timeout) + where T : IResourceWithEnvironment => + builder.WithEnvironment( + $"{ConfigPrefix}__ActivityTimeout", + timeout.ToString("c", CultureInfo.InvariantCulture)); + + /// + /// Sets how long a request proxied to one service may sit idle before AuthProxy cancels it. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The service the limit applies to. + /// The longest a request may go with no bytes moving in either direction. Must be greater than zero. + /// The same for chaining. + /// + /// Takes precedence over for that service's backend and frontend. + /// + public static IResourceBuilder WithServiceActivityTimeout( + this IResourceBuilder builder, + string serviceName, + TimeSpan timeout) + where T : IResourceWithEnvironment => + builder.WithEnvironment( + $"{ConfigPrefix}__Services__{serviceName}__ActivityTimeout", + timeout.ToString("c", CultureInfo.InvariantCulture)); + /// /// Terminates the local AuthProxy session whenever identity verification refuses a caller. /// diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/given/a_streaming_deployment.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/given/a_streaming_deployment.cs new file mode 100644 index 00000000..9eed9b1d --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/given/a_streaming_deployment.cs @@ -0,0 +1,184 @@ +// 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; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.ReverseProxy.for_ActivityTimeout.given; + +/// +/// A real AuthProxy reverse proxy in front of a real streaming origin, both on loopback sockets. +/// +/// +/// Real sockets because the activity timeout is enforced by YARP while it copies bytes between two live +/// connections. An in-memory test server would never exercise that copy loop, so a stream could be asserted +/// as "configured" and still be cut in a deployment. +/// +/// The origin stays quiet for between its first and second message. A spec sets the +/// proxy's activity timeout below or above that silence and observes whether the second message arrives. +/// +/// +public class a_streaming_deployment : Specification +{ + /// The time the origin says nothing between its two messages. + protected static readonly TimeSpan Silence = TimeSpan.FromSeconds(2); + + /// The second and final message, which only arrives if the stream was not cut. + protected const string FinalMessage = "final"; + + WebApplication _origin; + WebApplication _proxy; + + /// Gets the proxy's base address. + protected string ProxyAddress { get; private set; } + + /// + /// Starts the origin and a proxy whose root activity timeout is . + /// + /// The idle limit the proxy is configured with. + /// A task representing the asynchronous operation. + protected async Task StartWith(TimeSpan activityTimeout) + { + _origin = await StartOrigin(); + var originAddress = AddressOf(_origin); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + builder.Services.Configure(options => + { + options.ActivityTimeout = activityTimeout; + options.Services = new Dictionary + { + ["App"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = originAddress }, + AnonymousPaths = ["/api/stream"], + }, + }; + }); + builder.SetupReverseProxy(); + + _proxy = builder.Build(); + _proxy.UseReverseProxy(); + await _proxy.StartAsync(); + ProxyAddress = AddressOf(_proxy); + } + + /// + /// Reads a Server-Sent Events stream through the proxy until it ends, however it ends. + /// + /// Everything received before the stream ended or was cut. + protected async Task ReadServerSentEvents() + { + var received = new StringBuilder(); + + try + { + using var client = new HttpClient(); + using var response = await client.GetAsync( + $"{ProxyAddress}api/stream/sse", + HttpCompletionOption.ResponseHeadersRead); + await using var stream = await response.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(stream); + + string? line; + while ((line = await reader.ReadLineAsync()) is not null) + { + received.AppendLine(line); + } + } + catch (Exception ex) when (ex is HttpRequestException or IOException or WebSocketException) + { + // A stream cut by the proxy surfaces as a broken read; what arrived before it is the observation. + } + + return received.ToString(); + } + + /// + /// Reads a WebSocket session through the proxy until it closes, however it closes. + /// + /// Every text message received before the session ended or was cut. + protected async Task> ReadWebSocketMessages() + { + var messages = new List(); + using var socket = new ClientWebSocket(); + + try + { + await socket.ConnectAsync(new Uri($"ws://{new Uri(ProxyAddress).Authority}/api/stream/ws"), CancellationToken.None); + + var buffer = new byte[256]; + while (socket.State == WebSocketState.Open) + { + var result = await socket.ReceiveAsync(buffer, CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) + { + break; + } + + messages.Add(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } + } + catch (WebSocketException) + { + // A session cut by the proxy surfaces as an aborted socket; what arrived before it is the observation. + } + + return messages; + } + + static string AddressOf(WebApplication app) => + app.Services.GetRequiredService().Features.Get()!.Addresses.First().TrimEnd('/') + "/"; + + static async Task StartOrigin() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Logging.ClearProviders(); + + var app = builder.Build(); + app.UseWebSockets(); + + app.Map("/api/stream/sse", async context => + { + context.Response.ContentType = "text/event-stream"; + await context.Response.WriteAsync("data: first\n\n"); + await context.Response.Body.FlushAsync(); + await Task.Delay(Silence); + await context.Response.WriteAsync($"data: {FinalMessage}\n\n"); + await context.Response.Body.FlushAsync(); + }); + + app.Map("/api/stream/ws", async context => + { + using var socket = await context.WebSockets.AcceptWebSocketAsync(); + await socket.SendAsync(Encoding.UTF8.GetBytes("first"), WebSocketMessageType.Text, true, CancellationToken.None); + await Task.Delay(Silence); + await socket.SendAsync(Encoding.UTF8.GetBytes(FinalMessage), WebSocketMessageType.Text, true, CancellationToken.None); + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None); + }); + + await app.StartAsync(); + return app; + } + + async Task Destroy() + { + if (_proxy is not null) + { + await _proxy.DisposeAsync(); + } + + if (_origin is not null) + { + await _origin.DisposeAsync(); + } + } +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_server_sent_events_stream_is_quiet_for_less_than_the_timeout.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_server_sent_events_stream_is_quiet_for_less_than_the_timeout.cs new file mode 100644 index 00000000..60cddaaf --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_server_sent_events_stream_is_quiet_for_less_than_the_timeout.cs @@ -0,0 +1,16 @@ +// 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_ActivityTimeout; + +public class when_a_server_sent_events_stream_is_quiet_for_less_than_the_timeout : given.a_streaming_deployment +{ + string _received; + + async Task Establish() => await StartWith(TimeSpan.FromSeconds(30)); + + async Task Because() => _received = await ReadServerSentEvents(); + + [Fact] void should_deliver_what_arrived_before_the_silence() => _received.ShouldContain("first"); + [Fact] void should_keep_the_stream_open_until_the_next_message() => _received.ShouldContain(FinalMessage); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_server_sent_events_stream_is_quiet_for_longer_than_the_timeout.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_server_sent_events_stream_is_quiet_for_longer_than_the_timeout.cs new file mode 100644 index 00000000..cf297bc9 --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_server_sent_events_stream_is_quiet_for_longer_than_the_timeout.cs @@ -0,0 +1,16 @@ +// 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_ActivityTimeout; + +public class when_a_server_sent_events_stream_is_quiet_for_longer_than_the_timeout : given.a_streaming_deployment +{ + string _received; + + async Task Establish() => await StartWith(TimeSpan.FromMilliseconds(500)); + + async Task Because() => _received = await ReadServerSentEvents(); + + [Fact] void should_deliver_what_arrived_before_the_silence() => _received.ShouldContain("first"); + [Fact] void should_cut_the_stream_before_the_next_message() => _received.ShouldNotContain(FinalMessage); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_web_socket_is_quiet_for_less_than_the_timeout.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_web_socket_is_quiet_for_less_than_the_timeout.cs new file mode 100644 index 00000000..3237ec4e --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_web_socket_is_quiet_for_less_than_the_timeout.cs @@ -0,0 +1,16 @@ +// 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_ActivityTimeout; + +public class when_a_web_socket_is_quiet_for_less_than_the_timeout : given.a_streaming_deployment +{ + IReadOnlyList _messages; + + async Task Establish() => await StartWith(TimeSpan.FromSeconds(30)); + + async Task Because() => _messages = await ReadWebSocketMessages(); + + [Fact] void should_deliver_what_arrived_before_the_silence() => _messages.ShouldContain("first"); + [Fact] void should_keep_the_session_open_until_the_next_message() => _messages.ShouldContain(FinalMessage); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_web_socket_is_quiet_for_longer_than_the_timeout.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_web_socket_is_quiet_for_longer_than_the_timeout.cs new file mode 100644 index 00000000..fb44bdb9 --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/when_a_web_socket_is_quiet_for_longer_than_the_timeout.cs @@ -0,0 +1,16 @@ +// 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_ActivityTimeout; + +public class when_a_web_socket_is_quiet_for_longer_than_the_timeout : given.a_streaming_deployment +{ + IReadOnlyList _messages; + + async Task Establish() => await StartWith(TimeSpan.FromMilliseconds(500)); + + async Task Because() => _messages = await ReadWebSocketMessages(); + + [Fact] void should_deliver_what_arrived_before_the_silence() => _messages.ShouldContain("first"); + [Fact] void should_cut_the_session_before_the_next_message() => _messages.ShouldNotContain(FinalMessage); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_lobby_has_no_timeouts.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_lobby_has_no_timeouts.cs new file mode 100644 index 00000000..efdef685 --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_lobby_has_no_timeouts.cs @@ -0,0 +1,27 @@ +// 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_ActivityTimeoutConfigurationValidator; + +public class when_a_lobby_has_no_timeouts : Specification +{ + ValidateOptionsResult _result; + + void Because() => _result = new ActivityTimeoutConfigurationValidator().Validate( + null, + new C.AuthProxy + { + ActivityTimeout = TimeSpan.FromMinutes(1), + Invite = new C.Invite + { + Lobby = new C.Service + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.local/" }, + Frontend = new C.ServiceEndpoint { BaseUrl = "https://frontend.local/" }, + Registration = new C.ServiceEndpoint { BaseUrl = "https://registration.local/" }, + }, + }, + }); + + [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_registration_timeout_is_stated.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_registration_timeout_is_stated.cs new file mode 100644 index 00000000..2155c02b --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_registration_timeout_is_stated.cs @@ -0,0 +1,28 @@ +// 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_ActivityTimeoutConfigurationValidator; + +public class when_a_registration_timeout_is_stated : Specification +{ + ValidateOptionsResult _result; + + void Because() => _result = new ActivityTimeoutConfigurationValidator().Validate( + null, + new C.AuthProxy + { + Services = new Dictionary + { + ["Portal"] = new() + { + Registration = new C.ServiceEndpoint { BaseUrl = "https://registration.local/", ActivityTimeout = TimeSpan.FromMinutes(1) }, + }, + }, + }); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); + [Fact] void should_report_one_failure() => _result.Failures.Count().ShouldEqual(1); + [Fact] void should_name_the_registration_setting() => _result.FailureMessage!.ShouldContain($"{C.AuthProxy.SectionKey}:Services:Portal:Registration:ActivityTimeout "); + [Fact] void should_explain_the_supported_endpoints() => _result.FailureMessage!.ShouldContain("ActivityTimeout only applies to Backend and Frontend endpoints, not Registration."); + [Fact] void should_explain_how_to_correct_the_setting() => _result.FailureMessage!.ShouldContain("Remove this setting."); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_timeout_is_below_one_millisecond.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_timeout_is_below_one_millisecond.cs new file mode 100644 index 00000000..d449fd0f --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_timeout_is_below_one_millisecond.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_ActivityTimeoutConfigurationValidator; + +public class when_a_timeout_is_below_one_millisecond : Specification +{ + ValidateOptionsResult _result; + + void Because() => _result = new ActivityTimeoutConfigurationValidator().Validate( + null, + new C.AuthProxy { ActivityTimeout = TimeSpan.FromTicks(TimeSpan.TicksPerMillisecond / 2) }); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); + [Fact] void should_name_the_setting() => _result.FailureMessage!.ShouldContain($"{C.AuthProxy.SectionKey}:ActivityTimeout "); + [Fact] void should_explain_the_minimum() => _result.FailureMessage!.ShouldContain("less than one millisecond"); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_timeout_is_thirty_days.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_timeout_is_thirty_days.cs new file mode 100644 index 00000000..57a8a8db --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_a_timeout_is_thirty_days.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_ActivityTimeoutConfigurationValidator; + +public class when_a_timeout_is_thirty_days : Specification +{ + ValidateOptionsResult _result; + + void Because() => _result = new ActivityTimeoutConfigurationValidator().Validate( + null, + new C.AuthProxy { ActivityTimeout = TimeSpan.FromDays(30) }); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); + [Fact] void should_name_the_setting() => _result.FailureMessage!.ShouldContain($"{C.AuthProxy.SectionKey}:ActivityTimeout "); + [Fact] void should_explain_the_maximum() => _result.FailureMessage!.ShouldContain("the proxy can schedule"); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_every_timeout_is_positive.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_every_timeout_is_positive.cs new file mode 100644 index 00000000..7ecaa61a --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_every_timeout_is_positive.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_ActivityTimeoutConfigurationValidator; + +public class when_every_timeout_is_positive : Specification +{ + ValidateOptionsResult _result; + + void Because() => _result = new ActivityTimeoutConfigurationValidator().Validate( + null, + new C.AuthProxy + { + ActivityTimeout = TimeSpan.FromMinutes(10), + Services = new Dictionary + { + ["App"] = new() + { + ActivityTimeout = TimeSpan.FromHours(1), + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.local/", ActivityTimeout = TimeSpan.FromHours(24) }, + }, + }, + }); + + [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_lobby_timeouts_are_stated.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_lobby_timeouts_are_stated.cs new file mode 100644 index 00000000..af638a73 --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_lobby_timeouts_are_stated.cs @@ -0,0 +1,36 @@ +// 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.Configuration; + +namespace Cratis.AuthProxy.ReverseProxy.for_ActivityTimeoutConfigurationValidator; + +public class when_lobby_timeouts_are_stated : Specification +{ + C.AuthProxy _options; + ValidateOptionsResult _result; + + void Establish() + { + var prefix = $"{C.AuthProxy.SectionKey}:Invite:Lobby"; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + [$"{prefix}:ActivityTimeout"] = "00:01:00", + [$"{prefix}:Backend:ActivityTimeout"] = "00:02:00", + [$"{prefix}:Frontend:ActivityTimeout"] = "00:03:00", + [$"{prefix}:Registration:ActivityTimeout"] = "00:04:00", + }).Build(); + _options = configuration.GetSection(C.AuthProxy.SectionKey).Get()!; + } + + void Because() => _result = new ActivityTimeoutConfigurationValidator().Validate(null, _options); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); + [Fact] void should_report_one_failure_per_setting() => _result.Failures.Count().ShouldEqual(4); + [Fact] void should_name_the_lobby_setting() => _result.FailureMessage!.ShouldContain($"{C.AuthProxy.SectionKey}:Invite:Lobby:ActivityTimeout is not supported."); + [Fact] void should_name_the_backend_setting() => _result.FailureMessage!.ShouldContain($"{C.AuthProxy.SectionKey}:Invite:Lobby:Backend:ActivityTimeout is not supported."); + [Fact] void should_name_the_frontend_setting() => _result.FailureMessage!.ShouldContain($"{C.AuthProxy.SectionKey}:Invite:Lobby:Frontend:ActivityTimeout is not supported."); + [Fact] void should_name_the_registration_setting() => _result.FailureMessage!.ShouldContain($"{C.AuthProxy.SectionKey}:Invite:Lobby:Registration:ActivityTimeout is not supported."); + [Fact] void should_explain_why_the_settings_are_unsupported() => _result.Failures.All(failure => failure.Contains("Invite:Lobby does not create proxy clusters, so ActivityTimeout cannot apply.", StringComparison.Ordinal)).ShouldBeTrue(); + [Fact] void should_explain_how_to_correct_the_settings() => _result.Failures.All(failure => failure.Contains("Remove this setting and configure the proxied service under Services instead.", StringComparison.Ordinal)).ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_no_timeout_is_stated.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_no_timeout_is_stated.cs new file mode 100644 index 00000000..7990396b --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_no_timeout_is_stated.cs @@ -0,0 +1,25 @@ +// 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_ActivityTimeoutConfigurationValidator; + +public class when_no_timeout_is_stated : Specification +{ + ValidateOptionsResult _result; + + void Because() => _result = new ActivityTimeoutConfigurationValidator().Validate( + null, + new C.AuthProxy + { + Services = new Dictionary + { + ["App"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.local/" }, + Registration = new C.ServiceEndpoint { BaseUrl = "https://registration.local/" }, + }, + }, + }); + + [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_timeouts_are_at_the_supported_boundaries.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_timeouts_are_at_the_supported_boundaries.cs new file mode 100644 index 00000000..d059fc2b --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_timeouts_are_at_the_supported_boundaries.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_ActivityTimeoutConfigurationValidator; + +public class when_timeouts_are_at_the_supported_boundaries : Specification +{ + ValidateOptionsResult _result; + + void Because() => _result = new ActivityTimeoutConfigurationValidator().Validate( + null, + new C.AuthProxy + { + ActivityTimeout = TimeSpan.FromMilliseconds(1), + Services = new Dictionary + { + ["App"] = new() { ActivityTimeout = TimeSpan.FromMilliseconds(int.MaxValue) }, + }, + }); + + [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_timeouts_are_just_outside_the_supported_boundaries.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_timeouts_are_just_outside_the_supported_boundaries.cs new file mode 100644 index 00000000..7b7a0c01 --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_timeouts_are_just_outside_the_supported_boundaries.cs @@ -0,0 +1,23 @@ +// 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_ActivityTimeoutConfigurationValidator; + +public class when_timeouts_are_just_outside_the_supported_boundaries : Specification +{ + ValidateOptionsResult _result; + + void Because() => _result = new ActivityTimeoutConfigurationValidator().Validate( + null, + new C.AuthProxy + { + ActivityTimeout = TimeSpan.FromTicks(TimeSpan.TicksPerMillisecond - 1), + Services = new Dictionary + { + ["App"] = new() { ActivityTimeout = TimeSpan.FromMilliseconds(int.MaxValue).Add(TimeSpan.FromTicks(1)) }, + }, + }); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); + [Fact] void should_fail_once_per_offending_setting() => _result.Failures.Count().ShouldEqual(2); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_timeouts_cannot_be_honored.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_timeouts_cannot_be_honored.cs new file mode 100644 index 00000000..11d6c43b --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeoutConfigurationValidator/when_timeouts_cannot_be_honored.cs @@ -0,0 +1,31 @@ +// 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_ActivityTimeoutConfigurationValidator; + +public class when_timeouts_cannot_be_honored : Specification +{ + ValidateOptionsResult _result; + + void Because() => _result = new ActivityTimeoutConfigurationValidator().Validate( + null, + new C.AuthProxy + { + ActivityTimeout = TimeSpan.Zero, + Services = new Dictionary + { + ["Portal"] = new() + { + ActivityTimeout = TimeSpan.FromSeconds(-1), + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.local/", ActivityTimeout = TimeSpan.FromDays(365) }, + Frontend = new C.ServiceEndpoint { BaseUrl = "https://frontend.local/", ActivityTimeout = TimeSpan.FromMinutes(1) }, + }, + }, + }); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); + [Fact] void should_fail_once_per_offending_setting() => _result.Failures.Count().ShouldEqual(3); + [Fact] void should_name_the_root_setting() => _result.FailureMessage!.ShouldContain($"{C.AuthProxy.SectionKey}:ActivityTimeout "); + [Fact] void should_name_the_service_setting() => _result.FailureMessage!.ShouldContain($"{C.AuthProxy.SectionKey}:Services:Portal:ActivityTimeout "); + [Fact] void should_name_the_endpoint_setting() => _result.FailureMessage!.ShouldContain($"{C.AuthProxy.SectionKey}:Services:Portal:Backend:ActivityTimeout "); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_a_service_states_its_own.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_a_service_states_its_own.cs new file mode 100644 index 00000000..f3096698 --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_a_service_states_its_own.cs @@ -0,0 +1,31 @@ +// 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.when_activity_timeouts_are_configured; + +public class and_a_service_states_its_own : given.a_provider_over_a_configuration +{ + void Establish() + { + _configuration.ActivityTimeout = TimeSpan.FromMinutes(30); + _configuration.Services = new Dictionary + { + ["Streams"] = new() + { + ActivityTimeout = TimeSpan.FromHours(2), + Backend = new C.ServiceEndpoint { BaseUrl = "https://streams.local/" }, + Frontend = new C.ServiceEndpoint { BaseUrl = "https://streams-web.local/" }, + }, + ["Other"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://other.local/" }, + }, + }; + } + + void Because() => CreateProvider(); + + [Fact] void should_apply_it_to_the_backend_of_that_service() => ActivityTimeoutOf("streams-backend-cluster").ShouldEqual(TimeSpan.FromHours(2)); + [Fact] void should_apply_it_to_the_frontend_of_that_service() => ActivityTimeoutOf("streams-frontend-cluster").ShouldEqual(TimeSpan.FromHours(2)); + [Fact] void should_leave_other_services_on_the_root_value() => ActivityTimeoutOf("other-backend-cluster").ShouldEqual(TimeSpan.FromMinutes(30)); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_an_endpoint_states_its_own.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_an_endpoint_states_its_own.cs new file mode 100644 index 00000000..7bc597cb --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_an_endpoint_states_its_own.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_MicroserviceReverseProxyConfigProvider.when_activity_timeouts_are_configured; + +public class and_an_endpoint_states_its_own : given.a_provider_over_a_configuration +{ + void Establish() + { + _configuration.ActivityTimeout = TimeSpan.FromMinutes(30); + _configuration.Services = new Dictionary + { + ["App"] = new() + { + ActivityTimeout = TimeSpan.FromHours(2), + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.local/", ActivityTimeout = TimeSpan.FromSeconds(45) }, + Frontend = new C.ServiceEndpoint { BaseUrl = "https://frontend.local/" }, + }, + }; + } + + void Because() => CreateProvider(); + + [Fact] void should_apply_it_to_that_endpoint() => ActivityTimeoutOf("app-backend-cluster").ShouldEqual(TimeSpan.FromSeconds(45)); + [Fact] void should_leave_the_sibling_endpoint_on_the_service_value() => ActivityTimeoutOf("app-frontend-cluster").ShouldEqual(TimeSpan.FromHours(2)); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_none_is_stated.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_none_is_stated.cs new file mode 100644 index 00000000..a3d4c65a --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_none_is_stated.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_MicroserviceReverseProxyConfigProvider.when_activity_timeouts_are_configured; + +public class and_none_is_stated : given.a_provider_over_a_configuration +{ + void Establish() => + _configuration.Services = new Dictionary + { + ["App"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.local/" }, + Frontend = new C.ServiceEndpoint { BaseUrl = "https://frontend.local/" }, + }, + }; + + void Because() => CreateProvider(); + + [Fact] void should_keep_five_minutes_for_the_backend() => ActivityTimeoutOf("app-backend-cluster").ShouldEqual(TimeSpan.FromMinutes(5)); + [Fact] void should_keep_five_minutes_for_the_frontend() => ActivityTimeoutOf("app-frontend-cluster").ShouldEqual(TimeSpan.FromMinutes(5)); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_only_the_root_states_one.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_only_the_root_states_one.cs new file mode 100644 index 00000000..161a71d9 --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/and_only_the_root_states_one.cs @@ -0,0 +1,25 @@ +// 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.when_activity_timeouts_are_configured; + +public class and_only_the_root_states_one : given.a_provider_over_a_configuration +{ + void Establish() + { + _configuration.ActivityTimeout = TimeSpan.FromMinutes(30); + _configuration.Services = new Dictionary + { + ["App"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.local/" }, + Frontend = new C.ServiceEndpoint { BaseUrl = "https://frontend.local/" }, + }, + }; + } + + void Because() => CreateProvider(); + + [Fact] void should_apply_it_to_the_backend() => ActivityTimeoutOf("app-backend-cluster").ShouldEqual(TimeSpan.FromMinutes(30)); + [Fact] void should_apply_it_to_the_frontend() => ActivityTimeoutOf("app-frontend-cluster").ShouldEqual(TimeSpan.FromMinutes(30)); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/given/a_provider_over_a_configuration.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/given/a_provider_over_a_configuration.cs new file mode 100644 index 00000000..f237e8e7 --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_activity_timeouts_are_configured/given/a_provider_over_a_configuration.cs @@ -0,0 +1,28 @@ +// 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.when_activity_timeouts_are_configured.given; + +/// +/// A provider built over a configuration a spec states, exposing the activity timeout each cluster ended up with. +/// +public class a_provider_over_a_configuration : Specification +{ + protected C.AuthProxy _configuration; + protected MicroserviceReverseProxyConfigProvider _provider; + + protected TimeSpan? ActivityTimeoutOf(string clusterId) => + _provider.GetConfig().Clusters.Single(_ => _.ClusterId == clusterId).HttpRequest!.ActivityTimeout; + + void Establish() + { + _configuration = new(); + } + + protected void CreateProvider() + { + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(_configuration); + _provider = new MicroserviceReverseProxyConfigProvider(monitor, Substitute.For>()); + } +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_the_configuration_reloads/and_an_activity_timeout_changed.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_the_configuration_reloads/and_an_activity_timeout_changed.cs new file mode 100644 index 00000000..67d84b2d --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_the_configuration_reloads/and_an_activity_timeout_changed.cs @@ -0,0 +1,21 @@ +// 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.when_the_configuration_reloads; + +/// +/// The timeout lives on the cluster, so a reload that only changes it has to reach the table being served — +/// otherwise an operator who lengthens it to stop a stream being cut would see no effect until a restart. +/// +public class and_an_activity_timeout_changed : given.a_provider_over_a_reloadable_configuration +{ + void Because() + { + var next = ConfigurationDeclaring("/portal"); + next.ActivityTimeout = TimeSpan.FromMinutes(20); + Reload(next); + } + + [Fact] void should_serve_the_replacement_timeout() => + _provider.GetConfig().Clusters.Single(_ => _.ClusterId == "app-frontend-cluster").HttpRequest!.ActivityTimeout.ShouldEqual(TimeSpan.FromMinutes(20)); +} diff --git a/Source/AuthProxy/Configuration/AuthProxy.cs b/Source/AuthProxy/Configuration/AuthProxy.cs index 3b944c2d..c3bf9175 100644 --- a/Source/AuthProxy/Configuration/AuthProxy.cs +++ b/Source/AuthProxy/Configuration/AuthProxy.cs @@ -13,6 +13,12 @@ public class AuthProxy /// public const string SectionKey = "Cratis:AuthProxy"; + /// + /// The activity timeout applied to a proxied request when neither the endpoint, its service nor the + /// root states one. + /// + public static readonly TimeSpan DefaultActivityTimeout = TimeSpan.FromMinutes(5); + /// /// Gets or sets the authentication configuration. /// @@ -108,6 +114,19 @@ public class AuthProxy /// public IList TenantResolutions { get; set; } = []; + /// + /// Gets or sets how long a proxied request may sit idle — with no bytes moving in either direction — + /// before the proxy cancels it. Applies to every endpoint that states no timeout of its own and whose + /// service states none. Leave unset for (five minutes). + /// + /// + /// The clock restarts every time data is read or written, so this is an idle limit rather than a limit on + /// the total duration. It is what ends a quiet WebSocket or Server-Sent Events stream. Must be greater + /// than zero. See and + /// for the narrower settings. + /// + public TimeSpan? ActivityTimeout { get; set; } + /// /// Gets or sets the services configuration. /// Services are keyed by a friendly name (e.g. "portal", "catalog"). diff --git a/Source/AuthProxy/Configuration/Invite.cs b/Source/AuthProxy/Configuration/Invite.cs index 11c5b751..13bbf4d6 100644 --- a/Source/AuthProxy/Configuration/Invite.cs +++ b/Source/AuthProxy/Configuration/Invite.cs @@ -147,6 +147,8 @@ public class Invite /// Gets or sets the lobby service configuration. /// When set, requests from users without a resolved tenant are forwarded to this service's frontend, /// invite exchanges return here, and registrations can redirect to its configured registration endpoint. + /// This configuration does not create proxy clusters. Setting ActivityTimeout on the lobby or any of its + /// endpoints fails configuration validation; configure the proxied service under AuthProxy.Services instead. /// public Service? Lobby { get; set; } } diff --git a/Source/AuthProxy/Configuration/Service.cs b/Source/AuthProxy/Configuration/Service.cs index 6daddebd..4f00fbed 100644 --- a/Source/AuthProxy/Configuration/Service.cs +++ b/Source/AuthProxy/Configuration/Service.cs @@ -35,6 +35,20 @@ public class Service /// public ServiceEndpoint? Registration { get; set; } + /// + /// Gets or sets how long a request proxied to this service's backend or frontend may sit idle — with no + /// bytes moving in either direction — before the proxy cancels it. Leave unset to use the root + /// , which itself defaults to + /// . + /// + /// + /// A long-lived stream (WebSocket, Server-Sent Events) is cut once it has been quiet for longer than this, + /// so set it above the longest silence the backend can leave between messages — or have the backend send a + /// heartbeat more often than that. narrows it to the backend + /// or the frontend alone. Must be greater than zero. + /// + public TimeSpan? ActivityTimeout { get; set; } + /// /// Gets or sets the request paths on this service that are served to unauthenticated callers. /// diff --git a/Source/AuthProxy/Configuration/ServiceEndpoint.cs b/Source/AuthProxy/Configuration/ServiceEndpoint.cs index 6b0ff08b..7ac108f0 100644 --- a/Source/AuthProxy/Configuration/ServiceEndpoint.cs +++ b/Source/AuthProxy/Configuration/ServiceEndpoint.cs @@ -15,4 +15,12 @@ public class ServiceEndpoint /// [Required, Url] public string BaseUrl { get; set; } = string.Empty; + + /// + /// Gets or sets how long a request proxied to this endpoint may sit idle — with no bytes moving in either + /// direction — before the proxy cancels it. Leave unset to use , then + /// the root . Must be greater than zero. + /// Only supported on Backend and Frontend endpoints; setting this on Registration fails configuration validation. + /// + public TimeSpan? ActivityTimeout { get; set; } } diff --git a/Source/AuthProxy/ReverseProxy/ActivityTimeoutConfigurationValidator.cs b/Source/AuthProxy/ReverseProxy/ActivityTimeoutConfigurationValidator.cs new file mode 100644 index 00000000..32b1ba06 --- /dev/null +++ b/Source/AuthProxy/ReverseProxy/ActivityTimeoutConfigurationValidator.cs @@ -0,0 +1,86 @@ +// 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; + +/// +/// Refuses a configuration stating a proxy activity timeout the proxy cannot honor. +/// +/// +/// A value below one millisecond becomes an immediate cancellation, and a value past the +/// signed integer millisecond limit YARP uses would silently shorten the configured timeout. Both are +/// configuration mistakes, so they are named here, at the one moment somebody is watching, rather than +/// surfacing as every request failing or as a silently different timeout than the one written down. +/// +public class ActivityTimeoutConfigurationValidator : IValidateOptions +{ + /// + /// The longest activity timeout that can be scheduled. + /// + internal static readonly TimeSpan Maximum = TimeSpan.FromMilliseconds(int.MaxValue); + + /// + public ValidateOptionsResult Validate(string? name, C.AuthProxy options) + { + var failures = new List(); + + Check($"{C.AuthProxy.SectionKey}:{nameof(C.AuthProxy.ActivityTimeout)}", options.ActivityTimeout, failures); + + foreach (var (serviceName, service) in options.Services) + { + var prefix = $"{C.AuthProxy.SectionKey}:{nameof(C.AuthProxy.Services)}:{serviceName}"; + Check($"{prefix}:{nameof(C.Service.ActivityTimeout)}", service.ActivityTimeout, failures); + Check($"{prefix}:{nameof(C.Service.Backend)}:{nameof(C.ServiceEndpoint.ActivityTimeout)}", service.Backend?.ActivityTimeout, failures); + Check($"{prefix}:{nameof(C.Service.Frontend)}:{nameof(C.ServiceEndpoint.ActivityTimeout)}", service.Frontend?.ActivityTimeout, failures); + if (service.Registration?.ActivityTimeout is not null) + { + failures.Add($"{prefix}:{nameof(C.Service.Registration)}:{nameof(C.ServiceEndpoint.ActivityTimeout)} is not supported. ActivityTimeout only applies to Backend and Frontend endpoints, not Registration. Remove this setting."); + } + } + + if (options.Invite?.Lobby is { } lobby) + { + var prefix = $"{C.AuthProxy.SectionKey}:{nameof(C.AuthProxy.Invite)}:{nameof(C.Invite.Lobby)}"; + CheckLobby($"{prefix}:{nameof(C.Service.ActivityTimeout)}", lobby.ActivityTimeout, failures); + CheckLobby($"{prefix}:{nameof(C.Service.Backend)}:{nameof(C.ServiceEndpoint.ActivityTimeout)}", lobby.Backend?.ActivityTimeout, failures); + CheckLobby($"{prefix}:{nameof(C.Service.Frontend)}:{nameof(C.ServiceEndpoint.ActivityTimeout)}", lobby.Frontend?.ActivityTimeout, failures); + CheckLobby($"{prefix}:{nameof(C.Service.Registration)}:{nameof(C.ServiceEndpoint.ActivityTimeout)}", lobby.Registration?.ActivityTimeout, failures); + } + + return failures.Count > 0 + ? ValidateOptionsResult.Fail(failures) + : ValidateOptionsResult.Success; + } + + static void CheckLobby(string key, TimeSpan? value, List failures) + { + if (value is not null) + { + failures.Add($"{key} is not supported. Invite:Lobby does not create proxy clusters, so ActivityTimeout cannot apply. Remove this setting and configure the proxied service under Services instead."); + } + } + + static void Check(string key, TimeSpan? value, List failures) + { + if (value is null) + { + return; + } + + if (value <= TimeSpan.Zero) + { + failures.Add($"{key} is '{value}', which is not greater than zero. A proxied request would be cancelled the moment it started. Leave the setting unset for the default of {C.AuthProxy.DefaultActivityTimeout}, or state how long a request may sit idle, for example '00:15:00'."); + } + else if (value < TimeSpan.FromMilliseconds(1)) + { + failures.Add($"{key} is '{value}', which is less than one millisecond. The proxy would round it down to zero and cancel the request immediately. State an idle limit of at least '00:00:00.001'."); + } + else if (value > Maximum) + { + failures.Add($"{key} is '{value}', which is longer than the {Maximum} the proxy can schedule. State a shorter idle limit."); + } + } +} diff --git a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs index e53d9a75..e4a92990 100644 --- a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs +++ b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs @@ -42,11 +42,6 @@ public class MicroserviceReverseProxyConfigProvider : IProxyConfigProvider, IDis /// const string ApiPathPrefix = "/api"; - static readonly ClusterConfig _baseCluster = new() - { - HttpRequest = new() { ActivityTimeout = TimeSpan.FromMinutes(5) }, - }; - readonly InMemoryConfigProvider _inner; readonly ILogger _logger; readonly Lock _rebuilding = new(); @@ -392,7 +387,7 @@ static List BuildClusters(C.AuthProxy config) if (ms.Backend is not null) { - clusters.Add(_baseCluster with + clusters.Add(ClusterFor(config, ms, ms.Backend) with { ClusterId = BackendClusterId(key), Destinations = new Dictionary @@ -404,7 +399,7 @@ static List BuildClusters(C.AuthProxy config) if (ms.Frontend is not null) { - clusters.Add(_baseCluster with + clusters.Add(ClusterFor(config, ms, ms.Frontend) with { ClusterId = FrontendClusterId(key), Destinations = new Dictionary @@ -418,6 +413,30 @@ static List BuildClusters(C.AuthProxy config) return clusters; } + /// + /// Creates the cluster skeleton for an endpoint, carrying the activity timeout that applies to it. + /// + /// The root configuration. + /// The service the endpoint belongs to. + /// The endpoint. + /// A cluster with its request settings filled in. + /// + /// The most specific statement wins: the endpoint's own, then its service's, then the root's, then + /// . The same value governs plain requests, WebSocket + /// sessions and Server-Sent Events streams, because YARP measures all of them as time since data last + /// moved in either direction. + /// + static ClusterConfig ClusterFor(C.AuthProxy config, C.Service service, C.ServiceEndpoint endpoint) => new() + { + HttpRequest = new() + { + ActivityTimeout = endpoint.ActivityTimeout + ?? service.ActivityTimeout + ?? config.ActivityTimeout + ?? C.AuthProxy.DefaultActivityTimeout, + }, + }; + static Dictionary ServiceMetadata(string serviceName) => new() { [ServiceSelection.RouteMetadataKey] = serviceName }; static string BackendClusterId(string key) => $"{key}-backend-cluster"; diff --git a/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs b/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs index 6fa40949..c79d5bb2 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; @@ -19,6 +21,7 @@ public static class ReverseProxyExtensions /// The same for chaining. public static WebApplicationBuilder SetupReverseProxy(this WebApplicationBuilder builder) { + builder.Services.AddSingleton, ActivityTimeoutConfigurationValidator>(); builder.Services.AddSingleton(); builder.Services.AddSingleton( sp => sp.GetRequiredService());