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:
+26
-6
@@ -47,17 +47,37 @@ public sealed class AuthenticationStateAuditActorAccessor : IAuditActorAccessor
|
||||
{
|
||||
if (_authenticationState is not null)
|
||||
{
|
||||
var task = _authenticationState.GetAuthenticationStateAsync();
|
||||
if (task.IsCompletedSuccessfully)
|
||||
{
|
||||
var actor = ResolveActor(task.Result.User);
|
||||
if (actor is not null) return actor;
|
||||
}
|
||||
var actor = TryResolveCircuitActor(_authenticationState);
|
||||
if (actor is not null) return actor;
|
||||
}
|
||||
return ResolveActor(_httpContextAccessor.HttpContext?.User);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The circuit's actor, or null when this scope has no circuit identity. The server-side provider
|
||||
/// is registered for EVERY scope on an AdminUI node, but only a circuit calls
|
||||
/// <c>SetAuthenticationState</c>; anywhere else (a minimal-API request, a hosted service's scope)
|
||||
/// <c>GetAuthenticationStateAsync</c> throws <see cref="InvalidOperationException"/> synchronously
|
||||
/// (fix round 1 / I5). That is "no circuit", never an audit failure — the caller falls back to
|
||||
/// the HTTP context.
|
||||
/// </summary>
|
||||
/// <param name="provider">The scoped provider.</param>
|
||||
/// <returns>The actor, or null.</returns>
|
||||
private static string? TryResolveCircuitActor(AuthenticationStateProvider provider)
|
||||
{
|
||||
Task<AuthenticationState> task;
|
||||
try
|
||||
{
|
||||
task = provider.GetAuthenticationStateAsync();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return task.IsCompletedSuccessfully ? ResolveActor(task.Result.User) : null;
|
||||
}
|
||||
|
||||
/// <summary>The claim-resolution rule shared with <see cref="HttpAuditActorAccessor"/>.</summary>
|
||||
/// <param name="user">The principal, or null.</param>
|
||||
/// <returns>The actor string, or null when unauthenticated.</returns>
|
||||
|
||||
+36
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user