From 7c1e85eb3b45b66dbc5fdbfe2a8f43ef0e098ce9 Mon Sep 17 00:00:00 2001 From: shiyingchen Date: Fri, 17 Jul 2026 15:26:51 +0800 Subject: [PATCH 1/5] Auth refresh sample for default mode --- .../AuthRefreshSample/Client/Client.csproj | 20 ++++ samples/AuthRefreshSample/Client/Program.cs | 87 ++++++++++++++ samples/AuthRefreshSample/README.md | 106 ++++++++++++++++++ samples/AuthRefreshSample/Server/ChatHub.cs | 21 ++++ samples/AuthRefreshSample/Server/Program.cs | 68 +++++++++++ .../AuthRefreshSample/Server/Server.csproj | 20 ++++ .../AuthRefreshSample/Server/appsettings.json | 14 +++ samples/AuthRefreshSample/Shared/DemoAuth.cs | 17 +++ 8 files changed, 353 insertions(+) create mode 100644 samples/AuthRefreshSample/Client/Client.csproj create mode 100644 samples/AuthRefreshSample/Client/Program.cs create mode 100644 samples/AuthRefreshSample/README.md create mode 100644 samples/AuthRefreshSample/Server/ChatHub.cs create mode 100644 samples/AuthRefreshSample/Server/Program.cs create mode 100644 samples/AuthRefreshSample/Server/Server.csproj create mode 100644 samples/AuthRefreshSample/Server/appsettings.json create mode 100644 samples/AuthRefreshSample/Shared/DemoAuth.cs diff --git a/samples/AuthRefreshSample/Client/Client.csproj b/samples/AuthRefreshSample/Client/Client.csproj new file mode 100644 index 00000000..0b662026 --- /dev/null +++ b/samples/AuthRefreshSample/Client/Client.csproj @@ -0,0 +1,20 @@ + + + + Exe + net11.0 + enable + enable + AuthRefreshSample + + + + + + + + + + + + diff --git a/samples/AuthRefreshSample/Client/Program.cs b/samples/AuthRefreshSample/Client/Program.cs new file mode 100644 index 00000000..ba8fec85 --- /dev/null +++ b/samples/AuthRefreshSample/Client/Program.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. + +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +using AuthRefreshSample; + +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +// Usage: dotnet run [hubUrl] [userId] [role] +var hubUrl = args.Length > 0 ? args[0] : "http://localhost:5000/chat"; +var userId = args.Length > 1 ? args[1] : "alice"; +var role = args.Length > 2 ? args[2] : "user"; + +// Short lifetime so a refresh happens quickly (the .NET client re-mints the app token before it expires). +var tokenLifetime = TimeSpan.FromMinutes(2); + +// Mint a fresh app token on demand. +string MintAppToken() +{ + var now = DateTimeOffset.UtcNow; + var credentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(DemoAuth.SigningKey)), + SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: DemoAuth.Issuer, + audience: DemoAuth.Audience, + claims: + [ + new Claim(JwtRegisteredClaimNames.Sub, userId), + new Claim("name", userId), + new Claim(ClaimTypes.Role, role), + ], + notBefore: now.UtcDateTime, + expires: now.Add(tokenLifetime).UtcDateTime, + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); +} + +var connection = new HubConnectionBuilder() + .WithUrl(hubUrl, options => + { + options.AccessTokenProvider = () => Task.FromResult(MintAppToken()); + }) + .WithAuthenticationRefresh(refresh => + { + refresh.EnableAutoRefresh = true; // schedule refresh off tokenLifetimeSeconds + refresh.RefreshBeforeExpiration = TimeSpan.FromSeconds(30); + refresh.OnAuthenticationRefreshed = ctx => + { + Console.WriteLine($"[refresh] succeeded; next lifetime = {ctx.NewTokenLifetime}"); + return Task.CompletedTask; + }; + refresh.OnAuthenticationRefreshFailed = ctx => + { + Console.WriteLine($"[refresh] FAILED: {ctx.Exception?.Message}"); + return Task.CompletedTask; + }; + }) + .WithAutomaticReconnect() + .Build(); + +connection.On("ReceiveMessage", (user, message) => + Console.WriteLine($"{user}: {message}")); + +Console.WriteLine($"Connecting to {hubUrl} as '{userId}' (role '{role}')..."); +await connection.StartAsync(); +Console.WriteLine("Connected. Type a message and press Enter to broadcast (empty line to quit)."); + +while (true) +{ + var line = Console.ReadLine(); + if (string.IsNullOrEmpty(line)) + { + break; + } + + await connection.InvokeAsync("Broadcast", line); +} + +await connection.DisposeAsync(); diff --git a/samples/AuthRefreshSample/README.md b/samples/AuthRefreshSample/README.md new file mode 100644 index 00000000..ad45445d --- /dev/null +++ b/samples/AuthRefreshSample/README.md @@ -0,0 +1,106 @@ +# Auth Refresh Sample (Default mode) + +A minimal ASP.NET Core **server** + .NET console **client** that demonstrate Azure SignalR +**Authentication Refresh** — refreshing an expiring SignalR auth token on a schedule **without +reconnecting** the client. + +- The client connects with a short-lived **app token** (a demo JWT it mints itself). +- The server opts the hub into refresh (`EnableAuthenticationRefresh`) and tears the connection down + when auth expires (`CloseOnAuthenticationExpiration`). +- Before the token expires, the client's `WithAuthenticationRefresh` auto-scheduler re-mints a fresh + app token and POSTs `{hubUrl}/refresh`; Azure SignalR extends the live connection's deadline and the + client adopts the refreshed **service** access token — the connection stays open the whole time. + +> [!IMPORTANT] +> Authentication Refresh is a **preview** feature. It requires the **.NET 11 preview SDK** and preview +> builds of ASP.NET Core SignalR and `Microsoft.Azure.SignalR`. + +## Layout + +| Path | What | +| --- | --- | +| `Server/` | ASP.NET Core app server: JWT auth, `AddAzureSignalR()`, `ChatHub` with refresh enabled. | +| `Client/` | .NET console client using `WithAuthenticationRefresh`. | + +Both share `DemoAuth.cs` (issuer/audience/HS256 key) so the client can mint tokens the server validates. +This is **demo-only**; a real app gets its app token from an identity provider. + +## Prerequisites + +- .NET 11 preview SDK. +- An Azure SignalR Service resource (connection string). + +## Configure the connection string (server) + +Set `Azure:SignalR:ConnectionString` — for local dev, user secrets or an environment variable: + +```bash +cd Server +dotnet user-secrets init +dotnet user-secrets set "Azure:SignalR:ConnectionString" "" +# or: setx Azure__SignalR__ConnectionString "" +``` + +## Run + +In one terminal: + +```bash +cd Server +dotnet run +``` + +In another: + +```bash +cd Client +dotnet run +# optional args: dotnet run -- http://localhost:5000/chat alice user +``` + +Type messages in the client to broadcast them. Roughly every ~90s (2 min token, refresh 30s before +expiry) you'll see: + +``` +[refresh] succeeded; next lifetime = 00:02:00 +system: auth refreshed for alice +``` + +...while the connection never drops. + +## Try the accept/reject gate + +The server rejects a refresh whose new token carries role `blocked`: + +```csharp +options.OnAuthenticationRefresh = context => + ValueTask.FromResult(!context.NewUser.IsInRole("blocked")); +``` + +Start the client with the `blocked` role to see the refresh fail with `403 permission_change_rejected` +(the existing connection is left open, unchanged, until its deadline): + +```bash +cd Client +dotnet run -- http://localhost:5000/chat alice blocked +``` + +``` +[refresh] FAILED: ...permission_change_rejected... +``` + +## How it works + +1. **Negotiate.** The client sends its app token to `/chat/negotiate`; the server validates it, and + because refresh is enabled for an authenticated principal with an expiry, advertises + `tokenLifetimeSeconds`. +2. **Connect.** The client connects to Azure SignalR with the returned service access token. +3. **Schedule.** `WithAuthenticationRefresh` schedules a refresh before `tokenLifetimeSeconds` elapses. +4. **Refresh.** The client re-mints a fresh app token (`AccessTokenProvider`) and POSTs + `/chat/refresh?id={connectionToken}`. The server runs the optional `OnAuthenticationRefresh` gate, + then asks Azure SignalR to extend the live connection's auth deadline and apply the refreshed claims. +5. **Adopt.** The server returns `{ accessToken, tokenLifetimeSeconds }`; the client adopts the new + service token and schedules the next refresh. The connection is never reconnected. + +> [!NOTE] +> The demo surfaces the app token's `exp` as the auth ticket's `ExpiresUtc` in `OnTokenValidated`(JwtBearer doesn't do this by default), which is what lets negotiate advertise `tokenLifetimeSeconds`. diff --git a/samples/AuthRefreshSample/Server/ChatHub.cs b/samples/AuthRefreshSample/Server/ChatHub.cs new file mode 100644 index 00000000..cea04c84 --- /dev/null +++ b/samples/AuthRefreshSample/Server/ChatHub.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace AuthRefreshSample; + +[Authorize] +public sealed class ChatHub : Hub +{ + public override Task OnConnectedAsync() => + Clients.Caller.SendAsync("ReceiveMessage", "system", $"connected as {Context.UserIdentifier}"); + + public Task Broadcast(string message) => + Clients.All.SendAsync("ReceiveMessage", Context.UserIdentifier ?? "anonymous", message); + + // Runs after Azure SignalR applies the refreshed claims to Context.User. React to a refresh here. + public override Task OnAuthenticationRefreshedAsync() => + Clients.Caller.SendAsync("ReceiveMessage", "system", $"auth refreshed for {Context.UserIdentifier}"); +} diff --git a/samples/AuthRefreshSample/Server/Program.cs b/samples/AuthRefreshSample/Server/Program.cs new file mode 100644 index 00000000..46fe6eb8 --- /dev/null +++ b/samples/AuthRefreshSample/Server/Program.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. + +using System.Text; + +using AuthRefreshSample; + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +builder.WebHost.UseUrls("http://localhost:5000"); + +var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(DemoAuth.SigningKey)); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = DemoAuth.Issuer, + ValidateAudience = true, + ValidAudience = DemoAuth.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = signingKey, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + }; + + options.Events = new JwtBearerEvents + { + // Auth refresh advertises tokenLifetimeSeconds only when the auth ticket carries an ExpiresUtc. + // JwtBearer does not set it from the token by default, so surface the token's exp. + OnTokenValidated = context => + { + if (context.SecurityToken is JsonWebToken jwt && jwt.ValidTo > DateTime.UtcNow) + { + context.Properties.ExpiresUtc = new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero); + } + + return Task.CompletedTask; + }, + }; + }); + +builder.Services.AddAuthorization(); +builder.Services.AddSignalR().AddAzureSignalR(); // Azure:SignalR:ConnectionString + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapHub("/chat", options => +{ + + options.CloseOnAuthenticationExpiration = true; + options.EnableAuthenticationRefresh = true; + // Optional accept/reject gate, run before Azure SignalR mutates anything. + options.OnAuthenticationRefresh = context => + ValueTask.FromResult(!context.NewUser.IsInRole("blocked")); +}).RequireAuthorization(); + +app.Run(); diff --git a/samples/AuthRefreshSample/Server/Server.csproj b/samples/AuthRefreshSample/Server/Server.csproj new file mode 100644 index 00000000..a0de8a3a --- /dev/null +++ b/samples/AuthRefreshSample/Server/Server.csproj @@ -0,0 +1,20 @@ + + + + net11.0 + enable + enable + AuthRefreshSample + + + + + + + + + + + + + diff --git a/samples/AuthRefreshSample/Server/appsettings.json b/samples/AuthRefreshSample/Server/appsettings.json new file mode 100644 index 00000000..62eb2611 --- /dev/null +++ b/samples/AuthRefreshSample/Server/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Azure": { + "SignalR": { + "ConnectionString": "" + } + } +} diff --git a/samples/AuthRefreshSample/Shared/DemoAuth.cs b/samples/AuthRefreshSample/Shared/DemoAuth.cs new file mode 100644 index 00000000..7f0c1562 --- /dev/null +++ b/samples/AuthRefreshSample/Shared/DemoAuth.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. + +namespace AuthRefreshSample; + +// DEMO ONLY. Shared HS256 signing material so the client can mint app tokens the server validates +// without a real identity provider. In production the app token comes from your IdP and you must +// never hard-code signing keys. This single file is linked into both the Server and Client projects. +internal static class DemoAuth +{ + public const string Issuer = "auth-refresh-sample"; + + public const string Audience = "auth-refresh-sample-hub"; + + // Must be >= 256 bits (32 bytes) for HS256. + public const string SigningKey = "auth-refresh-sample-demo-signing-key-please-change-0123456789"; +} From 23023d1b44fea675aae06c8ce4b56024de8c7c66 Mon Sep 17 00:00:00 2001 From: shiyingchen Date: Tue, 21 Jul 2026 18:49:39 +0800 Subject: [PATCH 2/5] add serverless mode sample with management sdk --- .../AuthRefreshSample/Client/Client.csproj | 13 +- samples/AuthRefreshSample/Client/Program.cs | 10 +- .../{Server => DefaultMode}/ChatHub.cs | 0 .../{Server => DefaultMode}/Program.cs | 0 .../AuthRefreshSample/DefaultMode/README.md | 36 +++++ .../{Server => DefaultMode}/Server.csproj | 0 .../{Server => DefaultMode}/appsettings.json | 0 samples/AuthRefreshSample/README.md | 108 +++++---------- .../Serverless/Management/Management.csproj | 29 ++++ .../Serverless/Management/Program.cs | 126 ++++++++++++++++++ .../Serverless/Management/README.md | 49 +++++++ .../Serverless/Management/SignalRService.cs | 35 +++++ .../Serverless/Management/appsettings.json | 8 ++ 13 files changed, 335 insertions(+), 79 deletions(-) rename samples/AuthRefreshSample/{Server => DefaultMode}/ChatHub.cs (100%) rename samples/AuthRefreshSample/{Server => DefaultMode}/Program.cs (100%) create mode 100644 samples/AuthRefreshSample/DefaultMode/README.md rename samples/AuthRefreshSample/{Server => DefaultMode}/Server.csproj (100%) rename samples/AuthRefreshSample/{Server => DefaultMode}/appsettings.json (100%) create mode 100644 samples/AuthRefreshSample/Serverless/Management/Management.csproj create mode 100644 samples/AuthRefreshSample/Serverless/Management/Program.cs create mode 100644 samples/AuthRefreshSample/Serverless/Management/README.md create mode 100644 samples/AuthRefreshSample/Serverless/Management/SignalRService.cs create mode 100644 samples/AuthRefreshSample/Serverless/Management/appsettings.json diff --git a/samples/AuthRefreshSample/Client/Client.csproj b/samples/AuthRefreshSample/Client/Client.csproj index 0b662026..7c254360 100644 --- a/samples/AuthRefreshSample/Client/Client.csproj +++ b/samples/AuthRefreshSample/Client/Client.csproj @@ -6,10 +6,21 @@ enable enable AuthRefreshSample + $(MSBuildThisFileDirectory)..\..\..\..\aspnetcore\src\SignalR + true - + + + + + + + + + + diff --git a/samples/AuthRefreshSample/Client/Program.cs b/samples/AuthRefreshSample/Client/Program.cs index ba8fec85..305f5a8e 100644 --- a/samples/AuthRefreshSample/Client/Program.cs +++ b/samples/AuthRefreshSample/Client/Program.cs @@ -8,7 +8,6 @@ using AuthRefreshSample; using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; // Usage: dotnet run [hubUrl] [userId] [role] @@ -71,7 +70,7 @@ string MintAppToken() Console.WriteLine($"Connecting to {hubUrl} as '{userId}' (role '{role}')..."); await connection.StartAsync(); -Console.WriteLine("Connected. Type a message and press Enter to broadcast (empty line to quit)."); +Console.WriteLine("Connected. Type /refresh to refresh authentication, or a message to broadcast (empty line to quit)."); while (true) { @@ -81,6 +80,13 @@ string MintAppToken() break; } + if (string.Equals(line, "/refresh", StringComparison.OrdinalIgnoreCase)) + { + var newTokenLifetime = await connection.RefreshAuthenticationAsync(); + Console.WriteLine($"[refresh] manually completed; next lifetime = {newTokenLifetime}"); + continue; + } + await connection.InvokeAsync("Broadcast", line); } diff --git a/samples/AuthRefreshSample/Server/ChatHub.cs b/samples/AuthRefreshSample/DefaultMode/ChatHub.cs similarity index 100% rename from samples/AuthRefreshSample/Server/ChatHub.cs rename to samples/AuthRefreshSample/DefaultMode/ChatHub.cs diff --git a/samples/AuthRefreshSample/Server/Program.cs b/samples/AuthRefreshSample/DefaultMode/Program.cs similarity index 100% rename from samples/AuthRefreshSample/Server/Program.cs rename to samples/AuthRefreshSample/DefaultMode/Program.cs diff --git a/samples/AuthRefreshSample/DefaultMode/README.md b/samples/AuthRefreshSample/DefaultMode/README.md new file mode 100644 index 00000000..edafd5d4 --- /dev/null +++ b/samples/AuthRefreshSample/DefaultMode/README.md @@ -0,0 +1,36 @@ +# Authentication Refresh in Default Mode + +This ASP.NET Core app hosts a SignalR hub through Azure SignalR Service in Default mode. It enables authentication refresh so the shared `Client/` can update an existing connection's authentication without reconnecting. + +## Prerequisites + +- .NET 11 preview SDK. +- An Azure SignalR Service resource in Default mode. +- Preview builds of the SignalR client and `Microsoft.Azure.SignalR`. + +## Configure + +Set the Azure SignalR connection string with user secrets: + +```bash +dotnet user-secrets set "Azure:SignalR:ConnectionString" "" +``` + +## Run + +Start this server: + +```bash +dotnet run +``` + +Then run the shared client: + +```bash +cd ../Client +dotnet run -- http://localhost:5000/chat alice user +``` + +Leave the client connected. Approximately every 90 seconds, it obtains a new application token and posts it to `/chat/refresh`; the Azure SignalR SDK updates the existing connection and returns a new service access token. The connection ID does not change. + +Type `/refresh` in the client to refresh authentication manually. diff --git a/samples/AuthRefreshSample/Server/Server.csproj b/samples/AuthRefreshSample/DefaultMode/Server.csproj similarity index 100% rename from samples/AuthRefreshSample/Server/Server.csproj rename to samples/AuthRefreshSample/DefaultMode/Server.csproj diff --git a/samples/AuthRefreshSample/Server/appsettings.json b/samples/AuthRefreshSample/DefaultMode/appsettings.json similarity index 100% rename from samples/AuthRefreshSample/Server/appsettings.json rename to samples/AuthRefreshSample/DefaultMode/appsettings.json diff --git a/samples/AuthRefreshSample/README.md b/samples/AuthRefreshSample/README.md index ad45445d..abf6c4fe 100644 --- a/samples/AuthRefreshSample/README.md +++ b/samples/AuthRefreshSample/README.md @@ -1,106 +1,62 @@ -# Auth Refresh Sample (Default mode) +# Azure SignalR Authentication Refresh Sample -A minimal ASP.NET Core **server** + .NET console **client** that demonstrate Azure SignalR -**Authentication Refresh** — refreshing an expiring SignalR auth token on a schedule **without -reconnecting** the client. +This sample shows how a .NET SignalR client can refresh authentication for an existing Azure SignalR connection without reconnecting. -- The client connects with a short-lived **app token** (a demo JWT it mints itself). -- The server opts the hub into refresh (`EnableAuthenticationRefresh`) and tears the connection down - when auth expires (`CloseOnAuthenticationExpiration`). -- Before the token expires, the client's `WithAuthenticationRefresh` auto-scheduler re-mints a fresh - app token and POSTs `{hubUrl}/refresh`; Azure SignalR extends the live connection's deadline and the - client adopts the refreshed **service** access token — the connection stays open the whole time. +The client uses a short-lived application token. Before it expires `WithAuthenticationRefresh` obtains a new application token and posts it to `{hubUrl}/refresh`. The server updates the connection's authentication expiration and returns a new Azure SignalR service access token. The connection remains active throughout the refresh. -> [!IMPORTANT] -> Authentication Refresh is a **preview** feature. It requires the **.NET 11 preview SDK** and preview -> builds of ASP.NET Core SignalR and `Microsoft.Azure.SignalR`. +## Modes -## Layout +### Default mode -| Path | What | -| --- | --- | -| `Server/` | ASP.NET Core app server: JWT auth, `AddAzureSignalR()`, `ChatHub` with refresh enabled. | -| `Client/` | .NET console client using `WithAuthenticationRefresh`. | +`DefaultMode/` hosts a SignalR hub with `Microsoft.Azure.SignalR`. The server enables `EnableAuthenticationRefresh` and `CloseOnAuthenticationExpiration`; the Azure SignalR SDK handles the negotiate and refresh endpoints. -Both share `DemoAuth.cs` (issuer/audience/HS256 key) so the client can mint tokens the server validates. -This is **demo-only**; a real app gets its app token from an identity provider. +### Serverless mode -## Prerequisites - -- .NET 11 preview SDK. -- An Azure SignalR Service resource (connection string). +`Serverless/Management/` implements the negotiate and refresh endpoints directly. It uses `ServiceHubContext.NegotiateWithTokenLifetimeAsync` to negotiate and `ServiceHubContext.RefreshConnectionAuthenticationAsync` to refresh the live connection. -## Configure the connection string (server) +Both modes expose the same client-facing contract, so they reuse the client in `Client/`. -Set `Azure:SignalR:ConnectionString` — for local dev, user secrets or an environment variable: +## Prerequisites -```bash -cd Server -dotnet user-secrets init -dotnet user-secrets set "Azure:SignalR:ConnectionString" "" -# or: setx Azure__SignalR__ConnectionString "" -``` +- .NET 11 preview SDK +- An Azure SignalR Service resource +- Preview SignalR and Azure SignalR SDK packages -## Run +Until a preview SignalR client package containing authentication refresh is published, clone `aspnetcore` beside this sample repository. The client project automatically references `aspnetcore/src/SignalR`; for another location, pass `-p:AspNetCoreSignalRSourceRoot=`. -In one terminal: +Use an Azure SignalR resource in Default mode with `DefaultMode/`, or in Serverless mode with `Serverless/Management/`. -```bash -cd Server -dotnet run -``` +## Configure -In another: +Set the connection string in the terminal where you will run the server: -```bash -cd Client -dotnet run -# optional args: dotnet run -- http://localhost:5000/chat alice user +```powershell +$env:Azure__SignalR__ConnectionString = "" ``` -Type messages in the client to broadcast them. Roughly every ~90s (2 min token, refresh 30s before -expiry) you'll see: - -``` -[refresh] succeeded; next lifetime = 00:02:00 -system: auth refreshed for alice -``` - -...while the connection never drops. +## Run -## Try the accept/reject gate +Start one server from the `AuthRefreshSample` directory. -The server rejects a refresh whose new token carries role `blocked`: +Default mode: -```csharp -options.OnAuthenticationRefresh = context => - ValueTask.FromResult(!context.NewUser.IsInRole("blocked")); +```bash +dotnet run --project DefaultMode ``` -Start the client with the `blocked` role to see the refresh fail with `403 permission_change_rejected` -(the existing connection is left open, unchanged, until its deadline): +Serverless mode with the Management SDK: ```bash -cd Client -dotnet run -- http://localhost:5000/chat alice blocked +dotnet run --project Serverless/Management ``` -``` -[refresh] FAILED: ...permission_change_rejected... -``` +Then start the shared client in another terminal: -## How it works +```bash +dotnet run --project Client -- http://localhost:5000/chat alice user +``` -1. **Negotiate.** The client sends its app token to `/chat/negotiate`; the server validates it, and - because refresh is enabled for an authenticated principal with an expiry, advertises - `tokenLifetimeSeconds`. -2. **Connect.** The client connects to Azure SignalR with the returned service access token. -3. **Schedule.** `WithAuthenticationRefresh` schedules a refresh before `tokenLifetimeSeconds` elapses. -4. **Refresh.** The client re-mints a fresh app token (`AccessTokenProvider`) and POSTs - `/chat/refresh?id={connectionToken}`. The server runs the optional `OnAuthenticationRefresh` gate, - then asks Azure SignalR to extend the live connection's auth deadline and apply the refreshed claims. -5. **Adopt.** The server returns `{ accessToken, tokenLifetimeSeconds }`; the client adopts the new - service token and schedules the next refresh. The connection is never reconnected. +Leave the client connected. It refreshes authentication approximately 30 seconds before each two-minute application token expires, without changing the connection ID. > [!NOTE] -> The demo surfaces the app token's `exp` as the auth ticket's `ExpiresUtc` in `OnTokenValidated`(JwtBearer doesn't do this by default), which is what lets negotiate advertise `tokenLifetimeSeconds`. +> The client's interactive `Broadcast` command requires the hosted hub in Default mode. In Serverless mode, client-to-server messages require an Azure SignalR upstream. Authentication refresh itself uses the same client in both modes. diff --git a/samples/AuthRefreshSample/Serverless/Management/Management.csproj b/samples/AuthRefreshSample/Serverless/Management/Management.csproj new file mode 100644 index 00000000..abff3283 --- /dev/null +++ b/samples/AuthRefreshSample/Serverless/Management/Management.csproj @@ -0,0 +1,29 @@ + + + + net11.0 + enable + enable + AuthRefreshSample.Serverless.Management + AuthRefreshSample-Serverless-Management + $(MSBuildThisFileDirectory)..\..\..\..\..\azure-signalr + true + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/AuthRefreshSample/Serverless/Management/Program.cs b/samples/AuthRefreshSample/Serverless/Management/Program.cs new file mode 100644 index 00000000..443318c0 --- /dev/null +++ b/samples/AuthRefreshSample/Serverless/Management/Program.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. + +using System.Security.Claims; +using System.Text; + +using AuthRefreshSample; +using AuthRefreshSample.Serverless.Management; + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Azure.SignalR.Management; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +builder.WebHost.UseUrls("http://localhost:5000"); + +var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(DemoAuth.SigningKey)); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = DemoAuth.Issuer, + ValidateAudience = true, + ValidAudience = DemoAuth.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = signingKey, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + if (context.SecurityToken is JsonWebToken jwt && jwt.ValidTo > DateTime.UtcNow) + { + context.Properties.ExpiresUtc = new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero); + } + + return Task.CompletedTask; + }, + }; + }); + +builder.Services.AddAuthorization(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(serviceProvider => serviceProvider.GetRequiredService()); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapPost("/chat/negotiate", async (HttpContext httpContext, SignalRService signalR) => +{ + var authentication = await httpContext.AuthenticateAsync(); + var expiresAt = authentication.Properties?.ExpiresUtc; + var userId = GetUserId(httpContext.User); + if (expiresAt is null || expiresAt <= DateTimeOffset.UtcNow || string.IsNullOrEmpty(userId)) + { + return Results.Unauthorized(); + } + + var result = await signalR.HubContext.NegotiateWithTokenLifetimeAsync( + new NegotiationOptions + { + HttpContext = httpContext, + UserId = userId, + Claims = BuildClaims(userId), + TokenLifetime = expiresAt.Value - DateTimeOffset.UtcNow, + CloseOnAuthenticationExpiration = true, + }, + httpContext.RequestAborted); + + return Results.Json(new + { + url = result.Url, + accessToken = result.AccessToken, + tokenLifetimeSeconds = result.TokenLifetimeSeconds, + }); +}).RequireAuthorization(); + +app.MapPost("/chat/refresh", async (HttpContext httpContext, SignalRService signalR) => +{ + var connectionToken = httpContext.Request.Query["id"].FirstOrDefault(); + if (string.IsNullOrEmpty(connectionToken)) + { + return Results.BadRequest(new { error = "missing_connection_token" }); + } + + var authentication = await httpContext.AuthenticateAsync(); + var expiresAt = authentication.Properties?.ExpiresUtc; + var userId = GetUserId(httpContext.User); + if (expiresAt is null || expiresAt <= DateTimeOffset.UtcNow || string.IsNullOrEmpty(userId)) + { + return Results.Unauthorized(); + } + + var result = await signalR.HubContext.RefreshConnectionAuthenticationAsync( + connectionToken, + expiresAt.Value, + BuildClaims(userId), + httpContext.RequestAborted); + + return Results.Json(new + { + accessToken = result.AccessToken, + tokenLifetimeSeconds = result.TokenLifetimeSeconds, + }); +}).RequireAuthorization(); + +app.Run(); + +static string? GetUserId(ClaimsPrincipal user) => + user.FindFirstValue(ClaimTypes.NameIdentifier) + ?? user.FindFirstValue(JwtRegisteredClaimNames.Sub); + +static List BuildClaims(string userId) => + [new Claim(ClaimTypes.NameIdentifier, userId)]; \ No newline at end of file diff --git a/samples/AuthRefreshSample/Serverless/Management/README.md b/samples/AuthRefreshSample/Serverless/Management/README.md new file mode 100644 index 00000000..c390f246 --- /dev/null +++ b/samples/AuthRefreshSample/Serverless/Management/README.md @@ -0,0 +1,49 @@ +# Authentication Refresh in Serverless Mode with the Management SDK + +This ASP.NET Core app is the serverless authentication boundary for Azure SignalR Service. It uses the Management SDK to negotiate a connection and refresh its authentication without reconnecting. The existing `Default/Client` is used unchanged. + +## Prerequisites + +- .NET 11 preview SDK. +- An Azure SignalR Service resource in Serverless mode. +- Preview builds of the SignalR client and `Microsoft.Azure.SignalR.Management`. + +Until the preview Management SDK package is published, clone `azure-signalr` beside this sample repository. The project automatically references that source tree. For another location, pass `-p:AzureSignalRSourceRoot=` to `dotnet build` or `dotnet run`. + +The shared client similarly references a sibling `aspnetcore/src/SignalR` source tree so refresh requests use the application token after the Azure SignalR negotiate redirect. For another location, pass `-p:AspNetCoreSignalRSourceRoot=` when running the client. + +## Configure + +Set the Azure SignalR connection string with user secrets: + +```bash +dotnet user-secrets set "Azure:SignalR:ConnectionString" "" +``` + +`ServiceTransportType` defaults to `Transient`. Change it to `Persistent` in `appsettings.json` to exercise the persistent Management SDK transport; the application code is the same for both. + +## Run + +Start this server: + +```bash +dotnet run +``` + +Then run the same client used by the Default-mode sample: + +```bash +cd ../../Client +dotnet run -- http://localhost:5000/chat alice user +``` + +Leave the client connected. Approximately every 90 seconds, it obtains a new application token and posts it to `/chat/refresh`; the Management SDK updates the existing connection and returns a new service access token. The connection ID does not change. + +Type `/refresh` in the client to refresh authentication manually. + +## Endpoints + +| Route | Purpose | +| --- | --- | +| `POST /chat/negotiate` | Validates the application token and calls `NegotiateWithTokenLifetimeAsync`. | +| `POST /chat/refresh?id={connectionToken}` | Validates the new token and calls `RefreshConnectionAuthenticationAsync`. | diff --git a/samples/AuthRefreshSample/Serverless/Management/SignalRService.cs b/samples/AuthRefreshSample/Serverless/Management/SignalRService.cs new file mode 100644 index 00000000..3f8f5952 --- /dev/null +++ b/samples/AuthRefreshSample/Serverless/Management/SignalRService.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.Azure.SignalR.Management; + +namespace AuthRefreshSample.Serverless.Management; + +internal sealed class SignalRService : IHostedService +{ + public const string HubName = "chat"; + + private readonly IConfiguration _configuration; + private readonly ILoggerFactory _loggerFactory; + + public SignalRService(IConfiguration configuration, ILoggerFactory loggerFactory) + { + _configuration = configuration; + _loggerFactory = loggerFactory; + } + + public ServiceHubContext HubContext { get; private set; } = null!; + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var serviceManager = new ServiceManagerBuilder() + .WithConfiguration(_configuration) + .WithLoggerFactory(_loggerFactory) + .BuildServiceManager(); + + HubContext = await serviceManager.CreateHubContextAsync(HubName, cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => + HubContext?.DisposeAsync() ?? Task.CompletedTask; +} \ No newline at end of file diff --git a/samples/AuthRefreshSample/Serverless/Management/appsettings.json b/samples/AuthRefreshSample/Serverless/Management/appsettings.json new file mode 100644 index 00000000..04c17681 --- /dev/null +++ b/samples/AuthRefreshSample/Serverless/Management/appsettings.json @@ -0,0 +1,8 @@ +{ + "Azure": { + "SignalR": { + "ConnectionString": "", + "ServiceTransportType": "Transient" + } + } +} \ No newline at end of file From f9bcc79f1c961260764576a2f8fff6a7352202ad Mon Sep 17 00:00:00 2001 From: shiyingchen Date: Tue, 21 Jul 2026 19:01:19 +0800 Subject: [PATCH 3/5] update --- samples/AuthRefreshSample/Client/Program.cs | 7 ++----- samples/AuthRefreshSample/DefaultMode/ChatHub.cs | 3 --- samples/AuthRefreshSample/README.md | 5 ++--- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/samples/AuthRefreshSample/Client/Program.cs b/samples/AuthRefreshSample/Client/Program.cs index 305f5a8e..fad1ce19 100644 --- a/samples/AuthRefreshSample/Client/Program.cs +++ b/samples/AuthRefreshSample/Client/Program.cs @@ -65,12 +65,9 @@ string MintAppToken() .WithAutomaticReconnect() .Build(); -connection.On("ReceiveMessage", (user, message) => - Console.WriteLine($"{user}: {message}")); - Console.WriteLine($"Connecting to {hubUrl} as '{userId}' (role '{role}')..."); await connection.StartAsync(); -Console.WriteLine("Connected. Type /refresh to refresh authentication, or a message to broadcast (empty line to quit)."); +Console.WriteLine("Connected. Type /refresh to refresh authentication (empty line to quit)."); while (true) { @@ -87,7 +84,7 @@ string MintAppToken() continue; } - await connection.InvokeAsync("Broadcast", line); + Console.WriteLine("Unknown command. Type /refresh or press Enter to quit."); } await connection.DisposeAsync(); diff --git a/samples/AuthRefreshSample/DefaultMode/ChatHub.cs b/samples/AuthRefreshSample/DefaultMode/ChatHub.cs index cea04c84..362201d1 100644 --- a/samples/AuthRefreshSample/DefaultMode/ChatHub.cs +++ b/samples/AuthRefreshSample/DefaultMode/ChatHub.cs @@ -12,9 +12,6 @@ public sealed class ChatHub : Hub public override Task OnConnectedAsync() => Clients.Caller.SendAsync("ReceiveMessage", "system", $"connected as {Context.UserIdentifier}"); - public Task Broadcast(string message) => - Clients.All.SendAsync("ReceiveMessage", Context.UserIdentifier ?? "anonymous", message); - // Runs after Azure SignalR applies the refreshed claims to Context.User. React to a refresh here. public override Task OnAuthenticationRefreshedAsync() => Clients.Caller.SendAsync("ReceiveMessage", "system", $"auth refreshed for {Context.UserIdentifier}"); diff --git a/samples/AuthRefreshSample/README.md b/samples/AuthRefreshSample/README.md index abf6c4fe..468666a8 100644 --- a/samples/AuthRefreshSample/README.md +++ b/samples/AuthRefreshSample/README.md @@ -56,7 +56,6 @@ Then start the shared client in another terminal: dotnet run --project Client -- http://localhost:5000/chat alice user ``` -Leave the client connected. It refreshes authentication approximately 30 seconds before each two-minute application token expires, without changing the connection ID. +Leave the client connected. The application token is valid for two minutes, and the client is configured to refresh 30 seconds before expiration. The first automatic refresh therefore occurs about 90 seconds after connecting, and another is scheduled about 90 seconds after each successful refresh. The connection ID does not change. -> [!NOTE] -> The client's interactive `Broadcast` command requires the hosted hub in Default mode. In Serverless mode, client-to-server messages require an Azure SignalR upstream. Authentication refresh itself uses the same client in both modes. +Type `/refresh` to refresh authentication immediately. A successful manual refresh resets the next automatic-refresh schedule using the lifetime returned by the server. From 4810968f7e2a7f3981667c5a776a814678e29941 Mon Sep 17 00:00:00 2001 From: shiyingchen Date: Thu, 23 Jul 2026 09:25:05 +0800 Subject: [PATCH 4/5] update serverless sample --- .../Serverless/Management/Program.cs | 11 +++++++---- .../AuthRefreshSample/Serverless/Management/README.md | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/samples/AuthRefreshSample/Serverless/Management/Program.cs b/samples/AuthRefreshSample/Serverless/Management/Program.cs index 443318c0..1bfe9c3d 100644 --- a/samples/AuthRefreshSample/Serverless/Management/Program.cs +++ b/samples/AuthRefreshSample/Serverless/Management/Program.cs @@ -17,6 +17,7 @@ builder.WebHost.UseUrls("http://localhost:5000"); +var serviceTokenLifetime = TimeSpan.FromHours(1); var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(DemoAuth.SigningKey)); builder.Services @@ -63,7 +64,7 @@ var authentication = await httpContext.AuthenticateAsync(); var expiresAt = authentication.Properties?.ExpiresUtc; var userId = GetUserId(httpContext.User); - if (expiresAt is null || expiresAt <= DateTimeOffset.UtcNow || string.IsNullOrEmpty(userId)) + if (expiresAt <= DateTimeOffset.UtcNow || string.IsNullOrEmpty(userId)) { return Results.Unauthorized(); } @@ -74,7 +75,9 @@ HttpContext = httpContext, UserId = userId, Claims = BuildClaims(userId), - TokenLifetime = expiresAt.Value - DateTimeOffset.UtcNow, + TokenLifetime = serviceTokenLifetime, + AuthenticationExpiresOn = expiresAt, + EnableAuthenticationRefresh = true, CloseOnAuthenticationExpiration = true, }, httpContext.RequestAborted); @@ -98,14 +101,14 @@ var authentication = await httpContext.AuthenticateAsync(); var expiresAt = authentication.Properties?.ExpiresUtc; var userId = GetUserId(httpContext.User); - if (expiresAt is null || expiresAt <= DateTimeOffset.UtcNow || string.IsNullOrEmpty(userId)) + if (expiresAt <= DateTimeOffset.UtcNow || string.IsNullOrEmpty(userId)) { return Results.Unauthorized(); } var result = await signalR.HubContext.RefreshConnectionAuthenticationAsync( connectionToken, - expiresAt.Value, + expiresAt, BuildClaims(userId), httpContext.RequestAborted); diff --git a/samples/AuthRefreshSample/Serverless/Management/README.md b/samples/AuthRefreshSample/Serverless/Management/README.md index c390f246..43254da9 100644 --- a/samples/AuthRefreshSample/Serverless/Management/README.md +++ b/samples/AuthRefreshSample/Serverless/Management/README.md @@ -39,6 +39,8 @@ dotnet run -- http://localhost:5000/chat alice user Leave the client connected. Approximately every 90 seconds, it obtains a new application token and posts it to `/chat/refresh`; the Management SDK updates the existing connection and returns a new service access token. The connection ID does not change. +The sample enables authentication refresh through `NegotiationOptions.EnableAuthenticationRefresh`, configures a one-hour maximum service-token lifetime, and passes the application ticket's absolute expiration separately through `NegotiationOptions.AuthenticationExpiresOn`. Because the demo application token expires in two minutes, the Management SDK mints the service token with the shorter remaining application-authentication lifetime. + Type `/refresh` in the client to refresh authentication manually. ## Endpoints From 35d65037bd96c91fc8a784382aef5f6a9f0a2b35 Mon Sep 17 00:00:00 2001 From: shiyingchen Date: Wed, 29 Jul 2026 11:18:19 +0800 Subject: [PATCH 5/5] update samples --- .../DefaultMode/Server.csproj | 12 +++- .../Serverless/Management/Management.csproj | 1 + .../Serverless/Management/Program.cs | 72 +++++++++++++++---- .../Serverless/Management/README.md | 8 ++- 4 files changed, 78 insertions(+), 15 deletions(-) diff --git a/samples/AuthRefreshSample/DefaultMode/Server.csproj b/samples/AuthRefreshSample/DefaultMode/Server.csproj index a0de8a3a..af7ea213 100644 --- a/samples/AuthRefreshSample/DefaultMode/Server.csproj +++ b/samples/AuthRefreshSample/DefaultMode/Server.csproj @@ -5,13 +5,23 @@ enable enable AuthRefreshSample + AuthRefreshSample-DefaultMode + $(MSBuildThisFileDirectory)..\..\..\..\azure-signalr + true - + + + + + + + + diff --git a/samples/AuthRefreshSample/Serverless/Management/Management.csproj b/samples/AuthRefreshSample/Serverless/Management/Management.csproj index abff3283..428ac038 100644 --- a/samples/AuthRefreshSample/Serverless/Management/Management.csproj +++ b/samples/AuthRefreshSample/Serverless/Management/Management.csproj @@ -16,6 +16,7 @@ + diff --git a/samples/AuthRefreshSample/Serverless/Management/Program.cs b/samples/AuthRefreshSample/Serverless/Management/Program.cs index 1bfe9c3d..1332f030 100644 --- a/samples/AuthRefreshSample/Serverless/Management/Program.cs +++ b/samples/AuthRefreshSample/Serverless/Management/Program.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Azure.SignalR.Common; using Microsoft.Azure.SignalR.Management; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; @@ -64,6 +65,7 @@ var authentication = await httpContext.AuthenticateAsync(); var expiresAt = authentication.Properties?.ExpiresUtc; var userId = GetUserId(httpContext.User); + var role = GetRole(httpContext.User); if (expiresAt <= DateTimeOffset.UtcNow || string.IsNullOrEmpty(userId)) { return Results.Unauthorized(); @@ -74,7 +76,7 @@ { HttpContext = httpContext, UserId = userId, - Claims = BuildClaims(userId), + Claims = BuildClaims(userId, role), TokenLifetime = serviceTokenLifetime, AuthenticationExpiresOn = expiresAt, EnableAuthenticationRefresh = true, @@ -101,22 +103,57 @@ var authentication = await httpContext.AuthenticateAsync(); var expiresAt = authentication.Properties?.ExpiresUtc; var userId = GetUserId(httpContext.User); + var role = GetRole(httpContext.User); if (expiresAt <= DateTimeOffset.UtcNow || string.IsNullOrEmpty(userId)) { return Results.Unauthorized(); } - var result = await signalR.HubContext.RefreshConnectionAuthenticationAsync( - connectionToken, - expiresAt, - BuildClaims(userId), - httpContext.RequestAborted); + if (httpContext.User.IsInRole("blocked")) + { + return Results.Json( + new { error = "permission_change_rejected" }, + statusCode: StatusCodes.Status403Forbidden); + } - return Results.Json(new + try { - accessToken = result.AccessToken, - tokenLifetimeSeconds = result.TokenLifetimeSeconds, - }); + var result = await signalR.HubContext.RefreshConnectionAuthenticationAsync( + connectionToken, + new RefreshConnectionAuthenticationOptions + { + AuthenticationExpiresOn = expiresAt, + Claims = BuildClaims(userId, role), + TokenLifetime = serviceTokenLifetime, + }, + httpContext.RequestAborted); + + return Results.Json(new + { + accessToken = result.AccessToken, + tokenLifetimeSeconds = result.TokenLifetimeSeconds, + }); + } + catch (AzureSignalRException ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase)) + { + return Results.NotFound(new { error = "connection_not_found" }); + } + catch (AzureSignalRException ex) when (ex.Message.Contains("different user", StringComparison.OrdinalIgnoreCase)) + { + return Results.Json( + new { error = "permission_change_rejected" }, + statusCode: StatusCodes.Status403Forbidden); + } + catch (ArgumentOutOfRangeException) + { + return Results.BadRequest(new { error = "invalid_expiration" }); + } + catch (AzureSignalRException) + { + return Results.Json( + new { error = "internal_server_error" }, + statusCode: StatusCodes.Status500InternalServerError); + } }).RequireAuthorization(); app.Run(); @@ -125,5 +162,16 @@ user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue(JwtRegisteredClaimNames.Sub); -static List BuildClaims(string userId) => - [new Claim(ClaimTypes.NameIdentifier, userId)]; \ No newline at end of file +static string? GetRole(ClaimsPrincipal user) => + user.FindFirstValue(ClaimTypes.Role) + ?? user.FindFirstValue("role"); + +static List BuildClaims(string userId, string? role) +{ + var claims = new List { new(ClaimTypes.NameIdentifier, userId) }; + if (!string.IsNullOrEmpty(role)) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + return claims; +} \ No newline at end of file diff --git a/samples/AuthRefreshSample/Serverless/Management/README.md b/samples/AuthRefreshSample/Serverless/Management/README.md index 43254da9..66403e04 100644 --- a/samples/AuthRefreshSample/Serverless/Management/README.md +++ b/samples/AuthRefreshSample/Serverless/Management/README.md @@ -1,6 +1,6 @@ # Authentication Refresh in Serverless Mode with the Management SDK -This ASP.NET Core app is the serverless authentication boundary for Azure SignalR Service. It uses the Management SDK to negotiate a connection and refresh its authentication without reconnecting. The existing `Default/Client` is used unchanged. +This ASP.NET Core app is the serverless authentication boundary for Azure SignalR Service. It uses the Management SDK to negotiate a connection and refresh its authentication without reconnecting. The shared `Client/` is used unchanged. ## Prerequisites @@ -39,7 +39,11 @@ dotnet run -- http://localhost:5000/chat alice user Leave the client connected. Approximately every 90 seconds, it obtains a new application token and posts it to `/chat/refresh`; the Management SDK updates the existing connection and returns a new service access token. The connection ID does not change. -The sample enables authentication refresh through `NegotiationOptions.EnableAuthenticationRefresh`, configures a one-hour maximum service-token lifetime, and passes the application ticket's absolute expiration separately through `NegotiationOptions.AuthenticationExpiresOn`. Because the demo application token expires in two minutes, the Management SDK mints the service token with the shorter remaining application-authentication lifetime. +The sample enables authentication refresh through `NegotiationOptions.EnableAuthenticationRefresh`, configures a one-hour maximum service-token lifetime, and passes the application ticket's absolute expiration separately through `NegotiationOptions.AuthenticationExpiresOn`. Refresh uses `RefreshConnectionAuthenticationOptions` to provide the new expiration, projected user and role claims, and the same one-hour service-token maximum. Because the demo application token expires in two minutes, the Management SDK mints the service token with the shorter remaining application-authentication lifetime. + +The refresh endpoint maps an unknown connection to `404 connection_not_found`, a blocked or +different user to `403 permission_change_rejected`, invalid expiration to `400 invalid_expiration`, +and unexpected service failures to `500 internal_server_error`. Type `/refresh` in the client to refresh authentication manually.