diff --git a/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs index 13ba192..860c776 100644 --- a/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs +++ b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs @@ -16,6 +16,15 @@ public sealed class OidcIssuerOptions public OidcClientsOptions Clients { get; init; } = new(); + public OidcLimitsOptions Limits { get; init; } = new(); + + /// + /// Optional path of the issuer's brake marker. While a file exists there, /connect/* answers 503 and nothing is issued; + /// remove it to release. Checked on every request (one stat), so engaging and releasing need no restart. + /// Unset: the issuer has no marker brake (removing this section and restarting is the other off switch). + /// + public string BrakePath { get; init; } = string.Empty; + /// Null when the section is absent. public static OidcIssuerOptions? FromConfiguration(IConfiguration configuration) { @@ -42,3 +51,25 @@ public sealed class OidcClientOptions /// MAS's back-channel logout URL for this provider. Empty: no logout is sent and no session is ended. public string BackchannelLogoutUri { get; init; } = string.Empty; } + +/// +/// Budgets on /connect/authorize, /connect/token and /connect/userinfo, and the bound on what the anonymous authorize +/// endpoint can park. Per source address and per client id, one fixed window; a refused caller gets 429 with Retry-After. +/// The defaults suit one client (MAS) behind one proxy. +/// +public sealed class OidcLimitsOptions +{ + public int PerAddressPermits { get; init; } = 30; + + public int PerClientPermits { get; init; } = 300; + + public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1); + + public int MaxTrackedKeys { get; init; } = 10_000; + + /// The most OpenIddict token rows (parked authorize requests, codes, access tokens) allowed to exist; a new authorize request beyond it is refused until the prune frees room. + public int MaxTokenRows { get; init; } = 20_000; + + /// How often expired and orphaned authorizations and tokens are deleted. + public TimeSpan PruneInterval { get; init; } = TimeSpan.FromMinutes(5); +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcGuard.cs b/src/Bifrost.Web/Features/Oidc/OidcGuard.cs new file mode 100644 index 0000000..f07ac71 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcGuard.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Text; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Shared; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// The issuer's front door, ahead of OpenIddict: the marker brake, the per-address and per-client budgets, and the bound +/// on token rows. The address is the connection's, already corrected by the forwarded-headers handling that trusts only +/// the local proxy, so a caller cannot pick its own. It fails closed: anything this middleware cannot decide is a 503, never a pass. +/// +public sealed class OidcGuardMiddleware(RequestDelegate next, OidcIssuerOptions options, OidcRateLimit limit, ILogger logger) +{ + private const int MaxClientIdLength = 128; + + public async Task InvokeAsync(HttpContext http, IOpenIddictTokenManager tokens) + { + var endpoint = EndpointOf(http.Request.Path); + if (endpoint is null) + { + await next(http); + return; + } + + try + { + if (BrakeEngaged(options.BrakePath)) + { + await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", TimeSpan.FromMinutes(1)); + return; + } + + if (limit.Spend($"address:{AddressKey(http.Connection.RemoteIpAddress)}") is { } byAddress) + { + await RefuseAsync(http, StatusCodes.Status429TooManyRequests, "too_many_requests", byAddress.RetryAfter); + return; + } + + if (limit.Spend($"client:{endpoint}:{await ClientKeyAsync(http, endpoint)}") is { } byClient) + { + await RefuseAsync(http, StatusCodes.Status429TooManyRequests, "too_many_requests", byClient.RetryAfter); + return; + } + + // A new authorize request parks a row for anyone who can name the (public) client; a resumed one carries request_uri and parks none. + if (endpoint == "authorize" && !http.Request.Query.ContainsKey("request_uri") && await tokens.CountAsync(http.RequestAborted) >= options.Limits.MaxTokenRows) + { + await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", options.Limits.PruneInterval); + return; + } + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogError("OIDC guard could not decide ({ExceptionType}); the request is refused.", exception.GetType().Name); + await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", TimeSpan.FromMinutes(1)); + return; + } + + await next(http); + } + + private static string? EndpointOf(PathString path) => + path.Equals(OidcServiceCollectionExtensions.AuthorizationPath, StringComparison.OrdinalIgnoreCase) ? "authorize" + : path.Equals(OidcServiceCollectionExtensions.TokenPath, StringComparison.OrdinalIgnoreCase) ? "token" + : path.Equals(OidcServiceCollectionExtensions.UserInfoPath, StringComparison.OrdinalIgnoreCase) ? "userinfo" + : null; + + private static bool BrakeEngaged(string path) => !string.IsNullOrWhiteSpace(path) && File.Exists(path); + + // An IPv6 caller owns its /64, so rotating the low bits buys nothing. + private static string AddressKey(IPAddress? address) + { + if (address is null) + { + return "unknown"; + } + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + if (address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6) + { + return address.ToString(); + } + + var bytes = address.GetAddressBytes(); + Array.Clear(bytes, 8, 8); + return new IPAddress(bytes).ToString() + "/64"; + } + + // The client the caller names, not one it proved: authorize names it in the query or form, token in its Basic header. + // An unreadable value shares one key, so garbage cannot mint keys. + private static async Task ClientKeyAsync(HttpContext http, string endpoint) + { + string? clientId = null; + if (endpoint == "authorize") + { + clientId = http.Request.Query["client_id"].FirstOrDefault() + ?? (http.Request.HasFormContentType ? (await http.Request.ReadFormAsync(http.RequestAborted))["client_id"].FirstOrDefault() : null); + } + else if (endpoint == "token" && http.Request.Headers.Authorization.FirstOrDefault() is { } header + && header.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + { + try + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(header["Basic ".Length..].Trim())); + clientId = Uri.UnescapeDataString(decoded.Split(':', 2)[0]); + } + catch (FormatException) + { + } + } + + return string.IsNullOrEmpty(clientId) || clientId.Length > MaxClientIdLength ? "unnamed" : clientId; + } + + private static Task RefuseAsync(HttpContext http, int status, string error, TimeSpan retryAfter) + { + http.Response.StatusCode = status; + http.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); + http.Response.ContentType = "application/json"; + return http.Response.WriteAsync($"{{\"error\":\"{error}\"}}", http.RequestAborted); + } +} + +/// The issuer's per-address and per-client permits, on the shared fixed-window budget. +public sealed class OidcRateLimit(OidcIssuerOptions options, TimeProvider clock) +{ + private readonly FixedWindowBudget addresses = new(options.Limits.PerAddressPermits, options.Limits.Window, options.Limits.MaxTrackedKeys, clock); + private readonly FixedWindowBudget clients = new(options.Limits.PerClientPermits, options.Limits.Window, options.Limits.MaxTrackedKeys, clock); + + public BudgetDenial? Spend(string key) => key.StartsWith("address:", StringComparison.Ordinal) ? addresses.Spend(key) : clients.Spend(key); +} + +/// +/// Deletes expired and orphaned authorizations and tokens on a schedule (OpenIddict's own prune: tokens that are no longer +/// valid or have expired, and authorizations that are ad hoc or invalid with no live token). Live rows are never touched. +/// +public sealed class OidcPruner(IServiceScopeFactory scopes, OidcIssuerOptions options, TimeProvider clock, ILogger logger) : BackgroundService +{ + public async Task PruneAsync(CancellationToken cancellationToken) + { + await using var scope = scopes.CreateAsyncScope(); + var threshold = clock.GetUtcNow(); + await scope.ServiceProvider.GetRequiredService().PruneAsync(threshold, cancellationToken); + await scope.ServiceProvider.GetRequiredService().PruneAsync(threshold, cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(options.Limits.PruneInterval, clock); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + await PruneAsync(stoppingToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogError("OIDC prune failed ({ExceptionType}); the next tick tries again.", exception.GetType().Name); + } + } + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs index 5ad30b8..78526af 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs @@ -67,6 +67,9 @@ public static class OidcServiceCollectionExtensions server.Issuer = issuer; }); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); services.AddHostedService(); if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri)) { diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 87d8088..2a5ff5f 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -285,6 +285,11 @@ if (!app.Environment.IsDevelopment()) app.UseHttpLogging(); app.UseHttpsRedirection(); +if (oidcOptions is not null) +{ + app.UseMiddleware(); // before authentication: OpenIddict answers /connect/* inside it +} + app.UseStaticFiles(); app.UseRouting(); app.UseConceptTallyCors(); diff --git a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs index 30098f5..e9ead63 100644 --- a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -19,7 +19,7 @@ namespace Bifrost.Web.Tests; public sealed class OidcIssuerTests : IClassFixture { private static readonly Uri Origin = new("https://localhost"); - private readonly OidcWebApplicationFactory _factory; + private OidcWebApplicationFactory _factory; public OidcIssuerTests(OidcWebApplicationFactory factory) => _factory = factory; @@ -178,6 +178,161 @@ public sealed class OidcIssuerTests : IClassFixture Assert.False(redirect.ContainsKey("error")); } + private static IReadOnlyDictionary Settings(params (string Key, string Value)[] pairs) => + pairs.ToDictionary(p => $"Bifrost:Oidc:{p.Key}", p => (string?)p.Value); + + [Fact] + public async Task A_flood_from_one_address_is_refused_with_429_and_a_retry_after() + { + using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "5"))); + using var client = factory.CreateClient(ClientOptions()); + + var statuses = new List(); + HttpResponseMessage? last = null; + for (var i = 0; i < 8; i++) + { + last?.Dispose(); + last = await client.GetAsync(NewRequest().Path); + statuses.Add(last.StatusCode); + } + + Assert.DoesNotContain(HttpStatusCode.TooManyRequests, statuses.Take(5)); + Assert.All(statuses.Skip(5), status => Assert.Equal(HttpStatusCode.TooManyRequests, status)); + Assert.True(int.Parse(last!.Headers.GetValues("Retry-After").Single()) >= 1); + using var tokenFlood = await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary())); + Assert.Equal(HttpStatusCode.TooManyRequests, tokenFlood.StatusCode); // one budget over all three endpoints + using var userinfoFlood = await client.GetAsync("/connect/userinfo"); + Assert.Equal(HttpStatusCode.TooManyRequests, userinfoFlood.StatusCode); + last.Dispose(); + } + + [Fact] + public async Task A_flood_naming_one_client_is_refused_for_that_client_only() + { + using var factory = new OidcSettingsFactory(Settings(("Limits:PerClientPermits", "3"))); + using var client = factory.CreateClient(ClientOptions()); + + var flooded = new List(); + for (var i = 0; i < 5; i++) + { + using var response = await client.GetAsync(NewRequest("noisy-client").Path); + flooded.Add(response.StatusCode); + } + + using var other = await client.GetAsync(NewRequest().Path); + Assert.Equal([HttpStatusCode.TooManyRequests, HttpStatusCode.TooManyRequests], flooded.Skip(3)); + Assert.NotEqual(HttpStatusCode.TooManyRequests, other.StatusCode); + Assert.NotEqual(HttpStatusCode.TooManyRequests, flooded[0]); + } + + [Fact] + public async Task A_mas_flow_fits_the_default_budgets_and_the_window_reopens() + { + using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "30"), ("Limits:PerClientPermits", "300"))); + _factory = factory; + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Within Budget"); + + var verified = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null); + Assert.Equal(identity, verified.Claim("preferred_username")); + + for (var i = 0; i < 40; i++) + { + using var burst = await client.GetAsync("/connect/userinfo"); + } + + using var refused = await client.GetAsync("/connect/userinfo"); + Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode); + factory.Clock.Advance(TimeSpan.FromMinutes(1)); + using var reopened = await client.GetAsync("/connect/userinfo"); + Assert.NotEqual(HttpStatusCode.TooManyRequests, reopened.StatusCode); + } + + [Fact] + public async Task The_brake_marker_stops_the_issuer_and_releasing_it_needs_no_restart() + { + var marker = Path.Combine(Path.GetTempPath(), $"bifrost-oidc-brake-{Guid.NewGuid():N}"); + using var factory = new OidcSettingsFactory(Settings(("BrakePath", marker))); + _factory = factory; + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Braked"); + try + { + Assert.True((await CompleteFlowAsync(client)).IdToken.Length > 0, "released: the flow passes"); + + await File.WriteAllTextAsync(marker, "held"); + foreach (var path in new[] { NewRequest().Path, "/connect/userinfo" }) + { + using var held = await client.GetAsync(path); + Assert.Equal(HttpStatusCode.ServiceUnavailable, held.StatusCode); + Assert.True(held.Headers.Contains("Retry-After")); + } + + using var token = await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary())); + Assert.Equal(HttpStatusCode.ServiceUnavailable, token.StatusCode); + using var discovery = await client.GetAsync("/.well-known/openid-configuration"); + Assert.Equal(HttpStatusCode.OK, discovery.StatusCode); // the brake stops issuing, not the published keys + + File.Delete(marker); + Assert.True((await CompleteFlowAsync(client)).IdToken.Length > 0, "released again, same process"); + } + finally + { + File.Delete(marker); + } + } + + [Fact] + public async Task Anonymous_authorize_requests_cannot_park_more_than_the_row_cap_and_the_prune_gives_the_room_back() + { + using var factory = new OidcSettingsFactory(Settings(("Limits:MaxTokenRows", "3"))); + _factory = factory; + using var client = factory.CreateClient(ClientOptions()); + + var parked = new List(); + for (var i = 0; i < 5; i++) + { + using var response = await client.GetAsync(NewRequest().Path); + parked.Add(response.StatusCode); + } + + Assert.Equal(3L, await factory.TokenRowsAsync()); + Assert.All(parked.Skip(3), status => Assert.Equal(HttpStatusCode.ServiceUnavailable, status)); + Assert.All(parked.Take(3), status => Assert.NotEqual(HttpStatusCode.ServiceUnavailable, status)); + + factory.Clock.Advance(TimeSpan.FromMinutes(11)); // past the ten-minute parked lifetime + await factory.PruneAsync(); + Assert.Equal(0L, await factory.TokenRowsAsync()); + using var again = await client.GetAsync(NewRequest().Path); + Assert.NotEqual(HttpStatusCode.ServiceUnavailable, again.StatusCode); + } + + [Fact] + public async Task The_prune_removes_expired_rows_and_leaves_a_live_token_alone() + { + using var factory = new OidcSettingsFactory(Settings()); + _factory = factory; + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Pruned"); + var tokens = await CompleteFlowAsync(client); // a parked request, a code and an access token existed along the way + using (var anonymous = factory.CreateClient(ClientOptions())) + { + using var parked = await anonymous.GetAsync(NewRequest().Path); // a parked request that will expire + } + + var before = await factory.TokenRowsAsync(); + await factory.PruneAsync(); + Assert.Equal(before, await factory.TokenRowsAsync()); // nothing has expired yet: nothing goes + + factory.Clock.Advance(TimeSpan.FromMinutes(11)); // parked requests and codes are dead, the access token (an hour) is not + await factory.PruneAsync(); + var after = await factory.TokenRowsAsync(); + Assert.True(after < before, "expired rows went"); + Assert.True(after >= 1, "the live access token stayed"); + + using var userinfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + userinfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var response = await factory.CreateClient(ClientOptions()).SendAsync(userinfo); + Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + [Theory] [InlineData("unknown-client", OidcTestKit.RedirectUri, true)] [InlineData(OidcTestKit.ClientId, "https://evil.example.test/callback", true)] diff --git a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs index 0b14d18..902dbda 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs @@ -92,7 +92,10 @@ public sealed class OidcTestKit : IDisposable ["Bifrost:Oidc:EncryptionKeyPath"] = EncryptionKeyPath, ["Bifrost:Oidc:Clients:Mas:ClientId"] = ClientId, ["Bifrost:Oidc:Clients:Mas:ClientSecretPath"] = ClientSecretPath, - ["Bifrost:Oidc:Clients:Mas:RedirectUri"] = RedirectUri + ["Bifrost:Oidc:Clients:Mas:RedirectUri"] = RedirectUri, + // The shared test client has no remote address, so every request is one caller: the budgets are out of the way unless a test sets them. + ["Bifrost:Oidc:Limits:PerAddressPermits"] = "100000", + ["Bifrost:Oidc:Limits:PerClientPermits"] = "100000" }; public void Dispose() @@ -114,6 +117,11 @@ public sealed class OidcTestClock : TimeProvider public override DateTimeOffset GetUtcNow() => _now; + // The monotonic reading follows the moved time, so a budget's window can be aged the same way as a parked request. + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() => _now.UtcTicks; + public void Advance(TimeSpan by) => _now += by; } @@ -258,6 +266,27 @@ public sealed class OidcFixedIdentityFactory(string identity) : OidcWebApplicati } } +/// An OIDC host with extra settings (limits, a brake path) applied after the kit's own. +public sealed class OidcSettingsFactory(IReadOnlyDictionary settings) : OidcWebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + foreach (var (key, value) in settings) + { + builder.UseSetting(key, value); + } + } + + public Task PruneAsync() => Services.GetRequiredService().PruneAsync(CancellationToken.None); + + public async Task TokenRowsAsync() + { + using var scope = Services.CreateScope(); + return await scope.ServiceProvider.GetRequiredService().CountAsync(); + } +} + /// An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived. public sealed class OidcForeignIssuerFactory : OidcWebApplicationFactory {