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/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 88575f2..293aa3c 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -17,6 +17,7 @@ namespace Bifrost.Web.Features.Heimdall; public sealed class HeimdallAuthService( HttpClient httpClient, HeimdallAuthAttemptStore attemptStore, + HeimdallTokenVerifier tokenVerifier, BifrostDbContext dbContext, BifrostIdentityService bifrostIdentityService, IOptions heimdallOptions, @@ -38,7 +39,7 @@ public sealed class HeimdallAuthService( { if (!AllowedProviders.Contains(provider)) { - throw new HeimdallAuthException($"Unsupported Heimdall provider '{provider}'."); + throw new HeimdallAuthException("Unsupported Heimdall provider."); } var options = heimdallOptions.Value; @@ -67,8 +68,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 +77,59 @@ 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 binding = attemptStore.Create(attemptId, provider, returnUrl, requireMemberAccess); + return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId, binding); } - public void StoreBackendCallback(HeimdallBackendCallbackPayload payload) + public async Task StoreBackendCallbackAsync( + HeimdallBackendCallbackPayload payload, + CancellationToken cancellationToken) { - 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); + if (string.IsNullOrWhiteSpace(payload.AccessToken)) + { + throw new HeimdallAuthException("Heimdall callback carried no access token."); + } + + var identity = await tokenVerifier.VerifyAsync(payload.AccessToken, payload.Account?.Id, cancellationToken); + attemptStore.Complete(payload.AttemptId, identity); } public async Task CompleteAttemptAsync( string attemptId, + string? binding, 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 (attempt.RequiresMemberAccess && !payload.HasMemberAccess) + var attempt = attemptStore.TryTake(attemptId, binding); + var identity = attempt.Identity; + 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"), + bifrostIdentityService.BuildPrincipal(userAccount, attempt.Provider), string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl); } 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 +150,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 +160,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 +173,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 @@ -264,45 +264,96 @@ public class HeimdallAuthException(string message) : InvalidOperationException(m public sealed class HeimdallAuthPendingException(string message) : HeimdallAuthException(message); -public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId); +public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId, string Binding); public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, string RedirectUri); -public sealed class HeimdallAuthAttemptStore +/// +/// Attempts Start issued. An attempt lives for , is completed only by a verified +/// Heimdall identity, and is redeemed once by the browser that holds its binding value. The store holds at +/// most attempts; a full store drops the one that expires first. +/// +public sealed class HeimdallAuthAttemptStore(TimeProvider timeProvider) { - private readonly ConcurrentDictionary _attempts = new(StringComparer.Ordinal); + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); + public const int Capacity = 1024; - public void Create(string attemptId, string returnUrl) + private readonly object _gate = new(); + private readonly Dictionary _attempts = new(StringComparer.Ordinal); + + public string Create(string attemptId, string provider, string returnUrl, bool requiresMemberAccess) { - _attempts[attemptId] = new HeimdallAuthAttempt(returnUrl, null); + var now = timeProvider.GetUtcNow(); + var binding = Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlEncode( + System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + lock (_gate) + { + while (_attempts.Count >= Capacity) + { + _attempts.Remove(_attempts.MinBy(x => x.Value.ExpiresAt).Key); + } + + _attempts[attemptId] = new HeimdallAuthAttempt( + provider, returnUrl, requiresMemberAccess, binding, now + Lifetime, null); + } + + return binding; } - public void SetRequiresMemberAccess(string attemptId, bool requiresMemberAccess) + public void Complete(string attemptId, HeimdallVerifiedIdentity identity) { - _attempts.AddOrUpdate( - attemptId, - _ => new HeimdallAuthAttempt("/App", null, requiresMemberAccess), - (_, existing) => existing with { RequiresMemberAccess = requiresMemberAccess }); + lock (_gate) + { + if (!_attempts.TryGetValue(attemptId, out var attempt) || + attempt.ExpiresAt <= timeProvider.GetUtcNow() || + attempt.Identity is not null) + { + throw new HeimdallAuthException("Heimdall callback did not match a pending attempt."); + } + + _attempts[attemptId] = attempt with { Identity = identity }; + } } - public void Complete(string attemptId, HeimdallBackendCallbackPayload payload) + public HeimdallRedeemedAttempt TryTake(string attemptId, string? binding) { - _attempts.AddOrUpdate( - attemptId, - _ => new HeimdallAuthAttempt(payload.ReturnTo ?? "/App", payload), - (_, existing) => existing with { Payload = payload }); - } + lock (_gate) + { + if (!_attempts.TryGetValue(attemptId, out var attempt) || + attempt.ExpiresAt <= timeProvider.GetUtcNow() || + binding is null || + !System.Security.Cryptography.CryptographicOperations.FixedTimeEquals( + System.Text.Encoding.UTF8.GetBytes(binding), + System.Text.Encoding.UTF8.GetBytes(attempt.Binding))) + { + throw new HeimdallAuthException("Heimdall auth attempt is not valid for this browser."); + } - public HeimdallAuthAttempt? TryTake(string attemptId) - { - return _attempts.TryRemove(attemptId, out var attempt) ? attempt : null; + if (attempt.Identity is null) + { + throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet."); + } + + _attempts.Remove(attemptId); + return new HeimdallRedeemedAttempt( + attempt.Provider, attempt.ReturnUrl, attempt.RequiresMemberAccess, attempt.Identity); + } } } public sealed record HeimdallAuthAttempt( + string Provider, string ReturnUrl, - HeimdallBackendCallbackPayload? Payload, - bool RequiresMemberAccess = false); + bool RequiresMemberAccess, + string Binding, + DateTimeOffset ExpiresAt, + HeimdallVerifiedIdentity? Identity); + +public sealed record HeimdallRedeemedAttempt( + string Provider, + string ReturnUrl, + bool RequiresMemberAccess, + HeimdallVerifiedIdentity Identity); public sealed record HeimdallStartRequest( [property: JsonPropertyName("appSlug")] string AppSlug, @@ -333,29 +384,22 @@ public sealed record HeimdallStartResponse( 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; -} + [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); +public static class HeimdallBindingCookie +{ + public const string Name = "bifrost_heimdall_binding"; + + public static CookieOptions Options() => new() + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/auth/heimdall", + MaxAge = HeimdallAuthAttemptStore.Lifetime + }; +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs new file mode 100644 index 0000000..dab753b --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -0,0 +1,249 @@ +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. +/// +public sealed class HeimdallTokenVerifier( + HttpClient httpClient, + IOptions heimdallOptions, + TimeProvider timeProvider) +{ + public const string TokenType = "heimdall_access"; + + 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 Dictionary _keys = new(StringComparer.Ordinal); + private DateTimeOffset _fetchedAt = DateTimeOffset.MinValue; + private DateTimeOffset _attemptedAt = DateTimeOffset.MinValue; + + public async Task VerifyAsync( + string token, + string? payloadAccountId, + CancellationToken cancellationToken) + { + var options = heimdallOptions.Value; + var parts = token.Split('.'); + if (parts.Length != 3) + { + throw Refuse("token is not a compact JWS"); + } + + byte[] headerBytes, payloadBytes, signature; + try + { + headerBytes = WebEncoders.Base64UrlDecode(parts[0]); + payloadBytes = WebEncoders.Base64UrlDecode(parts[1]); + signature = WebEncoders.Base64UrlDecode(parts[2]); + } + catch (FormatException) + { + throw Refuse("token is not base64url"); + } + + string kid; + try + { + using var header = JsonDocument.Parse(headerBytes); + if (header.RootElement.ValueKind != JsonValueKind.Object || + StringClaim(header.RootElement, "alg") != "EdDSA") + { + throw Refuse("token algorithm is not EdDSA"); + } + + kid = StringClaim(header.RootElement, "kid") is { Length: > 0 } found + ? found + : throw Refuse("token has no key id"); + } + catch (JsonException) + { + throw Refuse("token header is not JSON"); + } + + 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"); + } + + try + { + using var payload = JsonDocument.Parse(payloadBytes); + return CheckClaims(payload.RootElement, options, payloadAccountId); + } + catch (JsonException) + { + throw Refuse("token payload is not JSON"); + } + } + + 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 >= expires + ClockSkew) + { + 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))); + } + + private async Task FindKeyAsync(string kid, CancellationToken cancellationToken) + { + await _fetchGate.WaitAsync(cancellationToken); + try + { + var now = timeProvider.GetUtcNow(); + var stale = now - _fetchedAt >= KeyMaxAge; + if ((stale || !_keys.ContainsKey(kid)) && now - _attemptedAt >= KeyRefreshInterval) + { + _attemptedAt = now; + _keys = await FetchKeysAsync(cancellationToken); + _fetchedAt = now; + } + + return _keys.GetValueOrDefault(kid); + } + finally + { + _fetchGate.Release(); + } + } + + private async Task> FetchKeysAsync(CancellationToken cancellationToken) + { + try + { + 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); + if (document.RootElement.TryGetProperty("keys", out var list) && list.ValueKind == JsonValueKind.Array) + { + foreach (var jwk in list.EnumerateArray()) + { + if (jwk.ValueKind != JsonValueKind.Object || + StringClaim(jwk, "kty") != "OKP" || + StringClaim(jwk, "crv") != "Ed25519" || + StringClaim(jwk, "kid") is not { Length: > 0 } id || + StringClaim(jwk, "x") is not { } x) + { + continue; + } + + var key = WebEncoders.Base64UrlDecode(x); + if (key.Length == Ed25519.PublicKeySize) + { + keys[id] = key; + } + } + } + + return keys; + } + catch (Exception error) when (error is HttpRequestException or JsonException or FormatException) + { + throw Refuse("Heimdall keys could not be fetched"); + } + } + + 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/Program.cs b/src/Bifrost.Web/Program.cs index 94276e1..3eb149d 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -130,6 +130,11 @@ builder.Services.AddHttpClient((serviceProvider, client) => var options = serviceProvider.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); }); +builder.Services.AddHttpClient((serviceProvider, client) => +{ + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); +}); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.stripe.com/"); @@ -388,6 +393,7 @@ app.MapGet("/auth/heimdall/{provider}", async ( requireMemberAccess, cancellationToken); + httpContext.Response.Cookies.Append(HeimdallBindingCookie.Name, start.Binding, HeimdallBindingCookie.Options()); return Results.Redirect(start.AuthorizationUrl.ToString()); } catch (HeimdallAuthException error) @@ -396,18 +402,21 @@ app.MapGet("/auth/heimdall/{provider}", async ( } }).AllowAnonymous(); -app.MapPost("/auth/heimdall/callback", ( +app.MapPost("/auth/heimdall/callback", async ( HeimdallBackendCallbackPayload payload, - HeimdallAuthService heimdallAuthService) => + HeimdallAuthService heimdallAuthService, + ILogger logger, + CancellationToken cancellationToken) => { try { - heimdallAuthService.StoreBackendCallback(payload); + await heimdallAuthService.StoreBackendCallbackAsync(payload, cancellationToken); return Results.Accepted(); } catch (HeimdallAuthException error) { - return Results.BadRequest(error.Message); + logger.LogWarning("Heimdall callback refused: {Reason}", error.Message); + return Results.BadRequest("Heimdall callback refused."); } }).AllowAnonymous(); @@ -546,8 +555,12 @@ app.MapGet("/auth/heimdall/wait", async ( try { - var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken); + var signIn = await heimdallAuthService.CompleteAttemptAsync( + attemptId, + httpContext.Request.Cookies[HeimdallBindingCookie.Name], + cancellationToken); await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); + httpContext.Response.Cookies.Delete(HeimdallBindingCookie.Name, HeimdallBindingCookie.Options()); return Results.Redirect(signIn.RedirectUri); } catch (HeimdallAuthPendingException) diff --git a/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs b/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs new file mode 100644 index 0000000..619688b --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs @@ -0,0 +1,785 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.WebUtilities; + +namespace Bifrost.Web.Tests; + +/// +/// Sign-in through the Heimdall callback, against a local Bifrost with a test JWKS. Deliveries are shaped +/// like Heimdall's backend-handoff.ts payload and claims.ts claim set; forgeries differ from them in the +/// one property under test. +/// +public sealed class HeimdallCallbackTests +{ + private static string Flip(string value) => value[..^1] + (value[^1] == '0' ? '1' : '0'); + + private static async Task DeliverGenuine( + HeimdallHost host, StartedAttempt attempt, string accountId, string[]? capabilities = null, string? displayName = "Genuine Name") + { + return await host.DeliverAsync(HeimdallShapes.Delivery( + attempt.AttemptId, accountId, host.Token(accountId, capabilities, displayName))); + } + + private static async Task AssertRefused(HeimdallHost host, StartedAttempt attempt, string accountId, HttpResponseMessage delivery) + { + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.False(wait.SignedIn); + Assert.Equal(HttpStatusCode.OK, wait.Response.StatusCode); + Assert.Null(await host.Account(accountId)); + } + + [Fact] + public async Task Genuine_delivery_signs_in_the_account_the_token_names() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(returnUrl: "/App/Mill"); + + using var delivery = await DeliverGenuine(host, attempt, accountId); + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.True(wait.SignedIn); + Assert.Equal(HttpStatusCode.Redirect, wait.Response.StatusCode); + Assert.Equal("/App/Mill", wait.Response.Headers.Location!.OriginalString); + + var account = await host.Account(accountId); + Assert.NotNull(account); + Assert.Equal("Genuine Name", account!.DisplayName); + Assert.Equal(MembershipStatus.Authenticated, account.Membership!.Status); + Assert.Empty(account.Membership.RoleAssignments); + + var cleared = HeimdallHost.SetCookieHeader(wait.Response, HeimdallHost.BindingCookie); + Assert.NotNull(cleared); + Assert.Contains("1970", cleared); + } + + [Fact] + public async Task The_display_name_is_the_claim_and_falls_back_to_the_account_id() + { + using var host = new HeimdallHost(); + var named = HeimdallShapes.NewAccountId(); + var unnamed = HeimdallShapes.NewAccountId(); + + var first = await host.StartAsync(); + await DeliverGenuine(host, first, named, displayName: "Claimed Name"); + Assert.True((await host.WaitAsync(first.AttemptId, first.Binding)).SignedIn); + + var second = await host.StartAsync(); + await DeliverGenuine(host, second, unnamed, displayName: null); + Assert.True((await host.WaitAsync(second.AttemptId, second.Binding)).SignedIn); + + Assert.Equal("Claimed Name", (await host.Account(named))!.DisplayName); + Assert.Equal(unnamed, (await host.Account(unnamed))!.DisplayName); + } + + [Fact] + public async Task A_callback_without_an_access_token_signs_nobody_in() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var payload = HeimdallShapes.Delivery(attempt.AttemptId, accountId, "unused"); + payload.Remove("accessToken"); + + using var delivery = await host.DeliverAsync(payload); + + await AssertRefused(host, attempt, accountId, delivery); + } + + [Fact] + public async Task An_empty_access_token_signs_nobody_in() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, " ")); + + await AssertRefused(host, attempt, accountId, delivery); + } + + [Theory] + [InlineData("other-key-same-kid")] + [InlineData("other-key-other-kid")] + [InlineData("tampered-payload")] + [InlineData("alg-none")] + [InlineData("alg-hs256")] + [InlineData("truncated-signature")] + [InlineData("no-kid")] + [InlineData("unpublished-kid")] + [InlineData("four-segments")] + [InlineData("two-segments")] + [InlineData("not-base64url")] + [InlineData("payload-not-json")] + public async Task A_forged_token_signs_nobody_in(string forgery) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var claims = HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access"]); + var genuine = host.Key.Sign(claims); + var parts = genuine.Split('.'); + + var token = forgery switch + { + "other-key-same-kid" => HeimdallKey.Generate(host.Key.Kid).Sign(claims), + "other-key-other-kid" => HeimdallKey.Generate().Sign(claims), + "tampered-payload" => parts[0] + "." + WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes( + HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access", "x"]))) + "." + parts[2], + "alg-none" => WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"none\",\"typ\":\"JWT\"}")) + "." + parts[1] + ".", + "alg-hs256" => host.Key.Sign(claims, alg: "HS256"), + "truncated-signature" => parts[0] + "." + parts[1] + "." + + WebEncoders.Base64UrlEncode(WebEncoders.Base64UrlDecode(parts[2])[..63]), + "no-kid" => host.Key.Sign(claims, kid: null), + "unpublished-kid" => host.Key.Sign(claims, kid: "ed25519-" + new string('0', 12)), + "four-segments" => genuine + ".AAAA", + "two-segments" => parts[0] + "." + parts[1], + "not-base64url" => parts[0] + "." + parts[1] + ".!!" + parts[2][2..], + "payload-not-json" => parts[0] + "." + WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("not json")) + "." + parts[2], + _ => throw new InvalidOperationException(forgery) + }; + Assert.NotEqual(genuine, token); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + await AssertRefused(host, attempt, accountId, delivery); + } + + [Fact] + public async Task A_forged_token_cannot_sign_in_as_an_existing_account() + { + using var host = new HeimdallHost(); + var victim = HeimdallShapes.NewAccountId(); + var real = await host.StartAsync(); + await DeliverGenuine(host, real, victim, displayName: "Victim Name"); + Assert.True((await host.WaitAsync(real.AttemptId, real.Binding)).SignedIn); + + var attempt = await host.StartAsync(); + var forged = HeimdallKey.Generate(host.Key.Kid).Sign(HeimdallShapes.AccessClaims(victim, host.Clock.Now, displayName: "Mallory")); + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, victim, forged)); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + Assert.False((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + Assert.Equal("Victim Name", (await host.Account(victim))!.DisplayName); + } + + [Theory] + [InlineData("aud-other-app")] + [InlineData("aud-case")] + [InlineData("aud-array")] + [InlineData("aud-missing")] + [InlineData("app-slug-other")] + [InlineData("app-missing")] + [InlineData("typ-refresh")] + [InlineData("typ-missing")] + [InlineData("iss-other")] + [InlineData("iss-trailing-slash")] + [InlineData("expired")] + [InlineData("not-yet-valid")] + [InlineData("nbf-missing")] + [InlineData("exp-missing")] + [InlineData("exp-string")] + [InlineData("exp-out-of-range")] + [InlineData("sub-differs")] + [InlineData("account-id-differs")] + [InlineData("account-id-missing")] + [InlineData("sub-empty")] + public async Task A_signed_token_with_a_wrong_claim_signs_nobody_in(string wrong) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var now = host.Clock.Now.ToUnixTimeSeconds(); + var token = host.Token(accountId, ["member_access"], mutate: claims => + { + switch (wrong) + { + case "aud-other-app": claims["aud"] = "ghostlight"; break; + case "aud-case": claims["aud"] = "Bifrost"; break; + case "aud-array": claims["aud"] = new[] { "bifrost" }; break; + case "aud-missing": claims.Remove("aud"); break; + case "app-slug-other": claims["app"] = new Dictionary { ["slug"] = "ghostlight" }; break; + case "app-missing": claims.Remove("app"); break; + case "typ-refresh": claims["typ"] = "heimdall_refresh"; break; + case "typ-missing": claims.Remove("typ"); break; + case "iss-other": claims["iss"] = "https://heimdall.example"; break; + case "iss-trailing-slash": claims["iss"] = HeimdallShapes.Issuer + "/"; break; + case "expired": claims["exp"] = now - 3600; break; + case "not-yet-valid": claims["nbf"] = now + 3600; claims["exp"] = now + 7200; break; + case "nbf-missing": claims.Remove("nbf"); break; + case "exp-missing": claims.Remove("exp"); break; + case "exp-string": claims["exp"] = (now + 900).ToString(); break; + case "exp-out-of-range": claims["exp"] = long.MaxValue; break; + case "sub-differs": claims["sub"] = Flip(accountId); break; + case "account-id-differs": claims["account_id"] = Flip(accountId); break; + case "account-id-missing": claims.Remove("account_id"); break; + case "sub-empty": claims["sub"] = string.Empty; claims["account_id"] = string.Empty; break; + default: throw new InvalidOperationException(wrong); + } + }); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + await AssertRefused(host, attempt, accountId, delivery); + } + + [Fact] + public async Task A_token_for_a_different_account_than_the_payload_names_is_refused_by_one_character() + { + using var host = new HeimdallHost(); + var tokenAccount = HeimdallShapes.NewAccountId(); + var payloadAccount = Flip(tokenAccount); + var attempt = await host.StartAsync(); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( + attempt.AttemptId, payloadAccount, host.Token(tokenAccount))); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + Assert.False((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + Assert.Null(await host.Account(tokenAccount)); + Assert.Null(await host.Account(payloadAccount)); + } + + [Fact] + public async Task A_payload_with_no_account_is_refused() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var payload = HeimdallShapes.Delivery(attempt.AttemptId, accountId, host.Token(accountId)); + payload["account"] = null; + + using var delivery = await host.DeliverAsync(payload); + + await AssertRefused(host, attempt, accountId, delivery); + } + + [Theory] + [InlineData(60, 900, true)] + [InlineData(61, 900, false)] + [InlineData(-1000, -59, true)] + [InlineData(-1000, -60, false)] + public async Task Token_validity_allows_a_minute_of_clock_skew(int notBeforeOffset, int expiresOffset, bool accepted) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var now = host.Clock.Now.ToUnixTimeSeconds(); + var token = host.Token(accountId, mutate: claims => + { + claims["nbf"] = now + notBeforeOffset; + claims["exp"] = now + expiresOffset; + }); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + Assert.Equal(accepted ? HttpStatusCode.Accepted : HttpStatusCode.BadRequest, delivery.StatusCode); + } + + [Fact] + public async Task A_well_signed_token_for_an_attempt_start_never_issued_creates_nothing() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var unissued = Guid.NewGuid().ToString("N"); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(unissued, accountId, host.Token(accountId))); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + var wait = await host.WaitAsync(unissued, "anything"); + Assert.False(wait.SignedIn); + Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Null(await host.Account(accountId)); + + // The same id arriving at wait before and after the refused delivery is equally unknown: no pending page. + var again = await host.WaitAsync(unissued, "anything"); + Assert.Equal(HttpStatusCode.Redirect, again.Response.StatusCode); + } + + [Fact] + public async Task A_second_delivery_does_not_replace_the_first() + { + using var host = new HeimdallHost(); + var first = HeimdallShapes.NewAccountId(); + var second = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + + using var accepted = await DeliverGenuine(host, attempt, first); + using var replaced = await DeliverGenuine(host, attempt, second); + + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, replaced.StatusCode); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + Assert.NotNull(await host.Account(first)); + Assert.Null(await host.Account(second)); + } + + [Fact] + public async Task An_attempt_is_deliverable_until_ten_minutes_after_start() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime - TimeSpan.FromSeconds(1)); + + using var delivery = await DeliverGenuine(host, attempt, accountId); + + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + } + + [Fact] + public async Task An_expired_attempt_is_refused_at_callback() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime); + + using var delivery = await DeliverGenuine(host, attempt, accountId); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.False(wait.SignedIn); + Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + } + + [Fact] + public async Task A_delivered_attempt_expires_at_wait() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + await DeliverGenuine(host, attempt, accountId); + host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime); + + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + + Assert.False(wait.SignedIn); + Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Null(await host.Account(accountId)); + } + + [Fact] + public void A_full_store_drops_the_attempt_that_expires_first() + { + var clock = new TestClock(); + var store = new HeimdallAuthAttemptStore(clock); + var identity = new HeimdallVerifiedIdentity("account", "Name", false); + for (var i = 0; i < HeimdallAuthAttemptStore.Capacity; i++) + { + store.Create($"attempt-{i}", "discord", "/App", false); + clock.Advance(TimeSpan.FromMilliseconds(1)); + } + + // Exactly at capacity nothing has been dropped yet. + store.Complete("attempt-0", identity); + store.Create("attempt-extra", "discord", "/App", false); + + Assert.Throws(() => store.Complete("attempt-0", identity)); + store.Complete("attempt-1", identity); + store.Complete("attempt-extra", identity); + store.Complete($"attempt-{HeimdallAuthAttemptStore.Capacity - 1}", identity); + } + + [Fact] + public async Task Start_sets_a_private_per_attempt_binding_cookie() + { + using var host = new HeimdallHost(); + var first = await host.StartAsync(); + var second = await host.StartAsync(); + + Assert.Equal(HttpStatusCode.Redirect, first.Response.StatusCode); + var header = HeimdallHost.SetCookieHeader(first.Response, HeimdallHost.BindingCookie); + Assert.NotNull(header); + var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); + Assert.Contains("httponly", attributes); + Assert.Contains("secure", attributes); + Assert.Contains("samesite=lax", attributes); + Assert.Contains("path=/auth/heimdall", attributes); + Assert.Contains("max-age=600", attributes); + + Assert.Equal(43, first.Binding!.Length); + Assert.NotEqual(first.Binding, second.Binding); + Assert.DoesNotContain(first.AttemptId, header); + Assert.NotEqual(first.AttemptId, first.Binding); + } + + [Fact] + public async Task Wait_signs_in_only_the_browser_that_holds_the_binding_and_only_once() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var other = await host.StartAsync(); + await DeliverGenuine(host, attempt, accountId); + + var wrong = new Dictionary + { + ["no cookie"] = null, + ["empty cookie"] = string.Empty, + ["another attempt's cookie"] = other.Binding, + ["same-shape random cookie"] = WebEncoders.Base64UrlEncode(new byte[32]), + ["the attempt id as cookie"] = attempt.AttemptId, + ["a prefix of the cookie"] = attempt.Binding![..^1], + ["the cookie with a suffix"] = attempt.Binding + "A" + }; + foreach (var (name, binding) in wrong) + { + var refused = await host.WaitAsync(attempt.AttemptId, binding); + Assert.False(refused.SignedIn, name); + Assert.StartsWith("/?auth=heimdall-error", refused.Response.Headers.Location!.OriginalString); + } + + Assert.Null(await host.Account(accountId)); + + var granted = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.True(granted.SignedIn); + + var replay = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.False(replay.SignedIn); + } + + [Fact] + public async Task Wait_before_delivery_is_pending_and_does_not_consume_the_attempt() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + + var early = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.False(early.SignedIn); + Assert.Equal(HttpStatusCode.OK, early.Response.StatusCode); + Assert.Equal("2", Assert.Single(early.Response.Headers.GetValues("Refresh"))); + + await DeliverGenuine(host, attempt, accountId); + + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + } + + [Fact] + public async Task Wait_without_an_attempt_id_signs_nobody_in() + { + using var host = new HeimdallHost(); + using var client = host.NewClient(); + + using var response = await client.GetAsync("/auth/heimdall/wait"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Null(HeimdallHost.CookieValue(response, ".AspNetCore.Cookies")); + } + + [Theory] + [InlineData("member_access", true)] + [InlineData("app_access", true)] + [InlineData("MEMBER_ACCESS", true)] + [InlineData("billing_access", false)] + public async Task Member_access_comes_from_the_token_capabilities_alone(string capability, bool grants) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(member: true); + var payload = HeimdallShapes.Delivery( + attempt.AttemptId, + accountId, + host.Token(accountId, [capability]), + looseCapabilities: grants ? [] : ["member_access", "app_access"], + looseFacts: grants ? [] : ["entitlement.app_access"]); + + using var delivery = await host.DeliverAsync(payload); + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + + var account = await host.Account(accountId); + if (grants) + { + Assert.True(wait.SignedIn); + Assert.Equal(MembershipStatus.Active, account!.Membership!.Status); + Assert.Contains(account.Membership.RoleAssignments, x => x.Role == MemberRole.StandardMember); + } + else + { + Assert.False(wait.SignedIn); + Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Null(account); + } + } + + [Fact] + public async Task A_token_capability_grants_no_role_to_a_sign_in_that_did_not_ask_for_member_access() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(member: false); + + await DeliverGenuine(host, attempt, accountId, ["member_access"]); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + + var account = await host.Account(accountId); + Assert.Equal(MembershipStatus.Authenticated, account!.Membership!.Status); + Assert.Empty(account.Membership.RoleAssignments); + } + + [Fact] + public async Task Loose_capabilities_cannot_turn_a_plain_token_into_member_access() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(member: false); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( + attempt.AttemptId, + accountId, + host.Token(accountId), + looseCapabilities: ["member_access"], + looseFacts: ["entitlement.app_access"])); + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + + var account = await host.Account(accountId); + Assert.Equal(MembershipStatus.Authenticated, account!.Membership!.Status); + Assert.Empty(account.Membership.RoleAssignments); + } + + [Fact] + public async Task The_loose_return_to_and_name_decide_nothing() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(returnUrl: "/App/Mill"); + + await host.DeliverAsync(HeimdallShapes.Delivery( + attempt.AttemptId, accountId, host.Token(accountId), looseName: "Loose Name", returnTo: "https://evil.example/landing")); + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + + Assert.Equal("/App/Mill", wait.Response.Headers.Location!.OriginalString); + Assert.Equal("Genuine Name", (await host.Account(accountId))!.DisplayName); + } + + [Fact] + public async Task Keys_are_fetched_once_and_cached() + { + using var host = new HeimdallHost(); + for (var i = 0; i < 3; i++) + { + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + using var delivery = await DeliverGenuine(host, attempt, accountId); + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + } + + Assert.Equal(1, host.Heimdall.JwksFetches); + } + + [Fact] + public async Task An_unknown_key_id_refetches_at_most_every_thirty_seconds() + { + using var host = new HeimdallHost(); + var unknown = HeimdallKey.Generate(); + async Task DeliverUnknown() + { + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( + attempt.AttemptId, accountId, host.Token(accountId, signer: unknown))); + return delivery.StatusCode; + } + + Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); + Assert.Equal(1, host.Heimdall.JwksFetches); + + host.Clock.Advance(TimeSpan.FromSeconds(29)); + Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); + Assert.Equal(1, host.Heimdall.JwksFetches); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); + Assert.Equal(2, host.Heimdall.JwksFetches); + Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); + Assert.Equal(2, host.Heimdall.JwksFetches); + } + + [Fact] + public async Task A_rotated_in_key_is_accepted_once_the_refetch_interval_has_passed() + { + using var host = new HeimdallHost(); + var rotated = HeimdallKey.Generate(); + var accountId = HeimdallShapes.NewAccountId(); + + var early = await host.StartAsync(); + using var refused = await host.DeliverAsync(HeimdallShapes.Delivery( + early.AttemptId, accountId, host.Token(accountId, signer: rotated))); + Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode); + + host.Heimdall.Published.Add(rotated); + host.Clock.Advance(TimeSpan.FromSeconds(30)); + var late = await host.StartAsync(); + using var accepted = await host.DeliverAsync(HeimdallShapes.Delivery( + late.AttemptId, accountId, host.Token(accountId, signer: rotated))); + + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + } + + [Fact] + public async Task Cached_keys_are_refetched_after_an_hour_and_a_withdrawn_key_stops_working() + { + using var host = new HeimdallHost(); + var warm = await host.StartAsync(); + var warmAccount = HeimdallShapes.NewAccountId(); + using (await DeliverGenuine(host, warm, warmAccount)) + { + } + + host.Heimdall.Published.Clear(); + host.Clock.Advance(TimeSpan.FromHours(1) - TimeSpan.FromSeconds(1)); + var inside = await host.StartAsync(); + using (var stillCached = await DeliverGenuine(host, inside, HeimdallShapes.NewAccountId())) + { + Assert.Equal(HttpStatusCode.Accepted, stillCached.StatusCode); + } + + Assert.Equal(1, host.Heimdall.JwksFetches); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + var outside = await host.StartAsync(); + using var withdrawn = await DeliverGenuine(host, outside, HeimdallShapes.NewAccountId()); + + Assert.Equal(HttpStatusCode.BadRequest, withdrawn.StatusCode); + Assert.Equal(2, host.Heimdall.JwksFetches); + } + + [Fact] + public async Task A_failing_key_fetch_refuses_without_hammering_and_recovers() + { + using var host = new HeimdallHost(); + host.Heimdall.JwksStatus = HttpStatusCode.InternalServerError; + var accountId = HeimdallShapes.NewAccountId(); + + var first = await host.StartAsync(); + using (var down = await DeliverGenuine(host, first, accountId)) + { + Assert.Equal(HttpStatusCode.BadRequest, down.StatusCode); + } + + var second = await host.StartAsync(); + using (var stillDown = await DeliverGenuine(host, second, accountId)) + { + Assert.Equal(HttpStatusCode.BadRequest, stillDown.StatusCode); + } + + Assert.Equal(1, host.Heimdall.JwksFetches); + + host.Heimdall.JwksStatus = HttpStatusCode.OK; + host.Clock.Advance(TimeSpan.FromSeconds(30)); + var third = await host.StartAsync(); + using var recovered = await DeliverGenuine(host, third, accountId); + + Assert.Equal(HttpStatusCode.Accepted, recovered.StatusCode); + } + + [Theory] + [InlineData("rsa-kty")] + [InlineData("x25519-crv")] + [InlineData("short-x")] + public async Task A_published_key_that_is_not_a_full_ed25519_key_verifies_nothing(string junk) + { + using var host = new HeimdallHost(); + var kid = "ed25519-" + new string('a', 12); + var x = WebEncoders.Base64UrlEncode(host.Key.PublicKey); + host.Heimdall.ExtraJwks.Add(42); + host.Heimdall.ExtraJwks.Add(junk switch + { + "rsa-kty" => new Dictionary { ["kty"] = "RSA", ["crv"] = "Ed25519", ["kid"] = kid, ["x"] = x }, + "x25519-crv" => new Dictionary { ["kty"] = "OKP", ["crv"] = "X25519", ["kid"] = kid, ["x"] = x }, + _ => new Dictionary { ["kty"] = "OKP", ["crv"] = "Ed25519", ["kid"] = kid, ["x"] = x[..20] } + }); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var token = host.Key.Sign(HeimdallShapes.AccessClaims(accountId, host.Clock.Now), kid: kid); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + var genuine = await host.StartAsync(); + using var accepted = await DeliverGenuine(host, genuine, accountId); + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + } + + [Fact] + public async Task Refusals_echo_no_input_in_responses_or_logs() + { + using var host = new HeimdallHost(); + var suffix = Guid.NewGuid().ToString("N"); + var attemptCanary = "CANARYATTEMPT" + suffix; + var accountCanary = "CANARYACCOUNT" + suffix; + var kidCanary = "CANARYKID" + suffix; + var claimCanary = "CANARYCLAIM" + suffix; + var providerCanary = "CANARYPROVIDER" + suffix; + var bindingCanary = "CANARYBINDING" + suffix; + var tokens = new List(); + + var canaryClaims = HeimdallShapes.AccessClaims(accountCanary, host.Clock.Now, mutate: claims => + { + claims["iss"] = claimCanary; + claims["aud"] = claimCanary; + claims["typ"] = claimCanary; + claims["display_name"] = claimCanary; + }); + var issued = await host.StartAsync(); + var wrongSubject = host.Token(HeimdallShapes.NewAccountId()); + tokens.Add(wrongSubject); + tokens.Add(HeimdallKey.Generate(kidCanary).Sign(canaryClaims, kid: kidCanary)); + tokens.Add(host.Key.Sign(canaryClaims)); + tokens.Add($"{claimCanary}.{claimCanary}.{claimCanary}"); + + var responses = new List(); + responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[1]))); + responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[2]))); + responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[3]))); + responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(issued.AttemptId, accountCanary, wrongSubject))); + responses.Add((await host.WaitAsync(attemptCanary, bindingCanary)).Response); + responses.Add((await host.WaitAsync(issued.AttemptId, bindingCanary)).Response); + using (var client = host.NewClient()) + { + responses.Add(await client.GetAsync($"/auth/heimdall/{providerCanary}")); + responses.Add(await client.PostAsync( + "/auth/heimdall/callback", + new StringContent("{\"attemptId\":\"" + attemptCanary + "\",", Encoding.UTF8, "application/json"))); + } + + var forbidden = new List { attemptCanary, accountCanary, kidCanary, claimCanary, providerCanary, bindingCanary }; + forbidden.AddRange(tokens); + forbidden.AddRange(tokens.SelectMany(x => x.Split('.')).Where(x => x.Length > 8)); + + foreach (var response in responses) + { + var observed = new StringBuilder() + .Append(await response.Content.ReadAsStringAsync()) + .Append(response.ToString()); + foreach (var cookie in response.Headers.Where(x => x.Key == "Set-Cookie").SelectMany(x => x.Value)) + { + observed.Append(cookie); + } + + foreach (var canary in forbidden) + { + Assert.DoesNotContain(canary, observed.ToString()); + } + + response.Dispose(); + } + + var logs = string.Join("\n", host.Logs.Lines); + Assert.Contains("Heimdall callback refused", logs); + foreach (var canary in forbidden) + { + Assert.DoesNotContain(canary, logs); + } + } +} diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs new file mode 100644 index 0000000..6744e1c --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -0,0 +1,362 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Features.Heimdall; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace Bifrost.Web.Tests.Support; + +/// An Ed25519 key shaped like Heimdall's signing key: a "ed25519-" kid over a thumbprint prefix. +public sealed class HeimdallKey +{ + private readonly byte[] _seed; + + private HeimdallKey(byte[] seed, byte[] publicKey, string kid) + { + _seed = seed; + PublicKey = publicKey; + Kid = kid; + } + + public string Kid { get; } + + public byte[] PublicKey { get; } + + public static HeimdallKey Generate(string? kid = null) + { + var seed = RandomNumberGenerator.GetBytes(Ed25519.SecretKeySize); + var publicKey = new byte[Ed25519.PublicKeySize]; + Ed25519.GeneratePublicKey(seed, 0, publicKey, 0); + kid ??= "ed25519-" + Convert.ToHexString(SHA256.HashData(publicKey)).ToLowerInvariant()[..12]; + return new HeimdallKey(seed, publicKey, kid); + } + + public object Jwk() => new Dictionary + { + ["kty"] = "OKP", + ["crv"] = "Ed25519", + ["x"] = WebEncoders.Base64UrlEncode(PublicKey), + ["alg"] = "EdDSA", + ["kid"] = Kid, + ["use"] = "sig" + }; + + /// Signs like Heimdall's signJwt: header {alg, kid, typ}, compact JWS. + public string Sign(IDictionary claims, string alg = "EdDSA", string? kid = "") + { + var header = new Dictionary { ["alg"] = alg, ["typ"] = "JWT" }; + if (kid is not null) + { + header["kid"] = kid.Length == 0 ? Kid : kid; + } + + var signingInput = + WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(header)) + "." + + WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(claims)); + var message = Encoding.ASCII.GetBytes(signingInput); + var signature = new byte[Ed25519.SignatureSize]; + Ed25519.Sign(_seed, 0, message, 0, message.Length, signature, 0); + return signingInput + "." + WebEncoders.Base64UrlEncode(signature); + } +} + +public static class HeimdallShapes +{ + public const string Issuer = "https://heimdall.test"; + + public static string NewAccountId() => Guid.NewGuid().ToString(); + + /// The claim set of Heimdall src/claims.ts issueAccessClaim. + public static Dictionary AccessClaims( + string accountId, + DateTimeOffset now, + string[]? capabilities = null, + string? displayName = "Genuine Name", + Action>? mutate = null) + { + var issuedAt = now.ToUnixTimeSeconds(); + var claims = new Dictionary + { + ["iss"] = Issuer, + ["aud"] = "bifrost", + ["sub"] = accountId, + ["sid"] = Guid.NewGuid().ToString(), + ["jti"] = Guid.NewGuid().ToString(), + ["iat"] = issuedAt, + ["nbf"] = issuedAt, + ["exp"] = issuedAt + 900, + ["typ"] = "heimdall_access", + ["account_id"] = accountId, + ["access_revision"] = 1, + ["app"] = new Dictionary { ["slug"] = "bifrost", ["profile_version"] = 1 }, + ["facts"] = Array.Empty(), + ["capabilities"] = capabilities ?? [], + ["identities"] = new[] + { + new Dictionary { ["provider"] = "discord", ["providerUserId"] = "1234567890" } + } + }; + if (displayName is not null) + { + claims["display_name"] = displayName; + } + + mutate?.Invoke(claims); + return claims; + } + + /// The success payload of Heimdall src/backend-handoff.ts, with the unsigned fields Bifrost used to trust. + public static Dictionary Delivery( + string attemptId, + string accountId, + string accessToken, + string[]? looseCapabilities = null, + string[]? looseFacts = null, + string looseName = "Loose Name", + string returnTo = "/App/Elsewhere") => new() + { + ["source"] = "heimdall", + ["kind"] = "oauth_result", + ["handoffKind"] = "backend_callback", + ["attemptId"] = attemptId, + ["provider"] = "discord", + ["appSlug"] = "bifrost", + ["returnTo"] = returnTo, + ["connection"] = null, + ["status"] = "success", + ["mode"] = "sign_in", + ["account"] = new Dictionary { ["id"] = accountId, ["displayName"] = looseName }, + ["entitlements"] = new Dictionary + { + ["facts"] = looseFacts ?? [], + ["snapshots"] = Array.Empty() + }, + ["session"] = new Dictionary { ["accountId"] = accountId, ["appSlug"] = "bifrost" }, + ["accessToken"] = accessToken, + ["refreshToken"] = "refresh-token-not-read", + ["refresh"] = new Dictionary { ["expiresAt"] = "2026-11-11T00:00:00.000Z" }, + ["verification"] = new Dictionary { ["issuer"] = Issuer }, + ["sharedCapabilities"] = looseCapabilities ?? [], + ["hybridCapabilities"] = Array.Empty() + }; +} + +public sealed class TestClock : TimeProvider +{ + private DateTimeOffset _now = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero); + + public DateTimeOffset Now => _now; + + public void Advance(TimeSpan by) => _now += by; + + public override DateTimeOffset GetUtcNow() => _now; +} + +/// Stands in for Heimdall's HTTP surface: the JWKS and the oauth start endpoint. +public sealed class FakeHeimdall : HttpMessageHandler +{ + public List Published { get; } = []; + + public List ExtraJwks { get; } = []; + + public int JwksFetches { get; private set; } + + public HttpStatusCode JwksStatus { get; set; } = HttpStatusCode.OK; + + public List StartBodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + if (request.Method == HttpMethod.Get && path == "/.well-known/jwks.json") + { + JwksFetches++; + var keys = Published.Select(x => x.Jwk()).Concat(ExtraJwks).ToArray(); + return new HttpResponseMessage(JwksStatus) + { + Content = new StringContent(JsonSerializer.Serialize(new { keys }), Encoding.UTF8, "application/json") + }; + } + + if (request.Method == HttpMethod.Post && path.StartsWith("/v1/oauth/", StringComparison.Ordinal) && path.EndsWith("/start", StringComparison.Ordinal)) + { + StartBodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}""", + Encoding.UTF8, + "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } +} + +public sealed class CapturedLogs : ILoggerProvider +{ + private readonly List _lines = []; + + public IReadOnlyList Lines + { + get + { + lock (_lines) + { + return _lines.ToArray(); + } + } + } + + public ILogger CreateLogger(string categoryName) => new Capture(this); + + public void Dispose() + { + } + + private sealed class Capture(CapturedLogs owner) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (owner._lines) + { + owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception)); + } + } + } +} + +public sealed record StartedAttempt(string AttemptId, string? Binding, HttpResponseMessage Response); + +public sealed record WaitResult(HttpResponseMessage Response, bool SignedIn); + +/// A local Bifrost with Heimdall faked in-process: a test JWKS, a test clock, captured logs. +public sealed class HeimdallHost : TestWebApplicationFactory +{ + public const string BindingCookie = "bifrost_heimdall_binding"; + + public TestClock Clock { get; } = new(); + + public FakeHeimdall Heimdall { get; } = new(); + + public CapturedLogs Logs { get; } = new(); + + public HeimdallKey Key { get; } = HeimdallKey.Generate(); + + public HeimdallHost() + { + Heimdall.Published.Add(Key); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Heimdall:BaseUrl"] = HeimdallShapes.Issuer, + ["Heimdall:DiscordGuildId"] = "guild-1", + ["Heimdall:DiscordAllowedRoleIds:0"] = "role-1" + }); + }); + builder.ConfigureLogging(logging => logging.AddProvider(Logs)); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Clock); + services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); + services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); + }); + } + + public HttpClient NewClient() => CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false, + BaseAddress = new Uri("https://localhost") + }); + + public string Token(string accountId, string[]? capabilities = null, string? displayName = "Genuine Name", + Action>? mutate = null, HeimdallKey? signer = null) => + (signer ?? Key).Sign(HeimdallShapes.AccessClaims(accountId, Clock.Now, capabilities, displayName, mutate)); + + public async Task StartAsync(bool member = false, string provider = "discord", string returnUrl = "/App/Mill") + { + using var client = NewClient(); + var before = Heimdall.StartBodies.Count; + var response = await client.GetAsync( + $"/auth/heimdall/{provider}?returnUrl={Uri.EscapeDataString(returnUrl)}" + (member ? "&access=member" : string.Empty)); + var attemptId = Heimdall.StartBodies.Count > before + ? JsonDocument.Parse(Heimdall.StartBodies[^1]).RootElement.GetProperty("handoff").GetProperty("attemptId").GetString()! + : string.Empty; + return new StartedAttempt(attemptId, CookieValue(response, BindingCookie), response); + } + + public async Task DeliverAsync(object payload) + { + using var client = NewClient(); + return await client.PostAsync( + "/auth/heimdall/callback", + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); + } + + public async Task WaitAsync(string attemptId, string? binding) + { + using var client = NewClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/auth/heimdall/wait?attemptId={Uri.EscapeDataString(attemptId)}"); + if (binding is not null) + { + request.Headers.Add("Cookie", $"{BindingCookie}={binding}"); + } + + var response = await client.SendAsync(request); + var session = CookieValue(response, ".AspNetCore.Cookies"); + return new WaitResult(response, !string.IsNullOrEmpty(session)); + } + + public static string? CookieValue(HttpResponseMessage response, string name) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var headers)) + { + return null; + } + + var prefix = name + "="; + var header = headers.FirstOrDefault(x => x.StartsWith(prefix, StringComparison.Ordinal)); + return header?[prefix.Length..].Split(';')[0]; + } + + public static string? SetCookieHeader(HttpResponseMessage response, string name) => + response.Headers.TryGetValues("Set-Cookie", out var headers) + ? headers.FirstOrDefault(x => x.StartsWith(name + "=", StringComparison.Ordinal)) + : null; + + public async Task Db(Func> query) + { + using var scope = Services.CreateScope(); + return await query(scope.ServiceProvider.GetRequiredService()); + } + + public Task Account(string heimdallAccountId) => + Db(db => db.UserAccounts + .Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments) + .SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId)); + + public Task AccountCount() => Db(db => db.UserAccounts.CountAsync()); +}