fix(security): audit actor falls back to HttpContext when the server auth-state provider has no circuit

ServerAuthenticationStateProvider throws InvalidOperationException from GetAuthenticationStateAsync in any
scope where no circuit set its state (minimal-API requests, hosted-service scopes). The accessor now treats
that as "no circuit identity" and falls back to the HTTP principal. Pinned with the real provider on both
sides (unset -> HTTP fallback; set -> circuit principal wins).

Refs #733
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 13:36:51 -04:00
parent 3ddb6876db
commit c0a0e7aa81
2 changed files with 62 additions and 6 deletions
@@ -58,6 +58,42 @@ public sealed class AuthenticationStateAuditActorAccessorTests
accessor.CurrentActor.ShouldBeNull();
}
/// <summary>
/// Fix round 1 / I5 — the REAL server-side provider, resolved in a non-circuit scope (a minimal-API
/// request, a background service), throws <see cref="InvalidOperationException"/> from
/// <c>GetAuthenticationStateAsync</c> because nothing called <c>SetAuthenticationState</c>. That used
/// to escape <c>CurrentActor</c>; it must fall back to the HTTP principal instead.
/// </summary>
[Fact]
public void Real_server_provider_outside_a_circuit_falls_back_to_the_http_principal()
{
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "dave")) };
var accessor = new AuthenticationStateAuditActorAccessor(
new HttpContextAccessorStub(context),
new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider());
accessor.CurrentActor.ShouldBe("dave");
// No HTTP principal either ⇒ null, still no throw.
new AuthenticationStateAuditActorAccessor(
new HttpContextAccessorStub(null),
new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider())
.CurrentActor.ShouldBeNull();
}
/// <summary>Positive control on the real provider: once a circuit has set its state, that principal wins.</summary>
[Fact]
public void Real_server_provider_inside_a_circuit_serves_the_circuit_principal()
{
var provider = new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider();
provider.SetAuthenticationState(Task.FromResult(
new AuthenticationState(Authenticated(new Claim(ZbClaimTypes.Username, "erin")))));
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "http-user")) };
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), provider)
.CurrentActor.ShouldBe("erin");
}
private static ClaimsPrincipal Authenticated(params Claim[] claims) =>
new(new ClaimsIdentity(claims, "TestScheme", ZbClaimTypes.Name, ZbClaimTypes.Role));