using Bifrost.Web.Features.Shared; 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", string mode = "sign_in", string provider = "discord") => new() { ["status"] = "success", ["provider"] = provider, ["mode"] = mode, ["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; } /// The account model and route of POST /v1/apps/bifrost/identity-moves (the host's clock is ). public FakeMoves Moves { get; } public FakeHeimdall() => Moves = new FakeMoves(() => Now()); public List StartBodies { get; } = []; public List StartSecrets { get; } = []; /// The path of every start, so a google link start can be told from a sign-in start. public List StartPaths { get; } = []; /// The Authorization header of every start (null when absent). public List StartAuthorizations { get; } = []; public List ConfirmBodies { get; } = []; public List ConfirmAuthorizations { get; } = []; /// When set, every links/confirm answers this status with Heimdall's error code for it. public HttpStatusCode? ConfirmStatus { get; set; } /// When set with , the body of that answer verbatim (empty string included). public string? ConfirmBody { get; set; } private readonly HashSet _pendingLinks = new(StringComparer.Ordinal); /// The logins the fake account holds, as GET /v1/me/identities answers them (with the fields Bifrost must never keep). public List> Identities { get; } = []; /// Adds a login to the fake account and returns its id. public string AddIdentity(string provider, string? displayName = "A Name", string? id = null) { id ??= Guid.NewGuid().ToString(); Identities.Add(new Dictionary { ["id"] = id, ["provider"] = provider, ["providerUserId"] = "PUID-CANARY-" + id[..8], ["username"] = "username-canary-" + id[..8], ["displayName"] = displayName, ["linkedAt"] = "2026-11-13T12:00:00.000Z", ["scopes"] = new[] { "scope-canary" } }); return id; } public List ListAuthorizations { get; } = []; /// The clock the fake judges a step-up proof's age by (the host sets it to its test clock). public Func Now { get; set; } = () => DateTimeOffset.UtcNow; /// The returnTo of every start that asked for a step-up and passed Heimdall's checks: its completion carries the proof. public HashSet StepUpReturnTos { get; } = new(StringComparer.Ordinal); /// /// Discord or Patreon sign-ins that came through a provider the fake account (once it holds any login) does not hold. Heimdall mints an /// unrelated account for such a sign-in, which would then hold that login. Google never mints. /// public List OrphanMints { get; } = []; /// When set, GET /v1/me/identities answers this status with an error body. public HttpStatusCode? ListStatus { get; set; } /// The Authorization header and identity id of every unlink. public List<(string? Authorization, string Id)> Unlinks { get; } = []; /// When set, every unlink answers this status with Heimdall's error code for it (the fake's own rules are bypassed). public HttpStatusCode? UnlinkStatus { get; set; } /// When set, an unlink is applied and then answered with this status (200 answers a body that is not JSON): a lost answer. public HttpStatusCode? UnlinkApplyThenFail { get; set; } /// Every step-up assertion that reached the route: the app secret header, the Authorization header and the raw body. public List<(string? Secret, string? Authorization, string Body)> StepUps { get; } = []; /// When set, the step-up route refuses with this status and the contract's error code for it, after the schema and secret checks. public HttpStatusCode? StepUpStatus { get; set; } /// When true the step-up route throws, as an unreachable Heimdall does. public bool StepUpDown { get; set; } private readonly HashSet _stepUpNonces = new(StringComparer.Ordinal); private static readonly System.Text.RegularExpressions.Regex NonceShape = new("^[A-Za-z0-9_-]{16,128}$"); /// /// POST /v1/apps/bifrost/step-up as Heimdall's service-contract.md describes it: schema (method 1..64, nonce pattern) before the /// secret; then the app secret; then the bearer (live, for the same app); then the method set; then single-use per session and /// nonce; a 201 token that copies the bearer's claims and adds step_up_at and step_up_method. /// private HttpResponseMessage StepUpRoute(HttpRequestMessage request, string body) { static HttpResponseMessage Json(HttpStatusCode status, string json) => new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; static HttpResponseMessage Refuse(HttpStatusCode status, string error) => Json(status, $$"""{"error":"{{error}}"}"""); var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null; var authorization = request.Headers.Authorization?.ToString(); StepUps.Add((secret, authorization, body)); if (StepUpDown) { throw new HttpRequestException("Heimdall is down"); } string? method = null; string? nonce = null; try { var root = JsonDocument.Parse(body).RootElement; method = root.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null; nonce = root.TryGetProperty("nonce", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() : null; } catch (JsonException) { } if (method is not { Length: >= 1 and <= 64 } || nonce is null || !NonceShape.IsMatch(nonce)) { return Json(HttpStatusCode.BadRequest, """{"statusCode":400,"error":"Bad Request","message":"body does not match the schema"}"""); } if (secret != RequiredAppSecret) { return Refuse(HttpStatusCode.Unauthorized, "app_auth_required"); } if (StepUpStatus is { } forced) { return forced switch { HttpStatusCode.Forbidden => Refuse(forced, "step_up_assertion_not_permitted"), HttpStatusCode.TooManyRequests => Refuse(forced, "step_up_rate_limited"), HttpStatusCode.Unauthorized => Refuse(forced, "sign_in_required"), HttpStatusCode.Conflict => Refuse(forced, "step_up_nonce_replayed"), _ => Json(forced, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}""") }; } Dictionary? claims = null; try { var token = authorization is not null && authorization.StartsWith("Bearer ", StringComparison.Ordinal) ? authorization["Bearer ".Length..] : null; var parts = token?.Split('.'); if (parts is { Length: 3 }) { claims = JsonSerializer.Deserialize>(WebEncoders.Base64UrlDecode(parts[1])); } } catch (Exception error) when (error is JsonException or FormatException) { } if (claims is null || !claims.TryGetValue("exp", out var exp) || exp.GetInt64() <= Now().ToUnixTimeSeconds() || !claims.TryGetValue("sid", out var sid)) { return Refuse(HttpStatusCode.Unauthorized, "sign_in_required"); } if (claims["app"].GetProperty("slug").GetString() != "bifrost") { return Refuse(HttpStatusCode.Forbidden, "bearer_app_mismatch"); } if (method is not ("bifrost-passkey" or "bifrost-email-code")) { return Refuse(HttpStatusCode.BadRequest, "unknown_step_up_method"); } if (!_stepUpNonces.Add(sid.GetString() + ":" + nonce)) { return Refuse(HttpStatusCode.Conflict, "step_up_nonce_replayed"); } var minted = claims.ToDictionary(x => x.Key, x => (object?)x.Value); minted["jti"] = Guid.NewGuid().ToString(); minted["step_up_at"] = Now().ToUnixTimeSeconds(); minted["step_up_method"] = method; return Json( HttpStatusCode.Created, JsonSerializer.Serialize(new { accessToken = Published[0].Sign(minted), claimSet = new { }, stepUp = new { at = Now().ToUnixTimeSeconds(), method, validForSeconds = 600 } })); } /// Throws instead of answering a member route, as an unreachable Heimdall does. public Exception? MemberFailure { get; set; } private HttpResponseMessage MemberRoute(HttpRequestMessage request, string path) { static HttpResponseMessage Json(HttpStatusCode status, string json) => new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; if (MemberFailure is not null) { throw MemberFailure; } var authorization = request.Headers.Authorization?.ToString(); object Listing() => new { accountId = "a", identities = Identities }; if (request.Method == HttpMethod.Get && path == "/v1/me/identities") { ListAuthorizations.Add(authorization); if (authorization is null) { return Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""); } return ListStatus switch { HttpStatusCode.Unauthorized => Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""), { } listed => Json(listed, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}"""), _ => Json(HttpStatusCode.OK, JsonSerializer.Serialize(Listing())) }; } var segments = path.Split('/'); if (request.Method == HttpMethod.Post && segments is ["", "v1", "me", "identities", var id, "unlink"]) { Unlinks.Add((authorization, id)); if (authorization is null) { return Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""); } if (UnlinkApplyThenFail is { } lost) { // The unlink happened; the answer did not arrive intact. Identities.RemoveAll(x => (string?)x["id"] == id); return Json(lost, lost == HttpStatusCode.OK ? "not json" : """{"error":"server_error"}"""); } if (UnlinkStatus is { } forced) { return forced switch { HttpStatusCode.Unauthorized => Json(forced, """{"error":"sign_in_required"}"""), HttpStatusCode.Forbidden => Json(forced, """{"error":"step_up_required"}"""), HttpStatusCode.NotFound => Json(forced, """{"error":"identity_not_found"}"""), HttpStatusCode.Conflict => Json(forced, """{"error":"last_identity"}"""), _ => Json(forced, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}""") }; } // Heimdall's rule: the bearer must carry a step_up_at claim from the last 600 seconds, and not from the future. var payload = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(authorization["Bearer ".Length..].Split('.')[1])); using var claims = JsonDocument.Parse(payload); var fresh = claims.RootElement.TryGetProperty("step_up_at", out var at) && at.TryGetInt64(out var seconds) && seconds <= Now().ToUnixTimeSeconds() && Now().ToUnixTimeSeconds() - seconds <= 600; if (!fresh) { return Json(HttpStatusCode.Forbidden, """{"error":"step_up_required"}"""); } var held = Identities.FirstOrDefault(x => (string?)x["id"] == id); if (held is null) { return Json(HttpStatusCode.NotFound, """{"error":"identity_not_found"}"""); } if (Identities.Count == 1) { return Json(HttpStatusCode.Conflict, """{"error":"last_identity"}"""); } Identities.Remove(held); return Json( HttpStatusCode.OK, JsonSerializer.Serialize(new { unlinked = new { id, provider = held["provider"], providerUserId = held["providerUserId"] }, identities = Identities })); } return new HttpResponseMessage(HttpStatusCode.NotFound); } /// Registers a pending link Heimdall will confirm once for any bearer. public string RegisterPendingLink(string? id = null) { id ??= "pending-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant(); _pendingLinks.Add(id); return id; } /// 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; /// A raw Content-Type header for the start answer (for example a charset .NET cannot decode); null means application/json. public string? StartContentType { get; set; } 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; var authorization = request.Headers.Authorization?.ToString(); StartBodies.Add(body); StartSecrets.Add(secret); StartPaths.Add(path); StartAuthorizations.Add(authorization); var root = JsonDocument.Parse(body).RootElement; static HttpResponseMessage Refuse(HttpStatusCode status, string error) => new(status) { Content = new StringContent($$"""{"error":"{{error}}"}""", Encoding.UTF8, "application/json") }; var startProvider = path.Split('/')[3]; if (root.TryGetProperty("stepUp", out var stepUp) && stepUp.ValueKind == JsonValueKind.True) { // Heimdall's step-up rules (service-contract.md, "Step-up"): the app's request, sign_in only, a provider that can re-authenticate. if (secret != RequiredAppSecret) { return Refuse(HttpStatusCode.Unauthorized, "app_auth_required"); } if (root.TryGetProperty("mode", out var stepMode) && stepMode.GetString() != "sign_in") { return Refuse(HttpStatusCode.BadRequest, "step_up_only_in_sign_in"); } if (startProvider is not ("google" or "twitch" or "youtube")) { return Refuse(HttpStatusCode.BadRequest, "step_up_not_supported"); } } if (root.TryGetProperty("mode", out var mode) && mode.GetString() == "link") { if (authorization is null) { return new HttpResponseMessage(HttpStatusCode.Unauthorized) { Content = new StringContent("""{"error":"link_session_required"}""", Encoding.UTF8, "application/json") }; } var linkProvider = path.Split('/')[3]; return new HttpResponseMessage(HttpStatusCode.Created) { Content = new StringContent( $$"""{"authorizationUrl":"https://heimdall.test/oauth/{{linkProvider}}/authorize?state=link-fixture"}""", Encoding.UTF8, "application/json") }; } // Discord and Patreon mint an account for an identity no account holds; Google is requires_link and refuses (identity_not_linked). if (startProvider != "google" && Identities.Count > 0 && Identities.All(x => (string?)x["provider"] != startProvider)) { OrphanMints.Add(startProvider); } if (StartStatus == HttpStatusCode.OK && root.TryGetProperty("stepUp", out var asked) && asked.ValueKind == JsonValueKind.True) { StepUpReturnTos.Add(root.GetProperty("returnTo").GetString()!); } 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") }; } var content = new ByteArrayContent(Encoding.UTF8.GetBytes(StartBody)); content.Headers.TryAddWithoutValidation("Content-Type", StartContentType ?? "application/json"); return new HttpResponseMessage(StartStatus) { Content = content }; } if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/step-up") { return StepUpRoute(request, await request.Content!.ReadAsStringAsync(cancellationToken)); } if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/identity-moves") { return Moves.Route(request, await request.Content!.ReadAsStringAsync(cancellationToken), RequiredAppSecret); } if (path.StartsWith("/v1/me/identities", StringComparison.Ordinal)) { return MemberRoute(request, path); } if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/links/confirm") { var body = await request.Content!.ReadAsStringAsync(cancellationToken); var authorization = request.Headers.Authorization?.ToString(); ConfirmBodies.Add(body); ConfirmAuthorizations.Add(authorization); static HttpResponseMessage Json(HttpStatusCode status, string json) => new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; if (authorization is null) { return Json(HttpStatusCode.Unauthorized, """{"error":"invalid_link_session"}"""); } if (ConfirmStatus is { } forced) { if (ConfirmBody is not null) { return Json(forced, ConfirmBody); } return forced switch { HttpStatusCode.Conflict => Json(forced, """{"error":"identity_linked_elsewhere"}"""), HttpStatusCode.Gone => Json(forced, """{"error":"invalid_or_expired_pending_link"}"""), _ => Json(forced, """{"error":"invalid_link_session","detail":"HEIMDALL FREE TEXT"}""") }; } var id = JsonDocument.Parse(body).RootElement.GetProperty("pendingLinkId").GetString()!; return _pendingLinks.Remove(id) ? Json(HttpStatusCode.Created, """{"status":"linked","provider":"google","accountId":"a","returnTo":"https://localhost/auth/heimdall/complete"}""") : Json(HttpStatusCode.Gone, """{"error":"invalid_or_expired_pending_link"}"""); } 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; private readonly bool _traceHttp; private readonly bool _realSessions; 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", bool traceHttp = false, bool realSessions = false) { _traceHttp = traceHttp; _realSessions = realSessions; _appSecret = appSecret; _appSlug = appSlug; _baseUrl = baseUrl; _publicBaseUrl = publicBaseUrl; _discordRoles = discordRoles; Heimdall.Published.Add(Key); Heimdall.Now = () => Clock.Now; 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); if (_traceHttp) { // The framework's HttpClient logging writes request headers at Trace; it must never write a bearer there. logging.AddFilter("System.Net.Http", LogLevel.Trace); } }); builder.ConfigureServices(services => { if (_realSessions) { // The base factory authenticates every request as a test admin; a link needs the session cookie to be the identity. services.Configure(options => { options.DefaultAuthenticateScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; }); } services.RemoveAll(); services.AddSingleton(Clock); // The merge sweeper ticks on the wall clock; a host on a manual clock drives the saga itself (AccountMergeSagaTests runs the sweeper's own loop). foreach (var sweeper in services.Where(x => x.ImplementationType == typeof(Bifrost.Web.Features.Membership.AccountMergeSweeper)).ToList()) { services.Remove(sweeper); } services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall); services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); 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 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); } return AttemptFrom(await client.SendAsync(request), before); } /// The attempt a response started at Heimdall, if the start count moved since . public StartedAttempt AttemptFrom(HttpResponseMessage response, int before) { 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, string mode = "sign_in", string provider = "discord", bool? stepUpRequested = null) { // A completion of a start that asked for a step-up carries the proof the way Heimdall's does: step_up_at in the token and stepUpRequested. var asked = Heimdall.StepUpReturnTos.Contains(returnTo ?? attempt.ReturnTo); var token = accessToken ?? (asked ? Token(accountId, mutate: claims => claims["step_up_at"] = Clock.Now.ToUnixTimeSeconds()) : Token(accountId)); var completion = HeimdallShapes.Completion( returnTo ?? attempt.ReturnTo, accountId, token, looseCapabilities, looseFacts, looseName, mode, provider); if (stepUpRequested ?? asked) { completion["stepUpRequested"] = true; } 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, BifrostCookies.Session); return new CompletionResult(response, !string.IsNullOrEmpty(session)); } /// A browser session: a normal Heimdall sign-in, answered with the session cookie it earned. private readonly Dictionary _sessionAccounts = new(StringComparer.Ordinal); public async Task SignInSessionAsync(string accountId, string provider = "discord") { var attempt = await StartAsync(provider: provider); var done = await CompleteAsync(attempt, CodeFor(attempt, accountId)); var session = CookieValue(done.Response, BifrostCookies.Session) ?? throw new InvalidOperationException("sign-in set no session"); _sessionAccounts[session] = accountId; return session; } /// The raw POST of the complete page, whatever fields and cookies the caller names. public async Task PostCompleteAsync(IDictionary fields, string? cookieHeader) { using var client = NewClient(); using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/heimdall/complete") { Content = new FormUrlEncodedContent(fields) }; if (cookieHeader is not null) { request.Headers.Add("Cookie", cookieHeader); } return await client.SendAsync(request); } public const string LinkedAccountsUrl = "/Membership/LinkedAccounts"; /// The Linked accounts page as a browser reads it, with its session when there is one. public async Task<(HttpResponseMessage Response, string Html)> GetLinkedAccountsAsync(string? session, string query = "") { using var client = NewClient(); using var request = new HttpRequestMessage(HttpMethod.Get, LinkedAccountsUrl + query); if (session is not null) { request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}"); } var response = await client.SendAsync(request); return (response, await response.Content.ReadAsStringAsync()); } /// /// One of the page's buttons as a browser posts it: the page is fetched for its antiforgery token and cookie (when /// ), then the form is posted with the session. Without a session nothing is fetched. /// public async Task PostLinkedAccountsAsync( string? session, string handler, IDictionary? form = null, bool antiforgery = true, HttpMethod? method = null, string? extraCookies = null) { using var client = NewClient(); var cookies = new List(); var fields = new Dictionary(form ?? new Dictionary()); if (session is not null) { cookies.Add($"{BifrostCookies.Session}={session}"); } if (antiforgery && session is not null) { using var get = new HttpRequestMessage(HttpMethod.Get, LinkedAccountsUrl); get.Headers.Add("Cookie", string.Join("; ", cookies)); using var page = await client.SendAsync(get); var html = await page.Content.ReadAsStringAsync(); var token = System.Text.RegularExpressions.Regex.Match( html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value; fields["__RequestVerificationToken"] = token; cookies.Add($"{BifrostCookies.Antiforgery}={CookieValue(page, BifrostCookies.Antiforgery)}"); } if (extraCookies is not null) { cookies.Add(extraCookies); } var before = Heimdall.StartBodies.Count; using var request = new HttpRequestMessage(method ?? HttpMethod.Post, $"{LinkedAccountsUrl}?handler={handler}"); if (request.Method == HttpMethod.Post) { request.Content = new FormUrlEncodedContent(fields); } if (cookies.Count > 0) { request.Headers.Add("Cookie", string.Join("; ", cookies)); } return AttemptFrom(await client.SendAsync(request), before); } /// /// A link start needs the account's current list (Bifrost will not guess a provider to prove with). Link tests that are not /// about the list get one seeded for a session of this host: the Discord login it signed in with. false leaves it. /// public Task StartLinkAsync( string? session, string provider = "google", bool antiforgery = true, HttpMethod? method = null, string? extraCookies = null, bool seedList = true) { if (seedList && session is not null && _sessionAccounts.TryGetValue(session, out var account) && Services.GetRequiredService().Get(account) is null) { Services.GetRequiredService().Put( account, [new LinkedIdentitySummary(Guid.NewGuid().ToString(), "discord", "Seeded", null)]); } return PostLinkedAccountsAsync(session, "Link", new Dictionary { ["provider"] = provider }, antiforgery, method, extraCookies); } /// The list button. is the provider the member names, needed only when no current list is held. public Task StartListAsync(string? session, string? via = "discord") => PostLinkedAccountsAsync(session, "Refresh", via is null ? null : new Dictionary { ["via"] = via }); public Task StartUnlinkAsync(string? session, string identityId) => PostLinkedAccountsAsync(session, "Unlink", new Dictionary { ["identityId"] = identityId }); 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()); }