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:
parent
78d38d92e6
commit
83daabe512
20 changed files with 4771 additions and 48 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
85
docs/oidc-issuer.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
41
src/Bifrost.Web/Configuration/OidcIssuerOptions.cs
Normal file
41
src/Bifrost.Web/Configuration/OidcIssuerOptions.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
2441
src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs
generated
Normal file
2441
src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
166
src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs
Normal file
166
src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
72
src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs
Normal file
72
src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs
Normal 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;
|
||||
}
|
||||
142
src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs
Normal file
142
src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs
Normal 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]);
|
||||
}
|
||||
133
src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs
Normal file
133
src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
737
tests/Bifrost.Web.Tests/OidcIssuerTests.cs
Normal file
737
tests/Bifrost.Web.Tests/OidcIssuerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs
Normal file
104
tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs
Normal 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
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
260
tests/Bifrost.Web.Tests/Support/OidcTestKit.cs
Normal file
260
tests/Bifrost.Web.Tests/Support/OidcTestKit.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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, )"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue