diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md index 5698c25..a7f59a4 100644 --- a/docs/oidc-issuer.md +++ b/docs/oidc-issuer.md @@ -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:///upstream/backchannel-logout/`; 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 diff --git a/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs index 1a14457..13ba192 100644 --- a/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs +++ b/src/Bifrost.Web/Configuration/OidcIssuerOptions.cs @@ -38,4 +38,7 @@ public sealed class OidcClientOptions public string ClientSecretPath { get; init; } = string.Empty; public string RedirectUri { get; init; } = string.Empty; + + /// MAS's back-channel logout URL for this provider. Empty: no logout is sent and no session is ended. + public string BackchannelLogoutUri { get; init; } = string.Empty; } diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 6d164e2..b435347 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -117,8 +117,8 @@ public static class OidcEndpoints return Results.Json(claims); } - /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints. - private static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler. + internal static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) { if (!Guid.TryParse(subject, out var accountId)) { diff --git a/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs index 58f541a..8f0be75 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs @@ -47,6 +47,13 @@ public sealed class OidcKeyMaterial : IDisposable throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:RedirectUri must be an absolute http(s) URI."); } + if (!string.IsNullOrWhiteSpace(mas.BackchannelLogoutUri) && + (!Uri.TryCreate(mas.BackchannelLogoutUri, UriKind.Absolute, out var logout) || + logout.Scheme is not ("https" or "http"))) + { + throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri must be an absolute http(s) URI."); + } + var ecdsa = LoadSigningKey(options.SigningKeyPath); try { diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs new file mode 100644 index 0000000..ac6fa72 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -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; + +/// +/// 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. +/// +public sealed class OidcLogoutReconciler( + IServiceScopeFactory scopeFactory, + OidcIssuerOptions options, + OidcKeyMaterial material, + IOptions serverOptions, + IHttpClientFactory httpClients, + TimeProvider time, + ILogger logger) : BackgroundService +{ + public const string HttpClientName = "bifrost-oidc-logout"; + public const string LogoutEvent = "http://schemas.openid.net/event/backchannel-logout"; + public const string LogoutTokenType = "logout+jwt"; + + public static readonly TimeSpan Interval = TimeSpan.FromSeconds(30); + public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + public static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(2); + public static readonly TimeSpan MaxBackoff = TimeSpan.FromMinutes(15); + + private readonly Dictionary _backoff = new(StringComparer.Ordinal); + + private sealed record Backoff(int Failures, DateTimeOffset NotBefore); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ReconcileOnceAsync(stoppingToken); + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception error) + { + // The type only: messages can carry connection strings or URLs. + logger.LogError("Back-channel logout reconcile failed: {ErrorType}", error.GetType().Name); + try + { + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + } + + /// One reconcile pass. Subjects are independent: one that cannot be delivered never blocks another. + public async Task ReconcileOnceAsync(CancellationToken cancellationToken) + { + var subjects = await ListSubjectsAsync(cancellationToken); + foreach (var gone in _backoff.Keys.Except(subjects).ToArray()) + { + _backoff.Remove(gone); + } + + foreach (var subject in subjects) + { + cancellationToken.ThrowIfCancellationRequested(); + 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> ListSubjectsAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var applications = scope.ServiceProvider.GetRequiredService(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + + var subjects = new HashSet(StringComparer.Ordinal); + var application = await applications.FindByClientIdAsync(options.Clients.Mas.ClientId, cancellationToken); + if (application is null || await applications.GetIdAsync(application, cancellationToken) is not { } applicationId) + { + return subjects; + } + + await foreach (var authorization in authorizations.FindByApplicationIdAsync(applicationId, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken) && + await authorizations.GetSubjectAsync(authorization, cancellationToken) is { Length: > 0 } subject) + { + subjects.Add(subject); + } + } + + return subjects; + } + + private async Task ReconcileSubjectAsync(string subject, CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (await OidcEndpoints.IsStillActiveMemberAsync(db, subject, cancellationToken)) + { + _backoff.Remove(subject); + return; + } + + // 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(); + await foreach (var authorization in authorizations.FindBySubjectAsync(subject, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken)) + { + await authorizations.TryRevokeAsync(authorization, cancellationToken); + } + } + + _backoff.Remove(subject); + return; + } + + // 400 (token refused) and 404 (unknown provider) are configuration faults: retrying sooner cannot fix them. + var fault = status is HttpStatusCode.BadRequest or HttpStatusCode.NotFound; + var delay = fault ? MaxBackoff : BackoffFor(failures); + _backoff[subject] = new Backoff(failures, time.GetUtcNow() + delay); + logger.LogWarning( + "Back-channel logout to MAS failed with {Status}; retrying in {Delay}", + status is { } s ? (int)s : "no response", + delay); + } + + internal static TimeSpan BackoffFor(int failures) + { + var seconds = Interval.TotalSeconds * Math.Pow(2, Math.Min(failures, 16) - 1); + return TimeSpan.FromSeconds(Math.Min(seconds, MaxBackoff.TotalSeconds)); + } + + /// The status MAS answered, or null on a network fault or timeout. The response body is never read. + private async Task SendLogoutAsync(string subject, CancellationToken cancellationToken) + { + var token = BuildLogoutToken(subject); + using var request = new HttpRequestMessage(HttpMethod.Post, options.Clients.Mas.BackchannelLogoutUri) + { + Content = new FormUrlEncodedContent([new KeyValuePair("logout_token", token)]) + }; + try + { + using var response = await httpClients.CreateClient(HttpClientName) + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return response.StatusCode; + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + return null; + } + } + + /// + /// The OIDC back-channel logout token MAS verifies like an id_token: iss and aud as the id_token carries them, exp + /// and iat both present, an events member, and neither nonce nor sid (MAS finds the session by sub). + /// + private string BuildLogoutToken(string subject) + { + var now = time.GetUtcNow(); + var descriptor = new SecurityTokenDescriptor + { + TokenType = LogoutTokenType, + Issuer = serverOptions.Value.Issuer!.AbsoluteUri, + Audience = options.Clients.Mas.ClientId, + IssuedAt = now.UtcDateTime, + Expires = now.UtcDateTime + TokenLifetime, + SigningCredentials = new SigningCredentials(material.SigningKey, SecurityAlgorithms.EcdsaSha256), + Claims = new Dictionary + { + [Claims.Subject] = Guid.TryParse(subject, out var id) ? id.ToString("D") : subject, + [Claims.JwtId] = Guid.NewGuid().ToString("D"), + ["events"] = new Dictionary { [LogoutEvent] = new Dictionary() } + } + }; + return new JsonWebTokenHandler().CreateToken(descriptor); + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs index 0f1da67..5ad30b8 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs @@ -68,6 +68,14 @@ public static class OidcServiceCollectionExtensions }); services.AddHostedService(); + 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(); + } + return services; } } diff --git a/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs index a5bc0e0..73d5200 100644 --- a/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs +++ b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs @@ -87,7 +87,31 @@ public sealed class OidcKeyMaterialTests : IDisposable Assert.Contains("Bifrost:Oidc:SigningKeyPath", failure.Message, StringComparison.Ordinal); } - private OidcIssuerOptions Options(string? blank = null, string? redirectUri = null) => new() + [Theory] + [InlineData("not a uri")] + [InlineData("/upstream/backchannel-logout/01ABC")] + [InlineData("ftp://mas.example.test/upstream/backchannel-logout/01ABC")] + public void A_back_channel_logout_uri_that_is_not_absolute_http_stops_startup_naming_the_setting_not_the_value(string uri) + { + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options(logoutUri: uri))); + + Assert.Contains("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(uri, failure.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("https://mas.example.test/upstream/backchannel-logout/01ABC")] + [InlineData("http://mas.internal/upstream/backchannel-logout/01ABC")] + public void An_absent_or_absolute_http_back_channel_logout_uri_loads(string uri) + { + using var material = OidcKeyMaterial.Load(Options(logoutUri: uri)); + + Assert.NotNull(material.SigningKey); + } + + private OidcIssuerOptions Options(string? blank = null, string? redirectUri = null, string logoutUri = "") => new() { SigningKeyPath = blank == "SigningKeyPath" ? "" : _kit.SigningKeyPath, EncryptionKeyPath = blank == "EncryptionKeyPath" ? "" : _kit.EncryptionKeyPath, @@ -97,7 +121,8 @@ public sealed class OidcKeyMaterialTests : IDisposable { ClientId = blank == "ClientId" ? "" : OidcTestKit.ClientId, ClientSecretPath = blank == "ClientSecretPath" ? "" : _kit.ClientSecretPath, - RedirectUri = blank == "RedirectUri" ? "" : redirectUri ?? OidcTestKit.RedirectUri + RedirectUri = blank == "RedirectUri" ? "" : redirectUri ?? OidcTestKit.RedirectUri, + BackchannelLogoutUri = logoutUri } } }; diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs new file mode 100644 index 0000000..8aa5d15 --- /dev/null +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -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(); + var found = new List(); + await foreach (var authorization in manager.FindBySubjectAsync(factory.AccountId(identity).ToString("D"))) + { + found.Add(authorization); + } + + Assert.NotEmpty(found); + Assert.True(await manager.TryRevokeAsync(found[0])); + } + + [Theory] + [InlineData(MembershipStatus.Suspended)] + [InlineData(MembershipStatus.PendingApproval)] + [InlineData(MembershipStatus.Authenticated)] + public async Task A_member_who_is_not_active_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status) + { + using var factory = new OidcLogoutFactory(); + var (identity, idToken) = await factory.GrantAsync(status); + var subject = factory.AccountId(identity).ToString("D"); + var reconciler = factory.Reconciler; + + await reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = Assert.Single(factory.Mas.LogoutTokens()); + var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, new TokenValidationParameters + { + ValidIssuer = OidcLogoutFactory.Origin.AbsoluteUri, + ValidAudience = OidcTestKit.ClientId, + IssuerSigningKeys = (await factory.JwksAsync()).GetSigningKeys(), + ValidAlgorithms = ["ES256"], + ValidTypes = ["logout+jwt"], + ValidateLifetime = false + }); + Assert.True(result.IsValid, result.Exception?.Message); + var jwt = (JsonWebToken)result.SecurityToken; + Assert.Equal("logout+jwt", jwt.Typ); + Assert.Equal(new JsonWebToken(idToken).Issuer, jwt.Issuer); + Assert.Equal(subject, jwt.Subject); + Assert.Equal(TimeSpan.FromMinutes(2), jwt.ValidTo - jwt.IssuedAt); + Assert.Equal(factory.Time.GetUtcNow().ToUnixTimeSeconds(), new DateTimeOffset(jwt.IssuedAt).ToUnixTimeSeconds()); + Assert.False(string.IsNullOrEmpty(jwt.Id)); + var payload = JsonDocument.Parse(Base64UrlEncoder.Decode(jwt.EncodedPayload)).RootElement; + var events = payload.GetProperty("events"); + Assert.Equal(["http://schemas.openid.net/event/backchannel-logout"], events.EnumerateObject().Select(x => x.Name).ToArray()); + Assert.Equal(JsonValueKind.Object, events.EnumerateObject().Single().Value.ValueKind); + Assert.False(payload.TryGetProperty("nonce", out _)); + Assert.False(payload.TryGetProperty("sid", out _)); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Single(factory.Mas.Bodies); + } + + [Fact] + public async Task Two_logouts_for_one_subject_carry_different_jti() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(500, 200); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + factory.Time.Advance(TimeSpan.FromSeconds(30)); + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var ids = factory.Mas.LogoutTokens().Select(x => new JsonWebToken(x).Id).ToArray(); + Assert.Equal(2, ids.Length); + Assert.NotEqual(ids[0], ids[1]); + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(identity).ToString("D"))); + } + + [Fact] + public async Task An_active_member_is_never_sent_and_keeps_the_authorizations() + { + using var factory = new OidcLogoutFactory(); + var (suspended, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var (active, _) = await factory.GrantAsync(MembershipStatus.Active); + var activeSubject = factory.AccountId(active).ToString("D"); + var before = factory.ValidAuthorizations(activeSubject); + Assert.True(before > 0); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = Assert.Single(factory.Mas.LogoutTokens()); + Assert.Equal(factory.AccountId(suspended).ToString("D"), new JsonWebToken(token).Subject); + Assert.Equal(before, factory.ValidAuthorizations(activeSubject)); + } + + [Fact] + public async Task One_subject_that_cannot_be_delivered_does_not_block_another() + { + using var factory = new OidcLogoutFactory(); + var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(500); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(2, factory.Mas.Bodies.Count); + var delivered = new[] { first, second }.Count(x => factory.ValidAuthorizations(factory.AccountId(x).ToString("D")) == 0); + Assert.Equal(1, delivered); + } + + [Fact] + public async Task Delivery_retries_with_exponential_capped_backoff_and_revokes_only_after_a_2xx() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(500, 500, 500, 500, 500, 500, 500, 200); + var reconciler = factory.Reconciler; + + var posts = 0; + foreach (var delay in ExpectedBackoff) + { + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(++posts, factory.Mas.Bodies.Count); + Assert.True(factory.ValidAuthorizations(subject) > 0, "a failed delivery must not revoke"); + + factory.Time.Advance(delay - TimeSpan.FromSeconds(1)); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(posts, factory.Mas.Bodies.Count); + + factory.Time.Advance(TimeSpan.FromSeconds(1)); + } + + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(8, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Theory] + [InlineData(FakeMasHandler.NetworkFault)] + [InlineData(FakeMasHandler.Timeout)] + [InlineData(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(); + 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(); + 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(); + 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(() => 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().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(); + 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(); + var flaky = new FlakyScopeFactory(factory.Services.GetRequiredService(), 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 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().OfType()); + } + + [Fact] + public void The_reconciler_is_hosted_when_the_uri_is_set() + { + using var factory = new OidcLogoutFactory(); + using var client = factory.CreateClient(); + + Assert.NotNull(factory.Reconciler); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs new file mode 100644 index 0000000..b7425f6 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/OidcLogoutKit.cs @@ -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; + +/// A clock the test moves; its timers never fire, so the hosted reconciler only ticks when a test calls it. +public sealed class ManualClock : TimeProvider +{ + private DateTimeOffset _now = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) => + base.CreateTimer(callback, state, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); +} + +/// A clock whose timers fire only when the test says so, to drive the reconciler's own loop. +public sealed class ControlledClock : TimeProvider +{ + private readonly List _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; + } + + /// How many delays are currently waiting to be fired. + public int Waiting + { + get + { + lock (_timers) + { + return _timers.Count(x => !x.Disposed); + } + } + } + + /// Fires every timer that is waiting (each delay the loop is sitting in). + 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; + } + } +} + +/// Collects what a component logs, formatted, so a test can say what must and must not appear. +public sealed class ListLogger : ILogger +{ + 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 state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (_entries) + { + _entries.Add((logLevel, formatter(state, exception))); + } + } +} + +/// +/// A stand-in for MAS's back-channel logout route: answers a scripted status per POST (then 200), records every form +/// body, and can fail like the network does. +/// +public sealed class FakeMasHandler : HttpMessageHandler +{ + public const int NetworkFault = -1; + public const int Timeout = -2; + public const int Unexpected = -3; + public const int Hang = -4; + + private readonly Queue _script = new(); + private readonly List _bodies = []; + + public void Script(params int[] statuses) + { + lock (_script) + { + foreach (var status in statuses) + { + _script.Enqueue(status); + } + } + } + + public IReadOnlyList Bodies + { + get + { + lock (_bodies) + { + return _bodies.ToArray(); + } + } + } + + /// The logout_tokens posted, in order; asserts each body is exactly one logout_token field. + public IReadOnlyList LogoutTokens() => Bodies.Select(body => + { + var form = QueryHelpers.ParseQuery(body); + Assert.Equal(["logout_token"], form.Keys.ToArray()); + return form["logout_token"].ToString(); + }).ToArray(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("application/x-www-form-urlencoded", request.Content!.Headers.ContentType!.MediaType); + var body = await request.Content.ReadAsStringAsync(cancellationToken); + lock (_bodies) + { + _bodies.Add(body); + } + + int status; + lock (_script) + { + status = _script.Count > 0 ? _script.Dequeue() : 200; + } + + return status switch + { + NetworkFault => throw new HttpRequestException("network down"), + Timeout => throw new TaskCanceledException("timed out"), + 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 HangAsync(CancellationToken cancellationToken) + { + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("unreachable"); + } +} + +/// +/// 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. +/// +public sealed class RedirectingMas : IDisposable +{ + private readonly System.Net.Sockets.TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly List _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"; + + /// "METHOD path" for every request received, in order. + public IReadOnlyList 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) + { + } + } +} + +/// A scope factory that fails the first scopes asked of it, as a database outage would. +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(); + } +} + +/// An OIDC host with MAS's back-channel logout URI set, a fake MAS behind it and a clock the test drives. +public sealed class OidcLogoutFactory : OidcWebApplicationFactory +{ + public const string LogoutUri = "https://mas.example.test/upstream/backchannel-logout/01TESTPROVIDER"; + public static readonly Uri Origin = new("https://localhost"); + + private readonly string _logoutUri; + private readonly bool _fakeTransport; + + public OidcLogoutFactory() + { + _logoutUri = LogoutUri; + _fakeTransport = true; + } + + /// Keeps the real registered HttpClient and its primary handler and posts to . + public OidcLogoutFactory(string logoutUri) + { + _logoutUri = logoutUri; + _fakeTransport = false; + } + + public FakeMasHandler Mas { get; } = new(); + + public ManualClock Time { get; } = new(); + + public OidcLogoutReconciler Reconciler => Services.GetServices().OfType().Single(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.UseSetting("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", _logoutUri); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Time); + if (_fakeTransport) + { + services.AddHttpClient(OidcLogoutReconciler.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => Mas); + } + }); + } + + /// A reconciler with no memory, built over this host's database, as a restarted process would have. + public OidcLogoutReconciler NewReconciler( + TimeProvider? time = null, + ILogger? logger = null, + IServiceScopeFactory? scopes = null) => new( + scopes ?? Services.GetRequiredService(), + Services.GetRequiredService(), + Services.GetRequiredService(), + Services.GetRequiredService>(), + Services.GetRequiredService(), + time ?? Time, + logger ?? NullLogger.Instance); + + public WebApplicationFactoryClientOptions HostClientOptions() => new() + { + AllowAutoRedirect = false, + HandleCookies = true, + BaseAddress = Origin + }; + + /// Registers a member, sets , and completes one authorization-code flow for them. + public async Task<(string Identity, string IdToken)> GrantAsync(MembershipStatus status = MembershipStatus.Active) + { + using var client = CreateClient(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 + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = OidcTestKit.RedirectUri, + ["code_verifier"] = verifier + }) + }; + exchange.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{OidcTestKit.ClientId}:{Kit.ClientSecret}"))); + using var tokens = await bare.SendAsync(exchange); + Assert.Equal(HttpStatusCode.OK, tokens.StatusCode); + var idToken = (await tokens.Content.ReadFromJsonAsync()).GetProperty("id_token").GetString()!; + + if (status != MembershipStatus.Active) + { + await SetMembershipAsync(identity, status); + } + + return (identity, idToken); + } + + private static bool IsAuthorizeRedirect(HttpResponseMessage response) => + response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found && + response.Headers.Location is { } location && + (location.IsAbsoluteUri ? location.AbsolutePath : location.OriginalString).StartsWith("/connect/authorize", StringComparison.Ordinal); + + /// Gives one more valid ad hoc authorization of the MAS client. + public async Task AddAuthorizationAsync(string subject) + { + using var scope = Services.CreateScope(); + var applications = scope.ServiceProvider.GetRequiredService(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + var application = (await applications.FindByClientIdAsync(OidcTestKit.ClientId))!; + await authorizations.CreateAsync(new OpenIddictAuthorizationDescriptor + { + ApplicationId = await applications.GetIdAsync(application), + Subject = subject, + Status = OpenIddictConstants.Statuses.Valid, + Type = OpenIddictConstants.AuthorizationTypes.AdHoc + }); + } + + /// Revokes every authorization of the subject, as an operator would outside the reconciler. + public async Task RevokeAllAsync(string subject) + { + using var scope = Services.CreateScope(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + await foreach (var authorization in authorizations.FindBySubjectAsync(subject)) + { + await authorizations.TryRevokeAsync(authorization); + } + } + + /// How many of the subject's authorizations are still valid. + public int ValidAuthorizations(string subject) + { + using var scope = Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService() + .Set().AsNoTracking() + .Count(x => x.Subject == subject && x.Status == "valid"); + } + + public async Task JwksAsync() + { + using var client = CreateClient(HostClientOptions()); + return new JsonWebKeySet(await client.GetStringAsync("/.well-known/jwks")); + } +}