diff --git a/samples/AuthRefreshSample/Client/Client.csproj b/samples/AuthRefreshSample/Client/Client.csproj new file mode 100644 index 00000000..7c254360 --- /dev/null +++ b/samples/AuthRefreshSample/Client/Client.csproj @@ -0,0 +1,31 @@ + + + + Exe + net11.0 + enable + enable + AuthRefreshSample + $(MSBuildThisFileDirectory)..\..\..\..\aspnetcore\src\SignalR + true + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AuthRefreshSample/Client/Program.cs b/samples/AuthRefreshSample/Client/Program.cs new file mode 100644 index 00000000..fad1ce19 --- /dev/null +++ b/samples/AuthRefreshSample/Client/Program.cs @@ -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(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(); diff --git a/samples/AuthRefreshSample/DefaultMode/ChatHub.cs b/samples/AuthRefreshSample/DefaultMode/ChatHub.cs new file mode 100644 index 00000000..362201d1 --- /dev/null +++ b/samples/AuthRefreshSample/DefaultMode/ChatHub.cs @@ -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}"); +} diff --git a/samples/AuthRefreshSample/DefaultMode/Program.cs b/samples/AuthRefreshSample/DefaultMode/Program.cs new file mode 100644 index 00000000..46fe6eb8 --- /dev/null +++ b/samples/AuthRefreshSample/DefaultMode/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/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/DefaultMode/Server.csproj b/samples/AuthRefreshSample/DefaultMode/Server.csproj new file mode 100644 index 00000000..af7ea213 --- /dev/null +++ b/samples/AuthRefreshSample/DefaultMode/Server.csproj @@ -0,0 +1,30 @@ + + + + net11.0 + enable + enable + AuthRefreshSample + AuthRefreshSample-DefaultMode + $(MSBuildThisFileDirectory)..\..\..\..\azure-signalr + true + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AuthRefreshSample/DefaultMode/appsettings.json b/samples/AuthRefreshSample/DefaultMode/appsettings.json new file mode 100644 index 00000000..62eb2611 --- /dev/null +++ b/samples/AuthRefreshSample/DefaultMode/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Azure": { + "SignalR": { + "ConnectionString": "" + } + } +} diff --git a/samples/AuthRefreshSample/README.md b/samples/AuthRefreshSample/README.md new file mode 100644 index 00000000..468666a8 --- /dev/null +++ b/samples/AuthRefreshSample/README.md @@ -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=`. + +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 = "" +``` + +## 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. diff --git a/samples/AuthRefreshSample/Serverless/Management/Management.csproj b/samples/AuthRefreshSample/Serverless/Management/Management.csproj new file mode 100644 index 00000000..428ac038 --- /dev/null +++ b/samples/AuthRefreshSample/Serverless/Management/Management.csproj @@ -0,0 +1,30 @@ + + + + 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..1332f030 --- /dev/null +++ b/samples/AuthRefreshSample/Serverless/Management/Program.cs @@ -0,0 +1,177 @@ +// 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.Common; +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 serviceTokenLifetime = TimeSpan.FromHours(1); +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); + var role = GetRole(httpContext.User); + if (expiresAt <= DateTimeOffset.UtcNow || string.IsNullOrEmpty(userId)) + { + return Results.Unauthorized(); + } + + var result = await signalR.HubContext.NegotiateWithTokenLifetimeAsync( + new NegotiationOptions + { + HttpContext = httpContext, + UserId = userId, + Claims = BuildClaims(userId, role), + TokenLifetime = serviceTokenLifetime, + AuthenticationExpiresOn = expiresAt, + EnableAuthenticationRefresh = true, + 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); + var role = GetRole(httpContext.User); + if (expiresAt <= DateTimeOffset.UtcNow || string.IsNullOrEmpty(userId)) + { + return Results.Unauthorized(); + } + + if (httpContext.User.IsInRole("blocked")) + { + return Results.Json( + new { error = "permission_change_rejected" }, + statusCode: StatusCodes.Status403Forbidden); + } + + try + { + 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(); + +static string? GetUserId(ClaimsPrincipal user) => + user.FindFirstValue(ClaimTypes.NameIdentifier) + ?? user.FindFirstValue(JwtRegisteredClaimNames.Sub); + +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 new file mode 100644 index 00000000..66403e04 --- /dev/null +++ b/samples/AuthRefreshSample/Serverless/Management/README.md @@ -0,0 +1,55 @@ +# 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 shared `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. + +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. + +## 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 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"; +}