diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/AuthenticationStateAuditActorAccessor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/AuthenticationStateAuditActorAccessor.cs
index d689b85e..13ede448 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/AuthenticationStateAuditActorAccessor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/AuthenticationStateAuditActorAccessor.cs
@@ -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);
}
}
+ ///
+ /// 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
+ /// SetAuthenticationState; anywhere else (a minimal-API request, a hosted service's scope)
+ /// GetAuthenticationStateAsync throws synchronously
+ /// (fix round 1 / I5). That is "no circuit", never an audit failure — the caller falls back to
+ /// the HTTP context.
+ ///
+ /// The scoped provider.
+ /// The actor, or null.
+ private static string? TryResolveCircuitActor(AuthenticationStateProvider provider)
+ {
+ Task task;
+ try
+ {
+ task = provider.GetAuthenticationStateAsync();
+ }
+ catch (InvalidOperationException)
+ {
+ return null;
+ }
+ return task.IsCompletedSuccessfully ? ResolveActor(task.Result.User) : null;
+ }
+
/// The claim-resolution rule shared with .
/// The principal, or null.
/// The actor string, or null when unauthenticated.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuthenticationStateAuditActorAccessorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuthenticationStateAuditActorAccessorTests.cs
index 5dc1f391..6341ef6f 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuthenticationStateAuditActorAccessorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuthenticationStateAuditActorAccessorTests.cs
@@ -58,6 +58,42 @@ public sealed class AuthenticationStateAuditActorAccessorTests
accessor.CurrentActor.ShouldBeNull();
}
+ ///
+ /// Fix round 1 / I5 — the REAL server-side provider, resolved in a non-circuit scope (a minimal-API
+ /// request, a background service), throws from
+ /// GetAuthenticationStateAsync because nothing called SetAuthenticationState. That used
+ /// to escape CurrentActor; it must fall back to the HTTP principal instead.
+ ///
+ [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();
+ }
+
+ /// Positive control on the real provider: once a circuit has set its state, that principal wins.
+ [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));