Logout client: no auto-redirect, pin the redirect rule and the 10 s timeout

The named logout HttpClient's primary handler disables auto-redirect, so a 3xx from MAS is a
non-delivery retried like any non-2xx instead of being followed to a final 2xx that revokes.
Pinned through the real DI registration against a real loopback listener answering 302, and
the registered client's Timeout is pinned to exactly 10 s. The unexpected-exception test is
replaced by the isolation test (its fix is the next commit) and the cancellation test stands alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-28 01:50:53 +01:00
commit ec66fba5ca
3 changed files with 199 additions and 9 deletions

View file

@ -70,7 +70,9 @@ public static class OidcServiceCollectionExtensions
services.AddHostedService<OidcClientSeeder>();
if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri))
{
services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout);
// MAS answers the logout POST itself: a redirect is a non-delivery, never followed to some other 2xx.
services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout)
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false });
services.AddHostedService<OidcLogoutReconciler>();
}

View file

@ -319,26 +319,89 @@ public sealed class OidcLogoutTests
}
[Fact]
public async Task An_unexpected_exception_is_not_a_network_fault_and_a_cancelled_post_is_not_a_failed_delivery()
public async Task One_subject_that_throws_unexpectedly_does_not_stop_the_pass_and_keeps_its_authorizations()
{
using var factory = new OidcLogoutFactory();
var log = new ListLogger<OidcLogoutReconciler>();
var reconciler = factory.NewReconciler(logger: log);
var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended);
var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended);
var subjects = new[] { first, second }.Select(x => factory.AccountId(x).ToString("D")).ToArray();
// Whichever subject the pass reaches first throws; the other is still logged out and revoked.
factory.Mas.Script(FakeMasHandler.Unexpected);
await reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(2, factory.Mas.Bodies.Count);
var firstToken = factory.Mas.LogoutTokens()[0];
var thrown = new JsonWebToken(firstToken).Subject;
var other = subjects.Single(x => x != thrown);
Assert.Equal(0, factory.ValidAuthorizations(other));
Assert.True(factory.ValidAuthorizations(thrown) > 0);
var failure = Assert.Single(log.Entries, x => x.Level == LogLevel.Error);
Assert.Contains(thrown, failure.Message, StringComparison.Ordinal);
Assert.Contains(nameof(InvalidOperationException), failure.Message, StringComparison.Ordinal);
Assert.DoesNotContain("not a network fault", failure.Message, StringComparison.Ordinal);
Assert.DoesNotContain(firstToken, failure.Message, StringComparison.Ordinal);
// The failed subject was not backed off: the next pass tries it again and delivers.
await reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(3, factory.Mas.Bodies.Count);
Assert.Equal(0, factory.ValidAuthorizations(thrown));
}
[Fact]
public async Task A_cancelled_post_stops_the_pass_and_is_not_a_failed_delivery()
{
using var factory = new OidcLogoutFactory();
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
var subject = factory.AccountId(identity).ToString("D");
var reconciler = factory.Reconciler;
factory.Mas.Script(FakeMasHandler.Unexpected);
await Assert.ThrowsAsync<InvalidOperationException>(() => reconciler.ReconcileOnceAsync(CancellationToken.None));
// Stopping while the post is in flight surfaces as cancellation and records no backoff: the next pass posts at once.
factory.Mas.Script(FakeMasHandler.Hang);
using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => reconciler.ReconcileOnceAsync(stop.Token));
Assert.Equal(2, factory.Mas.Bodies.Count);
Assert.Single(factory.Mas.Bodies);
await reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(3, factory.Mas.Bodies.Count);
Assert.Equal(2, factory.Mas.Bodies.Count);
Assert.Equal(0, factory.ValidAuthorizations(subject));
}
[Fact]
public async Task A_redirect_from_mas_is_not_followed_and_is_not_delivery()
{
using var mas = new RedirectingMas();
using var factory = new OidcLogoutFactory(mas.LogoutUri);
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
var subject = factory.AccountId(identity).ToString("D");
var before = factory.ValidAuthorizations(subject);
Assert.True(before > 0);
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(["POST /logout"], mas.Requests);
Assert.Equal(before, factory.ValidAuthorizations(subject));
// A redirect is a non-delivery like any non-2xx: it backs off, then posts again.
factory.Time.Advance(TimeSpan.FromSeconds(30));
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(["POST /logout", "POST /logout"], mas.Requests);
Assert.Equal(before, factory.ValidAuthorizations(subject));
}
[Fact]
public void The_logout_client_times_out_after_ten_seconds()
{
using var factory = new OidcLogoutFactory();
using var client = factory.CreateClient();
Assert.Equal(TimeSpan.FromSeconds(10), OidcLogoutReconciler.RequestTimeout);
Assert.Equal(
TimeSpan.FromSeconds(10),
factory.Services.GetRequiredService<IHttpClientFactory>().CreateClient(OidcLogoutReconciler.HttpClientName).Timeout);
}
[Fact]
public async Task The_hosted_loop_ticks_again_after_each_interval_and_survives_a_failed_tick()
{

View file

@ -211,12 +211,134 @@ public sealed class FakeMasHandler : HttpMessageHandler
}
}
/// <summary>
/// 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.
/// </summary>
public sealed class RedirectingMas : IDisposable
{
private readonly System.Net.Sockets.TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly List<string> _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";
/// <summary>"METHOD path" for every request received, in order.</summary>
public IReadOnlyList<string> 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)
{
}
}
}
/// <summary>An OIDC host with MAS's back-channel logout URI set, a fake MAS behind it and a clock the test drives.</summary>
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;
}
/// <summary>Keeps the real registered HttpClient and its primary handler and posts to <paramref name="logoutUri"/>.</summary>
public OidcLogoutFactory(string logoutUri)
{
_logoutUri = logoutUri;
_fakeTransport = false;
}
public FakeMasHandler Mas { get; } = new();
public ManualClock Time { get; } = new();
@ -226,12 +348,15 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.UseSetting("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", LogoutUri);
builder.UseSetting("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", _logoutUri);
builder.ConfigureServices(services =>
{
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(Time);
services.AddHttpClient(OidcLogoutReconciler.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => Mas);
if (_fakeTransport)
{
services.AddHttpClient(OidcLogoutReconciler.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => Mas);
}
});
}