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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/admin/realm-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ The page currently has these tabs:
identification by HTTPS-URL `client_id` (linked detail page:
[Client ID Metadata Documents](./client-id-metadata-documents)).
Off by default.
- **Native Passwordless Grants** — the per-realm master toggle for the
cookieless `urn:cocoar:*` grants (see [Native app integration](../integrate/native-apps))
- [Rate Limits](#rate-limits) — per-IP request ceilings for the realm's auth endpoints
- [Account Deletion](#account-deletion) — grace period and recycle-bin retention policy
- [Signing Keys](#signing-keys) — rotate the realm's OAuth/OIDC token-signing key

Expand Down Expand Up @@ -168,6 +171,28 @@ Off by default. See the full feature page for when to enable it, what gets accep

→ **[Dynamic Client Registration](./dynamic-client-registration)** (full feature page)

## Rate Limits

Per-IP request ceilings for this realm's auth endpoints. Each policy is a **max requests / window (minutes)** pair, partitioned by source IP and applied **per realm**. The shipped defaults are the secure production posture — the knob exists so a test realm, dev, or a legitimately bursty consumer can raise a ceiling **without a modgud code change + redeploy**, and so a hardened realm can tighten one.

| Policy | Endpoint | Default |
| --- | --- | --- |
| Native OTP request | `POST /api/account/native/otp/request` (+ native register) | 5 / 60 min |
| Magic-link request | `POST /api/account/magic-link/request` | 5 / 60 min |
| Password-reset request | `POST /api/account/forgot-password` | 5 / 60 min |
| Email-OTP login verify | `POST /api/account/email-otp/login` | 30 / 1 min |
| Email verification resend | `POST /api/account/email/send-verification` | 5 / 60 min |
| Passkey ceremony begin / enroll | `POST /connect/passkey/begin` (+ enroll) | 60 / 5 min |
| First-admin bootstrap | `POST /api/account/bootstrap-admin` | 10 / 15 min |

Notes:

- A realm that never touches this tab behaves exactly as before — the defaults are the previously-hardcoded values.
- Limits are **per realm**: raising a ceiling on one realm does not affect another on the same shared IdP.
- The limiter is in-memory and resets on app restart; over-limit requests get `429 Too Many Requests`.
- A config change takes effect within a few seconds (a short cache smooths the per-request lookup).
- This governs the **request rate**; it is independent of per-challenge attempt counters (e.g. the email-OTP `MaxAttempts` brute-force lock) and of the realm's anti-enumeration uniform responses.

## Account Deletion

Controls the account-deletion lifecycle for this realm — the self-service grace period and the admin recycle-bin retention. These replace the old hardcoded 7-day confirm-token window. The mechanics of both flows are documented under [Users → recycle bin & permanent erase](./users#recycle-bin-permanent-erase) and [Profile → Privacy](../end-user/profile#privacy).
Expand Down
2 changes: 1 addition & 1 deletion docs/integrate/native-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ From then on, the app uses **Flow 3 (passkey)** as the steady-state login.
| Client lacks the `gt:urn:cocoar:*` permission | `unauthorized_client` |
| Wrong/expired code, link, or passkey assertion | `invalid_grant` — **uniform** message + jitter (anti-enumeration); don't parse it for "which part was wrong" |
| TOTP required but missing/invalid (OTP & magic flows) | `invalid_grant` ("Two-factor authentication is required; supply totp_code.") |
| Rate limit hit (OTP request, passkey begin, token endpoint) | `429 Too Many Requests` — back off |
| Rate limit hit (OTP request, passkey begin, token endpoint) | `429 Too Many Requests` — back off. The per-IP ceilings are realm-configurable under [Realm Settings → Rate Limits](../admin/realm-settings#rate-limits) (defaults unchanged). |
| Passkey begin while realm has no primary domain | `503` (admin must set the realm/client RP-ID) |

### Discovery
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Modgud.Api.Tests.Infrastructure;
using Modgud.Application.DTOs.RealmSettings;
using Modgud.Authentication.RealmSettings;

namespace Modgud.Api.Tests.Authorization;

/// <summary>
/// Verifies the per-realm configurable auth rate-limit ceilings actually drive the
/// live ASP.NET limiter (native-otp policy as the representative case). A realm that
/// lowers the limit gets throttled sooner; one that raises it past the shipped
/// default (5/h) is allowed more. The limit is resolved per request from the realm's
/// AuthRateLimits via AuthRateLimitResolutionMiddleware (TTL=0 in Testing, so a patch
/// takes effect immediately). Requests share one budget via the X-Test-RateLimit
/// header — distinct per test so the limit value baked into the partition key keeps
/// the two tests isolated.
/// </summary>
[Collection(IntegrationTestCollection.Name)]
public class AuthRateLimitConfigTests : IntegrationTestBase
{
public AuthRateLimitConfigTests(SharedPostgresFixture fixture) : base(fixture) { }

[Fact]
public async Task LoweredNativeOtpLimit_ThrottlesSooner()
{
await SetNativeOtpLimitAsync(permitLimit: 2, windowMinutes: 60);
var anon = Factory.CreateClient();

// With the ceiling lowered to 2, the first two pass the limiter and the
// third is throttled — proving the realm override drives the live limiter.
for (var i = 1; i <= 2; i++)
{
var passed = await SendAsync(anon, "lowered", $"probe{i}@nowhere.example");
Assert.NotEqual(HttpStatusCode.TooManyRequests, passed.StatusCode);
}

var rejected = await SendAsync(anon, "lowered", "probe-over@nowhere.example");
Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode);
}

[Fact]
public async Task RaisedNativeOtpLimit_AllowsPastShippedDefault()
{
// Raise the ceiling well past the shipped default of 5/h.
await SetNativeOtpLimitAsync(permitLimit: 20, windowMinutes: 60);
var anon = Factory.CreateClient();

// Six requests — one more than the old hardcoded default — must all pass the
// limiter. Under the un-raised 5/h ceiling the sixth would have been 429.
for (var i = 1; i <= 6; i++)
{
var passed = await SendAsync(anon, "raised", $"probe{i}@nowhere.example");
Assert.NotEqual(HttpStatusCode.TooManyRequests, passed.StatusCode);
}
}

private async Task SetNativeOtpLimitAsync(int permitLimit, int windowMinutes)
{
using var scope = Factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<IHttpContextAccessor>()
.HttpContext = new DefaultHttpContext { Items = { ["TenantId"] = "system" } };
var settings = scope.ServiceProvider.GetRequiredService<IRealmSettingsService>();
var result = await settings.PatchAsync(new UpdateRealmSettingsDto
{
AuthRateLimits = new UpdateAuthRateLimitsDto
{
NativeOtp = new RateLimitRuleDto { PermitLimit = permitLimit, WindowMinutes = windowMinutes },
},
}, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}

private static Task<HttpResponseMessage> SendAsync(HttpClient client, string budget, string email)
{
var req = new HttpRequestMessage(HttpMethod.Post, "/api/account/native/otp/request")
{
Content = JsonContent.Create(new { Email = email }),
};
// Shared budget so the requests partition together; distinct per test.
req.Headers.Add("X-Test-RateLimit", $"auth-rl-config-{budget}");
return client.SendAsync(req, TestContext.Current.CancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Hosting;
using Modgud.Authentication.RealmSettings;
using Modgud.Domain.Realms;
using Modgud.Infrastructure.Persistence.Tenancy;

namespace Modgud.Api.Middleware;

/// <summary>
/// Bridges the per-realm auth rate-limit config into the ASP.NET rate limiter.
///
/// <para>The limiter's policy factories (<c>AddPolicy(...)</c> in <c>Program.cs</c>)
/// run synchronously and cannot resolve the realm's configured ceilings (an async
/// Marten lookup). This middleware does that lookup once per request — after
/// <c>RealmMiddleware</c> has resolved the tenant and before <c>UseRateLimiter</c> —
/// and stashes the realm's <see cref="AuthRateLimitSettings"/> on
/// <see cref="HttpContext.Items"/> under <see cref="ItemsKey"/>, where the factories
/// read it. Absent (e.g. an endpoint that doesn't rate-limit, or a resolution
/// failure) ⇒ the factory falls back to the shipped <see cref="AuthRateLimitDefaults"/>.</para>
///
/// <para>It only does the lookup for endpoints that actually opt into a limiter
/// policy (<see cref="EnableRateLimitingAttribute"/> metadata), and caches the
/// per-realm result for a few seconds so a flood against an anonymous auth endpoint
/// doesn't turn each would-be-throttled request into a fresh DB hit — keeping the
/// limiter's cheap-rejection property. Config edits take effect within the TTL.</para>
/// </summary>
public sealed class AuthRateLimitResolutionMiddleware(RequestDelegate next, IHostEnvironment env)
{
public const string ItemsKey = "Modgud.AuthRateLimits";

// No cache in Testing — each request reloads so a PATCH to the realm's limits
// takes effect immediately and rate-limit tests are deterministic. In every
// other environment a short TTL keeps the limiter's cheap-rejection property
// under flood; config edits take effect within the window.
private readonly TimeSpan _cacheTtl =
env.IsEnvironment("Testing") ? TimeSpan.Zero : TimeSpan.FromSeconds(10);

// realm slug → (expiry, settings). One instance for the app lifetime, so the
// cache is process-wide. A null Value is a legitimately-cached "no overrides".
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new(StringComparer.Ordinal);

private readonly record struct CacheEntry(DateTimeOffset Expires, AuthRateLimitSettings? Value);

public async Task InvokeAsync(HttpContext context)
{
if (context.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>() is not null)
{
var slug = context.Items[TenantConstants.HttpContextTenantIdKey] as string
?? TenantConstants.SystemTenantId;

if (!_cache.TryGetValue(slug, out var entry) || entry.Expires <= DateTimeOffset.UtcNow)
{
try
{
var realmSettings = context.RequestServices.GetRequiredService<IRealmSettingsService>();
var doc = await realmSettings.LoadAsync(context.RequestAborted);
entry = new CacheEntry(DateTimeOffset.UtcNow + _cacheTtl, doc.AuthRateLimits);
if (_cacheTtl > TimeSpan.Zero) _cache[slug] = entry;
}
catch
{
// Tenant not resolved / DB hiccup: leave Items unset so the
// policy factory uses the shipped defaults. Never block the
// request on a settings-resolution failure.
entry = default;
}
}

if (entry.Expires != default)
context.Items[ItemsKey] = entry.Value;
}

await next(context);
}
}
Loading
Loading