Skip to content

Add SignalR Auth Refresh support to server and .NET client#67111

Draft
BrennanConroy wants to merge 12 commits into
mainfrom
brecon/authrefresh
Draft

Add SignalR Auth Refresh support to server and .NET client#67111
BrennanConroy wants to merge 12 commits into
mainfrom
brecon/authrefresh

Conversation

@BrennanConroy

Copy link
Copy Markdown
Member

Summary

Adds SignalR auth refresh support so clients can update authentication credentials for an active connection without reconnecting.

This is primarily for bearer-token scenarios where the access token used to establish the connection expires or rotates while the SignalR connection is still active (although cookies should also work assuming the cookie jar is updated). The feature adds a server refresh endpoint, client refresh APIs/options, negotiate metadata for token lifetime, and hub-layer user state updates so refreshed credentials can be reflected in authorization and user-targeted routing.

High-level flow

  1. The server enables auth refresh with HttpConnectionDispatcherOptions.EnableAuthRefresh.
  2. If the current auth ticket has an expiration, negotiate includes tokenLifetimeSeconds.
  3. The .NET client exposes that initial lifetime and can either:
    • call HubConnection.RefreshAuthAsync() manually, or
    • configure automatic refresh through AuthRefreshOptions.
  4. The client sends a POST to {hub-or-connection-url}/refresh?id={connectionToken} with freshly acquired auth credentials.
  5. The server validates the refresh request using the endpoint auth metadata.
  6. The optional OnAuthRefresh callback can accept or reject the refreshed principal.
  7. If accepted, the connection principal and auth expiration are updated.
  8. SignalR’s hub layer recomputes UserIdentifier, and rekeys Clients.User(...) routing when needed through the HubLifetimeManager, and publishes a new hub caller context snapshot for future hub work.
  9. Existing in-flight hub methods keep the caller context they started with so they don't see a different User over the lifetime of the individual hub method call.

Server changes

  • Adds an internal /refresh endpoint alongside the existing negotiate/connect/send/poll endpoints.
  • Negotiation can include tokenLifetimeSeconds when auth refresh is enabled and the auth ticket has ExpiresUtc.
  • /refresh rejects negotiate v0 connections because v0 has no private connection token (ConnectionId == ConnectionToken).
  • Expired connections get an auth-refresh grace period before connection cleanup when CloseOnAuthenticationExpiration and auth refresh are both enabled.

Endpoint metadata / auth behavior

  • The refresh endpoint is stamped with the same authorization metadata and endpoint conventions as the connection endpoint.
  • OnAuthRefresh runs after the request has authenticated but before the connection user is replaced.
  • If OnAuthRefresh returns false, the connection keeps its current principal.
  • If OnAuthRefresh throws, the exception is allowed to propagate through normal ASP.NET Core server error handling.

Client changes

  • Adds client auth-refresh options through AuthRefreshOptions.
  • HubConnection.RefreshAuthAsync() delegates to the underlying transport auth-refresh feature.
  • RefreshAuthAsync() fetches a fresh access token rather than reusing the access token cached when the connection started.
  • The refreshed token is also cached for subsequent transport requests, so later Long Polling polls/sends use the refreshed credential.
  • The client parses tokenLifetimeSeconds from refresh responses and uses it to schedule subsequent automatic refreshes.
  • Automatic refresh is re-armed after reconnect.

Refresh URL behavior

  • /refresh intentionally posts to the original configured client URL, not a negotiated redirect URL.
  • This treats refresh as part of the application authentication plane.
  • The request uses the private connection token as the id query parameter.

Hub-layer changes

  • Adds overridable Hub.OnAuthRefreshedAsync().
  • The hub layer subscribes to the connection user-update feature.
  • When the connection user changes, SignalR recomputes the hub UserIdentifier.
  • If the UserIdentifier changes, the lifetime manager is asked to rekey the connection before hub-visible state is published.
  • If the lifetime manager cannot rekey a changed UserIdentifier, the connection is aborted rather than staying reachable under stale user-targeting state.
  • Hub.OnAuthRefreshedAsync() is invoked after the connection user state has been applied.
  • Hub.OnAuthRefreshedAsync() is serialized through ActiveInvocationLimit, so it interleaves with hub invocations according to MaximumParallelInvocations.

Hub caller context snapshots

  • DefaultHubCallerContext now captures User and UserIdentifier as a snapshot.
  • DefaultHubDispatcher captures one HubCallerContext per hub operation and uses that same snapshot for:
    • authorization,
    • hub filters,
    • HubInvocationContext,
    • and hub.Context.
  • In-flight hub methods continue to see the old caller context after auth refresh.
  • Hub methods that start after the refresh see the new caller context.

UserIdentifier rekeying

  • Adds HubLifetimeManager.OnUserIdentifierChangedAsync(...).
  • The default lifetime manager updates the connection's live UserIdentifier because default user targeting scans connection state.
  • The Redis lifetime manager removes the old user subscription and subscribes the connection to the new user channel.

WindowsIdentity handling

  • Explicit /refresh clones WindowsIdentity-backed principals before storing them on the connection.
  • SignalR owns and disposes cloned identities when they are replaced or when the connection is disposed.
  • Caller/request-owned WindowsIdentity instances are not disposed by SignalR.
  • Long Polling's existing request-lifetime WindowsIdentity path is preserved to avoid SafeHandle disposal races.
  • Tests cover clone/dispose behavior and disposal during connection cleanup.

BrennanConroy and others added 12 commits March 3, 2026 17:23
Server-side:
- Add /refresh HTTP endpoint mapped alongside /negotiate
- Add tokenLifetimeSeconds to negotiate response (NegotiationResponse + NegotiateProtocol)
- Add EnableAuthRefresh and AuthRefreshGracePeriod to HttpConnectionDispatcherOptions
- Add UpdateUser method to HttpConnectionContext for updating ClaimsPrincipal
- Add TryGetConnectionByConnectionId to HttpConnectionManager
- Server re-authenticates on /refresh and updates connection's User and auth expiration
- Compute TTL from AuthenticationProperties.ExpiresUtc

.NET Client-side:
- Add IAuthRefreshFeature interface in Connections.Abstractions
- HttpConnection implements IAuthRefreshFeature, POSTs to /refresh endpoint
- HubConnection.RefreshAuthAsync() discovers IAuthRefreshFeature via Features collection
- Auto-refresh timer schedules at: now + TTL - RefreshBeforeExpiration (default 5 min)
- Timer disposes on StopAsync/DisposeAsync

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…eFeature, Hub.OnAuthRefreshedAsync, abort on identifier change

- New public IConnectionUserUpdateFeature in Connections.Abstractions exposes a UserUpdated event raised by HttpConnectionContext.UpdateUser.
- HubConnectionContext.User now reads from IConnectionUserFeature on every access so refreshed claims take effect immediately (including for [Authorize]).
- New virtual Hub.OnAuthRefreshedAsync(ClaimsPrincipal? previousUser) lifecycle hook; dispatched by HubConnectionHandler via DefaultHubDispatcher (same scope/activator/activity pattern as OnConnected).
- HubConnectionHandler subscribes to IConnectionUserUpdateFeature.UserUpdated; if the recomputed UserIdentifier changes it logs a warning and aborts the connection (SignalR user-targeting requires a stable identifier), otherwise it dispatches OnAuthRefreshedAsync.
- Adds tests for HttpConnectionContext exposing the feature, the event firing, exception isolation in the handler, hub User reflecting refreshed claims, OnAuthRefreshedAsync being invoked, and abort-on-identifier-change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
DefaultHubDispatcher.OnAuthRefreshedAsync now acquires the per-connection ChannelBasedSemaphore (same path normal hub invocations use), so the refresh callback respects MaximumParallelInvocations and orders with any in-flight invocations instead of running concurrently. Exceptions from user code are caught and logged via FailedInvokingHubMethod (must not throw out of the semaphore callback). Adds a test that holds the semaphore with a blocking hub method and asserts OnAuthRefreshedAsync only runs after the method releases.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Added 5 hub-layer tests (same-identifier dispatch, exception in OnAuthRefreshedAsync,
multiple sequential refreshes, Context.User reflects new principal, missing feature is
no-op) and 3 connection-layer tests (multiple subscribers, unsubscribe, no subscribers).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove the previousUser parameter from Hub.OnAuthRefreshedAsync and the
IConnectionUserUpdateFeature.UserUpdated event signature. Holding a reference
to the previous ClaimsPrincipal across the refresh boundary is unsafe when the
identity is a WindowsIdentity, since its underlying SafeHandle can be disposed
when the refresh HTTP request completes.

Add two tests exercising claim-based authorization through a refresh:
- RefreshAddingRequiredClaimAllowsAuthorizedHubMethod
- RefreshRemovingRequiredClaimBlocksAuthorizedHubMethod

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…dispose owned identities

When /refresh hands the connection a new ClaimsPrincipal backed by a
WindowsIdentity, the SafeHandles inside it are tied to the /refresh
HTTP request lifetime, not the connection. After that request ends the
handles get disposed and any later hub method or OnAuthRefreshedAsync
invocation that reaches through to the WindowsIdentity fails.

Mirror HttpConnectionDispatcher.CloneUser: clone WindowsIdentity-bearing
principals into a connection-owned copy inside UpdateUser, and dispose
the previously-owned identities only after the UserUpdated event has
been raised so subscribers see the new principal first. Track ownership
explicitly via _ownsUserIdentities so DisposeAsync uses the same rule
(replacing the prior long-polling-only check).

Add three tests covering: clone-on-refresh disposes the prior owned
WindowsIdentity; non-WindowsIdentity principals pass through without
cloning; DisposeAsync disposes a refresh-clone even outside long polling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ned permission policy

Introduce HttpConnectionDispatcherOptions.OnAuthRefresh, a
Func<AuthRefreshContext, ValueTask<bool>> the application can supply
to inspect the previous and new ClaimsPrincipal during a /refresh call
and accept or reject the swap. When the callback returns false the
endpoint responds with HTTP 403 (permission_change_rejected) and the
connection's user is left unchanged. When null (default) behavior is
unchanged - any successful re-auth is accepted.

The AuthRefreshContext exposes HttpContext, ConnectionId, PreviousUser,
NewUser, NewExpiration, and a mutable DenyReason that surfaces in the
403 body for client diagnostics. Keeps the framework unopinionated:
apps decide downgrade-only / protected-claim / subject-mismatch rules
without us shipping comparison semantics.

Added 4 tests covering: callback receives correct context and accepts,
false return produces 403 with DenyReason and leaves user untouched,
default description when DenyReason is null, callback exceptions
propagate without mutating connection state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…resh endpoint

Mirror the existing MapConnectionHandlerEndPointRoutingAppliesNegotiateMetadata
tests for the new /refresh endpoint:
- when EnableAuthRefresh=true the route table contains three endpoints in
  order (/negotiate, /refresh, /), and only /refresh carries
  AuthRefreshMetadata plus HttpConnectionDispatcherOptions
- when EnableAuthRefresh is left default only /negotiate and / are
  registered and neither carries AuthRefreshMetadata

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds HttpConnectionTests.RefreshAuth.cs covering:
- throws InvalidOperationException before connection started
- POSTs to {url}/refresh?id={connectionToken} with correct path composition
- sends Bearer token from AccessTokenProvider
- omits Authorization header when no token provider
- returns parsed tokenLifetimeSeconds (and null when absent)
- throws HttpRequestException on 401/403/404
- propagates network exceptions
- propagates cancellation via CancellationToken

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…fresh feature-only auth

- Run the auth-refresh path on the Long Polling poll endpoint so token
  expiration/principal updates stay consistent across transports, with
  stale-guard handling for concurrent polls.
- Rekey connections in the hub lifetime managers (default and Redis) when
  a refresh changes the UserIdentifier, including scaleout coverage in the
  Specification.Tests base class.
- /refresh now reads only IAuthenticateResultFeature (no AuthenticateAsync
  fallback), matching negotiate and the connect/poll paths.
- Add OnAuthRefresh permission-change handling and functional/unit test
  coverage (client HubConnectionTests.AuthRefresh, dispatcher, Redis e2e).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@BrennanConroy BrennanConroy added the area-signalr Includes: SignalR clients and servers label Jun 10, 2026
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.

1 participant