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:
parent
83daabe512
commit
404dafdd40
8 changed files with 748 additions and 4 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
204
src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs
Normal file
204
src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
278
tests/Bifrost.Web.Tests/OidcLogoutTests.cs
Normal file
278
tests/Bifrost.Web.Tests/OidcLogoutTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
221
tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs
Normal file
221
tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue