Harden SignalR authentication refresh - #68459
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens SignalR authentication refresh across server, transport, and .NET client layers by adding explicit refresh-validation hooks, preventing stale refresh publications from overwriting newer state, and enabling Blazor Server circuits to accept identity transitions (including to/from anonymous) without reconnecting.
Changes:
- Adds a replaceable, synchronous “user refreshing” validation callback and threads authentication expiration through refresh notifications to prevent stale overwrites.
- Updates transport refresh and ownership checks (refresh vs. send/delete/connection association) and adjusts refresh behaviors/errors accordingly.
- Improves .NET client auto-refresh timer lifecycle/synchronization and adds regression coverage across server/transport/client/Blazor.
Show a summary per file
| File | Description |
|---|---|
| src/SignalR/server/SignalR/test/Microsoft.AspNetCore.SignalR.Tests/HubConnectionHandlerTests.AuthenticationRefresh.cs | Adds server-side refresh/ordering/identity-policy regression tests. |
| src/SignalR/server/Core/src/HubConnectionHandlerLog.cs | Refines/extends logging for accepted vs rejected identifier changes and validation errors. |
| src/SignalR/server/Core/src/HubConnectionHandler.cs | Adds OnUserRefreshing validation hook + stale refresh guarding before publishing hub user state. |
| src/SignalR/server/Core/src/HubConnectionContext.cs | Tracks auth expiration for stale-refresh suppression at hub publication boundary. |
| src/SignalR/common/Http.Connections/test/HttpConnectionDispatcherTests.cs | Ensures send/delete ownership enforcement remains strict even with permissive refresh policy. |
| src/SignalR/common/Http.Connections/test/HttpConnectionDispatcherTests.AuthenticationRefresh.cs | Adds transport-level tests for refresh validation, anonymous transitions, and callback sequencing. |
| src/SignalR/common/Http.Connections/src/PublicAPI.Unshipped.txt | Updates unshipped public API for OnAuthenticationRefresh delegate type. |
| src/SignalR/common/Http.Connections/src/Internal/HttpConnectionDispatcher.cs | Reworks refresh pipeline: candidate principal handling, validation, atomic swap/revalidate, and separates ownership checks. |
| src/SignalR/common/Http.Connections/src/Internal/HttpConnectionContext.cs | Implements OnUserRefreshing, expiration-aware user-refreshed notifications, and refresh validation helpers. |
| src/SignalR/common/Http.Connections/src/HttpConnectionDispatcherOptions.cs | Changes OnAuthenticationRefresh to Task-based delegate. |
| src/SignalR/clients/csharp/Client/test/UnitTests/HubConnectionTests.AuthenticationRefresh.cs | Adds client timer/disposal/short-lived-token and lifecycle regression tests. |
| src/SignalR/clients/csharp/Client.Core/src/HubConnection.cs | Synchronizes auth-refresh timer state across start/stop/close/manual/timer refresh and prevents re-arming after teardown. |
| src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.1/PublicAPI.Unshipped.txt | Updates unshipped public API for IConnectionUserRefreshFeature shape/signatures. |
| src/Servers/Connections.Abstractions/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt | Updates unshipped public API for IConnectionUserRefreshFeature shape/signatures. |
| src/Servers/Connections.Abstractions/src/PublicAPI/net462/PublicAPI.Unshipped.txt | Updates unshipped public API for IConnectionUserRefreshFeature shape/signatures. |
| src/Servers/Connections.Abstractions/src/PublicAPI/net11.0/PublicAPI.Unshipped.txt | Updates unshipped public API for IConnectionUserRefreshFeature shape/signatures. |
| src/Servers/Connections.Abstractions/src/PublicAPI/net10.0/PublicAPI.Unshipped.txt | Updates unshipped public API for IConnectionUserRefreshFeature shape/signatures. |
| src/Servers/Connections.Abstractions/src/Features/IConnectionUserRefreshFeature.cs | Introduces OnUserRefreshing + adds expiration to OnUserRefreshed callback contract. |
| src/Components/test/testassets/Components.TestServer/Pages/Authentication.cshtml | Enables emitting NameIdentifier for E2E identity-transition scenarios. |
| src/Components/test/E2ETest/ServerExecutionTests/ServerAuthTest.cs | Expands Blazor Server E2E to cover identity switches and sign-out without reconnecting. |
| src/Components/test/E2ETest/Infrastructure/WebDriverExtensions/BasicTestAppAuthenticationWebDriverExtensions.cs | Adds URL parameter to control NameIdentifier emission in E2E sign-in helper. |
| src/Components/Server/test/Circuits/ComponentHubTest.cs | Adds coverage that ComponentHub replaces SignalR refresh policy and wires feature in test setup. |
| src/Components/Server/src/ComponentHub.cs | Opts Blazor Server into permissive refresh identity transitions via IConnectionUserRefreshFeature. |
Review details
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 23/23 changed files
- Comments generated: 1
- Review effort level: Lite
| private bool _useStatefulReconnect; | ||
| private DefaultHubCallerContext? _hubCallerContext; | ||
| private string? _userIdentifier; | ||
| private DateTimeOffset _userAuthenticationExpiration = DateTimeOffset.MinValue; |
| // rejected request leaves the existing connection fully intact. A registered user-refresh | ||
| // validator can use an application-specific identity mapping; otherwise the connection applies | ||
| // its secure sub/NameIdentifier/Upn fallback. | ||
| if (connection.ClientReconnectExpected() && await RejectIfUserChangedAsync(connection, context)) |
There was a problem hiding this comment.
This used to be gated on !options.EnableAuthenticationRefresh, so when refresh was off the strict identity check applied and nothing could override it. Now it always goes through the replaceable policy, which means any connection with refresh disabled can have that check turned off.
With EnableAuthenticationRefresh false there's no path that publishes a changed principal to the hub. authRefreshEligible below is false, so we skip the refresh path and fall into the raw swap at the bottom, which rewrites connection.User and the persisted HttpContext.User while HubConnectionContext.User and everything above it keep the old principal. No callback, no notification, and authorization keeps evaluating the old user. Accepting a changed principal here while refresh is disabled can only ever produce that divergence, so I don't think the policy should be able to open this gate in that state at all.
I left a separate note about defaulting EnableAuthenticationRefresh for Blazor, but that doesn't remove the need to fix this. ComponentHub.OnConnectedAsync installs static _ => true unconditionally, so a Blazor app that explicitly opts back out lands right here, and so does any app that sets a permissive OnUserRefreshing without enabling refresh. Before this PR that combination wasn't expressible.
Can this call RejectIfConnectionUserChangedAsync when EnableAuthenticationRefresh is false, the way the send and delete paths already do?
LongPollingChangedUserRejectedWhenAuthenticationRefreshDisabled leaves the default policy in place, so it doesn't cover the disabled-plus-permissive combination.
| await authenticationRefreshLock.WaitAsync(); | ||
| try | ||
| { | ||
| if (connection.IsAuthenticationRefreshStale(authenticationExpiration)) |
There was a problem hiding this comment.
I think I pointed us at the wrong key on #67111. IsAuthenticationRefreshStale only fires when both expirations are real and strictly ordered, so cookie auth (both MaxValue) and equal JWT expirations fall straight through it. And since UpdateUser invokes the callbacks outside _userLock, two racing refreshes can arrive reversed and the older principal wins.
Could this compare the incoming principal against connection.Features.Get<IConnectionUserFeature>()?.User instead? UpdateUser hands the callback the same instance it stored, so reference equality holds, and HandshakeAsync already reads the user that way. Would need to keep applying when the feature is absent, and OlderAuthenticationRefreshNotificationDoesNotOverwriteNewerHubUser sets Connection.User back before raising, so that test would need a tweak.
| { | ||
| // ComponentHub owns authentication state at the circuit layer and does not use SignalR | ||
| // groups or user routing, so it can accept an identity change without rekeying those. | ||
| Context.Features.Get<IConnectionUserRefreshFeature>()?.OnUserRefreshing = static _ => true; |
There was a problem hiding this comment.
Blazor accepts identity changes here, but its default server and client configuration never enables authentication refresh. MapBlazorHub leaves EnableAuthenticationRefresh false, so /refresh isn't mapped, and CircuitManager builds the connection without withAuthenticationRefresh(), so WebSockets never schedule a refresh.
Can MapBlazorHub enable the server option before configureOptions, and CircuitManager call withAuthenticationRefresh() before configureSignalR, so apps can still override either default?
There was a problem hiding this comment.
I knew I was forgetting something!
| var timer = new Timer( | ||
| static state => | ||
| { | ||
| var timerState = (AuthenticationRefreshTimerState)state!; | ||
| _ = timerState.HubConnection.OnAuthenticationRefreshTimerFired(timerState); | ||
| }, | ||
| timerState, | ||
| refreshIn, | ||
| Timeout.InfiniteTimeSpan); // One-shot timer |
There was a problem hiding this comment.
refreshIn has no upper clamp. The TS client caps it at MAX_AUTHENTICATION_REFRESH_INTERVAL_IN_MS (2_147_483_647) before scheduling, but here it goes straight into new Timer, which throws above uint.MaxValue - 1 ms. The server clamps tokenLifetimeSeconds to int.MaxValue seconds, so a long-lived cookie (a 60 day "remember me" is enough) makes StartAsyncCore throw and the connection never starts. Same Math.Min the TS client does?
| } | ||
|
|
||
| connection.ApplyUserState(user, newUserId); | ||
| connection.ApplyUserState(user, newUserId, authenticationExpiration); |
There was a problem hiding this comment.
Changing UserIdentifier here leaves lifetime-manager routing inconsistent. The default manager starts matching the new ID immediately, while Redis subscribed under the original ID and later tries to unsubscribe using the new one. Since HubLifetimeManager has no rekey contract, can we apply the new principal while keeping UserIdentifier fixed for the connection's lifetime, and document that Clients.User(...) remains keyed to the original ID?
Prevent stale authentication publications and refresh timers, separate transport ownership from refresh policy, and allow Blazor circuits to accept identity transitions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a24ee9dd-34dc-456d-a884-16110ff745bd
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a24ee9dd-34dc-456d-a884-16110ff745bd
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a24ee9dd-34dc-456d-a884-16110ff745bd
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a24ee9dd-34dc-456d-a884-16110ff745bd
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a24ee9dd-34dc-456d-a884-16110ff745bd
2a5e07f to
56f6531
Compare
|
/backport to release/11.0-rc1 |
|
Started backporting to |
* Harden SignalR authentication refresh Prevent stale authentication publications and refresh timers, separate transport ownership from refresh policy, and allow Blazor circuits to accept identity transitions. * Fix authentication refresh during reconnect * Address authentication refresh review feedback * Harden authentication refresh identity handling * Fix authentication refresh test identities --------- Co-authored-by: Brennan <brecon@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a24ee9dd-34dc-456d-a884-16110ff745bd
Summary
Fixes #68284