diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index 1bb3c83..0000000 --- a/Directory.Build.props +++ /dev/null @@ -1,7 +0,0 @@ - - - - true - - - diff --git a/Dockerfile b/Dockerfile index 45072e5..4f607e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,14 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src -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 src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/ +RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj COPY . . RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \ --configuration Release \ --output /app/publish \ - /p:UseAppHost=false /p:RestoreLockedMode=true + /p:UseAppHost=false 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 d971d0c..683d7d2 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,7 +196,15 @@ 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 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. +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. 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 542ff8d..fdcef97 100644 --- a/docs/context.md +++ b/docs/context.md @@ -18,10 +18,8 @@ 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` - - 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 + - owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation + - Bifrost consumes Heimdall identity/capability facts instead of becoming the credential vault - `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 @@ -57,7 +55,7 @@ After bootstrap, the next session should start implementing the first vertical s - member alpha first - invite-only onboarding -- 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 as the only auth provider in v1 - 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 a251b00..c0a2754 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -103,8 +103,6 @@ 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 @@ -155,7 +153,7 @@ Repo Personas may generate bridge requests. They do not own the bridge itself. | Concern | Owner | Reason | | --- | --- | --- | -| Who is this actor? | Bifrost (identity) with Heimdall (authentication) | Bifrost issues the identity; account custody and credential proof stay with the auth authority. | +| Who is this actor? | Heimdall | Identity and account custody must 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. | @@ -207,7 +205,6 @@ 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 deleted file mode 100644 index a7f59a4..0000000 --- a/docs/oidc-issuer.md +++ /dev/null @@ -1,113 +0,0 @@ -# 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 deleted file mode 100644 index f67a9a5..0000000 --- a/docs/research/2026-10-29-estate-tally-game-theory/README.md +++ /dev/null @@ -1,330 +0,0 @@ -# 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 deleted file mode 100644 index 8e82ed4..0000000 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs +++ /dev/null @@ -1,284 +0,0 @@ -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 deleted file mode 100644 index edf1dd3..0000000 --- a/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index b435347..0000000 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ /dev/null @@ -1,142 +0,0 @@ -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 deleted file mode 100644 index 8f0be75..0000000 --- a/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index ac6fa72..0000000 --- a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs +++ /dev/null @@ -1,220 +0,0 @@ -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 deleted file mode 100644 index 5ad30b8..0000000 --- a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 1de135b..0000000 --- a/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs +++ /dev/null @@ -1,34 +0,0 @@ -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 d0656d3..99ecd84 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -11,7 +11,6 @@ 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; @@ -126,12 +125,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddHttpClient((serviceProvider, client) => - 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)); +{ + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); +}); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.stripe.com/"); @@ -226,13 +225,6 @@ 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(); @@ -275,11 +267,6 @@ 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, @@ -313,18 +300,22 @@ app.MapPost("/github/webhooks", async ( app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions gitHubOptions) => { - var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); + var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); + if (string.IsNullOrWhiteSpace(returnUrl)) + { + returnUrl = "/App"; + } var provider = httpContext.Request.Query["provider"].ToString(); if (string.IsNullOrWhiteSpace(provider)) { - await WriteSignInChooserAsync(httpContext, returnPath.Value); + await WriteSignInChooserAsync(httpContext, returnUrl); return; } if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase)) { - httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnPath.Value)}"); + httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnUrl)}"); return; } @@ -336,7 +327,7 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions hostOptions, - ILogger logger, CancellationToken cancellationToken) => { try { - var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); + var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); + if (string.IsNullOrWhiteSpace(returnUrl)) + { + returnUrl = "/App"; + } + var requireMemberAccess = string.Equals( httpContext.Request.Query["access"].ToString(), "member", @@ -390,18 +385,30 @@ app.MapGet("/auth/heimdall/{provider}", async ( var start = await heimdallAuthService.StartAsync( provider, hostOptions.Value.PublicBaseUrl, - returnPath, + returnUrl, requireMemberAccess, - httpContext.Request.Cookies, cancellationToken); - httpContext.Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options()); return Results.Redirect(start.AuthorizationUrl.ToString()); } catch (HeimdallAuthException error) { - logger.LogWarning("Heimdall start refused: {Reason}", error.Message); - return Results.Redirect(HeimdallAuthService.ErrorRedirect); + 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); } }).AllowAnonymous(); @@ -527,44 +534,31 @@ eveGovernance.MapGet("/surface", async ( "application/json"); }); -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 ( +app.MapGet("/auth/heimdall/wait", async ( HttpContext httpContext, HeimdallAuthService heimdallAuthService, - IOptions hostOptions, - ILogger logger, CancellationToken cancellationToken) => { - var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); - var attempt = form?["attempt"].ToString(); - var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt); + var attemptId = httpContext.Request.Query["attemptId"].ToString(); + if (string.IsNullOrWhiteSpace(attemptId)) + { + return Results.Redirect("/?auth=heimdall-error&detail=missing-attempt-id"); + } + try { - var signIn = await heimdallAuthService.CompleteAsync( - hostOptions.Value.PublicBaseUrl, - attempt, - held?.Value, - form?["code"].ToString(), - cancellationToken); + var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken); await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); - httpContext.Response.Cookies.Delete(held!.Value.Name, HeimdallAttemptCookie.DeleteOptions()); - return Results.LocalRedirect(signIn.ReturnPath.Value); + 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"); } catch (HeimdallAuthException error) { - 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); + return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); } }).AllowAnonymous(); @@ -584,23 +578,6 @@ 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 47bbb7b..b24dd83 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -27,7 +27,6 @@ "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 deleted file mode 100644 index bd15130..0000000 --- a/src/Bifrost.Web/packages.lock.json +++ /dev/null @@ -1,430 +0,0 @@ -{ - "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 1e94198..77670c1 100644 --- a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj +++ b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj @@ -22,7 +22,6 @@ - diff --git a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs deleted file mode 100644 index aeaf092..0000000 --- a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs +++ /dev/null @@ -1,64 +0,0 @@ -// 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(/