Add Heimdall Discord Patreon access

This commit is contained in:
Meta Vens 2026-06-08 21:04:51 +01:00
commit 53b8f9a9ab
11 changed files with 594 additions and 8 deletions

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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