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