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/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/Program.cs b/src/Bifrost.Web/Program.cs index 99ecd84..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; @@ -125,12 +126,12 @@ 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/"); @@ -225,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(); @@ -267,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, @@ -300,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; } @@ -327,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", @@ -385,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(); @@ -534,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(); @@ -578,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/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(/