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 =>
|
||||
{
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<long>("GitHubUserId")
|
||||
b.Property<long?>("GitHubUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("HeimdallAccountId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(160)
|
||||
.HasColumnType("character varying(160)");
|
||||
|
||||
b.Property<DateTimeOffset>("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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
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 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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@
|
|||
<code>GitHubOAuth:ClientSecret</code> before using sign-in.
|
||||
</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">
|
||||
@if (Model.Actor.IsActiveMember)
|
||||
|
|
@ -33,6 +39,8 @@
|
|||
else
|
||||
{
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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<BifrostHostOptions>()
|
||||
.BindConfiguration(BifrostHostOptions.SectionName);
|
||||
|
||||
builder.Services
|
||||
.AddOptions<HeimdallOptions>()
|
||||
.BindConfiguration(HeimdallOptions.SectionName);
|
||||
|
||||
builder.Services.AddRazorPages(options =>
|
||||
{
|
||||
options.Conventions.AuthorizePage("/Membership/Status");
|
||||
|
|
@ -67,6 +72,12 @@ builder.Services.AddScoped<ReadinessService>();
|
|||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||
builder.Services.AddSingleton<StartupConfigurationValidator>();
|
||||
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"];
|
||||
if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath))
|
||||
|
|
@ -227,6 +238,78 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuth
|
|||
new() { RedirectUri = redirectUri });
|
||||
}).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) =>
|
||||
{
|
||||
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue