Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Documentation/aspire/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion Documentation/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Cratis AuthProxy is configured entirely through the `Cratis:AuthProxy` section o
"Ingress": { ... },
"Invite": { ... },
"Management": { ... },
"ActivityTimeout": "00:05:00",
"PagesPath": "",
"DataProtectionKeysPath": ""
}
Expand All @@ -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. |
83 changes: 83 additions & 0 deletions Documentation/configuration/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -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

Expand Down Expand Up @@ -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:<name>:<Backend or Frontend>:ActivityTimeout` | That endpoint only. |
| `Cratis:AuthProxy:Services:<name>: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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> _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");
}
40 changes: 40 additions & 0 deletions Source/Aspire/AuthProxyExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,46 @@ public static IResourceBuilder<T> WithIdentityVerification<T>(
return builder;
}

/// <summary>
/// Sets how long a proxied request may sit idle before AuthProxy cancels it, for every service.
/// </summary>
/// <typeparam name="T">The resource type (must support environment variables).</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="timeout">The longest a request may go with no bytes moving in either direction. Must be greater than zero.</param>
/// <returns>The same <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <remarks>
/// 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 <see cref="WithServiceActivityTimeout{T}"/> to give one service a different limit.
/// </remarks>
public static IResourceBuilder<T> WithActivityTimeout<T>(
this IResourceBuilder<T> builder,
TimeSpan timeout)
where T : IResourceWithEnvironment =>
builder.WithEnvironment(
$"{ConfigPrefix}__ActivityTimeout",
timeout.ToString("c", CultureInfo.InvariantCulture));

/// <summary>
/// Sets how long a request proxied to one service may sit idle before AuthProxy cancels it.
/// </summary>
/// <typeparam name="T">The resource type (must support environment variables).</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="serviceName">The service the limit applies to.</param>
/// <param name="timeout">The longest a request may go with no bytes moving in either direction. Must be greater than zero.</param>
/// <returns>The same <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <remarks>
/// Takes precedence over <see cref="WithActivityTimeout{T}"/> for that service's backend and frontend.
/// </remarks>
public static IResourceBuilder<T> WithServiceActivityTimeout<T>(
this IResourceBuilder<T> builder,
string serviceName,
TimeSpan timeout)
where T : IResourceWithEnvironment =>
builder.WithEnvironment(
$"{ConfigPrefix}__Services__{serviceName}__ActivityTimeout",
timeout.ToString("c", CultureInfo.InvariantCulture));

/// <summary>
/// Terminates the local AuthProxy session whenever identity verification refuses a caller.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A real AuthProxy reverse proxy in front of a real streaming origin, both on loopback sockets.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// The origin stays quiet for <see cref="Silence"/> 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.
/// </para>
/// </remarks>
public class a_streaming_deployment : Specification
{
/// <summary>The time the origin says nothing between its two messages.</summary>
protected static readonly TimeSpan Silence = TimeSpan.FromSeconds(2);

/// <summary>The second and final message, which only arrives if the stream was not cut.</summary>
protected const string FinalMessage = "final";

WebApplication _origin;
WebApplication _proxy;

/// <summary>Gets the proxy's base address.</summary>
protected string ProxyAddress { get; private set; }

/// <summary>
/// Starts the origin and a proxy whose root activity timeout is <paramref name="activityTimeout"/>.
/// </summary>
/// <param name="activityTimeout">The idle limit the proxy is configured with.</param>
/// <returns>A task representing the asynchronous operation.</returns>
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<C.AuthProxy>(options =>
{
options.ActivityTimeout = activityTimeout;
options.Services = new Dictionary<string, C.Service>
{
["App"] = new()
{
Backend = new C.ServiceEndpoint { BaseUrl = originAddress },
AnonymousPaths = ["/api/stream"],
},
};
});
builder.SetupReverseProxy();

_proxy = builder.Build();
_proxy.UseReverseProxy();
await _proxy.StartAsync();
ProxyAddress = AddressOf(_proxy);
}

/// <summary>
/// Reads a Server-Sent Events stream through the proxy until it ends, however it ends.
/// </summary>
/// <returns>Everything received before the stream ended or was cut.</returns>
protected async Task<string> 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();
}

/// <summary>
/// Reads a WebSocket session through the proxy until it closes, however it closes.
/// </summary>
/// <returns>Every text message received before the session ended or was cut.</returns>
protected async Task<IReadOnlyList<string>> ReadWebSocketMessages()
{
var messages = new List<string>();
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<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First().TrimEnd('/') + "/";

static async Task<WebApplication> 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();
}
}
}
Loading
Loading