Add native Bifrost identity registration
This commit is contained in:
parent
445c27978b
commit
23c02be2a6
17 changed files with 366 additions and 29 deletions
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
- product name: `Bifrost`
|
- product name: `Bifrost`
|
||||||
- v1 audience: invite-only member alpha
|
- v1 audience: invite-only member alpha
|
||||||
- auth model: GitHub sign-in only
|
- auth model: Bifrost-native identity registration, with GitHub and Heimdall-linked provider accounts attached as external identities
|
||||||
- membership model: invite + approval gate before active participation
|
- membership model: invite + approval gate before active participation
|
||||||
- task sources: GitHub issues plus internal tasks
|
- task sources: GitHub issues plus internal tasks
|
||||||
- governance model: app-native motions and voting
|
- governance model: app-native motions and voting
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core
|
||||||
|
|
||||||
- Razor Pages app under `src/Bifrost.Web`
|
- Razor Pages app under `src/Bifrost.Web`
|
||||||
- PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations`
|
- PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations`
|
||||||
- GitHub OAuth sign-in plus invite/approval-based membership gating
|
- Bifrost-native identity registration plus GitHub OAuth and Heimdall-linked provider identities, with invite/approval-based membership gating
|
||||||
- explicit member roles and admin-managed patron or contributor tier snapshots
|
- explicit member roles and admin-managed patron or contributor tier snapshots
|
||||||
- patron support event recording with derived patron point summaries and tier refresh for voting weight
|
- patron support event recording with derived patron point summaries and tier refresh for voting weight
|
||||||
- shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links
|
- shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links
|
||||||
|
|
@ -37,13 +37,15 @@ It is closer to deployable now, but it is still not a finished internet-facing m
|
||||||
- ASP.NET Core 8 LTS
|
- ASP.NET Core 8 LTS
|
||||||
- Razor Pages + HTMX
|
- Razor Pages + HTMX
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- GitHub OAuth for sign-in
|
- Bifrost-native identity registration
|
||||||
|
- GitHub OAuth for linked sign-in and repository integration
|
||||||
- GitHub App for issue and pull request integration
|
- GitHub App for issue and pull request integration
|
||||||
|
|
||||||
## Alpha Scope
|
## Alpha Scope
|
||||||
|
|
||||||
- invite-only member portal
|
- invite-only member portal
|
||||||
- GitHub sign-in only
|
- Bifrost identity registration without OAuth; membership remains invite/approval gated
|
||||||
|
- GitHub and Heimdall-linked provider accounts as external identities
|
||||||
- GitHub-backed and internal work items
|
- GitHub-backed and internal work items
|
||||||
- app-native motions and voting
|
- app-native motions and voting
|
||||||
- internal ledgers for patronage, contribution, point evidence, and payout eligibility
|
- internal ledgers for patronage, contribution, point evidence, and payout eligibility
|
||||||
|
|
@ -70,6 +72,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
|
||||||
## Local Dev Notes
|
## Local Dev Notes
|
||||||
|
|
||||||
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
|
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
|
||||||
|
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
|
||||||
- set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path
|
- set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path
|
||||||
- set `Bridge:LocalBridgeToken` to allow the transitional local bridge actuator to register and receipt governed actions through `/bridge/actions/*`
|
- set `Bridge:LocalBridgeToken` to allow the transitional local bridge actuator to register and receipt governed actions through `/bridge/actions/*`
|
||||||
- set `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` when `tools/bifrost-bridge.mjs` should round-trip every action through the app ledger before mutating GitHub, Discord, or Reddit
|
- set `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` when `tools/bifrost-bridge.mjs` should round-trip every action through the app ledger before mutating GitHub, Discord, or Reddit
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
- One ASP.NET web app process for v1.
|
- One ASP.NET web app process for v1.
|
||||||
- Server-rendered UI with HTMX for progressive interactions.
|
- Server-rendered UI with HTMX for progressive interactions.
|
||||||
- PostgreSQL as the system of record.
|
- PostgreSQL as the system of record.
|
||||||
- GitHub OAuth for login.
|
- Bifrost-native identity registration for login, with GitHub OAuth available as a linked external identity.
|
||||||
- GitHub App plus webhooks for issue and pull request lifecycle sync.
|
- GitHub App plus webhooks for issue and pull request lifecycle sync.
|
||||||
- Discord and Reddit should become native Bifrost interfaces for work, voting/priority prompts, reward discussion, maintainer notices, public organizing threads, and agent swarm routing once identity/capability claims are available.
|
- Discord and Reddit should become native Bifrost interfaces for work, voting/priority prompts, reward discussion, maintainer notices, public organizing threads, and agent swarm routing once identity/capability claims are available.
|
||||||
- Yggdrasil-hosted deployment behind nginx with systemd-managed app service.
|
- Yggdrasil-hosted deployment behind nginx with systemd-managed app service.
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
## Product Rules
|
## Product Rules
|
||||||
|
|
||||||
- Invite-only member alpha.
|
- Invite-only member alpha.
|
||||||
- GitHub sign-in only in v1.
|
- Bifrost-native identity is the local account root in v1; GitHub and Heimdall-linked providers attach to that root instead of owning it.
|
||||||
- Authentication is separate from membership approval.
|
- Authentication is separate from membership approval.
|
||||||
- Work items may come from GitHub issues or internal platform-native tasks.
|
- Work items may come from GitHub issues or internal platform-native tasks.
|
||||||
- Voting is app-native and off-chain.
|
- Voting is app-native and off-chain.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ That means Bifrost owns the operational meaning of the crossing:
|
||||||
|
|
||||||
It does not mean Bifrost should own OAuth sludge. Bifrost is both an identity provider and identity consumer: a GameCult actor should be able to register with Bifrost without OAuth, and that Bifrost identity should be enough to authenticate with most GameCult services. Heimdall owns outside-provider OAuth, linked-account token custody, grants, and signed account claims. Bifrost associates those Heimdall-backed accounts with the Bifrost identity, consumes the permission facts, and decides whether a bridge action is allowed in the Bifrost domain.
|
It does not mean Bifrost should own OAuth sludge. Bifrost is both an identity provider and identity consumer: a GameCult actor should be able to register with Bifrost without OAuth, and that Bifrost identity should be enough to authenticate with most GameCult services. Heimdall owns outside-provider OAuth, linked-account token custody, grants, and signed account claims. Bifrost associates those Heimdall-backed accounts with the Bifrost identity, consumes the permission facts, and decides whether a bridge action is allowed in the Bifrost domain.
|
||||||
|
|
||||||
|
The native registration surface is `POST /auth/bifrost/register`. It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody.
|
||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
|
|
||||||
- Heimdall owns provider OAuth, outside-account credentials, token custody, and capability/account claims.
|
- Heimdall owns provider OAuth, outside-account credentials, token custody, and capability/account claims.
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ The product center is the Labor Platform loop: eligible participants express pol
|
||||||
|
|
||||||
After bootstrap, the next session should start implementing the first vertical slice:
|
After bootstrap, the next session should start implementing the first vertical slice:
|
||||||
|
|
||||||
- GitHub OAuth sign-in
|
- Bifrost-native identity registration plus GitHub OAuth linked sign-in
|
||||||
- membership gating
|
- membership gating
|
||||||
- core project/member data model
|
- core project/member data model
|
||||||
- work items from GitHub and internal sources
|
- work items from GitHub and internal sources
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ It is currently a strong first application slice, not an internet-facing alpha.
|
||||||
|
|
||||||
- ASP.NET Core 8 Razor Pages app
|
- ASP.NET Core 8 Razor Pages app
|
||||||
- PostgreSQL-backed EF Core model plus initial migration
|
- PostgreSQL-backed EF Core model plus initial migration
|
||||||
- GitHub OAuth wiring and startup config validation
|
- Bifrost-native identity registration, GitHub OAuth wiring, and startup config validation
|
||||||
- invite and approval gate with explicit member roles
|
- invite and approval gate with explicit member roles
|
||||||
- member profile editing plus patron or contributor tier snapshots
|
- member profile editing plus patron or contributor tier snapshots
|
||||||
- project, work item, motion, member, and ledger UI
|
- project, work item, motion, member, and ledger UI
|
||||||
|
|
@ -39,7 +39,7 @@ It is currently a strong first application slice, not an internet-facing alpha.
|
||||||
|
|
||||||
The labor platform concept doc establishes the social and economic model. Bifrost should implement that model with a few v1 clarifications:
|
The labor platform concept doc establishes the social and economic model. Bifrost should implement that model with a few v1 clarifications:
|
||||||
|
|
||||||
- keep GitHub sign-in only for Bifrost v1 even though the concept doc allows GitHub or GameCult accounts
|
- keep Bifrost-native identity as the account root for v1, with GitHub and Heimdall-linked providers as attached external identities rather than account owners
|
||||||
- keep the app invite-only during alpha
|
- keep the app invite-only during alpha
|
||||||
- keep payout execution out of scope; only compute, review, and audit payout proposals
|
- keep payout execution out of scope; only compute, review, and audit payout proposals
|
||||||
- treat contribution points, patron points, decay, and revenue share as first-class product systems, not spreadsheet afterthoughts
|
- treat contribution points, patron points, decay, and revenue share as first-class product systems, not spreadsheet afterthoughts
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,12 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<UserAccount>(entity =>
|
modelBuilder.Entity<UserAccount>(entity =>
|
||||||
{
|
{
|
||||||
|
entity.HasIndex(x => x.NormalizedBifrostIdentity).IsUnique().HasFilter("\"NormalizedBifrostIdentity\" <> ''");
|
||||||
entity.HasIndex(x => x.GitHubUserId).IsUnique().HasFilter("\"GitHubUserId\" IS NOT NULL");
|
entity.HasIndex(x => x.GitHubUserId).IsUnique().HasFilter("\"GitHubUserId\" IS NOT NULL");
|
||||||
entity.HasIndex(x => x.HeimdallAccountId).IsUnique().HasFilter("\"HeimdallAccountId\" <> ''");
|
entity.HasIndex(x => x.HeimdallAccountId).IsUnique().HasFilter("\"HeimdallAccountId\" <> ''");
|
||||||
entity.HasIndex(x => x.NormalizedGitHubLogin).IsUnique().HasFilter("\"NormalizedGitHubLogin\" <> ''");
|
entity.HasIndex(x => x.NormalizedGitHubLogin).IsUnique().HasFilter("\"NormalizedGitHubLogin\" <> ''");
|
||||||
|
entity.Property(x => x.BifrostIdentity).HasMaxLength(120);
|
||||||
|
entity.Property(x => x.NormalizedBifrostIdentity).HasMaxLength(120);
|
||||||
entity.Property(x => x.HeimdallAccountId).HasMaxLength(160);
|
entity.Property(x => x.HeimdallAccountId).HasMaxLength(160);
|
||||||
entity.Property(x => x.GitHubLogin).HasMaxLength(120);
|
entity.Property(x => x.GitHubLogin).HasMaxLength(120);
|
||||||
entity.Property(x => x.NormalizedGitHubLogin).HasMaxLength(120);
|
entity.Property(x => x.NormalizedGitHubLogin).HasMaxLength(120);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[Migration("20260622165000_AddBifrostNativeIdentity")]
|
||||||
|
public partial class AddBifrostNativeIdentity : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "BifrostIdentity",
|
||||||
|
table: "UserAccounts",
|
||||||
|
type: "character varying(120)",
|
||||||
|
maxLength: 120,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "NormalizedBifrostIdentity",
|
||||||
|
table: "UserAccounts",
|
||||||
|
type: "character varying(120)",
|
||||||
|
maxLength: 120,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserAccounts_NormalizedBifrostIdentity",
|
||||||
|
table: "UserAccounts",
|
||||||
|
column: "NormalizedBifrostIdentity",
|
||||||
|
unique: true,
|
||||||
|
filter: "\"NormalizedBifrostIdentity\" <> ''");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_UserAccounts_NormalizedBifrostIdentity",
|
||||||
|
table: "UserAccounts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BifrostIdentity",
|
||||||
|
table: "UserAccounts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "NormalizedBifrostIdentity",
|
||||||
|
table: "UserAccounts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1227,6 +1227,11 @@ namespace Bifrost.Web.Data.Migrations
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("BifrostIdentity")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
|
@ -1251,6 +1256,11 @@ namespace Bifrost.Web.Data.Migrations
|
||||||
b.Property<DateTimeOffset>("LastSeenAtUtc")
|
b.Property<DateTimeOffset>("LastSeenAtUtc")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedBifrostIdentity")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
b.Property<string>("NormalizedGitHubLogin")
|
b.Property<string>("NormalizedGitHubLogin")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(120)
|
.HasMaxLength(120)
|
||||||
|
|
@ -1266,6 +1276,10 @@ namespace Bifrost.Web.Data.Migrations
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasFilter("\"HeimdallAccountId\" <> ''");
|
.HasFilter("\"HeimdallAccountId\" <> ''");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedBifrostIdentity")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"NormalizedBifrostIdentity\" <> ''");
|
||||||
|
|
||||||
b.HasIndex("NormalizedGitHubLogin")
|
b.HasIndex("NormalizedGitHubLogin")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasFilter("\"NormalizedGitHubLogin\" <> ''");
|
.HasFilter("\"NormalizedGitHubLogin\" <> ''");
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,10 @@ public sealed class UserAccount
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public string BifrostIdentity { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string NormalizedBifrostIdentity { get; set; } = string.Empty;
|
||||||
|
|
||||||
public long? GitHubUserId { get; set; }
|
public long? GitHubUserId { get; set; }
|
||||||
|
|
||||||
public string HeimdallAccountId { get; set; } = string.Empty;
|
public string HeimdallAccountId { get; set; } = string.Empty;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public sealed class HeimdallAuthService(
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
HeimdallAuthAttemptStore attemptStore,
|
HeimdallAuthAttemptStore attemptStore,
|
||||||
BifrostDbContext dbContext,
|
BifrostDbContext dbContext,
|
||||||
|
BifrostIdentityService bifrostIdentityService,
|
||||||
IOptions<HeimdallOptions> heimdallOptions,
|
IOptions<HeimdallOptions> heimdallOptions,
|
||||||
TimeProvider timeProvider)
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
|
|
@ -116,25 +117,8 @@ public sealed class HeimdallAuthService(
|
||||||
}
|
}
|
||||||
|
|
||||||
var userAccount = await UpsertUserAccountAsync(payload, attempt.RequiresMemberAccess, cancellationToken);
|
var userAccount = await UpsertUserAccountAsync(payload, attempt.RequiresMemberAccess, cancellationToken);
|
||||||
var claims = new List<Claim>
|
|
||||||
{
|
|
||||||
new(ClaimTypes.NameIdentifier, userAccount.GitHubUserId?.ToString() ?? userAccount.HeimdallAccountId),
|
|
||||||
new(ClaimTypes.Name, userAccount.DisplayName),
|
|
||||||
new(BifrostClaimTypes.HeimdallAccountId, userAccount.HeimdallAccountId),
|
|
||||||
new(BifrostClaimTypes.AuthProvider, payload.Provider ?? "heimdall"),
|
|
||||||
new(BifrostClaimTypes.DisplayName, userAccount.DisplayName),
|
|
||||||
new(BifrostClaimTypes.AvatarUrl, userAccount.AvatarUrl)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(userAccount.GitHubLogin))
|
|
||||||
{
|
|
||||||
claims.Add(new Claim(BifrostClaimTypes.GitHubLogin, userAccount.GitHubLogin));
|
|
||||||
}
|
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
||||||
var principal = new ClaimsPrincipal(identity);
|
|
||||||
return new HeimdallCompletedSignIn(
|
return new HeimdallCompletedSignIn(
|
||||||
principal,
|
bifrostIdentityService.BuildPrincipal(userAccount, payload.Provider ?? "heimdall"),
|
||||||
string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl);
|
string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,6 +190,10 @@ public sealed class HeimdallAuthService(
|
||||||
|
|
||||||
userAccount.MemberProfile!.Nickname = displayName;
|
userAccount.MemberProfile!.Nickname = displayName;
|
||||||
userAccount.MemberProfile.UpdatedAtUtc = now;
|
userAccount.MemberProfile.UpdatedAtUtc = now;
|
||||||
|
await bifrostIdentityService.EnsureIdentityAsync(
|
||||||
|
userAccount,
|
||||||
|
displayName,
|
||||||
|
cancellationToken);
|
||||||
if (grantMemberAccess)
|
if (grantMemberAccess)
|
||||||
{
|
{
|
||||||
ApplicationBootstrapper.EnsureRole(
|
ApplicationBootstrapper.EnsureRole(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ public static class GitHubAuthenticationDefaults
|
||||||
|
|
||||||
public static class BifrostClaimTypes
|
public static class BifrostClaimTypes
|
||||||
{
|
{
|
||||||
|
public const string BifrostIdentity = "urn:bifrost:identity";
|
||||||
public const string GitHubLogin = "urn:bifrost:github-login";
|
public const string GitHubLogin = "urn:bifrost:github-login";
|
||||||
public const string HeimdallAccountId = "urn:bifrost:heimdall-account-id";
|
public const string HeimdallAccountId = "urn:bifrost:heimdall-account-id";
|
||||||
public const string AuthProvider = "urn:bifrost:auth-provider";
|
public const string AuthProvider = "urn:bifrost:auth-provider";
|
||||||
|
|
|
||||||
153
src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs
Normal file
153
src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Bifrost.Web.Data;
|
||||||
|
using Bifrost.Web.Domain;
|
||||||
|
using Bifrost.Web.Features.Shared;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Features.Membership;
|
||||||
|
|
||||||
|
public sealed partial class BifrostIdentityService(
|
||||||
|
BifrostDbContext dbContext,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
public async Task<UserAccount> RegisterNativeAsync(
|
||||||
|
string identity,
|
||||||
|
string? displayName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedIdentity = NormalizeIdentity(identity);
|
||||||
|
var exists = await dbContext.UserAccounts.AnyAsync(
|
||||||
|
x => x.NormalizedBifrostIdentity == normalizedIdentity,
|
||||||
|
cancellationToken);
|
||||||
|
if (exists)
|
||||||
|
{
|
||||||
|
throw new BifrostIdentityException("That Bifrost identity is already registered.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = timeProvider.GetUtcNow();
|
||||||
|
var publicIdentity = ToPublicIdentity(normalizedIdentity);
|
||||||
|
var resolvedDisplayName = string.IsNullOrWhiteSpace(displayName)
|
||||||
|
? publicIdentity
|
||||||
|
: displayName.Trim();
|
||||||
|
var userAccount = new UserAccount
|
||||||
|
{
|
||||||
|
BifrostIdentity = publicIdentity,
|
||||||
|
NormalizedBifrostIdentity = normalizedIdentity,
|
||||||
|
DisplayName = resolvedDisplayName,
|
||||||
|
CreatedAtUtc = now,
|
||||||
|
LastSeenAtUtc = now,
|
||||||
|
MemberProfile = new MemberProfile
|
||||||
|
{
|
||||||
|
Nickname = resolvedDisplayName,
|
||||||
|
Headline = "Bifrost-native identity",
|
||||||
|
UpdatedAtUtc = now
|
||||||
|
},
|
||||||
|
Membership = new Bifrost.Web.Domain.Membership
|
||||||
|
{
|
||||||
|
Status = MembershipStatus.Authenticated,
|
||||||
|
CreatedAtUtc = now,
|
||||||
|
Notes = "Registered with native Bifrost identity"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.UserAccounts.Add(userAccount);
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return userAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnsureIdentityAsync(
|
||||||
|
UserAccount userAccount,
|
||||||
|
string preferredIdentity,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(userAccount.NormalizedBifrostIdentity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedBase = NormalizeCandidate(preferredIdentity);
|
||||||
|
var normalizedIdentity = normalizedBase;
|
||||||
|
for (var suffix = 2; await IdentityExistsAsync(userAccount.Id, normalizedIdentity, cancellationToken); suffix++)
|
||||||
|
{
|
||||||
|
normalizedIdentity = $"{normalizedBase}-{suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
userAccount.BifrostIdentity = ToPublicIdentity(normalizedIdentity);
|
||||||
|
userAccount.NormalizedBifrostIdentity = normalizedIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClaimsPrincipal BuildPrincipal(UserAccount userAccount, string authProvider = "bifrost")
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, userAccount.Id.ToString()),
|
||||||
|
new(ClaimTypes.Name, userAccount.DisplayName),
|
||||||
|
new(BifrostClaimTypes.BifrostIdentity, userAccount.BifrostIdentity),
|
||||||
|
new(BifrostClaimTypes.AuthProvider, authProvider),
|
||||||
|
new(BifrostClaimTypes.DisplayName, userAccount.DisplayName),
|
||||||
|
new(BifrostClaimTypes.AvatarUrl, userAccount.AvatarUrl)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(userAccount.HeimdallAccountId))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim(BifrostClaimTypes.HeimdallAccountId, userAccount.HeimdallAccountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(userAccount.GitHubLogin))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim(BifrostClaimTypes.GitHubLogin, userAccount.GitHubLogin));
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeIdentity(string identity)
|
||||||
|
{
|
||||||
|
var normalized = identity.Trim().ToLowerInvariant();
|
||||||
|
if (!IdentityPattern().IsMatch(normalized))
|
||||||
|
{
|
||||||
|
throw new BifrostIdentityException(
|
||||||
|
"Bifrost identity must be 3-80 characters and may contain letters, numbers, dots, underscores, or hyphens.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> IdentityExistsAsync(
|
||||||
|
Guid currentUserAccountId,
|
||||||
|
string normalizedIdentity,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await dbContext.UserAccounts.AnyAsync(
|
||||||
|
x => x.Id != currentUserAccountId && x.NormalizedBifrostIdentity == normalizedIdentity,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private static string NormalizeCandidate(string preferredIdentity)
|
||||||
|
{
|
||||||
|
var candidate = CandidateInvalidCharacters().Replace(
|
||||||
|
preferredIdentity.Trim().ToLowerInvariant(),
|
||||||
|
"-");
|
||||||
|
candidate = CandidateSeparators().Replace(candidate, "-").Trim('.', '_', '-');
|
||||||
|
if (candidate.Length > 80)
|
||||||
|
{
|
||||||
|
candidate = candidate[..80].Trim('.', '_', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.Length >= 3 ? candidate : "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToPublicIdentity(string normalizedIdentity) => normalizedIdentity;
|
||||||
|
|
||||||
|
[GeneratedRegex("^[a-z0-9][a-z0-9._-]{1,78}[a-z0-9]$")]
|
||||||
|
private static partial Regex IdentityPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex("[^a-z0-9._-]+")]
|
||||||
|
private static partial Regex CandidateInvalidCharacters();
|
||||||
|
|
||||||
|
[GeneratedRegex("[._-]{2,}")]
|
||||||
|
private static partial Regex CandidateSeparators();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BifrostIdentityException(string message) : InvalidOperationException(message);
|
||||||
|
|
@ -10,6 +10,7 @@ namespace Bifrost.Web.Features.Membership;
|
||||||
|
|
||||||
public sealed class MembershipSynchronizationService(
|
public sealed class MembershipSynchronizationService(
|
||||||
BifrostDbContext dbContext,
|
BifrostDbContext dbContext,
|
||||||
|
BifrostIdentityService bifrostIdentityService,
|
||||||
IOptions<BootstrapOptions> bootstrapOptions,
|
IOptions<BootstrapOptions> bootstrapOptions,
|
||||||
TimeProvider timeProvider)
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
|
|
@ -97,6 +98,11 @@ public sealed class MembershipSynchronizationService(
|
||||||
userAccount.MemberProfile.Nickname = userAccount.DisplayName;
|
userAccount.MemberProfile.Nickname = userAccount.DisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await bifrostIdentityService.EnsureIdentityAsync(
|
||||||
|
userAccount,
|
||||||
|
userAccount.GitHubLogin,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
if (invitation is not null && invitation.AcceptedByUserAccountId is null)
|
if (invitation is not null && invitation.AcceptedByUserAccountId is null)
|
||||||
{
|
{
|
||||||
invitation.AcceptedByUserAccountId = userAccount.Id;
|
invitation.AcceptedByUserAccountId = userAccount.Id;
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,17 @@ public sealed class CurrentBifrostActorAccessor(
|
||||||
return Cache(httpContext, CurrentBifrostActor.Anonymous);
|
return Cache(httpContext, CurrentBifrostActor.Anonymous);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var bifrostIdentity = httpContext.User.FindFirstValue(BifrostClaimTypes.BifrostIdentity);
|
||||||
|
var normalizedBifrostIdentity = string.IsNullOrWhiteSpace(bifrostIdentity)
|
||||||
|
? string.Empty
|
||||||
|
: BifrostIdentityService.NormalizeIdentity(bifrostIdentity);
|
||||||
var heimdallAccountId = httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId);
|
var heimdallAccountId = httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId);
|
||||||
var gitHubId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var gitHubId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var hasGitHubUserId = long.TryParse(gitHubId, out var gitHubUserId);
|
var hasGitHubUserId = long.TryParse(gitHubId, out var gitHubUserId);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(heimdallAccountId) && !hasGitHubUserId)
|
if (string.IsNullOrWhiteSpace(normalizedBifrostIdentity) &&
|
||||||
|
string.IsNullOrWhiteSpace(heimdallAccountId) &&
|
||||||
|
!hasGitHubUserId)
|
||||||
{
|
{
|
||||||
return Cache(httpContext, CurrentBifrostActor.Anonymous);
|
return Cache(httpContext, CurrentBifrostActor.Anonymous);
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +53,8 @@ public sealed class CurrentBifrostActorAccessor(
|
||||||
.Include(x => x.Membership!)
|
.Include(x => x.Membership!)
|
||||||
.ThenInclude(x => x.TierSnapshots)
|
.ThenInclude(x => x.TierSnapshots)
|
||||||
.SingleOrDefaultAsync(
|
.SingleOrDefaultAsync(
|
||||||
x => (!string.IsNullOrWhiteSpace(heimdallAccountId) && x.HeimdallAccountId == heimdallAccountId) ||
|
x => (!string.IsNullOrWhiteSpace(normalizedBifrostIdentity) && x.NormalizedBifrostIdentity == normalizedBifrostIdentity) ||
|
||||||
|
(!string.IsNullOrWhiteSpace(heimdallAccountId) && x.HeimdallAccountId == heimdallAccountId) ||
|
||||||
(hasGitHubUserId && x.GitHubUserId == gitHubUserId),
|
(hasGitHubUserId && x.GitHubUserId == gitHubUserId),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ builder.Services.AddScoped<AgentDispatchRunService>();
|
||||||
builder.Services.AddScoped<AgentTransportReceiptService>();
|
builder.Services.AddScoped<AgentTransportReceiptService>();
|
||||||
builder.Services.AddScoped<GovernanceActivityReceiptService>();
|
builder.Services.AddScoped<GovernanceActivityReceiptService>();
|
||||||
builder.Services.AddScoped<MembershipSynchronizationService>();
|
builder.Services.AddScoped<MembershipSynchronizationService>();
|
||||||
|
builder.Services.AddScoped<BifrostIdentityService>();
|
||||||
builder.Services.AddScoped<GitHubWebhookService>();
|
builder.Services.AddScoped<GitHubWebhookService>();
|
||||||
builder.Services.AddScoped<MotionGovernanceService>();
|
builder.Services.AddScoped<MotionGovernanceService>();
|
||||||
builder.Services.AddScoped<MotionEveSurfaceService>();
|
builder.Services.AddScoped<MotionEveSurfaceService>();
|
||||||
|
|
@ -282,6 +283,38 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuth
|
||||||
new() { RedirectUri = returnUrl });
|
new() { RedirectUri = returnUrl });
|
||||||
}).AllowAnonymous();
|
}).AllowAnonymous();
|
||||||
|
|
||||||
|
app.MapPost("/auth/bifrost/register", async (
|
||||||
|
NativeBifrostRegistrationRequest request,
|
||||||
|
HttpContext httpContext,
|
||||||
|
BifrostIdentityService bifrostIdentityService,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userAccount = await bifrostIdentityService.RegisterNativeAsync(
|
||||||
|
request.Identity,
|
||||||
|
request.DisplayName,
|
||||||
|
cancellationToken);
|
||||||
|
await httpContext.SignInAsync(
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
bifrostIdentityService.BuildPrincipal(userAccount));
|
||||||
|
|
||||||
|
var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl;
|
||||||
|
return JsonResult(
|
||||||
|
new NativeBifrostRegistrationResponse(
|
||||||
|
userAccount.Id,
|
||||||
|
userAccount.BifrostIdentity,
|
||||||
|
userAccount.DisplayName,
|
||||||
|
userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(),
|
||||||
|
returnUrl),
|
||||||
|
appJsonSerializerOptions);
|
||||||
|
}
|
||||||
|
catch (BifrostIdentityException error)
|
||||||
|
{
|
||||||
|
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
}).AllowAnonymous();
|
||||||
|
|
||||||
app.MapGet("/auth/heimdall/{provider}", async (
|
app.MapGet("/auth/heimdall/{provider}", async (
|
||||||
string provider,
|
string provider,
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
|
|
@ -894,3 +927,15 @@ static bool HasValidLocalBridgeToken(HttpRequest request, BridgeOptions bridgeOp
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|
||||||
|
public sealed record NativeBifrostRegistrationRequest(
|
||||||
|
string Identity,
|
||||||
|
string? DisplayName,
|
||||||
|
string? ReturnUrl);
|
||||||
|
|
||||||
|
public sealed record NativeBifrostRegistrationResponse(
|
||||||
|
Guid UserAccountId,
|
||||||
|
string BifrostIdentity,
|
||||||
|
string DisplayName,
|
||||||
|
string MembershipStatus,
|
||||||
|
string ReturnUrl);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ using Bifrost.Web.Data;
|
||||||
using Bifrost.Web.Domain;
|
using Bifrost.Web.Domain;
|
||||||
using Bifrost.Web.Tests.Support;
|
using Bifrost.Web.Tests.Support;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Bifrost.Web.Tests;
|
namespace Bifrost.Web.Tests;
|
||||||
|
|
@ -39,6 +40,62 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Bifrost_native_registration_creates_authenticated_identity_without_oauth()
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
|
||||||
|
request.Headers.Add("X-Test-Anonymous", "true");
|
||||||
|
request.Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
identity = $"native-{Guid.NewGuid():N}",
|
||||||
|
displayName = "Native Bifrost User",
|
||||||
|
returnUrl = "/App"
|
||||||
|
});
|
||||||
|
|
||||||
|
using var response = await client.SendAsync(request);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var identity = payload.GetProperty("bifrostIdentity").GetString();
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(identity));
|
||||||
|
Assert.Equal("Authenticated", payload.GetProperty("membershipStatus").GetString());
|
||||||
|
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||||
|
var userAccount = dbContext.UserAccounts
|
||||||
|
.Include(x => x.Membership)
|
||||||
|
.Single(x => x.BifrostIdentity == identity);
|
||||||
|
|
||||||
|
Assert.Null(userAccount.GitHubUserId);
|
||||||
|
Assert.Equal(string.Empty, userAccount.HeimdallAccountId);
|
||||||
|
Assert.Equal(MembershipStatus.Authenticated, userAccount.Membership!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Bifrost_native_registration_rejects_duplicate_identity()
|
||||||
|
{
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
var identity = $"native-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
using var firstRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
|
||||||
|
firstRequest.Headers.Add("X-Test-Anonymous", "true");
|
||||||
|
firstRequest.Content = JsonContent.Create(new { identity, displayName = "First" });
|
||||||
|
using var firstResponse = await client.SendAsync(firstRequest);
|
||||||
|
|
||||||
|
using var secondRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
|
||||||
|
secondRequest.Headers.Add("X-Test-Anonymous", "true");
|
||||||
|
secondRequest.Content = JsonContent.Create(new { identity = identity.ToUpperInvariant(), displayName = "Second" });
|
||||||
|
using var secondResponse = await client.SendAsync(secondRequest);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, secondResponse.StatusCode);
|
||||||
|
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||||
|
Assert.Single(dbContext.UserAccounts.Where(x => x.NormalizedBifrostIdentity == identity));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GitHub_issue_webhook_creates_work_item()
|
public async Task GitHub_issue_webhook_creates_work_item()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue