Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Documentation/aspire/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Documentation/configuration/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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. |

Expand Down
9 changes: 9 additions & 0 deletions Documentation/configuration/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> _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();
}
21 changes: 21 additions & 0 deletions Source/Aspire/AuthProxyExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,27 @@ public static IResourceBuilder<T> WithIdentityVerification<T>(
return builder;
}

/// <summary>
/// Terminates the local AuthProxy session whenever identity verification refuses a caller.
/// </summary>
/// <typeparam name="T">The resource type (must support environment variables).</typeparam>
/// <param name="builder">The resource builder.</param>
/// <returns>The same <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <remarks>
/// 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.
/// <para>
/// Not calling this method preserves the released behavior: the refusal is served while the local
/// authentication session remains active.
/// </para>
/// </remarks>
public static IResourceBuilder<T> WithSessionTerminationOnIdentityDenial<T>(
this IResourceBuilder<T> builder)
where T : IResourceWithEnvironment =>
builder.WithEnvironment($"{ConfigPrefix}__Session__TerminateOnIdentityDenial", bool.TrueString);

/// <summary>
/// Registers a frontend (SPA / static-assets) endpoint for a named service in AuthProxy.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>The default-off compatibility mode refuses without destroying the local cookie session.</summary>
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);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
/// <param name="harness">The running proxy whose configuration enables session termination.</param>
[Collection(RequiredVerificationSpecCollection.Name)]
public class when_identity_denial_terminates_the_local_session(
RequiredVerificationHarness harness) : IAsyncLifetime
{
static readonly Action<RequiredVerificationHarness>[] _denials =
[
_ => _.DenyEveryCaller(),
_ => _.FailEveryVerification(),
_ => _.AnswerWithoutVerdict(),
_ => _.AnswerWithMalformedJson(),
_ => _.AnswerWithConflictingVerdicts()
];

readonly List<HttpStatusCode> _statuses = [];
readonly List<bool> _forwarded = [];
readonly List<bool> _forbiddenPages = [];
readonly List<bool> _redirected = [];
readonly List<string> _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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class when_identity_verification_is_required(RequiredVerificationHarness

HttpResponseMessage? _admittedPage;
bool _originSawTheVerifiedCaller;
bool _positiveSessionWasPreserved;

public async Task InitializeAsync()
{
Expand All @@ -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()
Expand Down Expand Up @@ -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.");

/// <summary>
/// Builds an authenticated request from a caller nobody else has used.
/// </summary>
/// <param name="path">The path to request.</param>
/// <param name="hint">A label making the caller recognizable in a failure.</param>
/// <returns>The request.</returns>
/// <remarks>
/// 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.
/// </remarks>
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);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Session termination does not close the anonymous surfaces needed for a clean re-entry.</summary>
/// <param name="harness">The running proxy whose configuration enables session termination.</param>
[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);
}
Loading
Loading