Add native Bifrost identity registration

This commit is contained in:
Meta Vens 2026-06-22 15:07:22 +01:00
commit 23c02be2a6
17 changed files with 366 additions and 29 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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\" <> ''");

View file

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

View file

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

View file

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

View 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);

View file

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

View file

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

View file

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

View file

@ -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()
{ {