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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-27 04:57:16 +01:00
commit 83daabe512
20 changed files with 4771 additions and 48 deletions

View file

@ -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

View file

@ -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.

85
docs/oidc-issuer.md Normal file
View file

@ -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.

View file

@ -17,6 +17,8 @@
<PackageReference Include="Npgsql" Version="8.0.3" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="7.7.1" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.7.1" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,41 @@
namespace Bifrost.Web.Configuration;
/// <summary>
/// The OIDC provider surface (section <c>Bifrost:Oidc</c>). 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.
/// </summary>
public sealed class OidcIssuerOptions
{
public const string SectionName = "Bifrost:Oidc";
/// <summary>PEM file holding the EC P-256 private key that signs tokens (ES256).</summary>
public string SigningKeyPath { get; init; } = string.Empty;
/// <summary>File holding the 32-byte symmetric token-encryption key as base64.</summary>
public string EncryptionKeyPath { get; init; } = string.Empty;
public OidcClientsOptions Clients { get; init; } = new();
/// <summary>Null when the section is absent.</summary>
public static OidcIssuerOptions? FromConfiguration(IConfiguration configuration)
{
var section = configuration.GetSection(SectionName);
return section.Exists() ? section.Get<OidcIssuerOptions>() ?? new OidcIssuerOptions() : null;
}
}
public sealed class OidcClientsOptions
{
/// <summary>Matrix Authentication Service, the one client of this provider.</summary>
public OidcClientOptions Mas { get; init; } = new();
}
public sealed class OidcClientOptions
{
public string ClientId { get; init; } = string.Empty;
/// <summary>File holding the client secret.</summary>
public string ClientSecretPath { get; init; } = string.Empty;
public string RedirectUri { get; init; } = string.Empty;
}

View file

@ -5,6 +5,10 @@ namespace Bifrost.Web.Data;
public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> 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<UserAccount> UserAccounts => Set<UserAccount>();
public DbSet<MemberProfile> MemberProfiles => Set<MemberProfile>();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,166 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
public partial class AddOpenIddict : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "OpenIddictApplications",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ApplicationType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ClientId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
ClientSecret = table.Column<string>(type: "text", nullable: true),
ClientType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ConsentType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
DisplayName = table.Column<string>(type: "text", nullable: true),
DisplayNames = table.Column<string>(type: "text", nullable: true),
JsonWebKeySet = table.Column<string>(type: "text", nullable: true),
Permissions = table.Column<string>(type: "text", nullable: true),
PostLogoutRedirectUris = table.Column<string>(type: "text", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
RedirectUris = table.Column<string>(type: "text", nullable: true),
Requirements = table.Column<string>(type: "text", nullable: true),
Settings = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictApplications", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OpenIddictScopes",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
Descriptions = table.Column<string>(type: "text", nullable: true),
DisplayName = table.Column<string>(type: "text", nullable: true),
DisplayNames = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
Resources = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictScopes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OpenIddictAuthorizations",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ApplicationId = table.Column<string>(type: "text", nullable: true),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
CreationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
Scopes = table.Column<string>(type: "text", nullable: true),
Status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Subject = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: true),
Type = table.Column<string>(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<string>(type: "text", nullable: false),
ApplicationId = table.Column<string>(type: "text", nullable: true),
AuthorizationId = table.Column<string>(type: "text", nullable: true),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
CreationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ExpirationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Payload = table.Column<string>(type: "text", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
RedemptionDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ReferenceId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Subject = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: true),
Type = table.Column<string>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OpenIddictScopes");
migrationBuilder.DropTable(
name: "OpenIddictTokens");
migrationBuilder.DropTable(
name: "OpenIddictAuthorizations");
migrationBuilder.DropTable(
name: "OpenIddictApplications");
}
}
}

View file

@ -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<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ApplicationType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ClientId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ClientSecret")
.HasColumnType("text");
b.Property<string>("ClientType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ConsentType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("DisplayNames")
.HasColumnType("text");
b.Property<string>("JsonWebKeySet")
.HasColumnType("text");
b.Property<string>("Permissions")
.HasColumnType("text");
b.Property<string>("PostLogoutRedirectUris")
.HasColumnType("text");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<string>("RedirectUris")
.HasColumnType("text");
b.Property<string>("Requirements")
.HasColumnType("text");
b.Property<string>("Settings")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ClientId")
.IsUnique();
b.ToTable("OpenIddictApplications", (string)null);
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ApplicationId")
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<string>("Scopes")
.HasColumnType("text");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("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<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Descriptions")
.HasColumnType("text");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("DisplayNames")
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<string>("Resources")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("OpenIddictScopes", (string)null);
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ApplicationId")
.HasColumnType("text");
b.Property<string>("AuthorizationId")
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Payload")
.HasColumnType("text");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<DateTime?>("RedemptionDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReferenceId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("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
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class OidcClientSeeder(
IServiceScopeFactory scopeFactory,
OidcIssuerOptions options,
OidcKeyMaterial material,
IOptions<OpenIddictServerOptions> 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<IOpenIddictApplicationManager>();
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<object>();
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;
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<IResult> 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<IResult> 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<IResult> 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<string, object?> { [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);
}
/// <summary>The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints.</summary>
private static async Task<bool> 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<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = error,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description
}),
[OpenIddictScheme]);
}

View file

@ -0,0 +1,133 @@
using System.Security.Cryptography;
using Bifrost.Web.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace Bifrost.Web.Features.Oidc;
/// <summary>A configuration fault in the OIDC provider. The message names the setting, never its value.</summary>
public sealed class OidcConfigurationException(string message) : InvalidOperationException(message);
/// <summary>
/// The secrets the OIDC provider runs on, read once from the files <see cref="OidcIssuerOptions"/> names. Any
/// unreadable or wrong-shaped file stops startup; there is no fallback key.
/// </summary>
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.");
}
}
}

View file

@ -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";
/// <summary>How long a parked authorize request (OpenIddict's request token) can wait for the user to sign in.</summary>
private static readonly TimeSpan ParkedRequestLifetime = TimeSpan.FromMinutes(10);
/// <summary>
/// Registers Bifrost as an OIDC provider whose issuer is <see cref="BifrostHostOptions.PublicBaseUrl"/>: 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.
/// </summary>
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<BifrostDbContext>())
.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<OpenIddictServerOptions>().Configure<IOptions<BifrostHostOptions>>((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<OidcClientSeeder>();
return services;
}
}

View file

@ -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,

View file

@ -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",

View file

@ -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());
}

View file

@ -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<OidcWebApplicationFactory>
{
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<JsonElement>("/.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<Bifrost.Web.Features.Membership.BifrostIdentityService>();
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<JsonElement>()).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<OpenIddict.Abstractions.IOpenIddictApplicationManager>();
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<IHostedService>().OfType<Bifrost.Web.Features.Oidc.OidcClientSeeder>().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<Bifrost.Web.Data.BifrostDbContext>()
.Set<OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication>()
.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<HttpResponseMessage> UserInfoAsync(string accessToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
return NewClient().SendAsync(request);
}
/// <summary>A null status deletes the account instead.</summary>
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<OpenIddict.Abstractions.IOpenIddictApplicationManager>();
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<IHostedService>().OfType<Bifrost.Web.Features.Oidc.OidcClientSeeder>());
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<Bifrost.Web.Data.BifrostDbContext>();
var rows = db.Set<OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication>().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<OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken>().AsNoTracking().Where(x => x.Application!.Id != keptId));
Assert.Empty(db.Set<OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization>().AsNoTracking().Where(x => x.Application!.Id != keptId));
Assert.Equal(configuredBefore, Counts([OidcTestKit.ClientId]).Single());
}
private sealed record GrantCount(int Tokens, int Authorizations);
private List<GrantCount> Counts(IEnumerable<string> clientIds)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Bifrost.Web.Data.BifrostDbContext>();
var applications = db.Set<OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication>().AsNoTracking().ToList();
return clientIds
.Select(id => applications.Single(x => x.ClientId == id).Id)
.Select(applicationId => new GrantCount(
db.Set<OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken>().Count(x => x.Application!.Id == applicationId),
db.Set<OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization>().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<JsonElement>("/.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<string> 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<Bifrost.Web.Data.BifrostDbContext>();
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);
}
/// <summary>Starts an authorize request and follows the provider's own redirects until the first response that is not one of them.</summary>
private static async Task<HttpResponseMessage> 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<string, string?> 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<string> 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<Tokens> CompleteFlowAsync(HttpClient signedIn)
{
var request = NewRequest();
var code = await CodeAsync(signedIn, request);
return await ExchangeAsync(signedIn, request, code);
}
private async Task<Tokens> 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<JsonElement>();
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<HttpResponseMessage> TokenAsync(
HttpClient client, AuthRequest request, string code, string? secret = null, bool secretInBody = false)
{
var form = new Dictionary<string, string>
{
["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<Verified> 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<JsonElement> GetJwksAsync(HttpClient client)
{
var discovery = await client.GetFromJsonAsync<JsonElement>("/.well-known/openid-configuration");
var jwksUri = new Uri(discovery.GetProperty("jwks_uri").GetString()!);
return await client.GetFromJsonAsync<JsonElement>(jwksUri.PathAndQuery);
}
private static string[] Strings(JsonElement document, string property) =>
document.GetProperty(property).EnumerateArray().Select(x => x.GetString()!).ToArray();
}
public sealed class OidcSurfaceAbsentTests : IClassFixture<TestWebApplicationFactory>
{
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<Exception>(() => 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<string> 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;
}
}
}
}

View file

@ -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<ECDsaSecurityKey>(material.SigningKey);
Assert.False(string.IsNullOrEmpty(signing.KeyId));
Assert.Equal(_kit.ClientSecret, material.ClientSecret);
Assert.Equal(32, Assert.IsType<SymmetricSecurityKey>(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<OidcConfigurationException>(() => 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<OidcConfigurationException>(() => 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<OidcConfigurationException>(() => 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<OidcConfigurationException>(() => 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<OidcConfigurationException>(() => 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
}
}
};
}

View file

@ -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;
/// <summary>What is wrong with the signing key file a test host is started with.</summary>
public enum OidcKeyFault
{
None,
MissingFile,
RsaKey,
PublicKeyOnly,
NotPem
}
/// <summary>Key files and client settings for an OIDC test host. Everything is generated per kit; nothing is shared or committed.</summary>
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<string, string?> Configuration => new Dictionary<string, string?>
{
["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)
{
}
}
}
/// <summary>A clock the test moves, so a parked authorize request can be aged past its lifetime.</summary>
public sealed class OidcTestClock : TimeProvider
{
private DateTimeOffset _now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
/// <summary>
/// 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).
/// </summary>
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<AuthenticationOptions>(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
});
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(Clock);
ConfigureActors(services);
});
}
/// <summary>A hook for tests that replace the seam Bifrost reads the signed-in account through.</summary>
protected virtual void ConfigureActors(IServiceCollection services)
{
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
Kit.Dispose();
}
}
/// <summary>Sets the membership of the account registered under <paramref name="identity"/>.</summary>
public async Task SetMembershipAsync(string identity, MembershipStatus status)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
db.Memberships.Single(x => x.UserAccountId == account.Id).Status = status;
await db.SaveChangesAsync();
}
/// <summary>Renames the account's display name, as the owner would between two sign-ins.</summary>
public async Task SetDisplayNameAsync(string identity, string displayName)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).DisplayName = displayName;
await db.SaveChangesAsync();
}
/// <summary>Removes the account (and with it its membership), as an owner deleting themselves would.</summary>
public async Task DeleteAccountAsync(string identity)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
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<BifrostDbContext>()
.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id;
}
}
/// <summary>An OIDC host whose signing key file is unusable, so startup must refuse it.</summary>
public sealed class OidcKeyFaultFactory(OidcKeyFault fault) : OidcWebApplicationFactory(fault);
/// <summary>An OIDC host whose actor accessor reports an Active account that has no Bifrost identity, which sign-in itself never produces.</summary>
public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory
{
protected override void ConfigureActors(IServiceCollection services)
{
services.RemoveAll<ICurrentBifrostActorAccessor>();
services.AddScoped<ICurrentBifrostActorAccessor, ActiveWithoutIdentity>();
}
private sealed class ActiveWithoutIdentity : ICurrentBifrostActorAccessor
{
public Task<CurrentBifrostActor> 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 }
}));
}
}
/// <summary>An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived.</summary>
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<string, string?> { ["Host:PublicBaseUrl"] = PublicBaseUrl }));
}
}
/// <summary>
/// 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.
/// </summary>
public sealed class OidcCascadeFactory : OidcWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services =>
services.Configure<OpenIddictEntityFrameworkCoreOptions>(options => options.DisableBulkOperations = true));
}
}

View file

@ -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, )"
}
}