diff --git a/Documentation/aspire/index.md b/Documentation/aspire/index.md index f6b8a2e..d4393d6 100644 --- a/Documentation/aspire/index.md +++ b/Documentation/aspire/index.md @@ -95,6 +95,25 @@ authproxy See [Services](../configuration/services.md) for the underlying configuration model. +### Identity verification denials + +After declaring a service's identity endpoint as an authorization authority, you can make any denial end +the caller's local AuthProxy session: + +```csharp +authproxy + .WithIdentityVerification("main", IdentityVerificationMode.Required) + .WithSessionTerminationOnIdentityDenial(); +``` + +`WithSessionTerminationOnIdentityDenial` is global and composes deterministically across services: calling +it more than once still writes the same enabled session setting. A denial clears AuthProxy's local session +before the existing `403` response; it does not initiate logout at the external identity provider. Omit the +call to preserve the default behavior, where the authenticated session remains active after a denial. + +See [Identity verification](../configuration/services.md#identity-enrichment) for the denial matrix +and the direct configuration equivalent. + ### Anonymous paths Declare the request paths on a service that should be served without a session — a magic-link diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index cd950ca..ea393cc 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -371,6 +371,7 @@ context is **session-scoped or short-lived** — closing the browser ends them. "Session": { "Lifetime": "12:00:00", "SlidingExpiration": false, + "TerminateOnIdentityDenial": false, "IdentityRevalidationInterval": "00:10:00", "TenantRevalidationInterval": "00:10:00" } @@ -383,6 +384,7 @@ context is **session-scoped or short-lived** — closing the browser ends them. |----------|---------|-------------| | `Lifetime` | `12:00:00` | Absolute lifetime of the authentication ticket. When it elapses the user must re-authenticate with the identity provider, even in a browser session that never closed. | | `SlidingExpiration` | `false` | Whether activity extends the ticket lifetime. Disabled by default so `Lifetime` is a hard bound. | +| `TerminateOnIdentityDenial` | `false` | Whether an identity-verification denial ends the local AuthProxy session before serving the forbidden response. This signs out of AuthProxy and clears its session cookies; it does not log the caller out of the external identity provider. | | `IdentityRevalidationInterval` | `00:10:00` | How long a resolved authorization is remembered before the identity details — and the authorization they represent — are re-resolved against the services. Zero or negative falls back to ten minutes. | | `TenantRevalidationInterval` | `00:10:00` | How long a tenant selected through the [tenant-selection flow](tenant-selection.md) is trusted before it is re-validated against `TenantsEndpoint`, so revoked tenant access takes effect without per-request backend calls. Zero or negative disables re-validation. | diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 262cf1c..46bb1b5 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -277,6 +277,14 @@ behind: the sealed `.cratis-identity-authorization` record is cleared, the reada cookie is expired, and the in-memory result is evicted. Without that, the next request would present one of them and skip the question that was just answered no. +Set `Cratis:AuthProxy:Session:TerminateOnIdentityDenial` to `true` when a denial should also end the local +AuthProxy session. +AuthProxy signs out of its authentication cookie and clears the identity, authorization, tenant, invitation, +registration, provider-selection, transient authentication, and configured additional logout cookies before +serving the same `403`. It retains capability-entry and in-progress logout state, and does not initiate logout +at the external identity provider. The default is `false`, which preserves the authenticated session exactly +as earlier releases did. + > [!IMPORTANT] > Two of those three erasures are *requests to the browser*, not guarantees. Clearing a cookie means > sending a `Set-Cookie` that expires it, and a non-browser caller is free to ignore it and keep presenting @@ -331,6 +339,7 @@ From Aspire: ```csharp authProxy.WithIdentityVerification("portal", IdentityVerificationMode.Required); +authProxy.WithSessionTerminationOnIdentityDenial(); ``` > **Requiring verification makes that service a single point of failure, on purpose.** While it is down, diff --git a/Source/Aspire.Specs/for_AuthProxyExtensions/when_enabling_session_termination_on_identity_denial.cs b/Source/Aspire.Specs/for_AuthProxyExtensions/when_enabling_session_termination_on_identity_denial.cs new file mode 100644 index 0000000..d5d9e49 --- /dev/null +++ b/Source/Aspire.Specs/for_AuthProxyExtensions/when_enabling_session_termination_on_identity_denial.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Aspire.for_AuthProxyExtensions; + +public class when_enabling_session_termination_on_identity_denial : given.an_auth_proxy_resource +{ + Dictionary _environment; + + void Establish() + { + _resource.WithSessionTerminationOnIdentityDenial(); + _resource.WithSessionTerminationOnIdentityDenial(); + } + + async Task Because() => _environment = await EnvironmentVariables(); + + [Fact] void should_enable_the_global_session_setting() => + _environment["Cratis__AuthProxy__Session__TerminateOnIdentityDenial"].ShouldEqual(bool.TrueString); + + [Fact] void should_not_create_a_per_service_setting() => + _environment.Keys.Any(_ => _.Contains("Services", StringComparison.Ordinal)).ShouldBeFalse(); +} diff --git a/Source/Aspire/AuthProxyExtensions.cs b/Source/Aspire/AuthProxyExtensions.cs index 5135268..b0090db 100644 --- a/Source/Aspire/AuthProxyExtensions.cs +++ b/Source/Aspire/AuthProxyExtensions.cs @@ -126,6 +126,27 @@ public static IResourceBuilder WithIdentityVerification( return builder; } + /// + /// Terminates the local AuthProxy session whenever identity verification refuses a caller. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// The same for chaining. + /// + /// This is a global session setting rather than a per-service setting. A refusal from any participating + /// identity-details service signs the caller out of AuthProxy's local cookie scheme and clears the + /// AuthProxy-owned session cookies before the existing forbidden response is served. It does not sign + /// the caller out of the external identity provider. + /// + /// Not calling this method preserves the released behavior: the refusal is served while the local + /// authentication session remains active. + /// + /// + public static IResourceBuilder WithSessionTerminationOnIdentityDenial( + this IResourceBuilder builder) + where T : IResourceWithEnvironment => + builder.WithEnvironment($"{ConfigPrefix}__Session__TerminateOnIdentityDenial", bool.TrueString); + /// /// Registers a frontend (SPA / static-assets) endpoint for a named service in AuthProxy. /// diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_denial_session_termination_is_disabled.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_denial_session_termination_is_disabled.cs new file mode 100644 index 0000000..9deeca8 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_denial_session_termination_is_disabled.cs @@ -0,0 +1,65 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_AccessControl; + +/// The default-off compatibility mode refuses without destroying the local cookie session. +public class when_identity_denial_session_termination_is_disabled : IAsyncLifetime +{ + RequiredVerificationHarness? _harness; + HttpResponseMessage? _denial; + HttpResponseMessage? _recovered; + bool _forwardedOnDenial; + bool _forwardedAfterRecovery; + bool _authenticationCookieWasDeleted; + + public async Task InitializeAsync() + { + _harness = new RequiredVerificationHarness(terminateOnIdentityDenial: false); + using var client = _harness.CreateSecurityClient(); + var session = _harness.AuthenticatedRequest(RequiredVerificationHarness.ProtectedPath); + var cookie = session.Message.Headers.GetValues("Cookie").Single(); + + _harness.FailEveryVerification(); + _harness.Origin.Clear(); + _denial = await client.SendAsync(session.Message); + _forwardedOnDenial = _harness.Origin.ReceivedAnythingFor(RequiredVerificationHarness.ProtectedPath); + _authenticationCookieWasDeleted = session.AuthenticationCookieNames.Any( + _ => RequiredVerificationHarness.Deletes(_denial, _)); + + _harness.VerifyEveryCaller(); + _harness.Origin.Clear(); + using var retry = new HttpRequestMessage(HttpMethod.Get, RequiredVerificationHarness.ProtectedPath); + retry.Headers.TryAddWithoutValidation("Cookie", cookie); + _recovered = await client.SendAsync(retry); + _forwardedAfterRecovery = _harness.Origin.ReceivedAnythingFor(RequiredVerificationHarness.ProtectedPath); + } + + public async Task DisposeAsync() + { + if (_harness is not null) + { + await _harness.DisposeAsync(); + } + } + + [Fact] + public void should_still_return_forbidden() => + Assert.Equal(HttpStatusCode.Forbidden, _denial!.StatusCode); + + [Fact] + public void should_not_forward_the_denied_request() => + Assert.False(_forwardedOnDenial); + + [Fact] + public void should_preserve_the_authentication_cookie() => + Assert.False(_authenticationCookieWasDeleted); + + [Fact] + public void should_reuse_the_same_session_after_verification_recovers() => + Assert.Equal(HttpStatusCode.OK, _recovered!.StatusCode); + + [Fact] + public void should_forward_after_verification_recovers() => + Assert.True(_forwardedAfterRecovery); +} diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_denial_terminates_the_local_session.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_denial_terminates_the_local_session.cs new file mode 100644 index 0000000..31be606 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_denial_terminates_the_local_session.cs @@ -0,0 +1,91 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_AccessControl; + +/// +/// Required verification has one terminal negative outcome. Whether the origin states no, is unavailable, +/// or answers something that cannot be a verdict, the running ingress must refuse before forwarding and +/// terminate the local cookie session without starting an external-provider logout. +/// +/// The running proxy whose configuration enables session termination. +[Collection(RequiredVerificationSpecCollection.Name)] +public class when_identity_denial_terminates_the_local_session( + RequiredVerificationHarness harness) : IAsyncLifetime +{ + static readonly Action[] _denials = + [ + _ => _.DenyEveryCaller(), + _ => _.FailEveryVerification(), + _ => _.AnswerWithoutVerdict(), + _ => _.AnswerWithMalformedJson(), + _ => _.AnswerWithConflictingVerdicts() + ]; + + readonly List _statuses = []; + readonly List _forwarded = []; + readonly List _forbiddenPages = []; + readonly List _redirected = []; + readonly List _missingCookieExpiries = []; + + public async Task InitializeAsync() + { + using var client = harness.CreateSecurityClient(); + foreach (var deny in _denials) + { + deny(harness); + harness.Origin.Clear(); + var session = harness.AuthenticatedRequest(RequiredVerificationHarness.ProtectedPath); + using var response = await client.SendAsync(session.Message); + _statuses.Add(response.StatusCode); + _forwarded.Add(harness.Origin.ReceivedAnythingFor(RequiredVerificationHarness.ProtectedPath)); + _forbiddenPages.Add( + (await response.Content.ReadAsStringAsync()) + .Contains(RequiredVerificationHarness.ForbiddenMarker, StringComparison.Ordinal)); + _redirected.Add(response.Headers.Location is not null); + + var expected = session.AuthenticationCookieNames.Concat( + [ + Cookies.Identity, + Cookies.IdentityAuthorization, + Cookies.Tenant, + Cookies.Tenants, + Cookies.InviteToken, + Cookies.InvitationEntryState, + Cookies.Registration, + Cookies.Providers, + RequiredVerificationHarness.CorrelationCookie, + RequiredVerificationHarness.NonceCookie, + RequiredVerificationHarness.AdditionalSessionCookie + ]); + _missingCookieExpiries.AddRange( + expected.Where(_ => !RequiredVerificationHarness.Deletes(response, _))); + } + } + + public Task DisposeAsync() + { + harness.VerifyEveryCaller(); + return Task.CompletedTask; + } + + [Fact] + public void should_return_forbidden_for_every_negative_outcome() => + Assert.All(_statuses, _ => Assert.Equal(HttpStatusCode.Forbidden, _)); + + [Fact] + public void should_serve_the_forbidden_page_for_every_negative_outcome() => + Assert.All(_forbiddenPages, Assert.True); + + [Fact] + public void should_never_forward_the_protected_request() => + Assert.DoesNotContain(true, _forwarded); + + [Fact] + public void should_expire_the_complete_owned_session() => + Assert.Empty(_missingCookieExpiries); + + [Fact] + public void should_not_initiate_external_provider_logout() => + Assert.DoesNotContain(true, _redirected); +} diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_verification_is_required.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_verification_is_required.cs index 425b683..5344b1f 100644 --- a/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_verification_is_required.cs +++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_verification_is_required.cs @@ -32,6 +32,7 @@ public class when_identity_verification_is_required(RequiredVerificationHarness HttpResponseMessage? _admittedPage; bool _originSawTheVerifiedCaller; + bool _positiveSessionWasPreserved; public async Task InitializeAsync() { @@ -40,19 +41,24 @@ public async Task InitializeAsync() harness.FailEveryVerification(); harness.Origin.Clear(); - _refusedPage = await client.SendAsync(Request(RequiredVerificationHarness.ProtectedPath, "unverified-page")); + _refusedPage = await client.SendAsync( + harness.AuthenticatedRequest(RequiredVerificationHarness.ProtectedPath).Message); _refusedBody = await _refusedPage.Content.ReadAsStringAsync(); _originSawTheProtectedPath = harness.Origin.ReceivedAnythingFor(RequiredVerificationHarness.ProtectedPath); harness.Origin.Clear(); - _refusedAsset = await client.SendAsync(Request(RequiredVerificationHarness.StaticAssetPath, "unverified-asset")); + _refusedAsset = await client.SendAsync( + harness.AuthenticatedRequest(RequiredVerificationHarness.StaticAssetPath).Message); _originSawTheStaticAsset = harness.Origin.ReceivedAnythingFor(RequiredVerificationHarness.StaticAssetPath); harness.VerifyEveryCaller(); harness.Origin.Clear(); - _admittedPage = await client.SendAsync(Request(RequiredVerificationHarness.ProtectedPath, "verified")); + var admitted = harness.AuthenticatedRequest(RequiredVerificationHarness.ProtectedPath); + _admittedPage = await client.SendAsync(admitted.Message); _originSawTheVerifiedCaller = harness.Origin.ReceivedAnythingFor(RequiredVerificationHarness.ProtectedPath); + _positiveSessionWasPreserved = admitted.AuthenticationCookieNames.All( + _ => !RequiredVerificationHarness.Deletes(_admittedPage, _)); } public Task DisposeAsync() @@ -96,17 +102,7 @@ public void should_admit_the_caller_once_verification_answers_again() => public void should_forward_the_verified_caller_to_the_backend() => Assert.True(_originSawTheVerifiedCaller, "Restoring the verifier is the only thing that lets a caller through."); - /// - /// Builds an authenticated request from a caller nobody else has used. - /// - /// The path to request. - /// A label making the caller recognizable in a failure. - /// The request. - /// - /// A fresh caller each time because the proxy keeps its answer per user and tenant. A shared identity - /// would let one request's answer stand in for the next, and every assertion here is about the answer - /// this request got. - /// - static HttpRequestMessage Request(string path, string hint) => - SecurityHarness.Authenticated(HttpMethod.Get, path, SecurityHarness.UniqueUser(hint)); + [Fact] + public void should_preserve_a_verified_cookie_session() => + Assert.True(_positiveSessionWasPreserved); } diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_reentering_after_identity_denial.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_reentering_after_identity_denial.cs new file mode 100644 index 0000000..eaa074c --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_reentering_after_identity_denial.cs @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_AccessControl; + +/// Session termination does not close the anonymous surfaces needed for a clean re-entry. +/// The running proxy whose configuration enables session termination. +[Collection(RequiredVerificationSpecCollection.Name)] +public class when_reentering_after_identity_denial( + RequiredVerificationHarness harness) : IAsyncLifetime +{ + HttpResponseMessage? _providers; + HttpResponseMessage? _anonymous; + bool _anonymousWasForwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateSecurityClient(); + harness.DenyEveryCaller(); + using var denial = await client.SendAsync( + harness.AuthenticatedRequest(RequiredVerificationHarness.ProtectedPath).Message); + + harness.Origin.Clear(); + _providers = await client.GetAsync(WellKnownPaths.Providers); + _anonymous = await client.GetAsync(RequiredVerificationHarness.AnonymousPath); + _anonymousWasForwarded = harness.Origin.ReceivedAnythingFor(RequiredVerificationHarness.AnonymousPath); + } + + public Task DisposeAsync() + { + harness.VerifyEveryCaller(); + return Task.CompletedTask; + } + + [Fact] + public void should_keep_the_provider_surface_reachable() => + Assert.Equal(HttpStatusCode.OK, _providers!.StatusCode); + + [Fact] + public void should_keep_the_declared_anonymous_route_reachable() => + Assert.Equal(HttpStatusCode.OK, _anonymous!.StatusCode); + + [Fact] + public void should_forward_the_declared_anonymous_route() => + Assert.True(_anonymousWasForwarded); +} diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_required_verification_has_no_tenant.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_required_verification_has_no_tenant.cs new file mode 100644 index 0000000..1c1aa19 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_required_verification_has_no_tenant.cs @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_AccessControl; + +/// Tenant-less Required refusal reaches the same local-session termination boundary. +/// The running proxy whose configuration enables session termination. +[Collection(RequiredVerificationSpecCollection.Name)] +public class when_required_verification_has_no_tenant( + RequiredVerificationHarness harness) : IAsyncLifetime +{ + HttpResponseMessage? _response; + bool _forwarded; + IReadOnlyList _authenticationCookies = []; + + public async Task InitializeAsync() + { + using var client = harness.CreateSecurityClient(); + harness.VerifyEveryCaller(); + harness.Origin.Clear(); + var session = harness.AuthenticatedRequest( + RequiredVerificationHarness.ProtectedPath, + includeTenant: false, + passTenancyWithoutTenant: true); + _authenticationCookies = session.AuthenticationCookieNames; + _response = await client.SendAsync(session.Message); + _forwarded = harness.Origin.ReceivedAnythingFor(RequiredVerificationHarness.ProtectedPath); + } + + public Task DisposeAsync() + { + harness.VerifyEveryCaller(); + return Task.CompletedTask; + } + + [Fact] + public void should_return_forbidden() => + Assert.Equal(HttpStatusCode.Forbidden, _response!.StatusCode); + + [Fact] + public void should_not_forward_the_request() => + Assert.False(_forwarded); + + [Fact] + public void should_expire_the_primary_cookie_and_chunks() => + Assert.All( + _authenticationCookies, + _ => Assert.True(RequiredVerificationHarness.Deletes(_response!, _))); + + [Fact] + public void should_not_start_provider_logout() => + Assert.Null(_response!.Headers.Location); +} diff --git a/Source/AuthProxy.Security.Specs/given/RequiredVerificationHarness.cs b/Source/AuthProxy.Security.Specs/given/RequiredVerificationHarness.cs index fb61c96..a342b29 100644 --- a/Source/AuthProxy.Security.Specs/given/RequiredVerificationHarness.cs +++ b/Source/AuthProxy.Security.Specs/given/RequiredVerificationHarness.cs @@ -1,13 +1,15 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Security.Claims; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Cratis.AuthProxy.Security.given; @@ -36,22 +38,50 @@ public class RequiredVerificationHarness : WebApplicationFactory /// The tenant every request resolves to. public const string TenantId = "33333333-3333-3333-3333-333333333333"; + /// The claim used to resolve a caller's tenant. + public const string TenantClaim = "urn:cratis:security-spec:tenant"; + + /// The source identifier mapped to . + public const string TenantSourceIdentifier = "security-spec-tenant"; + /// A path that requires a session, and therefore a verified caller. public const string ProtectedPath = "/private"; + /// A path the service explicitly declares anonymous. + public const string AnonymousPath = "/public"; + /// A static asset path a browser fetches after the page, served by the service's frontend. public const string StaticAssetPath = "/assets/app.js"; /// The body of the page a refused caller is served, so a spec can recognize it. public const string ForbiddenMarker = "forbidden-page"; + /// An application-owned cookie configured for deletion with the session. + public const string AdditionalSessionCookie = "security-spec-session"; + + /// A representative transient provider correlation cookie. + public const string CorrelationCookie = $"{Cookies.CorrelationPrefix}security-spec"; + + /// A representative transient provider nonce cookie. + public const string NonceCookie = $"{Cookies.NoncePrefix}security-spec"; + readonly string _pagesPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + readonly bool _terminateOnIdentityDenial; /// /// Initializes a new instance of the class. /// - public RequiredVerificationHarness() + public RequiredVerificationHarness() : this(true) { + } + + /// + /// Initializes a new instance of the class. + /// + /// Whether denial terminates the local session. + internal RequiredVerificationHarness(bool terminateOnIdentityDenial) + { + _terminateOnIdentityDenial = terminateOnIdentityDenial; Directory.CreateDirectory(_pagesPath); File.WriteAllText(Path.Combine(_pagesPath, WellKnownPageNames.SelectProvider), "Select Provider"); File.WriteAllText(Path.Combine(_pagesPath, WellKnownPageNames.Forbidden), $"{ForbiddenMarker}"); @@ -80,6 +110,28 @@ public void VerifyEveryCaller() => /// public void FailEveryVerification() => Origin.IdentityResponse = () => Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + /// Makes the origin explicitly deny every caller. + public void DenyEveryCaller() => Origin.IdentityResponse = () => Results.Json(new + { + isAuthenticated = true, + isAuthorized = false, + details = new { } + }); + + /// Makes the origin answer without an identity verdict. + public void AnswerWithoutVerdict() => Origin.IdentityResponse = () => Results.Json(new { }); + + /// Makes the origin answer with malformed JSON. + public void AnswerWithMalformedJson() => Origin.IdentityResponse = () => Results.Text("{not-json", "application/json"); + + /// Makes the origin answer with conflicting identity verdicts. + public void AnswerWithConflictingVerdicts() => Origin.IdentityResponse = () => Results.Json(new + { + isAuthenticated = false, + isAuthorized = true, + details = new { displayName = "Contradictory Caller" } + }); + /// /// Creates a client that surfaces redirects as responses rather than following them. /// @@ -87,6 +139,88 @@ public void VerifyEveryCaller() => public HttpClient CreateSecurityClient() => CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); + /// + /// Builds a request carrying a real protected ASP.NET Core cookie ticket. + /// + /// The path to request. + /// Whether the ticket carries the configured tenant claim. + /// + /// Whether to present a pending-registration cookie so the tenant-less request reaches identity + /// verification. + /// + /// The request and the authentication-cookie chunk names it carries. + public SessionRequest AuthenticatedRequest( + string path, + bool includeTenant = true, + bool passTenancyWithoutTenant = false) + { + var options = Services.GetRequiredService>() + .Get(CookieAuthenticationDefaults.AuthenticationScheme); + var user = Guid.NewGuid().ToString("N"); + var claims = new List + { + new(ClaimTypes.NameIdentifier, user), + new("oid", user), + new(ClaimTypes.Name, user), + new( + "urn:cratis:security-spec:padding", + string.Join('-', Enumerable.Range(0, 150).Select(_ => Guid.NewGuid().ToString("N")))) + }; + if (includeTenant) + { + claims.Add(new Claim(TenantClaim, TenantSourceIdentifier)); + } + + var principal = new ClaimsPrincipal( + new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)); + var ticket = new AuthenticationTicket( + principal, + CookieAuthenticationDefaults.AuthenticationScheme); + var protectedTicket = options.TicketDataFormat.Protect(ticket); + var cookieContext = new DefaultHttpContext(); + cookieContext.Request.Scheme = "https"; + options.CookieManager.AppendResponseCookie( + cookieContext, + options.Cookie.Name!, + protectedTicket, + options.Cookie.Build(cookieContext)); + + var authenticationCookies = cookieContext.Response.Headers.SetCookie + .Select(_ => _.Split(';', 2)[0]) + .ToArray(); + var presented = authenticationCookies + .Concat( + [ + $"{CorrelationCookie}=correlation", + $"{NonceCookie}=nonce", + $"{AdditionalSessionCookie}=additional" + ]) + .ToList(); + if (passTenancyWithoutTenant) + { + presented.Add($"{Cookies.Registration}=pending"); + } + + var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.TryAddWithoutValidation("Cookie", string.Join("; ", presented)); + + return new SessionRequest( + request, + authenticationCookies + .Select(_ => _[.._.IndexOf('=', StringComparison.Ordinal)]) + .ToArray()); + } + + /// Gets whether a response expires the named cookie. + /// The response to inspect. + /// The exact cookie name. + /// when the response carries an expiry for the cookie. + public static bool Deletes(HttpResponseMessage response, string name) => + response.Headers.TryGetValues("Set-Cookie", out var values) + && values.Any(_ => + _.StartsWith($"{name}=;", StringComparison.Ordinal) + && _.Contains("expires=", StringComparison.OrdinalIgnoreCase)); + /// protected override void Dispose(bool disposing) { @@ -116,22 +250,21 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) [$"{C.AuthProxy.SectionKey}:Services:app:Frontend:BaseUrl"] = Origin.BaseUrl, [$"{C.AuthProxy.SectionKey}:Services:app:IdentityVerification"] = nameof(C.IdentityVerificationMode.Required), [$"{C.AuthProxy.SectionKey}:Services:app:IdentityVerificationTimeout"] = "00:00:05", + [$"{C.AuthProxy.SectionKey}:Services:app:AnonymousPaths:0"] = AnonymousPath, [$"{C.AuthProxy.SectionKey}:Session:IdentityResultCacheDuration"] = "00:00:00", + [$"{C.AuthProxy.SectionKey}:Session:TerminateOnIdentityDenial"] = _terminateOnIdentityDenial.ToString(), + [$"{C.AuthProxy.SectionKey}:Logout:AdditionalCookies:0:Name"] = AdditionalSessionCookie, [$"{C.AuthProxy.SectionKey}:PagesPath"] = _pagesPath, - [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Specified), - [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:TenantId"] = TenantId, + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Claim), + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:ClaimType"] = TenantClaim, + [$"{C.AuthProxy.SectionKey}:Tenants:{TenantId}:SourceIdentifiers:0"] = TenantSourceIdentifier, [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Provider One", [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.example.test/one", [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-one", - })) - .ConfigureTestServices(services => services - .AddAuthentication(HeaderAuthenticationHandler.Scheme) - .AddScheme( - HeaderAuthenticationHandler.Scheme, - _ => { })); + })); } } diff --git a/Source/AuthProxy.Security.Specs/given/SessionRequest.cs b/Source/AuthProxy.Security.Specs/given/SessionRequest.cs new file mode 100644 index 0000000..4a617fa --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/SessionRequest.cs @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// A request carrying a protected cookie session and its chunk names. +/// The request message. +/// The primary authentication cookie names presented. +public record SessionRequest( + HttpRequestMessage Message, + IReadOnlyList AuthenticationCookieNames); diff --git a/Source/AuthProxy.Specs/Authentication/for_SessionTermination/when_terminating_a_session.cs b/Source/AuthProxy.Specs/Authentication/for_SessionTermination/when_terminating_a_session.cs new file mode 100644 index 0000000..ebe5a8b --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_SessionTermination/when_terminating_a_session.cs @@ -0,0 +1,85 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Authentication.for_SessionTermination; + +public class when_terminating_a_session : Specification +{ + const string AuthenticationCookie = ".Cratis.AuthProxy.Auth.v2"; + const string AuthenticationChunkOne = $"{AuthenticationCookie}C1"; + const string AuthenticationChunkTwo = $"{AuthenticationCookie}C2"; + const string CorrelationCookie = $"{Cookies.CorrelationPrefix}provider.state"; + const string NonceCookie = $"{Cookies.NoncePrefix}provider.state"; + const string AdditionalCookie = "shared-session"; + + DefaultHttpContext _context; + ServiceProvider _services; + + void Establish() + { + var services = new ServiceCollection(); + services.AddLogging(); + services + .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => options.Cookie.Name = AuthenticationCookie); + + _services = services.BuildServiceProvider(); + _context = new DefaultHttpContext + { + RequestServices = _services + }; + _context.Request.Scheme = "https"; + _context.Request.Path = "/identity-check"; + _context.Request.Headers.Cookie = string.Join("; ", + [ + $"{AuthenticationCookie}=chunks-2", + $"{AuthenticationChunkOne}=first", + $"{AuthenticationChunkTwo}=second", + $"{Cookies.Identity}=identity", + $"{Cookies.IdentityAuthorization}=authorization", + $"{Cookies.Tenant}=tenant", + $"{Cookies.Tenants}=tenants", + $"{Cookies.InviteToken}=invite", + $"{Cookies.InvitationEntryState}=invite-state", + $"{Cookies.Registration}=registration", + $"{Cookies.Providers}=providers", + $"{CorrelationCookie}=correlation", + $"{NonceCookie}=nonce", + $"{AdditionalCookie}=additional", + $"{Cookies.EntryTransaction}=entry", + $"{Cookies.LogoutRedirect}=logout" + ]); + } + + async Task Because() => await SessionTermination.SignOutAndClearCookies( + _context, + new C.Logout + { + AdditionalCookies = [new C.LogoutCookie { Name = AdditionalCookie }] + }); + + void Destroy() => _services.Dispose(); + + [Fact] void should_expire_the_authentication_cookie() => WasDeleted(AuthenticationCookie).ShouldBeTrue(); + [Fact] void should_expire_the_first_authentication_cookie_chunk() => WasDeleted(AuthenticationChunkOne).ShouldBeTrue(); + [Fact] void should_expire_the_second_authentication_cookie_chunk() => WasDeleted(AuthenticationChunkTwo).ShouldBeTrue(); + [Fact] void should_expire_the_identity_cookie() => WasDeleted(Cookies.Identity).ShouldBeTrue(); + [Fact] void should_expire_the_identity_authorization_cookie() => WasDeleted(Cookies.IdentityAuthorization).ShouldBeTrue(); + [Fact] void should_expire_the_selected_tenant_cookie() => WasDeleted(Cookies.Tenant).ShouldBeTrue(); + [Fact] void should_expire_the_selectable_tenants_cookie() => WasDeleted(Cookies.Tenants).ShouldBeTrue(); + [Fact] void should_expire_the_invite_cookie() => WasDeleted(Cookies.InviteToken).ShouldBeTrue(); + [Fact] void should_expire_the_invitation_entry_cookie() => WasDeleted(Cookies.InvitationEntryState).ShouldBeTrue(); + [Fact] void should_expire_the_registration_cookie() => WasDeleted(Cookies.Registration).ShouldBeTrue(); + [Fact] void should_expire_the_provider_cookie() => WasDeleted(Cookies.Providers).ShouldBeTrue(); + [Fact] void should_expire_the_correlation_cookie() => WasDeleted(CorrelationCookie).ShouldBeTrue(); + [Fact] void should_expire_the_nonce_cookie() => WasDeleted(NonceCookie).ShouldBeTrue(); + [Fact] void should_expire_the_configured_additional_cookie() => WasDeleted(AdditionalCookie).ShouldBeTrue(); + [Fact] void should_retain_the_entry_cookie() => WasDeleted(Cookies.EntryTransaction).ShouldBeFalse(); + [Fact] void should_retain_the_logout_redirect_cookie() => WasDeleted(Cookies.LogoutRedirect).ShouldBeFalse(); + + bool WasDeleted(string name) => + _context.Response.Headers.SetCookie.Any(_ => _?.StartsWith($"{name}=;", StringComparison.Ordinal) == true); +} diff --git a/Source/AuthProxy.Specs/Configuration/for_Session/when_using_defaults.cs b/Source/AuthProxy.Specs/Configuration/for_Session/when_using_defaults.cs index f964efe..2713078 100644 --- a/Source/AuthProxy.Specs/Configuration/for_Session/when_using_defaults.cs +++ b/Source/AuthProxy.Specs/Configuration/for_Session/when_using_defaults.cs @@ -11,6 +11,7 @@ public class when_using_defaults : Specification [Fact] void should_bound_the_session_lifetime_to_twelve_hours() => _session.Lifetime.ShouldEqual(TimeSpan.FromHours(12)); [Fact] void should_use_an_absolute_lifetime() => _session.SlidingExpiration.ShouldBeFalse(); + [Fact] void should_preserve_the_session_on_identity_denial() => _session.TerminateOnIdentityDenial.ShouldBeFalse(); [Fact] void should_revalidate_identity_every_ten_minutes() => _session.IdentityRevalidationInterval.ShouldEqual(TimeSpan.FromMinutes(10)); [Fact] void should_revalidate_the_selected_tenant_every_ten_minutes() => _session.TenantRevalidationInterval.ShouldEqual(TimeSpan.FromMinutes(10)); } diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/given/an_identity_middleware.cs b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/given/an_identity_middleware.cs index aabaaaf..b0cf010 100644 --- a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/given/an_identity_middleware.cs +++ b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/given/an_identity_middleware.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.Arc.Identity; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; namespace Cratis.AuthProxy.Identity.for_IdentityMiddleware.given; @@ -27,6 +29,7 @@ public class an_identity_middleware : Specification protected C.Service _service; protected IIdentityDetailsResolver _resolver; protected IErrorPageProvider _errorPages; + protected IAuthenticationService _authenticationService; protected DefaultHttpContext _context; protected IdentityMiddleware _middleware; protected bool _nextCalled; @@ -64,6 +67,11 @@ void Establish() }; _context.Request.Path = ProtectedPath; + _authenticationService = Substitute.For(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(_authenticationService); + _context.RequestServices = serviceProvider; + _middleware = new IdentityMiddleware( _ => { @@ -82,9 +90,32 @@ void Establish() protected void ResolveTenant(string tenantId = TenantId) => _context.Items[TenancyMiddleware.TenantIdItemKey] = tenantId; + /// + /// Enables local session termination when identity verification refuses the caller. + /// + protected void EnableSessionTermination() => _config.Session.TerminateOnIdentityDenial = true; + /// /// Asserts that the request was refused with the forbidden page. /// protected void ShouldHaveBeenRefused() => _errorPages.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.Forbidden, StatusCodes.Status403Forbidden); + + /// + /// Asserts that the local authentication session was terminated. + /// + protected void ShouldHaveTerminatedSession() => + _authenticationService.Received(1).SignOutAsync( + _context, + CookieAuthenticationDefaults.AuthenticationScheme, + Arg.Any()); + + /// + /// Asserts that the local authentication session was preserved. + /// + protected void ShouldHavePreservedSession() => + _authenticationService.DidNotReceive().SignOutAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); } diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_a_tenant_resolves.cs b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_a_tenant_resolves.cs index fdc3477..97c0e76 100644 --- a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_a_tenant_resolves.cs +++ b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_a_tenant_resolves.cs @@ -9,7 +9,11 @@ namespace Cratis.AuthProxy.Identity.for_IdentityMiddleware; /// public class when_a_tenant_resolves : given.an_identity_middleware { - void Establish() => ResolveTenant(); + void Establish() + { + ResolveTenant(); + EnableSessionTermination(); + } async Task Because() => await _middleware.InvokeAsync(_context); @@ -17,6 +21,7 @@ [Fact] void should_ask_for_a_verdict() => _resolver.Received(1).Resolve(_context, Arg.Any(), TenantId); [Fact] void should_forward_the_request() => _nextCalled.ShouldBeTrue(); + [Fact] void should_preserve_the_session() => ShouldHavePreservedSession(); [Fact] void should_not_refuse_it() => _errorPages.DidNotReceive().WriteErrorPageAsync(Arg.Any(), Arg.Any(), Arg.Any()); } diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_identity_is_denied/and_session_termination_is_disabled.cs b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_identity_is_denied/and_session_termination_is_disabled.cs new file mode 100644 index 0000000..aa0db2e --- /dev/null +++ b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_identity_is_denied/and_session_termination_is_disabled.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Identity; + +namespace Cratis.AuthProxy.Identity.for_IdentityMiddleware.when_identity_is_denied; + +public class and_session_termination_is_disabled : given.an_identity_middleware +{ + void Establish() + { + ResolveTenant(); + _config.Session.TerminateOnIdentityDenial = false; + _resolver + .Resolve(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => new IdentityProviderResult("user-1", "User One", true, false, [], new object())); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_preserve_the_session() => ShouldHavePreservedSession(); + [Fact] void should_still_refuse_the_request() => ShouldHaveBeenRefused(); + [Fact] void should_not_forward_the_request() => _nextCalled.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_identity_is_denied/and_session_termination_is_enabled.cs b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_identity_is_denied/and_session_termination_is_enabled.cs new file mode 100644 index 0000000..d581eeb --- /dev/null +++ b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_identity_is_denied/and_session_termination_is_enabled.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Identity; + +namespace Cratis.AuthProxy.Identity.for_IdentityMiddleware.when_identity_is_denied; + +public class and_session_termination_is_enabled : given.an_identity_middleware +{ + void Establish() + { + ResolveTenant(); + EnableSessionTermination(); + _resolver + .Resolve(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => new IdentityProviderResult("user-1", "User One", true, false, [], new object())); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_terminate_the_session() => ShouldHaveTerminatedSession(); + [Fact] void should_still_refuse_the_request() => ShouldHaveBeenRefused(); + [Fact] void should_not_forward_the_request() => _nextCalled.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_no_tenant_resolves/and_verification_is_required.cs b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_no_tenant_resolves/and_verification_is_required.cs index 0927bbb..24c9d58 100644 --- a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_no_tenant_resolves/and_verification_is_required.cs +++ b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_no_tenant_resolves/and_verification_is_required.cs @@ -22,6 +22,7 @@ public class and_verification_is_required : given.an_identity_middleware [Fact] void should_refuse_the_request() => ShouldHaveBeenRefused(); [Fact] void should_not_forward_the_request() => _nextCalled.ShouldBeFalse(); + [Fact] void should_preserve_the_session_by_default() => ShouldHavePreservedSession(); [Fact] void should_not_pretend_to_have_asked() => _resolver.DidNotReceive().Resolve(Arg.Any(), Arg.Any(), Arg.Any()); } diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_no_tenant_resolves/and_verification_is_required_with_session_termination_enabled.cs b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_no_tenant_resolves/and_verification_is_required_with_session_termination_enabled.cs new file mode 100644 index 0000000..e979795 --- /dev/null +++ b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_no_tenant_resolves/and_verification_is_required_with_session_termination_enabled.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Identity.for_IdentityMiddleware.when_no_tenant_resolves; + +public class and_verification_is_required_with_session_termination_enabled : given.an_identity_middleware +{ + void Establish() => EnableSessionTermination(); + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_terminate_the_session() => ShouldHaveTerminatedSession(); + [Fact] void should_still_refuse_the_request() => ShouldHaveBeenRefused(); + [Fact] void should_not_forward_the_request() => _nextCalled.ShouldBeFalse(); + [Fact] void should_not_pretend_to_have_asked() => + _resolver.DidNotReceive().Resolve(Arg.Any(), Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy/Configuration/Session.cs b/Source/AuthProxy/Configuration/Session.cs index c765458..3438721 100644 --- a/Source/AuthProxy/Configuration/Session.cs +++ b/Source/AuthProxy/Configuration/Session.cs @@ -51,6 +51,16 @@ public class Session /// public bool SlidingExpiration { get; set; } + /// + /// Gets or sets whether an identity-verification denial terminates the local AuthProxy session before + /// serving the forbidden response. Disabled by default to preserve the existing refusal behavior. + /// + /// + /// Termination signs out of the local authentication cookie and clears AuthProxy-owned session cookies; + /// it does not initiate logout at the external identity provider. + /// + public bool TerminateOnIdentityDenial { get; set; } + /// /// Gets or sets how long the identity-details cookie is trusted before the browser drops it and the /// identity details (including whether the user is still authorized) are re-resolved against the diff --git a/Source/AuthProxy/Identity/IdentityMiddleware.cs b/Source/AuthProxy/Identity/IdentityMiddleware.cs index 29bdf93..5d477d3 100644 --- a/Source/AuthProxy/Identity/IdentityMiddleware.cs +++ b/Source/AuthProxy/Identity/IdentityMiddleware.cs @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.AuthProxy.Authentication; using Cratis.AuthProxy.ErrorPages; using Microsoft.Extensions.Options; using C = Cratis.AuthProxy.Configuration; @@ -44,7 +45,7 @@ public async Task InvokeAsync(HttpContext context) { if (MustBeVerified(context, current)) { - await Refuse(context); + await Refuse(context, current); return; } } @@ -53,7 +54,7 @@ public async Task InvokeAsync(HttpContext context) var result = await identityDetailsResolver.Resolve(context, principal, tenantId); if (!result.IsAuthorized) { - await Refuse(context); + await Refuse(context, current); return; } } @@ -94,9 +95,16 @@ static bool MustBeVerified(HttpContext context, C.AuthProxy config) => && !context.IsInvitation() && !context.IsRegistration(); - Task Refuse(HttpContext context) => - errorPageProvider.WriteErrorPageAsync( + async Task Refuse(HttpContext context, C.AuthProxy config) + { + if (config.Session.TerminateOnIdentityDenial) + { + await SessionTermination.SignOutAndClearCookies(context, config.Logout); + } + + await errorPageProvider.WriteErrorPageAsync( context, WellKnownPageNames.Forbidden, StatusCodes.Status403Forbidden); + } }