Back-channel logout: members who leave Active are logged out of chat (gamecult-chat cut bifrost-oidc-backchannel-logout r2)
Merges gamecult-chat/bifrost-oidc-backchannel-logout (83daabe..37c8fe1). gamecult-chat:cut_report:cut-bifrost-oidc-backchannel-logout.h1/h2; Soul s1 and s2 hold. Not deployed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1894f653d0
9 changed files with 1297 additions and 4 deletions
|
|
@ -44,6 +44,7 @@ echoes its value.
|
|||
| `Bifrost:Oidc:Clients:Mas:ClientId` | The client id MAS presents. |
|
||||
| `Bifrost:Oidc:Clients:Mas:ClientSecretPath` | File holding the client secret. |
|
||||
| `Bifrost:Oidc:Clients:Mas:RedirectUri` | MAS's callback URL, registered exactly. |
|
||||
| `Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri` | Optional. MAS's back-channel logout URL, `https://<mas>/upstream/backchannel-logout/<provider ULID>`; absolute http(s) or startup fails. Unset: no logout is sent and no authorization is revoked. |
|
||||
|
||||
There are no development or ephemeral keys in the host. Key files are provisioned like the other
|
||||
`[workload.secret_files]` paths.
|
||||
|
|
@ -72,6 +73,33 @@ The authorize endpoint reads the cookie session and the same actor facts the `/A
|
|||
- Signed in and `Active`: a code is issued.
|
||||
- Token endpoint: membership is read again; an account no longer `Active` gets `invalid_grant` and no tokens.
|
||||
|
||||
## Back-channel logout
|
||||
|
||||
When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the
|
||||
subjects holding valid authorizations of the MAS client; for each subject whose membership is not `Active` (the same
|
||||
reading the token and userinfo endpoints use; a deleted account or a subject that is not an account id counts as not
|
||||
`Active`) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations,
|
||||
which also invalidates their tokens. Membership is never written by this path: any way of leaving `Active` (admin
|
||||
action, sync, a database edit, a future suspend) is covered because the work is derived from membership state.
|
||||
|
||||
The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it,
|
||||
`aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the
|
||||
`http://schemas.openid.net/event/backchannel-logout` event. It carries no `nonce` and no `sid`; MAS finds the sessions
|
||||
by `sub`. It is sent as `application/x-www-form-urlencoded` field `logout_token` with a 10 second timeout, and the
|
||||
response body is never logged.
|
||||
|
||||
Failure handling is per subject, in memory: a 5xx, a timeout or a network fault backs off exponentially from 30 seconds
|
||||
to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configuration fault, logged by status only,
|
||||
and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing
|
||||
else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions.
|
||||
|
||||
Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions.
|
||||
|
||||
MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an
|
||||
id_token (`iss`, `aud`, `alg`, JWKS signature), `exp` and `iat` are required, `sub` or `sid` is required, `nonce` must be
|
||||
absent. Owed on the MAS side (gamecult-chat): the provider's `backchannel_logout_uri` and
|
||||
`on_backchannel_logout: logout_all`. Re-check against the MAS version pinned there.
|
||||
|
||||
## MAS configuration
|
||||
|
||||
Configure MAS's upstream provider with the issuer above, the client id and secret, the token endpoint auth method
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
220
src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs
Normal file
220
src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
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. One subject's unexpected exception is logged and skipped, never aborting the
|
||||
/// pass. 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();
|
||||
try
|
||||
{
|
||||
await ReconcileSubjectAsync(subject, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// One subject's fault never stops the pass: its authorizations stay valid and the next tick retries it.
|
||||
// The subject id and the exception type only: messages can carry connection strings or URLs.
|
||||
logger.LogError("Back-channel logout for subject {Subject} failed: {ErrorType}", subject, error.GetType().Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// A subject that recovered is forgotten at once; one still owed a logout waits out its backoff.
|
||||
_backoff.TryGetValue(subject, out var previous);
|
||||
if (previous is not null && time.GetUtcNow() < previous.NotBefore)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var failures = (previous?.Failures ?? 0) + 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,14 @@ public static class OidcServiceCollectionExtensions
|
|||
});
|
||||
|
||||
services.AddHostedService<OidcClientSeeder>();
|
||||
if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri))
|
||||
{
|
||||
// MAS answers the logout POST itself: a redirect is a non-delivery, never followed to some other 2xx.
|
||||
services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false });
|
||||
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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
499
tests/Bifrost.Web.Tests/OidcLogoutTests.cs
Normal file
499
tests/Bifrost.Web.Tests/OidcLogoutTests.cs
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
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.Logging;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class OidcLogoutTests
|
||||
{
|
||||
private static readonly TimeSpan[] ExpectedBackoff =
|
||||
new[] { 30, 60, 120, 240, 480, 900, 900 }.Select(x => TimeSpan.FromSeconds(x)).ToArray();
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_authorizations_carry_the_subject_and_a_valid_status_after_a_code_flow()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (identity, _) = await factory.GrantAsync();
|
||||
|
||||
Assert.True(factory.ValidAuthorizations(factory.AccountId(identity).ToString("D")) > 0);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
|
||||
var found = new List<object>();
|
||||
await foreach (var authorization in manager.FindBySubjectAsync(factory.AccountId(identity).ToString("D")))
|
||||
{
|
||||
found.Add(authorization);
|
||||
}
|
||||
|
||||
Assert.NotEmpty(found);
|
||||
Assert.True(await manager.TryRevokeAsync(found[0]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Suspended)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
public async Task A_member_who_is_not_active_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status)
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (identity, idToken) = await factory.GrantAsync(status);
|
||||
var subject = factory.AccountId(identity).ToString("D");
|
||||
var reconciler = factory.Reconciler;
|
||||
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
|
||||
var token = Assert.Single(factory.Mas.LogoutTokens());
|
||||
var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, new TokenValidationParameters
|
||||
{
|
||||
ValidIssuer = OidcLogoutFactory.Origin.AbsoluteUri,
|
||||
ValidAudience = OidcTestKit.ClientId,
|
||||
IssuerSigningKeys = (await factory.JwksAsync()).GetSigningKeys(),
|
||||
ValidAlgorithms = ["ES256"],
|
||||
ValidTypes = ["logout+jwt"],
|
||||
ValidateLifetime = false
|
||||
});
|
||||
Assert.True(result.IsValid, result.Exception?.Message);
|
||||
var jwt = (JsonWebToken)result.SecurityToken;
|
||||
Assert.Equal("logout+jwt", jwt.Typ);
|
||||
Assert.Equal(new JsonWebToken(idToken).Issuer, jwt.Issuer);
|
||||
Assert.Equal(subject, jwt.Subject);
|
||||
Assert.Equal(TimeSpan.FromMinutes(2), jwt.ValidTo - jwt.IssuedAt);
|
||||
Assert.Equal(factory.Time.GetUtcNow().ToUnixTimeSeconds(), new DateTimeOffset(jwt.IssuedAt).ToUnixTimeSeconds());
|
||||
Assert.False(string.IsNullOrEmpty(jwt.Id));
|
||||
var payload = JsonDocument.Parse(Base64UrlEncoder.Decode(jwt.EncodedPayload)).RootElement;
|
||||
var events = payload.GetProperty("events");
|
||||
Assert.Equal(["http://schemas.openid.net/event/backchannel-logout"], events.EnumerateObject().Select(x => x.Name).ToArray());
|
||||
Assert.Equal(JsonValueKind.Object, events.EnumerateObject().Single().Value.ValueKind);
|
||||
Assert.False(payload.TryGetProperty("nonce", out _));
|
||||
Assert.False(payload.TryGetProperty("sid", out _));
|
||||
Assert.Equal(0, factory.ValidAuthorizations(subject));
|
||||
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
Assert.Single(factory.Mas.Bodies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_logouts_for_one_subject_carry_different_jti()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
factory.Mas.Script(500, 200);
|
||||
|
||||
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(30));
|
||||
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
|
||||
var ids = factory.Mas.LogoutTokens().Select(x => new JsonWebToken(x).Id).ToArray();
|
||||
Assert.Equal(2, ids.Length);
|
||||
Assert.NotEqual(ids[0], ids[1]);
|
||||
Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(identity).ToString("D")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_active_member_is_never_sent_and_keeps_the_authorizations()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (suspended, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
var (active, _) = await factory.GrantAsync(MembershipStatus.Active);
|
||||
var activeSubject = factory.AccountId(active).ToString("D");
|
||||
var before = factory.ValidAuthorizations(activeSubject);
|
||||
Assert.True(before > 0);
|
||||
|
||||
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
|
||||
var token = Assert.Single(factory.Mas.LogoutTokens());
|
||||
Assert.Equal(factory.AccountId(suspended).ToString("D"), new JsonWebToken(token).Subject);
|
||||
Assert.Equal(before, factory.ValidAuthorizations(activeSubject));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_subject_that_cannot_be_delivered_does_not_block_another()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
factory.Mas.Script(500);
|
||||
|
||||
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, factory.Mas.Bodies.Count);
|
||||
var delivered = new[] { first, second }.Count(x => factory.ValidAuthorizations(factory.AccountId(x).ToString("D")) == 0);
|
||||
Assert.Equal(1, delivered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delivery_retries_with_exponential_capped_backoff_and_revokes_only_after_a_2xx()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
var subject = factory.AccountId(identity).ToString("D");
|
||||
factory.Mas.Script(500, 500, 500, 500, 500, 500, 500, 200);
|
||||
var reconciler = factory.Reconciler;
|
||||
|
||||
var posts = 0;
|
||||
foreach (var delay in ExpectedBackoff)
|
||||
{
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
Assert.Equal(++posts, factory.Mas.Bodies.Count);
|
||||
Assert.True(factory.ValidAuthorizations(subject) > 0, "a failed delivery must not revoke");
|
||||
|
||||
factory.Time.Advance(delay - TimeSpan.FromSeconds(1));
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
Assert.Equal(posts, factory.Mas.Bodies.Count);
|
||||
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
Assert.Equal(8, factory.Mas.Bodies.Count);
|
||||
Assert.Equal(0, factory.ValidAuthorizations(subject));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FakeMasHandler.NetworkFault)]
|
||||
[InlineData(FakeMasHandler.Timeout)]
|
||||
[InlineData(300)]
|
||||
[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);
|
||||
await factory.AddAuthorizationAsync("not-a-guid");
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(201)]
|
||||
[InlineData(204)]
|
||||
[InlineData(299)]
|
||||
public async Task Any_2xx_is_delivery(int status)
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
factory.Mas.Script(status);
|
||||
|
||||
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(identity).ToString("D")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failure_is_logged_by_status_and_delay_only_and_a_success_logs_no_failure()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
var log = new ListLogger<OidcLogoutReconciler>();
|
||||
factory.Mas.Script(503, FakeMasHandler.NetworkFault);
|
||||
|
||||
await factory.NewReconciler(logger: log).ReconcileOnceAsync(CancellationToken.None);
|
||||
|
||||
// The first subject failed with 503, the second with no response; both were delivered nothing.
|
||||
var warnings = log.Entries.Where(x => x.Level == LogLevel.Warning).Select(x => x.Message).ToArray();
|
||||
Assert.Equal(2, warnings.Length);
|
||||
Assert.Contains(warnings, x => x.Contains("503", StringComparison.Ordinal) && x.Contains("00:00:30", StringComparison.Ordinal));
|
||||
Assert.Contains(warnings, x => x.Contains("no response", StringComparison.Ordinal));
|
||||
Assert.All(log.Entries, x =>
|
||||
{
|
||||
Assert.DoesNotContain(FakeMasHandler.BodyCanary, x.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("mas.example.test", x.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("logout_token", x.Message, StringComparison.Ordinal);
|
||||
});
|
||||
|
||||
var clean = new ListLogger<OidcLogoutReconciler>();
|
||||
await factory.NewReconciler(logger: clean).ReconcileOnceAsync(CancellationToken.None);
|
||||
Assert.Empty(clean.Entries.Where(x => x.Level >= LogLevel.Warning));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_subject_who_recovers_or_whose_grants_vanish_carries_no_stale_backoff()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
var subject = factory.AccountId(identity).ToString("D");
|
||||
factory.Mas.Script(500, 500);
|
||||
var reconciler = factory.Reconciler;
|
||||
|
||||
// Recovered: Active again clears the backoff, so the next demotion is delivered at once.
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
Assert.Single(factory.Mas.Bodies);
|
||||
await factory.SetMembershipAsync(identity, MembershipStatus.Active);
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
await factory.SetMembershipAsync(identity, MembershipStatus.Suspended);
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
Assert.Equal(2, factory.Mas.Bodies.Count);
|
||||
|
||||
// Vanished: grants revoked elsewhere drop the subject from the list, which drops its backoff.
|
||||
await factory.RevokeAllAsync(subject);
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
await factory.AddAuthorizationAsync(subject);
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
Assert.Equal(3, factory.Mas.Bodies.Count);
|
||||
Assert.Equal(0, factory.ValidAuthorizations(subject));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_subject_that_throws_unexpectedly_does_not_stop_the_pass_and_keeps_its_authorizations()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var log = new ListLogger<OidcLogoutReconciler>();
|
||||
var reconciler = factory.NewReconciler(logger: log);
|
||||
var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
var subjects = new[] { first, second }.Select(x => factory.AccountId(x).ToString("D")).ToArray();
|
||||
|
||||
// Whichever subject the pass reaches first throws; the other is still logged out and revoked.
|
||||
factory.Mas.Script(FakeMasHandler.Unexpected);
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, factory.Mas.Bodies.Count);
|
||||
var firstToken = factory.Mas.LogoutTokens()[0];
|
||||
var thrown = new JsonWebToken(firstToken).Subject;
|
||||
var other = subjects.Single(x => x != thrown);
|
||||
Assert.Equal(0, factory.ValidAuthorizations(other));
|
||||
Assert.True(factory.ValidAuthorizations(thrown) > 0);
|
||||
var failure = Assert.Single(log.Entries, x => x.Level == LogLevel.Error);
|
||||
Assert.Contains(thrown, failure.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(nameof(InvalidOperationException), failure.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("not a network fault", failure.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(firstToken, failure.Message, StringComparison.Ordinal);
|
||||
|
||||
// The failed subject was not backed off: the next pass tries it again and delivers.
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
Assert.Equal(3, factory.Mas.Bodies.Count);
|
||||
Assert.Equal(0, factory.ValidAuthorizations(thrown));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cancelled_post_stops_the_pass_and_is_not_a_failed_delivery()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
var subject = factory.AccountId(identity).ToString("D");
|
||||
var reconciler = factory.Reconciler;
|
||||
|
||||
// Stopping while the post is in flight surfaces as cancellation and records no backoff: the next pass posts at once.
|
||||
factory.Mas.Script(FakeMasHandler.Hang);
|
||||
using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => reconciler.ReconcileOnceAsync(stop.Token));
|
||||
Assert.Single(factory.Mas.Bodies);
|
||||
await reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
Assert.Equal(2, factory.Mas.Bodies.Count);
|
||||
Assert.Equal(0, factory.ValidAuthorizations(subject));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_redirect_from_mas_is_not_followed_and_is_not_delivery()
|
||||
{
|
||||
using var mas = new RedirectingMas();
|
||||
using var factory = new OidcLogoutFactory(mas.LogoutUri);
|
||||
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
var subject = factory.AccountId(identity).ToString("D");
|
||||
var before = factory.ValidAuthorizations(subject);
|
||||
Assert.True(before > 0);
|
||||
|
||||
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(["POST /logout"], mas.Requests);
|
||||
Assert.Equal(before, factory.ValidAuthorizations(subject));
|
||||
|
||||
// A redirect is a non-delivery like any non-2xx: it backs off, then posts again.
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(30));
|
||||
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
Assert.Equal(["POST /logout", "POST /logout"], mas.Requests);
|
||||
Assert.Equal(before, factory.ValidAuthorizations(subject));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_logout_client_times_out_after_ten_seconds()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), OidcLogoutReconciler.RequestTimeout);
|
||||
Assert.Equal(
|
||||
TimeSpan.FromSeconds(10),
|
||||
factory.Services.GetRequiredService<IHttpClientFactory>().CreateClient(OidcLogoutReconciler.HttpClientName).Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_hosted_loop_ticks_again_after_each_interval_and_survives_a_failed_tick()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var clock = new ControlledClock();
|
||||
var log = new ListLogger<OidcLogoutReconciler>();
|
||||
var reconciler = factory.NewReconciler(clock, log);
|
||||
var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
await reconciler.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await WaitForAsync(() => factory.Mas.Bodies.Count == 1);
|
||||
Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(first).ToString("D")));
|
||||
|
||||
// A failing tick is logged and retried only after the interval, never in a tight loop.
|
||||
var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
factory.Mas.Script(FakeMasHandler.Unexpected);
|
||||
await WaitForAsync(() => clock.Waiting > 0);
|
||||
clock.FireTimers();
|
||||
await WaitForAsync(() => factory.Mas.Bodies.Count == 2);
|
||||
await Task.Delay(300);
|
||||
Assert.Equal(2, factory.Mas.Bodies.Count);
|
||||
|
||||
await WaitForAsync(() => clock.Waiting > 0);
|
||||
clock.FireTimers();
|
||||
await WaitForAsync(() => factory.Mas.Bodies.Count == 3);
|
||||
var failure = Assert.Single(log.Entries, x => x.Level == LogLevel.Error);
|
||||
Assert.Contains(nameof(InvalidOperationException), failure.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("not a network fault", failure.Message, StringComparison.Ordinal);
|
||||
await WaitForAsync(() => factory.ValidAuthorizations(factory.AccountId(second).ToString("D")) == 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await reconciler.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_pass_that_cannot_read_its_subjects_is_logged_by_type_and_retried_after_the_interval()
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var clock = new ControlledClock();
|
||||
var log = new ListLogger<OidcLogoutReconciler>();
|
||||
var flaky = new FlakyScopeFactory(factory.Services.GetRequiredService<IServiceScopeFactory>(), failures: 1);
|
||||
var reconciler = factory.NewReconciler(clock, log, flaky);
|
||||
var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended);
|
||||
await reconciler.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await WaitForAsync(() => log.Entries.Any(x => x.Level == LogLevel.Error) && clock.Waiting > 0);
|
||||
var failure = Assert.Single(log.Entries, x => x.Level == LogLevel.Error);
|
||||
Assert.Contains(nameof(InvalidOperationException), failure.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(FlakyScopeFactory.Secret, failure.Message, StringComparison.Ordinal);
|
||||
|
||||
// Never a tight loop: nothing posts until the interval passes, then the pass succeeds.
|
||||
await Task.Delay(300);
|
||||
Assert.Empty(factory.Mas.Bodies);
|
||||
clock.FireTimers();
|
||||
await WaitForAsync(() => factory.Mas.Bodies.Count == 1);
|
||||
await WaitForAsync(() => factory.ValidAuthorizations(factory.AccountId(identity).ToString("D")) == 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await reconciler.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (!condition())
|
||||
{
|
||||
Assert.True(DateTime.UtcNow < deadline, "timed out waiting for the reconciler");
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
503
tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs
Normal file
503
tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
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.Configuration;
|
||||
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;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OpenIddict.Abstractions;
|
||||
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 clock whose timers fire only when the test says so, to drive the reconciler's own loop.</summary>
|
||||
public sealed class ControlledClock : TimeProvider
|
||||
{
|
||||
private readonly List<PendingTimer> _timers = [];
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
var timer = new PendingTimer(() => callback(state));
|
||||
lock (_timers)
|
||||
{
|
||||
_timers.Add(timer);
|
||||
}
|
||||
|
||||
return timer;
|
||||
}
|
||||
|
||||
/// <summary>How many delays are currently waiting to be fired.</summary>
|
||||
public int Waiting
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_timers)
|
||||
{
|
||||
return _timers.Count(x => !x.Disposed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fires every timer that is waiting (each delay the loop is sitting in).</summary>
|
||||
public void FireTimers()
|
||||
{
|
||||
PendingTimer[] due;
|
||||
lock (_timers)
|
||||
{
|
||||
due = _timers.Where(x => !x.Disposed).ToArray();
|
||||
_timers.Clear();
|
||||
}
|
||||
|
||||
foreach (var timer in due)
|
||||
{
|
||||
timer.Fire();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PendingTimer(Action fire) : ITimer
|
||||
{
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public void Fire() => fire();
|
||||
|
||||
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Disposed = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Collects what a component logs, formatted, so a test can say what must and must not appear.</summary>
|
||||
public sealed class ListLogger<T> : ILogger<T>
|
||||
{
|
||||
private readonly List<(LogLevel Level, string Message)> _entries = [];
|
||||
|
||||
public IReadOnlyList<(LogLevel Level, string Message)> Entries
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_entries)
|
||||
{
|
||||
return _entries.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (_entries)
|
||||
{
|
||||
_entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
public const int Unexpected = -3;
|
||||
public const int Hang = -4;
|
||||
|
||||
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"),
|
||||
Unexpected => throw new InvalidOperationException("not a network fault"),
|
||||
Hang => await HangAsync(cancellationToken),
|
||||
_ => new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent(BodyCanary) }
|
||||
};
|
||||
}
|
||||
|
||||
public const string BodyCanary = "mas-body-canary";
|
||||
|
||||
private static async Task<HttpResponseMessage> HangAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
throw new InvalidOperationException("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A real HTTP listener on loopback standing in for a MAS that answers every POST with 302 to /login and GET /login with
|
||||
/// 200: what a real HttpClient does with that answer is the registered handler's rule, which a fake handler cannot see.
|
||||
/// </summary>
|
||||
public sealed class RedirectingMas : IDisposable
|
||||
{
|
||||
private readonly System.Net.Sockets.TcpListener _listener = new(IPAddress.Loopback, 0);
|
||||
private readonly List<string> _requests = [];
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private readonly Task _accepting;
|
||||
|
||||
public RedirectingMas()
|
||||
{
|
||||
_listener.Start();
|
||||
_accepting = Task.Run(AcceptAsync);
|
||||
}
|
||||
|
||||
public string LogoutUri => $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/logout";
|
||||
|
||||
/// <summary>"METHOD path" for every request received, in order.</summary>
|
||||
public IReadOnlyList<string> Requests
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_requests)
|
||||
{
|
||||
return _requests.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AcceptAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_stop.IsCancellationRequested)
|
||||
{
|
||||
var connection = await _listener.AcceptTcpClientAsync(_stop.Token);
|
||||
_ = Task.Run(() => ServeAsync(connection));
|
||||
}
|
||||
}
|
||||
catch (Exception) when (_stop.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeAsync(System.Net.Sockets.TcpClient connection)
|
||||
{
|
||||
using var owned = connection;
|
||||
try
|
||||
{
|
||||
var stream = connection.GetStream();
|
||||
using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true);
|
||||
var requestLine = await reader.ReadLineAsync(_stop.Token) ?? "";
|
||||
var length = 0;
|
||||
while (await reader.ReadLineAsync(_stop.Token) is { Length: > 0 } header)
|
||||
{
|
||||
if (header.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
length = int.Parse(header["Content-Length:".Length..].Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var body = new char[length];
|
||||
var read = 0;
|
||||
while (read < length)
|
||||
{
|
||||
var n = await reader.ReadAsync(body.AsMemory(read), _stop.Token);
|
||||
if (n == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
read += n;
|
||||
}
|
||||
|
||||
var parts = requestLine.Split(' ');
|
||||
lock (_requests)
|
||||
{
|
||||
_requests.Add($"{parts[0]} {parts[1]}");
|
||||
}
|
||||
|
||||
var response = parts[0] == "POST"
|
||||
? "HTTP/1.1 302 Found\r\nLocation: /login\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
: "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes(response), _stop.Token);
|
||||
}
|
||||
catch (Exception error) when (error is IOException or OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stop.Cancel();
|
||||
_listener.Stop();
|
||||
try
|
||||
{
|
||||
_accepting.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch (AggregateException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A scope factory that fails the first scopes asked of it, as a database outage would.</summary>
|
||||
public sealed class FlakyScopeFactory(IServiceScopeFactory inner, int failures) : IServiceScopeFactory
|
||||
{
|
||||
public const string Secret = "scope-factory-secret-detail";
|
||||
|
||||
private int _failures = failures;
|
||||
|
||||
public IServiceScope CreateScope()
|
||||
{
|
||||
if (Interlocked.Decrement(ref _failures) >= 0)
|
||||
{
|
||||
throw new InvalidOperationException(Secret);
|
||||
}
|
||||
|
||||
return inner.CreateScope();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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");
|
||||
|
||||
private readonly string _logoutUri;
|
||||
private readonly bool _fakeTransport;
|
||||
|
||||
public OidcLogoutFactory()
|
||||
{
|
||||
_logoutUri = LogoutUri;
|
||||
_fakeTransport = true;
|
||||
}
|
||||
|
||||
/// <summary>Keeps the real registered HttpClient and its primary handler and posts to <paramref name="logoutUri"/>.</summary>
|
||||
public OidcLogoutFactory(string logoutUri)
|
||||
{
|
||||
_logoutUri = logoutUri;
|
||||
_fakeTransport = false;
|
||||
}
|
||||
|
||||
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);
|
||||
if (_fakeTransport)
|
||||
{
|
||||
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(
|
||||
TimeProvider? time = null,
|
||||
ILogger<OidcLogoutReconciler>? logger = null,
|
||||
IServiceScopeFactory? scopes = null) => new(
|
||||
scopes ?? Services.GetRequiredService<IServiceScopeFactory>(),
|
||||
Services.GetRequiredService<OidcIssuerOptions>(),
|
||||
Services.GetRequiredService<OidcKeyMaterial>(),
|
||||
Services.GetRequiredService<IOptions<OpenIddictServerOptions>>(),
|
||||
Services.GetRequiredService<IHttpClientFactory>(),
|
||||
time ?? Time,
|
||||
logger ?? NullLogger<OidcLogoutReconciler>.Instance);
|
||||
|
||||
public WebApplicationFactoryClientOptions HostClientOptions() => 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(HostClientOptions());
|
||||
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(HostClientOptions());
|
||||
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>Gives <paramref name="subject"/> one more valid ad hoc authorization of the MAS client.</summary>
|
||||
public async Task AddAuthorizationAsync(string subject)
|
||||
{
|
||||
using var scope = 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 = subject,
|
||||
Status = OpenIddictConstants.Statuses.Valid,
|
||||
Type = OpenIddictConstants.AuthorizationTypes.AdHoc
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Revokes every authorization of the subject, as an operator would outside the reconciler.</summary>
|
||||
public async Task RevokeAllAsync(string subject)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var authorizations = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
|
||||
await foreach (var authorization in authorizations.FindBySubjectAsync(subject))
|
||||
{
|
||||
await authorizations.TryRevokeAsync(authorization);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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(HostClientOptions());
|
||||
return new JsonWebKeySet(await client.GetStringAsync("/.well-known/jwks"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue