Add Heimdall Discord Patreon access
This commit is contained in:
parent
755649ff29
commit
53b8f9a9ab
11 changed files with 594 additions and 8 deletions
29
src/Bifrost.Web/Configuration/HeimdallOptions.cs
Normal file
29
src/Bifrost.Web/Configuration/HeimdallOptions.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -59,8 +59,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<UserAccount>(entity =>
|
modelBuilder.Entity<UserAccount>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasIndex(x => x.GitHubUserId).IsUnique();
|
entity.HasIndex(x => x.GitHubUserId).IsUnique().HasFilter("\"GitHubUserId\" IS NOT NULL");
|
||||||
entity.HasIndex(x => x.NormalizedGitHubLogin).IsUnique();
|
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.GitHubLogin).HasMaxLength(120);
|
||||||
entity.Property(x => x.NormalizedGitHubLogin).HasMaxLength(120);
|
entity.Property(x => x.NormalizedGitHubLogin).HasMaxLength(120);
|
||||||
entity.Property(x => x.DisplayName).HasMaxLength(200);
|
entity.Property(x => x.DisplayName).HasMaxLength(200);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddHeimdallUserIdentity : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_UserAccounts_GitHubUserId",
|
||||||
|
table: "UserAccounts");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_UserAccounts_NormalizedGitHubLogin",
|
||||||
|
table: "UserAccounts");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<long>(
|
||||||
|
name: "GitHubUserId",
|
||||||
|
table: "UserAccounts",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(long),
|
||||||
|
oldType: "bigint");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
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\" <> ''");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<long>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -825,9 +825,14 @@ namespace Bifrost.Web.Data.Migrations
|
||||||
.HasMaxLength(120)
|
.HasMaxLength(120)
|
||||||
.HasColumnType("character varying(120)");
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
b.Property<long>("GitHubUserId")
|
b.Property<long?>("GitHubUserId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("HeimdallAccountId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("LastSeenAtUtc")
|
b.Property<DateTimeOffset>("LastSeenAtUtc")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
|
@ -839,10 +844,16 @@ namespace Bifrost.Web.Data.Migrations
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("GitHubUserId")
|
b.HasIndex("GitHubUserId")
|
||||||
.IsUnique();
|
.IsUnique()
|
||||||
|
.HasFilter("\"GitHubUserId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("HeimdallAccountId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"HeimdallAccountId\" <> ''");
|
||||||
|
|
||||||
b.HasIndex("NormalizedGitHubLogin")
|
b.HasIndex("NormalizedGitHubLogin")
|
||||||
.IsUnique();
|
.IsUnique()
|
||||||
|
.HasFilter("\"NormalizedGitHubLogin\" <> ''");
|
||||||
|
|
||||||
b.ToTable("UserAccounts");
|
b.ToTable("UserAccounts");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,9 @@ public sealed class UserAccount
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
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;
|
public string GitHubLogin { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
|
|
||||||
334
src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs
Normal file
334
src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs
Normal file
|
|
@ -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> heimdallOptions,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> AllowedProviders = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"discord",
|
||||||
|
"patreon"
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<HeimdallAuthStartResult> 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<HeimdallStartResponse>(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<HeimdallCompletedSignIn> 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<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(
|
||||||
|
principal,
|
||||||
|
string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<UserAccount> 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<string, HeimdallAuthAttempt> _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);
|
||||||
|
|
@ -15,6 +15,8 @@ public static class GitHubAuthenticationDefaults
|
||||||
public static class BifrostClaimTypes
|
public static class BifrostClaimTypes
|
||||||
{
|
{
|
||||||
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 AuthProvider = "urn:bifrost:auth-provider";
|
||||||
public const string DisplayName = "urn:bifrost:display-name";
|
public const string DisplayName = "urn:bifrost:display-name";
|
||||||
public const string AvatarUrl = "urn:bifrost:avatar-url";
|
public const string AvatarUrl = "urn:bifrost:avatar-url";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Bifrost.Web.Data;
|
using Bifrost.Web.Data;
|
||||||
|
using Bifrost.Web.Features.Membership;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Bifrost.Web.Features.Shared;
|
namespace Bifrost.Web.Features.Shared;
|
||||||
|
|
@ -29,8 +30,11 @@ public sealed class CurrentBifrostActorAccessor(
|
||||||
return Cache(httpContext, CurrentBifrostActor.Anonymous);
|
return Cache(httpContext, CurrentBifrostActor.Anonymous);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var heimdallAccountId = httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId);
|
||||||
var gitHubId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
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);
|
return Cache(httpContext, CurrentBifrostActor.Anonymous);
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +46,10 @@ public sealed class CurrentBifrostActorAccessor(
|
||||||
.ThenInclude(x => x.RoleAssignments)
|
.ThenInclude(x => x.RoleAssignments)
|
||||||
.Include(x => x.Membership!)
|
.Include(x => x.Membership!)
|
||||||
.ThenInclude(x => x.TierSnapshots)
|
.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));
|
return Cache(httpContext, new CurrentBifrostActor(true, userAccount));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@
|
||||||
<code>GitHubOAuth:ClientSecret</code> before using sign-in.
|
<code>GitHubOAuth:ClientSecret</code> before using sign-in.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
else if (Model.Auth == "heimdall-error")
|
||||||
|
{
|
||||||
|
<div class="callout warning">
|
||||||
|
Heimdall sign-in did not complete. Check the provider configuration and membership policy.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
@if (Model.Actor.IsActiveMember)
|
@if (Model.Actor.IsActiveMember)
|
||||||
|
|
@ -33,6 +39,8 @@
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<a class="button" href="/auth/sign-in">Sign in with GitHub</a>
|
<a class="button" href="/auth/sign-in">Sign in with GitHub</a>
|
||||||
|
<a class="button secondary" href="/auth/heimdall/discord">Sign in with Discord</a>
|
||||||
|
<a class="button secondary" href="/auth/heimdall/patreon">Sign in with Patreon</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<a class="button secondary" href="https://github.com/GameCult/Bifrost">View repository</a>
|
<a class="button secondary" href="https://github.com/GameCult/Bifrost">View repository</a>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using System.Text.Json;
|
||||||
using Bifrost.Web.Configuration;
|
using Bifrost.Web.Configuration;
|
||||||
using Bifrost.Web.Data;
|
using Bifrost.Web.Data;
|
||||||
using Bifrost.Web.Features.GitHub;
|
using Bifrost.Web.Features.GitHub;
|
||||||
|
using Bifrost.Web.Features.Heimdall;
|
||||||
using Bifrost.Web.Features.Membership;
|
using Bifrost.Web.Features.Membership;
|
||||||
using Bifrost.Web.Features.Shared;
|
using Bifrost.Web.Features.Shared;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
|
@ -33,6 +34,10 @@ builder.Services
|
||||||
.AddOptions<BifrostHostOptions>()
|
.AddOptions<BifrostHostOptions>()
|
||||||
.BindConfiguration(BifrostHostOptions.SectionName);
|
.BindConfiguration(BifrostHostOptions.SectionName);
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddOptions<HeimdallOptions>()
|
||||||
|
.BindConfiguration(HeimdallOptions.SectionName);
|
||||||
|
|
||||||
builder.Services.AddRazorPages(options =>
|
builder.Services.AddRazorPages(options =>
|
||||||
{
|
{
|
||||||
options.Conventions.AuthorizePage("/Membership/Status");
|
options.Conventions.AuthorizePage("/Membership/Status");
|
||||||
|
|
@ -67,6 +72,12 @@ builder.Services.AddScoped<ReadinessService>();
|
||||||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||||
builder.Services.AddSingleton<StartupConfigurationValidator>();
|
builder.Services.AddSingleton<StartupConfigurationValidator>();
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
|
||||||
|
builder.Services.AddSingleton<HeimdallAuthAttemptStore>();
|
||||||
|
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
|
||||||
|
{
|
||||||
|
var options = serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value;
|
||||||
|
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
|
||||||
|
});
|
||||||
|
|
||||||
var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"];
|
var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"];
|
||||||
if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath))
|
if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath))
|
||||||
|
|
@ -227,6 +238,78 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuth
|
||||||
new() { RedirectUri = redirectUri });
|
new() { RedirectUri = redirectUri });
|
||||||
}).AllowAnonymous();
|
}).AllowAnonymous();
|
||||||
|
|
||||||
|
app.MapGet("/auth/heimdall/{provider}", async (
|
||||||
|
string provider,
|
||||||
|
HttpContext httpContext,
|
||||||
|
HeimdallAuthService heimdallAuthService,
|
||||||
|
IOptions<BifrostHostOptions> 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) =>
|
app.MapPost("/auth/sign-out", async (HttpContext httpContext) =>
|
||||||
{
|
{
|
||||||
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,13 @@
|
||||||
"ExpectedHost": "bifrost.gamecult.org",
|
"ExpectedHost": "bifrost.gamecult.org",
|
||||||
"RequireStrictHostValidation": true
|
"RequireStrictHostValidation": true
|
||||||
},
|
},
|
||||||
|
"Heimdall": {
|
||||||
|
"BaseUrl": "https://heimdall.gamecult.org",
|
||||||
|
"AppSlug": "bifrost",
|
||||||
|
"DiscordGuildId": "",
|
||||||
|
"DiscordAllowedRoleIds": [],
|
||||||
|
"PatreonTierTitle": "Inner Sanctum"
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue