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 = "") => 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) { header["kid"] = kid.Length == 0 ? Kid : kid; } 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); 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 body Heimdall's redeem route answers with: the completionPayload of src/app.ts, whose unsigned /// fields (loose capabilities, facts, name) Bifrost must not decide anything from. /// public static Dictionary Completion( string returnTo, string accountId, string accessToken, string[]? looseCapabilities = null, string[]? looseFacts = null, string looseName = "Loose Name") => new() { ["status"] = "success", ["provider"] = "discord", ["mode"] = "sign_in", ["appSlug"] = "bifrost", ["account"] = new Dictionary { ["id"] = accountId, ["displayName"] = looseName }, ["entitlements"] = new Dictionary { ["facts"] = looseFacts ?? [], ["snapshots"] = Array.Empty() }, ["returnTo"] = returnTo, ["connection"] = null, ["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, the oauth start endpoint (which, like app.ts, answers /// a policy start only when the app caller's secret is present) and the completion-code redeem route. /// public sealed class FakeHeimdall : HttpMessageHandler { private readonly Dictionary> _codes = new(StringComparer.Ordinal); public List Published { get; } = []; public List ExtraJwks { get; } = []; public int JwksFetches { get; private set; } public HttpStatusCode JwksStatus { get; set; } = HttpStatusCode.OK; private TaskCompletionSource? _jwksHold; /// Completes when a JWKS request arrives after . public TaskCompletionSource JwksArrived { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); /// Holds the next JWKS responses until the returned source completes. public TaskCompletionSource HoldJwks() { JwksArrived = new(TaskCreationOptions.RunContinuationsAsynchronously); return _jwksHold = new(TaskCreationOptions.RunContinuationsAsynchronously); } public Exception? JwksFailure { get; set; } public List StartBodies { get; } = []; public List StartSecrets { get; } = []; /// The secret a policy start must carry in x-heimdall-app-secret; null means Heimdall asks for none. public string? RequiredAppSecret { get; set; } public HttpStatusCode StartStatus { get; set; } = HttpStatusCode.OK; public string StartBody { get; set; } = """{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}"""; public int RedeemCalls { get; private set; } public HttpStatusCode? RedeemStatus { get; set; } public string? RedeemBody { get; set; } /// Registers a completion code Heimdall will redeem once, answering with this payload. public string RegisterCode(Dictionary completion) { var code = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); _codes[code] = completion; return code; } 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++; JwksArrived.TrySetResult(); if (_jwksHold is not null) { await _jwksHold.Task.WaitAsync(cancellationToken); } if (JwksFailure is not null) { throw JwksFailure; } 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)) { var body = await request.Content!.ReadAsStringAsync(cancellationToken); var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null; StartBodies.Add(body); StartSecrets.Add(secret); var wantsPolicy = JsonDocument.Parse(body).RootElement.TryGetProperty("entitlementPolicy", out _); if (wantsPolicy && RequiredAppSecret is not null && secret != RequiredAppSecret) { return new HttpResponseMessage(HttpStatusCode.Unauthorized) { Content = new StringContent("""{"error":"app_auth_required"}""", Encoding.UTF8, "application/json") }; } return new HttpResponseMessage(StartStatus) { Content = new StringContent(StartBody, Encoding.UTF8, "application/json") }; } if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/auth-completions/redeem") { RedeemCalls++; if (RedeemStatus is { } status) { return new HttpResponseMessage(status) { Content = new StringContent(RedeemBody ?? "{}", Encoding.UTF8, "application/json") }; } var code = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)) .RootElement.GetProperty("completionCode").GetString()!; if (!_codes.Remove(code, out var completion)) { return new HttpResponseMessage(HttpStatusCode.Gone) { Content = new StringContent("""{"error":"invalid_or_expired_completion_code"}""", Encoding.UTF8, "application/json") }; } return new HttpResponseMessage(HttpStatusCode.Created) { Content = new StringContent(RedeemBody ?? JsonSerializer.Serialize(completion), 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)); } } } } /// An attempt Start issued: its nonce, the cookie it set, and the returnTo it sent Heimdall. public sealed record StartedAttempt(string Nonce, string? CookieName, string? Cookie, string ReturnTo, HttpResponseMessage Response); public sealed record CompletionResult(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 AppSecret = "test-heimdall-app-secret"; private readonly string _appSecret; private readonly string _appSlug; private readonly string _baseUrl; private readonly string _publicBaseUrl; private readonly bool _discordRoles; public TestClock Clock { get; } = new(); public FakeHeimdall Heimdall { get; } = new(); public CapturedLogs Logs { get; } = new(); public HeimdallKey Key { get; } = HeimdallKey.Generate(); public HeimdallHost( string appSecret = AppSecret, string baseUrl = HeimdallShapes.Issuer, string publicBaseUrl = "https://localhost", bool discordRoles = true, string appSlug = "bifrost") { _appSecret = appSecret; _appSlug = appSlug; _baseUrl = baseUrl; _publicBaseUrl = publicBaseUrl; _discordRoles = discordRoles; Heimdall.Published.Add(Key); Heimdall.RequiredAppSecret = AppSecret; } protected override void ConfigureWebHost(IWebHostBuilder builder) { base.ConfigureWebHost(builder); // The GitHub OAuth scheme is registered from configuration read while the app builds, which only host settings reach. builder.UseSetting("GitHubOAuth:ClientId", "configured-for-tests"); builder.UseSetting("GitHubOAuth:ClientSecret", "configured-for-tests"); builder.ConfigureAppConfiguration((_, configuration) => { configuration.AddInMemoryCollection(new Dictionary { ["Heimdall:BaseUrl"] = _baseUrl, ["Heimdall:AppSecret"] = _appSecret, ["Heimdall:AppSlug"] = _appSlug, ["Heimdall:DiscordGuildId"] = "guild-1", ["Host:PublicBaseUrl"] = _publicBaseUrl }); if (_discordRoles) { configuration.AddInMemoryCollection(new Dictionary { ["Heimdall:DiscordAllowedRoleIds:0"] = "role-1" }); } }); builder.ConfigureLogging(logging => logging.AddProvider(Logs)); builder.ConfigureServices(services => { services.RemoveAll(); services.AddSingleton(Clock); services.AddHttpClient(HeimdallTokenVerifier.ClientName).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 Task StartAsync( bool member = false, string provider = "discord", string returnUrl = "/App/Mill", string? cookieHeader = null) => StartAtAsync( $"/auth/heimdall/{provider}?returnUrl={Uri.EscapeDataString(returnUrl)}" + (member ? "&access=member" : string.Empty), cookieHeader); public async Task StartAtAsync(string url, string? cookieHeader = null) { using var client = NewClient(); var before = Heimdall.StartBodies.Count; using var request = new HttpRequestMessage(HttpMethod.Get, url); if (cookieHeader is not null) { request.Headers.Add("Cookie", cookieHeader); } var response = await client.SendAsync(request); if (Heimdall.StartBodies.Count == before) { return new StartedAttempt(string.Empty, null, null, string.Empty, response); } var returnTo = JsonDocument.Parse(Heimdall.StartBodies[^1]).RootElement.GetProperty("returnTo").GetString()!; var nonce = QueryHelpers.ParseQuery(new Uri(returnTo).Query)["attempt"].ToString(); var slot = HeimdallAttemptCookie.SlotNames.FirstOrDefault(x => CookieValue(response, x) is { Length: > 0 }); return new StartedAttempt(nonce, slot, slot is null ? null : CookieValue(response, slot), returnTo, response); } /// Registers a completion code as Heimdall would mint it after authenticating this attempt. public string CodeFor(StartedAttempt attempt, string accountId, string? accessToken = null, string[]? looseCapabilities = null, string[]? looseFacts = null, string looseName = "Loose Name", string? returnTo = null) { var completion = HeimdallShapes.Completion( returnTo ?? attempt.ReturnTo, accountId, accessToken ?? Token(accountId), looseCapabilities, looseFacts, looseName); return Heimdall.RegisterCode(completion); } /// The browser's POST from the complete page: the attempt and code in a form, the attempt cookie by name. public Task CompleteAsync(string? nonce, string? code, string? cookie, string? cookieName = null) => CompleteWithHeaderAsync( nonce, code, cookie is null ? null : $"{cookieName ?? HeimdallAttemptCookie.SlotNames[0]}={cookie}"); /// The same POST carrying a browser's whole Cookie header (a jar), not one attempt cookie. public async Task CompleteWithHeaderAsync(string? nonce, string? code, string? cookieHeader) { using var client = NewClient(); var fields = new Dictionary(); if (nonce is not null) { fields["attempt"] = nonce; } if (code is not null) { fields["code"] = code; } using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/heimdall/complete") { Content = new FormUrlEncodedContent(fields) }; if (cookieHeader is not null) { request.Headers.Add("Cookie", cookieHeader); } var response = await client.SendAsync(request); var session = CookieValue(response, ".AspNetCore.Cookies"); return new CompletionResult(response, !string.IsNullOrEmpty(session)); } public Task CompleteAsync(StartedAttempt attempt, string code) => CompleteAsync(attempt.Nonce, code, attempt.Cookie, attempt.CookieName); 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()); }