DEV Community

Christos Matskas for The 425 Show

Posted on • Updated on

Secure .NET 5 SignalR solutions with Azure AD

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.

  1. git clone https://github.com/425show/dotnet-chat
  2. cd chat.web
  3. dotnet run
  4. On a new terminal cd chat
  5. dotnet run
  6. One the console app, connect and issue commands

You can see the solution running below!

Alt Text

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>"
    },
Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

and inside the ConfigureServices() method, add the following line at the end

services.AddMicrosoftIdentityWebApiAuthentication(Configuration);
Enter fullscreen mode Exit fullscreen mode

Finally, in the Configure() method, you need to add the following line of code right after the app.UseAuthorization();

app.UseAuthentication();
Enter fullscreen mode Exit fullscreen mode

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; 
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

If everything has been configured correctly, and why wouldn't it - the Notebook did it's magic, you should be presented with this:

Alt Text

And with authentication working end-to-end this is the full experience

Alt Text

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)

Collapse
 
vikram002 profile image
vikram002 • Edited

I do not see Notebook attached to the repo. Can you help me with that?
I'm using visual studio 2019

Collapse
 
teokk profile image
Teo Khai Kiong

ConfigureServices() code snippet is empty?

Collapse
 
christosmatskas profile image
Christos Matskas

Fixed. Thanks so much for letting me know :)