From 23c02be2a60a6efec5524ce72cccfbe997e4d6c8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 22 Jun 2026 15:07:22 +0100 Subject: [PATCH] Add native Bifrost identity registration --- AGENTS.md | 2 +- README.md | 9 +- docs/bifrost-mvp-plan.md | 4 +- docs/bridge.md | 2 + docs/context.md | 2 +- docs/full-implementation-strategy.md | 4 +- src/Bifrost.Web/Data/BifrostDbContext.cs | 3 + ...20260622165000_AddBifrostNativeIdentity.cs | 54 +++++++ .../BifrostDbContextModelSnapshot.cs | 14 ++ src/Bifrost.Web/Domain/BifrostModels.cs | 4 + .../Features/Heimdall/HeimdallAuthService.cs | 24 +-- .../Membership/ActiveMemberRequirement.cs | 1 + .../Membership/BifrostIdentityService.cs | 153 ++++++++++++++++++ .../MembershipSynchronizationService.cs | 6 + .../Shared/CurrentBifrostActorAccessor.cs | 11 +- src/Bifrost.Web/Program.cs | 45 ++++++ .../PlatformEndpointsTests.cs | 57 +++++++ 17 files changed, 366 insertions(+), 29 deletions(-) create mode 100644 src/Bifrost.Web/Data/Migrations/20260622165000_AddBifrostNativeIdentity.cs create mode 100644 src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs diff --git a/AGENTS.md b/AGENTS.md index 0f4a218..c06e133 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ - product name: `Bifrost` - 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 - task sources: GitHub issues plus internal tasks - governance model: app-native motions and voting diff --git a/README.md b/README.md index f6ad58a..301409e 100644 --- a/README.md +++ b/README.md @@ -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` - 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 - 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 @@ -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 - Razor Pages + HTMX - 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 ## Alpha Scope - 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 - app-native motions and voting - 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 - 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 `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 diff --git a/docs/bifrost-mvp-plan.md b/docs/bifrost-mvp-plan.md index 0daf214..fa1bd10 100644 --- a/docs/bifrost-mvp-plan.md +++ b/docs/bifrost-mvp-plan.md @@ -15,7 +15,7 @@ - One ASP.NET web app process for v1. - Server-rendered UI with HTMX for progressive interactions. - 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. - 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. @@ -42,7 +42,7 @@ ## Product Rules - 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. - Work items may come from GitHub issues or internal platform-native tasks. - Voting is app-native and off-chain. diff --git a/docs/bridge.md b/docs/bridge.md index 0e19f3d..09a5f51 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -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. +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 - Heimdall owns provider OAuth, outside-account credentials, token custody, and capability/account claims. diff --git a/docs/context.md b/docs/context.md index 521e31a..fdcef97 100644 --- a/docs/context.md +++ b/docs/context.md @@ -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: -- GitHub OAuth sign-in +- Bifrost-native identity registration plus GitHub OAuth linked sign-in - membership gating - core project/member data model - work items from GitHub and internal sources diff --git a/docs/full-implementation-strategy.md b/docs/full-implementation-strategy.md index 0f9cf42..94daec7 100644 --- a/docs/full-implementation-strategy.md +++ b/docs/full-implementation-strategy.md @@ -14,7 +14,7 @@ It is currently a strong first application slice, not an internet-facing alpha. - ASP.NET Core 8 Razor Pages app - 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 - member profile editing plus patron or contributor tier snapshots - 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: -- 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 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 diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 7a72a16..0d9d841 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -67,9 +67,12 @@ public sealed class BifrostDbContext(DbContextOptions options) { modelBuilder.Entity(entity => { + entity.HasIndex(x => x.NormalizedBifrostIdentity).IsUnique().HasFilter("\"NormalizedBifrostIdentity\" <> ''"); entity.HasIndex(x => x.GitHubUserId).IsUnique().HasFilter("\"GitHubUserId\" IS NOT NULL"); entity.HasIndex(x => x.HeimdallAccountId).IsUnique().HasFilter("\"HeimdallAccountId\" <> ''"); 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.GitHubLogin).HasMaxLength(120); entity.Property(x => x.NormalizedGitHubLogin).HasMaxLength(120); diff --git a/src/Bifrost.Web/Data/Migrations/20260622165000_AddBifrostNativeIdentity.cs b/src/Bifrost.Web/Data/Migrations/20260622165000_AddBifrostNativeIdentity.cs new file mode 100644 index 0000000..efbb170 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260622165000_AddBifrostNativeIdentity.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + [Migration("20260622165000_AddBifrostNativeIdentity")] + public partial class AddBifrostNativeIdentity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BifrostIdentity", + table: "UserAccounts", + type: "character varying(120)", + maxLength: 120, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + 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\" <> ''"); + } + + /// + 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"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 62953e1..5de58b7 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -1227,6 +1227,11 @@ namespace Bifrost.Web.Data.Migrations .HasMaxLength(500) .HasColumnType("character varying(500)"); + b.Property("BifrostIdentity") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); @@ -1251,6 +1256,11 @@ namespace Bifrost.Web.Data.Migrations b.Property("LastSeenAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("NormalizedBifrostIdentity") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + b.Property("NormalizedGitHubLogin") .IsRequired() .HasMaxLength(120) @@ -1266,6 +1276,10 @@ namespace Bifrost.Web.Data.Migrations .IsUnique() .HasFilter("\"HeimdallAccountId\" <> ''"); + b.HasIndex("NormalizedBifrostIdentity") + .IsUnique() + .HasFilter("\"NormalizedBifrostIdentity\" <> ''"); + b.HasIndex("NormalizedGitHubLogin") .IsUnique() .HasFilter("\"NormalizedGitHubLogin\" <> ''"); diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index e86c737..2fe93aa 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -244,6 +244,10 @@ public sealed class UserAccount { 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 string HeimdallAccountId { get; set; } = string.Empty; diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index bf406c0..88575f2 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -18,6 +18,7 @@ public sealed class HeimdallAuthService( HttpClient httpClient, HeimdallAuthAttemptStore attemptStore, BifrostDbContext dbContext, + BifrostIdentityService bifrostIdentityService, IOptions heimdallOptions, TimeProvider timeProvider) { @@ -116,25 +117,8 @@ public sealed class HeimdallAuthService( } var userAccount = await UpsertUserAccountAsync(payload, attempt.RequiresMemberAccess, cancellationToken); - var claims = new List - { - 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( - principal, + bifrostIdentityService.BuildPrincipal(userAccount, payload.Provider ?? "heimdall"), string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl); } @@ -206,6 +190,10 @@ public sealed class HeimdallAuthService( userAccount.MemberProfile!.Nickname = displayName; userAccount.MemberProfile.UpdatedAtUtc = now; + await bifrostIdentityService.EnsureIdentityAsync( + userAccount, + displayName, + cancellationToken); if (grantMemberAccess) { ApplicationBootstrapper.EnsureRole( diff --git a/src/Bifrost.Web/Features/Membership/ActiveMemberRequirement.cs b/src/Bifrost.Web/Features/Membership/ActiveMemberRequirement.cs index d175bcc..709268c 100644 --- a/src/Bifrost.Web/Features/Membership/ActiveMemberRequirement.cs +++ b/src/Bifrost.Web/Features/Membership/ActiveMemberRequirement.cs @@ -14,6 +14,7 @@ public static class GitHubAuthenticationDefaults public static class BifrostClaimTypes { + public const string BifrostIdentity = "urn:bifrost:identity"; public const string GitHubLogin = "urn:bifrost:github-login"; public const string HeimdallAccountId = "urn:bifrost:heimdall-account-id"; public const string AuthProvider = "urn:bifrost:auth-provider"; diff --git a/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs new file mode 100644 index 0000000..d485b91 --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs @@ -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 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 + { + 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 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); diff --git a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs index 89e95b2..8ee13a0 100644 --- a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs +++ b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs @@ -10,6 +10,7 @@ namespace Bifrost.Web.Features.Membership; public sealed class MembershipSynchronizationService( BifrostDbContext dbContext, + BifrostIdentityService bifrostIdentityService, IOptions bootstrapOptions, TimeProvider timeProvider) { @@ -97,6 +98,11 @@ public sealed class MembershipSynchronizationService( userAccount.MemberProfile.Nickname = userAccount.DisplayName; } + await bifrostIdentityService.EnsureIdentityAsync( + userAccount, + userAccount.GitHubLogin, + cancellationToken); + if (invitation is not null && invitation.AcceptedByUserAccountId is null) { invitation.AcceptedByUserAccountId = userAccount.Id; diff --git a/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs b/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs index 37a4905..5d34fad 100644 --- a/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs +++ b/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs @@ -30,11 +30,17 @@ public sealed class CurrentBifrostActorAccessor( 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 gitHubId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); 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); } @@ -47,7 +53,8 @@ public sealed class CurrentBifrostActorAccessor( .Include(x => x.Membership!) .ThenInclude(x => x.TierSnapshots) .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), cancellationToken); diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index b893539..007e545 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -88,6 +88,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -282,6 +283,38 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions +{ + 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 ( string provider, HttpContext httpContext, @@ -894,3 +927,15 @@ static bool HasValidLocalBridgeToken(HttpRequest request, BridgeOptions bridgeOp } 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); diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index 060fafb..21f9efd 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -8,6 +8,7 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Tests.Support; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace Bifrost.Web.Tests; @@ -39,6 +40,62 @@ public sealed class PlatformEndpointsTests : IClassFixture(); + 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(); + 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(); + Assert.Single(dbContext.UserAccounts.Where(x => x.NormalizedBifrostIdentity == identity)); + } + [Fact] public async Task GitHub_issue_webhook_creates_work_item() {