From 53b8f9a9abbfb10a18b5c386fd23f913c5e52d50 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 8 Jun 2026 21:04:51 +0100 Subject: [PATCH] Add Heimdall Discord Patreon access --- .../Configuration/HeimdallOptions.cs | 29 ++ src/Bifrost.Web/Data/BifrostDbContext.cs | 6 +- .../20260608210000_AddHeimdallUserIdentity.cs | 101 ++++++ .../BifrostDbContextModelSnapshot.cs | 17 +- src/Bifrost.Web/Domain/BifrostModels.cs | 4 +- .../Features/Heimdall/HeimdallAuthService.cs | 334 ++++++++++++++++++ .../Membership/ActiveMemberRequirement.cs | 2 + .../Shared/CurrentBifrostActorAccessor.cs | 11 +- src/Bifrost.Web/Pages/Index.cshtml | 8 + src/Bifrost.Web/Program.cs | 83 +++++ src/Bifrost.Web/appsettings.json | 7 + 11 files changed, 594 insertions(+), 8 deletions(-) create mode 100644 src/Bifrost.Web/Configuration/HeimdallOptions.cs create mode 100644 src/Bifrost.Web/Data/Migrations/20260608210000_AddHeimdallUserIdentity.cs create mode 100644 src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs diff --git a/src/Bifrost.Web/Configuration/HeimdallOptions.cs b/src/Bifrost.Web/Configuration/HeimdallOptions.cs new file mode 100644 index 0000000..5c8b5b8 --- /dev/null +++ b/src/Bifrost.Web/Configuration/HeimdallOptions.cs @@ -0,0 +1,29 @@ +namespace Bifrost.Web.Configuration; + +public sealed class HeimdallOptions +{ + public const string SectionName = "Heimdall"; + + public string BaseUrl { get; init; } = "https://heimdall.gamecult.org"; + + public string AppSlug { get; init; } = "bifrost"; + + public string DiscordGuildId { get; init; } = string.Empty; + + public string[] DiscordAllowedRoleIds { get; init; } = []; + + public string PatreonTierTitle { get; init; } = "Inner Sanctum"; + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(BaseUrl) && + !string.IsNullOrWhiteSpace(AppSlug); + + public bool IsDiscordConfigured => + IsConfigured && + !string.IsNullOrWhiteSpace(DiscordGuildId) && + DiscordAllowedRoleIds.Length > 0; + + public bool IsPatreonConfigured => + IsConfigured && + !string.IsNullOrWhiteSpace(PatreonTierTitle); +} diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 63f590b..670adba 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -59,8 +59,10 @@ public sealed class BifrostDbContext(DbContextOptions options) { modelBuilder.Entity(entity => { - entity.HasIndex(x => x.GitHubUserId).IsUnique(); - entity.HasIndex(x => x.NormalizedGitHubLogin).IsUnique(); + 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.HeimdallAccountId).HasMaxLength(160); entity.Property(x => x.GitHubLogin).HasMaxLength(120); entity.Property(x => x.NormalizedGitHubLogin).HasMaxLength(120); entity.Property(x => x.DisplayName).HasMaxLength(200); diff --git a/src/Bifrost.Web/Data/Migrations/20260608210000_AddHeimdallUserIdentity.cs b/src/Bifrost.Web/Data/Migrations/20260608210000_AddHeimdallUserIdentity.cs new file mode 100644 index 0000000..4ca4bda --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260608210000_AddHeimdallUserIdentity.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class AddHeimdallUserIdentity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UserAccounts_GitHubUserId", + table: "UserAccounts"); + + migrationBuilder.DropIndex( + name: "IX_UserAccounts_NormalizedGitHubLogin", + table: "UserAccounts"); + + migrationBuilder.AlterColumn( + name: "GitHubUserId", + table: "UserAccounts", + type: "bigint", + nullable: true, + oldClrType: typeof(long), + oldType: "bigint"); + + migrationBuilder.AddColumn( + name: "HeimdallAccountId", + table: "UserAccounts", + type: "character varying(160)", + maxLength: 160, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_UserAccounts_GitHubUserId", + table: "UserAccounts", + column: "GitHubUserId", + unique: true, + filter: "\"GitHubUserId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_UserAccounts_HeimdallAccountId", + table: "UserAccounts", + column: "HeimdallAccountId", + unique: true, + filter: "\"HeimdallAccountId\" <> ''"); + + migrationBuilder.CreateIndex( + name: "IX_UserAccounts_NormalizedGitHubLogin", + table: "UserAccounts", + column: "NormalizedGitHubLogin", + unique: true, + filter: "\"NormalizedGitHubLogin\" <> ''"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_UserAccounts_GitHubUserId", + table: "UserAccounts"); + + migrationBuilder.DropIndex( + name: "IX_UserAccounts_HeimdallAccountId", + table: "UserAccounts"); + + migrationBuilder.DropIndex( + name: "IX_UserAccounts_NormalizedGitHubLogin", + table: "UserAccounts"); + + migrationBuilder.DropColumn( + name: "HeimdallAccountId", + table: "UserAccounts"); + + migrationBuilder.AlterColumn( + name: "GitHubUserId", + table: "UserAccounts", + type: "bigint", + nullable: false, + defaultValue: 0L, + oldClrType: typeof(long), + oldType: "bigint", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_UserAccounts_GitHubUserId", + table: "UserAccounts", + column: "GitHubUserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserAccounts_NormalizedGitHubLogin", + table: "UserAccounts", + column: "NormalizedGitHubLogin", + unique: true); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index ea2e155..7fff467 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -825,9 +825,14 @@ namespace Bifrost.Web.Data.Migrations .HasMaxLength(120) .HasColumnType("character varying(120)"); - b.Property("GitHubUserId") + b.Property("GitHubUserId") .HasColumnType("bigint"); + b.Property("HeimdallAccountId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + b.Property("LastSeenAtUtc") .HasColumnType("timestamp with time zone"); @@ -839,10 +844,16 @@ namespace Bifrost.Web.Data.Migrations b.HasKey("Id"); b.HasIndex("GitHubUserId") - .IsUnique(); + .IsUnique() + .HasFilter("\"GitHubUserId\" IS NOT NULL"); + + b.HasIndex("HeimdallAccountId") + .IsUnique() + .HasFilter("\"HeimdallAccountId\" <> ''"); b.HasIndex("NormalizedGitHubLogin") - .IsUnique(); + .IsUnique() + .HasFilter("\"NormalizedGitHubLogin\" <> ''"); b.ToTable("UserAccounts"); }); diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index e523ef0..5c07334 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -164,7 +164,9 @@ public sealed class UserAccount { public Guid Id { get; set; } = Guid.NewGuid(); - public long GitHubUserId { get; set; } + public long? GitHubUserId { get; set; } + + public string HeimdallAccountId { get; set; } = string.Empty; public string GitHubLogin { get; set; } = string.Empty; diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs new file mode 100644 index 0000000..80d06f3 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -0,0 +1,334 @@ +using System.Net.Http.Json; +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Text.Json.Serialization; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Shared; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Heimdall; + +public sealed class HeimdallAuthService( + HttpClient httpClient, + HeimdallAuthAttemptStore attemptStore, + BifrostDbContext dbContext, + IOptions heimdallOptions, + TimeProvider timeProvider) +{ + private static readonly HashSet AllowedProviders = new(StringComparer.OrdinalIgnoreCase) + { + "discord", + "patreon" + }; + + public async Task StartAsync( + string provider, + string callbackBaseUrl, + string returnUrl, + CancellationToken cancellationToken) + { + if (!AllowedProviders.Contains(provider)) + { + throw new HeimdallAuthException($"Unsupported Heimdall provider '{provider}'."); + } + + var options = heimdallOptions.Value; + if (!options.IsConfigured) + { + throw new HeimdallAuthException("Heimdall is not configured."); + } + + var entitlementPolicy = provider.Equals("discord", StringComparison.OrdinalIgnoreCase) + ? BuildDiscordPolicy(options) + : BuildPatreonPolicy(options); + var attemptId = Guid.NewGuid().ToString("N"); + var callbackUrl = new Uri(new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute), "auth/heimdall/callback"); + + var request = new HeimdallStartRequest( + options.AppSlug, + "sign_in", + ToAttemptReturnUrl(callbackBaseUrl, attemptId, returnUrl), + new HeimdallHandoff("backend_callback", attemptId, callbackUrl.ToString()), + entitlementPolicy); + + var response = await httpClient.PostAsJsonAsync( + $"/v1/oauth/{provider}/start", + request, + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var detail = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HeimdallAuthException($"Heimdall rejected {provider} auth start: {detail}"); + } + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + if (payload is null || string.IsNullOrWhiteSpace(payload.AuthorizationUrl)) + { + throw new HeimdallAuthException("Heimdall auth start returned no authorization URL."); + } + + attemptStore.Create(attemptId, returnUrl); + return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId); + } + + public void StoreBackendCallback(HeimdallBackendCallbackPayload payload) + { + if (!string.Equals(payload.AppSlug, heimdallOptions.Value.AppSlug, StringComparison.OrdinalIgnoreCase)) + { + throw new HeimdallAuthException("Heimdall callback targeted a different app."); + } + + if (string.IsNullOrWhiteSpace(payload.AttemptId)) + { + throw new HeimdallAuthException("Heimdall callback omitted attempt id."); + } + + attemptStore.Complete(payload.AttemptId, payload); + } + + public async Task CompleteAttemptAsync( + string attemptId, + CancellationToken cancellationToken) + { + var attempt = attemptStore.TryTake(attemptId) + ?? throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet."); + var payload = attempt.Payload + ?? throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet."); + + if (!payload.IsSuccessful) + { + throw new HeimdallAuthException(payload.ErrorDescription ?? payload.Error ?? "Heimdall auth did not succeed."); + } + + if (!payload.HasMemberAccess) + { + throw new HeimdallAuthException("Heimdall did not grant Bifrost member access."); + } + + var userAccount = await UpsertUserAccountAsync(payload, 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, + string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl); + } + + private async Task UpsertUserAccountAsync( + HeimdallBackendCallbackPayload payload, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var accountId = payload.Account?.Id ?? throw new HeimdallAuthException("Heimdall response omitted account id."); + var displayName = payload.Account.DisplayName ?? accountId; + + var userAccount = await dbContext.UserAccounts + .Include(x => x.MemberProfile) + .Include(x => x.Membership!) + .ThenInclude(x => x.RoleAssignments) + .Include(x => x.Membership!) + .ThenInclude(x => x.TierSnapshots) + .SingleOrDefaultAsync(x => x.HeimdallAccountId == accountId, cancellationToken); + + if (userAccount is null) + { + userAccount = new UserAccount + { + HeimdallAccountId = accountId, + DisplayName = displayName, + CreatedAtUtc = now, + LastSeenAtUtc = now, + MemberProfile = new MemberProfile + { + Nickname = displayName, + Headline = $"Verified through Heimdall {payload.Provider}", + UpdatedAtUtc = now + }, + Membership = new Bifrost.Web.Domain.Membership + { + Status = MembershipStatus.Active, + CreatedAtUtc = now, + ApprovedAtUtc = now, + Notes = "Activated by Heimdall member access claim" + } + }; + dbContext.UserAccounts.Add(userAccount); + } + else + { + userAccount.DisplayName = displayName; + userAccount.LastSeenAtUtc = now; + userAccount.MemberProfile ??= new MemberProfile + { + UserAccountId = userAccount.Id, + Nickname = displayName, + Headline = $"Verified through Heimdall {payload.Provider}", + UpdatedAtUtc = now + }; + userAccount.Membership ??= new Bifrost.Web.Domain.Membership + { + UserAccountId = userAccount.Id, + CreatedAtUtc = now + }; + userAccount.Membership.Status = MembershipStatus.Active; + userAccount.Membership.ApprovedAtUtc ??= now; + } + + userAccount.MemberProfile!.Nickname = displayName; + userAccount.MemberProfile.UpdatedAtUtc = now; + ApplicationBootstrapper.EnsureRole( + userAccount.Membership!, + MemberRole.StandardMember, + null, + now, + "Heimdall member access"); + + await dbContext.SaveChangesAsync(cancellationToken); + return userAccount; + } + + private static HeimdallEntitlementPolicy BuildDiscordPolicy(HeimdallOptions options) + { + if (!options.IsDiscordConfigured) + { + throw new HeimdallAuthException("Heimdall Discord access is not configured."); + } + + return new HeimdallEntitlementPolicy( + "discord_role_access", + options.DiscordGuildId, + options.DiscordAllowedRoleIds, + null); + } + + private static HeimdallEntitlementPolicy BuildPatreonPolicy(HeimdallOptions options) + { + if (!options.IsPatreonConfigured) + { + throw new HeimdallAuthException("Heimdall Patreon access is not configured."); + } + + return new HeimdallEntitlementPolicy( + "patreon_membership_access", + null, + null, + options.PatreonTierTitle); + } + + private static string ToAttemptReturnUrl(string callbackBaseUrl, string attemptId, string returnUrl) + { + var baseUri = new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute); + var target = string.IsNullOrWhiteSpace(returnUrl) ? "/App" : returnUrl; + var waitUri = new Uri(baseUri, "auth/heimdall/wait"); + return $"{waitUri}?attemptId={Uri.EscapeDataString(attemptId)}&returnTo={Uri.EscapeDataString(target)}"; + } +} + +public class HeimdallAuthException(string message) : InvalidOperationException(message); + +public sealed class HeimdallAuthPendingException(string message) : HeimdallAuthException(message); + +public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId); + +public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, string RedirectUri); + +public sealed class HeimdallAuthAttemptStore +{ + private readonly ConcurrentDictionary _attempts = new(StringComparer.Ordinal); + + public void Create(string attemptId, string returnUrl) + { + _attempts[attemptId] = new HeimdallAuthAttempt(returnUrl, null); + } + + public void Complete(string attemptId, HeimdallBackendCallbackPayload payload) + { + _attempts.AddOrUpdate( + attemptId, + _ => new HeimdallAuthAttempt(payload.ReturnTo ?? "/App", payload), + (_, existing) => existing with { Payload = payload }); + } + + public HeimdallAuthAttempt? TryTake(string attemptId) + { + return _attempts.TryRemove(attemptId, out var attempt) ? attempt : null; + } +} + +public sealed record HeimdallAuthAttempt(string ReturnUrl, HeimdallBackendCallbackPayload? Payload); + +public sealed record HeimdallStartRequest( + [property: JsonPropertyName("appSlug")] string AppSlug, + [property: JsonPropertyName("mode")] string Mode, + [property: JsonPropertyName("returnTo")] string ReturnTo, + [property: JsonPropertyName("handoff")] HeimdallHandoff Handoff, + [property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy EntitlementPolicy); + +public sealed record HeimdallHandoff( + [property: JsonPropertyName("kind")] string Kind, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [property: JsonPropertyName("attemptId")] string? AttemptId = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null); + +public sealed record HeimdallEntitlementPolicy( + [property: JsonPropertyName("kind")] string Kind, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [property: JsonPropertyName("guildId")] string? GuildId, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [property: JsonPropertyName("allowedRoleIds")] string[]? AllowedRoleIds, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [property: JsonPropertyName("requiredTierTitle")] string? RequiredTierTitle); + +public sealed record HeimdallStartResponse( + [property: JsonPropertyName("authorizationUrl")] string AuthorizationUrl); + +public sealed record HeimdallBackendCallbackPayload( + [property: JsonPropertyName("attemptId")] string AttemptId, + [property: JsonPropertyName("appSlug")] string? AppSlug, + [property: JsonPropertyName("status")] string? Status, + [property: JsonPropertyName("provider")] string? Provider, + [property: JsonPropertyName("returnTo")] string? ReturnTo, + [property: JsonPropertyName("error")] string? Error, + [property: JsonPropertyName("errorDescription")] string? ErrorDescription, + [property: JsonPropertyName("account")] HeimdallAccount? Account, + [property: JsonPropertyName("sharedCapabilities")] string[]? SharedCapabilities, + [property: JsonPropertyName("entitlements")] HeimdallEntitlements? Entitlements) +{ + public bool IsSuccessful => string.Equals(Status, "success", StringComparison.OrdinalIgnoreCase); + + public bool HasMemberAccess => + SharedCapabilities?.Any(capability => + string.Equals(capability, "member_access", StringComparison.OrdinalIgnoreCase) || + string.Equals(capability, "app_access", StringComparison.OrdinalIgnoreCase)) == true || + Entitlements?.Facts?.Any(fact => + string.Equals(fact, "entitlement.app_access", StringComparison.OrdinalIgnoreCase)) == true; +} + +public sealed record HeimdallAccount( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("displayName")] string? DisplayName); + +public sealed record HeimdallEntitlements( + [property: JsonPropertyName("facts")] string[]? Facts); diff --git a/src/Bifrost.Web/Features/Membership/ActiveMemberRequirement.cs b/src/Bifrost.Web/Features/Membership/ActiveMemberRequirement.cs index 1027731..d175bcc 100644 --- a/src/Bifrost.Web/Features/Membership/ActiveMemberRequirement.cs +++ b/src/Bifrost.Web/Features/Membership/ActiveMemberRequirement.cs @@ -15,6 +15,8 @@ public static class GitHubAuthenticationDefaults public static class BifrostClaimTypes { 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"; public const string DisplayName = "urn:bifrost:display-name"; public const string AvatarUrl = "urn:bifrost:avatar-url"; } diff --git a/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs b/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs index 49700f7..37a4905 100644 --- a/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs +++ b/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Bifrost.Web.Data; +using Bifrost.Web.Features.Membership; using Microsoft.EntityFrameworkCore; namespace Bifrost.Web.Features.Shared; @@ -29,8 +30,11 @@ public sealed class CurrentBifrostActorAccessor( return Cache(httpContext, CurrentBifrostActor.Anonymous); } + var heimdallAccountId = httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId); var gitHubId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); - if (!long.TryParse(gitHubId, out var gitHubUserId)) + var hasGitHubUserId = long.TryParse(gitHubId, out var gitHubUserId); + + if (string.IsNullOrWhiteSpace(heimdallAccountId) && !hasGitHubUserId) { return Cache(httpContext, CurrentBifrostActor.Anonymous); } @@ -42,7 +46,10 @@ public sealed class CurrentBifrostActorAccessor( .ThenInclude(x => x.RoleAssignments) .Include(x => x.Membership!) .ThenInclude(x => x.TierSnapshots) - .SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken); + .SingleOrDefaultAsync( + x => (!string.IsNullOrWhiteSpace(heimdallAccountId) && x.HeimdallAccountId == heimdallAccountId) || + (hasGitHubUserId && x.GitHubUserId == gitHubUserId), + cancellationToken); return Cache(httpContext, new CurrentBifrostActor(true, userAccount)); } diff --git a/src/Bifrost.Web/Pages/Index.cshtml b/src/Bifrost.Web/Pages/Index.cshtml index 28db5cd..26e2538 100644 --- a/src/Bifrost.Web/Pages/Index.cshtml +++ b/src/Bifrost.Web/Pages/Index.cshtml @@ -20,6 +20,12 @@ GitHubOAuth:ClientSecret before using sign-in. } + else if (Model.Auth == "heimdall-error") + { +
+ Heimdall sign-in did not complete. Check the provider configuration and membership policy. +
+ }
@if (Model.Actor.IsActiveMember) @@ -33,6 +39,8 @@ else { Sign in with GitHub + Sign in with Discord + Sign in with Patreon } View repository diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index b3021c2..2901011 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Features.GitHub; +using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authentication; @@ -33,6 +34,10 @@ builder.Services .AddOptions() .BindConfiguration(BifrostHostOptions.SectionName); +builder.Services + .AddOptions() + .BindConfiguration(HeimdallOptions.SectionName); + builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); @@ -67,6 +72,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient((serviceProvider, client) => +{ + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); +}); var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"]; if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath)) @@ -227,6 +238,78 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions hostOptions, + CancellationToken cancellationToken) => +{ + try + { + var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); + if (string.IsNullOrWhiteSpace(returnUrl)) + { + returnUrl = "/App"; + } + + var start = await heimdallAuthService.StartAsync( + provider, + hostOptions.Value.PublicBaseUrl, + returnUrl, + cancellationToken); + + return Results.Redirect(start.AuthorizationUrl.ToString()); + } + catch (HeimdallAuthException error) + { + return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); + } +}).AllowAnonymous(); + +app.MapPost("/auth/heimdall/callback", ( + HeimdallBackendCallbackPayload payload, + HeimdallAuthService heimdallAuthService) => +{ + try + { + heimdallAuthService.StoreBackendCallback(payload); + return Results.Accepted(); + } + catch (HeimdallAuthException error) + { + return Results.BadRequest(error.Message); + } +}).AllowAnonymous(); + +app.MapGet("/auth/heimdall/wait", async ( + HttpContext httpContext, + HeimdallAuthService heimdallAuthService, + CancellationToken cancellationToken) => +{ + var attemptId = httpContext.Request.Query["attemptId"].ToString(); + if (string.IsNullOrWhiteSpace(attemptId)) + { + return Results.Redirect("/?auth=heimdall-error&detail=missing-attempt-id"); + } + + try + { + var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken); + await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); + return Results.Redirect(signIn.RedirectUri); + } + catch (HeimdallAuthPendingException) + { + httpContext.Response.Headers.Append("Refresh", "2"); + return Results.Text("Waiting for Heimdall sign-in to complete.", "text/plain"); + } + catch (HeimdallAuthException error) + { + return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); + } +}).AllowAnonymous(); + app.MapPost("/auth/sign-out", async (HttpContext httpContext) => { await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json index 2380f07..e8891aa 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -24,6 +24,13 @@ "ExpectedHost": "bifrost.gamecult.org", "RequireStrictHostValidation": true }, + "Heimdall": { + "BaseUrl": "https://heimdall.gamecult.org", + "AppSlug": "bifrost", + "DiscordGuildId": "", + "DiscordAllowedRoleIds": [], + "PatreonTierTitle": "Inner Sanctum" + }, "Logging": { "LogLevel": { "Default": "Information",