using System.Net; using System.Net.Http.Json; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; 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.DataProtection; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Bifrost.Web.Features.Heimdall; /// /// Heimdall sign-in. Start issues an attempt that lives only in a Data Protection-protected cookie in the /// browser that began it. The browser that Heimdall authenticated presents the completion code from its /// URL fragment; Bifrost redeems it at Heimdall itself and signs in only when that browser holds the /// attempt's cookie and the redeemed return target names the same attempt. /// public sealed class HeimdallAuthService( HttpClient httpClient, HeimdallTokenVerifier tokenVerifier, BifrostDbContext dbContext, BifrostIdentityService bifrostIdentityService, IOptions heimdallOptions, IDataProtectionProvider dataProtectionProvider, TimeProvider timeProvider) { public const string ErrorRedirect = "/?auth=heimdall-error"; public const string AppSecretHeader = "x-heimdall-app-secret"; private static readonly HashSet AllowedProviders = new(StringComparer.OrdinalIgnoreCase) { "discord", "patreon", "twitch" }; private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Bifrost.Heimdall.Attempt.v1"); /// Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app. public static void ConfigureClient(HttpClient client, HeimdallOptions options) { client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); if (!string.IsNullOrWhiteSpace(options.AppSecret)) { client.DefaultRequestHeaders.Add(AppSecretHeader, options.AppSecret); } } public async Task StartAsync( string provider, string callbackBaseUrl, LocalReturnPath returnPath, bool requireMemberAccess, IEnumerable> requestCookies, CancellationToken cancellationToken) { if (!AllowedProviders.Contains(provider)) { throw new HeimdallAuthException("Unsupported Heimdall provider."); } var options = heimdallOptions.Value; if (!options.IsConfigured) { throw new HeimdallAuthException("Heimdall is not configured."); } var entitlementPolicy = requireMemberAccess ? BuildMemberAccessPolicy(provider, options) : null; var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); var request = new HeimdallStartRequest( options.AppSlug, "sign_in", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), entitlementPolicy); var response = await httpClient.PostAsJsonAsync( $"/v1/oauth/{provider}/start", request, cancellationToken); if (!response.IsSuccessStatusCode) { throw new HeimdallAuthException("Heimdall rejected the auth start."); } 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."); } var attempt = new HeimdallAttempt( nonce, provider, requireMemberAccess, returnPath, timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime); return new HeimdallAuthStartResult( new Uri(payload.AuthorizationUrl, UriKind.Absolute), ChooseSlot(requestCookies), Protect(attempt)); } /// /// The slot the new attempt's cookie takes: the first slot that holds no live attempt, else the one whose /// attempt expires first. Parallel starts carry the same request cookies, so they choose the same slot and /// overwrite each other; the Cookie header holds at most attempt /// cookies however many starts a browser makes. /// private string ChooseSlot(IEnumerable> requestCookies) { var cookies = requestCookies.ToLookup(x => x.Key, x => x.Value, StringComparer.Ordinal); string? oldest = null; var oldestExpiry = DateTimeOffset.MaxValue; foreach (var name in HeimdallAttemptCookie.SlotNames) { var live = Read(cookies[name].FirstOrDefault()); if (live is null) { return name; } if (live.ExpiresAt < oldestExpiry) { oldest = name; oldestExpiry = live.ExpiresAt; } } return oldest!; } /// /// The slot cookie that holds the attempt for this nonce: its name and value, or null when the browser holds /// none (never started here, expired, or overwritten by a later start). /// public (string Name, string Value)? FindAttemptCookie(IEnumerable> requestCookies, string? nonce) { foreach (var (name, value) in requestCookies) { if (HeimdallAttemptCookie.SlotNames.Contains(name) && Unprotect(value, nonce) is not null) { return (name, value); } } return null; } public async Task CompleteAsync( string callbackBaseUrl, string? nonce, string? cookieValue, string? code, CancellationToken cancellationToken) { var attempt = Unprotect(cookieValue, nonce) ?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser."); if (string.IsNullOrWhiteSpace(code)) { throw new HeimdallAuthException("Heimdall completion carried no code."); } var redeemed = await RedeemAsync(code, cancellationToken); if (!string.Equals(redeemed.ReturnTo, ToAttemptReturnUrl(callbackBaseUrl, attempt.Nonce), StringComparison.Ordinal)) { throw new HeimdallAuthException("Heimdall completion belongs to another attempt."); } if (string.IsNullOrWhiteSpace(redeemed.AccessToken)) { throw new HeimdallAuthException("Heimdall completion carried no access token."); } var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken); if (attempt.RequiresMemberAccess && !identity.HasMemberAccess) { throw new HeimdallAuthException("Heimdall did not grant Bifrost member access."); } var userAccount = await UpsertUserAccountAsync( identity, attempt.Provider, attempt.RequiresMemberAccess, cancellationToken); return new HeimdallCompletedSignIn( bifrostIdentityService.BuildPrincipal(userAccount, attempt.Provider), attempt.ReturnPath); } private async Task RedeemAsync(string code, CancellationToken cancellationToken) { try { using var response = await httpClient.PostAsJsonAsync( $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/auth-completions/redeem", new HeimdallRedeemRequest(code), cancellationToken); if (response.StatusCode != HttpStatusCode.Created) { throw new HeimdallAuthException("Heimdall did not redeem the completion code."); } return await response.Content.ReadFromJsonAsync(cancellationToken) ?? throw new HeimdallAuthException("Heimdall redeem returned no body."); } catch (Exception error) when (error is not HeimdallAuthException && !cancellationToken.IsCancellationRequested) { throw new HeimdallAuthException("Heimdall redeem could not be read."); } } private string Protect(HeimdallAttempt attempt) => _protector.Protect(JsonSerializer.Serialize(new AttemptWire( attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt))); /// The live attempt a cookie value protects when it belongs to this nonce, else null. private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) => Read(cookieValue) is { } attempt && string.Equals(attempt.Nonce, nonce, StringComparison.Ordinal) ? attempt : null; /// The live attempt a cookie value protects, else null. private HeimdallAttempt? Read(string? cookieValue) { if (string.IsNullOrEmpty(cookieValue)) { return null; } try { var wire = JsonSerializer.Deserialize(_protector.Unprotect(cookieValue)); if (wire is null || wire.ExpiresAt <= timeProvider.GetUtcNow()) { return null; } return new HeimdallAttempt( wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt); } catch (Exception error) when (error is CryptographicException or JsonException or FormatException) { return null; } } private sealed record AttemptWire( string Nonce, string Provider, bool RequiresMemberAccess, string ReturnPath, DateTimeOffset ExpiresAt); private async Task UpsertUserAccountAsync( HeimdallVerifiedIdentity identity, string provider, bool grantMemberAccess, CancellationToken cancellationToken) { var now = timeProvider.GetUtcNow(); var accountId = identity.AccountId; var displayName = identity.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 {provider}", UpdatedAtUtc = now }, Membership = new Bifrost.Web.Domain.Membership { Status = grantMemberAccess ? MembershipStatus.Active : MembershipStatus.Authenticated, CreatedAtUtc = now, ApprovedAtUtc = grantMemberAccess ? now : null, Notes = grantMemberAccess ? "Activated by Heimdall member access claim" : $"Identity verified through Heimdall {provider}" } }; dbContext.UserAccounts.Add(userAccount); } else { userAccount.DisplayName = displayName; userAccount.LastSeenAtUtc = now; userAccount.MemberProfile ??= new MemberProfile { UserAccountId = userAccount.Id, Nickname = displayName, Headline = $"Verified through Heimdall {provider}", UpdatedAtUtc = now }; userAccount.Membership ??= new Bifrost.Web.Domain.Membership { UserAccountId = userAccount.Id, CreatedAtUtc = now }; // A suspension is an admin's decision: the claim neither lifts it nor, absent, imposes it. if (grantMemberAccess && userAccount.Membership.Status != MembershipStatus.Suspended) { userAccount.Membership.Status = MembershipStatus.Active; userAccount.Membership.ApprovedAtUtc ??= now; } } userAccount.MemberProfile!.Nickname = displayName; userAccount.MemberProfile.UpdatedAtUtc = now; await bifrostIdentityService.EnsureIdentityAsync( userAccount, displayName, cancellationToken); if (grantMemberAccess && userAccount.Membership!.Status == MembershipStatus.Active) { 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 HeimdallEntitlementPolicy BuildMemberAccessPolicy(string provider, HeimdallOptions options) { if (provider.Equals("discord", StringComparison.OrdinalIgnoreCase)) { return BuildDiscordPolicy(options); } if (provider.Equals("patreon", StringComparison.OrdinalIgnoreCase)) { return BuildPatreonPolicy(options); } throw new HeimdallAuthException("Heimdall provider does not support Bifrost member access."); } private static string ToAttemptReturnUrl(string callbackBaseUrl, string nonce) { var baseUri = new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute); return $"{new Uri(baseUri, "auth/heimdall/complete")}?attempt={Uri.EscapeDataString(nonce)}"; } } public class HeimdallAuthException(string message) : InvalidOperationException(message); public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string CookieName, string CookieValue); public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, LocalReturnPath ReturnPath); /// An attempt Start issued. It exists only inside its protected cookie. public sealed record HeimdallAttempt( string Nonce, string Provider, bool RequiresMemberAccess, LocalReturnPath ReturnPath, DateTimeOffset ExpiresAt); 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: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy); public sealed record HeimdallHandoff( [property: JsonPropertyName("kind")] string Kind); 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 HeimdallRedeemRequest( [property: JsonPropertyName("completionCode")] string CompletionCode); public sealed record HeimdallRedeemResponse( [property: JsonPropertyName("returnTo")] string? ReturnTo, [property: JsonPropertyName("accessToken")] string? AccessToken, [property: JsonPropertyName("account")] HeimdallAccount? Account); public sealed record HeimdallAccount( [property: JsonPropertyName("id")] string Id); /// /// The attempt cookies: a ring of fixed names, so the Cookie header a browser sends to /// every path is bounded by construction (the __Host- prefix forces Path=/; a worst-case attempt is about 2.4 KB, nginx's line limit is 8 KB). /// A start takes a free slot or overwrites the one expiring first; an overwritten attempt finds no cookie and /// is refused. The attempt's nonce lives inside the protected value, never in the cookie name. /// public static class HeimdallAttemptCookie { public const string Prefix = BifrostCookies.HeimdallAttemptPrefix; public const int SlotCount = 2; public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); public static readonly IReadOnlyList SlotNames = Enumerable.Range(0, SlotCount).Select(x => Prefix + x).ToArray(); public static CookieOptions Options() => new() { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax, Path = BifrostCookies.Path, MaxAge = Lifetime }; /// /// The options a deletion carries: the attempt cookie's Path, which a cookie is matched on, and Secure, without /// which a browser ignores a Set-Cookie for a __Host- name. It carries no Max-Age, which would take precedence /// over the expiry Delete writes and leave the emptied cookie alive. /// public static CookieOptions DeleteOptions() => new() { Path = BifrostCookies.Path, Secure = true }; }