From 404dafdd401333862e364de48cc90185f543c37a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 00:38:15 +0100 Subject: [PATCH] OIDC back-channel logout: a reconciler ends the sessions of subjects who are not Active OidcLogoutReconciler derives pending logouts from valid OpenIddict authorizations x membership, POSTs one signed logout token per non-Active subject to MAS, and revokes the authorizations only after a 2xx. Registered only when Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri is set. The active-member reading is the endpoints' existing one, now shared. Co-Authored-By: Claude Opus 5.5 --- .../Configuration/OidcIssuerOptions.cs | 3 + .../Features/Oidc/OidcEndpoints.cs | 4 +- .../Features/Oidc/OidcKeyMaterial.cs | 7 + .../Features/Oidc/OidcLogoutReconciler.cs | 204 +++++++++++++ .../Oidc/OidcServiceCollectionExtensions.cs | 6 + .../Bifrost.Web.Tests/OidcKeyMaterialTests.cs | 29 +- tests/Bifrost.Web.Tests/OidcLogoutTests.cs | 278 ++++++++++++++++++ .../Support/OidcLogoutKit.cs | 221 ++++++++++++++ 8 files changed, 748 insertions(+), 4 deletions(-) create mode 100644 src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs create mode 100644 tests/Bifrost.Web.Tests/OidcLogoutTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs diff --git a/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs index 1a14457..13ba192 100644 --- a/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs +++ b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs @@ -38,4 +38,7 @@ public sealed class OidcClientOptions public string ClientSecretPath { get; init; } = string.Empty; public string RedirectUri { get; init; } = string.Empty; + + /// MAS's back-channel logout URL for this provider. Empty: no logout is sent and no session is ended. + public string BackchannelLogoutUri { get; init; } = string.Empty; } diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 6d164e2..b435347 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -117,8 +117,8 @@ public static class OidcEndpoints return Results.Json(claims); } - /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints. - private static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler. + internal static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) { if (!Guid.TryParse(subject, out var accountId)) { diff --git a/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs index 58f541a..8f0be75 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs @@ -47,6 +47,13 @@ public sealed class OidcKeyMaterial : IDisposable throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:RedirectUri must be an absolute http(s) URI."); } + if (!string.IsNullOrWhiteSpace(mas.BackchannelLogoutUri) && + (!Uri.TryCreate(mas.BackchannelLogoutUri, UriKind.Absolute, out var logout) || + logout.Scheme is not ("https" or "http"))) + { + throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri must be an absolute http(s) URI."); + } + var ecdsa = LoadSigningKey(options.SigningKeyPath); try { diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs new file mode 100644 index 0000000..ba88f48 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -0,0 +1,204 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// Ends the chat sessions of a subject who is no longer an Active member. Each tick it lists the subjects holding valid +/// authorizations of the configured client; for each one the shared active-member reading refuses, it POSTs one signed +/// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The +/// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it. +/// Per-subject backoff lives in memory only. It never writes membership. +/// +public sealed class OidcLogoutReconciler( + IServiceScopeFactory scopeFactory, + OidcIssuerOptions options, + OidcKeyMaterial material, + IOptions serverOptions, + IHttpClientFactory httpClients, + TimeProvider time, + ILogger logger) : BackgroundService +{ + public const string HttpClientName = "bifrost-oidc-logout"; + public const string LogoutEvent = "http://schemas.openid.net/event/backchannel-logout"; + public const string LogoutTokenType = "logout+jwt"; + + public static readonly TimeSpan Interval = TimeSpan.FromSeconds(30); + public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + public static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(2); + public static readonly TimeSpan MaxBackoff = TimeSpan.FromMinutes(15); + + private readonly Dictionary _backoff = new(StringComparer.Ordinal); + + private sealed record Backoff(int Failures, DateTimeOffset NotBefore); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ReconcileOnceAsync(stoppingToken); + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception error) + { + // The type only: messages can carry connection strings or URLs. + logger.LogError("Back-channel logout reconcile failed: {ErrorType}", error.GetType().Name); + try + { + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + } + + /// One reconcile pass. Subjects are independent: one that cannot be delivered never blocks another. + public async Task ReconcileOnceAsync(CancellationToken cancellationToken) + { + var subjects = await ListSubjectsAsync(cancellationToken); + foreach (var gone in _backoff.Keys.Except(subjects).ToArray()) + { + _backoff.Remove(gone); + } + + foreach (var subject in subjects) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_backoff.TryGetValue(subject, out var wait) && time.GetUtcNow() < wait.NotBefore) + { + continue; + } + + await ReconcileSubjectAsync(subject, cancellationToken); + } + } + + private async Task> ListSubjectsAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var applications = scope.ServiceProvider.GetRequiredService(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + + var subjects = new HashSet(StringComparer.Ordinal); + var application = await applications.FindByClientIdAsync(options.Clients.Mas.ClientId, cancellationToken); + if (application is null || await applications.GetIdAsync(application, cancellationToken) is not { } applicationId) + { + return subjects; + } + + await foreach (var authorization in authorizations.FindByApplicationIdAsync(applicationId, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken) && + await authorizations.GetSubjectAsync(authorization, cancellationToken) is { Length: > 0 } subject) + { + subjects.Add(subject); + } + } + + return subjects; + } + + private async Task ReconcileSubjectAsync(string subject, CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (await OidcEndpoints.IsStillActiveMemberAsync(db, subject, cancellationToken)) + { + _backoff.Remove(subject); + return; + } + + var failures = _backoff.TryGetValue(subject, out var previous) ? previous.Failures + 1 : 1; + var status = await SendLogoutAsync(subject, cancellationToken); + if (status is { } code && (int)code is >= 200 and < 300) + { + var authorizations = scope.ServiceProvider.GetRequiredService(); + await foreach (var authorization in authorizations.FindBySubjectAsync(subject, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken)) + { + await authorizations.TryRevokeAsync(authorization, cancellationToken); + } + } + + _backoff.Remove(subject); + return; + } + + // 400 (token refused) and 404 (unknown provider) are configuration faults: retrying sooner cannot fix them. + var fault = status is HttpStatusCode.BadRequest or HttpStatusCode.NotFound; + var delay = fault ? MaxBackoff : BackoffFor(failures); + _backoff[subject] = new Backoff(failures, time.GetUtcNow() + delay); + logger.LogWarning( + "Back-channel logout to MAS failed with {Status}; retrying in {Delay}", + status is { } s ? (int)s : "no response", + delay); + } + + internal static TimeSpan BackoffFor(int failures) + { + var seconds = Interval.TotalSeconds * Math.Pow(2, Math.Min(failures, 16) - 1); + return TimeSpan.FromSeconds(Math.Min(seconds, MaxBackoff.TotalSeconds)); + } + + /// The status MAS answered, or null on a network fault or timeout. The response body is never read. + private async Task SendLogoutAsync(string subject, CancellationToken cancellationToken) + { + var token = BuildLogoutToken(subject); + using var request = new HttpRequestMessage(HttpMethod.Post, options.Clients.Mas.BackchannelLogoutUri) + { + Content = new FormUrlEncodedContent([new KeyValuePair("logout_token", token)]) + }; + try + { + using var response = await httpClients.CreateClient(HttpClientName) + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return response.StatusCode; + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + return null; + } + } + + /// + /// The OIDC back-channel logout token MAS verifies like an id_token: iss and aud as the id_token carries them, exp + /// and iat both present, an events member, and neither nonce nor sid (MAS finds the session by sub). + /// + private string BuildLogoutToken(string subject) + { + var now = time.GetUtcNow(); + var descriptor = new SecurityTokenDescriptor + { + TokenType = LogoutTokenType, + Issuer = serverOptions.Value.Issuer!.AbsoluteUri, + Audience = options.Clients.Mas.ClientId, + IssuedAt = now.UtcDateTime, + Expires = now.UtcDateTime + TokenLifetime, + SigningCredentials = new SigningCredentials(material.SigningKey, SecurityAlgorithms.EcdsaSha256), + Claims = new Dictionary + { + [Claims.Subject] = Guid.TryParse(subject, out var id) ? id.ToString("D") : subject, + [Claims.JwtId] = Guid.NewGuid().ToString("D"), + ["events"] = new Dictionary { [LogoutEvent] = new Dictionary() } + } + }; + return new JsonWebTokenHandler().CreateToken(descriptor); + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs index 0f1da67..0e3bbf1 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs @@ -68,6 +68,12 @@ public static class OidcServiceCollectionExtensions }); services.AddHostedService(); + if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri)) + { + services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout); + services.AddHostedService(); + } + return services; } } diff --git a/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs index a5bc0e0..73d5200 100644 --- a/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs +++ b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs @@ -87,7 +87,31 @@ public sealed class OidcKeyMaterialTests : IDisposable Assert.Contains("Bifrost:Oidc:SigningKeyPath", failure.Message, StringComparison.Ordinal); } - private OidcIssuerOptions Options(string? blank = null, string? redirectUri = null) => new() + [Theory] + [InlineData("not a uri")] + [InlineData("/upstream/backchannel-logout/01ABC")] + [InlineData("ftp://mas.example.test/upstream/backchannel-logout/01ABC")] + public void A_back_channel_logout_uri_that_is_not_absolute_http_stops_startup_naming_the_setting_not_the_value(string uri) + { + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options(logoutUri: uri))); + + Assert.Contains("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(uri, failure.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("https://mas.example.test/upstream/backchannel-logout/01ABC")] + [InlineData("http://mas.internal/upstream/backchannel-logout/01ABC")] + public void An_absent_or_absolute_http_back_channel_logout_uri_loads(string uri) + { + using var material = OidcKeyMaterial.Load(Options(logoutUri: uri)); + + Assert.NotNull(material.SigningKey); + } + + private OidcIssuerOptions Options(string? blank = null, string? redirectUri = null, string logoutUri = "") => new() { SigningKeyPath = blank == "SigningKeyPath" ? "" : _kit.SigningKeyPath, EncryptionKeyPath = blank == "EncryptionKeyPath" ? "" : _kit.EncryptionKeyPath, @@ -97,7 +121,8 @@ public sealed class OidcKeyMaterialTests : IDisposable { ClientId = blank == "ClientId" ? "" : OidcTestKit.ClientId, ClientSecretPath = blank == "ClientSecretPath" ? "" : _kit.ClientSecretPath, - RedirectUri = blank == "RedirectUri" ? "" : redirectUri ?? OidcTestKit.RedirectUri + RedirectUri = blank == "RedirectUri" ? "" : redirectUri ?? OidcTestKit.RedirectUri, + BackchannelLogoutUri = logoutUri } } }; diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs new file mode 100644 index 0000000..3b37f21 --- /dev/null +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -0,0 +1,278 @@ +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 = + [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(); + var found = new List(); + 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(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + 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().OfType()); + } + + [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); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs new file mode 100644 index 0000000..b04ded9 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs @@ -0,0 +1,221 @@ +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.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.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +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 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; + + 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"), + _ => new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent("mas-body-canary") } + }; + } +} + +/// 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"); + + 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); + 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() => new( + Services.GetRequiredService(), + Services.GetRequiredService(), + Services.GetRequiredService(), + Services.GetRequiredService>(), + Services.GetRequiredService(), + Time, + NullLogger.Instance); + + public WebApplicationFactoryClientOptions ClientOptions() => 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(ClientOptions()); + 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(ClientOptions()); + 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); + + /// 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(ClientOptions()); + return new JsonWebKeySet(await client.GetStringAsync("/.well-known/jwks")); + } +}