If you haven't used .NET SignalR for real time communication or live data, then you're missing out. SignalR is a fantastic for anything thatn needs real time information and it uses a spoke-hub model for single, multi and broadcasting information. As with every solution, we also need to ensure that our communication channels are secure end to end. SignalR uses HTTPS in transit but anyone with the right URL can join. To protect against this and allow only certain clients or users to use the application, we can use Azure AD to protect our SignalR solution. And there's no better man to talk about SignalR than Brady Gaster, a principal PM in the .NET team. Brady joined us last week on the 425Show to build a secure SignalR solution using Azure AD.
You can catch the on demand video here:
Starting with a normal SignalR chat app
We have a barebones SignalR chat solution. Our back-end SignalR hub runs on an ASP.NET Core 5 web app and the front-end client is built using a console app. You can find the repo with the code on GitHub here
All you have to do is clone, build and run.
git clone https://github.com/425show/dotnet-chat
cd chat.web
dotnet run
- On a new terminal
cd chat
dotnet run
- One the console app, connect and issue commands
You can see the solution running below!
Let's create some Azure AD App Registrations
For our solution to be secured by Azure Active Directory, we need to create 2 Azure AD app registrations. To do this, we'll use an .NET Interactive Notebook. The Notebook is attached to the repo. You'll need to update the variables in each code segment and run it! A lot more straightforward than following a bunch of text-based instructions here!
Install the .NET Interactive extension in VS Code and run each code segment as per the instructions in the Notebook :)
This is a fun bit! I promise
Adding authentication to our SignalR web App
Now that we have the AAD app registration details at hand, let's add the necessary code to secure, what is technically, an API. Our SignalR hub runs as an API.
Open the terminal and type dotnet add package Microsoft.Identity.Web
Open the appsettings.json
file and add the following section at the top
"AzureAd" : {
"Instance" : "https://login.microsoftonline.com/",
"Domain" : "<your tenant name>.onmicrosoft.com",
"TenantId" : "<your tenant Id>",
"ClientId" : "<your client Id>"
},
The TenantId
and ClientId
will be available to you in the Notebook
Next, in startup.cs
add the following at the top
using Microsoft.Identity.Web;
and inside the ConfigureServices()
method, add the following line at the end
services.AddMicrosoftIdentityWebApiAuthentication(Configuration);
Finally, in the Configure()
method, you need to add the following line of code right after the app.UseAuthorization();
app.UseAuthentication();
The last step to securing our SignalR Hub is to update the Hub class to only accept authenticated calls (ie. requests with a valid Access token) and with the right scope (i.e user.chat
). We added a little bit of code to also retrieve the authenticated user's name so that we can display it in the logs - that's the icing on the cake
Update the ChatHub.cs
with the following code:
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Web.Resource;
namespace chat.web.Hubs
{
[Authorize]
[RequiredScope("user.chat")]
public class ChatHub : Hub
{
private readonly ILogger<ChatHub> logger;
public ChatHub(ILogger<ChatHub> logger)
{
this.logger = logger;
}
public async Task SendMessage(string message)
{
await Clients.All.SendAsync("messageReceived", new {
text = message,
username = GetNameFromTokenClaims(this.Context)
});
}
public override Task OnConnectedAsync()
{
var username = GetNameFromTokenClaims(this.Context);
logger.LogInformation($"{username} just logged in and connected");
return Task.CompletedTask;
}
private string GetNameFromTokenClaims(HubCallerContext context)
{
return context.User.Claims.FirstOrDefault(c => c.Type.Equals("Name", System.StringComparison.InvariantCultureIgnoreCase)).Value;
}
}
}
Save and build the project to ensure that you have added everything as expected.
Update the Console app to authenticate users
Now that the back-end (i.e the SignalR Hub) expects an access token to be passed with our requests, we need to update our client app to acquire a token on behalf of the user.
Update the Program.cs
with the following following code:
using System;
using System.Linq;
using System.Threading.Tasks;
using chat.Commands;
using Microsoft.Identity.Client;
namespace chat
{
class Program
{
static string _accessToken = string.Empty;
static string _redirectUri = "http://localhost";
static string _clientId = "4eaa58bb-2d9a-40ea-ab65-d3103e3c2e68";
static string _scope = "api://a15f51b4-acc6-4880-9213-64102e825a77/user.chat";
static async Task Main(string[] args)
{
IPublicClientApplication app = PublicClientApplicationBuilder
.Create(_clientId)
.WithRedirectUri(_redirectUri)
.WithAuthority(AadAuthorityAudience.AzureAdMultipleOrgs)
.Build();
AuthenticationResult result;
var accounts = await app.GetAccountsAsync();
var scopes = new string[] { _scope };
try
{
result = await app.AcquireTokenSilent(scopes,
accounts.FirstOrDefault()).ExecuteAsync();
}
catch (MsalUiRequiredException)
{
result = await app.AcquireTokenInteractive(scopes).ExecuteAsync();
}
if(result != null)
{
_accessToken = result.AccessToken;
}
while (true)
{
Console.WriteLine("Enter Command:");
var input = Console.ReadLine();
HandleInput(input);
}
}
static void HandleInput(string input)
{
if (input.StartsWith("connect", StringComparison.OrdinalIgnoreCase))
{
ConnectCommand.HandleCommand(input, _accessToken).Wait();
}
if (input.StartsWith("receive", StringComparison.OrdinalIgnoreCase))
{
ReceiveCommand.HandleCommand(input);
}
if (input.StartsWith("say", StringComparison.OrdinalIgnoreCase))
{
SayCommand.HandleCommand(input);
}
}
}
}
This code instantiates a new MSAL PublicClient (look Ma! no secrets) and prompts the user to authenticate in order to get an access token to call the SignalR hub.
We also have to update the ChatClient.cs
file to make use of the access token Update the code as per the below:
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
namespace chat
{
public class ChatClient
{
string _hubUrl = "https://localhost:5001/hubs/chat";
private HubConnection _connection;
public static ChatClient Instance { get; private set; }
static ChatClient()
{
Instance = new ChatClient();
}
public async Task Connect(string accessToken)
{
_connection = new HubConnectionBuilder()
.WithUrl(_hubUrl, options => {
options.AccessTokenProvider = () => Task.FromResult(accessToken);
})
.Build();
await _connection.StartAsync();
}
public async Task Disconnect()
{
if(_connection != null && _connection.State == HubConnectionState.Connected)
await _connection.DisposeAsync();
}
}
}
Finally, we need to update the Connect.cs
file to pass the token to the ChatClient
as per the code below:
using System;
using System.Threading.Tasks;
namespace chat.Commands
{
public class ConnectCommand
{
public static async Task HandleCommand(string command, string accessToken)
{
Console.WriteLine("Starting SignalR Connection");
await ChatClient.Instance.Connect(accessToken);
}
}
}
Save and build!
We are now ready to put our code to the test. Run the chat.web
and then fire up the console app. The first thing you'll notice is that the console app prompts you to authenticate and consent to the permissions. One of these permissions is the User.Chat
that we have defined in our Azure AD app registration.
If everything has been configured correctly, and why wouldn't it - the Notebook did it's magic, you should be presented with this:
And with authentication working end-to-end this is the full experience
With these few code changes we were able to secure our SignalR solution.
Source Code
You can find the fully working secure solution on our GitHub repo
I hope that this helps you on your project as well, and as always, make sure to let us know in the comments if you have any questions.
Top comments (3)
I do not see Notebook attached to the repo. Can you help me with that?
I'm using visual studio 2019
ConfigureServices() code snippet is empty?
Fixed. Thanks so much for letting me know :)