diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1bb3c83 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + + true + + + diff --git a/Dockerfile b/Dockerfile index 4f607e6..45072e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,15 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src -COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/ -RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj +COPY Directory.Build.props . +COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/packages.lock.json src/Bifrost.Web/ +RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj --locked-mode COPY . . RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \ --configuration Release \ --output /app/publish \ - /p:UseAppHost=false + /p:UseAppHost=false /p:RestoreLockedMode=true FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime WORKDIR /app diff --git a/docs/bridge.md b/docs/bridge.md index 683d7d2..d971d0c 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,15 +196,7 @@ The command is a dry-run shape probe for public surfaces that are real enough to Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona. -Persona replies cross back through two explicit CultMesh surfaces. Epiphany is -the sole writer of `epiphany.persona_discord_delivery_request.v0`; Bifrost -reads that surface, authenticates the dedicated Persona-mouth signer, journals -external execution before touching Discord, and is the sole writer of -`bifrost.persona_discord_delivery_receipt.v0`. Epiphany reads receipts but -never receives the Discord token or write access to Bifrost private state. -`unknown` is terminal for automatic transport retry: an external post may have -happened, so restart recovery must not manufacture a duplicate. Failed and -unknown outcomes remain Persona pressure for explicit resolution. +Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds//control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`. The Discord posting actuator, its permit and its RUDP listener are removed. Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority. diff --git a/docs/context.md b/docs/context.md index fdcef97..542ff8d 100644 --- a/docs/context.md +++ b/docs/context.md @@ -18,8 +18,10 @@ The product center is the Labor Platform loop: eligible participants express pol - source of deployment and infrastructure conventions - documents Yggdrasil, nginx, systemd, and local-Postgres patterns - `E:\Projects\Heimdall` - - owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation - - Bifrost consumes Heimdall identity/capability facts instead of becoming the credential vault + - authenticates: owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation + - does not issue GameCult identity: its account id (`HeimdallAccountId`) is an authenticator handle, never who a person is on GameCult + - Bifrost consumes Heimdall authentication/capability facts instead of becoming the credential vault + - remaining scar: Heimdall still mints its own account id in code (`src/app.ts` `createAccount`); chat does not need that to move, so it has not - `E:\Projects\VoidBot` - observes Discord, packages consensus, and runs repo Persona heartbeats - should call Bifrost for governed GitHub/Discord/Reddit/intake crossings instead of becoming hidden transport authority @@ -55,7 +57,7 @@ After bootstrap, the next session should start implementing the first vertical s - member alpha first - invite-only onboarding -- GitHub as the only auth provider in v1 +- Bifrost is the GameCult identity issuer: `UserAccount.Id` is the subject, and Bifrost is the OIDC provider for chat (`docs/oidc-issuer.md`); GitHub, native, and Heimdall-linked sign-in only authenticate into it - GitHub issues and internal tasks both supported - app-native governance and voting - priority and reward pressure should be transparent and auditable diff --git a/docs/jurisdiction-map.md b/docs/jurisdiction-map.md index c0a2754..a251b00 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -103,6 +103,8 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut - signed identity and capability claims - grants, consent, revocation, and capability evaluation +Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity. + Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed. ### Patron Collection Crossing @@ -153,7 +155,7 @@ Repo Personas may generate bridge requests. They do not own the bridge itself. | Concern | Owner | Reason | | --- | --- | --- | -| Who is this actor? | Heimdall | Identity and account custody must stay with the auth authority. | +| Who is this actor? | Bifrost (identity) with Heimdall (authentication) | Bifrost issues the identity; account custody and credential proof stay with the auth authority. | | Is this actor allowed to request this crossing? | Heimdall plus Bifrost | Heimdall issues grants; Bifrost applies bridge-domain policy. | | What work is being requested? | Bifrost | The request shape, priority, provenance, and lifecycle are governance concerns. | | How does interest become reward pressure? | Bifrost | Priority, bounty growth, vote weight, and reward allocation are Labor Platform concerns. | @@ -205,6 +207,7 @@ This is not the final hosted control plane. It is the current bridge actuator an - Do not add Bifrost-scoped Discord transport or dispatch receipts to arbitrary repo Persona scripts. Call Bifrost. - Do not add Bifrost-scoped Reddit posting or Persona flair handling to arbitrary repo Persona scripts. Call Bifrost. - Do not put OAuth credentials or account custody into Bifrost helper scripts as the long-term plan. Use Heimdall claims. +- Do not hand Heimdall ids, GitHub ids, or display names out as the GameCult subject. The OIDC `sub` is `UserAccount.Id`, and the localpart is `NormalizedBifrostIdentity`, minted once. - Do not make CultCache documents decide policy. They store packets; Bifrost gives those packets meaning. - Do not let a repo Persona turn a proposal into endless Aquarium pressure once it has enough shape. Route it to Bifrost as a reviewable artifact. - Do not expose bridge debug noise to public channels. Public receipts should say what happened, which Bifrost topic or work item owns it, where it landed, and what comes next. diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md new file mode 100644 index 0000000..a7f59a4 --- /dev/null +++ b/docs/oidc-issuer.md @@ -0,0 +1,113 @@ +# Bifrost OIDC issuer + +Bifrost is the GameCult identity issuer. It runs an OpenID Connect provider on its public host so that Matrix +Authentication Service (MAS) can sign chat users in with their Bifrost account. Heimdall authenticates people into +Bifrost; it does not issue the identity chat sees. + +The provider is OpenIddict (`OpenIddict.AspNetCore` and `OpenIddict.EntityFrameworkCore`, 7.7.1). Bifrost owns two +decisions at the authorize endpoint: who the chat user is, and whether they may get a session. + +## Endpoints + +The issuer is `Host:PublicBaseUrl`, normalized by .NET into an absolute URI. For a bare host that adds a trailing +slash, so for `https://bifrost.gamecult.org` the issuer string is `https://bifrost.gamecult.org/`. Configure MAS with +that exact string. + +| Endpoint | Path | +| --- | --- | +| Discovery | `/.well-known/openid-configuration` | +| JWKS | `/.well-known/jwks` | +| Authorization | `/connect/authorize` | +| Token | `/connect/token` | +| Userinfo | `/connect/userinfo` | + +Only the authorization code flow exists, and PKCE (S256) is required. There is no implicit, hybrid, password, +client-credentials or device flow, no refresh token, and no dynamic client registration. The token endpoint takes +`client_secret_basic` only. Scopes are `openid` and `profile`. + +## The one client + +Exactly one client is registered, from configuration, and seeded or updated at startup; every other application +row (with its authorizations and tokens) is deleted at startup. Any other `client_id`, an unregistered `redirect_uri`, or a request without PKCE is refused by the +provider before any sign-in redirect. + +## Configuration + +The section is `Bifrost:Oidc`. Absent, the provider surface is not mapped at all and discovery answers 404. Present, +it must be complete: an unreadable or wrong-shaped file stops startup with a message that names the setting and never +echoes its value. + +| Setting | Meaning | +| --- | --- | +| `Bifrost:Oidc:SigningKeyPath` | PEM file with an EC P-256 private key. Tokens and id_tokens are signed ES256; JWKS publishes exactly that key. A non-P-256 or public-only key stops startup. | +| `Bifrost:Oidc:EncryptionKeyPath` | File holding 32 random bytes as base64 (`openssl rand -base64 32`). Encrypts the tokens OpenIddict issues, the access token and the parked authorize request among them. | +| `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:Clients:Mas:BackchannelLogoutUri` | Optional. MAS's back-channel logout URL, `https:///upstream/backchannel-logout/`; 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 +`[workload.secret_files]` paths. + +## Claims + +| Claim | Value | +| --- | --- | +| `sub` | `UserAccount.Id`, formatted `D` | +| `preferred_username` | `NormalizedBifrostIdentity` (minted once; there is no rename path) | +| `name` | `DisplayName`, else `BifrostIdentity` | + +No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost. + +## Who may sign in + +The authorize endpoint reads the cookie session and the same actor facts the `/App` policy reads. + +- No session: challenge to `/auth/sign-in`, which resumes the original request after sign-in. OpenIddict parks the + full request (its request token, in the OpenIddict token table, expiring after ten minutes, spent when used) and + the return path carries only a short `request_uri`, so `LocalReturnPath.MaxLength` stays 256. +- Signed in, membership not `Active`: redirect to the client's `redirect_uri` with `error=access_denied` and + `error_description=The account is not an active GameCult member.` +- Signed in, `Active`, but `NormalizedBifrostIdentity` empty: `error=access_denied` with + `error_description=The account has no Bifrost identity.` +- Signed in and `Active`: a code is issued. +- Token endpoint: membership is read again; an account no longer `Active` gets `invalid_grant` and no tokens. + +## Back-channel logout + +When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the +subjects holding valid authorizations of the MAS client; for each subject whose membership is not `Active` (the same +reading the token and userinfo endpoints use; a deleted account or a subject that is not an account id counts as not +`Active`) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations, +which also invalidates their tokens. Membership is never written by this path: any way of leaving `Active` (admin +action, sync, a database edit, a future suspend) is covered because the work is derived from membership state. + +The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it, +`aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the +`http://schemas.openid.net/event/backchannel-logout` event. It carries no `nonce` and no `sid`; MAS finds the sessions +by `sub`. It is sent as `application/x-www-form-urlencoded` field `logout_token` with a 10 second timeout, and the +response body is never logged. + +Failure handling is per subject, in memory: a 5xx, a timeout or a network fault backs off exponentially from 30 seconds +to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configuration fault, logged by status only, +and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing +else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions. + +Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions. + +MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an +id_token (`iss`, `aud`, `alg`, JWKS signature), `exp` and `iat` are required, `sub` or `sid` is required, `nonce` must be +absent. Owed on the MAS side (gamecult-chat): the provider's `backchannel_logout_uri` and +`on_backchannel_logout: logout_all`. Re-check against the MAS version pinned there. + +## MAS configuration + +Configure MAS's upstream provider with the issuer above, the client id and secret, the token endpoint auth method +`client_secret_basic`, the id_token signing algorithm `ES256`, scope `openid profile`, and a localpart claim import +from `preferred_username`. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an +account. + +## Remaining scar + +Heimdall still mints its own account id in code (`src/app.ts` `createAccount`). That id is an authenticator handle +and chat does not need it to move. diff --git a/docs/research/2026-10-29-estate-tally-game-theory/README.md b/docs/research/2026-10-29-estate-tally-game-theory/README.md new file mode 100644 index 0000000..f67a9a5 --- /dev/null +++ b/docs/research/2026-10-29-estate-tally-game-theory/README.md @@ -0,0 +1,330 @@ +# Eyes: estate internal tally, game-theory probes (2026-10-29) + +Fork: Fork Desk question 39, `eureka-body:question:estate-internal-tally` (InForce; options proportional | winner-take-all; Self's recommended: proportional). Context ruling `eureka-body:ruling:governance-estate-blocks`. Session self-2026-10-29-eureka. Facts and measured results only, no recommendation. +Operator additions folded in (her words relayed by the Self): within-bloc vote-power curves ("Patrons vote power scales with donation total, but it's logarithmic... Member vote should scale with contribution... via a lower exponent"); present case ("I'm the only active member"); renormalization ruling ("normalize it by who actually voted"). + +## Code, outputs, model +Dir `sims/` (numpy 2.5.4, Python 3.13, `python -I + + + """; +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs new file mode 100644 index 0000000..8e82ed4 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -0,0 +1,284 @@ +using System.Text.Json; +using Bifrost.Web.Configuration; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace Bifrost.Web.Features.Heimdall; + +/// +/// The identity a Heimdall-signed access token asserts. Everything here comes from verified claims. +/// +public sealed record HeimdallVerifiedIdentity(string AccountId, string? DisplayName, bool HasMemberAccess); + +/// +/// Verifies Heimdall's access tokens (EdDSA) against Heimdall's published JWKS. Heimdall is the issuer +/// at ; the audience is this app's slug. Failures name a rule and +/// never echo the token or any of its contents. One instance serves the process: it owns the key cache. +/// +public sealed class HeimdallTokenVerifier( + IHttpClientFactory httpClientFactory, + IOptions heimdallOptions, + TimeProvider timeProvider) +{ + public const string TokenType = "heimdall_access"; + public const string ClientName = "heimdall-jwks"; + + internal static readonly TimeSpan ClockSkew = TimeSpan.FromSeconds(60); + internal static readonly TimeSpan KeyRefreshInterval = TimeSpan.FromSeconds(30); + internal static readonly TimeSpan KeyMaxAge = TimeSpan.FromHours(1); + + private static readonly string[] MemberCapabilities = ["member_access", "app_access"]; + + private readonly SemaphoreSlim _fetchGate = new(1, 1); + private volatile KeySnapshot _keys = new(new Dictionary(StringComparer.Ordinal), DateTimeOffset.MinValue); + private DateTimeOffset _attemptedAt = DateTimeOffset.MinValue; + + public async Task VerifyAsync( + string token, + string? payloadAccountId, + CancellationToken cancellationToken) + { + var parts = token.Split('.'); + if (parts.Length != 3) + { + throw Refuse("token is not a compact JWS"); + } + + var headerBytes = Decode(parts[0]); + var payloadBytes = Decode(parts[1]); + var signature = Decode(parts[2]); + + using var header = ParseJson(headerBytes, "token header is not JSON"); + var kid = ReadKeyId(header.RootElement); + var publicKey = await FindKeyAsync(kid, cancellationToken); + if (publicKey is null) + { + throw Refuse("token key is not published"); + } + + var signingInput = System.Text.Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"); + if (signature.Length != Ed25519.SignatureSize || + !Ed25519.Verify(signature, 0, publicKey, 0, signingInput, 0, signingInput.Length)) + { + throw Refuse("token signature is invalid"); + } + + using var payload = ParseJson(payloadBytes, "token payload is not JSON"); + return CheckClaims(payload.RootElement, heimdallOptions.Value, payloadAccountId); + } + + private sealed record KeySnapshot(Dictionary Keys, DateTimeOffset FetchedAt); + + private static byte[] Decode(string segment) + { + try + { + return WebEncoders.Base64UrlDecode(segment); + } + catch (FormatException) + { + throw Refuse("token is not base64url"); + } + } + + private static JsonDocument ParseJson(byte[] bytes, string rule) + { + try + { + return JsonDocument.Parse(bytes); + } + catch (JsonException) + { + throw Refuse(rule); + } + } + + private static string ReadKeyId(JsonElement header) + { + if (header.ValueKind != JsonValueKind.Object || StringClaim(header, "alg") != "EdDSA") + { + throw Refuse("token algorithm is not EdDSA"); + } + + var kid = StringClaim(header, "kid"); + if (string.IsNullOrEmpty(kid)) + { + throw Refuse("token has no key id"); + } + + return kid; + } + + private HeimdallVerifiedIdentity CheckClaims(JsonElement claims, HeimdallOptions options, string? payloadAccountId) + { + if (claims.ValueKind != JsonValueKind.Object) + { + throw Refuse("token payload is not an object"); + } + + if (StringClaim(claims, "iss") != options.BaseUrl.TrimEnd('/')) + { + throw Refuse("token issuer is not Heimdall"); + } + + if (StringClaim(claims, "aud") != options.AppSlug) + { + throw Refuse("token audience is not this app"); + } + + if (StringClaim(claims, "typ") != TokenType) + { + throw Refuse("token type is not an access token"); + } + + if (!claims.TryGetProperty("app", out var app) || + app.ValueKind != JsonValueKind.Object || + StringClaim(app, "slug") != options.AppSlug) + { + throw Refuse("token app is not this app"); + } + + var now = timeProvider.GetUtcNow(); + if (NumericDate(claims, "nbf") is not { } notBefore || now < notBefore - ClockSkew) + { + throw Refuse("token is not yet valid"); + } + + if (NumericDate(claims, "exp") is not { } expires || now - ClockSkew >= expires) + { + throw Refuse("token has expired"); + } + + var subject = StringClaim(claims, "sub"); + if (string.IsNullOrEmpty(subject) || + subject != StringClaim(claims, "account_id") || + subject != payloadAccountId) + { + throw Refuse("token subject does not match the account"); + } + + var capabilities = new List(); + if (claims.TryGetProperty("capabilities", out var list) && list.ValueKind == JsonValueKind.Array) + { + foreach (var capability in list.EnumerateArray()) + { + if (capability.ValueKind == JsonValueKind.String) + { + capabilities.Add(capability.GetString()!); + } + } + } + + return new HeimdallVerifiedIdentity( + subject, + StringClaim(claims, "display_name"), + capabilities.Any(capability => MemberCapabilities.Contains(capability, StringComparer.OrdinalIgnoreCase))); + } + + /// The JWKS client waits a short, bounded time: a slow fetch must not hold sign-ins for long. + public static void ConfigureClient(HttpClient client, HeimdallOptions options) + { + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); + client.Timeout = TimeSpan.FromSeconds(10); + } + + private async Task FindKeyAsync(string kid, CancellationToken cancellationToken) + { + if (TryFreshKey(kid, out var cached)) + { + return cached; + } + + await _fetchGate.WaitAsync(cancellationToken); + try + { + var now = timeProvider.GetUtcNow(); + if (now - _attemptedAt >= KeyRefreshInterval) + { + _attemptedAt = now; + _keys = new KeySnapshot(await FetchKeysAsync(cancellationToken), now); + } + + return _keys.Keys.GetValueOrDefault(kid); + } + finally + { + _fetchGate.Release(); + } + } + + private bool TryFreshKey(string kid, out byte[]? key) + { + var snapshot = _keys; + key = null; + return timeProvider.GetUtcNow() - snapshot.FetchedAt < KeyMaxAge && snapshot.Keys.TryGetValue(kid, out key); + } + + private async Task> FetchKeysAsync(CancellationToken cancellationToken) + { + try + { + return await ReadKeysAsync(cancellationToken); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + throw Refuse("Heimdall keys could not be fetched"); + } + } + + private async Task> ReadKeysAsync(CancellationToken cancellationToken) + { + var httpClient = httpClientFactory.CreateClient(ClientName); + using var response = await httpClient.GetAsync("/.well-known/jwks.json", cancellationToken); + response.EnsureSuccessStatusCode(); + await using var body = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); + var keys = new Dictionary(StringComparer.Ordinal); + foreach (var jwk in document.RootElement.GetProperty("keys").EnumerateArray()) + { + if (jwk.ValueKind != JsonValueKind.Object) + { + continue; + } + + var id = StringClaim(jwk, "kid"); + var x = StringClaim(jwk, "x"); + if (StringClaim(jwk, "kty") != "OKP" || + StringClaim(jwk, "crv") != "Ed25519" || + string.IsNullOrEmpty(id) || + x is null) + { + continue; + } + + var key = WebEncoders.Base64UrlDecode(x); + if (key.Length == Ed25519.PublicKeySize) + { + keys[id] = key; + } + } + + return keys; + } + + private static string? StringClaim(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static DateTimeOffset? NumericDate(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetInt64(out var seconds) || + seconds is < 0 or > 253402300799) + { + return null; + } + + return DateTimeOffset.FromUnixTimeSeconds(seconds); + } + + private static HeimdallAuthException Refuse(string rule) => new($"Heimdall token refused: {rule}."); +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs new file mode 100644 index 0000000..edf1dd3 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs @@ -0,0 +1,72 @@ +using Bifrost.Web.Configuration; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// Makes the provider's one client match configuration at startup, creating it or updating it in place, and removes +/// every other application (with its authorizations and tokens), so a client left by an older configuration cannot get codes. +/// +public sealed class OidcClientSeeder( + IServiceScopeFactory scopeFactory, + OidcIssuerOptions options, + OidcKeyMaterial material, + IOptions serverOptions) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + // Reading the server options now runs their configuration, so a bad issuer stops startup rather than the first request. + _ = serverOptions.Value; + + await using var scope = scopeFactory.CreateAsyncScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + var mas = options.Clients.Mas; + + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = mas.ClientId, + ClientSecret = material.ClientSecret, + ClientType = ClientTypes.Confidential, + DisplayName = "Matrix Authentication Service", + RedirectUris = { new Uri(mas.RedirectUri, UriKind.Absolute) }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.ResponseTypes.Code, + Permissions.Scopes.Profile, + Permissions.Prefixes.Scope + Scopes.OpenId + } + }; + + var existing = await manager.FindByClientIdAsync(mas.ClientId, cancellationToken); + if (existing is null) + { + await manager.CreateAsync(descriptor, cancellationToken); + } + else + { + await manager.UpdateAsync(existing, descriptor, cancellationToken); + } + + var applications = new List(); + await foreach (var application in manager.ListAsync(cancellationToken: cancellationToken)) + { + applications.Add(application); + } + + foreach (var application in applications) + { + if (!string.Equals(await manager.GetClientIdAsync(application, cancellationToken), mas.ClientId, StringComparison.Ordinal)) + { + await manager.DeleteAsync(application, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs new file mode 100644 index 0000000..b435347 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -0,0 +1,142 @@ +using System.Security.Claims; +using Microsoft.AspNetCore; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// The decisions Bifrost owns at the OIDC provider: who the chat user is (sub = UserAccount.Id, +/// preferred_username = NormalizedBifrostIdentity) and whether they may get a session (membership Active). +/// OpenIddict does the protocol; these endpoints read the cookie session and the same actor facts the /App policy reads. +/// +public static class OidcEndpoints +{ + public const string NotActiveDescription = "The account is not an active GameCult member."; + public const string NoIdentityDescription = "The account has no Bifrost identity."; + + private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme; + private const string IdentityAuthenticationType = "Bifrost.Oidc"; + + public static void MapBifrostOidc(this IEndpointRouteBuilder app) + { + app.MapMethods(OidcServiceCollectionExtensions.AuthorizationPath, ["GET", "POST"], (Delegate)AuthorizeAsync).AllowAnonymous(); + app.MapPost(OidcServiceCollectionExtensions.TokenPath, (Delegate)ExchangeAsync).AllowAnonymous(); + app.MapMethods(OidcServiceCollectionExtensions.UserInfoPath, ["GET", "POST"], (Delegate)UserInfoAsync).AllowAnonymous(); + } + + private static async Task AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors) + { + var request = http.GetOpenIddictServerRequest() + ?? throw new InvalidOperationException("The OpenIddict request could not be retrieved."); + + if (http.User.Identity?.IsAuthenticated != true) + { + // OpenIddict has already replaced the request with a short request_uri, so this URL fits a sign-in + // return path (LocalReturnPath.MaxLength); a test pins that, because Parse would otherwise fall back to /App. + return Results.Challenge( + new AuthenticationProperties { RedirectUri = http.Request.PathBase + http.Request.Path + http.Request.QueryString }, + [CookieAuthenticationDefaults.AuthenticationScheme]); + } + + var actor = await actors.GetAsync(http.RequestAborted); + if (actor.UserAccount is null || !actor.IsActiveMember) + { + return Deny(Errors.AccessDenied, NotActiveDescription); + } + + var account = actor.UserAccount; + if (string.IsNullOrWhiteSpace(account.NormalizedBifrostIdentity)) + { + return Deny(Errors.AccessDenied, NoIdentityDescription); + } + + var identity = new ClaimsIdentity(IdentityAuthenticationType, Claims.Name, Claims.Role); + identity.SetClaim(Claims.Subject, account.Id.ToString("D")); + identity.SetClaim(Claims.PreferredUsername, account.NormalizedBifrostIdentity); + identity.SetClaim( + Claims.Name, + string.IsNullOrWhiteSpace(account.DisplayName) ? account.BifrostIdentity : account.DisplayName); + + var principal = new ClaimsPrincipal(identity); + principal.SetScopes(request.GetScopes()); + principal.SetDestinations(claim => claim.Type switch + { + Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken], + Claims.PreferredUsername or Claims.Name when principal.HasScope(Scopes.Profile) => + [Destinations.AccessToken, Destinations.IdentityToken], + _ => [Destinations.AccessToken] + }); + + return Results.SignIn(principal, properties: null, OpenIddictScheme); + } + + private static async Task ExchangeAsync(HttpContext http, BifrostDbContext db) + { + // Only the authorization code grant is enabled, so OpenIddict has refused every other grant already. + // The code's own principal carries the claims chosen at authorize time; membership is read again here, + // because the code outlives the authorize decision (a member suspended since gets no tokens). + var result = await http.AuthenticateAsync(OpenIddictScheme); + var subject = result.Principal?.GetClaim(Claims.Subject); + if (!await IsStillActiveMemberAsync(db, subject, http.RequestAborted)) + { + return Deny(Errors.InvalidGrant, NotActiveDescription); + } + + return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme); + } + + private static async Task UserInfoAsync(HttpContext http, BifrostDbContext db) + { + var result = await http.AuthenticateAsync(OpenIddictScheme); + if (!result.Succeeded || result.Principal is null) + { + return Results.Challenge(authenticationSchemes: [OpenIddictScheme]); + } + + // An access token outlives the authorize decision too: a member suspended since is no longer answered. + if (!await IsStillActiveMemberAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted)) + { + return Deny(Errors.InsufficientAccess, NotActiveDescription); + } + + var claims = new Dictionary { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) }; + if (result.Principal.HasScope(Scopes.Profile)) + { + claims[Claims.PreferredUsername] = result.Principal.GetClaim(Claims.PreferredUsername); + claims[Claims.Name] = result.Principal.GetClaim(Claims.Name); + } + + return Results.Json(claims); + } + + /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler. + internal static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + { + if (!Guid.TryParse(subject, out var accountId)) + { + return false; + } + + var account = await db.UserAccounts.AsNoTracking() + .Include(x => x.Membership) + .SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken); + return new CurrentBifrostActor(true, account).IsActiveMember; + } + + private static IResult Deny(string error, string description) => + Results.Forbid( + new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = error, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description + }), + [OpenIddictScheme]); +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs new file mode 100644 index 0000000..8f0be75 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs @@ -0,0 +1,140 @@ +using System.Security.Cryptography; +using Bifrost.Web.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Bifrost.Web.Features.Oidc; + +/// A configuration fault in the OIDC provider. The message names the setting, never its value. +public sealed class OidcConfigurationException(string message) : InvalidOperationException(message); + +/// +/// The secrets the OIDC provider runs on, read once from the files names. Any +/// unreadable or wrong-shaped file stops startup; there is no fallback key. +/// +public sealed class OidcKeyMaterial : IDisposable +{ + private const string NistP256Oid = "1.2.840.10045.3.1.7"; + private const int EncryptionKeyBytes = 32; + + private readonly ECDsa _signingEcdsa; + + private OidcKeyMaterial(ECDsa signingEcdsa, SecurityKey signingKey, SecurityKey encryptionKey, string clientSecret) + { + _signingEcdsa = signingEcdsa; + SigningKey = signingKey; + EncryptionKey = encryptionKey; + ClientSecret = clientSecret; + } + + public SecurityKey SigningKey { get; } + + public SecurityKey EncryptionKey { get; } + + public string ClientSecret { get; } + + public static OidcKeyMaterial Load(OidcIssuerOptions options) + { + var mas = options.Clients.Mas; + Require(options.SigningKeyPath, "Bifrost:Oidc:SigningKeyPath"); + Require(options.EncryptionKeyPath, "Bifrost:Oidc:EncryptionKeyPath"); + Require(mas.ClientId, "Bifrost:Oidc:Clients:Mas:ClientId"); + Require(mas.ClientSecretPath, "Bifrost:Oidc:Clients:Mas:ClientSecretPath"); + Require(mas.RedirectUri, "Bifrost:Oidc:Clients:Mas:RedirectUri"); + // A rooted path such as "/callback" parses as an absolute file URI on Linux, so the scheme is checked too. + if (!Uri.TryCreate(mas.RedirectUri, UriKind.Absolute, out var redirect) || + redirect.Scheme is not ("https" or "http")) + { + throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:RedirectUri must be an absolute http(s) URI."); + } + + if (!string.IsNullOrWhiteSpace(mas.BackchannelLogoutUri) && + (!Uri.TryCreate(mas.BackchannelLogoutUri, UriKind.Absolute, out var logout) || + logout.Scheme is not ("https" or "http"))) + { + throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri must be an absolute http(s) URI."); + } + + var ecdsa = LoadSigningKey(options.SigningKeyPath); + try + { + var signingKey = new ECDsaSecurityKey(ecdsa) + { + KeyId = Base64UrlEncoder.Encode(SHA256.HashData(ecdsa.ExportSubjectPublicKeyInfo())) + }; + return new OidcKeyMaterial( + ecdsa, + signingKey, + new SymmetricSecurityKey(LoadEncryptionKey(options.EncryptionKeyPath)), + ReadFile(mas.ClientSecretPath, "Bifrost:Oidc:Clients:Mas:ClientSecretPath").Trim()); + } + catch + { + ecdsa.Dispose(); + throw; + } + } + + public void Dispose() => _signingEcdsa.Dispose(); + + private static ECDsa LoadSigningKey(string path) + { + const string field = "Bifrost:Oidc:SigningKeyPath"; + var pem = ReadFile(path, field); + var ecdsa = ECDsa.Create(); + try + { + ecdsa.ImportFromPem(pem); + if (ecdsa.ExportParameters(false).Curve.Oid.Value != NistP256Oid) + { + throw new CryptographicException(); + } + + // ImportFromPem accepts a public key; a signing key needs the private half. + ecdsa.ExportParameters(true); + return ecdsa; + } + catch (Exception error) when (error is CryptographicException or ArgumentException) + { + ecdsa.Dispose(); + throw new OidcConfigurationException($"{field} must name a PEM file holding an EC P-256 private key."); + } + } + + private static byte[] LoadEncryptionKey(string path) + { + const string field = "Bifrost:Oidc:EncryptionKeyPath"; + try + { + var key = Convert.FromBase64String(ReadFile(path, field).Trim()); + if (key.Length == EncryptionKeyBytes) + { + return key; + } + } + catch (FormatException) + { + } + + throw new OidcConfigurationException($"{field} must name a file holding {EncryptionKeyBytes} bytes as base64."); + } + + private static string ReadFile(string path, string field) + { + try + { + return File.ReadAllText(path); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + throw new OidcConfigurationException($"{field} must name a readable file."); + } + } + + private static void Require(string value, string field) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new OidcConfigurationException($"{field} is required when Bifrost:Oidc is configured."); + } + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs new file mode 100644 index 0000000..ac6fa72 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -0,0 +1,220 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// Ends the chat sessions of a subject who is no longer an Active member. Each tick it lists the subjects holding valid +/// authorizations of the configured client; for each one the shared active-member reading refuses, it POSTs one signed +/// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The +/// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it. +/// Per-subject backoff lives in memory only. One subject's unexpected exception is logged and skipped, never aborting the +/// pass. It never writes membership. +/// +public sealed class OidcLogoutReconciler( + IServiceScopeFactory scopeFactory, + OidcIssuerOptions options, + OidcKeyMaterial material, + IOptions serverOptions, + IHttpClientFactory httpClients, + TimeProvider time, + ILogger logger) : BackgroundService +{ + public const string HttpClientName = "bifrost-oidc-logout"; + public const string LogoutEvent = "http://schemas.openid.net/event/backchannel-logout"; + public const string LogoutTokenType = "logout+jwt"; + + public static readonly TimeSpan Interval = TimeSpan.FromSeconds(30); + public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + public static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(2); + public static readonly TimeSpan MaxBackoff = TimeSpan.FromMinutes(15); + + private readonly Dictionary _backoff = new(StringComparer.Ordinal); + + private sealed record Backoff(int Failures, DateTimeOffset NotBefore); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ReconcileOnceAsync(stoppingToken); + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception error) + { + // The type only: messages can carry connection strings or URLs. + logger.LogError("Back-channel logout reconcile failed: {ErrorType}", error.GetType().Name); + try + { + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + } + + /// One reconcile pass. Subjects are independent: one that cannot be delivered never blocks another. + public async Task ReconcileOnceAsync(CancellationToken cancellationToken) + { + var subjects = await ListSubjectsAsync(cancellationToken); + foreach (var gone in _backoff.Keys.Except(subjects).ToArray()) + { + _backoff.Remove(gone); + } + + foreach (var subject in subjects) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await ReconcileSubjectAsync(subject, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception error) + { + // One subject's fault never stops the pass: its authorizations stay valid and the next tick retries it. + // The subject id and the exception type only: messages can carry connection strings or URLs. + logger.LogError("Back-channel logout for subject {Subject} failed: {ErrorType}", subject, error.GetType().Name); + } + } + } + + private async Task> ListSubjectsAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var applications = scope.ServiceProvider.GetRequiredService(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + + var subjects = new HashSet(StringComparer.Ordinal); + var application = await applications.FindByClientIdAsync(options.Clients.Mas.ClientId, cancellationToken); + if (application is null || await applications.GetIdAsync(application, cancellationToken) is not { } applicationId) + { + return subjects; + } + + await foreach (var authorization in authorizations.FindByApplicationIdAsync(applicationId, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken) && + await authorizations.GetSubjectAsync(authorization, cancellationToken) is { Length: > 0 } subject) + { + subjects.Add(subject); + } + } + + return subjects; + } + + private async Task ReconcileSubjectAsync(string subject, CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (await OidcEndpoints.IsStillActiveMemberAsync(db, subject, cancellationToken)) + { + _backoff.Remove(subject); + return; + } + + // A subject that recovered is forgotten at once; one still owed a logout waits out its backoff. + _backoff.TryGetValue(subject, out var previous); + if (previous is not null && time.GetUtcNow() < previous.NotBefore) + { + return; + } + + var failures = (previous?.Failures ?? 0) + 1; + var status = await SendLogoutAsync(subject, cancellationToken); + if (status is { } code && (int)code is >= 200 and < 300) + { + var authorizations = scope.ServiceProvider.GetRequiredService(); + await foreach (var authorization in authorizations.FindBySubjectAsync(subject, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken)) + { + await authorizations.TryRevokeAsync(authorization, cancellationToken); + } + } + + _backoff.Remove(subject); + return; + } + + // 400 (token refused) and 404 (unknown provider) are configuration faults: retrying sooner cannot fix them. + var fault = status is HttpStatusCode.BadRequest or HttpStatusCode.NotFound; + var delay = fault ? MaxBackoff : BackoffFor(failures); + _backoff[subject] = new Backoff(failures, time.GetUtcNow() + delay); + logger.LogWarning( + "Back-channel logout to MAS failed with {Status}; retrying in {Delay}", + status is { } s ? (int)s : "no response", + delay); + } + + internal static TimeSpan BackoffFor(int failures) + { + var seconds = Interval.TotalSeconds * Math.Pow(2, Math.Min(failures, 16) - 1); + return TimeSpan.FromSeconds(Math.Min(seconds, MaxBackoff.TotalSeconds)); + } + + /// The status MAS answered, or null on a network fault or timeout. The response body is never read. + private async Task SendLogoutAsync(string subject, CancellationToken cancellationToken) + { + var token = BuildLogoutToken(subject); + using var request = new HttpRequestMessage(HttpMethod.Post, options.Clients.Mas.BackchannelLogoutUri) + { + Content = new FormUrlEncodedContent([new KeyValuePair("logout_token", token)]) + }; + try + { + using var response = await httpClients.CreateClient(HttpClientName) + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return response.StatusCode; + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + return null; + } + } + + /// + /// The OIDC back-channel logout token MAS verifies like an id_token: iss and aud as the id_token carries them, exp + /// and iat both present, an events member, and neither nonce nor sid (MAS finds the session by sub). + /// + private string BuildLogoutToken(string subject) + { + var now = time.GetUtcNow(); + var descriptor = new SecurityTokenDescriptor + { + TokenType = LogoutTokenType, + Issuer = serverOptions.Value.Issuer!.AbsoluteUri, + Audience = options.Clients.Mas.ClientId, + IssuedAt = now.UtcDateTime, + Expires = now.UtcDateTime + TokenLifetime, + SigningCredentials = new SigningCredentials(material.SigningKey, SecurityAlgorithms.EcdsaSha256), + Claims = new Dictionary + { + [Claims.Subject] = Guid.TryParse(subject, out var id) ? id.ToString("D") : subject, + [Claims.JwtId] = Guid.NewGuid().ToString("D"), + ["events"] = new Dictionary { [LogoutEvent] = new Dictionary() } + } + }; + return new JsonWebTokenHandler().CreateToken(descriptor); + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs new file mode 100644 index 0000000..5ad30b8 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs @@ -0,0 +1,81 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +public static class OidcServiceCollectionExtensions +{ + public const string AuthorizationPath = "/connect/authorize"; + public const string TokenPath = "/connect/token"; + public const string UserInfoPath = "/connect/userinfo"; + + /// How long a parked authorize request (OpenIddict's request token) can wait for the user to sign in. + private static readonly TimeSpan ParkedRequestLifetime = TimeSpan.FromMinutes(10); + + /// + /// Registers Bifrost as an OIDC provider whose issuer is : authorization-code flow with + /// PKCE, ES256 id_tokens, scopes openid and profile, one configured client. Keys come from files, never from + /// generated development keys. The authorize request is parked by OpenIddict's own request caching, so the + /// sign-in return path carries only a short request_uri. + /// + public static IServiceCollection AddBifrostOidc(this IServiceCollection services, OidcIssuerOptions options) + { + var material = OidcKeyMaterial.Load(options); + services.AddSingleton(material); + services.AddSingleton(options); + + services.AddOpenIddict() + .AddCore(core => core.UseEntityFrameworkCore().UseDbContext()) + .AddServer(server => + { + server.SetAuthorizationEndpointUris(AuthorizationPath) + .SetTokenEndpointUris(TokenPath) + .SetUserInfoEndpointUris(UserInfoPath); + + server.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange(); + server.RegisterScopes(Scopes.OpenId, Scopes.Profile); + server.RegisterClaims(Claims.Subject, Claims.PreferredUsername, Claims.Name); + server.EnableAuthorizationRequestCaching(); + server.Configure(opts => + { + opts.ClientAuthenticationMethods.Clear(); + opts.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.ClientSecretBasic); + opts.RequestTokenLifetime = ParkedRequestLifetime; + }); + + server.AddSigningKey(material.SigningKey); + server.AddEncryptionKey(material.EncryptionKey); + + server.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableTokenEndpointPassthrough() + .EnableUserInfoEndpointPassthrough(); + }); + + // The issuer is the public host, read from the bound options so it has one source. + services.AddOptions().Configure>((server, host) => + { + if (!Uri.TryCreate(host.Value.PublicBaseUrl, UriKind.Absolute, out var issuer)) + { + throw new OidcConfigurationException("Host:PublicBaseUrl is required when Bifrost:Oidc is configured."); + } + + server.Issuer = issuer; + }); + + services.AddHostedService(); + if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri)) + { + // MAS answers the logout POST itself: a redirect is a non-delivery, never followed to some other 2xx. + services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false }); + services.AddHostedService(); + } + + return services; + } +} diff --git a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs new file mode 100644 index 0000000..b6c9083 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs @@ -0,0 +1,131 @@ +namespace Bifrost.Web.Features.Shared; + +// Why a permit was refused and how long until the answer can change. TableFull is not the caller's own spending: the +// table holds only spent windows and has no room for a key it does not know. +public readonly record struct BudgetDenial(TimeSpan RetryAfter, bool TableFull); + +// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, timed on the injected +// clock's monotonic timestamps (never its wall time, which can step), so a clock adjustment cannot stretch or reset a +// window. Windows all have one length, so they end in the order they began: a list of entries in start order expires +// them from the front and the table never holds a key past its window. +// The table holds at most maxKeys keys. A new key arriving at the cap evicts the least recently used key that still +// has permits left (nginx limit_req_zone does the same); a key that has spent all its permits is never evicted, so a +// rotating caller can at most win back a forgotten key's partial budget and never a spent one, and nobody fresh is +// refused because the table is full. Only when every held key is spent is a new key refused, with the true wait. +// Every step is O(1): two linked lists, one by window start and one by last use among keys with permits left. +// System.Threading.RateLimiting is not used because its limiters time themselves on Stopwatch and timers, which a +// test cannot move. +public sealed class FixedWindowBudget +{ + private readonly int permits; + private readonly TimeSpan window; + private readonly int maxKeys; + private readonly TimeProvider clock; + private readonly long origin; + private readonly Dictionary entries = new(StringComparer.Ordinal); + private readonly LinkedList byStart = new(); + private readonly LinkedList byUse = new(); + private readonly object gate = new(); + + public FixedWindowBudget(int permits, TimeSpan window, int maxKeys, TimeProvider clock) + { + ArgumentOutOfRangeException.ThrowIfLessThan(permits, 1); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(window, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(maxKeys, 1); + this.permits = permits; + this.window = window; + this.maxKeys = maxKeys; + this.clock = clock; + origin = clock.GetTimestamp(); + } + + // How many keys are held now: the keys whose window is still open, never more than maxKeys. + public int TrackedKeys + { + get + { + lock (gate) + { + return entries.Count; + } + } + } + + // Takes one permit for the key and returns null, or returns why it could not. + public BudgetDenial? Spend(string key) + { + lock (gate) + { + var now = clock.GetElapsedTime(origin); + while (byStart.First is { Value: var oldest } && oldest.Start + window <= now) + { + Remove(oldest); + } + + if (entries.TryGetValue(key, out var entry)) + { + if (entry.Count >= permits) + { + return new BudgetDenial(entry.Start + window - now, false); + } + + entry.Count++; + if (entry.Count >= permits) + { + byUse.Remove(entry.UseNode!); + entry.UseNode = null; + } + else + { + byUse.Remove(entry.UseNode!); + byUse.AddLast(entry.UseNode!); + } + + return null; + } + + if (entries.Count >= maxKeys) + { + if (byUse.First is not { Value: var forgotten }) + { + return new BudgetDenial(byStart.First!.Value.Start + window - now, true); + } + + Remove(forgotten); + } + + var added = new Entry(key, now); + entries[key] = added; + added.StartNode = byStart.AddLast(added); + if (added.Count < permits) + { + added.UseNode = byUse.AddLast(added); + } + + return null; + } + } + + private void Remove(Entry entry) + { + entries.Remove(entry.Key); + byStart.Remove(entry.StartNode!); + if (entry.UseNode is not null) + { + byUse.Remove(entry.UseNode); + } + } + + private sealed class Entry(string key, TimeSpan start) + { + public string Key { get; } = key; + + public TimeSpan Start { get; } = start; + + public int Count { get; set; } = 1; + + public LinkedListNode? StartNode { get; set; } + + public LinkedListNode? UseNode { get; set; } + } +} diff --git a/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs new file mode 100644 index 0000000..1de135b --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs @@ -0,0 +1,34 @@ +namespace Bifrost.Web.Features.Shared; + +/// +/// A sign-in return target that stays on this site. The only way to make one is , which +/// accepts a path of at most characters that starts with a single slash and holds no +/// control character, and otherwise yields . Callers parse once at entry and carry the typed value from there. +/// +public sealed record LocalReturnPath +{ + private LocalReturnPath(string value) => Value = value; + + /// Bounds the path so the attempt cookie that carries it stays far below a browser's 4096-byte limit. + public const int MaxLength = 256; + + public static LocalReturnPath Default { get; } = new("/App"); + + public string Value { get; } + + public static LocalReturnPath Parse(string? candidate) + { + if (string.IsNullOrEmpty(candidate) || + candidate.Length > MaxLength || + candidate[0] != '/' || + candidate.Any(char.IsControl) || + (candidate.Length > 1 && (candidate[1] == '/' || candidate[1] == '\\'))) + { + return Default; + } + + return new LocalReturnPath(candidate); + } + + public override string ToString() => Value; +} diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index c511413..98b8dc7 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -16,12 +16,40 @@ public sealed class StartupConfigurationValidator( { public void Validate() { - if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) + var failures = new List(); + + // Checked in every environment: a bad value here would otherwise pass startup and throw on every cast. + var conceptVotes = conceptVoteOptions.Value; + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) { - return; + failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); } - var failures = new List(); + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits)) + { + failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}."); + } + + if (conceptVotes.CastPermits < 1 || conceptVotes.CastWindow <= TimeSpan.Zero) + { + failures.Add("ConceptVotes:CastPermits must be at least 1 and ConceptVotes:CastWindow must be positive."); + } + + if (conceptVotes.MintPermits < 1 || conceptVotes.MintWindow <= TimeSpan.Zero) + { + failures.Add("ConceptVotes:MintPermits must be at least 1 and ConceptVotes:MintWindow must be positive."); + } + + if (conceptVotes.MaxTrackedKeys < 1) + { + failures.Add("ConceptVotes:MaxTrackedKeys must be at least 1."); + } + + if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) + { + Refuse(failures); + return; + } if (!gitHubOAuthOptions.Value.IsConfigured) { @@ -73,7 +101,6 @@ public sealed class StartupConfigurationValidator( failures.Add("DataProtection:KeysPath must be configured for production."); } - var conceptVotes = conceptVoteOptions.Value; if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) || conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength) { @@ -81,21 +108,16 @@ public sealed class StartupConfigurationValidator( $"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production."); } - if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) - { - failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); - } - - if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits)) - { - failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}."); - } - if (!conceptVotes.AllowedOrigins.All(IsExactHttpsOrigin)) { failures.Add("ConceptVotes:AllowedOrigins entries must each be exactly https://host with no path, wildcard or trailing slash."); } + Refuse(failures); + } + + private static void Refuse(List failures) + { if (failures.Count > 0) { throw new InvalidOperationException( diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index 525811d..dd87f6d 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -17,6 +17,7 @@ public sealed class VoteModel( ICurrentBifrostActorAccessor actorAccessor, ConceptAppraisalService appraisals, VoterKeyResolver deviceCookie, + ConceptVoteRateLimits limits, IOptions options, IOptions host) : PageModel { @@ -61,7 +62,7 @@ public sealed class VoteModel( public async Task OnGetAsync(string collection, string key, CancellationToken cancellationToken) { var actor = await actorAccessor.GetAsync(cancellationToken); - var voter = ResolveVoter(actor, mint: false); + var voter = ResolveVoter(actor); SignedIn = voter?.UserAccountId is not null; return await LoadAsync(collection, key, voter, cancellationToken) ? Page() : NotFound(); } @@ -75,12 +76,17 @@ public sealed class VoteModel( public async Task OnPostRetractAsync(string collection, string key, CancellationToken cancellationToken) { var actor = await actorAccessor.GetAsync(cancellationToken); + var voter = ResolveVoter(actor); if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) { return NotFound(); } - var voter = ResolveVoter(actor, mint: false); + if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal, voter) is { } throttled) + { + return throttled; + } + await appraisals.RetractAsync(collection, key, voter, cancellationToken); return RedirectToPage(new { collection, key }); } @@ -92,12 +98,28 @@ public sealed class VoteModel( CancellationToken cancellationToken) { var actor = await actorAccessor.GetAsync(cancellationToken); + var client = HttpContext.Connection.RemoteIpAddress; + var voter = ResolveVoter(actor); if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) { return NotFound(); } - var voter = ResolveVoter(actor, mint: true); + if (Throttled(limits.DenyCast(client, voter), CastRefusal, voter) is { } throttled) + { + return throttled; + } + + if (voter is null) + { + if (Throttled(limits.DenyMint(client), MintRefusal, voter) is { } mintThrottled) + { + return mintThrottled; + } + + voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext)); + } + var result = await appraisals.CastAsync( collection, key, voter, stance, HttpContext.Connection.RemoteIpAddress, cancellationToken); return result is CastResult.UnknownSubject or CastResult.RetiredSubject @@ -106,16 +128,41 @@ public sealed class VoteModel( } // Accounts key to the account and never touch the cookie. An anonymous visitor keys to a device cookie that - // unprotects to a well-formed id; a cast without one mints it, and no other path does. - private VoterIdentity? ResolveVoter(CurrentBifrostActor actor, bool mint) + // unprotects to a well-formed id; a cast without one mints it (CastAsync, after the mint budget), and no other path does. + private VoterIdentity? ResolveVoter(CurrentBifrostActor actor) => + VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); + + // A denied budget is a 429 that says when to retry, in words a voter can read; it writes no row, sets no cookie and + // repeats nothing of the request. The subject is checked before any budget is spent, so a request for a concept that + // does not exist costs the sender nothing and the table nothing. A refused new device is told it can sign in, which + // has a budget of its own. A table full of spent windows is not the visitor's doing and says so. + private const string CastRefusal = "You are voting too fast. Please wait a little before voting again."; + + private const string MintRefusal = + "Too many new voters have voted from your network recently. You can sign in to vote now, or try again later."; + + private const string BusyRefusal = "Voting is very busy right now. Please try again in a little while."; + + // Accounts have their own table, so a visitor who is not signed in can still vote by signing in while a table is full. + private const string BusyRefusalSignIn = + "Voting is very busy right now. You can sign in to vote now, or try again in a little while."; + + private IActionResult? Throttled(BudgetDenial? denial, string spentRefusal, VoterIdentity? voter) { - var voter = VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); - if (voter is null && mint) + if (denial is not { } refused) { - voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext)); + return null; } - return voter; + var busy = voter?.UserAccountId is null ? BusyRefusalSignIn : BusyRefusal; + var refusal = refused.TableFull ? busy : spentRefusal; + Response.Headers.RetryAfter = ((int)Math.Ceiling(refused.RetryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); + return new ContentResult + { + StatusCode = StatusCodes.Status429TooManyRequests, + ContentType = "text/plain; charset=utf-8", + Content = refusal + }; } private async Task LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken) diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 94276e1..d0656d3 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -11,6 +11,7 @@ using Bifrost.Web.Features.GitHub; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Motions; +using Bifrost.Web.Features.Oidc; using Bifrost.Web.Features.Patronage; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authentication; @@ -119,17 +120,18 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); builder.Services.AddHttpClient((serviceProvider, client) => -{ - var options = serviceProvider.GetRequiredService>().Value; - client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); -}); + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) + .RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase)); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => + HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.stripe.com/"); @@ -224,6 +226,13 @@ builder.Services.AddAuthorization(options => builder.Services.AddConceptTallyCors(); +// The OIDC provider is mapped only when Bifrost:Oidc is configured; a configured provider that cannot load its keys stops startup. +var oidcOptions = OidcIssuerOptions.FromConfiguration(builder.Configuration); +if (oidcOptions is not null) +{ + builder.Services.AddBifrostOidc(oidcOptions); +} + var app = builder.Build(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); @@ -266,6 +275,11 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke app.MapConceptTallyExport(); +if (oidcOptions is not null) +{ + app.MapBifrostOidc(); +} + app.MapPost("/github/webhooks", async ( HttpRequest request, GitHubWebhookService gitHubWebhookService, @@ -299,22 +313,18 @@ app.MapPost("/github/webhooks", async ( app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions gitHubOptions) => { - var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); - if (string.IsNullOrWhiteSpace(returnUrl)) - { - returnUrl = "/App"; - } + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); var provider = httpContext.Request.Query["provider"].ToString(); if (string.IsNullOrWhiteSpace(provider)) { - await WriteSignInChooserAsync(httpContext, returnUrl); + await WriteSignInChooserAsync(httpContext, returnPath.Value); return; } if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase)) { - httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnUrl)}"); + httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnPath.Value)}"); return; } @@ -326,7 +336,7 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions hostOptions, + ILogger logger, CancellationToken cancellationToken) => { try { - var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); - if (string.IsNullOrWhiteSpace(returnUrl)) - { - returnUrl = "/App"; - } - + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); var requireMemberAccess = string.Equals( httpContext.Request.Query["access"].ToString(), "member", @@ -384,30 +390,18 @@ app.MapGet("/auth/heimdall/{provider}", async ( var start = await heimdallAuthService.StartAsync( provider, hostOptions.Value.PublicBaseUrl, - returnUrl, + returnPath, requireMemberAccess, + httpContext.Request.Cookies, cancellationToken); + httpContext.Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options()); return Results.Redirect(start.AuthorizationUrl.ToString()); } catch (HeimdallAuthException error) { - return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); - } -}).AllowAnonymous(); - -app.MapPost("/auth/heimdall/callback", ( - HeimdallBackendCallbackPayload payload, - HeimdallAuthService heimdallAuthService) => -{ - try - { - heimdallAuthService.StoreBackendCallback(payload); - return Results.Accepted(); - } - catch (HeimdallAuthException error) - { - return Results.BadRequest(error.Message); + logger.LogWarning("Heimdall start refused: {Reason}", error.Message); + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -533,31 +527,44 @@ eveGovernance.MapGet("/surface", async ( "application/json"); }); -app.MapGet("/auth/heimdall/wait", async ( +app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) => +{ + httpContext.Response.Headers.CacheControl = "no-store"; + httpContext.Response.Headers["Referrer-Policy"] = "no-referrer"; + return Results.Content(HeimdallCompletePage.Html, "text/html", System.Text.Encoding.UTF8); +}).AllowAnonymous(); + +app.MapPost("/auth/heimdall/complete", async ( HttpContext httpContext, HeimdallAuthService heimdallAuthService, + IOptions hostOptions, + ILogger logger, CancellationToken cancellationToken) => { - var attemptId = httpContext.Request.Query["attemptId"].ToString(); - if (string.IsNullOrWhiteSpace(attemptId)) - { - return Results.Redirect("/?auth=heimdall-error&detail=missing-attempt-id"); - } - + var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); + var attempt = form?["attempt"].ToString(); + var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt); try { - var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken); + var signIn = await heimdallAuthService.CompleteAsync( + hostOptions.Value.PublicBaseUrl, + attempt, + held?.Value, + form?["code"].ToString(), + cancellationToken); await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); - return Results.Redirect(signIn.RedirectUri); - } - catch (HeimdallAuthPendingException) - { - httpContext.Response.Headers.Append("Refresh", "2"); - return Results.Text("Waiting for Heimdall sign-in to complete.", "text/plain"); + httpContext.Response.Cookies.Delete(held!.Value.Name, HeimdallAttemptCookie.DeleteOptions()); + return Results.LocalRedirect(signIn.ReturnPath.Value); } catch (HeimdallAuthException error) { - return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); + logger.LogWarning("Heimdall completion refused: {Reason}", error.Message); + if (held is not null) + { + httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions()); + } + + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -577,6 +584,23 @@ static IResult JsonResult(T value, JsonSerializerOptions options, int statusC "application/json", statusCode: statusCode); +static async Task ReadFormOrNullAsync(HttpRequest request, CancellationToken cancellationToken) +{ + if (!request.HasFormContentType) + { + return null; + } + + try + { + return await request.ReadFormAsync(cancellationToken); + } + catch (Exception error) when (error is InvalidDataException or BadHttpRequestException) + { + return null; + } +} + static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl) { httpContext.Response.ContentType = "text/html; charset=utf-8"; diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json index b24dd83..47bbb7b 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -27,6 +27,7 @@ "Heimdall": { "BaseUrl": "https://heimdall.gamecult.org", "AppSlug": "bifrost", + "AppSecret": "", "DiscordGuildId": "", "DiscordAllowedRoleIds": [], "PatreonTierTitle": "Inner Sanctum", diff --git a/src/Bifrost.Web/packages.lock.json b/src/Bifrost.Web/packages.lock.json new file mode 100644 index 0000000..bd15130 --- /dev/null +++ b/src/Bifrost.Web/packages.lock.json @@ -0,0 +1,430 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "BouncyCastle.Cryptography": { + "type": "Direct", + "requested": "[2.4.0, )", + "resolved": "2.4.0", + "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "94reKYu63jg4O75UI3LMJHwOSi8tQ6IfubiZhdnSsWcgtmAuF8OyLfjK/MIxuvaQRJZAF6E747FIuxjOtb8/og==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "4.5.0", + "Microsoft.EntityFrameworkCore.Relational": "8.0.0", + "Microsoft.Extensions.DependencyModel": "8.0.0", + "Mono.TextTemplating": "2.2.1" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Direct", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Npgsql": { + "type": "Direct", + "requested": "[8.0.3, )", + "resolved": "8.0.3", + "contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "GDXiMS9peEdjSCU/rfgyHruio7q6tYuywGaktqEi6UPQ6ILechp3fVVX+dHXkIXt4nklCBzYVWkzFrSL9ubKUA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.0", + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.0", + "Microsoft.EntityFrameworkCore.Relational": "8.0.0", + "Npgsql": "8.0.0" + } + }, + "OpenIddict.EntityFrameworkCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "U+Lq0DuEm9bolWOVur0Qy3LgwzCSjRQKs2Q8tUfFfpuAJsnhgK7j401eDm5PdHV1wnoaBYhXEMkkW+UKeADyaw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "8.0.30", + "OpenIddict.Core": "7.7.1", + "OpenIddict.EntityFrameworkCore.Models": "7.7.1" + } + }, + "OpenIddict.Server.AspNetCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "YQW64MJBVXsJ+H5kb+HFAQaSXsavOCEG+kDgD+DaYK7apLFtbOrgN233RhHOuztChWcY8SfjpjeN0ZXPwTFsbA==", + "dependencies": { + "OpenIddict.Server": "7.7.1" + } + }, + "System.Text.Json": { + "type": "Direct", + "requested": "[8.0.5, )", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==", + "dependencies": { + "System.Formats.Asn1": "10.0.2" + } + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.3.3", + "contentHash": "j/rOZtLMVJjrfLRlAMckJLPW/1rze9MT1yfWqSIbUPGRu1m1P0fuo9PmqapwsmePfGB5PJrudQLvmUOAMF0DqQ==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "lwAbIZNdnY0SUNoDmZHkVUwLO8UyNnyyh1t/4XsbFxi4Ounb3xszIYZaWhyj5ZjyfcwqwmtMbE7fUTVCqQEIdQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.3", + "System.Collections.Immutable": "6.0.0", + "System.Reflection.Metadata": "6.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encoding.CodePages": "6.0.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "cM59oMKAOxvdv76bdmaKPy5hfj+oR+zxikWoueEB7CwTko7mt9sVKZI8Qxlov0C/LuKEG+WQwifepqL3vuTiBQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Common": "[4.5.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "h74wTpmGOp4yS4hj+EvNzEiPgg/KVs2wmSfTZ81upJZOtPkJsVkgfsgtxxqmAeapjT/vLKfmYV0bS8n5MNVP+g==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.CSharp": "[4.5.0]", + "Microsoft.CodeAnalysis.Common": "[4.5.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[4.5.0]" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "l4dDRmGELXG72XZaonnOeORyD/T5RpEu5LGHOUIhnv+MmUWDY/m1kWXGwtcgQ5CJ5ynkFiRnIYzTKXYjUs7rbw==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Bcl.AsyncInterfaces": "6.0.0", + "Microsoft.CodeAnalysis.Common": "[4.5.0]", + "System.Composition": "6.0.0", + "System.IO.Pipelines": "6.0.3", + "System.Threading.Channels": "6.0.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "Y6GP16iuqAUkcXSgMqjgao9w0UHSz8x04LRDadsi++sn7yBI9C/sy61xNLzPKM+6DZEbyPRHGq3M58f6DhB25g==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.30", + "Microsoft.EntityFrameworkCore.Analyzers": "8.0.30", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Logging": "8.0.1" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "JoVu3708PrMcA3BUeo1+gTcD4nRI8o3g3kwNXwgTPUDhe0dqN2CQmHmEPDD0uZlI1v2oq3F7tR46VycUHzP9ng==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "+ZVRBR3CzGu0AGWTbewOWTzV+UG//yDrA0gdvb9i1dTidSvwG9qRRggEI9fie0MPC0LAcS9Z8X5HRYWWxefutg==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "uGWv6fvDUvJDKAYXfVwPVLmit2i6WCmfdzwrHHuw4OJ30s9GDVLsvV62Gi8TIgFSUlTVgSVW/c+hAvDwmY1TDw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.30", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "NSmDw3K0ozNDgShSIpsZcbFIzBX4w28nDag+TfaQujkXGazBm+lid5onlWoCBy4VsLxqnnKjEBbGSJVWJMf43g==", + "dependencies": { + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.19.2" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.IdentityModel.Logging": "8.19.2" + } + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "2.2.1", + "contentHash": "KZYeKBET/2Z0gY1WlTAK7+RHTl7GSbtvTLDXEZZojUdAPqpQNDL6tHv7VUpqfX5VEOh+uRGKaZXkuD253nEOBQ==", + "dependencies": { + "System.CodeDom": "4.4.0" + } + }, + "OpenIddict.Abstractions": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "dgb+2dGHzwjVUs7Jc8Dt3PQurVCEllX8XW8+WPmdB1eV5IKRoAZyiJyp3mgFOR1sk/jvilPqnMzQYGkVNVf66Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "OpenIddict.Core": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "TLk8u4zZ9IvTpTYV+a6nX1eBpnPR5E3seMPGcArXhfBK0VTImWWzPbcdx+GxiPxyq7+8iOg05uIJt7FsRwDpZg==", + "dependencies": { + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.Extensions.Options": "8.0.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, + "OpenIddict.EntityFrameworkCore.Models": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "DjJ5/7TBV7q7zB43v7em73nQBIacid5hxtlZzLnuWBg7kzvlBlUvvmgEhW/asoMBT3sR2nEyufB0wKOnn1niXQ==" + }, + "OpenIddict.Server": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "Se5+zNefCR9VpRPudmXOVJ6z79rSdEx+PvlPn1fGvKhLW6cqT5w8q8lPdRKe+zdiyAEXpSg+K5m6lTSpVqYOWA==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "2sCCb7doXEwtYAbqzbF/8UAeDRMNmPaQbU2q50Psg1J9KzumyVVCgKQY8s53WIPTufNT0DpSe9QRvVjOzfDWBA==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Composition": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "d7wMuKQtfsxUa7S13tITC8n1cQzewuhD5iDjZtK2prwFfKVzdYtgrTHgjaV03Zq7feGQ5gkP85tJJntXwInsJA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0", + "System.Composition.Convention": "6.0.0", + "System.Composition.Hosting": "6.0.0", + "System.Composition.Runtime": "6.0.0", + "System.Composition.TypedParts": "6.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "WK1nSDLByK/4VoC7fkNiFuTVEiperuCN/Hyn+VN30R+W2ijO1d0Z2Qm0ScEl9xkSn1G2MyapJi8xpf4R8WRa/w==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "XYi4lPRdu5bM4JVJ3/UIHAiG6V6lWWUlkhB9ab4IOq0FrRsp0F4wTyV4Dj+Ds+efoXJ3qbLqlvaUozDO7OLeXA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "w/wXjj7kvxuHPLdzZ0PAUt++qJl03t7lENmb2Oev0n3zbxyNULbWBlnd5J5WUMMv15kg5o+/TCZFb6lSwfaUUQ==", + "dependencies": { + "System.Composition.Runtime": "6.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "qkRH/YBaMPTnzxrS5RDk1juvqed4A6HOD/CwRcDGyPpYps1J27waBddiiq1y93jk2ZZ9wuA/kynM+NO0kb3PKg==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "iUR1eHrL8Cwd82neQCJ00MpwNIBs4NZgXzrPqx8NJf/k4+mwBO0XCRmHYJT4OLSwDDqh5nBLJWkz5cROnrGhRA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0", + "System.Composition.Hosting": "6.0.0", + "System.Composition.Runtime": "6.0.0" + } + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "III/lNMSn0ZRBuM9m5Cgbiho5j81u0FAEagFX5ta2DKbljZ3T0IpD8j+BIiHQPeKqJppWS9bGEp6JnKnWKze0g==", + "dependencies": { + "System.Collections.Immutable": "6.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "ZFCILZuOvtKPauZ/j/swhvw68ZRi9ATCfvGbk1QfydmcXBkIWecWKn/250UH7rahZ5OoDBaiAudJtPvLwzw85A==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "TY8/9+tI0mNaUMgntOxxaq2ndTkdXqLSxvPmas7XEqOlv9lQtB7wLjYGd756lOaO7Dvb5r/WXhluM+0Xe87v5Q==" + } + } + } +} \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj index 77670c1..1e94198 100644 --- a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj +++ b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 1870b6f..5434e8d 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -4,6 +4,7 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Tests.Support; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -183,6 +184,37 @@ public sealed partial class ConceptVotePageTests : IClassFixture + { + http.Request.Method = HttpMethods.Post; + http.Request.Scheme = "https"; + http.Request.Host = new HostString("localhost"); + http.Request.Path = $"/mill/{collection}/alpha"; + http.Request.QueryString = new QueryString("?handler=Up"); + http.Request.ContentType = "application/x-www-form-urlencoded"; + http.Request.Headers["X-Test-Anonymous"] = "true"; + http.Request.Headers["Origin"] = string.Empty; + if (fetchSite is not null) + { + http.Request.Headers["Sec-Fetch-Site"] = fetchSite; + } + }); + + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + Assert.False(context.Response.Headers.ContainsKey("Set-Cookie")); + Assert.Empty(await Rows(collection, "alpha")); + } + [Theory] [InlineData("same-origin", true)] [InlineData("same-site", false)] @@ -638,46 +670,4 @@ public sealed partial class ConceptVotePageTests : IClassFixture _lines = []; - - public IReadOnlyList Lines - { - get - { - lock (_lines) - { - return _lines.ToArray(); - } - } - } - - public ILogger CreateLogger(string categoryName) => new Capture(this); - - public void Dispose() - { - } - - private sealed class Capture(CapturingLoggerProvider owner) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - lock (owner._lines) - { - owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception)); - } - } - } - } } diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs new file mode 100644 index 0000000..04cdff4 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -0,0 +1,621 @@ +using System.Net; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests; + +// The vote budgets through the full pipeline: a loopback peer (the proxy) forwards the client address in +// X-Forwarded-For, the clock is the test's own, and Cast is 3 per 10 minutes, Mint 2 per hour. +public sealed class ConceptVoteRateLimitTests : IClassFixture +{ + private const string Site = "https://localhost"; + private const string CookieName = "bifrost_voter"; + + private readonly LimitedFactory _factory; + + public ConceptVoteRateLimitTests(LimitedFactory factory) => _factory = factory; + + [Fact] + public async Task Burst_from_one_prefix_gets_429_while_another_prefix_is_unaffected() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.10"); + foreach (var handler in new[] { "Up", "Down", "Up" }) + { + using var ok = await device.Post(collection, handler); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var other = new Client(_factory, "203.0.113.11"); + using var unaffected = await other.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode); + } + + [Fact] + public async Task Retract_spends_the_cast_budget() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.12"); + await device.Post(collection, "Up"); + await device.Post(collection, "Retract"); + await device.Post(collection, "Retract"); + + using var denied = await device.Post(collection, "Retract"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Addresses_in_one_ipv6_prefix_share_a_bucket_and_another_prefix_does_not() + { + var collection = await NewSubject(); + var first = new Client(_factory, "2001:db8:1:100::1"); + var sameBlock = new Client(_factory, "2001:db8:1:1ff::2"); + await first.Post(collection, "Up"); + await first.Post(collection, "Down"); + using var third = await sameBlock.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, third.StatusCode); + + using var denied = await sameBlock.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var elsewhere = new Client(_factory, "2001:db8:1:200::1"); + using var unaffected = await elsewhere.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode); + } + + [Fact] + public async Task The_window_ends_exactly_at_its_length_and_retry_after_counts_down_to_it() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.13"); + for (var i = 0; i < 3; i++) + { + await device.Post(collection, "Up"); + } + + using (var first = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, first.StatusCode); + Assert.Equal("600", RetryAfter(first)); + } + + // 499.5 s remain: Retry-After rounds up, so a client told to wait never retries early. + _factory.Clock.Advance(TimeSpan.FromMilliseconds(100_500)); + using (var later = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, later.StatusCode); + Assert.Equal("500", RetryAfter(later)); + } + + _factory.Clock.Advance(TimeSpan.FromMilliseconds(498_500)); + using (var last = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, last.StatusCode); + Assert.Equal("1", RetryAfter(last)); + } + + _factory.Clock.Advance(TimeSpan.FromSeconds(1)); + using var reopened = await device.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode); + } + + [Fact] + public async Task Forged_forwarded_header_from_non_loopback_peer_does_not_move_the_bucket() + { + var collection = await NewSubject(); + var device = new Client(_factory, "198.51.100.9") { Peer = "198.51.100.9" }; + for (var i = 0; i < 3; i++) + { + using var ok = await device.Post(collection, "Up", forwardedFor: $"192.0.2.{i + 1}"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Up", forwardedFor: "192.0.2.200"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Signed_in_accounts_are_limited_per_account() + { + var collection = await NewSubject(); + var alice = new Client(_factory, "203.0.113.20") { Account = ("7001", "alice-limits") }; + var aliceElsewhere = new Client(_factory, "203.0.113.21") { Account = ("7001", "alice-limits") }; + var bob = new Client(_factory, "203.0.113.20") { Account = ("7002", "bob-limits") }; + var visitor = new Client(_factory, "203.0.113.20"); + + await alice.Post(collection, "Up"); + await alice.Post(collection, "Down"); + await aliceElsewhere.Post(collection, "Up"); + using var denied = await aliceElsewhere.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var aliceAgain = new Client(_factory, "203.0.113.22") { Account = ("7001", "alice-limits") }; + using var sameAccountNewPrefix = await aliceAgain.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, sameAccountNewPrefix.StatusCode); + + using var bobOk = await bob.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, bobOk.StatusCode); + using var visitorOk = await visitor.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, visitorOk.StatusCode); + } + + [Fact] + public async Task Retract_by_a_signed_in_account_spends_the_account_budget_from_any_prefix() + { + var collection = await NewSubject(); + var home = new Client(_factory, "203.0.113.60") { Account = ("7003", "carol-limits") }; + var away = new Client(_factory, "203.0.113.61") { Account = ("7003", "carol-limits") }; + var elsewhere = new Client(_factory, "203.0.113.62") { Account = ("7003", "carol-limits") }; + await home.Post(collection, "Up"); + await home.Post(collection, "Down"); + using var third = await away.Post(collection, "Retract"); + Assert.Equal(HttpStatusCode.Redirect, third.StatusCode); + + using var denied = await elsewhere.Post(collection, "Retract"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Mint_budget_is_spent_only_by_minting_casts() + { + var collection = await NewSubject(); + const string prefix = "203.0.113.30"; + var first = new Client(_factory, prefix); + using (var mint = await first.Post(collection, "Up")) + { + Assert.NotNull(SetCookie(mint)); + } + + // Recasts by the existing device spend cast permits only; they would exhaust a budget of 2 if they spent mint. + using (var recast = await first.Post(collection, "Down")) + { + Assert.Equal(HttpStatusCode.Redirect, recast.StatusCode); + Assert.Null(SetCookie(recast)); + } + + using (var recastAgain = await first.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.Redirect, recastAgain.StatusCode); + } + + _factory.Clock.Advance(TimeSpan.FromMinutes(10)); + var second = new Client(_factory, prefix); + using (var secondMint = await second.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.Redirect, secondMint.StatusCode); + Assert.NotNull(SetCookie(secondMint)); + } + + var rowsBefore = (await Rows(collection)).Count; + var third = new Client(_factory, prefix); + using var denied = await third.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.Null(SetCookie(denied)); + Assert.Equal(rowsBefore, (await Rows(collection)).Count); + Assert.StartsWith("text/plain", denied.Content.Headers.ContentType?.MediaType); + var refusal = await denied.Content.ReadAsStringAsync(); + Assert.Contains("sign in", refusal, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("203.0.113", refusal); + + // An hour after the first mint the budget is whole again. + _factory.Clock.Advance(TimeSpan.FromMinutes(50)); + using var reopened = await third.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode); + } + + [Fact] + public async Task Anonymous_cast_under_the_limit_succeeds() + { + var collection = await NewSubject(); + var visitor = new Client(_factory, "203.0.113.40"); + + using var response = await visitor.Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var row = Assert.Single(await Rows(collection)); + Assert.Equal(VoterClass.Anonymous, row.VoterClass); + Assert.Equal(ConceptStance.Up, row.Stance); + } + + [Fact] + public async Task Rejection_writes_nothing_says_when_to_retry_and_echoes_no_address() + { + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + var collection = await NewSubject(); + var visitor = new Client(factory, "203.0.113.77"); + for (var i = 0; i < 3; i++) + { + await visitor.Post(collection, i == 1 ? "Down" : "Up"); + } + + var before = Assert.Single(await Rows(collection)); + using var denied = await visitor.Post(collection, "Down"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.NotNull(RetryAfter(denied)); + Assert.Null(SetCookie(denied)); + var after = Assert.Single(await Rows(collection)); + Assert.Equal(before.Stance, after.Stance); + var body = await denied.Content.ReadAsStringAsync(); + Assert.Contains("too fast", body); + var seen = body + + string.Join("\n", denied.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))); + Assert.DoesNotContain("203.0.113", seen); + Assert.DoesNotContain(logs.Lines, line => line.Contains("203.0.113", StringComparison.Ordinal)); + } + + [Fact] + public void Defaults_allow_thirty_new_devices_an_hour_per_address() + { + var defaults = new Bifrost.Web.Configuration.ConceptVoteOptions(); + + Assert.Equal(30, defaults.MintPermits); + Assert.Equal(TimeSpan.FromHours(1), defaults.MintWindow); + Assert.Equal(100_000, defaults.MaxTrackedKeys); + } + + [Fact] + public void Every_table_holds_at_most_the_configured_keys_and_accounts_have_a_table_of_their_own() + { + var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits( + Microsoft.Extensions.Options.Options.Create( + new Bifrost.Web.Configuration.ConceptVoteOptions { MaxTrackedKeys = 1, CastPermits = 1, MintPermits = 1 }), + new TestClock()); + var account = new VoterIdentity("account:test", VoterClass.Account, Guid.NewGuid()); + + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null)); + Assert.True(limits.DenyCast(IPAddress.Parse("198.51.100.2"), null)!.Value.TableFull); + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.2"), account)); + var otherAccount = new VoterIdentity("account:other", VoterClass.Account, Guid.NewGuid()); + Assert.True(limits.DenyCast(IPAddress.Parse("198.51.100.2"), otherAccount)!.Value.TableFull); + Assert.Null(limits.DenyMint(IPAddress.Parse("198.51.100.1"))); + Assert.True(limits.DenyMint(IPAddress.Parse("198.51.100.2"))!.Value.TableFull); + } + + [Fact] + public async Task Filling_the_table_with_unspent_keys_locks_out_neither_a_fresh_visitor_nor_a_fresh_account() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + using var filler = await new Client(factory, $"203.0.113.{80 + i}").Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, filler.StatusCode); + } + + using var visitor = await new Client(factory, "203.0.113.90").Post(collection, "Up"); + using var account = await new Client(factory, "203.0.113.91") { Account = ("7101", "fresh-account") } + .Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.Redirect, visitor.StatusCode); + Assert.NotNull(SetCookie(visitor)); + Assert.Equal(HttpStatusCode.Redirect, account.StatusCode); + } + + [Fact] + public async Task A_spent_key_survives_eviction_pressure_and_stays_refused_while_rotating_callers_succeed() + { + var factory = Capped(4); + var collection = await NewSubject(); + var spender = new Client(factory, "203.0.113.100"); + for (var i = 0; i < 3; i++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + for (var i = 0; i < 12; i++) + { + using var rotating = await new Client(factory, $"203.0.114.{i + 1}").Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, rotating.StatusCode); + } + + using var denied = await spender.Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.Contains("too fast", await denied.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task A_rotating_caller_wins_back_only_a_forgotten_keys_unspent_budget() + { + var factory = Capped(2); + var collection = await NewSubject(); + var forgotten = new Client(factory, "203.0.113.110"); + await forgotten.Post(collection, "Up"); + await forgotten.Post(collection, "Up"); + var recent = new Client(factory, "203.0.113.111"); + await recent.Post(collection, "Up"); + await new Client(factory, "203.0.113.112").Post(collection, "Up"); + + // forgotten was least recently used and had one permit left: it was evicted, so it has all three again, no more. + for (var i = 0; i < 3; i++) + { + using var ok = await forgotten.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await forgotten.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task A_table_full_of_spent_keys_refuses_a_new_visitor_with_its_true_reason_and_leaves_accounts_alone() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + var spender = new Client(factory, $"203.0.113.{120 + i}"); + for (var cast = 0; cast < 3; cast++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + } + + var rowsBefore = (await Rows(collection)).Count; + using var visitor = await new Client(factory, "203.0.113.130").Post(collection, "Up"); + using var account = await new Client(factory, "203.0.113.131") { Account = ("7102", "spared-account") } + .Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, visitor.StatusCode); + Assert.NotNull(RetryAfter(visitor)); + Assert.Null(SetCookie(visitor)); + var body = await visitor.Content.ReadAsStringAsync(); + Assert.Contains("busy", body); + Assert.DoesNotContain("too fast", body); + Assert.Contains("sign in", body, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("203.0.113", body + string.Join(";", visitor.Headers.Select(h => string.Join(",", h.Value)))); + Assert.Equal(HttpStatusCode.Redirect, account.StatusCode); + Assert.Equal(rowsBefore + 1, (await Rows(collection)).Count); + } + + [Fact] + public async Task A_full_account_table_refuses_a_new_account_as_busy_without_offering_sign_in() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + var spender = new Client(factory, $"203.0.113.{150 + i}") { Account = ($"72{i:00}", $"spent-account-{i}") }; + for (var cast = 0; cast < 3; cast++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + } + + using var fresh = await new Client(factory, "203.0.113.160") { Account = ("7299", "fresh-signed-in") } + .Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, fresh.StatusCode); + var body = await fresh.Content.ReadAsStringAsync(); + Assert.Contains("busy", body); + Assert.DoesNotContain("sign in", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Posts_to_a_missing_subject_spend_no_budget_and_mint_no_device() + { + var collection = await NewSubject(); + var sender = new Client(_factory, "203.0.113.140"); + foreach (var handler in new[] { "Up", "Down", "Retract" }) + { + for (var i = 0; i < 4; i++) + { + using var missing = await sender.Post("c" + Guid.NewGuid().ToString("N"), handler); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + Assert.Null(SetCookie(missing)); + } + } + + for (var i = 0; i < 3; i++) + { + using var ok = await sender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await sender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public void A_request_with_no_client_address_shares_one_bucket_apart_from_every_address() + { + var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits( + Microsoft.Extensions.Options.Options.Create(new Bifrost.Web.Configuration.ConceptVoteOptions { CastPermits = 1 }), + new TestClock()); + + Assert.Null(limits.DenyCast(null, null)); + Assert.NotNull(limits.DenyCast(null, null)); + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null)); + } + + [Fact] + public async Task Reads_are_not_under_the_vote_budgets() + { + var collection = await NewSubject(); + var visitor = new Client(_factory, "203.0.113.50"); + for (var i = 0; i < 4; i++) + { + await visitor.Post(collection, "Up"); + } + + using var page = await visitor.Get($"/mill/{collection}/alpha"); + using var export = await visitor.Get($"/public/concepts/{collection}/tallies"); + + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + Assert.Equal(HttpStatusCode.OK, export.StatusCode); + } + + private WebApplicationFactory Capped(int maxTrackedKeys) => + _factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:MaxTrackedKeys"] = maxTrackedKeys.ToString(System.Globalization.CultureInfo.InvariantCulture) + }))); + + private static string? RetryAfter(HttpResponseMessage response) => + response.Headers.TryGetValues("Retry-After", out var values) ? values.Single() : null; + + private static string? SetCookie(HttpResponseMessage response) => + response.Headers.TryGetValues("Set-Cookie", out var values) + ? values.FirstOrDefault(x => x.StartsWith(CookieName + "=", StringComparison.Ordinal)) + : null; + + private async Task NewSubject() + { + var collection = "c" + Guid.NewGuid().ToString("N"); + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = "alpha", + Title = "alpha", + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + return collection; + } + + private async Task> Rows(string collection) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.ConceptAppraisals.AsNoTracking() + .Where(x => x.Subject!.Collection == collection) + .ToListAsync(); + } + + // One browser: a client address that reaches the app through the proxy, a cookie jar, optionally an account. + private sealed class Client(WebApplicationFactory factory, string address) + { + private readonly HttpClient _http = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false, + BaseAddress = new Uri(Site) + }); + + private string? _cookie; + + public string Peer { get; init; } = "127.0.0.1"; + + public (string Id, string Login)? Account { get; init; } + + public Task Post(string collection, string handler, string? forwardedFor = null) => + Send( + new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler={handler}") + { + Content = new FormUrlEncodedContent([]) + }, + forwardedFor); + + public Task Get(string path) => Send(new HttpRequestMessage(HttpMethod.Get, path), null); + + private async Task Send(HttpRequestMessage request, string? forwardedFor) + { + request.Headers.TryAddWithoutValidation("Origin", Site); + request.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor ?? address); + request.Headers.TryAddWithoutValidation("X-Test-Peer", Peer); + if (Account is { } account) + { + request.Headers.TryAddWithoutValidation("X-Test-GitHub-Id", account.Id); + request.Headers.TryAddWithoutValidation("X-Test-GitHub-Login", account.Login); + } + else + { + request.Headers.TryAddWithoutValidation("X-Test-Anonymous", "true"); + } + + if (_cookie is not null) + { + request.Headers.TryAddWithoutValidation("Cookie", _cookie); + } + + var response = await _http.SendAsync(request); + if (SetCookie(response) is { } set) + { + _cookie = set.Split(';')[0]; + } + + return response; + } + } + + public sealed class LimitedFactory : TestWebApplicationFactory + { + public TestClock Clock { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:CastPermits"] = "3", + ["ConceptVotes:CastWindow"] = "00:10:00", + ["ConceptVotes:MintPermits"] = "2", + ["ConceptVotes:MintWindow"] = "01:00:00", + ["ConceptVotes:NetworkTagSecret"] = "rate-limit-test-tag-secret-0123456789" + })); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Clock); + services.AddTransient(); + }); + } + } + + public sealed class TestClock : TimeProvider + { + private DateTimeOffset _now = DateTimeOffset.UtcNow; + private long _ticks = 5_000_000; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override DateTimeOffset GetUtcNow() => _now; + + public override long GetTimestamp() => _ticks; + + public void Advance(TimeSpan by) + { + _now += by; + _ticks += by.Ticks; + } + } + + // The proxy's connection: TestServer has no socket, so the peer address is set ahead of the app pipeline. + private sealed class PeerFilter : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use((context, nextMiddleware) => + { + context.Connection.RemoteIpAddress = + IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "127.0.0.1"); + return nextMiddleware(context); + }); + next(app); + }; + } +} diff --git a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs new file mode 100644 index 0000000..e2fedb2 --- /dev/null +++ b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs @@ -0,0 +1,299 @@ +using Bifrost.Web.Features.Shared; + +namespace Bifrost.Web.Tests; + +public sealed class FixedWindowBudgetTests +{ + private static readonly TimeSpan Window = TimeSpan.FromSeconds(60); + private const int Cap = 1000; + + [Fact] + public void Permits_are_spent_per_key_and_the_next_is_denied_with_the_time_left() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, Cap, clock); + + Assert.True(Try(budget, "a", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out var wait)); + + Assert.Equal(TimeSpan.FromSeconds(50), wait); + Assert.True(Try(budget, "b", out _)); + } + + [Fact] + public void A_window_opens_at_its_first_permit_and_closes_exactly_one_window_later() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Assert.True(Try(budget, "a", out _)); + + clock.Advance(Window - TimeSpan.FromTicks(1)); + Assert.False(Try(budget, "a", out var wait)); + Assert.Equal(TimeSpan.FromTicks(1), wait); + + clock.Advance(TimeSpan.FromTicks(1)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out wait)); + Assert.Equal(Window, wait); + } + + [Fact] + public void A_denied_attempt_does_not_extend_the_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + for (var i = 0; i < 5; i++) + { + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.False(Try(budget, "a", out _)); + } + + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.True(Try(budget, "a", out _)); + } + + [Fact] + public void An_expired_window_is_dropped_and_a_live_one_keeps_counting_to_its_exact_end() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + clock.Advance(TimeSpan.FromSeconds(30)); + Try(budget, "b", out _); + clock.Advance(TimeSpan.FromSeconds(30)); + + // At 60 s a has ended and b has 30 s left. + Try(budget, "c", out _); + Assert.Equal(2, budget.TrackedKeys); + Assert.False(Try(budget, "b", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(30), wait); + + clock.Advance(TimeSpan.FromSeconds(30) - TimeSpan.FromTicks(1)); + Assert.False(Try(budget, "b", out _)); + clock.Advance(TimeSpan.FromTicks(1)); + Assert.True(Try(budget, "b", out _)); + } + + [Fact] + public void A_wall_clock_step_back_does_not_extend_a_window_or_its_retry_after() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + clock.Advance(TimeSpan.FromSeconds(10)); + clock.StepWall(-TimeSpan.FromHours(1)); + + Assert.False(Try(budget, "a", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(50), wait); + clock.Advance(TimeSpan.FromSeconds(50)); + Assert.True(Try(budget, "a", out _)); + Assert.Equal(1, budget.TrackedKeys); + } + + [Fact] + public void A_wall_clock_step_forward_does_not_reset_a_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + clock.StepWall(TimeSpan.FromHours(1)); + + Assert.False(Try(budget, "a", out var wait)); + Assert.Equal(Window, wait); + } + + [Fact] + public void A_new_key_at_the_cap_evicts_the_least_recently_used_key_with_permits_left() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(3, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.True(Try(budget, "b", out _)); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.True(Try(budget, "a", out _)); + + // b was used least recently, though a began first: c takes b's place and a keeps its count. + Assert.True(Try(budget, "c", out _)); + Assert.Equal(2, budget.TrackedKeys); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out _)); + } + + [Fact] + public void A_forgotten_key_regains_only_what_it_had_not_spent_and_a_spent_key_regains_nothing() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Assert.True(Try(budget, "spent", out _)); + Assert.True(Try(budget, "spent", out _)); + Assert.True(Try(budget, "partial", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + + // Rotating callers evict partial, never spent. + for (var i = 0; i < 10; i++) + { + Assert.True(Try(budget, "rot" + i, out _)); + } + + Assert.False(Try(budget, "spent", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(50), wait); + Assert.Equal(2, budget.TrackedKeys); + + // partial came back whole (2 permits), which is all a rotating caller can win: its own forgotten budget. + Assert.True(Try(budget, "partial", out _)); + } + + [Fact] + public void A_key_that_spends_its_last_permit_leaves_the_evictable_set() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + Assert.True(Try(budget, "a", out _)); + Assert.True(Try(budget, "b", out _)); + + // a is spent and b is not: the newcomer evicts b although a was used less recently. + Assert.True(Try(budget, "c", out _)); + Assert.False(Try(budget, "a", out _)); + Assert.True(Try(budget, "b", out _)); + } + + [Fact] + public void A_table_full_of_spent_keys_refuses_a_new_key_as_table_full_with_the_wait_for_the_oldest_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + clock.Advance(TimeSpan.FromSeconds(20)); + Assert.True(Try(budget, "b", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + + var denial = budget.Spend("c"); + Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), true), denial); + Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), false), budget.Spend("a")); + Assert.Equal(2, budget.TrackedKeys); + + clock.Advance(TimeSpan.FromSeconds(30)); + Assert.True(Try(budget, "c", out _)); + Assert.Equal(2, budget.TrackedKeys); + Assert.False(Try(budget, "b", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(20), wait); + } + + [Fact] + public void An_evicted_key_leaves_the_expiry_list_so_its_old_window_end_cannot_remove_the_key_that_took_its_name() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Try(budget, "a", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "b", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "c", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "a", out _); + + // The first a began at 0 and ended at 60; the a that returned at 3 is still inside its window. + clock.Advance(TimeSpan.FromSeconds(57)); + Try(budget, "c", out _); + + Assert.Equal(2, budget.TrackedKeys); + } + + [Fact] + public void A_held_key_under_its_permits_is_served_even_when_the_table_is_full() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 1, clock); + Assert.True(Try(budget, "a", out _)); + + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out _)); + } + + [Fact] + public void Eviction_work_does_not_scan_the_table() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 50_000, clock); + for (var i = 0; i < 50_000; i++) + { + budget.Spend("k" + i); + } + + var started = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < 200_000; i++) + { + budget.Spend("n" + i); + } + + Assert.Equal(50_000, budget.TrackedKeys); + Assert.True(started.Elapsed < TimeSpan.FromSeconds(10), $"200000 evicting inserts took {started.Elapsed}"); + } + + [Fact] + public void Expired_windows_are_dropped_so_the_table_does_not_grow_with_every_key_ever_seen() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + for (var i = 0; i < 100; i++) + { + Try(budget, "k" + i, out _); + } + + Assert.Equal(100, budget.TrackedKeys); + clock.Advance(Window); + Try(budget, "fresh", out _); + + Assert.Equal(1, budget.TrackedKeys); + } + + [Theory] + [InlineData(0, 60)] + [InlineData(-1, 60)] + [InlineData(1, 0)] + [InlineData(1, -5)] + public void A_budget_that_cannot_be_enforced_is_refused(int permits, int seconds) + { + Assert.ThrowsAny( + () => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), Cap, new Clock())); + } + + [Fact] + public void A_cap_below_one_is_refused() + { + Assert.Throws(() => new FixedWindowBudget(1, Window, 0, new Clock())); + } + + private static bool Try(FixedWindowBudget budget, string key, out TimeSpan wait) + { + var denial = budget.Spend(key); + wait = denial?.RetryAfter ?? TimeSpan.Zero; + return denial is null; + } + + // Time advances monotonic and wall together; StepWall moves only the wall clock, as an NTP step would. + private sealed class Clock : TimeProvider + { + private DateTimeOffset _wall = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero); + private long _ticks = 5_000_000; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override DateTimeOffset GetUtcNow() => _wall; + + public override long GetTimestamp() => _ticks; + + public void Advance(TimeSpan by) + { + _wall += by; + _ticks += by.Ticks; + } + + public void StepWall(TimeSpan by) => _wall += by; + } +} diff --git a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs new file mode 100644 index 0000000..aeaf092 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs @@ -0,0 +1,64 @@ +// Runs the complete page's real inline script against a simulated browser, once per scenario, and prints +// what it did as JSON: the ordered events, and the address and form values at the moment of the submit. +// Usage: node complete-page-harness.cjs +const fs = require("fs"); +const vm = require("vm"); + +const html = fs.readFileSync(process.argv[2], "utf8"); +const scripts = [...html.matchAll(/