Bifrost/tests/Bifrost.Web.Tests/OidcLogoutTests.cs
Meta Vens 69b670e3af Fix logout test support compile errors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:38:56 +01:00

278 lines
12 KiB
C#

using System.Text.Json;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Oidc;
using Bifrost.Web.Tests.Support;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
namespace Bifrost.Web.Tests;
public sealed class OidcLogoutTests
{
private static readonly TimeSpan[] ExpectedBackoff =
new[] { 30, 60, 120, 240, 480, 900, 900 }.Select(x => TimeSpan.FromSeconds(x)).ToArray();
[Fact]
public async Task Probe_authorizations_carry_the_subject_and_a_valid_status_after_a_code_flow()
{
using var factory = new OidcLogoutFactory();
var (identity, _) = await factory.GrantAsync();
Assert.True(factory.ValidAuthorizations(factory.AccountId(identity).ToString("D")) > 0);
using var scope = factory.Services.CreateScope();
var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
var found = new List<object>();
await foreach (var authorization in manager.FindBySubjectAsync(factory.AccountId(identity).ToString("D")))
{
found.Add(authorization);
}
Assert.NotEmpty(found);
Assert.True(await manager.TryRevokeAsync(found[0]));
}
[Theory]
[InlineData(MembershipStatus.Suspended)]
[InlineData(MembershipStatus.PendingApproval)]
[InlineData(MembershipStatus.Authenticated)]
public async Task A_member_who_is_not_active_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status)
{
using var factory = new OidcLogoutFactory();
var (identity, idToken) = await factory.GrantAsync(status);
var subject = factory.AccountId(identity).ToString("D");
var reconciler = factory.Reconciler;
await reconciler.ReconcileOnceAsync(CancellationToken.None);
var token = Assert.Single(factory.Mas.LogoutTokens());
var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, new TokenValidationParameters
{
ValidIssuer = OidcLogoutFactory.Origin.AbsoluteUri,
ValidAudience = OidcTestKit.ClientId,
IssuerSigningKeys = (await factory.JwksAsync()).GetSigningKeys(),
ValidAlgorithms = ["ES256"],
ValidTypes = ["logout+jwt"],
ValidateLifetime = false
});
Assert.True(result.IsValid, result.Exception?.Message);
var jwt = (JsonWebToken)result.SecurityToken;
Assert.Equal("logout+jwt", jwt.Typ);
Assert.Equal(new JsonWebToken(idToken).Issuer, jwt.Issuer);
Assert.Equal(subject, jwt.Subject);
Assert.Equal(TimeSpan.FromMinutes(2), jwt.ValidTo - jwt.IssuedAt);
Assert.Equal(factory.Time.GetUtcNow().ToUnixTimeSeconds(), new DateTimeOffset(jwt.IssuedAt).ToUnixTimeSeconds());
Assert.False(string.IsNullOrEmpty(jwt.Id));
var payload = JsonDocument.Parse(Base64UrlEncoder.Decode(jwt.EncodedPayload)).RootElement;
var events = payload.GetProperty("events");
Assert.Equal(["http://schemas.openid.net/event/backchannel-logout"], events.EnumerateObject().Select(x => x.Name).ToArray());
Assert.Equal(JsonValueKind.Object, events.EnumerateObject().Single().Value.ValueKind);
Assert.False(payload.TryGetProperty("nonce", out _));
Assert.False(payload.TryGetProperty("sid", out _));
Assert.Equal(0, factory.ValidAuthorizations(subject));
await reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Single(factory.Mas.Bodies);
}
[Fact]
public async Task Two_logouts_for_one_subject_carry_different_jti()
{
using var factory = new OidcLogoutFactory();
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
factory.Mas.Script(500, 200);
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
factory.Time.Advance(TimeSpan.FromSeconds(30));
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
var ids = factory.Mas.LogoutTokens().Select(x => new JsonWebToken(x).Id).ToArray();
Assert.Equal(2, ids.Length);
Assert.NotEqual(ids[0], ids[1]);
Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(identity).ToString("D")));
}
[Fact]
public async Task An_active_member_is_never_sent_and_keeps_the_authorizations()
{
using var factory = new OidcLogoutFactory();
var (suspended, _) = await factory.GrantAsync(MembershipStatus.Suspended);
var (active, _) = await factory.GrantAsync(MembershipStatus.Active);
var activeSubject = factory.AccountId(active).ToString("D");
var before = factory.ValidAuthorizations(activeSubject);
Assert.True(before > 0);
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
var token = Assert.Single(factory.Mas.LogoutTokens());
Assert.Equal(factory.AccountId(suspended).ToString("D"), new JsonWebToken(token).Subject);
Assert.Equal(before, factory.ValidAuthorizations(activeSubject));
}
[Fact]
public async Task One_subject_that_cannot_be_delivered_does_not_block_another()
{
using var factory = new OidcLogoutFactory();
var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended);
var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended);
factory.Mas.Script(500);
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(2, factory.Mas.Bodies.Count);
var delivered = new[] { first, second }.Count(x => factory.ValidAuthorizations(factory.AccountId(x).ToString("D")) == 0);
Assert.Equal(1, delivered);
}
[Fact]
public async Task Delivery_retries_with_exponential_capped_backoff_and_revokes_only_after_a_2xx()
{
using var factory = new OidcLogoutFactory();
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
var subject = factory.AccountId(identity).ToString("D");
factory.Mas.Script(500, 500, 500, 500, 500, 500, 500, 200);
var reconciler = factory.Reconciler;
var posts = 0;
foreach (var delay in ExpectedBackoff)
{
await reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(++posts, factory.Mas.Bodies.Count);
Assert.True(factory.ValidAuthorizations(subject) > 0, "a failed delivery must not revoke");
factory.Time.Advance(delay - TimeSpan.FromSeconds(1));
await reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(posts, factory.Mas.Bodies.Count);
factory.Time.Advance(TimeSpan.FromSeconds(1));
}
await reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(8, factory.Mas.Bodies.Count);
Assert.Equal(0, factory.ValidAuthorizations(subject));
}
[Theory]
[InlineData(FakeMasHandler.NetworkFault)]
[InlineData(FakeMasHandler.Timeout)]
[InlineData(302)]
[InlineData(401)]
public async Task A_network_fault_timeout_or_unexpected_status_keeps_the_authorizations_and_retries(int fault)
{
using var factory = new OidcLogoutFactory();
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
var subject = factory.AccountId(identity).ToString("D");
factory.Mas.Script(fault);
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.True(factory.ValidAuthorizations(subject) > 0);
factory.Time.Advance(TimeSpan.FromSeconds(30));
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(2, factory.Mas.Bodies.Count);
Assert.Equal(0, factory.ValidAuthorizations(subject));
}
[Fact]
public async Task A_restarted_reconciler_resumes_the_pending_subject_from_the_table()
{
using var factory = new OidcLogoutFactory();
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
var subject = factory.AccountId(identity).ToString("D");
factory.Mas.Script(500);
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Single(factory.Mas.Bodies);
await factory.NewReconciler().ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(2, factory.Mas.Bodies.Count);
Assert.Equal(0, factory.ValidAuthorizations(subject));
}
[Theory]
[InlineData(400)]
[InlineData(404)]
public async Task A_400_or_404_is_a_fault_not_success_and_backs_off_at_the_cap(int status)
{
using var factory = new OidcLogoutFactory();
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
var subject = factory.AccountId(identity).ToString("D");
factory.Mas.Script(status, status);
var reconciler = factory.Reconciler;
await reconciler.ReconcileOnceAsync(CancellationToken.None);
factory.Time.Advance(OidcLogoutReconciler.MaxBackoff - TimeSpan.FromSeconds(1));
await reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Single(factory.Mas.Bodies);
Assert.True(factory.ValidAuthorizations(subject) > 0);
factory.Time.Advance(TimeSpan.FromSeconds(1));
await reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(2, factory.Mas.Bodies.Count);
factory.Time.Advance(OidcLogoutReconciler.MaxBackoff - TimeSpan.FromSeconds(1));
await reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(2, factory.Mas.Bodies.Count);
Assert.True(factory.ValidAuthorizations(subject) > 0);
}
[Fact]
public async Task A_subject_that_is_not_an_account_id_is_treated_as_not_active()
{
using var factory = new OidcLogoutFactory();
var (active, _) = await factory.GrantAsync(MembershipStatus.Active);
using (var scope = factory.Services.CreateScope())
{
var applications = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var authorizations = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
var application = (await applications.FindByClientIdAsync(OidcTestKit.ClientId))!;
await authorizations.CreateAsync(new OpenIddictAuthorizationDescriptor
{
ApplicationId = await applications.GetIdAsync(application),
Subject = "not-a-guid",
Status = OpenIddictConstants.Statuses.Valid,
Type = OpenIddictConstants.AuthorizationTypes.AdHoc
});
}
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
var token = Assert.Single(factory.Mas.LogoutTokens());
Assert.Equal("not-a-guid", new JsonWebToken(token).Subject);
Assert.Equal(0, factory.ValidAuthorizations("not-a-guid"));
Assert.True(factory.ValidAuthorizations(factory.AccountId(active).ToString("D")) > 0);
}
[Fact]
public async Task A_deleted_account_is_treated_as_not_active()
{
using var factory = new OidcLogoutFactory();
var (identity, _) = await factory.GrantAsync(MembershipStatus.Active);
var subject = factory.AccountId(identity).ToString("D");
await factory.DeleteAccountAsync(identity);
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
Assert.Equal(subject, new JsonWebToken(Assert.Single(factory.Mas.LogoutTokens())).Subject);
Assert.Equal(0, factory.ValidAuthorizations(subject));
}
[Fact]
public void Without_a_back_channel_logout_uri_the_reconciler_is_not_hosted_and_nothing_is_revoked()
{
using var factory = new OidcWebApplicationFactory();
using var client = factory.CreateClient();
Assert.Empty(factory.Services.GetServices<IHostedService>().OfType<OidcLogoutReconciler>());
}
[Fact]
public void The_reconciler_is_hosted_when_the_uri_is_set()
{
using var factory = new OidcLogoutFactory();
using var client = factory.CreateClient();
Assert.NotNull(factory.Reconciler);
}
}