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