From 83daabe512771a005580d4290a28cbfb275d17e8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 27 Oct 2026 04:57:16 +0100 Subject: [PATCH] Bifrost as OIDC issuer for the Matrix chat (gamecult-chat cut bifrost-oidc-issuer, squash of 1ef289a..ec4acfa and abd12b3..) One configured client (stale clients removed with their tokens and authorizations), Active membership re-checked at token and userinfo through IsStillActiveMemberAsync, PKCE required by the server option, OpenIddict.Server.AspNetCore + EntityFrameworkCore 7.7.1 with no vulnerable transitives. Soul s2, s3 and s4 hold; Low conformance gaps in gamecult-chat:follow_up:oidc-issuer-spec-conformance. Co-Authored-By: Claude Opus 5.5 --- docs/context.md | 8 +- docs/jurisdiction-map.md | 5 +- docs/oidc-issuer.md | 85 + src/Bifrost.Web/Bifrost.Web.csproj | 2 + .../Configuration/OidcIssuerOptions.cs | 41 + src/Bifrost.Web/Data/BifrostDbContext.cs | 4 + .../20261022163159_AddOpenIddict.Designer.cs | 2441 +++++++++++++++++ .../20261022163159_AddOpenIddict.cs | 166 ++ .../BifrostDbContextModelSnapshot.cs | 246 +- .../Features/Oidc/OidcClientSeeder.cs | 72 + .../Features/Oidc/OidcEndpoints.cs | 142 + .../Features/Oidc/OidcKeyMaterial.cs | 133 + .../Oidc/OidcServiceCollectionExtensions.cs | 73 + src/Bifrost.Web/Program.cs | 13 + src/Bifrost.Web/packages.lock.json | 142 +- .../MigrationDiscoveryTests.cs | 3 +- tests/Bifrost.Web.Tests/OidcIssuerTests.cs | 737 +++++ .../Bifrost.Web.Tests/OidcKeyMaterialTests.cs | 104 + .../Bifrost.Web.Tests/Support/OidcTestKit.cs | 260 ++ tests/Bifrost.Web.Tests/packages.lock.json | 142 +- 20 files changed, 4771 insertions(+), 48 deletions(-) create mode 100644 docs/oidc-issuer.md create mode 100644 src/Bifrost.Web/Configuration/OidcIssuerOptions.cs create mode 100644 src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs create mode 100644 src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs create mode 100644 src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs create mode 100644 src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs create mode 100644 src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs create mode 100644 src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs create mode 100644 tests/Bifrost.Web.Tests/OidcIssuerTests.cs create mode 100644 tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/OidcTestKit.cs 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..5698c25 --- /dev/null +++ b/docs/oidc-issuer.md @@ -0,0 +1,85 @@ +# 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. | + +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. + +## 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/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj index 128d84e..7cd14b3 100644 --- a/src/Bifrost.Web/Bifrost.Web.csproj +++ b/src/Bifrost.Web/Bifrost.Web.csproj @@ -17,6 +17,8 @@ + + diff --git a/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs new file mode 100644 index 0000000..1a14457 --- /dev/null +++ b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs @@ -0,0 +1,41 @@ +namespace Bifrost.Web.Configuration; + +/// +/// The OIDC provider surface (section Bifrost:Oidc). An absent section means the provider is not mapped at +/// all; a present section is complete or startup fails. Keys and the client secret are named by file path, never inlined. +/// +public sealed class OidcIssuerOptions +{ + public const string SectionName = "Bifrost:Oidc"; + + /// PEM file holding the EC P-256 private key that signs tokens (ES256). + public string SigningKeyPath { get; init; } = string.Empty; + + /// File holding the 32-byte symmetric token-encryption key as base64. + public string EncryptionKeyPath { get; init; } = string.Empty; + + public OidcClientsOptions Clients { get; init; } = new(); + + /// Null when the section is absent. + public static OidcIssuerOptions? FromConfiguration(IConfiguration configuration) + { + var section = configuration.GetSection(SectionName); + return section.Exists() ? section.Get() ?? new OidcIssuerOptions() : null; + } +} + +public sealed class OidcClientsOptions +{ + /// Matrix Authentication Service, the one client of this provider. + public OidcClientOptions Mas { get; init; } = new(); +} + +public sealed class OidcClientOptions +{ + public string ClientId { get; init; } = string.Empty; + + /// File holding the client secret. + public string ClientSecretPath { get; init; } = string.Empty; + + public string RedirectUri { get; init; } = string.Empty; +} diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 9863cfd..5596594 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -5,6 +5,10 @@ namespace Bifrost.Web.Data; public sealed class BifrostDbContext(DbContextOptions options) : DbContext(options) { + // The OpenIddict tables are part of this model for every way a context is built: the host, the concepts + // command, tests and the design-time factory that generates migrations. + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseOpenIddict(); + public DbSet UserAccounts => Set(); public DbSet MemberProfiles => Set(); diff --git a/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs b/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs new file mode 100644 index 0000000..154c5ae --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs @@ -0,0 +1,2441 @@ +// +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + [DbContext(typeof(BifrostDbContext))] + [Migration("20261022163159_AddOpenIddict")] + partial class AddOpenIddict + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.30") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .IsRequired() + .HasColumnType("text"); + + b.Property("LaunchMode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("LogPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ResultPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ThreadId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TurnId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerProcessId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RequestId"); + + b.HasIndex("StartedByUserAccountId"); + + b.HasIndex("Status"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "Status"); + + b.ToTable("AgentDispatchRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentTransportReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("OccurredAtUtc"); + + b.HasIndex("RequestId"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "ActivityKind"); + + b.ToTable("AgentTransportReceipts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("AuthorityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("BifrostIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpiphanyAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("EpiphanyLaneId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EpiphanyRunId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ExternalReceiptId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("FailureReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("HeimdallCapabilityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceiptUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetLocator") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TargetSurface") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetSurfaceName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("BifrostIdentity"); + + b.HasIndex("HeimdallCapabilityReference"); + + b.HasIndex("MotionId"); + + b.HasIndex("Status"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("ActorKind", "ActorName"); + + b.HasIndex("SourceKind", "SourceId"); + + b.HasIndex("TargetSurface", "ActionKind", "Status"); + + b.HasIndex("TargetSurface", "TargetSurfaceName", "Status"); + + b.ToTable("BridgeActions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NetworkTag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Stance") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("VoterClass") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("VoterKey") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("NetworkTag", "CastAtUtc"); + + b.HasIndex("SubjectId", "VoterKey") + .IsUnique(); + + b.ToTable("ConceptAppraisals"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptSubject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Collection") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("RegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.HasKey("Id"); + + b.HasIndex("Collection", "Key") + .IsUnique(); + + b.ToTable("ConceptSubjects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RanAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Rate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.HasKey("Id"); + + b.ToTable("DecayRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IssueNumber") + .HasColumnType("integer"); + + b.Property("IssueUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId") + .IsUnique(); + + b.HasIndex("RepositoryFullName", "IssueNumber") + .IsUnique(); + + b.ToTable("GitHubIssueLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsMerged") + .HasColumnType("boolean"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ReviewDecision") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("RepositoryFullName", "PullRequestNumber") + .IsUnique(); + + b.ToTable("GitHubPullRequestLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("CommentId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DispatchRequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("JurisdictionAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("JurisdictionRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TopicId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("OccurredAtUtc"); + + b.HasIndex("TopicId"); + + b.HasIndex("JurisdictionRepoName", "JurisdictionAgentIdentity", "ActivityKind"); + + b.ToTable("GovernanceActivityReceipts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("EffectiveAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasColumnType("text"); + + b.Property("NominalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Points") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("LedgerEntries"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Availability") + .IsRequired() + .HasColumnType("text"); + + b.Property("Headline") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortfolioUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Skills") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("MemberProfiles"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CanManageLedger") + .HasColumnType("boolean"); + + b.Property("CanManageProjects") + .HasColumnType("boolean"); + + b.Property("CanModerateMotions") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPlatformAdmin") + .HasColumnType("boolean"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("Memberships"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AcceptedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedByUserAccountId"); + + b.HasIndex("IssuedByUserAccountId"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique(); + + b.ToTable("MembershipInvitations"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalThreshold") + .HasPrecision(6, 3) + .HasColumnType("numeric(6,3)"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosesAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("OpensAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ResolutionNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.ToTable("Motions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("ExternalSupportId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("IsCurrentRecurringSupport") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProviderEventId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderPayerId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Provider", "ProviderEventId") + .IsUnique() + .HasFilter("\"ProviderEventId\" <> ''"); + + b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); + + b.ToTable("PatronSupportEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.ToTable("PayoutProposalBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDecaying") + .HasColumnType("boolean"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("PointTransactions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GitHubRepository") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("OwnerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GitHubRepository"); + + b.HasIndex("OwnerUserAccountId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("RevenueEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("RevenueEventId") + .HasColumnType("uuid"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("RevenueEventId"); + + b.ToTable("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Basis") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RevenueShareBatchId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RevenueShareBatchId"); + + b.HasIndex("UserAccountId"); + + b.ToTable("RevenueShareLines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("MembershipId", "Role") + .IsUnique(); + + b.ToTable("RoleAssignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("MembershipId", "Kind", "IsCurrent"); + + b.ToTable("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BifrostIdentity") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("HeimdallAccountId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedBifrostIdentity") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubUserId") + .IsUnique() + .HasFilter("\"GitHubUserId\" IS NOT NULL"); + + b.HasIndex("HeimdallAccountId") + .IsUnique() + .HasFilter("\"HeimdallAccountId\" <> ''"); + + b.HasIndex("NormalizedBifrostIdentity") + .IsUnique() + .HasFilter("\"NormalizedBifrostIdentity\" <> ''"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique() + .HasFilter("\"NormalizedGitHubLogin\" <> ''"); + + b.ToTable("UserAccounts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId", "UserAccountId") + .IsUnique(); + + b.ToTable("VolunteerClaims"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Choice") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("MotionId", "UserAccountId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedToUserAccountId") + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContributionPoints") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedHours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExternalSourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RequestedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("SkillLevel") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RequestedByUserAccountId"); + + b.ToTable("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Hours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkLogs"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReviewerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewerUserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkReviews"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount") + .WithMany("AgentDispatchRuns") + .HasForeignKey("StartedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("StartedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentTransportReceipt", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AgentTransportReceipts") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Assignments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("Assignments") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AuditEvents") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("BridgeActions") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany() + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany() + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + + b.Navigation("Motion"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.HasOne("Bifrost.Web.Domain.ConceptSubject", "Subject") + .WithMany() + .HasForeignKey("SubjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", null) + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Subject"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithOne("GitHubIssueLink") + .HasForeignKey("Bifrost.Web.Domain.GitHubIssueLink", "WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("GitHubPullRequestLinks") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("GovernanceActivityReceipts") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("LedgerEntries") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("LedgerEntries") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("LedgerEntries") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("MemberProfile") + .HasForeignKey("Bifrost.Web.Domain.MemberProfile", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("Membership") + .HasForeignKey("Bifrost.Web.Domain.Membership", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AcceptedByUserAccount") + .WithMany() + .HasForeignKey("AcceptedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "IssuedByUserAccount") + .WithMany() + .HasForeignKey("IssuedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AcceptedByUserAccount"); + + b.Navigation("IssuedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany("CreatedMotions") + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("Motions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PatronSupportEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PatronSupportEvents") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PointTransactions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PointTransactions") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("PointTransactions") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "OwnerUserAccount") + .WithMany("OwnedProjects") + .HasForeignKey("OwnerUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OwnerUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.RevenueEvent", "RevenueEvent") + .WithMany("RevenueShareBatches") + .HasForeignKey("RevenueEventId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("RevenueEvent"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueShareLines") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.RevenueShareBatch", "RevenueShareBatch") + .WithMany("Lines") + .HasForeignKey("RevenueShareBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("RevenueShareBatch"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("RoleAssignments") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("TierSnapshots") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("VolunteerClaims") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("VolunteerClaims") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany("Votes") + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Votes") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Motion"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedToUserAccount") + .WithMany("AssignedWorkItems") + .HasForeignKey("AssignedToUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("WorkItems") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "RequestedByUserAccount") + .WithMany("RequestedWorkItems") + .HasForeignKey("RequestedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedToUserAccount"); + + b.Navigation("Project"); + + b.Navigation("RequestedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewedByUserAccount") + .WithMany() + .HasForeignKey("ReviewedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("WorkLogs") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkLogs") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewerUserAccount") + .WithMany("WorkReviews") + .HasForeignKey("ReviewerUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkReviews") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewerUserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Navigation("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Navigation("LedgerEntries"); + + b.Navigation("Motions"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RevenueEvents"); + + b.Navigation("RevenueShareLines"); + + b.Navigation("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Navigation("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Navigation("AgentDispatchRuns"); + + b.Navigation("AgentTransportReceipts"); + + b.Navigation("AssignedWorkItems"); + + b.Navigation("Assignments"); + + b.Navigation("AuditEvents"); + + b.Navigation("BridgeActions"); + + b.Navigation("CreatedMotions"); + + b.Navigation("GovernanceActivityReceipts"); + + b.Navigation("LedgerEntries"); + + b.Navigation("MemberProfile"); + + b.Navigation("Membership"); + + b.Navigation("OwnedProjects"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RequestedWorkItems"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("Votes"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Navigation("Assignments"); + + b.Navigation("GitHubIssueLink"); + + b.Navigation("GitHubPullRequestLinks"); + + b.Navigation("LedgerEntries"); + + b.Navigation("PointTransactions"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs b/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs new file mode 100644 index 0000000..64efe36 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs @@ -0,0 +1,166 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class AddOpenIddict : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OpenIddictApplications", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ClientId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + ClientSecret = table.Column(type: "text", nullable: true), + ClientType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConsentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + JsonWebKeySet = table.Column(type: "text", nullable: true), + Permissions = table.Column(type: "text", nullable: true), + PostLogoutRedirectUris = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedirectUris = table.Column(type: "text", nullable: true), + Requirements = table.Column(type: "text", nullable: true), + Settings = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictApplications", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictScopes", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Description = table.Column(type: "text", nullable: true), + Descriptions = table.Column(type: "text", nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Properties = table.Column(type: "text", nullable: true), + Resources = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictScopes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictAuthorizations", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + Properties = table.Column(type: "text", nullable: true), + Scopes = table.Column(type: "text", nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictAuthorizations", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictAuthorizations_OpenIddictApplications_Application~", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictTokens", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + AuthorizationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + ExpirationDate = table.Column(type: "timestamp with time zone", nullable: true), + Payload = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedemptionDate = table.Column(type: "timestamp with time zone", nullable: true), + ReferenceId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(150)", maxLength: 150, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictTokens", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictApplications_ApplicationId", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId", + column: x => x.AuthorizationId, + principalTable: "OpenIddictAuthorizations", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictApplications_ClientId", + table: "OpenIddictApplications", + column: "ClientId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type", + table: "OpenIddictAuthorizations", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictScopes_Name", + table: "OpenIddictScopes", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type", + table: "OpenIddictTokens", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_AuthorizationId", + table: "OpenIddictTokens", + column: "AuthorizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ReferenceId", + table: "OpenIddictTokens", + column: "ReferenceId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OpenIddictScopes"); + + migrationBuilder.DropTable( + name: "OpenIddictTokens"); + + migrationBuilder.DropTable( + name: "OpenIddictAuthorizations"); + + migrationBuilder.DropTable( + name: "OpenIddictApplications"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index b8c50c5..8c47ed8 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace Bifrost.Web.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("ProductVersion", "8.0.30") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -1616,6 +1616,214 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("WorkReviews"); }); + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => { b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount") @@ -2090,6 +2298,30 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("WorkItem"); }); + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => { b.Navigation("RoleAssignments"); @@ -2188,6 +2420,18 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("WorkReviews"); }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); #pragma warning restore 612, 618 } } 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..6d164e2 --- /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. + private 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..58f541a --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs @@ -0,0 +1,133 @@ +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."); + } + + 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/OidcServiceCollectionExtensions.cs b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs new file mode 100644 index 0000000..0f1da67 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs @@ -0,0 +1,73 @@ +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(); + return services; + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index c0f5e40..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; @@ -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, diff --git a/src/Bifrost.Web/packages.lock.json b/src/Bifrost.Web/packages.lock.json index 0336b92..bd15130 100644 --- a/src/Bifrost.Web/packages.lock.json +++ b/src/Bifrost.Web/packages.lock.json @@ -55,6 +55,26 @@ "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, )", @@ -71,6 +91,14 @@ "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", @@ -122,31 +150,31 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "SoODat83pGQUpWB9xULdMX6tuKpq/RTXDuJ2WeC1ldUKcKzLkaFJD1n+I0nOLY58odez/e7z8b6zdp235G/kyg==", + "resolved": "8.0.30", + "contentHash": "Y6GP16iuqAUkcXSgMqjgao9w0UHSz8x04LRDadsi++sn7yBI9C/sy61xNLzPKM+6DZEbyPRHGq3M58f6DhB25g==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "8.0.0", - "Microsoft.EntityFrameworkCore.Analyzers": "8.0.0", - "Microsoft.Extensions.Caching.Memory": "8.0.0", - "Microsoft.Extensions.Logging": "8.0.0" + "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.0", - "contentHash": "VR22s3+zoqlVI7xauFKn1znSIFHO8xuILT+noSwS8bZCKcHz0ydkTDQMuaxSa5WBaQrZmwtTz9rmRvJ7X8mSPQ==" + "resolved": "8.0.30", + "contentHash": "JoVu3708PrMcA3BUeo1+gTcD4nRI8o3g3kwNXwgTPUDhe0dqN2CQmHmEPDD0uZlI1v2oq3F7tR46VycUHzP9ng==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "ZXxEeLs2zoZ1TA+QoMMcw4f3Tirf8PzgdDax8RoWo0dxI2KmqiEGWYjhm2B/XyWfglc6+mNRyB8rZiQSmxCpeg==" + "resolved": "8.0.30", + "contentHash": "+ZVRBR3CzGu0AGWTbewOWTzV+UG//yDrA0gdvb9i1dTidSvwG9qRRggEI9fie0MPC0LAcS9Z8X5HRYWWxefutg==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "fFKkr24cYc7Zw5T6DC4tEyOEPgPbq23BBmym1r9kn4ET9F3HKaetpOeQtV2RryYyUxEeNkJuxgfiZHTisqZc+A==", + "resolved": "8.0.30", + "contentHash": "uGWv6fvDUvJDKAYXfVwPVLmit2i6WCmfdzwrHHuw4OJ30s9GDVLsvV62Gi8TIgFSUlTVgSVW/c+hAvDwmY1TDw==", "dependencies": { - "Microsoft.EntityFrameworkCore": "8.0.0", + "Microsoft.EntityFrameworkCore": "8.0.30", "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" } }, @@ -168,10 +196,10 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==", + "resolved": "8.0.1", + "contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { @@ -190,12 +218,12 @@ }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==", + "resolved": "8.0.1", + "contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "8.0.0", - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0" + "Microsoft.Extensions.DependencyInjection": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" } }, "Microsoft.Extensions.Logging.Abstractions": { @@ -220,6 +248,37 @@ "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", @@ -228,6 +287,42 @@ "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", @@ -289,6 +384,11 @@ "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", diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index f4b1077..04401c1 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -21,6 +21,7 @@ public sealed class MigrationDiscoveryTests "20260622202000_AddBridgeActionTargetSurfaceName", "20260705190000_AddPatronSupportProjectAttribution", "20261010043937_AddConceptVotes", + "20261022163159_AddOpenIddict", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. @@ -50,7 +51,7 @@ public sealed class MigrationDiscoveryTests } [Fact] - public void All_thirteen_migrations_are_listed_in_order() + public void All_fourteen_migrations_are_listed_in_order() { Assert.Equal(ExpectedMigrations, Discovered()); } diff --git a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs new file mode 100644 index 0000000..08e8595 --- /dev/null +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -0,0 +1,737 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Bifrost.Web.Tests; + +public sealed class OidcIssuerTests : IClassFixture +{ + private static readonly Uri Origin = new("https://localhost"); + private readonly OidcWebApplicationFactory _factory; + + public OidcIssuerTests(OidcWebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task Discovery_advertises_ES256_and_the_code_flow_only() + { + using var client = NewClient(); + var discovery = await client.GetFromJsonAsync("/.well-known/openid-configuration"); + + Assert.Equal(Origin.AbsoluteUri, discovery.GetProperty("issuer").GetString()); + Assert.Equal("/.well-known/jwks", new Uri(discovery.GetProperty("jwks_uri").GetString()!).AbsolutePath); + Assert.Equal(["ES256"], Strings(discovery, "id_token_signing_alg_values_supported")); + Assert.Equal(["code"], Strings(discovery, "response_types_supported")); + Assert.Equal(["authorization_code"], Strings(discovery, "grant_types_supported")); + Assert.Contains("S256", Strings(discovery, "code_challenge_methods_supported")); + Assert.Equal(["client_secret_basic"], Strings(discovery, "token_endpoint_auth_methods_supported")); + Assert.Equal(["openid", "profile"], Strings(discovery, "scopes_supported").Order()); + } + + [Fact] + public async Task Jwks_holds_exactly_the_configured_P256_key() + { + using var client = NewClient(); + var jwks = await GetJwksAsync(client); + + var key = Assert.Single(jwks.GetProperty("keys").EnumerateArray()); + Assert.Equal("EC", key.GetProperty("kty").GetString()); + Assert.Equal("P-256", key.GetProperty("crv").GetString()); + Assert.Equal("ES256", key.GetProperty("alg").GetString()); + + using var ec = ECDsa.Create(); + ec.ImportFromPem(_factory.Kit.SigningPublicKeyPem); + var expected = ec.ExportParameters(false); + Assert.Equal(Base64UrlEncoder.Encode(expected.Q.X), key.GetProperty("x").GetString()); + Assert.Equal(Base64UrlEncoder.Encode(expected.Q.Y), key.GetProperty("y").GetString()); + Assert.False(key.TryGetProperty("d", out _)); + } + + [Fact] + public async Task An_active_member_completes_code_and_pkce_and_gets_the_right_claims() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Ada Lovelace"); + var accountId = _factory.AccountId(identity); + await SetLinksAsync(identity, "heimdall-canary-7", "github-canary-7"); + + var tokens = await CompleteFlowAsync(client); + + var verified = await ValidateIdTokenAsync(tokens.IdToken, tokens.Request.Nonce); + Assert.Equal(accountId.ToString("D"), verified.Claim("sub")); + Assert.Equal(identity, verified.Claim("preferred_username")); + Assert.Equal("Ada Lovelace", verified.Claim("name")); + Assert.Equal(["ES256"], [verified.Alg]); + + // Nothing about the authenticator links, e-mail or membership leaves Bifrost. + Assert.DoesNotContain("heimdall-canary-7", verified.PayloadJson); + Assert.DoesNotContain("github-canary-7", verified.PayloadJson); + Assert.DoesNotContain("email", verified.PayloadJson, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("urn:bifrost", verified.PayloadJson); + + using var userinfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + userinfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var userinfoResponse = await NewClient().SendAsync(userinfo); + Assert.Equal(HttpStatusCode.OK, userinfoResponse.StatusCode); + var body = await userinfoResponse.Content.ReadAsStringAsync(); + var info = JsonDocument.Parse(body).RootElement; + Assert.Equal(accountId.ToString("D"), info.GetProperty("sub").GetString()); + Assert.Equal(identity, info.GetProperty("preferred_username").GetString()); + Assert.Equal("Ada Lovelace", info.GetProperty("name").GetString()); + Assert.DoesNotContain("canary", body); + } + + [Fact] + public async Task Sub_and_preferred_username_are_stable_across_display_name_changes() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "First Name"); + + var first = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null); + await _factory.SetDisplayNameAsync(identity, "Second Name"); + var second = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null); + + Assert.Equal(first.Claim("sub"), second.Claim("sub")); + Assert.Equal(first.Claim("preferred_username"), second.Claim("preferred_username")); + Assert.Equal("First Name", first.Claim("name")); + Assert.Equal("Second Name", second.Claim("name")); + } + + [Theory] + [InlineData(MembershipStatus.Authenticated)] + [InlineData(MembershipStatus.PendingApproval)] + [InlineData(MembershipStatus.Suspended)] + public async Task A_member_who_is_not_active_is_refused_with_access_denied_and_no_code(MembershipStatus status) + { + var (client, _) = await SignedInAsync(status, "Not Active"); + var request = NewRequest(); + + var final = await DriveAsync(client, request); + + var redirect = AssertRedirectToClient(final, request.State); + Assert.Equal("access_denied", redirect["error"]); + Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.NotActiveDescription, redirect["error_description"]); + Assert.False(redirect.ContainsKey("code")); + } + + [Fact] + public async Task An_active_account_with_no_bifrost_identity_is_refused_never_given_an_empty_username() + { + using var factory = new OidcNoIdentityFactory(); + using var client = factory.CreateClient(ClientOptions()); + await RegisterAsync(client, $"noid-{Guid.NewGuid():N}"); + var request = NewRequest(); + + var final = await DriveAsync(client, request); + + var redirect = AssertRedirectToClient(final, request.State); + Assert.Equal("access_denied", redirect["error"]); + Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.NoIdentityDescription, redirect["error_description"]); + Assert.False(redirect.ContainsKey("code")); + } + + [Theory] + [InlineData("unknown-client", OidcTestKit.RedirectUri, true)] + [InlineData(OidcTestKit.ClientId, "https://evil.example.test/callback", true)] + [InlineData(OidcTestKit.ClientId, OidcTestKit.RedirectUri, false)] + public async Task Unknown_client_wrong_redirect_uri_and_missing_pkce_are_refused_before_any_sign_in_redirect( + string clientId, string redirectUri, bool pkce) + { + using var client = NewClient(); + var request = NewRequest(clientId, redirectUri, pkce); + + using var response = await client.GetAsync(request.Path); + + var location = response.Headers.Location?.OriginalString ?? string.Empty; + Assert.DoesNotContain("/auth/sign-in", location); + Assert.DoesNotContain("request_uri=", location); + Assert.DoesNotContain("code=", location); + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task A_mas_sized_authorize_request_survives_sign_in_and_its_handle_is_single_use() + { + var request = NewRequest(extra: "&ui_locales=en&prompt=login&claims_locales=en&max_age=3600"); + Assert.True(request.Path.Length > LocalReturnPath.MaxLength, "the request must be longer than a sign-in return path"); + + using var client = NewClient(); + var signIn = await DriveAsync(client, request); + Assert.Equal(HttpStatusCode.Redirect, signIn.StatusCode); + Assert.Equal("/auth/sign-in", signIn.Headers.Location!.IsAbsoluteUri + ? signIn.Headers.Location.AbsolutePath + : signIn.Headers.Location.OriginalString.Split('?')[0]); + var resume = ReturnUrl(signIn); + Assert.True(resume.Length <= LocalReturnPath.MaxLength, "the resume path must fit the sign-in return path"); + Assert.Equal(resume, LocalReturnPath.Parse(resume).Value); + + var identity = await RegisterAsync(client, $"resume-{Guid.NewGuid():N}"); + await _factory.SetMembershipAsync(identity, MembershipStatus.Active); + + using var resumed = await client.GetAsync(resume); + var redirect = AssertRedirectToClient(resumed, request.State); + Assert.True(redirect.ContainsKey("code"), "the resumed request issues a code"); + + var tokens = await ExchangeAsync(client, request, redirect["code"]!); + var verified = await ValidateIdTokenAsync(tokens.IdToken, request.Nonce); + Assert.Equal(request.Nonce, verified.Claim("nonce")); + + // The handle was spent by the first resume. + using var replay = await client.GetAsync(resume); + var replayRedirect = replay.Headers.Location?.OriginalString ?? string.Empty; + Assert.DoesNotContain("code=", replayRedirect); + Assert.NotEqual(HttpStatusCode.OK, replay.StatusCode); + } + + [Fact] + public async Task A_parked_authorize_request_expires() + { + // Own host: moving the clock must not skew the id_token checks of the other tests. + using var own = new OidcWebApplicationFactory(); + await new OidcIssuerTests(own).ParkedRequestIsRefusedAfterItsLifetimeAsync(); + } + + private async Task ParkedRequestIsRefusedAfterItsLifetimeAsync() + { + // Fixed probe times, not the constant: a lifetime of a day, or of a minute, must show. + var early = NewRequest(); + var late = NewRequest(); + using var client = NewClient(); + var earlyResume = ReturnUrl(await DriveAsync(client, early)); + var lateResume = ReturnUrl(await DriveAsync(client, late)); + var identity = await RegisterAsync(client, $"late-{Guid.NewGuid():N}"); + await _factory.SetMembershipAsync(identity, MembershipStatus.Active); + + _factory.Clock.Advance(TimeSpan.FromMinutes(5)); + using var withinLifetime = await client.GetAsync(earlyResume); + Assert.True(AssertRedirectToClient(withinLifetime, early.State).ContainsKey("code"), "five minutes is within the lifetime"); + + _factory.Clock.Advance(TimeSpan.FromMinutes(25)); + using var expired = await client.GetAsync(lateResume); + var location = expired.Headers.Location?.OriginalString ?? string.Empty; + Assert.DoesNotContain("code=", location); + Assert.NotEqual(HttpStatusCode.OK, expired.StatusCode); + } + + [Fact] + public async Task The_token_endpoint_accepts_client_secret_basic_only() + { + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Basic Only"); + var request = NewRequest(); + var code = await CodeAsync(client, request); + + var wrongSecret = await TokenAsync(client, request, code, secret: "wrong-secret"); + Assert.NotEqual(HttpStatusCode.OK, wrongSecret.StatusCode); + + var asPost = await TokenAsync(client, request, code, secretInBody: true); + Assert.NotEqual(HttpStatusCode.OK, asPost.StatusCode); + } + + [Fact] + public async Task Identity_is_minted_once() + { + using var scope = _factory.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var account = new UserAccount { BifrostIdentity = "Original", NormalizedBifrostIdentity = "original" }; + + await service.EnsureIdentityAsync(account, "someone-else", CancellationToken.None); + + Assert.Equal("original", account.NormalizedBifrostIdentity); + Assert.Equal("Original", account.BifrostIdentity); + } + + [Fact] + public async Task The_openid_scope_alone_releases_the_subject_and_nothing_else() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Scoped Down"); + var request = NewRequest(scope: "openid"); + var code = await CodeAsync(client, request); + + var tokens = await ExchangeAsync(client, request, code); + + var verified = await ValidateIdTokenAsync(tokens.IdToken, request.Nonce); + Assert.Equal(_factory.AccountId(identity).ToString("D"), verified.Claim("sub")); + Assert.Null(verified.Claim("preferred_username")); + Assert.Null(verified.Claim("name")); + + using var userinfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + userinfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var response = await NewClient().SendAsync(userinfo); + var info = (await response.Content.ReadFromJsonAsync()).EnumerateObject().Select(x => x.Name).ToArray(); + Assert.Equal(["sub"], info); + } + + [Fact] + public async Task A_blank_display_name_falls_back_to_the_bifrost_identity() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, " "); + + var verified = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null); + + Assert.Equal(identity, verified.Claim("name")); + } + + [Fact] + public async Task Userinfo_without_a_valid_token_is_refused() + { + using var client = NewClient(); + using var none = await client.GetAsync("/connect/userinfo"); + Assert.Equal(HttpStatusCode.Unauthorized, none.StatusCode); + + using var forged = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + forged.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-a-token"); + using var forgedResponse = await client.SendAsync(forged); + Assert.Equal(HttpStatusCode.Unauthorized, forgedResponse.StatusCode); + } + + [Fact] + public async Task Startup_makes_the_one_client_match_configuration_again() + { + // Own host: the drift below leaves the application manager's cache stale for every other test. + using var own = new OidcWebApplicationFactory(); + await new OidcIssuerTests(own).SeederRestoresTheClientAsync(); + } + + private async Task SeederRestoresTheClientAsync() + { + using var warm = NewClient(); + await warm.GetAsync("/healthz"); + using var scope = _factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + var application = await manager.FindByClientIdAsync(OidcTestKit.ClientId); + Assert.NotNull(application); + + // Someone widened the client in the database; the next start puts configuration back. + var drifted = new OpenIddict.Abstractions.OpenIddictApplicationDescriptor + { + ClientId = OidcTestKit.ClientId, + ClientType = OpenIddict.Abstractions.OpenIddictConstants.ClientTypes.Confidential, + ClientSecret = "drifted-secret", + RedirectUris = { new Uri("https://evil.example.test/callback") } + }; + await manager.UpdateAsync(application, drifted); + var seeders = _factory.Services.GetServices().OfType().ToList(); + var seeder = Assert.Single(seeders); + await seeder.StartAsync(CancellationToken.None); + + // Read the stored row, not through the manager: the manager's cache still holds the drifted instance. + using var fresh = _factory.Services.CreateScope(); + var rows = fresh.ServiceProvider.GetRequiredService() + .Set() + .AsNoTracking().ToList(); + var stored = Assert.Single(rows); + Assert.Equal([OidcTestKit.RedirectUri], (await manager.GetRedirectUrisAsync(stored)).ToArray()); + Assert.True(await manager.ValidateClientSecretAsync(stored, _factory.Kit.ClientSecret)); + Assert.False(await manager.ValidateClientSecretAsync(stored, "drifted-secret")); + } + + [Theory] + [InlineData(MembershipStatus.Authenticated)] + [InlineData(MembershipStatus.PendingApproval)] + [InlineData(MembershipStatus.Suspended)] + [InlineData(null)] + public async Task A_member_no_longer_active_after_authorize_gets_no_tokens(MembershipStatus? status) + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone"); + var request = NewRequest(); + var code = await CodeAsync(client, request); + + await LeaveActiveAsync(identity, status); + + using var response = await TokenAsync(client, request, code); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("invalid_grant", body); + Assert.DoesNotContain("id_token", body); + Assert.DoesNotContain("access_token", body); + } + + [Theory] + [InlineData(MembershipStatus.Authenticated)] + [InlineData(MembershipStatus.PendingApproval)] + [InlineData(MembershipStatus.Suspended)] + [InlineData(null)] + public async Task Userinfo_refuses_an_access_token_whose_member_is_no_longer_active(MembershipStatus? status) + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone"); + var tokens = await CompleteFlowAsync(client); + + using (var before = await UserInfoAsync(tokens.AccessToken)) + { + Assert.Equal(HttpStatusCode.OK, before.StatusCode); + } + + await LeaveActiveAsync(identity, status); + + using var after = await UserInfoAsync(tokens.AccessToken); + Assert.Equal(HttpStatusCode.Forbidden, after.StatusCode); + Assert.DoesNotContain(identity, await after.Content.ReadAsStringAsync()); + } + + private Task UserInfoAsync(string accessToken) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return NewClient().SendAsync(request); + } + + /// A null status deletes the account instead. + private Task LeaveActiveAsync(string identity, MembershipStatus? status) => + status is { } value ? _factory.SetMembershipAsync(identity, value) : _factory.DeleteAccountAsync(identity); + + [Fact] + public async Task Startup_removes_every_client_but_the_configured_one_with_its_tokens_and_authorizations() + { + // Own host: the stale clients are rows the other tests must not see. + using var own = new OidcCascadeFactory(); + await new OidcIssuerTests(own).StaleClientsAreRemovedAsync(); + } + + private async Task StaleClientsAreRemovedAsync() + { + // Two stale clients, one of them differing from the configured id only in case. + var stale = new[] + { + (Id: "old-mas", Redirect: "https://old.example.test/callback"), + (Id: OidcTestKit.ClientId.ToUpperInvariant(), Redirect: "https://shouting.example.test/callback") + }; + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Member"); + using (var scope = _factory.Services.CreateScope()) + { + var manager = scope.ServiceProvider.GetRequiredService(); + foreach (var (id, redirect) in stale) + { + await manager.CreateAsync(new OpenIddict.Abstractions.OpenIddictApplicationDescriptor + { + ClientId = id, + ClientSecret = "stale-secret", + ClientType = OpenIddict.Abstractions.OpenIddictConstants.ClientTypes.Confidential, + RedirectUris = { new Uri(redirect) }, + Permissions = + { + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Endpoints.Authorization, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Endpoints.Token, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.ResponseTypes.Code, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Scopes.Profile, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddict.Abstractions.OpenIddictConstants.Scopes.OpenId + } + }); + } + } + + // The control: before seeding every client, stale or configured, really can obtain a code and so holds + // an authorization and a token. + foreach (var (id, redirect) in stale) + { + using var issued = await DriveAsync(client, NewRequest(id, redirect)); + Assert.Contains("code=", issued.Headers.Location?.OriginalString ?? string.Empty); + } + + _ = await CodeAsync(client, NewRequest()); + var staleCounts = Counts(stale.Select(x => x.Id)); + Assert.Equal(stale.Length, staleCounts.Count); + Assert.All(staleCounts, x => Assert.True(x.Tokens > 0 && x.Authorizations > 0, "the control must leave grants to remove")); + var configuredBefore = Counts([OidcTestKit.ClientId]).Single(); + Assert.True(configuredBefore.Tokens > 0 && configuredBefore.Authorizations > 0); + + var seeder = Assert.Single(_factory.Services.GetServices().OfType()); + await seeder.StartAsync(CancellationToken.None); + + foreach (var (id, redirect) in stale) + { + using var refused = await DriveAsync(client, NewRequest(id, redirect)); + Assert.DoesNotContain("code=", refused.Headers.Location?.OriginalString ?? string.Empty); + Assert.NotEqual(HttpStatusCode.OK, refused.StatusCode); + } + + using var fresh = _factory.Services.CreateScope(); + var db = fresh.ServiceProvider.GetRequiredService(); + var rows = db.Set().AsNoTracking().ToList(); + Assert.Equal([OidcTestKit.ClientId], rows.Select(x => x.ClientId).ToArray()); + + // Nothing issued to a removed client survives, and the configured client's grants are untouched. + var keptId = rows.Single().Id; + Assert.Empty(db.Set().AsNoTracking().Where(x => x.Application!.Id != keptId)); + Assert.Empty(db.Set().AsNoTracking().Where(x => x.Application!.Id != keptId)); + Assert.Equal(configuredBefore, Counts([OidcTestKit.ClientId]).Single()); + } + + private sealed record GrantCount(int Tokens, int Authorizations); + + private List Counts(IEnumerable clientIds) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var applications = db.Set().AsNoTracking().ToList(); + return clientIds + .Select(id => applications.Single(x => x.ClientId == id).Id) + .Select(applicationId => new GrantCount( + db.Set().Count(x => x.Application!.Id == applicationId), + db.Set().Count(x => x.Application!.Id == applicationId))) + .ToList(); + } + + [Fact] + public async Task The_issuer_is_the_configured_public_base_url_not_the_request_origin() + { + using var factory = new OidcForeignIssuerFactory(); + using var client = factory.CreateClient(ClientOptions()); + var discovery = await client.GetFromJsonAsync("/.well-known/openid-configuration"); + + Assert.Equal(OidcForeignIssuerFactory.PublicBaseUrl, discovery.GetProperty("issuer").GetString()); + } + + // ---- helpers ---- + + private static WebApplicationFactoryClientOptions ClientOptions() => new() + { + AllowAutoRedirect = false, + HandleCookies = true, + BaseAddress = Origin + }; + + private HttpClient NewClient() => _factory.CreateClient(ClientOptions()); + + private static async Task RegisterAsync(HttpClient client, string identity) + { + using var response = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return identity.ToLowerInvariant(); + } + + private async Task<(HttpClient Client, string Identity)> SignedInAsync(MembershipStatus status, string displayName) + { + var client = NewClient(); + var identity = await RegisterAsync(client, $"member-{Guid.NewGuid():N}"); + await _factory.SetMembershipAsync(identity, status); + await _factory.SetDisplayNameAsync(identity, displayName); + return (client, identity); + } + + private async Task SetLinksAsync(string identity, string heimdallAccountId, string gitHubLogin) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); + account.HeimdallAccountId = heimdallAccountId; + account.GitHubLogin = gitHubLogin; + await db.SaveChangesAsync(); + } + + private sealed record AuthRequest(string Verifier, string State, string Nonce, string Path) + { + public string Challenge => Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(Verifier))); + } + + private static AuthRequest NewRequest( + string clientId = OidcTestKit.ClientId, + string redirectUri = OidcTestKit.RedirectUri, + bool pkce = true, + string extra = "", + string scope = "openid%20profile") + { + var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48)); + var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); + var nonce = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); + var challenge = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + var path = "/connect/authorize?response_type=code" + + $"&client_id={Uri.EscapeDataString(clientId)}" + + $"&redirect_uri={Uri.EscapeDataString(redirectUri)}" + + $"&scope={scope}" + + $"&state={state}&nonce={nonce}" + + (pkce ? $"&code_challenge={challenge}&code_challenge_method=S256" : string.Empty) + + extra; + return new AuthRequest(verifier, state, nonce, path); + } + + /// Starts an authorize request and follows the provider's own redirects until the first response that is not one of them. + private static async Task DriveAsync(HttpClient client, AuthRequest request) + { + var response = await client.GetAsync(request.Path); + for (var hops = 0; hops < 4 && IsAuthorizeRedirect(response); hops++) + { + var next = response.Headers.Location!; + response.Dispose(); + response = await client.GetAsync(next.IsAbsoluteUri ? next.PathAndQuery : next.OriginalString); + } + + return response; + } + + private static bool IsAuthorizeRedirect(HttpResponseMessage response) => + response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found && + response.Headers.Location is { } location && + (location.IsAbsoluteUri ? location.AbsolutePath : location.OriginalString).StartsWith("/connect/authorize", StringComparison.Ordinal); + + private static string ReturnUrl(HttpResponseMessage signInRedirect) + { + var location = signInRedirect.Headers.Location!; + var query = location.IsAbsoluteUri ? location.Query : location.OriginalString[location.OriginalString.IndexOf('?')..]; + return Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(query) + .Single(x => string.Equals(x.Key, "returnUrl", StringComparison.OrdinalIgnoreCase)).Value.ToString(); + } + + private static Dictionary AssertRedirectToClient(HttpResponseMessage response, string state) + { + Assert.True(response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found, $"expected a redirect, got {(int)response.StatusCode}"); + var location = response.Headers.Location!; + Assert.Equal(new Uri(OidcTestKit.RedirectUri).GetLeftPart(UriPartial.Path), location.GetLeftPart(UriPartial.Path)); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(location.Query); + Assert.Equal(state, query["state"].ToString()); + return query.ToDictionary(x => x.Key, x => (string?)x.Value.ToString()); + } + + private async Task CodeAsync(HttpClient signedIn, AuthRequest request) + { + var final = await DriveAsync(signedIn, request); + return AssertRedirectToClient(final, request.State)["code"]!; + } + + private sealed record Tokens(AuthRequest Request, string IdToken, string AccessToken); + + private async Task CompleteFlowAsync(HttpClient signedIn) + { + var request = NewRequest(); + var code = await CodeAsync(signedIn, request); + return await ExchangeAsync(signedIn, request, code); + } + + private async Task ExchangeAsync(HttpClient client, AuthRequest request, string code) + { + using var response = await TokenAsync(client, request, code); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.DoesNotContain("refresh_token", body.EnumerateObject().Select(x => x.Name)); + return new Tokens(request, body.GetProperty("id_token").GetString()!, body.GetProperty("access_token").GetString()!); + } + + private async Task TokenAsync( + HttpClient client, AuthRequest request, string code, string? secret = null, bool secretInBody = false) + { + var form = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = OidcTestKit.RedirectUri, + ["code_verifier"] = request.Verifier + }; + var message = new HttpRequestMessage(HttpMethod.Post, "/connect/token"); + var effectiveSecret = secret ?? _factory.Kit.ClientSecret; + if (secretInBody) + { + form["client_id"] = OidcTestKit.ClientId; + form["client_secret"] = effectiveSecret; + } + else + { + message.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", + Convert.ToBase64String(Encoding.ASCII.GetBytes($"{OidcTestKit.ClientId}:{effectiveSecret}"))); + } + + message.Content = new FormUrlEncodedContent(form); + // The code is bound to the client; use a cookieless client so a session cannot influence the exchange. + using var bare = NewClient(); + return await bare.SendAsync(message); + } + + private sealed record Verified(string Alg, string PayloadJson, JsonElement Payload) + { + public string? Claim(string name) => Payload.TryGetProperty(name, out var value) ? value.GetString() : null; + } + + private async Task ValidateIdTokenAsync(string idToken, string? nonce) + { + using var client = NewClient(); + var jwks = new JsonWebKeySet((await GetJwksAsync(client)).GetRawText()); + var result = await new JsonWebTokenHandler().ValidateTokenAsync(idToken, new TokenValidationParameters + { + ValidIssuer = Origin.AbsoluteUri, + ValidAudience = OidcTestKit.ClientId, + IssuerSigningKeys = jwks.GetSigningKeys(), + ValidAlgorithms = ["ES256"] + }); + Assert.True(result.IsValid, result.Exception?.Message); + var jwt = (JsonWebToken)result.SecurityToken; + var payload = Encoding.UTF8.GetString(Base64UrlEncoder.DecodeBytes(jwt.EncodedPayload)); + if (nonce is not null) + { + Assert.Equal(nonce, JsonDocument.Parse(payload).RootElement.GetProperty("nonce").GetString()); + } + + return new Verified(jwt.Alg, payload, JsonDocument.Parse(payload).RootElement.Clone()); + } + + private static async Task GetJwksAsync(HttpClient client) + { + var discovery = await client.GetFromJsonAsync("/.well-known/openid-configuration"); + var jwksUri = new Uri(discovery.GetProperty("jwks_uri").GetString()!); + return await client.GetFromJsonAsync(jwksUri.PathAndQuery); + } + + private static string[] Strings(JsonElement document, string property) => + document.GetProperty(property).EnumerateArray().Select(x => x.GetString()!).ToArray(); +} + +public sealed class OidcSurfaceAbsentTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public OidcSurfaceAbsentTests(TestWebApplicationFactory factory) => _factory = factory; + + [Theory] + [InlineData("/.well-known/openid-configuration")] + [InlineData("/.well-known/jwks")] + [InlineData("/connect/authorize")] + [InlineData("/connect/token")] + [InlineData("/connect/userinfo")] + public async Task Without_the_configuration_section_no_provider_surface_exists(string path) + { + using var client = _factory.CreateClient(); + using var response = await client.GetAsync(path); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} + +public sealed class OidcStartupFailureTests +{ + [Theory] + [InlineData(OidcKeyFault.MissingFile)] + [InlineData(OidcKeyFault.RsaKey)] + [InlineData(OidcKeyFault.PublicKeyOnly)] + [InlineData(OidcKeyFault.NotPem)] + public void A_configured_provider_with_an_unusable_signing_key_stops_startup_naming_the_setting_not_the_path(OidcKeyFault fault) + { + using var factory = new OidcKeyFaultFactory(fault); + + var failure = Assert.ThrowsAny(() => factory.CreateClient()); + + var messages = Flatten(failure).ToList(); + Assert.Contains(messages, m => m.Contains("Bifrost:Oidc:SigningKeyPath", StringComparison.Ordinal)); + Assert.DoesNotContain(messages, m => m.Contains("canary-", StringComparison.Ordinal)); + Assert.DoesNotContain(messages, m => m.Contains("BEGIN", StringComparison.Ordinal)); + } + + private static IEnumerable Flatten(Exception error) + { + for (var current = error; current is not null; current = current.InnerException!) + { + yield return current.Message; + if (current.InnerException is null) + { + yield break; + } + } + } +} diff --git a/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs new file mode 100644 index 0000000..a5bc0e0 --- /dev/null +++ b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs @@ -0,0 +1,104 @@ +using System.Security.Cryptography; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Oidc; +using Bifrost.Web.Tests.Support; +using Microsoft.IdentityModel.Tokens; + +namespace Bifrost.Web.Tests; + +public sealed class OidcKeyMaterialTests : IDisposable +{ + private readonly OidcTestKit _kit = new(); + + public void Dispose() => _kit.Dispose(); + + [Fact] + public void A_complete_configuration_loads_the_signing_key_the_secret_and_the_encryption_key() + { + using var material = OidcKeyMaterial.Load(Options()); + + var signing = Assert.IsType(material.SigningKey); + Assert.False(string.IsNullOrEmpty(signing.KeyId)); + Assert.Equal(_kit.ClientSecret, material.ClientSecret); + Assert.Equal(32, Assert.IsType(material.EncryptionKey).Key.Length); + } + + [Theory] + [InlineData("SigningKeyPath", "Bifrost:Oidc:SigningKeyPath")] + [InlineData("EncryptionKeyPath", "Bifrost:Oidc:EncryptionKeyPath")] + [InlineData("ClientId", "Bifrost:Oidc:Clients:Mas:ClientId")] + [InlineData("ClientSecretPath", "Bifrost:Oidc:Clients:Mas:ClientSecretPath")] + [InlineData("RedirectUri", "Bifrost:Oidc:Clients:Mas:RedirectUri")] + public void A_missing_setting_stops_startup_naming_that_setting(string blank, string expectedField) + { + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options(blank: blank))); + + Assert.Contains(expectedField, failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_redirect_uri_that_is_not_absolute_stops_startup() + { + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options(redirectUri: "/callback"))); + + Assert.Contains("Bifrost:Oidc:Clients:Mas:RedirectUri", failure.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("missing")] + [InlineData("not base64 !!")] + [InlineData("AAAAAAAAAAAAAAAAAAAAAA==")] + public void An_unusable_encryption_key_stops_startup_naming_the_setting_not_the_path(string content) + { + if (content == "missing") + { + File.Delete(_kit.EncryptionKeyPath); + } + else + { + File.WriteAllText(_kit.EncryptionKeyPath, content); + } + + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options())); + + Assert.Contains("Bifrost:Oidc:EncryptionKeyPath", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("canary-", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_missing_client_secret_file_stops_startup_naming_the_setting_not_the_path() + { + File.Delete(_kit.ClientSecretPath); + + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options())); + + Assert.Contains("Bifrost:Oidc:Clients:Mas:ClientSecretPath", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("canary-", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_P384_key_is_not_a_P256_key() + { + using var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384); + File.WriteAllText(_kit.SigningKeyPath, p384.ExportPkcs8PrivateKeyPem()); + + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options())); + + Assert.Contains("Bifrost:Oidc:SigningKeyPath", failure.Message, StringComparison.Ordinal); + } + + private OidcIssuerOptions Options(string? blank = null, string? redirectUri = null) => new() + { + SigningKeyPath = blank == "SigningKeyPath" ? "" : _kit.SigningKeyPath, + EncryptionKeyPath = blank == "EncryptionKeyPath" ? "" : _kit.EncryptionKeyPath, + Clients = new OidcClientsOptions + { + Mas = new OidcClientOptions + { + ClientId = blank == "ClientId" ? "" : OidcTestKit.ClientId, + ClientSecretPath = blank == "ClientSecretPath" ? "" : _kit.ClientSecretPath, + RedirectUri = blank == "RedirectUri" ? "" : redirectUri ?? OidcTestKit.RedirectUri + } + } + }; +} diff --git a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs new file mode 100644 index 0000000..231cd33 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs @@ -0,0 +1,260 @@ +using System.Security.Cryptography; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using OpenIddict.EntityFrameworkCore; +using OpenIddict.EntityFrameworkCore.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bifrost.Web.Tests.Support; + +/// What is wrong with the signing key file a test host is started with. +public enum OidcKeyFault +{ + None, + MissingFile, + RsaKey, + PublicKeyOnly, + NotPem +} + +/// Key files and client settings for an OIDC test host. Everything is generated per kit; nothing is shared or committed. +public sealed class OidcTestKit : IDisposable +{ + public const string ClientId = "mas-test"; + public const string RedirectUri = "https://chat.example.test/_synapse/client/oidc/callback"; + + public OidcTestKit(OidcKeyFault fault = OidcKeyFault.None) + { + // The directory name is a canary: no error may echo any part of a configured path. + Directory = Path.Combine(Path.GetTempPath(), "canary-" + Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(Directory); + + using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256); + SigningKeyPem = ec.ExportPkcs8PrivateKeyPem(); + SigningPublicKeyPem = ec.ExportSubjectPublicKeyInfoPem(); + ClientSecret = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)); + + SigningKeyPath = Path.Combine(Directory, "signing.pem"); + EncryptionKeyPath = Path.Combine(Directory, "encryption.key"); + ClientSecretPath = Path.Combine(Directory, "mas.secret"); + + switch (fault) + { + case OidcKeyFault.None: + File.WriteAllText(SigningKeyPath, SigningKeyPem); + break; + case OidcKeyFault.RsaKey: + using (var rsa = RSA.Create(2048)) + { + File.WriteAllText(SigningKeyPath, rsa.ExportPkcs8PrivateKeyPem()); + } + + break; + case OidcKeyFault.PublicKeyOnly: + File.WriteAllText(SigningKeyPath, SigningPublicKeyPem); + break; + case OidcKeyFault.NotPem: + File.WriteAllText(SigningKeyPath, "not a key"); + break; + case OidcKeyFault.MissingFile: + break; + } + + File.WriteAllText(EncryptionKeyPath, Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + "\n"); + File.WriteAllText(ClientSecretPath, ClientSecret + "\n"); + } + + public string Directory { get; } + + public string SigningKeyPath { get; } + + public string EncryptionKeyPath { get; } + + public string ClientSecretPath { get; } + + public string ClientSecret { get; } + + public string SigningKeyPem { get; } + + public string SigningPublicKeyPem { get; } + + public IReadOnlyDictionary Configuration => new Dictionary + { + ["Bifrost:Oidc:SigningKeyPath"] = SigningKeyPath, + ["Bifrost:Oidc:EncryptionKeyPath"] = EncryptionKeyPath, + ["Bifrost:Oidc:Clients:Mas:ClientId"] = ClientId, + ["Bifrost:Oidc:Clients:Mas:ClientSecretPath"] = ClientSecretPath, + ["Bifrost:Oidc:Clients:Mas:RedirectUri"] = RedirectUri + }; + + public void Dispose() + { + try + { + System.IO.Directory.Delete(Directory, recursive: true); + } + catch (IOException) + { + } + } +} + +/// A clock the test moves, so a parked authorize request can be aged past its lifetime. +public sealed class OidcTestClock : TimeProvider +{ + private DateTimeOffset _now = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; +} + +/// +/// A test host with the OIDC provider configured from generated key files and cookie authentication as the real +/// sign-in state (the shared test host signs everyone in as an admin through a header-driven handler instead). +/// +public class OidcWebApplicationFactory : TestWebApplicationFactory +{ + public OidcWebApplicationFactory() : this(OidcKeyFault.None) + { + } + + protected OidcWebApplicationFactory(OidcKeyFault fault) => Kit = new(fault); + + public OidcTestKit Kit { get; } + + public OidcTestClock Clock { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + + // Program decides at registration time whether the provider exists, so these must be host settings: + // configuration added with ConfigureAppConfiguration reaches the app only at Build(). + foreach (var (key, value) in Kit.Configuration) + { + builder.UseSetting(key, value); + } + + builder.ConfigureServices(services => + { + services.Configure(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; + }); + services.RemoveAll(); + services.AddSingleton(Clock); + ConfigureActors(services); + }); + } + + /// A hook for tests that replace the seam Bifrost reads the signed-in account through. + protected virtual void ConfigureActors(IServiceCollection services) + { + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + Kit.Dispose(); + } + } + + /// Sets the membership of the account registered under . + public async Task SetMembershipAsync(string identity, MembershipStatus status) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); + db.Memberships.Single(x => x.UserAccountId == account.Id).Status = status; + await db.SaveChangesAsync(); + } + + /// Renames the account's display name, as the owner would between two sign-ins. + public async Task SetDisplayNameAsync(string identity, string displayName) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).DisplayName = displayName; + await db.SaveChangesAsync(); + } + + /// Removes the account (and with it its membership), as an owner deleting themselves would. + public async Task DeleteAccountAsync(string identity) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.UserAccounts.Remove(db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity)); + await db.SaveChangesAsync(); + } + + public Guid AccountId(string identity) + { + using var scope = Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService() + .UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id; + } +} + +/// An OIDC host whose signing key file is unusable, so startup must refuse it. +public sealed class OidcKeyFaultFactory(OidcKeyFault fault) : OidcWebApplicationFactory(fault); + +/// An OIDC host whose actor accessor reports an Active account that has no Bifrost identity, which sign-in itself never produces. +public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory +{ + protected override void ConfigureActors(IServiceCollection services) + { + services.RemoveAll(); + services.AddScoped(); + } + + private sealed class ActiveWithoutIdentity : ICurrentBifrostActorAccessor + { + public Task GetAsync(CancellationToken cancellationToken) => + Task.FromResult(new CurrentBifrostActor(true, new UserAccount + { + NormalizedBifrostIdentity = string.Empty, + DisplayName = "No Identity", + Membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active } + })); + } +} + +/// An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived. +public sealed class OidcForeignIssuerFactory : OidcWebApplicationFactory +{ + public const string PublicBaseUrl = "https://id.example.test/"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary { ["Host:PublicBaseUrl"] = PublicBaseUrl })); + } +} + +/// +/// An OIDC host on OpenIddict's own non-bulk deletion path. The in-memory test database cannot run the bulk +/// ExecuteDelete the library otherwise uses to remove an application's tokens and authorizations, so this switches +/// that option and the library's cascade runs unchanged. +/// +public sealed class OidcCascadeFactory : OidcWebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => + services.Configure(options => options.DisableBulkOperations = true)); + } +} diff --git a/tests/Bifrost.Web.Tests/packages.lock.json b/tests/Bifrost.Web.Tests/packages.lock.json index fecc424..e20d6e5 100644 --- a/tests/Bifrost.Web.Tests/packages.lock.json +++ b/tests/Bifrost.Web.Tests/packages.lock.json @@ -68,6 +68,14 @@ "System.IO.Pipelines": "8.0.0" } }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==", + "dependencies": { + "System.Formats.Asn1": "10.0.2" + } + }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "17.14.1", @@ -75,31 +83,31 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "SoODat83pGQUpWB9xULdMX6tuKpq/RTXDuJ2WeC1ldUKcKzLkaFJD1n+I0nOLY58odez/e7z8b6zdp235G/kyg==", + "resolved": "8.0.30", + "contentHash": "Y6GP16iuqAUkcXSgMqjgao9w0UHSz8x04LRDadsi++sn7yBI9C/sy61xNLzPKM+6DZEbyPRHGq3M58f6DhB25g==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "8.0.0", - "Microsoft.EntityFrameworkCore.Analyzers": "8.0.0", - "Microsoft.Extensions.Caching.Memory": "8.0.0", - "Microsoft.Extensions.Logging": "8.0.0" + "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.0", - "contentHash": "VR22s3+zoqlVI7xauFKn1znSIFHO8xuILT+noSwS8bZCKcHz0ydkTDQMuaxSa5WBaQrZmwtTz9rmRvJ7X8mSPQ==" + "resolved": "8.0.30", + "contentHash": "JoVu3708PrMcA3BUeo1+gTcD4nRI8o3g3kwNXwgTPUDhe0dqN2CQmHmEPDD0uZlI1v2oq3F7tR46VycUHzP9ng==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "ZXxEeLs2zoZ1TA+QoMMcw4f3Tirf8PzgdDax8RoWo0dxI2KmqiEGWYjhm2B/XyWfglc6+mNRyB8rZiQSmxCpeg==" + "resolved": "8.0.30", + "contentHash": "+ZVRBR3CzGu0AGWTbewOWTzV+UG//yDrA0gdvb9i1dTidSvwG9qRRggEI9fie0MPC0LAcS9Z8X5HRYWWxefutg==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "fFKkr24cYc7Zw5T6DC4tEyOEPgPbq23BBmym1r9kn4ET9F3HKaetpOeQtV2RryYyUxEeNkJuxgfiZHTisqZc+A==", + "resolved": "8.0.30", + "contentHash": "uGWv6fvDUvJDKAYXfVwPVLmit2i6WCmfdzwrHHuw4OJ30s9GDVLsvV62Gi8TIgFSUlTVgSVW/c+hAvDwmY1TDw==", "dependencies": { - "Microsoft.EntityFrameworkCore": "8.0.0", + "Microsoft.EntityFrameworkCore": "8.0.30", "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" } }, @@ -203,10 +211,10 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==", + "resolved": "8.0.1", + "contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { @@ -309,12 +317,12 @@ }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==", + "resolved": "8.0.1", + "contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "8.0.0", - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0" + "Microsoft.Extensions.DependencyInjection": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" } }, "Microsoft.Extensions.Logging.Abstractions": { @@ -414,6 +422,37 @@ "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" + } + }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", "resolved": "17.14.1", @@ -455,6 +494,60 @@ "Npgsql": "8.0.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": { + "type": "Transitive", + "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.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" + } + }, + "OpenIddict.Server.AspNetCore": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "YQW64MJBVXsJ+H5kb+HFAQaSXsavOCEG+kDgD+DaYK7apLFtbOrgN233RhHOuztChWcY8SfjpjeN0ZXPwTFsbA==", + "dependencies": { + "OpenIddict.Server": "7.7.1" + } + }, "System.Collections.Immutable": { "type": "Transitive", "resolved": "8.0.0", @@ -470,6 +563,11 @@ "resolved": "8.0.0", "contentHash": "fdYxcRjQqTTacKId/2IECojlDSFvp7LP5N78+0z/xH7v/Tuw5ZAxu23Y6PTCRinqyu2ePx+Gn1098NC6jM6d+A==" }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q==" + }, "System.IO.Pipelines": { "type": "Transitive", "resolved": "8.0.0", @@ -540,6 +638,8 @@ "Microsoft.Extensions.Caching.Memory": "[8.0.1, )", "Npgsql": "[8.0.3, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.0, )", + "OpenIddict.EntityFrameworkCore": "[7.7.1, )", + "OpenIddict.Server.AspNetCore": "[7.7.1, )", "System.Text.Json": "[8.0.5, )" } }