Bifrost/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs

479 lines
19 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public sealed class HeimdallAuthService(
HttpClient httpClient,
HeimdallTokenVerifier tokenVerifier,
BifrostDbContext dbContext,
BifrostIdentityService bifrostIdentityService,
IOptions<HeimdallOptions> heimdallOptions,
IDataProtectionProvider dataProtectionProvider,
TimeProvider timeProvider)
{
public const string ErrorRedirect = "/?auth=heimdall-error";
public const string AppSecretHeader = "x-heimdall-app-secret";
private static readonly HashSet<string> AllowedProviders = new(StringComparer.OrdinalIgnoreCase)
{
"discord",
"patreon",
"twitch"
};
private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Bifrost.Heimdall.Attempt.v1");
/// <summary>Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app.</summary>
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<HeimdallAuthStartResult> StartAsync(
string provider,
string callbackBaseUrl,
LocalReturnPath returnPath,
bool requireMemberAccess,
IEnumerable<KeyValuePair<string, string>> 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<HeimdallStartResponse>(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));
}
/// <summary>
/// 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 <see cref="HeimdallAttemptCookie.SlotNames"/> attempt
/// cookies however many starts a browser makes.
/// </summary>
private string ChooseSlot(IEnumerable<KeyValuePair<string, string>> 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!;
}
/// <summary>
/// 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).
/// </summary>
public (string Name, string Value)? FindAttemptCookie(IEnumerable<KeyValuePair<string, string>> 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<HeimdallCompletedSignIn> 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<HeimdallRedeemResponse> 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<HeimdallRedeemResponse>(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)));
/// <summary>The live attempt a cookie value protects when it belongs to this nonce, else null.</summary>
private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) =>
Read(cookieValue) is { } attempt && string.Equals(attempt.Nonce, nonce, StringComparison.Ordinal) ? attempt : null;
/// <summary>The live attempt a cookie value protects, else null.</summary>
private HeimdallAttempt? Read(string? cookieValue)
{
if (string.IsNullOrEmpty(cookieValue))
{
return null;
}
try
{
var wire = JsonSerializer.Deserialize<AttemptWire>(_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<UserAccount> 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);
/// <summary>An attempt Start issued. It exists only inside its protected cookie.</summary>
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);
/// <summary>
/// The attempt cookies: a ring of <see cref="SlotNames"/> 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.
/// </summary>
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<string> 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
};
/// <summary>
/// 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.
/// </summary>
public static CookieOptions DeleteOptions() => new() { Path = BifrostCookies.Path, Secure = true };
}