You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Azure SignalR must provide the same client-facing behavior for ASRS-connected hubs. The design goal is to be API-transparent to applications: an app that already uses AddAzureSignalR() opts into refresh through the exact same HttpConnectionDispatcherOptions surface self-hosted SignalR uses. So there is no new Microsoft.Azure.SignalR public API for applications. Instead, the SDK's AuthRefreshMatcherPolicy intercepts /refresh for the ASRS-connected hub and drives the flow over the service connection.
API Design
1. Application-facing surface (no change)
varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();builder.Services.AddAuthorization();builder.Services.AddSignalR().AddAzureSignalR();varapp=builder.Build();app.UseAuthentication();app.UseAuthorization();app.MapHub<ChatHub>("/chat", options =>{// Existing option which closes connections when a token expires.options.CloseOnAuthenticationExpiration=true;// ASP.NET Core (new): registers /refresh and advertises tokenLifetimeSeconds at /negotiate.// The Azure SignalR SDK's AuthRefreshMatcherPolicy intercepts /refresh for the ASRS-connected hub.options.EnableAuthenticationRefresh=true;// ASP.NET Core (new): optional accept/reject gate. Runs on this app server before ASRS mutates anything.options.OnAuthenticationRefresh= context =>{varok=context.NewUser.HasClaim("tenant","contoso");returnValueTask.FromResult(ok);// false -> 403 permission_change_rejected, nothing changes};});app.Run();
React inside the hub by overriding the ASP.NET Core Hub.OnAuthenticationRefreshedAsync() (runs after ASRS applied the refreshed claims to Context.User):
ServiceOptions is unchanged for this feature. The .NET client is the stock SignalR client (WithAuthenticationRefresh, AccessTokenProvider); it needs no ASRS-specific configuration. With AuthenticationRefreshOptions.EnableAutoRefresh = true (auto-refresh) the client schedules the POST {hubUrl}/refresh itself off the tokenLifetimeSeconds reported at /negotiate, re-minting a fresh app token via AccessTokenProvider — no app code runs per refresh. With auto-refresh off, the app drives refresh manually (e.g. HubConnection.RefreshAuthenticationAsync).
2. New Service Protocols
These are new protocols between Azure SignalR Service and user application (server side).
namespace Microsoft.Azure.SignalR.Protocol;
+// [41] App -> runtime. Ackable. Keyed by the connection TOKEN+// (runtime resolves token -> id internally; the id is never returned).+// Applies refresh; on success the ack payload is the post-refresh claim set.+public partial class RefreshAuthMessage : ExtensibleServiceMessage, IAckableMessage+{+ public RefreshAuthMessage(string connectionToken, System.Security.Claims.Claim[]? claims,+ System.DateTimeOffset expireTime, int ackId) { }+ public string ConnectionToken { get; set; }+ public System.Security.Claims.Claim[]? Claims { get; set; }+ public System.DateTimeOffset ExpireTime { get; set; }+ public int AckId { get; set; }+}+// [42] App -> runtime. Ackable, Keyed by the connection TOKEN.+// Fetches PreviousUser claims for the OnAuthenticationRefresh gate. Ack payload = { Claims }+public partial class GetConnectionClaimsMessage : ExtensibleServiceMessage, IAckableMessage+{+ public GetConnectionClaimsMessage(string connectionToken, int ackId) { }+ public string ConnectionToken { get; set; }+ public int AckId { get; set; }+}+// [43] runtime -> app. Server-bound, NO ack (best-effort). Keyed by connection ID.+// Refreshes Context.User on the owning app server.+public partial class UpdateConnectionClaimsMessage : ExtensibleServiceMessage+{+ public UpdateConnectionClaimsMessage(string connectionId, System.Security.Claims.Claim[]? claims) { }+ public string ConnectionId { get; set; }+ public System.Security.Claims.Claim[]? Claims { get; set; }+}
public static class ServiceProtocol // message-type constants
{
+ public const int RefreshAuthMessageType = 41;+ public const int GetConnectionClaimsMessageType = 42;+ public const int UpdateConnectionClaimsMessageType = 43;
}
3. Internal SDK mechanics
The whole app-server orchestration is internal and net11-guarded (#if NET11_0_OR_GREATER).
AuthRefreshMatcherPolicy — auto-registered by AddAzureSignalR(); detects endpoints carrying both HubMetadata and AuthenticationRefreshMetadata and swaps the /refresh endpoint delegate to RefreshHandler<THub> (mirrors NegotiateMatcherPolicy).
RefreshHandler<THub> — POST/disabled/missing-token/auth/Windows checks; optional GetConnectionClaimsMessage -> PreviousUser -> OnAuthenticationRefresh gate; sends RefreshAuthMessage; mints the refreshed service access token from the runtime-returned post-refresh claims; writes { accessToken, tokenLifetimeSeconds } or { error }.
NegotiateHandler<THub> — sets NegotiationResponse.TokenLifetime (serialized as tokenLifetimeSeconds) when refresh is enabled.
Task<RefreshConnectionAuthResult>RefreshConnectionAuthAsync(RefreshAuthMessagemessage,HubServiceEndpoint?preferredEndpoint=null,CancellationTokenct=default);Task<GetConnectionClaimsResult>GetConnectionClaimsAsync(GetConnectionClaimsMessagemessage,CancellationTokenct=default);// internal result records surfacing status + post-refresh claims + the owning endpoint:// RefreshConnectionAuthResult(AckStatus Status, IReadOnlyList<Claim>? Claims, HubServiceEndpoint? OwningEndpoint)// GetConnectionClaimsResult(AckStatus Status, IReadOnlyList<Claim>? Claims, HubServiceEndpoint? OwningEndpoint)
Behavior and Semantics
What refresh does
Extends/changes an existing connection's auth expiration and application claims without reconnection. The connection id, group memberships, in-flight messages, and transport all survive, so there is no reconnect gap or message loss. Without refresh, CloseOnAuthenticationExpiration = true tears the connection down at the auth deadline and the client must reconnect.
Pairs with CloseOnAuthenticationExpiration = true: the runtime aborts the connection at the auth deadline; refresh advances that deadline before it fires.
The {hubUrl}/refresh wire contract
The client POSTs its refreshed app token to POST {hubUrl}/refresh?id={connectionToken}. The success body extends self-hosted's { tokenLifetimeSeconds? } with the refreshed service access token:
The shipped self-hosted client parses both fields and, when accessToken is present, adopts it as its transport credential (HttpConnection.RefreshAuthenticationAsync → ParseRefreshResponse → swap _accessTokenProvider + AccessTokenHttpMessageHandler.UpdateCachedToken). Self-hosted omits accessToken; the field is additive and ignored when absent. Adopting it keeps all transports (WebSockets, SSE, Long Polling) and reconnects working past the original service-token exp — not just WebSockets.
Refresh is keyed by the secret connection token (id), never the public connection id; the token→id map stays inside the runtime and is never returned to the app server. Requires negotiate v1, v0 will get connection_not_found (404).
Identity and claims rules
Same-user is enforced by the runtime. A refreshed token that resolves to a different SignalR user id (per IUserIdProvider) is rejected with 403 permission_change_rejected, and the connection is left open unchanged. (Self-hosted aborts on a user-id change; ASRS rejects instead.) The user identifier is never rekeyed.
Only application claims + expiration are mutable. Everything service-owned — routing, sticky target, transport, connection id/token, close-on-auth mode, principal-shape markers (asrs.s.nt/rt/aut), and the token envelope — is fixed at /negotiate and preserved (deny-by-asrs.s. prefix). Close-on-auth mode can't be turned on/off by refresh; only the deadline moves.
Context.User is always claims-only. Claim values and types round-trip (so Identity.Name / IsInRole(...) behave as at connect), but live auth-handler/scheme state and non-serializable identities (e.g. WindowsIdentity) do not.
Not retroactive. A hub invocation already running keeps its original Context.User / Context.UserIdentifier snapshot; only later invocations observe the refreshed principal. [Authorize] on hub methods re-evaluates against refreshed claims, so a revoked permission takes effect on the live connection.
The application gate (OnAuthenticationRefresh)
Runs on the app server that received {hubUrl}/refresh, before ASRS mutates anything. Returning false → 403 permission_change_rejected, nothing changes.
PreviousUser is fetched read-only from the runtime (claims-only, via GetConnectionClaimsMessage); NewUser is the validated app principal. AuthenticationRefreshContext.ConnectionId is empty in Default mode — the public id never leaves the runtime, so correlate off your own HttpContext / the token.
The same-user check is not pre-checked on the app server; it live in the runtime.
Tokens and scheduling
tokenLifetimeSeconds = min(AccessTokenLifetime, ExpireTime − now), minted locally by the SDK. Because the service token always has an exp, it is always present for an eligible connection. When client auto-refresh (EnableAutoRefresh) is on, the client auto-schedules the next /refresh from this value (and for short lifetimes refreshes at half the lifetime, subject to a minimum interval); if the SDK advertises no tokenLifetimeSeconds, the client never schedules a refresh.
The refreshed service access token is minted from the runtime-returned post-refresh claim set for the connection's owning endpoint (preserves connect-time asrs.s.* system claims; advances only asrs.s.aeo), never re-derived from the /refresh request.
Concurrency, sharding, and propagation
Refreshes are serialized per connection; expiration extension is monotonic — a stale/out-of-order refresh with an equal/earlier deadline is an Ok no-op, never a regression or error.
Multi-endpoint: refresh pins to the connection's owning ServiceEndpoint (no rebalance, no re-negotiate); the token is minted for that endpoint. Ambiguous multi-owner success is a failure, not an arbitrary pick.
The routed Context.User update to the owning app server is best-effort and off the client response path — the client's /refresh succeeds even if that push lags; the principal reconciles on the next message or reconnect.
Compatibility and limits
The runtime ships first; older SDKs/clients no-op (don't advertise tokenLifetimeSeconds, so clients don't call /refresh), and an SDK that predates the UpdateConnectionClaimsMessage (type 43) ignores it.
WindowsIdentity principals are unsupported (400 windows_identity_not_supported) — they have no AuthenticationProperties.ExpiresUtc and rely on reconnect.
runtime ConnectionUserMismatch (folded from AckStatus.Forbidden)
internal_server_error
500
cross-pod / other runtime error
Flow
sequenceDiagram
autonumber
participant Client
participant App as App Server
participant ASRS as Azure SignalR Service
participant Owner as Owning App Server
Client->>App: POST /hub/negotiate
App-->>Client: { url, accessToken, tokenLifetimeSeconds }
Client->>ASRS: WebSocket / SSE / LongPolling connect with initial service access token
ASRS->>ASRS: Store AuthenticationExpiresOn, user claims
Note over Client,ASRS: accessToken is near expiry
Client->>App: POST {hubUrl}/refresh?id={connectionToken}<br/>Authorization: Bearer {new-app-token}
App->>App: Validates new app token
opt OnAuthenticationRefresh configured
App->>ASRS: GetConnectionClaimsMessage(ConnectionToken, AckId)<br/>read-only
ASRS-->>App: Ack(Ok, current claims as PreviousUser)<br/>or failure
App->>App: Run OnAuthenticationRefresh(HttpContext, PreviousUser, NewUser)
end
alt OnAuthenticationRefresh rejects
App-->>Client: 403 permission_change_rejected
else Accepted (or OnAuthenticationRefresh not configured)
App->>App: SDK derives ExpireTime from the new token + projects claims
App->>ASRS: RefreshAuthMessage(ConnectionToken, Claims, ExpireTime, AckId)
ASRS->>ASRS: Resolve token to id, reject v0, or route to the owning service instance<br/>verify same user, update AuthenticationExpiresOn, user claims
alt Refresh applied
ASRS-->>App: Ack(ackId, Ok, post-refresh Claims)
App->>App: SDK mints accessToken from returned Claims<br/>for the owning endpoint
App-->>Client: 200 OK { accessToken, tokenLifetimeSeconds }
Note over ASRS,Owner: App-server claims propagation is best-effort
opt Claims changed
ASRS->>Owner: Locate owning server connection,<br/>UpdateConnectionClaimsMessage(ConnectionId, Claims)
Owner->>Owner: Update Context.User
Note over ASRS,Owner: A failed routed update is logged without failing the client refresh
end
else Refresh failed
ASRS-->>App: Ack(ackId, failure status, typed error)
App-->>Client: 403 permission_change_rejected / 404 connection_not_found / 500
end
end
Note over Client,ASRS: Existing connection stays open
Background
ASP.NET Core added an opt-in SignalR authentication-refresh flow: the server exposes
POST {hubUrl}/refresh?id={connectionToken}, re-authenticates the request, and updates the connection'sClaimsPrincipaland expiration without reconnecting. See [API Proposal] SignalR Authentication Refresh.Azure SignalR must provide the same client-facing behavior for ASRS-connected hubs. The design goal is to be API-transparent to applications: an app that already uses
AddAzureSignalR()opts into refresh through the exact sameHttpConnectionDispatcherOptionssurface self-hosted SignalR uses. So there is no newMicrosoft.Azure.SignalRpublic API for applications. Instead, the SDK'sAuthRefreshMatcherPolicyintercepts/refreshfor the ASRS-connected hub and drives the flow over the service connection.API Design
1. Application-facing surface (no change)
React inside the hub by overriding the ASP.NET Core
Hub.OnAuthenticationRefreshedAsync()(runs after ASRS applied the refreshed claims toContext.User):ServiceOptionsis unchanged for this feature. The .NET client is the stock SignalR client (WithAuthenticationRefresh,AccessTokenProvider); it needs no ASRS-specific configuration. WithAuthenticationRefreshOptions.EnableAutoRefresh = true(auto-refresh) the client schedules thePOST {hubUrl}/refreshitself off thetokenLifetimeSecondsreported at/negotiate, re-minting a fresh app token viaAccessTokenProvider— no app code runs per refresh. With auto-refresh off, the app drives refresh manually (e.g.HubConnection.RefreshAuthenticationAsync).2. New Service Protocols
These are new protocols between Azure SignalR Service and user application (server side).
3. Internal SDK mechanics
The whole app-server orchestration is
internaland net11-guarded (#if NET11_0_OR_GREATER).AuthRefreshMatcherPolicy— auto-registered byAddAzureSignalR(); detects endpoints carrying bothHubMetadataandAuthenticationRefreshMetadataand swaps the/refreshendpoint delegate toRefreshHandler<THub>(mirrorsNegotiateMatcherPolicy).RefreshHandler<THub>— POST/disabled/missing-token/auth/Windows checks; optionalGetConnectionClaimsMessage->PreviousUser->OnAuthenticationRefreshgate; sendsRefreshAuthMessage; mints the refreshed service access token from the runtime-returned post-refresh claims; writes{ accessToken, tokenLifetimeSeconds }or{ error }.NegotiateHandler<THub>— setsNegotiationResponse.TokenLifetime(serialized astokenLifetimeSeconds) when refresh is enabled.Container / manager (internal
IServiceConnectionContainer/IServiceConnectionManager<THub>):Behavior and Semantics
What refresh does
CloseOnAuthenticationExpiration = truetears the connection down at the auth deadline and the client must reconnect.CloseOnAuthenticationExpiration = true: the runtime aborts the connection at the auth deadline; refresh advances that deadline before it fires.The
{hubUrl}/refreshwire contractThe client POSTs its refreshed app token to
POST {hubUrl}/refresh?id={connectionToken}. The success body extends self-hosted's{ tokenLifetimeSeconds? }with the refreshed service access token:{ "accessToken": "<refreshed-service-access-token>", "tokenLifetimeSeconds": 3600 }The shipped self-hosted client parses both fields and, when
accessTokenis present, adopts it as its transport credential (HttpConnection.RefreshAuthenticationAsync→ParseRefreshResponse→ swap_accessTokenProvider+AccessTokenHttpMessageHandler.UpdateCachedToken). Self-hosted omitsaccessToken; the field is additive and ignored when absent. Adopting it keeps all transports (WebSockets, SSE, Long Polling) and reconnects working past the original service-tokenexp— not just WebSockets.Refresh is keyed by the secret connection token (
id), never the public connection id; the token→id map stays inside the runtime and is never returned to the app server. Requires negotiate v1, v0 will getconnection_not_found(404).Identity and claims rules
IUserIdProvider) is rejected with403 permission_change_rejected, and the connection is left open unchanged. (Self-hosted aborts on a user-id change; ASRS rejects instead.) The user identifier is never rekeyed.asrs.s.nt/rt/aut), and the token envelope — is fixed at/negotiateand preserved (deny-by-asrs.s.prefix). Close-on-auth mode can't be turned on/off by refresh; only the deadline moves.Context.Useris always claims-only. Claim values and types round-trip (soIdentity.Name/IsInRole(...)behave as at connect), but live auth-handler/scheme state and non-serializable identities (e.g.WindowsIdentity) do not.Context.User/Context.UserIdentifiersnapshot; only later invocations observe the refreshed principal.[Authorize]on hub methods re-evaluates against refreshed claims, so a revoked permission takes effect on the live connection.The application gate (
OnAuthenticationRefresh){hubUrl}/refresh, before ASRS mutates anything. Returningfalse→403 permission_change_rejected, nothing changes.PreviousUseris fetched read-only from the runtime (claims-only, viaGetConnectionClaimsMessage);NewUseris the validated app principal.AuthenticationRefreshContext.ConnectionIdis empty in Default mode — the public id never leaves the runtime, so correlate off your ownHttpContext/ the token.Tokens and scheduling
tokenLifetimeSeconds = min(AccessTokenLifetime, ExpireTime − now), minted locally by the SDK. Because the service token always has anexp, it is always present for an eligible connection. When client auto-refresh (EnableAutoRefresh) is on, the client auto-schedules the next/refreshfrom this value (and for short lifetimes refreshes at half the lifetime, subject to a minimum interval); if the SDK advertises notokenLifetimeSeconds, the client never schedules a refresh.asrs.s.*system claims; advances onlyasrs.s.aeo), never re-derived from the/refreshrequest.Concurrency, sharding, and propagation
Okno-op, never a regression or error.ServiceEndpoint(no rebalance, no re-negotiate); the token is minted for that endpoint. Ambiguous multi-owner success is a failure, not an arbitrary pick.Context.Userupdate to the owning app server is best-effort and off the client response path — the client's/refreshsucceeds even if that push lags; the principal reconciles on the next message or reconnect.Compatibility and limits
tokenLifetimeSeconds, so clients don't call/refresh), and an SDK that predates theUpdateConnectionClaimsMessage(type 43) ignores it.WindowsIdentityprincipals are unsupported (400 windows_identity_not_supported) — they have noAuthenticationProperties.ExpiresUtcand rely on reconnect.401 invalid_token).Error Mapping
*= produced before any runtime mutation:errorrefresh_disabled*missing_connection_token*idabsentinvalid_token*windows_identity_not_supported*WindowsIdentitypermission_change_rejected*OnAuthenticationRefreshreturns falseconnection_not_foundpermission_change_rejectedConnectionUserMismatch(folded fromAckStatus.Forbidden)internal_server_errorFlow
sequenceDiagram autonumber participant Client participant App as App Server participant ASRS as Azure SignalR Service participant Owner as Owning App Server Client->>App: POST /hub/negotiate App-->>Client: { url, accessToken, tokenLifetimeSeconds } Client->>ASRS: WebSocket / SSE / LongPolling connect with initial service access token ASRS->>ASRS: Store AuthenticationExpiresOn, user claims Note over Client,ASRS: accessToken is near expiry Client->>App: POST {hubUrl}/refresh?id={connectionToken}<br/>Authorization: Bearer {new-app-token} App->>App: Validates new app token opt OnAuthenticationRefresh configured App->>ASRS: GetConnectionClaimsMessage(ConnectionToken, AckId)<br/>read-only ASRS-->>App: Ack(Ok, current claims as PreviousUser)<br/>or failure App->>App: Run OnAuthenticationRefresh(HttpContext, PreviousUser, NewUser) end alt OnAuthenticationRefresh rejects App-->>Client: 403 permission_change_rejected else Accepted (or OnAuthenticationRefresh not configured) App->>App: SDK derives ExpireTime from the new token + projects claims App->>ASRS: RefreshAuthMessage(ConnectionToken, Claims, ExpireTime, AckId) ASRS->>ASRS: Resolve token to id, reject v0, or route to the owning service instance<br/>verify same user, update AuthenticationExpiresOn, user claims alt Refresh applied ASRS-->>App: Ack(ackId, Ok, post-refresh Claims) App->>App: SDK mints accessToken from returned Claims<br/>for the owning endpoint App-->>Client: 200 OK { accessToken, tokenLifetimeSeconds } Note over ASRS,Owner: App-server claims propagation is best-effort opt Claims changed ASRS->>Owner: Locate owning server connection,<br/>UpdateConnectionClaimsMessage(ConnectionId, Claims) Owner->>Owner: Update Context.User Note over ASRS,Owner: A failed routed update is logged without failing the client refresh end else Refresh failed ASRS-->>App: Ack(ackId, failure status, typed error) App-->>Client: 403 permission_change_rejected / 404 connection_not_found / 500 end end Note over Client,ASRS: Existing connection stays open