Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions samples/AuthRefreshSample/Client/Client.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>AuthRefreshSample</RootNamespace>
<AspNetCoreSignalRSourceRoot Condition="'$(AspNetCoreSignalRSourceRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\..\aspnetcore\src\SignalR</AspNetCoreSignalRSourceRoot>
<UseAspNetCoreSignalRSource Condition="Exists('$(AspNetCoreSignalRSourceRoot)\clients\csharp\Client\src\Microsoft.AspNetCore.SignalR.Client.csproj')">true</UseAspNetCoreSignalRSource>
</PropertyGroup>

<ItemGroup Condition="'$(UseAspNetCoreSignalRSource)' != 'true'">
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="11.0.0-*" />
</ItemGroup>

<ItemGroup Condition="'$(UseAspNetCoreSignalRSource)' == 'true'">
<ProjectReference Include="$(AspNetCoreSignalRSourceRoot)\clients\csharp\Client\src\Microsoft.AspNetCore.SignalR.Client.csproj" />
<ProjectReference Include="$(AspNetCoreSignalRSourceRoot)\clients\csharp\Client.Core\src\Microsoft.AspNetCore.SignalR.Client.Core.csproj" />
<ProjectReference Include="$(AspNetCoreSignalRSourceRoot)\clients\csharp\Http.Connections.Client\src\Microsoft.AspNetCore.Http.Connections.Client.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.*" />
</ItemGroup>

<ItemGroup>
<Compile Include="..\Shared\DemoAuth.cs" Link="DemoAuth.cs" />
</ItemGroup>

</Project>
90 changes: 90 additions & 0 deletions samples/AuthRefreshSample/Client/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// 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.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<string?>(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();

Console.WriteLine($"Connecting to {hubUrl} as '{userId}' (role '{role}')...");
await connection.StartAsync();
Console.WriteLine("Connected. Type /refresh to refresh authentication (empty line to quit).");

while (true)
{
var line = Console.ReadLine();
if (string.IsNullOrEmpty(line))
{
break;
}

if (string.Equals(line, "/refresh", StringComparison.OrdinalIgnoreCase))
{
var newTokenLifetime = await connection.RefreshAuthenticationAsync();
Console.WriteLine($"[refresh] manually completed; next lifetime = {newTokenLifetime}");
continue;
}

Console.WriteLine("Unknown command. Type /refresh or press Enter to quit.");
}

await connection.DisposeAsync();
18 changes: 18 additions & 0 deletions samples/AuthRefreshSample/DefaultMode/ChatHub.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// 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}");

// 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}");
}
68 changes: 68 additions & 0 deletions samples/AuthRefreshSample/DefaultMode/Program.cs
Original file line number Diff line number Diff line change
@@ -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<ChatHub>("/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();
36 changes: 36 additions & 0 deletions samples/AuthRefreshSample/DefaultMode/README.md
Original file line number Diff line number Diff line change
@@ -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" "<your-asrs-connection-string>"
```

## 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.
30 changes: 30 additions & 0 deletions samples/AuthRefreshSample/DefaultMode/Server.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>AuthRefreshSample</RootNamespace>
<UserSecretsId>AuthRefreshSample-DefaultMode</UserSecretsId>
<AzureSignalRSourceRoot Condition="'$(AzureSignalRSourceRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\..\azure-signalr</AzureSignalRSourceRoot>
<UseAzureSignalRSource Condition="Exists('$(AzureSignalRSourceRoot)\src\Microsoft.Azure.SignalR\Microsoft.Azure.SignalR.csproj')">true</UseAzureSignalRSource>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="11.0.0-*" />
</ItemGroup>

<ItemGroup Condition="'$(UseAzureSignalRSource)' == 'true'">
<ProjectReference Include="$(AzureSignalRSourceRoot)\src\Microsoft.Azure.SignalR\Microsoft.Azure.SignalR.csproj" />
</ItemGroup>

<ItemGroup Condition="'$(UseAzureSignalRSource)' != 'true'">
<PackageReference Include="Microsoft.Azure.SignalR" Version="1.33.2-*" />
</ItemGroup>

<ItemGroup>
<!-- Shared demo auth constants, linked from a single source file. -->
<Compile Include="..\Shared\DemoAuth.cs" Link="DemoAuth.cs" />
</ItemGroup>

</Project>
14 changes: 14 additions & 0 deletions samples/AuthRefreshSample/DefaultMode/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Azure": {
"SignalR": {
"ConnectionString": ""
}
}
}
61 changes: 61 additions & 0 deletions samples/AuthRefreshSample/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Azure SignalR Authentication Refresh Sample

This sample shows how a .NET SignalR client can refresh authentication for an existing Azure SignalR connection without reconnecting.

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.

## Modes

### Default mode

`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.

### Serverless mode

`Serverless/Management/` implements the negotiate and refresh endpoints directly. It uses `ServiceHubContext.NegotiateWithTokenLifetimeAsync` to negotiate and `ServiceHubContext.RefreshConnectionAuthenticationAsync` to refresh the live connection.

Both modes expose the same client-facing contract, so they reuse the client in `Client/`.

## Prerequisites

- .NET 11 preview SDK
- An Azure SignalR Service resource
- Preview SignalR and Azure SignalR SDK packages

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=<path>`.

Use an Azure SignalR resource in Default mode with `DefaultMode/`, or in Serverless mode with `Serverless/Management/`.

## Configure

Set the connection string in the terminal where you will run the server:

```powershell
$env:Azure__SignalR__ConnectionString = "<your-connection-string>"
```

## Run

Start one server from the `AuthRefreshSample` directory.

Default mode:

```bash
dotnet run --project DefaultMode
```

Serverless mode with the Management SDK:

```bash
dotnet run --project Serverless/Management
```

Then start the shared client in another terminal:

```bash
dotnet run --project Client -- http://localhost:5000/chat alice user
```

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.

Type `/refresh` to refresh authentication immediately. A successful manual refresh resets the next automatic-refresh schedule using the lifetime returned by the server.
30 changes: 30 additions & 0 deletions samples/AuthRefreshSample/Serverless/Management/Management.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>AuthRefreshSample.Serverless.Management</RootNamespace>
<UserSecretsId>AuthRefreshSample-Serverless-Management</UserSecretsId>
<AzureSignalRSourceRoot Condition="'$(AzureSignalRSourceRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\..\..\azure-signalr</AzureSignalRSourceRoot>
<UseAzureSignalRSource Condition="Exists('$(AzureSignalRSourceRoot)\src\Microsoft.Azure.SignalR.Management\Microsoft.Azure.SignalR.Management.csproj')">true</UseAzureSignalRSource>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="11.0.0-*" />
</ItemGroup>

<ItemGroup Condition="'$(UseAzureSignalRSource)' == 'true'">
<ProjectReference Include="$(AzureSignalRSourceRoot)\src\Microsoft.Azure.SignalR.Management\Microsoft.Azure.SignalR.Management.csproj" />
<ProjectReference Include="$(AzureSignalRSourceRoot)\src\Microsoft.Azure.SignalR.Common\Microsoft.Azure.SignalR.Common.csproj" />
</ItemGroup>

<ItemGroup Condition="'$(UseAzureSignalRSource)' != 'true'">
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.2-*" />
</ItemGroup>

<ItemGroup>
<Compile Include="..\..\Shared\DemoAuth.cs" Link="DemoAuth.cs" />
</ItemGroup>

</Project>
Loading