using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Oidc; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using OpenIddict.EntityFrameworkCore.Models; using OpenIddict.Server; namespace Bifrost.Web.Tests.Support; /// A clock the test moves; its timers never fire, so the hosted reconciler only ticks when a test calls it. public sealed class ManualClock : TimeProvider { private DateTimeOffset _now = DateTimeOffset.UtcNow; public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan by) => _now += by; public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) => base.CreateTimer(callback, state, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); } /// A clock whose timers fire only when the test says so, to drive the reconciler's own loop. public sealed class ControlledClock : TimeProvider { private readonly List _timers = []; private DateTimeOffset _now = DateTimeOffset.UtcNow; public override DateTimeOffset GetUtcNow() => _now; public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { var timer = new PendingTimer(() => callback(state)); lock (_timers) { _timers.Add(timer); } return timer; } /// How many delays are currently waiting to be fired. public int Waiting { get { lock (_timers) { return _timers.Count(x => !x.Disposed); } } } /// Fires every timer that is waiting (each delay the loop is sitting in). public void FireTimers() { PendingTimer[] due; lock (_timers) { due = _timers.Where(x => !x.Disposed).ToArray(); _timers.Clear(); } foreach (var timer in due) { timer.Fire(); } } private sealed class PendingTimer(Action fire) : ITimer { public bool Disposed { get; private set; } public void Fire() => fire(); public bool Change(TimeSpan dueTime, TimeSpan period) => true; public void Dispose() => Disposed = true; public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; } } } /// Collects what a component logs, formatted, so a test can say what must and must not appear. public sealed class ListLogger : ILogger { private readonly List<(LogLevel Level, string Message)> _entries = []; public IReadOnlyList<(LogLevel Level, string Message)> Entries { get { lock (_entries) { return _entries.ToArray(); } } } 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 (_entries) { _entries.Add((logLevel, formatter(state, exception))); } } } /// /// A stand-in for MAS's back-channel logout route: answers a scripted status per POST (then 200), records every form /// body, and can fail like the network does. /// public sealed class FakeMasHandler : HttpMessageHandler { public const int NetworkFault = -1; public const int Timeout = -2; public const int Unexpected = -3; public const int Hang = -4; private readonly Queue _script = new(); private readonly List _bodies = []; public void Script(params int[] statuses) { lock (_script) { foreach (var status in statuses) { _script.Enqueue(status); } } } public IReadOnlyList Bodies { get { lock (_bodies) { return _bodies.ToArray(); } } } /// The logout_tokens posted, in order; asserts each body is exactly one logout_token field. public IReadOnlyList LogoutTokens() => Bodies.Select(body => { var form = QueryHelpers.ParseQuery(body); Assert.Equal(["logout_token"], form.Keys.ToArray()); return form["logout_token"].ToString(); }).ToArray(); protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Assert.Equal(HttpMethod.Post, request.Method); Assert.Equal("application/x-www-form-urlencoded", request.Content!.Headers.ContentType!.MediaType); var body = await request.Content.ReadAsStringAsync(cancellationToken); lock (_bodies) { _bodies.Add(body); } int status; lock (_script) { status = _script.Count > 0 ? _script.Dequeue() : 200; } return status switch { NetworkFault => throw new HttpRequestException("network down"), Timeout => throw new TaskCanceledException("timed out"), Unexpected => throw new InvalidOperationException("not a network fault"), Hang => await HangAsync(cancellationToken), _ => new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent(BodyCanary) } }; } public const string BodyCanary = "mas-body-canary"; private static async Task HangAsync(CancellationToken cancellationToken) { await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, cancellationToken); throw new InvalidOperationException("unreachable"); } } /// /// A real HTTP listener on loopback standing in for a MAS that answers every POST with 302 to /login and GET /login with /// 200: what a real HttpClient does with that answer is the registered handler's rule, which a fake handler cannot see. /// public sealed class RedirectingMas : IDisposable { private readonly System.Net.Sockets.TcpListener _listener = new(IPAddress.Loopback, 0); private readonly List _requests = []; private readonly CancellationTokenSource _stop = new(); private readonly Task _accepting; public RedirectingMas() { _listener.Start(); _accepting = Task.Run(AcceptAsync); } public string LogoutUri => $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/logout"; /// "METHOD path" for every request received, in order. public IReadOnlyList Requests { get { lock (_requests) { return _requests.ToArray(); } } } private async Task AcceptAsync() { try { while (!_stop.IsCancellationRequested) { var connection = await _listener.AcceptTcpClientAsync(_stop.Token); _ = Task.Run(() => ServeAsync(connection)); } } catch (Exception) when (_stop.IsCancellationRequested) { } } private async Task ServeAsync(System.Net.Sockets.TcpClient connection) { using var owned = connection; try { var stream = connection.GetStream(); using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); var requestLine = await reader.ReadLineAsync(_stop.Token) ?? ""; var length = 0; while (await reader.ReadLineAsync(_stop.Token) is { Length: > 0 } header) { if (header.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) { length = int.Parse(header["Content-Length:".Length..].Trim()); } } var body = new char[length]; var read = 0; while (read < length) { var n = await reader.ReadAsync(body.AsMemory(read), _stop.Token); if (n == 0) { break; } read += n; } var parts = requestLine.Split(' '); lock (_requests) { _requests.Add($"{parts[0]} {parts[1]}"); } var response = parts[0] == "POST" ? "HTTP/1.1 302 Found\r\nLocation: /login\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" : "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; await stream.WriteAsync(Encoding.ASCII.GetBytes(response), _stop.Token); } catch (Exception error) when (error is IOException or OperationCanceledException or ObjectDisposedException) { } } public void Dispose() { _stop.Cancel(); _listener.Stop(); try { _accepting.Wait(TimeSpan.FromSeconds(2)); } catch (AggregateException) { } } } /// A scope factory that fails the first scopes asked of it, as a database outage would. public sealed class FlakyScopeFactory(IServiceScopeFactory inner, int failures) : IServiceScopeFactory { public const string Secret = "scope-factory-secret-detail"; private int _failures = failures; public IServiceScope CreateScope() { if (Interlocked.Decrement(ref _failures) >= 0) { throw new InvalidOperationException(Secret); } return inner.CreateScope(); } } /// An OIDC host with MAS's back-channel logout URI set, a fake MAS behind it and a clock the test drives. public sealed class OidcLogoutFactory : OidcWebApplicationFactory { public const string LogoutUri = "https://mas.example.test/upstream/backchannel-logout/01TESTPROVIDER"; public static readonly Uri Origin = new("https://localhost"); private readonly string _logoutUri; private readonly bool _fakeTransport; public OidcLogoutFactory() { _logoutUri = LogoutUri; _fakeTransport = true; } /// Keeps the real registered HttpClient and its primary handler and posts to . public OidcLogoutFactory(string logoutUri) { _logoutUri = logoutUri; _fakeTransport = false; } public FakeMasHandler Mas { get; } = new(); public ManualClock Time { get; } = new(); public OidcLogoutReconciler Reconciler => Services.GetServices().OfType().Single(); protected override void ConfigureWebHost(IWebHostBuilder builder) { base.ConfigureWebHost(builder); builder.UseSetting("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", _logoutUri); builder.ConfigureServices(services => { services.RemoveAll(); services.AddSingleton(Time); if (_fakeTransport) { services.AddHttpClient(OidcLogoutReconciler.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => Mas); } }); } /// A reconciler with no memory, built over this host's database, as a restarted process would have. public OidcLogoutReconciler NewReconciler( TimeProvider? time = null, ILogger? logger = null, IServiceScopeFactory? scopes = null) => new( scopes ?? Services.GetRequiredService(), Services.GetRequiredService(), Services.GetRequiredService(), Services.GetRequiredService>(), Services.GetRequiredService(), time ?? Time, logger ?? NullLogger.Instance); public WebApplicationFactoryClientOptions HostClientOptions() => new() { AllowAutoRedirect = false, HandleCookies = true, BaseAddress = Origin }; /// Registers a member, sets , and completes one authorization-code flow for them. public async Task<(string Identity, string IdToken)> GrantAsync(MembershipStatus status = MembershipStatus.Active) { using var client = CreateClient(HostClientOptions()); var identity = $"member-{Guid.NewGuid():N}"; using (var register = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity })) { Assert.Equal(HttpStatusCode.OK, register.StatusCode); } await SetMembershipAsync(identity, MembershipStatus.Active); var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48)); var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); var challenge = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); var response = await client.GetAsync( "/connect/authorize?response_type=code" + $"&client_id={Uri.EscapeDataString(OidcTestKit.ClientId)}" + $"&redirect_uri={Uri.EscapeDataString(OidcTestKit.RedirectUri)}" + $"&scope=openid%20profile&state={state}&nonce=n-{state}" + $"&code_challenge={challenge}&code_challenge_method=S256"); for (var hops = 0; hops < 4 && IsAuthorizeRedirect(response); hops++) { var next = response.Headers.Location!; response.Dispose(); response = await client.GetAsync(next.IsAbsoluteUri ? next.PathAndQuery : next.OriginalString); } var code = QueryHelpers.ParseQuery(response.Headers.Location!.Query)["code"].ToString(); response.Dispose(); using var bare = CreateClient(HostClientOptions()); using var exchange = new HttpRequestMessage(HttpMethod.Post, "/connect/token") { Content = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "authorization_code", ["code"] = code, ["redirect_uri"] = OidcTestKit.RedirectUri, ["code_verifier"] = verifier }) }; exchange.Headers.Authorization = new AuthenticationHeaderValue( "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{OidcTestKit.ClientId}:{Kit.ClientSecret}"))); using var tokens = await bare.SendAsync(exchange); Assert.Equal(HttpStatusCode.OK, tokens.StatusCode); var idToken = (await tokens.Content.ReadFromJsonAsync()).GetProperty("id_token").GetString()!; if (status != MembershipStatus.Active) { await SetMembershipAsync(identity, status); } return (identity, idToken); } private static bool IsAuthorizeRedirect(HttpResponseMessage response) => response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found && response.Headers.Location is { } location && (location.IsAbsoluteUri ? location.AbsolutePath : location.OriginalString).StartsWith("/connect/authorize", StringComparison.Ordinal); /// Gives one more valid ad hoc authorization of the MAS client. public async Task AddAuthorizationAsync(string subject) { using var scope = Services.CreateScope(); var applications = scope.ServiceProvider.GetRequiredService(); var authorizations = scope.ServiceProvider.GetRequiredService(); var application = (await applications.FindByClientIdAsync(OidcTestKit.ClientId))!; await authorizations.CreateAsync(new OpenIddictAuthorizationDescriptor { ApplicationId = await applications.GetIdAsync(application), Subject = subject, Status = OpenIddictConstants.Statuses.Valid, Type = OpenIddictConstants.AuthorizationTypes.AdHoc }); } /// Revokes every authorization of the subject, as an operator would outside the reconciler. public async Task RevokeAllAsync(string subject) { using var scope = Services.CreateScope(); var authorizations = scope.ServiceProvider.GetRequiredService(); await foreach (var authorization in authorizations.FindBySubjectAsync(subject)) { await authorizations.TryRevokeAsync(authorization); } } /// How many of the subject's authorizations are still valid. public int ValidAuthorizations(string subject) { using var scope = Services.CreateScope(); return scope.ServiceProvider.GetRequiredService() .Set().AsNoTracking() .Count(x => x.Subject == subject && x.Status == "valid"); } public async Task JwksAsync() { using var client = CreateClient(HostClientOptions()); return new JsonWebKeySet(await client.GetStringAsync("/.well-known/jwks")); } }