From f3b8acbbc5f5e40e12a4e666aad365e8cf306bd5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 02:54:13 +0200 Subject: [PATCH 01/54] Heimdall sign-in: accept only a Heimdall-signed token for an attempt Start issued, in the browser that started it The callback verifies the EdDSA access token against Heimdall's JWKS and takes the account, display name and member access from its claims; attempts expire, are bounded, are completed only if issued, and are redeemed once with a per-attempt binding cookie. Not yet built or tested; the first verification run follows. Co-Authored-By: Claude Opus 5.5 --- src/Bifrost.Web/Bifrost.Web.csproj | 1 + .../Features/Heimdall/HeimdallAuthService.cs | 196 +++-- .../Heimdall/HeimdallTokenVerifier.cs | 249 ++++++ src/Bifrost.Web/Program.cs | 23 +- .../HeimdallCallbackTests.cs | 785 ++++++++++++++++++ .../Support/HeimdallTestKit.cs | 362 ++++++++ 6 files changed, 1535 insertions(+), 81 deletions(-) create mode 100644 src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs create mode 100644 tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs 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()); +} From e99eea8769d6aebb673dd889e5e62630f524277d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 02:55:35 +0200 Subject: [PATCH 02/54] Heimdall token verifier: one process-wide instance owns the key cache The verifier was registered as a typed client, which is transient, so each request would have started with an empty key cache. It is now a singleton that takes its HTTP client from the factory per fetch. Test hosts share one in-memory database root so EF keeps a single service provider. Co-Authored-By: Claude Opus 5.5 --- src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs | 6 ++++-- src/Bifrost.Web/Program.cs | 3 ++- tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs | 2 +- .../Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs index dab753b..e79a813 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -14,14 +14,15 @@ public sealed record HeimdallVerifiedIdentity(string AccountId, string? DisplayN /// /// 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. +/// never echo the token or any of its contents. One instance serves the process: it owns the key cache. /// public sealed class HeimdallTokenVerifier( - HttpClient httpClient, + 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); @@ -193,6 +194,7 @@ public sealed class HeimdallTokenVerifier( { try { + 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); diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 3eb149d..49e70b6 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -130,7 +130,8 @@ builder.Services.AddHttpClient((serviceProvider, client) => var options = serviceProvider.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); }); -builder.Services.AddHttpClient((serviceProvider, client) => +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => { var options = serviceProvider.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index 6744e1c..477a4ad 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -280,7 +280,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory { services.RemoveAll(); services.AddSingleton(Clock); - services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); + services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall); services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); }); } diff --git a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs index ed3c4ed..b6e356d 100644 --- a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs @@ -16,7 +16,8 @@ namespace Bifrost.Web.Tests.Support; public class TestWebApplicationFactory : WebApplicationFactory { - private readonly InMemoryDatabaseRoot _databaseRoot = new(); + // One root for every factory: EF caches a service provider per root, and warns past twenty. + private static readonly InMemoryDatabaseRoot _databaseRoot = new(); private readonly string _databaseName = $"bifrost-tests-{Guid.NewGuid():N}"; protected override void ConfigureWebHost(IWebHostBuilder builder) From 650bf6bcc6648b5826b4449379496fae80d62472 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 03:05:07 +0200 Subject: [PATCH 03/54] Heimdall token verifier: split decoding and key reading into small steps Behaviour is unchanged. The catch-and-refuse blocks sit in their own small methods so the mutation tool can compile mutants of the rest of the verifier. Co-Authored-By: Claude Opus 5.5 --- .../Heimdall/HeimdallTokenVerifier.cs | 141 ++++++++++-------- 1 file changed, 78 insertions(+), 63 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs index e79a813..c344d05 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -40,44 +40,18 @@ public sealed class HeimdallTokenVerifier( 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 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) { @@ -91,17 +65,50 @@ public sealed class HeimdallTokenVerifier( throw Refuse("token signature is invalid"); } + using var payload = ParseJson(payloadBytes, "token payload is not JSON"); + return CheckClaims(payload.RootElement, heimdallOptions.Value, payloadAccountId); + } + + private static byte[] Decode(string segment) + { try { - using var payload = JsonDocument.Parse(payloadBytes); - return CheckClaims(payload.RootElement, options, payloadAccountId); + 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("token payload is not JSON"); + 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) @@ -194,41 +201,49 @@ public sealed class HeimdallTokenVerifier( { try { - 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); - 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; + return await ReadKeysAsync(cancellationToken); } - catch (Exception error) when (error is HttpRequestException or JsonException or FormatException) + catch (Exception error) when (error is not OperationCanceledException) { 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() From 1ab67da47b53395e5ce09ac6ee0ca46170eee3be Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 03:26:48 +0200 Subject: [PATCH 04/54] Heimdall sign-in tests: pin each refusal rule by its reason; token dates cannot overflow The forgery and wrong-claim tests now assert which rule refused, start failures, blank attempt ids, capability shapes and date extremes are covered. A token whose exp is the largest representable date no longer overflows the clock-skew sum, and the unreachable blank-return-url fallback is removed (Program defaults it). Co-Authored-By: Claude Opus 5.5 --- .../Features/Heimdall/HeimdallAuthService.cs | 2 +- .../Heimdall/HeimdallTokenVerifier.cs | 2 +- .../HeimdallCallbackTests.cs | 204 +++++++++++++----- .../Support/HeimdallTestKit.cs | 27 ++- 4 files changed, 176 insertions(+), 59 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 293aa3c..d27a3fe 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -118,7 +118,7 @@ public sealed class HeimdallAuthService( cancellationToken); return new HeimdallCompletedSignIn( bifrostIdentityService.BuildPrincipal(userAccount, attempt.Provider), - string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl); + attempt.ReturnUrl); } private async Task UpsertUserAccountAsync( diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs index c344d05..58d67bf 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -144,7 +144,7 @@ public sealed class HeimdallTokenVerifier( throw Refuse("token is not yet valid"); } - if (NumericDate(claims, "exp") is not { } expires || now >= expires + ClockSkew) + if (NumericDate(claims, "exp") is not { } expires || now - ClockSkew >= expires) { throw Refuse("token has expired"); } diff --git a/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs b/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs index 619688b..8837c2a 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs @@ -24,6 +24,14 @@ public sealed class HeimdallCallbackTests attempt.AttemptId, accountId, host.Token(accountId, capabilities, displayName))); } + private static void AssertLogged(HeimdallHost host, string reason) => + Assert.Contains("Heimdall callback refused: " + reason, string.Join("\n", host.Logs.Lines)); + + private static void AssertTokenRefused(HeimdallHost host, string rule) => + AssertLogged(host, $"Heimdall token refused: {rule}."); + + private static string ErrorLocation(string message) => "/?auth=heimdall-error&detail=" + Uri.EscapeDataString(message); + private static async Task AssertRefused(HeimdallHost host, StartedAttempt attempt, string accountId, HttpResponseMessage delivery) { Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); @@ -90,6 +98,19 @@ public sealed class HeimdallCallbackTests using var delivery = await host.DeliverAsync(payload); await AssertRefused(host, attempt, accountId, delivery); + AssertLogged(host, "Heimdall callback carried no access token."); + } + + [Fact] + public async Task A_callback_without_an_attempt_id_signs_nobody_in() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(" ", accountId, host.Token(accountId))); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + AssertLogged(host, "Heimdall callback omitted attempt id."); } [Fact] @@ -102,27 +123,32 @@ public sealed class HeimdallCallbackTests using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, " ")); await AssertRefused(host, attempt, accountId, delivery); + AssertLogged(host, "Heimdall callback carried no access token."); } [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) + [InlineData("other-key-same-kid", "token signature is invalid")] + [InlineData("other-key-other-kid", "token key is not published")] + [InlineData("tampered-payload", "token signature is invalid")] + [InlineData("alg-none", "token algorithm is not EdDSA")] + [InlineData("alg-hs256", "token algorithm is not EdDSA")] + [InlineData("header-array", "token algorithm is not EdDSA")] + [InlineData("header-not-json", "token header is not JSON")] + [InlineData("truncated-signature", "token signature is invalid")] + [InlineData("no-kid", "token has no key id")] + [InlineData("unpublished-kid", "token key is not published")] + [InlineData("four-segments", "token is not a compact JWS")] + [InlineData("two-segments", "token is not a compact JWS")] + [InlineData("not-base64url", "token is not base64url")] + [InlineData("payload-not-json", "token payload is not JSON")] + [InlineData("payload-not-object", "token payload is not an object")] + public async Task A_forged_token_signs_nobody_in(string forgery, string rule) { 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 claimBytes = JsonSerializer.SerializeToUtf8Bytes(claims); var genuine = host.Key.Sign(claims); var parts = genuine.Split('.'); @@ -134,6 +160,8 @@ public sealed class HeimdallCallbackTests 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"), + "header-array" => host.Key.SignParts(Encoding.UTF8.GetBytes("[1]"), claimBytes), + "header-not-json" => host.Key.SignParts(Encoding.UTF8.GetBytes("not json"), claimBytes), "truncated-signature" => parts[0] + "." + parts[1] + "." + WebEncoders.Base64UrlEncode(WebEncoders.Base64UrlDecode(parts[2])[..63]), "no-kid" => host.Key.Sign(claims, kid: null), @@ -141,7 +169,8 @@ public sealed class HeimdallCallbackTests "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], + "payload-not-json" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("not json")), + "payload-not-object" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("[1]")), _ => throw new InvalidOperationException(forgery) }; Assert.NotEqual(genuine, token); @@ -149,6 +178,7 @@ public sealed class HeimdallCallbackTests using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); await AssertRefused(host, attempt, accountId, delivery); + AssertTokenRefused(host, rule); } [Fact] @@ -170,27 +200,30 @@ public sealed class HeimdallCallbackTests } [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) + [InlineData("aud-other-app", "token audience is not this app")] + [InlineData("aud-case", "token audience is not this app")] + [InlineData("aud-array", "token audience is not this app")] + [InlineData("aud-missing", "token audience is not this app")] + [InlineData("app-slug-other", "token app is not this app")] + [InlineData("app-missing", "token app is not this app")] + [InlineData("app-not-object", "token app is not this app")] + [InlineData("typ-refresh", "token type is not an access token")] + [InlineData("typ-missing", "token type is not an access token")] + [InlineData("iss-other", "token issuer is not Heimdall")] + [InlineData("iss-trailing-slash", "token issuer is not Heimdall")] + [InlineData("expired", "token has expired")] + [InlineData("exp-missing", "token has expired")] + [InlineData("exp-string", "token has expired")] + [InlineData("exp-out-of-range", "token has expired")] + [InlineData("exp-negative", "token has expired")] + [InlineData("not-yet-valid", "token is not yet valid")] + [InlineData("nbf-missing", "token is not yet valid")] + [InlineData("nbf-negative", "token is not yet valid")] + [InlineData("sub-differs", "token subject does not match the account")] + [InlineData("account-id-differs", "token subject does not match the account")] + [InlineData("account-id-missing", "token subject does not match the account")] + [InlineData("sub-empty", "token subject does not match the account")] + public async Task A_signed_token_with_a_wrong_claim_signs_nobody_in(string wrong, string rule) { using var host = new HeimdallHost(); var accountId = HeimdallShapes.NewAccountId(); @@ -206,16 +239,19 @@ public sealed class HeimdallCallbackTests 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 "app-not-object": claims["app"] = "bifrost"; 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 "exp-negative": claims["exp"] = -1; break; + case "not-yet-valid": claims["nbf"] = now + 3600; claims["exp"] = now + 7200; break; + case "nbf-missing": claims.Remove("nbf"); break; + case "nbf-negative": claims["nbf"] = -1; 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; @@ -227,6 +263,7 @@ public sealed class HeimdallCallbackTests using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); await AssertRefused(host, attempt, accountId, delivery); + AssertTokenRefused(host, rule); } [Fact] @@ -244,6 +281,7 @@ public sealed class HeimdallCallbackTests Assert.False((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); Assert.Null(await host.Account(tokenAccount)); Assert.Null(await host.Account(payloadAccount)); + AssertTokenRefused(host, "token subject does not match the account"); } [Fact] @@ -258,6 +296,7 @@ public sealed class HeimdallCallbackTests using var delivery = await host.DeliverAsync(payload); await AssertRefused(host, attempt, accountId, delivery); + AssertTokenRefused(host, "token subject does not match the account"); } [Theory] @@ -294,12 +333,9 @@ public sealed class HeimdallCallbackTests 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.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), 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); + AssertLogged(host, "Heimdall callback did not match a pending attempt."); } [Fact] @@ -315,6 +351,7 @@ public sealed class HeimdallCallbackTests Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, replaced.StatusCode); + AssertLogged(host, "Heimdall callback did not match a pending attempt."); Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); Assert.NotNull(await host.Account(first)); Assert.Null(await host.Account(second)); @@ -345,9 +382,10 @@ public sealed class HeimdallCallbackTests using var delivery = await DeliverGenuine(host, attempt, accountId); Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + AssertLogged(host, "Heimdall callback did not match a pending attempt."); var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); Assert.False(wait.SignedIn); - Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); } [Fact] @@ -362,7 +400,7 @@ public sealed class HeimdallCallbackTests var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); Assert.False(wait.SignedIn); - Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); Assert.Null(await host.Account(accountId)); } @@ -379,11 +417,11 @@ public sealed class HeimdallCallbackTests } // Exactly at capacity nothing has been dropped yet. - store.Complete("attempt-0", identity); + store.Complete("attempt-1", identity); store.Create("attempt-extra", "discord", "/App", false); Assert.Throws(() => store.Complete("attempt-0", identity)); - store.Complete("attempt-1", identity); + store.Complete("attempt-2", identity); store.Complete("attempt-extra", identity); store.Complete($"attempt-{HeimdallAuthAttemptStore.Capacity - 1}", identity); } @@ -434,7 +472,7 @@ public sealed class HeimdallCallbackTests { var refused = await host.WaitAsync(attempt.AttemptId, binding); Assert.False(refused.SignedIn, name); - Assert.StartsWith("/?auth=heimdall-error", refused.Response.Headers.Location!.OriginalString); + Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), refused.Response.Headers.Location!.OriginalString); } Assert.Null(await host.Account(accountId)); @@ -506,7 +544,7 @@ public sealed class HeimdallCallbackTests else { Assert.False(wait.SignedIn); - Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Equal(ErrorLocation("Heimdall did not grant Bifrost member access."), wait.Response.Headers.Location!.OriginalString); Assert.Null(account); } } @@ -665,6 +703,7 @@ public sealed class HeimdallCallbackTests using (var down = await DeliverGenuine(host, first, accountId)) { Assert.Equal(HttpStatusCode.BadRequest, down.StatusCode); + AssertTokenRefused(host, "Heimdall keys could not be fetched"); } var second = await host.StartAsync(); @@ -711,6 +750,75 @@ public sealed class HeimdallCallbackTests Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); } + [Fact] + public async Task Capabilities_that_are_not_a_list_of_names_grant_nothing_and_do_not_crash() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var token = host.Token(accountId, mutate: claims => claims["capabilities"] = "member_access"); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + Assert.Empty((await host.Account(accountId))!.Membership!.RoleAssignments); + } + + [Fact] + public async Task Member_access_is_found_among_other_and_non_string_capabilities() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(member: true); + var token = host.Token(accountId, mutate: claims => + claims["capabilities"] = new object?[] { 7, null, "billing_access", "member_access" }); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + Assert.Contains((await host.Account(accountId))!.Membership!.RoleAssignments, x => x.Role == MemberRole.StandardMember); + } + + [Theory] + [InlineData(0L, 900L)] + [InlineData(-1L, 253402300799L)] + public async Task Numeric_dates_at_the_ends_of_the_valid_range_are_accepted(long notBefore, long expires) + { + 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"] = notBefore < 0 ? now : notBefore; + claims["exp"] = expires == 900 ? now + 900 : expires; + }); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start.")] + [InlineData(HttpStatusCode.OK, "{}", "Heimdall auth start returned no authorization URL.")] + [InlineData(HttpStatusCode.OK, "{\"authorizationUrl\":\" \"}", "Heimdall auth start returned no authorization URL.")] + public async Task A_start_Heimdall_cannot_serve_issues_no_attempt_and_no_cookie(HttpStatusCode status, string body, string message) + { + using var host = new HeimdallHost(); + host.Heimdall.StartStatus = status; + host.Heimdall.StartBody = body; + + var attempt = await host.StartAsync(); + + Assert.Equal(ErrorLocation(message), attempt.Response.Headers.Location!.OriginalString); + Assert.Null(attempt.Binding); + var wait = await host.WaitAsync(attempt.AttemptId, "anything"); + Assert.False(wait.SignedIn); + } + [Fact] public async Task Refusals_echo_no_input_in_responses_or_logs() { diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index 477a4ad..22efa5b 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -52,7 +52,10 @@ public sealed class HeimdallKey }; /// Signs like Heimdall's signJwt: header {alg, kid, typ}, compact JWS. - public string Sign(IDictionary claims, string alg = "EdDSA", string? kid = "") + public string Sign(IDictionary claims, string alg = "EdDSA", string? kid = "") => + SignParts(HeaderBytes(alg, kid), JsonSerializer.SerializeToUtf8Bytes(claims)); + + public byte[] HeaderBytes(string alg = "EdDSA", string? kid = "") { var header = new Dictionary { ["alg"] = alg, ["typ"] = "JWT" }; if (kid is not null) @@ -60,9 +63,13 @@ public sealed class HeimdallKey header["kid"] = kid.Length == 0 ? Kid : kid; } - var signingInput = - WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(header)) + "." + - WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(claims)); + return JsonSerializer.SerializeToUtf8Bytes(header); + } + + /// Signs exactly these header and payload bytes, whatever they hold. + public string SignParts(byte[] headerBytes, byte[] payloadBytes) + { + var signingInput = WebEncoders.Base64UrlEncode(headerBytes) + "." + WebEncoders.Base64UrlEncode(payloadBytes); var message = Encoding.ASCII.GetBytes(signingInput); var signature = new byte[Ed25519.SignatureSize]; Ed25519.Sign(_seed, 0, message, 0, message.Length, signature, 0); @@ -175,6 +182,11 @@ public sealed class FakeHeimdall : HttpMessageHandler public List StartBodies { get; } = []; + public HttpStatusCode StartStatus { get; set; } = HttpStatusCode.OK; + + public string StartBody { get; set; } = + """{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}"""; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var path = request.RequestUri!.AbsolutePath; @@ -191,12 +203,9 @@ public sealed class FakeHeimdall : HttpMessageHandler 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) + return new HttpResponseMessage(StartStatus) { - Content = new StringContent( - """{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}""", - Encoding.UTF8, - "application/json") + Content = new StringContent(StartBody, Encoding.UTF8, "application/json") }; } From bad2117ea8646951a44615e4ab0cadece6cfb473 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 04:40:47 +0200 Subject: [PATCH 05/54] Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider, member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the cookie's nonce, verifies the access token for the redeemed account, and signs that browser in. Deleted: the callback route, the wait route, the server-side attempt store, the pending state and the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all answer one redirect and log a fixed reason. Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this commit alone; later commits record the verification. Co-Authored-By: Claude Opus 5.5 --- .../Configuration/HeimdallOptions.cs | 4 + .../Features/Heimdall/HeimdallAuthService.cs | 277 ++-- .../Features/Heimdall/HeimdallCompletePage.cs | 46 + .../Heimdall/HeimdallTokenVerifier.cs | 43 +- .../Features/Shared/LocalReturnPath.cs | 30 + src/Bifrost.Web/Program.cs | 123 +- src/Bifrost.Web/appsettings.json | 1 + .../HeimdallCallbackTests.cs | 893 ------------- .../HeimdallCompletionTests.cs | 1109 +++++++++++++++++ .../Support/HeimdallTestKit.cs | 205 ++- 10 files changed, 1590 insertions(+), 1141 deletions(-) create mode 100644 src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs create mode 100644 src/Bifrost.Web/Features/Shared/LocalReturnPath.cs delete mode 100644 tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs create mode 100644 tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs 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 d27a3fe..2491b8c 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -1,28 +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", @@ -30,10 +41,22 @@ 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, CancellationToken cancellationToken) { @@ -51,14 +74,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( @@ -77,35 +99,41 @@ public sealed class HeimdallAuthService( throw new HeimdallAuthException("Heimdall auth start returned no authorization URL."); } - var binding = attemptStore.Create(attemptId, provider, returnUrl, requireMemberAccess); - return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId, binding); + var attempt = new HeimdallAttempt( + nonce, + provider, + requireMemberAccess, + returnPath, + timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime); + return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), nonce, Protect(attempt)); } - public async Task StoreBackendCallbackAsync( - HeimdallBackendCallbackPayload payload, + public async Task CompleteAsync( + string callbackBaseUrl, + string? nonce, + string? cookieValue, + string? code, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(payload.AttemptId)) + var attempt = Unprotect(cookieValue, nonce) + ?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser."); + if (string.IsNullOrWhiteSpace(code)) { - throw new HeimdallAuthException("Heimdall callback omitted attempt id."); + throw new HeimdallAuthException("Heimdall completion carried no code."); } - if (string.IsNullOrWhiteSpace(payload.AccessToken)) + var redeemed = await RedeemAsync(code, cancellationToken); + if (!string.Equals(redeemed.ReturnTo, ToAttemptReturnUrl(callbackBaseUrl, attempt.Nonce), StringComparison.Ordinal)) { - throw new HeimdallAuthException("Heimdall callback carried no access token."); + throw new HeimdallAuthException("Heimdall completion belongs to another attempt."); } - var identity = await tokenVerifier.VerifyAsync(payload.AccessToken, payload.Account?.Id, cancellationToken); - attemptStore.Complete(payload.AttemptId, identity); - } + if (string.IsNullOrWhiteSpace(redeemed.AccessToken)) + { + throw new HeimdallAuthException("Heimdall completion carried no access token."); + } - public async Task CompleteAttemptAsync( - string attemptId, - string? binding, - CancellationToken cancellationToken) - { - var attempt = attemptStore.TryTake(attemptId, binding); - var identity = attempt.Identity; + 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."); @@ -118,9 +146,68 @@ public sealed class HeimdallAuthService( cancellationToken); return new HeimdallCompletedSignIn( bifrostIdentityService.BuildPrincipal(userAccount, attempt.Provider), - attempt.ReturnUrl); + 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))); + + private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) + { + if (string.IsNullOrEmpty(cookieValue) || string.IsNullOrEmpty(nonce)) + { + return null; + } + + try + { + var wire = JsonSerializer.Deserialize(_protector.Unprotect(cookieValue)); + if (wire is null || + !string.Equals(wire.Nonce, nonce, StringComparison.Ordinal) || + wire.ExpiresAt <= timeProvider.GetUtcNow()) + { + return null; + } + + return new HeimdallAttempt( + wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt); + } + catch (Exception error) when (error is CryptographicException or JsonException or FormatException) + { + return null; + } + } + + private sealed record AttemptWire( + string Nonce, + string Provider, + bool RequiresMemberAccess, + string ReturnPath, + DateTimeOffset ExpiresAt); + private async Task UpsertUserAccountAsync( HeimdallVerifiedIdentity identity, string provider, @@ -248,112 +335,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 Nonce, string CookieValue); -public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId, string Binding); +public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, LocalReturnPath ReturnPath); -public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, string RedirectUri); - -/// -/// 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) -{ - public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); - public const int Capacity = 1024; - - private readonly object _gate = new(); - private readonly Dictionary _attempts = new(StringComparer.Ordinal); - - public string Create(string attemptId, string provider, string returnUrl, bool requiresMemberAccess) - { - 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 Complete(string attemptId, HeimdallVerifiedIdentity identity) - { - 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 HeimdallRedeemedAttempt TryTake(string attemptId, string? binding) - { - 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."); - } - - 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( +/// An attempt Start issued. It exists only inside its protected cookie. +public sealed record HeimdallAttempt( + string Nonce, string Provider, - string ReturnUrl, bool RequiresMemberAccess, - string Binding, - DateTimeOffset ExpiresAt, - HeimdallVerifiedIdentity? Identity); - -public sealed record HeimdallRedeemedAttempt( - string Provider, - string ReturnUrl, - bool RequiresMemberAccess, - HeimdallVerifiedIdentity Identity); + LocalReturnPath ReturnPath, + DateTimeOffset ExpiresAt); public sealed record HeimdallStartRequest( [property: JsonPropertyName("appSlug")] string AppSlug, @@ -364,11 +368,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, @@ -382,17 +382,32 @@ public sealed record HeimdallEntitlementPolicy( public sealed record HeimdallStartResponse( [property: JsonPropertyName("authorizationUrl")] string AuthorizationUrl); -public sealed record HeimdallBackendCallbackPayload( - [property: JsonPropertyName("attemptId")] string AttemptId, +public sealed record HeimdallRedeemRequest( + [property: JsonPropertyName("completionCode")] string CompletionCode); + +public sealed record HeimdallRedeemResponse( + [property: JsonPropertyName("returnTo")] string? ReturnTo, [property: JsonPropertyName("accessToken")] string? AccessToken, [property: JsonPropertyName("account")] HeimdallAccount? Account); public sealed record HeimdallAccount( [property: JsonPropertyName("id")] string Id); -public static class HeimdallBindingCookie +/// +/// The per-attempt cookie: named for the attempt's nonce so two attempts in one browser both complete. +/// A name is only built from a well-formed nonce, so request input never becomes an arbitrary cookie name. +/// +public static class HeimdallAttemptCookie { - public const string Name = "bifrost_heimdall_binding"; + public const string Prefix = "bifrost_heimdall_"; + public const int NonceLength = 43; + + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); + + public static string? NameFor(string? nonce) => + nonce is { Length: NonceLength } && nonce.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_') + ? Prefix + nonce + : null; public static CookieOptions Options() => new() { @@ -400,6 +415,6 @@ public static class HeimdallBindingCookie Secure = true, SameSite = SameSiteMode.Lax, Path = "/auth/heimdall", - MaxAge = HeimdallAuthAttemptStore.Lifetime + MaxAge = Lifetime }; } 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 index 58d67bf..38f71f1 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -31,8 +31,7 @@ public sealed class HeimdallTokenVerifier( 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 volatile KeySnapshot _keys = new(new Dictionary(StringComparer.Ordinal), DateTimeOffset.MinValue); private DateTimeOffset _attemptedAt = DateTimeOffset.MinValue; public async Task VerifyAsync( @@ -69,6 +68,8 @@ public sealed class HeimdallTokenVerifier( return CheckClaims(payload.RootElement, heimdallOptions.Value, payloadAccountId); } + private sealed record KeySnapshot(Dictionary Keys, DateTimeOffset FetchedAt); + private static byte[] Decode(string segment) { try @@ -175,21 +176,36 @@ public sealed class HeimdallTokenVerifier( 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(); - var stale = now - _fetchedAt >= KeyMaxAge; - if ((stale || !_keys.ContainsKey(kid)) && now - _attemptedAt >= KeyRefreshInterval) + if (TryFreshKey(kid, out cached)) { - _attemptedAt = now; - _keys = await FetchKeysAsync(cancellationToken); - _fetchedAt = now; + return cached; } - return _keys.GetValueOrDefault(kid); + var now = timeProvider.GetUtcNow(); + if (now - _attemptedAt >= KeyRefreshInterval) + { + _attemptedAt = now; + _keys = new KeySnapshot(await FetchKeysAsync(cancellationToken), now); + } + + return _keys.Keys.GetValueOrDefault(kid); } finally { @@ -197,13 +213,20 @@ public sealed class HeimdallTokenVerifier( } } + 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 error) when (error is not OperationCanceledException) + catch (Exception) when (!cancellationToken.IsCancellationRequested) { throw Refuse("Heimdall keys could not be fetched"); } diff --git a/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs new file mode 100644 index 0000000..e9c97d1 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs @@ -0,0 +1,30 @@ +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 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; + + public static LocalReturnPath Default { get; } = new("/App"); + + public string Value { get; } + + public static LocalReturnPath Parse(string? candidate) + { + if (string.IsNullOrEmpty(candidate) || + 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 49e70b6..0123ce5 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -124,18 +124,11 @@ 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)); builder.Services.AddSingleton(); builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => -{ - var options = serviceProvider.GetRequiredService>().Value; - client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); -}); + HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.stripe.com/"); @@ -305,22 +298,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; } @@ -332,7 +321,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", @@ -390,34 +375,20 @@ app.MapGet("/auth/heimdall/{provider}", async ( var start = await heimdallAuthService.StartAsync( provider, hostOptions.Value.PublicBaseUrl, - returnUrl, + returnPath, requireMemberAccess, cancellationToken); - httpContext.Response.Cookies.Append(HeimdallBindingCookie.Name, start.Binding, HeimdallBindingCookie.Options()); + httpContext.Response.Cookies.Append( + HeimdallAttemptCookie.NameFor(start.Nonce), + 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", async ( - HeimdallBackendCallbackPayload payload, - HeimdallAuthService heimdallAuthService, - ILogger logger, - CancellationToken cancellationToken) => -{ - try - { - await heimdallAuthService.StoreBackendCallbackAsync(payload, cancellationToken); - return Results.Accepted(); - } - catch (HeimdallAuthException error) - { - logger.LogWarning("Heimdall callback refused: {Reason}", error.Message); - return Results.BadRequest("Heimdall callback refused."); + logger.LogWarning("Heimdall start refused: {Reason}", error.Message); + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -543,37 +514,46 @@ 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 cookieName = HeimdallAttemptCookie.NameFor(attempt); try { - var signIn = await heimdallAuthService.CompleteAttemptAsync( - attemptId, - httpContext.Request.Cookies[HeimdallBindingCookie.Name], + var signIn = await heimdallAuthService.CompleteAsync( + hostOptions.Value.PublicBaseUrl, + attempt, + cookieName is null ? null : httpContext.Request.Cookies[cookieName], + form?["code"].ToString(), cancellationToken); await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); - httpContext.Response.Cookies.Delete(HeimdallBindingCookie.Name, HeimdallBindingCookie.Options()); - 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(cookieName!, HeimdallAttemptCookie.Options()); + 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 (cookieName is not null) + { + httpContext.Response.Cookies.Delete(cookieName, HeimdallAttemptCookie.Options()); + } + + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } -}).AllowAnonymous(); +}).AllowAnonymous().DisableAntiforgery(); app.MapPost("/auth/sign-out", async (HttpContext httpContext) => { @@ -591,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/HeimdallCallbackTests.cs b/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs deleted file mode 100644 index 8837c2a..0000000 --- a/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs +++ /dev/null @@ -1,893 +0,0 @@ -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 void AssertLogged(HeimdallHost host, string reason) => - Assert.Contains("Heimdall callback refused: " + reason, string.Join("\n", host.Logs.Lines)); - - private static void AssertTokenRefused(HeimdallHost host, string rule) => - AssertLogged(host, $"Heimdall token refused: {rule}."); - - private static string ErrorLocation(string message) => "/?auth=heimdall-error&detail=" + Uri.EscapeDataString(message); - - 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); - AssertLogged(host, "Heimdall callback carried no access token."); - } - - [Fact] - public async Task A_callback_without_an_attempt_id_signs_nobody_in() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(" ", accountId, host.Token(accountId))); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - AssertLogged(host, "Heimdall callback omitted attempt id."); - } - - [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); - AssertLogged(host, "Heimdall callback carried no access token."); - } - - [Theory] - [InlineData("other-key-same-kid", "token signature is invalid")] - [InlineData("other-key-other-kid", "token key is not published")] - [InlineData("tampered-payload", "token signature is invalid")] - [InlineData("alg-none", "token algorithm is not EdDSA")] - [InlineData("alg-hs256", "token algorithm is not EdDSA")] - [InlineData("header-array", "token algorithm is not EdDSA")] - [InlineData("header-not-json", "token header is not JSON")] - [InlineData("truncated-signature", "token signature is invalid")] - [InlineData("no-kid", "token has no key id")] - [InlineData("unpublished-kid", "token key is not published")] - [InlineData("four-segments", "token is not a compact JWS")] - [InlineData("two-segments", "token is not a compact JWS")] - [InlineData("not-base64url", "token is not base64url")] - [InlineData("payload-not-json", "token payload is not JSON")] - [InlineData("payload-not-object", "token payload is not an object")] - public async Task A_forged_token_signs_nobody_in(string forgery, string rule) - { - 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 claimBytes = JsonSerializer.SerializeToUtf8Bytes(claims); - 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"), - "header-array" => host.Key.SignParts(Encoding.UTF8.GetBytes("[1]"), claimBytes), - "header-not-json" => host.Key.SignParts(Encoding.UTF8.GetBytes("not json"), claimBytes), - "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" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("not json")), - "payload-not-object" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("[1]")), - _ => 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); - AssertTokenRefused(host, rule); - } - - [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", "token audience is not this app")] - [InlineData("aud-case", "token audience is not this app")] - [InlineData("aud-array", "token audience is not this app")] - [InlineData("aud-missing", "token audience is not this app")] - [InlineData("app-slug-other", "token app is not this app")] - [InlineData("app-missing", "token app is not this app")] - [InlineData("app-not-object", "token app is not this app")] - [InlineData("typ-refresh", "token type is not an access token")] - [InlineData("typ-missing", "token type is not an access token")] - [InlineData("iss-other", "token issuer is not Heimdall")] - [InlineData("iss-trailing-slash", "token issuer is not Heimdall")] - [InlineData("expired", "token has expired")] - [InlineData("exp-missing", "token has expired")] - [InlineData("exp-string", "token has expired")] - [InlineData("exp-out-of-range", "token has expired")] - [InlineData("exp-negative", "token has expired")] - [InlineData("not-yet-valid", "token is not yet valid")] - [InlineData("nbf-missing", "token is not yet valid")] - [InlineData("nbf-negative", "token is not yet valid")] - [InlineData("sub-differs", "token subject does not match the account")] - [InlineData("account-id-differs", "token subject does not match the account")] - [InlineData("account-id-missing", "token subject does not match the account")] - [InlineData("sub-empty", "token subject does not match the account")] - public async Task A_signed_token_with_a_wrong_claim_signs_nobody_in(string wrong, string rule) - { - 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 "app-not-object": claims["app"] = "bifrost"; 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 "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 "exp-negative": claims["exp"] = -1; break; - case "not-yet-valid": claims["nbf"] = now + 3600; claims["exp"] = now + 7200; break; - case "nbf-missing": claims.Remove("nbf"); break; - case "nbf-negative": claims["nbf"] = -1; 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); - AssertTokenRefused(host, rule); - } - - [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)); - AssertTokenRefused(host, "token subject does not match the account"); - } - - [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); - AssertTokenRefused(host, "token subject does not match the account"); - } - - [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.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); - Assert.Null(await host.Account(accountId)); - AssertLogged(host, "Heimdall callback did not match a pending attempt."); - } - - [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); - AssertLogged(host, "Heimdall callback did not match a pending attempt."); - 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); - AssertLogged(host, "Heimdall callback did not match a pending attempt."); - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.False(wait.SignedIn); - Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), 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.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), 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-1", identity); - store.Create("attempt-extra", "discord", "/App", false); - - Assert.Throws(() => store.Complete("attempt-0", identity)); - store.Complete("attempt-2", 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.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), 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.Equal(ErrorLocation("Heimdall did not grant Bifrost member access."), 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); - AssertTokenRefused(host, "Heimdall keys could not be fetched"); - } - - 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 Capabilities_that_are_not_a_list_of_names_grant_nothing_and_do_not_crash() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var token = host.Token(accountId, mutate: claims => claims["capabilities"] = "member_access"); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.Empty((await host.Account(accountId))!.Membership!.RoleAssignments); - } - - [Fact] - public async Task Member_access_is_found_among_other_and_non_string_capabilities() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(member: true); - var token = host.Token(accountId, mutate: claims => - claims["capabilities"] = new object?[] { 7, null, "billing_access", "member_access" }); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.Contains((await host.Account(accountId))!.Membership!.RoleAssignments, x => x.Role == MemberRole.StandardMember); - } - - [Theory] - [InlineData(0L, 900L)] - [InlineData(-1L, 253402300799L)] - public async Task Numeric_dates_at_the_ends_of_the_valid_range_are_accepted(long notBefore, long expires) - { - 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"] = notBefore < 0 ? now : notBefore; - claims["exp"] = expires == 900 ? now + 900 : expires; - }); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - } - - [Theory] - [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start.")] - [InlineData(HttpStatusCode.OK, "{}", "Heimdall auth start returned no authorization URL.")] - [InlineData(HttpStatusCode.OK, "{\"authorizationUrl\":\" \"}", "Heimdall auth start returned no authorization URL.")] - public async Task A_start_Heimdall_cannot_serve_issues_no_attempt_and_no_cookie(HttpStatusCode status, string body, string message) - { - using var host = new HeimdallHost(); - host.Heimdall.StartStatus = status; - host.Heimdall.StartBody = body; - - var attempt = await host.StartAsync(); - - Assert.Equal(ErrorLocation(message), attempt.Response.Headers.Location!.OriginalString); - Assert.Null(attempt.Binding); - var wait = await host.WaitAsync(attempt.AttemptId, "anything"); - Assert.False(wait.SignedIn); - } - - [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/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs new file mode 100644 index 0000000..853bd1f --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -0,0 +1,1109 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +/// +/// Sign-in through Heimdall's browser completion, against a local Bifrost with a test JWKS and a fake +/// Heimdall shaped like src/app.ts (start, redeem) and src/claims.ts. Forgeries differ from the genuine +/// shape in the one property under test. +/// +public sealed class HeimdallCompletionTests +{ + private const string ErrorRedirect = "/?auth=heimdall-error"; + + private static string Flip(string value) => value[..^1] + (value[^1] == '0' ? '1' : '0'); + + /// Changes one character in the middle of a protected value, which its authentication tag must notice. + private static string Corrupt(string value) => value[..20] + (value[20] == 'A' ? 'B' : 'A') + value[21..]; + + private static async Task Genuine( + HeimdallHost host, StartedAttempt attempt, string accountId, string[]? capabilities = null, string? displayName = "Genuine Name") => + await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, host.Token(accountId, capabilities, displayName))); + + private static string CodeWith( + HeimdallHost host, StartedAttempt attempt, string accountId, Action> edit, string? token = null) + { + var completion = HeimdallShapes.Completion(attempt.ReturnTo, accountId, token ?? host.Token(accountId)); + edit(completion); + return host.Heimdall.RegisterCode(completion); + } + + private static void AssertLogged(HeimdallHost host, string reason) => + Assert.Contains("Heimdall completion refused: " + reason, string.Join("\n", host.Logs.Lines)); + + private static void AssertTokenRefused(HeimdallHost host, string rule) => + AssertLogged(host, $"Heimdall token refused: {rule}."); + + private static void AssertRefusedRedirect(CompletionResult result) + { + Assert.Equal(HttpStatusCode.Redirect, result.Response.StatusCode); + Assert.Equal(ErrorRedirect, result.Response.Headers.Location!.OriginalString); + Assert.False(result.SignedIn); + } + + private static async Task AssertRefused(HeimdallHost host, string accountId, CompletionResult result) + { + AssertRefusedRedirect(result); + Assert.Null(await host.Account(accountId)); + } + + [Fact] + public async Task Genuine_completion_signs_in_once_in_the_starting_browser() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(returnUrl: "/App/Mill"); + var code = host.CodeFor(attempt, accountId); + + var done = await host.CompleteAsync(attempt, code); + + Assert.True(done.SignedIn); + Assert.Equal(HttpStatusCode.Redirect, done.Response.StatusCode); + Assert.Equal("/App/Mill", done.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(done.Response, HeimdallAttemptCookie.Prefix + attempt.Nonce); + Assert.NotNull(cleared); + Assert.Contains("1970", cleared); + + var again = await host.CompleteAsync(attempt, code); + + AssertRefusedRedirect(again); + Assert.Equal(2, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall did not redeem the completion code."); + } + + [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(); + + Assert.True((await Genuine(host, await host.StartAsync(), named, displayName: "Claimed Name")).SignedIn); + Assert.True((await Genuine(host, await host.StartAsync(), unnamed, displayName: null)).SignedIn); + + Assert.Equal("Claimed Name", (await host.Account(named))!.DisplayName); + Assert.Equal(unnamed, (await host.Account(unnamed))!.DisplayName); + } + + [Theory] + [InlineData("absent")] + [InlineData("")] + [InlineData(" ")] + public async Task A_completion_without_an_access_token_signs_nobody_in(string token) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var code = CodeWith(host, attempt, accountId, completion => + { + if (token == "absent") + { + completion.Remove("accessToken"); + } + else + { + completion["accessToken"] = token; + } + }); + + var result = await host.CompleteAsync(attempt, code); + + await AssertRefused(host, accountId, result); + AssertLogged(host, "Heimdall completion carried no access token."); + } + + [Theory] + [InlineData(HttpStatusCode.Gone, "{}", "Heimdall did not redeem the completion code.")] + [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall did not redeem the completion code.")] + [InlineData(HttpStatusCode.OK, "valid", "Heimdall did not redeem the completion code.")] + [InlineData(HttpStatusCode.Created, "not json", "Heimdall redeem could not be read.")] + [InlineData(HttpStatusCode.Created, "[1]", "Heimdall redeem could not be read.")] + [InlineData(HttpStatusCode.Created, "null", "Heimdall redeem returned no body.")] + public async Task A_redeem_that_is_not_a_201_with_a_readable_body_signs_nobody_in(HttpStatusCode status, string body, string reason) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + host.Heimdall.RedeemStatus = status; + host.Heimdall.RedeemBody = body == "valid" + ? JsonSerializer.Serialize(HeimdallShapes.Completion(attempt.ReturnTo, accountId, host.Token(accountId))) + : body; + + var result = await host.CompleteAsync(attempt, "a-code"); + + await AssertRefused(host, accountId, result); + AssertLogged(host, reason); + } + + [Theory] + [InlineData("other-key-same-kid", "token signature is invalid")] + [InlineData("other-key-other-kid", "token key is not published")] + [InlineData("tampered-payload", "token signature is invalid")] + [InlineData("alg-none", "token algorithm is not EdDSA")] + [InlineData("alg-hs256", "token algorithm is not EdDSA")] + [InlineData("header-array", "token algorithm is not EdDSA")] + [InlineData("header-not-json", "token header is not JSON")] + [InlineData("truncated-signature", "token signature is invalid")] + [InlineData("no-kid", "token has no key id")] + [InlineData("unpublished-kid", "token key is not published")] + [InlineData("four-segments", "token is not a compact JWS")] + [InlineData("two-segments", "token is not a compact JWS")] + [InlineData("not-base64url", "token is not base64url")] + [InlineData("payload-not-json", "token payload is not JSON")] + [InlineData("payload-not-object", "token payload is not an object")] + public async Task A_forged_token_signs_nobody_in(string forgery, string rule) + { + 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 claimBytes = JsonSerializer.SerializeToUtf8Bytes(claims); + 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"), + "header-array" => host.Key.SignParts(Encoding.UTF8.GetBytes("[1]"), claimBytes), + "header-not-json" => host.Key.SignParts(Encoding.UTF8.GetBytes("not json"), claimBytes), + "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" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("not json")), + "payload-not-object" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("[1]")), + _ => throw new InvalidOperationException(forgery) + }; + Assert.NotEqual(genuine, token); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, token)); + + await AssertRefused(host, accountId, result); + AssertTokenRefused(host, rule); + } + + [Fact] + public async Task A_forged_token_cannot_sign_in_as_an_existing_account() + { + using var host = new HeimdallHost(); + var victim = HeimdallShapes.NewAccountId(); + Assert.True((await Genuine(host, await host.StartAsync(), victim, displayName: "Victim Name")).SignedIn); + + var attempt = await host.StartAsync(); + var forged = HeimdallKey.Generate(host.Key.Kid).Sign(HeimdallShapes.AccessClaims(victim, host.Clock.Now, displayName: "Mallory")); + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, victim, forged)); + + AssertRefusedRedirect(result); + Assert.Equal("Victim Name", (await host.Account(victim))!.DisplayName); + } + + [Theory] + [InlineData("aud-other-app", "token audience is not this app")] + [InlineData("aud-case", "token audience is not this app")] + [InlineData("aud-array", "token audience is not this app")] + [InlineData("aud-missing", "token audience is not this app")] + [InlineData("app-slug-other", "token app is not this app")] + [InlineData("app-missing", "token app is not this app")] + [InlineData("app-not-object", "token app is not this app")] + [InlineData("typ-refresh", "token type is not an access token")] + [InlineData("typ-missing", "token type is not an access token")] + [InlineData("iss-other", "token issuer is not Heimdall")] + [InlineData("iss-trailing-slash", "token issuer is not Heimdall")] + [InlineData("expired", "token has expired")] + [InlineData("exp-missing", "token has expired")] + [InlineData("exp-string", "token has expired")] + [InlineData("exp-out-of-range", "token has expired")] + [InlineData("exp-negative", "token has expired")] + [InlineData("not-yet-valid", "token is not yet valid")] + [InlineData("nbf-missing", "token is not yet valid")] + [InlineData("nbf-negative", "token is not yet valid")] + [InlineData("sub-differs", "token subject does not match the account")] + [InlineData("account-id-differs", "token subject does not match the account")] + [InlineData("account-id-missing", "token subject does not match the account")] + [InlineData("sub-empty", "token subject does not match the account")] + public async Task A_signed_token_with_a_wrong_claim_signs_nobody_in(string wrong, string rule) + { + 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 "app-not-object": claims["app"] = "bifrost"; 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 "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 "exp-negative": claims["exp"] = -1; break; + case "not-yet-valid": claims["nbf"] = now + 3600; claims["exp"] = now + 7200; break; + case "nbf-missing": claims.Remove("nbf"); break; + case "nbf-negative": claims["nbf"] = -1; 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); + } + }); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, token)); + + await AssertRefused(host, accountId, result); + AssertTokenRefused(host, rule); + } + + [Fact] + public async Task A_token_for_a_different_account_than_the_redeemed_one_is_refused_by_one_character() + { + using var host = new HeimdallHost(); + var tokenAccount = HeimdallShapes.NewAccountId(); + var redeemedAccount = Flip(tokenAccount); + var attempt = await host.StartAsync(); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, redeemedAccount, host.Token(tokenAccount))); + + AssertRefusedRedirect(result); + Assert.Null(await host.Account(tokenAccount)); + Assert.Null(await host.Account(redeemedAccount)); + AssertTokenRefused(host, "token subject does not match the account"); + } + + [Fact] + public async Task A_redeem_response_with_no_account_is_refused() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var code = CodeWith(host, attempt, accountId, completion => completion["account"] = null); + + var result = await host.CompleteAsync(attempt, code); + + await AssertRefused(host, accountId, result); + AssertTokenRefused(host, "token subject does not match the account"); + } + + [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; + }); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, token)); + + Assert.Equal(accepted, result.SignedIn); + } + + [Fact] + public async Task A_consent_link_completed_in_another_browser_signs_nobody_in() + { + using var host = new HeimdallHost(); + var victim = HeimdallShapes.NewAccountId(); + var attacker = await host.StartAsync(); + var victimsCode = host.CodeFor(attacker, victim); + + var swap = await host.CompleteAsync(attacker.Nonce, victimsCode, null); + + AssertRefusedRedirect(swap); + Assert.Null(await host.Account(victim)); + Assert.Equal(0, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall completion has no attempt for this browser."); + + var guess = await host.CompleteAsync(attacker.Nonce, "0123456789abcdef0123456789abcdef", attacker.Cookie); + + AssertRefusedRedirect(guess); + Assert.Equal(1, host.Heimdall.RedeemCalls); + Assert.Null(await host.Account(victim)); + } + + [Fact] + public async Task An_attackers_code_in_a_victims_attempt_signs_nobody_in() + { + using var host = new HeimdallHost(); + var attackerAccount = HeimdallShapes.NewAccountId(); + var victimAttempt = await host.StartAsync(); + var attackerAttempt = await host.StartAsync(); + var attackersCode = host.CodeFor(attackerAttempt, attackerAccount); + + var forced = await host.CompleteAsync(victimAttempt.Nonce, attackersCode, victimAttempt.Cookie); + + AssertRefusedRedirect(forced); + Assert.Null(await host.Account(attackerAccount)); + Assert.Equal(1, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall completion belongs to another attempt."); + + var crossed = await host.CompleteAsync(attackerAttempt.Nonce, attackersCode, victimAttempt.Cookie); + + AssertRefusedRedirect(crossed); + Assert.Equal(1, host.Heimdall.RedeemCalls); + } + + [Theory] + [InlineData("nonce-flipped")] + [InlineData("trailing-slash")] + [InlineData("other-host")] + [InlineData("other-scheme")] + [InlineData("other-path")] + [InlineData("extra-query")] + [InlineData("empty")] + public async Task A_redeemed_return_to_that_differs_by_one_character_is_refused(string difference) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var returnTo = difference switch + { + "nonce-flipped" => Flip(attempt.ReturnTo), + "trailing-slash" => attempt.ReturnTo + "/", + "other-host" => attempt.ReturnTo.Replace("https://localhost", "https://localhost.evil.example"), + "other-scheme" => attempt.ReturnTo.Replace("https://", "http://"), + "other-path" => attempt.ReturnTo.Replace("/complete", "/completed"), + "extra-query" => attempt.ReturnTo + "&x=1", + _ => string.Empty + }; + Assert.NotEqual(attempt.ReturnTo, returnTo); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, returnTo: returnTo)); + + await AssertRefused(host, accountId, result); + Assert.Equal(1, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall completion belongs to another attempt."); + } + + [Fact] + public async Task An_attempt_cookie_that_is_tampered_renamed_or_expired_is_no_attempt() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var other = await host.StartAsync(); + var onTime = await host.StartAsync(); + var atExpiry = await host.StartAsync(); + var afterExpiry = await host.StartAsync(); + var code = host.CodeFor(attempt, accountId); + var wrong = new Dictionary + { + ["empty cookie"] = string.Empty, + ["another attempt's cookie under this name"] = other.Cookie, + ["a corrupted byte"] = Corrupt(attempt.Cookie!), + ["a truncated value"] = attempt.Cookie![..^8], + ["a value with a suffix"] = attempt.Cookie + "A", + ["the nonce as the value"] = attempt.Nonce, + ["a same-shape random value"] = WebEncoders.Base64UrlEncode(new byte[attempt.Cookie.Length * 3 / 4]) + }; + + foreach (var (name, cookie) in wrong) + { + var refused = await host.CompleteAsync(attempt.Nonce, code, cookie); + AssertRefusedRedirect(refused); + Assert.True(refused.Response.Headers.Contains("Set-Cookie"), name); + } + + Assert.Equal(0, host.Heimdall.RedeemCalls); + Assert.Null(await host.Account(accountId)); + + host.Clock.Advance(HeimdallAttemptCookie.Lifetime - TimeSpan.FromSeconds(1)); + var accepted = await host.CompleteAsync(onTime, host.CodeFor(onTime, accountId)); + Assert.True(accepted.SignedIn); + Assert.Equal(1, host.Heimdall.RedeemCalls); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + var expiring = await host.CompleteAsync(atExpiry, host.CodeFor(atExpiry, accountId)); + AssertRefusedRedirect(expiring); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + var expired = await host.CompleteAsync(afterExpiry, host.CodeFor(afterExpiry, accountId)); + AssertRefusedRedirect(expired); + Assert.Equal(1, host.Heimdall.RedeemCalls); + } + + [Fact] + public async Task Anonymous_starts_cannot_displace_a_pending_sign_in() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var genuine = await host.StartAsync(); + + using (var client = host.NewClient()) + { + for (var i = 0; i < 5000; i++) + { + using var response = await client.GetAsync("/auth/heimdall/discord"); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + } + } + + Assert.True((await Genuine(host, genuine, accountId)).SignedIn); + } + + [Fact] + public async Task Two_attempts_in_one_browser_both_complete() + { + using var host = new HeimdallHost(); + var first = HeimdallShapes.NewAccountId(); + var second = HeimdallShapes.NewAccountId(); + var attemptOne = await host.StartAsync(returnUrl: "/App/One"); + var attemptTwo = await host.StartAsync(returnUrl: "/App/Two"); + + var doneTwo = await Genuine(host, attemptTwo, second); + var doneOne = await Genuine(host, attemptOne, first); + + Assert.True(doneTwo.SignedIn); + Assert.Equal("/App/Two", doneTwo.Response.Headers.Location!.OriginalString); + Assert.True(doneOne.SignedIn); + Assert.Equal("/App/One", doneOne.Response.Headers.Location!.OriginalString); + } + + [Fact] + public async Task Start_sets_a_private_per_attempt_cookie_that_reveals_nothing() + { + using var host = new HeimdallHost(); + var first = await host.StartAsync(returnUrl: "/App/Mill"); + var second = await host.StartAsync(returnUrl: "/App/Mill"); + + Assert.Equal(HttpStatusCode.Redirect, first.Response.StatusCode); + Assert.Equal("https://heimdall.test/oauth/discord/authorize?state=fixture", first.Response.Headers.Location!.OriginalString); + Assert.Equal(43, first.Nonce.Length); + Assert.NotEqual(first.Nonce, second.Nonce); + var header = HeimdallHost.SetCookieHeader(first.Response, "bifrost_heimdall_" + first.Nonce); + 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.NotEqual(first.Cookie, second.Cookie); + Assert.DoesNotContain(first.Nonce, first.Cookie!); + Assert.DoesNotContain("Mill", first.Cookie!); + Assert.DoesNotContain("Mill", Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(first.Cookie!))); + + using var body = JsonDocument.Parse(host.Heimdall.StartBodies[0]); + var handoff = body.RootElement.GetProperty("handoff"); + Assert.Equal("browser_completion", handoff.GetProperty("kind").GetString()); + Assert.Single(handoff.EnumerateObject()); + Assert.Equal($"https://localhost/auth/heimdall/complete?attempt={first.Nonce}", body.RootElement.GetProperty("returnTo").GetString()); + Assert.Equal("bifrost", body.RootElement.GetProperty("appSlug").GetString()); + Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString()); + Assert.False(body.RootElement.TryGetProperty("entitlementPolicy", out _)); + } + + [Fact] + public async Task The_complete_page_holds_no_code_and_leaks_no_referrer() + { + using var host = new HeimdallHost(); + using var client = host.NewClient(); + + using var response = await client.GetAsync("/auth/heimdall/complete?attempt=CANARYATTEMPT&code=CANARYCODE"); + var page = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType!.ToString()); + Assert.Equal("no-store", response.Headers.CacheControl!.ToString()); + Assert.Equal("no-referrer", Assert.Single(response.Headers.GetValues("Referrer-Policy"))); + Assert.Contains("location.hash", page); + Assert.Contains("heimdall_completion_code", page); + Assert.Contains("history.replaceState", page); + Assert.Contains("form.submit()", page); + Assert.Contains("method=\"post\" action=\"/auth/heimdall/complete\"", page); + Assert.Contains("