Skip to content

Harden SignalR authentication refresh - #68459

Merged
wtgodbe merged 5 commits into
mainfrom
brennanconroy-auth-refresh-followup-fixes
Aug 17, 2026
Merged

Harden SignalR authentication refresh#68459
wtgodbe merged 5 commits into
mainfrom
brennanconroy-auth-refresh-followup-fixes

Conversation

@BrennanConroy

@BrennanConroy BrennanConroy commented Aug 12, 2026

Copy link
Copy Markdown
Member

Summary

  • prevent stale authentication refresh publications from rolling back hub user state
  • synchronize the .NET client refresh timer across start, stop, close, manual refresh, and timer refresh, including safe short-lived-token scheduling
  • separate replaceable refresh-transition policy from Long Polling send/delete connection ownership checks
  • allow Blazor circuits to accept changed and anonymous identities and update circuit authentication state without reconnecting
  • add focused regressions for refresh races, timer cleanup, disposal, overflow, ownership, and Blazor identity transitions

Fixes #68284

@BrennanConroy BrennanConroy added the area-signalr Includes: SignalR clients and servers label Aug 13, 2026
@BrennanConroy
BrennanConroy marked this pull request as ready for review August 13, 2026 16:58
Copilot AI lite review requested due to automatic review settings August 13, 2026 16:58
@BrennanConroy
BrennanConroy requested a review from a team as a code owner August 13, 2026 16:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I knew I was forgetting something!

Comment on lines +706 to +714
var timer = new Timer(
static state =>
{
var timerState = (AuthenticationRefreshTimerState)state!;
_ = timerState.HubConnection.OnAuthenticationRefreshTimerFired(timerState);
},
timerState,
refreshIn,
Timeout.InfiniteTimeSpan); // One-shot timer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

BrennanConroy and others added 5 commits August 17, 2026 12:16
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
@javiercn
javiercn force-pushed the brennanconroy-auth-refresh-followup-fixes branch from 2a5e07f to 56f6531 Compare August 17, 2026 10:17

@javiercn javiercn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@wtgodbe
wtgodbe merged commit af049eb into main Aug 17, 2026
24 of 30 checks passed
@wtgodbe
wtgodbe deleted the brennanconroy-auth-refresh-followup-fixes branch August 17, 2026 16:34
@BrennanConroy

Copy link
Copy Markdown
Member Author

/backport to release/11.0-rc1

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/11.0-rc1 (link to workflow run)

wtgodbe pushed a commit that referenced this pull request Aug 17, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-signalr Includes: SignalR clients and servers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Blazor Server identity changes during SignalR authentication refresh

6 participants