WIP: OIDC guard (brake marker, per-address and per-client budgets, token-row cap), pruner; tests written, not yet run green
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
be44a09d52
commit
42dc46dbc6
6 changed files with 395 additions and 2 deletions
|
|
@ -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,25 @@ 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>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
|
|
|||
170
src/Bifrost.Web/Features/Oidc/OidcGuard.cs
Normal file
170
src/Bifrost.Web/Features/Oidc/OidcGuard.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class OidcGuardMiddleware(RequestDelegate next, OidcIssuerOptions options, OidcRateLimit limit, ILogger<OidcGuardMiddleware> 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<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The issuer's per-address and per-client permits, on the shared fixed-window budget.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class OidcPruner(IServiceScopeFactory scopes, OidcIssuerOptions options, TimeProvider clock, ILogger<OidcPruner> logger) : BackgroundService
|
||||
{
|
||||
public async Task PruneAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopes.CreateAsyncScope();
|
||||
var threshold = clock.GetUtcNow();
|
||||
await scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>().PruneAsync(threshold, cancellationToken);
|
||||
await scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -19,7 +19,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 +178,161 @@ 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_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<HttpStatusCode>();
|
||||
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<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(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)]
|
||||
|
|
|
|||
|
|
@ -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
|
|||
}
|
||||
}
|
||||
|
||||
/// <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<long> TokenRowsAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
return 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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue