OIDC issuer: address budget, token-row cap, scheduled prune, marker brake #50

Merged
heimdall merged 4 commits from self/oidc-limits-prune-brake into main 2026-11-11 08:12:49 +00:00
8 changed files with 581 additions and 7 deletions

View file

@ -44,6 +44,8 @@ echoes its value.
| `Bifrost:Oidc:Clients:Mas:ClientId` | The client id MAS presents. |
| `Bifrost:Oidc:Clients:Mas:ClientSecretPath` | File holding the client secret. |
| `Bifrost:Oidc:Clients:Mas:RedirectUri` | MAS's callback URL, registered exactly. |
| `Bifrost:Oidc:BrakePath` | Optional. While a file exists at this path, `/connect/authorize`, `/token` and `/userinfo` answer 503 `temporarily_unavailable` and nothing is issued; delete the file to release. Checked per request, so no restart. Unset: no marker brake. |
| `Bifrost:Oidc:Limits:*` | `PerAddressPermits` (30), `Window` (1 min), `MaxTrackedKeys` (10000), `MaxTokenRows` (20000), `PruneInterval` (5 min). |
| `Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri` | Optional. MAS's back-channel logout URL, `https://<mas>/upstream/backchannel-logout/<provider ULID>`; absolute http(s) or startup fails. Unset: no logout is sent and no authorization is revoked. |
There are no development or ephemeral keys in the host. Key files are provisioned like the other
@ -111,17 +113,35 @@ Configure MAS's upstream provider with the issuer above, the client id and secre
from `preferred_username`. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an
account.
## Limits, prune, brake
`OidcGuardMiddleware` sits ahead of OpenIddict and guards the three endpoints, matched by path segment so a trailing slash
is no way around it. In order: the marker brake (503; a marker that cannot be read is treated as engaged); one per-address
budget over the three endpoints together (429 with `Retry-After`; the address is the connection's after forwarded-headers
handling that trusts only the local proxy, IPv6 keyed by /64); and a cap on token rows: a new authorize request (no
`request_uri`) is refused with 503 while the table holds `MaxTokenRows`. Any failure inside the guard is a 503, never a pass.
MAS's flow is a handful of requests per sign-in from one address, far under the defaults (30 a minute is about 15 sign-ins).
There is no per-client budget. The one client's id is public and what a caller names is unverified, so a budget keyed on it
would let anyone spend the real client's permits and lock sign-in out. The bound on the anonymous endpoint is the address
budget plus the row cap, and the cap is itself a switch an attacker with many addresses can flip: it refuses new sign-ins
(never resumed ones) until the next prune frees rows, which is the bounded failure chosen over unbounded parked rows.
Not parking state before the user authenticates (as pushed authorization requests avoid by authenticating the client first)
would remove the switch, and OpenIddict's request caching does not offer that.
`OidcPruner` runs every `PruneInterval` and deletes tokens past expiry (parked authorize requests, codes, access tokens) or no
longer valid, then authorizations no longer valid or ad hoc with no token left (younger than five minutes are left alone).
Live rows are untouched. Plain EF queries rather than OpenIddict's own prune, which uses ExecuteDelete and cannot run on the
in-memory provider the tests use.
`docs/oidc-reserved-accounts.sql` is the read-only query for existing accounts that hold one of the 28 reserved names.
## Not built
Checked against the Window B list (2026-11-11). Present and tested: discovery, JWKS, authorize/token/userinfo, PKCE S256,
the one client, claims to a Matrix-legal localpart (`[a-z0-9._-]`, 3-80, lower case), reserved names at creation and now at
issuance, back-channel logout. Not present:
- **Rate limits and pruning.** No limiter sits on `/connect/*`, and expired OpenIddict rows (parked authorize requests,
tokens, authorizations) are never deleted. The authorize endpoint is anonymous and its client id and redirect URI are
public, so a caller can park rows without bound. Needs a budget (`FixedWindowBudget`) and a prune.
- **A brake.** The Bifrost process has none; the deploy brake belongs to Idunn. The only off switch is removing
`Bifrost:Oidc` and restarting, or membership leaving `Active` (the reconciler then logs the person out).
- **Non-member sign-in.** The gate is `Active` membership, so an account with no membership gets `access_denied`. A "Users"
tier for chat registration, with no vote, is a policy choice not yet made; nothing here writes membership or votes.
- **MAS upstream block.** `gamecult-ops` `chat/matrix-auth/config.yaml` has no `upstream_oauth2` (provider ULID,

View file

@ -0,0 +1,12 @@
-- Read-only. Which existing UserAccounts hold one of the 28 reserved localparts
-- (BifrostIdentityService.ReservedIdentities). Written for review; not run on live.
-- Run it in a read-only transaction by whoever holds the database:
-- psql -v ON_ERROR_STOP=1 -c 'BEGIN READ ONLY' -f docs/oidc-reserved-accounts.sql
-- Keep the list in step with ReservedIdentities; the issuer refuses these at authorize (#49).
SELECT a."Id", a."BifrostIdentity", a."NormalizedBifrostIdentity", a."CreatedAtUtc", a."LastSeenAtUtc"
FROM "UserAccounts" a
WHERE lower(a."NormalizedBifrostIdentity") IN (
'admin', 'aqua', 'deru', 'eureka', 'heimdall', 'ivixey', 'kiko', 'libby', 'meta', 'nibu', 'unnie', 'void', 'weksa',
'root', 'system', 'matrix', 'synapse', 'support', 'bot', 'mod', 'moderator', 'postmaster', 'gamecult',
'bifrost', 'idunn', 'odin', 'huginn', 'ratatoskr')
ORDER BY a."NormalizedBifrostIdentity";

View file

@ -16,6 +16,15 @@ public sealed class OidcIssuerOptions
public OidcClientsOptions Clients { get; init; } = new();
public OidcLimitsOptions Limits { get; init; } = new();
/// <summary>
/// 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).
/// </summary>
public string BrakePath { get; init; } = string.Empty;
/// <summary>Null when the section is absent.</summary>
public static OidcIssuerOptions? FromConfiguration(IConfiguration configuration)
{
@ -42,3 +51,22 @@ public sealed class OidcClientOptions
/// <summary>MAS's back-channel logout URL for this provider. Empty: no logout is sent and no session is ended.</summary>
public string BackchannelLogoutUri { get; init; } = string.Empty;
}
/// <summary>
/// One budget on /connect/authorize, /connect/token and /connect/userinfo together, per source address in one fixed window (a refused caller gets 429 with Retry-After),
/// and the bound on what the anonymous authorize endpoint can park. No per-client budget: the client id is public. The defaults suit one client (MAS) behind one proxy.
/// </summary>
public sealed class OidcLimitsOptions
{
public int PerAddressPermits { get; init; } = 30;
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
public int MaxTrackedKeys { get; init; } = 10_000;
/// <summary>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.</summary>
public int MaxTokenRows { get; init; } = 20_000;
/// <summary>How often expired and orphaned authorizations and tokens are deleted.</summary>
public TimeSpan PruneInterval { get; init; } = TimeSpan.FromMinutes(5);
}

View file

@ -0,0 +1,195 @@
using System.Net;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Microsoft.EntityFrameworkCore;
using OpenIddict.EntityFrameworkCore.Models;
using Bifrost.Web.Features.Shared;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using static OpenIddict.Abstractions.OpenIddictConstants;
using OpenIddict.Server.AspNetCore;
namespace Bifrost.Web.Features.Oidc;
/// <summary>
/// The issuer's front door, ahead of OpenIddict: the marker brake, the per-address budget, 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.
/// </summary>
public sealed class OidcGuardMiddleware(RequestDelegate next, OidcIssuerOptions options, OidcRateLimit limit, ILogger<OidcGuardMiddleware> logger)
{
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(AddressKey(http.Connection.RemoteIpAddress)) is { } byAddress)
{
await RefuseAsync(http, StatusCodes.Status429TooManyRequests, "too_many_requests", byAddress.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 const string Authorize = "authorize";
// OpenIddict answers these paths with or without a trailing slash, so the match is by segments, never exact equality.
private static string? EndpointOf(PathString path) =>
path.StartsWithSegments(OidcServiceCollectionExtensions.AuthorizationPath, StringComparison.OrdinalIgnoreCase) ? Authorize
: path.StartsWithSegments(OidcServiceCollectionExtensions.TokenPath, StringComparison.OrdinalIgnoreCase) ? "token"
: path.StartsWithSegments(OidcServiceCollectionExtensions.UserInfoPath, StringComparison.OrdinalIgnoreCase) ? "userinfo"
: null;
// A marker that cannot be told apart from an absent one (a permission or I/O error) throws, and the guard answers 503: the brake never releases by accident.
private static bool BrakeEngaged(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
try
{
File.GetAttributes(path);
return true;
}
catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException)
{
return false;
}
}
// 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";
}
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);
}
}
/// <summary>
/// The issuer's per-address permits on the shared fixed-window budget. There is no per-client budget: the one client's id is
/// public and what a caller names unverified, so a budget on it would let anyone spend the real client's permits.
/// </summary>
public sealed class OidcRateLimit(OidcIssuerOptions options, TimeProvider clock)
{
private readonly FixedWindowBudget addresses = new(options.Limits.PerAddressPermits, options.Limits.Window, options.Limits.MaxTrackedKeys, clock);
public BudgetDenial? Spend(string address) => addresses.Spend(address);
}
/// <summary>
/// Deletes expired and orphaned authorizations and tokens on a schedule: tokens past their expiry (parked authorize
/// requests, codes, access tokens) or no longer valid, then authorizations that are no longer valid or are ad hoc with no
/// token left. Live rows are never touched. Plain queries, not OpenIddict's ExecuteDelete prune, which the in-memory test provider
/// cannot run; an authorization takes its remaining tokens with it, because the foreign key has no cascade on Postgres.
/// </summary>
public sealed class OidcPruner(IServiceScopeFactory scopes, OidcIssuerOptions options, TimeProvider clock, ILogger<OidcPruner> logger) : BackgroundService
{
private const int Batch = 500;
// A fresh authorization briefly has no token yet; leave anything younger than this alone.
private static readonly TimeSpan AuthorizationGrace = TimeSpan.FromMinutes(5);
public async Task PruneAsync(CancellationToken cancellationToken)
{
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var now = clock.GetUtcNow();
var young = now - AuthorizationGrace;
int removed;
do
{
var tokens = await db.Set<OpenIddictEntityFrameworkCoreToken>()
.Where(token => token.ExpirationDate < now || (token.Status != Statuses.Valid && token.CreationDate < young))
.Take(Batch).ToListAsync(cancellationToken);
db.RemoveRange(tokens);
await db.SaveChangesAsync(cancellationToken);
removed = tokens.Count;
}
while (removed == Batch);
do
{
// A token row references its authorization (no cascade in the database), so the tokens go with it.
var authorizations = await db.Set<OpenIddictEntityFrameworkCoreAuthorization>()
.Include(authorization => authorization.Tokens)
.Where(authorization => authorization.CreationDate < young
&& (authorization.Status != Statuses.Valid || (authorization.Type == AuthorizationTypes.AdHoc && !authorization.Tokens.Any())))
.Take(Batch).ToListAsync(cancellationToken);
db.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens));
db.RemoveRange(authorizations);
await db.SaveChangesAsync(cancellationToken);
removed = authorizations.Count;
}
while (removed == Batch);
}
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);
}
}
}
}

View file

@ -67,6 +67,9 @@ public static class OidcServiceCollectionExtensions
server.Issuer = issuer;
});
services.AddSingleton<OidcRateLimit>();
services.AddSingleton<OidcPruner>();
services.AddHostedService(provider => provider.GetRequiredService<OidcPruner>());
services.AddHostedService<OidcClientSeeder>();
if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri))
{

View file

@ -285,6 +285,11 @@ if (!app.Environment.IsDevelopment())
app.UseHttpLogging();
app.UseHttpsRedirection();
if (oidcOptions is not null)
{
app.UseMiddleware<OidcGuardMiddleware>(); // before authentication: OpenIddict answers /connect/* inside it
}
app.UseStaticFiles();
app.UseRouting();
app.UseConceptTallyCors();

View file

@ -7,6 +7,7 @@ using System.Text.Json;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@ -19,7 +20,7 @@ namespace Bifrost.Web.Tests;
public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
{
private static readonly Uri Origin = new("https://localhost");
private readonly OidcWebApplicationFactory _factory;
private OidcWebApplicationFactory _factory;
public OidcIssuerTests(OidcWebApplicationFactory factory) => _factory = factory;
@ -178,6 +179,250 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
Assert.False(redirect.ContainsKey("error"));
}
private static IReadOnlyDictionary<string, string?> 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<HttpStatusCode>();
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<string, string>()));
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_the_real_client_does_not_spend_anyone_elses_permits_and_a_trailing_slash_is_no_way_around()
{
using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "4")));
using var client = factory.CreateClient(ClientOptions());
var statuses = new List<HttpStatusCode>();
foreach (var path in new[] { NewRequest().Path, NewRequest().Path, "/connect/authorize/?client_id=x", "/connect/token/", "/connect/userinfo/" })
{
using var response = await client.GetAsync(path);
statuses.Add(response.StatusCode);
}
Assert.Equal(HttpStatusCode.TooManyRequests, statuses[4]); // the slashed forms spend the same budget
Assert.DoesNotContain(HttpStatusCode.TooManyRequests, statuses.Take(4));
}
[Theory]
[InlineData("/connect/authorize")]
[InlineData("/connect/authorize/")]
[InlineData("/connect/token/")]
[InlineData("/connect/userinfo/")]
[InlineData("/CONNECT/Userinfo")]
public async Task The_brake_holds_for_every_spelling_of_an_endpoint(string path)
{
var marker = Path.Combine(Path.GetTempPath(), $"oidc-brake-{Guid.NewGuid():N}");
File.WriteAllText(marker, "braked");
try
{
using var factory = new OidcSettingsFactory(Settings(("BrakePath", marker)));
using var client = factory.CreateClient(ClientOptions());
using var response = await client.GetAsync(path == "/connect/authorize" || path == "/connect/authorize/" ? path + "?client_id=x" : path);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
finally
{
File.Delete(marker);
}
}
[Fact]
public async Task A_guard_that_cannot_decide_answers_503_and_never_passes_the_request_on()
{
var reached = false;
var options = new Bifrost.Web.Configuration.OidcIssuerOptions();
var middleware = new Bifrost.Web.Features.Oidc.OidcGuardMiddleware(
_ => { reached = true; return Task.CompletedTask; }, options,
new Bifrost.Web.Features.Oidc.OidcRateLimit(options, TimeProvider.System),
Microsoft.Extensions.Logging.Abstractions.NullLogger<Bifrost.Web.Features.Oidc.OidcGuardMiddleware>.Instance);
var http = new DefaultHttpContext();
http.Request.Path = "/connect/authorize";
http.Request.QueryString = new QueryString("?client_id=x");
http.Response.Body = new MemoryStream();
await middleware.InvokeAsync(http, System.Reflection.DispatchProxy.Create<OpenIddict.Abstractions.IOpenIddictTokenManager, ThrowingProxy>());
Assert.False(reached);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, http.Response.StatusCode);
}
/// <summary>Every call fails: the row count the guard asks of the token table cannot be answered.</summary>
public class ThrowingProxy : System.Reflection.DispatchProxy
{
protected override object? Invoke(System.Reflection.MethodInfo? targetMethod, object?[]? args) => throw new InvalidOperationException("the token table is down");
}
[Fact]
public async Task A_resumed_authorize_request_is_not_refused_by_the_row_cap_but_a_fresh_one_is()
{
using var factory = new OidcSettingsFactory(Settings(("Limits:MaxTokenRows", "3")));
_factory = factory;
var (client, _) = await SignedInAsync(MembershipStatus.Active, "Resumer");
using var first = await client.GetAsync(NewRequest().Path); // parks one row, answers with a redirect carrying the request_uri
var resume = first.Headers.Location!.ToString();
Assert.Contains("request_uri", resume);
using var anonymous = factory.CreateClient(ClientOptions());
for (var i = 0; i < 2; i++)
{
using var filler = await anonymous.GetAsync(NewRequest().Path);
}
using var fresh = await anonymous.GetAsync(NewRequest().Path);
Assert.Equal(HttpStatusCode.ServiceUnavailable, fresh.StatusCode);
using var resumed = await client.GetAsync(resume);
Assert.NotEqual(HttpStatusCode.ServiceUnavailable, resumed.StatusCode);
}
[Fact]
public async Task A_mas_flow_fits_the_default_budgets_and_the_window_reopens()
{
using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "30")));
_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<string, string>()));
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<HttpStatusCode>();
for (var i = 0; i < 5; i++)
{
using var response = await client.GetAsync(NewRequest().Path);
parked.Add(response.StatusCode);
}
Assert.Equal(3, 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(0, 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.Contains("urn:ietf:params:oauth:token-type:access_token", await factory.TokenTypesAsync()); // the live one, not merely some row
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);
}
[Fact]
public async Task The_prune_takes_a_revoked_authorization_with_its_tokens_and_touches_no_live_authorization()
{
using var factory = new OidcSettingsFactory(Settings());
_factory = factory;
var (revoked, _) = await SignedInAsync(MembershipStatus.Active, "Revoked");
await CompleteFlowAsync(revoked);
var (live, _) = await SignedInAsync(MembershipStatus.Active, "Live");
await CompleteFlowAsync(live);
Assert.Equal(2, await factory.AuthorizationRowsAsync());
Assert.Equal(6, await factory.TokensOfAuthorizationsAsync()); // a spent code, an access token and an id token each
await factory.RevokeFirstAuthorizationAsync();
factory.Clock.Advance(TimeSpan.FromMinutes(6)); // past the grace for a young row; the access tokens (an hour) are still alive
await factory.PruneAsync();
Assert.Equal(1, await factory.AuthorizationRowsAsync());
Assert.Equal(2, await factory.TokenRowsAsync()); // no orphan stays either: the revoked one's tokens went with it; the live one's access and id tokens stayed, spent codes go
}
[Theory]
[InlineData("unknown-client", OidcTestKit.RedirectUri, true)]
[InlineData(OidcTestKit.ClientId, "https://evil.example.test/callback", true)]

View file

@ -5,6 +5,7 @@ using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
@ -92,7 +93,9 @@ 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",
};
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,64 @@ public sealed class OidcFixedIdentityFactory(string identity) : OidcWebApplicati
}
}
/// <summary>An OIDC host with extra settings (limits, a brake path) applied after the kit's own.</summary>
public sealed class OidcSettingsFactory(IReadOnlyDictionary<string, string?> 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<Bifrost.Web.Features.Oidc.OidcPruner>().PruneAsync(CancellationToken.None);
public async Task<List<string?>> TokenTypesAsync()
{
using var scope = Services.CreateScope();
var manager = scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictTokenManager>();
var types = new List<string?>();
await foreach (var token in manager.ListAsync())
{
types.Add(await manager.GetTypeAsync(token));
}
return types;
}
public async Task<int> AuthorizationRowsAsync()
{
using var scope = Services.CreateScope();
return (int)await scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictAuthorizationManager>().CountAsync();
}
/// <summary>Tokens that belong to some authorization (the request and code rows carry none once spent).</summary>
public async Task<int> TokensOfAuthorizationsAsync()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
return await db.Set<OpenIddictEntityFrameworkCoreToken>().CountAsync(token => token.Authorization != null);
}
public async Task RevokeFirstAuthorizationAsync()
{
using var scope = Services.CreateScope();
var manager = scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictAuthorizationManager>();
await foreach (var authorization in manager.ListAsync(count: 1, offset: 0))
{
Assert.True(await manager.TryRevokeAsync(authorization));
}
}
public async Task<int> TokenRowsAsync()
{
using var scope = Services.CreateScope();
return (int)await scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictTokenManager>().CountAsync();
}
}
/// <summary>An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived.</summary>
public sealed class OidcForeignIssuerFactory : OidcWebApplicationFactory
{