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