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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-28 00:38:15 +01:00
commit 404dafdd40
8 changed files with 748 additions and 4 deletions

View file

@ -38,4 +38,7 @@ public sealed class OidcClientOptions
public string ClientSecretPath { get; init; } = string.Empty;
public string RedirectUri { get; init; } = string.Empty;
/// <summary>MAS's back-channel logout URL for this provider. Empty: no logout is sent and no session is ended.</summary>
public string BackchannelLogoutUri { get; init; } = string.Empty;
}

View file

@ -117,8 +117,8 @@ public static class OidcEndpoints
return Results.Json(claims);
}
/// <summary>The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints.</summary>
private static async Task<bool> IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
/// <summary>The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler.</summary>
internal static async Task<bool> IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
{
if (!Guid.TryParse(subject, out var accountId))
{

View file

@ -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
{

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class OidcLogoutReconciler(
IServiceScopeFactory scopeFactory,
OidcIssuerOptions options,
OidcKeyMaterial material,
IOptions<OpenIddictServerOptions> serverOptions,
IHttpClientFactory httpClients,
TimeProvider time,
ILogger<OidcLogoutReconciler> 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<string, Backoff> _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;
}
}
}
}
/// <summary>One reconcile pass. Subjects are independent: one that cannot be delivered never blocks another.</summary>
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<HashSet<string>> ListSubjectsAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var applications = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var authorizations = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
var subjects = new HashSet<string>(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<BifrostDbContext>();
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<IOpenIddictAuthorizationManager>();
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));
}
/// <summary>The status MAS answered, or null on a network fault or timeout. The response body is never read.</summary>
private async Task<HttpStatusCode?> 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<string, string>("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;
}
}
/// <summary>
/// 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).
/// </summary>
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<string, object>
{
[Claims.Subject] = Guid.TryParse(subject, out var id) ? id.ToString("D") : subject,
[Claims.JwtId] = Guid.NewGuid().ToString("D"),
["events"] = new Dictionary<string, object> { [LogoutEvent] = new Dictionary<string, object>() }
}
};
return new JsonWebTokenHandler().CreateToken(descriptor);
}
}

View file

@ -68,6 +68,12 @@ public static class OidcServiceCollectionExtensions
});
services.AddHostedService<OidcClientSeeder>();
if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri))
{
services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout);
services.AddHostedService<OidcLogoutReconciler>();
}
return services;
}
}

View file

@ -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<OidcConfigurationException>(() => 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
}
}
};

View file

@ -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<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);
}
}

View file

@ -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;
/// <summary>A clock the test moves; its timers never fire, so the hosted reconciler only ticks when a test calls it.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public sealed class FakeMasHandler : HttpMessageHandler
{
public const int NetworkFault = -1;
public const int Timeout = -2;
private readonly Queue<int> _script = new();
private readonly List<string> _bodies = [];
public void Script(params int[] statuses)
{
lock (_script)
{
foreach (var status in statuses)
{
_script.Enqueue(status);
}
}
}
public IReadOnlyList<string> Bodies
{
get
{
lock (_bodies)
{
return _bodies.ToArray();
}
}
}
/// <summary>The logout_tokens posted, in order; asserts each body is exactly one logout_token field.</summary>
public IReadOnlyList<string> 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<HttpResponseMessage> 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") }
};
}
}
/// <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");
public FakeMasHandler Mas { get; } = new();
public ManualClock Time { get; } = new();
public OidcLogoutReconciler Reconciler => Services.GetServices<IHostedService>().OfType<OidcLogoutReconciler>().Single();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.UseSetting("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", LogoutUri);
builder.ConfigureServices(services =>
{
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(Time);
services.AddHttpClient(OidcLogoutReconciler.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => Mas);
});
}
/// <summary>A reconciler with no memory, built over this host's database, as a restarted process would have.</summary>
public OidcLogoutReconciler NewReconciler() => new(
Services.GetRequiredService<IServiceScopeFactory>(),
Services.GetRequiredService<OidcIssuerOptions>(),
Services.GetRequiredService<OidcKeyMaterial>(),
Services.GetRequiredService<IOptions<OpenIddictServerOptions>>(),
Services.GetRequiredService<IHttpClientFactory>(),
Time,
NullLogger<OidcLogoutReconciler>.Instance);
public WebApplicationFactoryClientOptions ClientOptions() => new()
{
AllowAutoRedirect = false,
HandleCookies = true,
BaseAddress = Origin
};
/// <summary>Registers a member, sets <paramref name="status"/>, and completes one authorization-code flow for them.</summary>
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<string, string>
{
["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<JsonElement>()).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);
/// <summary>How many of the subject's authorizations are still valid.</summary>
public int ValidAuthorizations(string subject)
{
using var scope = Services.CreateScope();
return scope.ServiceProvider.GetRequiredService<BifrostDbContext>()
.Set<OpenIddictEntityFrameworkCoreAuthorization>().AsNoTracking()
.Count(x => x.Subject == subject && x.Status == "valid");
}
public async Task<JsonWebKeySet> JwksAsync()
{
using var client = CreateClient(ClientOptions());
return new JsonWebKeySet(await client.GetStringAsync("/.well-known/jwks"));
}
}