Add SignalR Auth Refresh support to server and .NET client#67111
Draft
BrennanConroy wants to merge 12 commits into
Draft
Add SignalR Auth Refresh support to server and .NET client#67111BrennanConroy wants to merge 12 commits into
BrennanConroy wants to merge 12 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
HttpConnectionDispatcherOptions.EnableAuthRefresh.tokenLifetimeSeconds.HubConnection.RefreshAuthAsync()manually, orAuthRefreshOptions.POSTto{hub-or-connection-url}/refresh?id={connectionToken}with freshly acquired auth credentials.OnAuthRefreshcallback can accept or reject the refreshed principal.UserIdentifier, and rekeysClients.User(...)routing when needed through theHubLifetimeManager, and publishes a new hub caller context snapshot for future hub work.Server changes
/refreshendpoint alongside the existing negotiate/connect/send/poll endpoints.tokenLifetimeSecondswhen auth refresh is enabled and the auth ticket hasExpiresUtc./refreshrejects negotiate v0 connections because v0 has no private connection token (ConnectionId == ConnectionToken).CloseOnAuthenticationExpirationand auth refresh are both enabled.Endpoint metadata / auth behavior
OnAuthRefreshruns after the request has authenticated but before the connection user is replaced.OnAuthRefreshreturnsfalse, the connection keeps its current principal.OnAuthRefreshthrows, the exception is allowed to propagate through normal ASP.NET Core server error handling.Client changes
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.tokenLifetimeSecondsfrom refresh responses and uses it to schedule subsequent automatic refreshes.Refresh URL behavior
/refreshintentionally posts to the original configured client URL, not a negotiated redirect URL.idquery parameter.Hub-layer changes
Hub.OnAuthRefreshedAsync().UserIdentifier.UserIdentifierchanges, the lifetime manager is asked to rekey the connection before hub-visible state is published.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 throughActiveInvocationLimit, so it interleaves with hub invocations according toMaximumParallelInvocations.Hub caller context snapshots
DefaultHubCallerContextnow capturesUserandUserIdentifieras a snapshot.DefaultHubDispatchercaptures oneHubCallerContextper hub operation and uses that same snapshot for:HubInvocationContext,hub.Context.UserIdentifier rekeying
HubLifetimeManager.OnUserIdentifierChangedAsync(...).UserIdentifierbecause default user targeting scans connection state.WindowsIdentity handling
/refreshclones WindowsIdentity-backed principals before storing them on the connection.