Skip to content

Feature: Azure SignalR Authentication Refresh for Default Mode #2278

Description

@MoChilia

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's ClaimsPrincipal and 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 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)

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddSignalR().AddAzureSignalR();

var app = 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 =>
    {
        var ok = context.NewUser.HasClaim("tenant", "contoso");
        return ValueTask.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):

public sealed class ChatHub : Hub
{
    public override Task OnAuthenticationRefreshedAsync() =>
        Clients.Caller.SendAsync("AuthenticationRefreshed", Context.UserIdentifier);
}

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.

  • Container / manager (internal IServiceConnectionContainer / IServiceConnectionManager<THub>):

    Task<RefreshConnectionAuthResult> RefreshConnectionAuthAsync(
        RefreshAuthMessage message, HubServiceEndpoint? preferredEndpoint = null, CancellationToken ct = default);
    
    Task<GetConnectionClaimsResult> GetConnectionClaimsAsync(
        GetConnectionClaimsMessage message, CancellationToken ct = 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:

    { "accessToken": "<refreshed-service-access-token>", "tokenLifetimeSeconds": 3600 }

    The shipped self-hosted client parses both fields and, when accessToken is present, adopts it as its transport credential (HttpConnection.RefreshAuthenticationAsyncParseRefreshResponse → 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 false403 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.
  • Anonymous connections can't refresh (401 invalid_token).

Error Mapping

* = produced before any runtime mutation:

error HTTP Source
refresh_disabled * 404 hub not opted in
— (no body) * 405 non-POST
missing_connection_token * 400 id absent
invalid_token * 401 app-token auth fails / anonymous
windows_identity_not_supported * 400 current or refreshed principal is WindowsIdentity
permission_change_rejected * 403 OnAuthenticationRefresh returns false
connection_not_found 404 unknown / disconnected / negotiate v0 / closed for auth expiration
permission_change_rejected 403 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
Loading

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Fields

    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions