diff --git a/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj index 2b21de6..09a3ea4 100644 --- a/src/Bifrost.Web/Bifrost.Web.csproj +++ b/src/Bifrost.Web/Bifrost.Web.csproj @@ -11,6 +11,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Bifrost.Web/Configuration/HeimdallOptions.cs b/src/Bifrost.Web/Configuration/HeimdallOptions.cs index adddfdc..01eaec9 100644 --- a/src/Bifrost.Web/Configuration/HeimdallOptions.cs +++ b/src/Bifrost.Web/Configuration/HeimdallOptions.cs @@ -14,6 +14,8 @@ public sealed class HeimdallOptions public string PatreonTierTitle { get; init; } = "Inner Sanctum"; + public string AppSecret { get; init; } = string.Empty; + public string PatronSupportIntakeSecret { get; init; } = string.Empty; public bool EnablePatronSupportIntake { get; init; } = true; @@ -24,11 +26,13 @@ public sealed class HeimdallOptions public bool IsDiscordConfigured => IsConfigured && + !string.IsNullOrWhiteSpace(AppSecret) && !string.IsNullOrWhiteSpace(DiscordGuildId) && DiscordAllowedRoleIds.Length > 0; public bool IsPatreonConfigured => IsConfigured && + !string.IsNullOrWhiteSpace(AppSecret) && !string.IsNullOrWhiteSpace(PatreonTierTitle); public bool IsPatronSupportIntakeConfigured => diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 88575f2..3e0b3a1 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -1,27 +1,39 @@ +using System.Net; using System.Net.Http.Json; -using System.Collections.Concurrent; 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.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; +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, - HeimdallAuthAttemptStore attemptStore, + 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", @@ -29,16 +41,29 @@ public sealed class HeimdallAuthService( "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, - string returnUrl, + LocalReturnPath returnPath, bool requireMemberAccess, + IEnumerable> requestCookies, CancellationToken cancellationToken) { if (!AllowedProviders.Contains(provider)) { - throw new HeimdallAuthException($"Unsupported Heimdall provider '{provider}'."); + throw new HeimdallAuthException("Unsupported Heimdall provider."); } var options = heimdallOptions.Value; @@ -50,14 +75,13 @@ public sealed class HeimdallAuthService( var entitlementPolicy = requireMemberAccess ? BuildMemberAccessPolicy(provider, options) : null; - var attemptId = Guid.NewGuid().ToString("N"); - var callbackUrl = new Uri(new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute), "auth/heimdall/callback"); + var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); var request = new HeimdallStartRequest( options.AppSlug, "sign_in", - ToAttemptReturnUrl(callbackBaseUrl, attemptId, returnUrl), - new HeimdallHandoff("backend_callback", attemptId, callbackUrl.ToString()), + ToAttemptReturnUrl(callbackBaseUrl, nonce), + new HeimdallHandoff("browser_completion"), entitlementPolicy); var response = await httpClient.PostAsJsonAsync( @@ -67,8 +91,7 @@ public sealed class HeimdallAuthService( if (!response.IsSuccessStatusCode) { - var detail = await response.Content.ReadAsStringAsync(cancellationToken); - throw new HeimdallAuthException($"Heimdall rejected {provider} auth start: {detail}"); + throw new HeimdallAuthException("Heimdall rejected the auth start."); } var payload = await response.Content.ReadFromJsonAsync(cancellationToken); @@ -77,59 +100,174 @@ public sealed class HeimdallAuthService( throw new HeimdallAuthException("Heimdall auth start returned no authorization URL."); } - attemptStore.Create(attemptId, returnUrl); - attemptStore.SetRequiresMemberAccess(attemptId, requireMemberAccess); - return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId); + 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)); } - public void StoreBackendCallback(HeimdallBackendCallbackPayload payload) + /// + /// 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) { - if (!string.Equals(payload.AppSlug, heimdallOptions.Value.AppSlug, StringComparison.OrdinalIgnoreCase)) + 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) { - throw new HeimdallAuthException("Heimdall callback targeted a different app."); + var live = Read(cookies[name].FirstOrDefault()); + if (live is null) + { + return name; + } + + if (live.ExpiresAt < oldestExpiry) + { + oldest = name; + oldestExpiry = live.ExpiresAt; + } } - if (string.IsNullOrWhiteSpace(payload.AttemptId)) - { - throw new HeimdallAuthException("Heimdall callback omitted attempt id."); - } - - attemptStore.Complete(payload.AttemptId, payload); + return oldest!; } - public async Task CompleteAttemptAsync( - string attemptId, + /// + /// 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 = 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) + var attempt = Unprotect(cookieValue, nonce) + ?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser."); + if (string.IsNullOrWhiteSpace(code)) { - throw new HeimdallAuthException(payload.ErrorDescription ?? payload.Error ?? "Heimdall auth did not succeed."); + throw new HeimdallAuthException("Heimdall completion carried no code."); } - if (attempt.RequiresMemberAccess && !payload.HasMemberAccess) + 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(payload, attempt.RequiresMemberAccess, cancellationToken); + var userAccount = await UpsertUserAccountAsync( + identity, + attempt.Provider, + attempt.RequiresMemberAccess, + cancellationToken); return new HeimdallCompletedSignIn( - bifrostIdentityService.BuildPrincipal(userAccount, payload.Provider ?? "heimdall"), - string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl); + 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( - HeimdallBackendCallbackPayload payload, + HeimdallVerifiedIdentity identity, + string provider, bool grantMemberAccess, 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 accountId = identity.AccountId; + var displayName = identity.DisplayName ?? accountId; var userAccount = await dbContext.UserAccounts .Include(x => x.MemberProfile) @@ -150,7 +288,7 @@ public sealed class HeimdallAuthService( MemberProfile = new MemberProfile { Nickname = displayName, - Headline = $"Verified through Heimdall {payload.Provider}", + Headline = $"Verified through Heimdall {provider}", UpdatedAtUtc = now }, Membership = new Bifrost.Web.Domain.Membership @@ -160,7 +298,7 @@ public sealed class HeimdallAuthService( ApprovedAtUtc = grantMemberAccess ? now : null, Notes = grantMemberAccess ? "Activated by Heimdall member access claim" - : $"Identity verified through Heimdall {payload.Provider}" + : $"Identity verified through Heimdall {provider}" } }; dbContext.UserAccounts.Add(userAccount); @@ -173,7 +311,7 @@ public sealed class HeimdallAuthService( { UserAccountId = userAccount.Id, Nickname = displayName, - Headline = $"Verified through Heimdall {payload.Provider}", + Headline = $"Verified through Heimdall {provider}", UpdatedAtUtc = now }; userAccount.Membership ??= new Bifrost.Web.Domain.Membership @@ -248,61 +386,29 @@ public sealed class HeimdallAuthService( return BuildPatreonPolicy(options); } - throw new HeimdallAuthException($"Heimdall provider '{provider}' does not support Bifrost member access."); + throw new HeimdallAuthException("Heimdall provider does not support Bifrost member access."); } - private static string ToAttemptReturnUrl(string callbackBaseUrl, string attemptId, string returnUrl) + private static string ToAttemptReturnUrl(string callbackBaseUrl, string nonce) { 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)}"; + return $"{new Uri(baseUri, "auth/heimdall/complete")}?attempt={Uri.EscapeDataString(nonce)}"; } } public class HeimdallAuthException(string message) : InvalidOperationException(message); -public sealed class HeimdallAuthPendingException(string message) : HeimdallAuthException(message); +public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string CookieName, string CookieValue); -public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId); +public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, LocalReturnPath ReturnPath); -public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, string RedirectUri); - -public sealed class HeimdallAuthAttemptStore -{ - private readonly ConcurrentDictionary _attempts = new(StringComparer.Ordinal); - - public void Create(string attemptId, string returnUrl) - { - _attempts[attemptId] = new HeimdallAuthAttempt(returnUrl, null); - } - - public void SetRequiresMemberAccess(string attemptId, bool requiresMemberAccess) - { - _attempts.AddOrUpdate( - attemptId, - _ => new HeimdallAuthAttempt("/App", null, requiresMemberAccess), - (_, existing) => existing with { RequiresMemberAccess = requiresMemberAccess }); - } - - 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, - bool RequiresMemberAccess = false); +/// 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, @@ -313,11 +419,7 @@ public sealed record HeimdallStartRequest( [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); + [property: JsonPropertyName("kind")] string Kind); public sealed record HeimdallEntitlementPolicy( [property: JsonPropertyName("kind")] string Kind, @@ -331,31 +433,45 @@ public sealed record HeimdallEntitlementPolicy( 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 sealed record HeimdallRedeemRequest( + [property: JsonPropertyName("completionCode")] string CompletionCode); - 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 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, - [property: JsonPropertyName("displayName")] string? DisplayName); + [property: JsonPropertyName("id")] string Id); -public sealed record HeimdallEntitlements( - [property: JsonPropertyName("facts")] string[]? Facts); +/// +/// The attempt cookies: a ring of fixed names, so the Cookie header a browser sends to +/// /auth/heimdall is bounded by construction (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 = "bifrost_heimdall_"; + 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 = "/auth/heimdall", + MaxAge = Lifetime + }; + + /// + /// The options a deletion carries: only the attempt cookie's Path, which a cookie is matched on. 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 = "/auth/heimdall" }; +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs new file mode 100644 index 0000000..662fb16 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs @@ -0,0 +1,46 @@ +namespace Bifrost.Web.Features.Heimdall; + +/// +/// The page Heimdall's "Return to the app" lands on. The completion code arrives in the URL fragment, which +/// the server never sees, so the page's script reads it, drops it from the address bar, and posts it with +/// the attempt to the same path. The page is static: it never contains a code. +/// +public static class HeimdallCompletePage +{ + public const string Html = """ + + + + + + + Signing in - Bifrost + + +

Finishing sign-in...

+ +
+ + +
+ + + + """; +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs new file mode 100644 index 0000000..8e82ed4 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -0,0 +1,284 @@ +using System.Text.Json; +using Bifrost.Web.Configuration; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace Bifrost.Web.Features.Heimdall; + +/// +/// The identity a Heimdall-signed access token asserts. Everything here comes from verified claims. +/// +public sealed record HeimdallVerifiedIdentity(string AccountId, string? DisplayName, bool HasMemberAccess); + +/// +/// Verifies Heimdall's access tokens (EdDSA) against Heimdall's published JWKS. Heimdall is the issuer +/// at ; the audience is this app's slug. Failures name a rule and +/// never echo the token or any of its contents. One instance serves the process: it owns the key cache. +/// +public sealed class HeimdallTokenVerifier( + IHttpClientFactory httpClientFactory, + IOptions heimdallOptions, + TimeProvider timeProvider) +{ + public const string TokenType = "heimdall_access"; + public const string ClientName = "heimdall-jwks"; + + internal static readonly TimeSpan ClockSkew = TimeSpan.FromSeconds(60); + internal static readonly TimeSpan KeyRefreshInterval = TimeSpan.FromSeconds(30); + internal static readonly TimeSpan KeyMaxAge = TimeSpan.FromHours(1); + + private static readonly string[] MemberCapabilities = ["member_access", "app_access"]; + + private readonly SemaphoreSlim _fetchGate = new(1, 1); + private volatile KeySnapshot _keys = new(new Dictionary(StringComparer.Ordinal), DateTimeOffset.MinValue); + private DateTimeOffset _attemptedAt = DateTimeOffset.MinValue; + + public async Task VerifyAsync( + string token, + string? payloadAccountId, + CancellationToken cancellationToken) + { + var parts = token.Split('.'); + if (parts.Length != 3) + { + throw Refuse("token is not a compact JWS"); + } + + var headerBytes = Decode(parts[0]); + var payloadBytes = Decode(parts[1]); + var signature = Decode(parts[2]); + + using var header = ParseJson(headerBytes, "token header is not JSON"); + var kid = ReadKeyId(header.RootElement); + var publicKey = await FindKeyAsync(kid, cancellationToken); + if (publicKey is null) + { + throw Refuse("token key is not published"); + } + + var signingInput = System.Text.Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"); + if (signature.Length != Ed25519.SignatureSize || + !Ed25519.Verify(signature, 0, publicKey, 0, signingInput, 0, signingInput.Length)) + { + throw Refuse("token signature is invalid"); + } + + using var payload = ParseJson(payloadBytes, "token payload is not JSON"); + return CheckClaims(payload.RootElement, heimdallOptions.Value, payloadAccountId); + } + + private sealed record KeySnapshot(Dictionary Keys, DateTimeOffset FetchedAt); + + private static byte[] Decode(string segment) + { + try + { + return WebEncoders.Base64UrlDecode(segment); + } + catch (FormatException) + { + throw Refuse("token is not base64url"); + } + } + + private static JsonDocument ParseJson(byte[] bytes, string rule) + { + try + { + return JsonDocument.Parse(bytes); + } + catch (JsonException) + { + throw Refuse(rule); + } + } + + private static string ReadKeyId(JsonElement header) + { + if (header.ValueKind != JsonValueKind.Object || StringClaim(header, "alg") != "EdDSA") + { + throw Refuse("token algorithm is not EdDSA"); + } + + var kid = StringClaim(header, "kid"); + if (string.IsNullOrEmpty(kid)) + { + throw Refuse("token has no key id"); + } + + return kid; + } + + private HeimdallVerifiedIdentity CheckClaims(JsonElement claims, HeimdallOptions options, string? payloadAccountId) + { + if (claims.ValueKind != JsonValueKind.Object) + { + throw Refuse("token payload is not an object"); + } + + if (StringClaim(claims, "iss") != options.BaseUrl.TrimEnd('/')) + { + throw Refuse("token issuer is not Heimdall"); + } + + if (StringClaim(claims, "aud") != options.AppSlug) + { + throw Refuse("token audience is not this app"); + } + + if (StringClaim(claims, "typ") != TokenType) + { + throw Refuse("token type is not an access token"); + } + + if (!claims.TryGetProperty("app", out var app) || + app.ValueKind != JsonValueKind.Object || + StringClaim(app, "slug") != options.AppSlug) + { + throw Refuse("token app is not this app"); + } + + var now = timeProvider.GetUtcNow(); + if (NumericDate(claims, "nbf") is not { } notBefore || now < notBefore - ClockSkew) + { + throw Refuse("token is not yet valid"); + } + + if (NumericDate(claims, "exp") is not { } expires || now - ClockSkew >= expires) + { + throw Refuse("token has expired"); + } + + var subject = StringClaim(claims, "sub"); + if (string.IsNullOrEmpty(subject) || + subject != StringClaim(claims, "account_id") || + subject != payloadAccountId) + { + throw Refuse("token subject does not match the account"); + } + + var capabilities = new List(); + if (claims.TryGetProperty("capabilities", out var list) && list.ValueKind == JsonValueKind.Array) + { + foreach (var capability in list.EnumerateArray()) + { + if (capability.ValueKind == JsonValueKind.String) + { + capabilities.Add(capability.GetString()!); + } + } + } + + return new HeimdallVerifiedIdentity( + subject, + StringClaim(claims, "display_name"), + capabilities.Any(capability => MemberCapabilities.Contains(capability, StringComparer.OrdinalIgnoreCase))); + } + + /// The JWKS client waits a short, bounded time: a slow fetch must not hold sign-ins for long. + public static void ConfigureClient(HttpClient client, HeimdallOptions options) + { + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); + client.Timeout = TimeSpan.FromSeconds(10); + } + + private async Task FindKeyAsync(string kid, CancellationToken cancellationToken) + { + if (TryFreshKey(kid, out var cached)) + { + return cached; + } + + await _fetchGate.WaitAsync(cancellationToken); + try + { + var now = timeProvider.GetUtcNow(); + if (now - _attemptedAt >= KeyRefreshInterval) + { + _attemptedAt = now; + _keys = new KeySnapshot(await FetchKeysAsync(cancellationToken), now); + } + + return _keys.Keys.GetValueOrDefault(kid); + } + finally + { + _fetchGate.Release(); + } + } + + private bool TryFreshKey(string kid, out byte[]? key) + { + var snapshot = _keys; + key = null; + return timeProvider.GetUtcNow() - snapshot.FetchedAt < KeyMaxAge && snapshot.Keys.TryGetValue(kid, out key); + } + + private async Task> FetchKeysAsync(CancellationToken cancellationToken) + { + try + { + return await ReadKeysAsync(cancellationToken); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + throw Refuse("Heimdall keys could not be fetched"); + } + } + + private async Task> ReadKeysAsync(CancellationToken cancellationToken) + { + var httpClient = httpClientFactory.CreateClient(ClientName); + using var response = await httpClient.GetAsync("/.well-known/jwks.json", cancellationToken); + response.EnsureSuccessStatusCode(); + await using var body = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); + var keys = new Dictionary(StringComparer.Ordinal); + foreach (var jwk in document.RootElement.GetProperty("keys").EnumerateArray()) + { + if (jwk.ValueKind != JsonValueKind.Object) + { + continue; + } + + var id = StringClaim(jwk, "kid"); + var x = StringClaim(jwk, "x"); + if (StringClaim(jwk, "kty") != "OKP" || + StringClaim(jwk, "crv") != "Ed25519" || + string.IsNullOrEmpty(id) || + x is null) + { + continue; + } + + var key = WebEncoders.Base64UrlDecode(x); + if (key.Length == Ed25519.PublicKeySize) + { + keys[id] = key; + } + } + + return keys; + } + + private static string? StringClaim(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static DateTimeOffset? NumericDate(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetInt64(out var seconds) || + seconds is < 0 or > 253402300799) + { + return null; + } + + return DateTimeOffset.FromUnixTimeSeconds(seconds); + } + + private static HeimdallAuthException Refuse(string rule) => new($"Heimdall token refused: {rule}."); +} diff --git a/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs new file mode 100644 index 0000000..1de135b --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs @@ -0,0 +1,34 @@ +namespace Bifrost.Web.Features.Shared; + +/// +/// A sign-in return target that stays on this site. The only way to make one is , which +/// accepts a path of at most characters that starts with a single slash and holds no +/// control character, and otherwise yields . Callers parse once at entry and carry the typed value from there. +/// +public sealed record LocalReturnPath +{ + private LocalReturnPath(string value) => Value = value; + + /// Bounds the path so the attempt cookie that carries it stays far below a browser's 4096-byte limit. + public const int MaxLength = 256; + + public static LocalReturnPath Default { get; } = new("/App"); + + public string Value { get; } + + public static LocalReturnPath Parse(string? candidate) + { + if (string.IsNullOrEmpty(candidate) || + candidate.Length > MaxLength || + candidate[0] != '/' || + candidate.Any(char.IsControl) || + (candidate.Length > 1 && (candidate[1] == '/' || candidate[1] == '\\'))) + { + return Default; + } + + return new LocalReturnPath(candidate); + } + + public override string ToString() => Value; +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 99ecd84..c0f5e40 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -125,12 +125,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); builder.Services.AddHttpClient((serviceProvider, client) => -{ - var options = serviceProvider.GetRequiredService>().Value; - client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); -}); + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) + .RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase)); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => + HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.stripe.com/"); @@ -300,22 +300,18 @@ app.MapPost("/github/webhooks", async ( app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions gitHubOptions) => { - var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); - if (string.IsNullOrWhiteSpace(returnUrl)) - { - returnUrl = "/App"; - } + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); var provider = httpContext.Request.Query["provider"].ToString(); if (string.IsNullOrWhiteSpace(provider)) { - await WriteSignInChooserAsync(httpContext, returnUrl); + await WriteSignInChooserAsync(httpContext, returnPath.Value); return; } if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase)) { - httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnUrl)}"); + httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnPath.Value)}"); return; } @@ -327,7 +323,7 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions hostOptions, + ILogger logger, CancellationToken cancellationToken) => { try { - var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); - if (string.IsNullOrWhiteSpace(returnUrl)) - { - returnUrl = "/App"; - } - + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); var requireMemberAccess = string.Equals( httpContext.Request.Query["access"].ToString(), "member", @@ -385,30 +377,18 @@ app.MapGet("/auth/heimdall/{provider}", async ( var start = await heimdallAuthService.StartAsync( provider, hostOptions.Value.PublicBaseUrl, - returnUrl, + returnPath, requireMemberAccess, + httpContext.Request.Cookies, cancellationToken); + httpContext.Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options()); 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); + logger.LogWarning("Heimdall start refused: {Reason}", error.Message); + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -534,31 +514,44 @@ eveGovernance.MapGet("/surface", async ( "application/json"); }); -app.MapGet("/auth/heimdall/wait", async ( +app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) => +{ + httpContext.Response.Headers.CacheControl = "no-store"; + httpContext.Response.Headers["Referrer-Policy"] = "no-referrer"; + return Results.Content(HeimdallCompletePage.Html, "text/html", System.Text.Encoding.UTF8); +}).AllowAnonymous(); + +app.MapPost("/auth/heimdall/complete", async ( HttpContext httpContext, HeimdallAuthService heimdallAuthService, + IOptions hostOptions, + ILogger logger, CancellationToken cancellationToken) => { - var attemptId = httpContext.Request.Query["attemptId"].ToString(); - if (string.IsNullOrWhiteSpace(attemptId)) - { - return Results.Redirect("/?auth=heimdall-error&detail=missing-attempt-id"); - } - + var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); + var attempt = form?["attempt"].ToString(); + var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt); try { - var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken); + var signIn = await heimdallAuthService.CompleteAsync( + hostOptions.Value.PublicBaseUrl, + attempt, + held?.Value, + form?["code"].ToString(), + 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"); + httpContext.Response.Cookies.Delete(held!.Value.Name, HeimdallAttemptCookie.DeleteOptions()); + return Results.LocalRedirect(signIn.ReturnPath.Value); } catch (HeimdallAuthException error) { - return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); + logger.LogWarning("Heimdall completion refused: {Reason}", error.Message); + if (held is not null) + { + httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions()); + } + + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -578,6 +571,23 @@ static IResult JsonResult(T value, JsonSerializerOptions options, int statusC "application/json", statusCode: statusCode); +static async Task ReadFormOrNullAsync(HttpRequest request, CancellationToken cancellationToken) +{ + if (!request.HasFormContentType) + { + return null; + } + + try + { + return await request.ReadFormAsync(cancellationToken); + } + catch (Exception error) when (error is InvalidDataException or BadHttpRequestException) + { + return null; + } +} + static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl) { httpContext.Response.ContentType = "text/html; charset=utf-8"; diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json index b24dd83..47bbb7b 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -27,6 +27,7 @@ "Heimdall": { "BaseUrl": "https://heimdall.gamecult.org", "AppSlug": "bifrost", + "AppSecret": "", "DiscordGuildId": "", "DiscordAllowedRoleIds": [], "PatreonTierTitle": "Inner Sanctum", diff --git a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj index 77670c1..1e94198 100644 --- a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj +++ b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs new file mode 100644 index 0000000..aeaf092 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs @@ -0,0 +1,64 @@ +// Runs the complete page's real inline script against a simulated browser, once per scenario, and prints +// what it did as JSON: the ordered events, and the address and form values at the moment of the submit. +// Usage: node complete-page-harness.cjs +const fs = require("fs"); +const vm = require("vm"); + +const html = fs.readFileSync(process.argv[2], "utf8"); +const scripts = [...html.matchAll(/