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
{