Managing your client connections in SignalR, including with authentication

Although this post is written with a focus on using F# and Fable.SignalR, the concepts talked about are applicable to SignalR in general, including when used with C#

When using SignalR, one of the most important things will be to manage the various connections made by the clients, as well as being able to send messages to specific clients. SignalR offers three different ways of specifying connections and clients, each with their distinct usages. The question comes down when to use what, and the advantages and disadvantages offered by each.

We're going to continue to build off the sample chat application from the previous SignalR blog posts. We're going to implement authentication using Auth0, but any identity provider (e.g. Keycloak or IdentityServer) should suffice. Unfortunatley, we're relying on specific Xamarin.Essentials features that aren't available in WPF, so that our new features will only work on Android. But that doesn't matter, since we can use this opportunity to "incentivize" users to switch to the android app ;)

This is Part Three of my F# SignalR tutorial series.

Source code for sample app can be found here, in the UserManagement branch

Types of Connections

SignalR provides three different ways to manage connections. You can specify a ConnectionID, add clients to a Group, or by referencing specific authenticated Users. They all have their own use cases, and in a large application would most likely all be used at different times. But for our simple chat application, we can choose which one to use, and explore how it works.

Connection ID

A Connection ID refers to a specific client connection to the server. Client in this case refers to the specific SignalR client deployed on a site/app, and connection refers to the active connection established by said client. In practice, this means that even if a given User is logged in e.g. on their phone and on a Web App, those would represent two different connection IDs. Additionally, if the client drops the connection for a moment and the automatic reconnect kicks in, the client would get a new connection ID.

In the context of our chat application, relying on the connection ID is insufficient. Even before we have a concept of a single User using multiple clients at the same time, we would still need to maintain a mapping of a client with whatever connection ID is currently active for that client. This can quickly get out of hand. That's not to say that using connection IDs is a bad idea, but just the first step in using the right tool for the right job.

SignalR Groups

A SignalR group is an arbitrary collection of connections assigned to a named group. A group can be created at any point, or a connection can be added to an existing group at any time. When sending a message to a group, it will be sent to all connections associated with that group. Additionally, whenever a connection disconnects, it will automatically be removed from any associated groups as well. However, this also means that when a client reconnects, it will need to be re-added to any groups that it should be a member of. A single connection can also be a member of multiple groups.

For our chat application, groups are a very good solution for our clients. Since a user joins the chat room by entering a username, we can create a SignalR group using the username as the group name, and add the connection ID to this group. Additionally, we can do the same on reconnect. This already allows the same user to enter the chat room on multiple end user applications if we had them. However, in this specific scenario we have to be careful as there is no validation of any kind. We still need to persist the existing groups in the backend somehow, and in our example server application, we are doing this with an In Memory dictionary. Then we can send a message to all groups, or specific groups by checking to see what exists within our store.

Authenticated Users

As SignalR sits on top of ASP.NET Core, we can leverage functionality that exists in there. One of these features is ASP.NET Core Authentication and User Management. Once a User is authenticated with the web server, SignalR itself also knows about the same authentication mechanism. This means that within a SignalR Hub we can access the HTTP Context User object, and all related properties. This allows us to check all manner of information that exists on the authenticated user, as well as any authorization policies. We can then check the UserId property to find specific users no matter how they are connected or how many connections they are associated with.

Within the Chat Application, we must now add a mechanism to allow a user to log in and authenticate against our server. I have decided to use Auth0 for the example app, because it's relatively simple, and also a hosted solution. But any Identity Provider solution that works with ASP.NET Core authentication should work. In previous iterations of the sample app, users could send a direct message to another specific user. We are now going to move this functionality so that only authenticated users may use this feature. On the backend, it is trivial to implement as we can query the IsAuthenticated property on the User object to see if a client is allowed to send a direct message.


Adding Authentication to SignalR

In this section I will describe how to add authentication via Auth0 to your Giraffe backend, in such a way that Fable.SignalR can also understand it. Once again, the concepts talked about here also apply to general ASP.NET Core, including when used with C#, however, as we're building off the same sample chat application as previous SignalR posts, I'm going to focus on F# with Giraffe and Fable.SignalR on the backend, and F# in Xamarin using Fabulous.

Adding login to the Android client

Adding login functionality to the client is straightforward. Since we have the Xamarin.Essentials package, we can utilize the Web Authenticator. The gist of how it works, is that any configuration to the identity provider is handled on a server backend, and utilizes whatever WebView is installed on the client to facilitate an authorization grant. The advantage of this is that configuration details such as client ID and client secret are all exclusively on the backend, as well as allowing and updates to the authentication happen entirely away from the client, so that all clients would receive the updates immediately.

On top of that, there is a technical reason in the Chat Application, because Auth0's Xamarin client libraries are meant to be included directly in the Android/iOS project instead of a shared codebase. Usually, this wouldn't be a problem, but since we like to stay within F# land and Fabulous, we are doing everything in the shared library, so we have to rely on the Web Authenticator to facilitate the login flow.

To actually start the login flow, we dispatch a message and receive an idToken from the backend, which we can save in our model.

| // Other message handlers
| Login ->
    let getAccessTokenAsync =
        async {
            try
                let authUri = Uri($"{serverUrl}/auth")
                        
                let! authResult =
                    WebAuthenticator.AuthenticateAsync(authUri, Uri("fabulouschat://"))
                    |> Async.AwaitTask
                       
                let idToken = authResult.Properties.["idToken"]
    
                // Dispatch a new message with the idToken
                return SetAuthResult idToken 
            with e ->
                return LoginFailed e
        }
    
    model, Cmd.ofAsyncMsg getAccessTokenAsync

That's how simple it is to implement login on the client using the Web Authenticator. The idToken we receive from the backend can then be parsed as any JWT token and we can extract any information we want. e.g.:

| SetAuthResult jwtToken ->
    let username =
        let parsed = JwtSecurityTokenHandler().ReadJwtToken(jwtToken)
        parsed.Payload.Item("nickname").ToString()
    { model with IdToken = jwtToken; Username = username }, Cmd.ofMsg EnterChatRoom

Additionally, we need to send the idToken to the SignalR hub when connecting. Expand the RegisterHub configuration like this:

| EnterChatRoom ->
    let cmd =
        Cmd.SignalR.connect
            RegisterHub
            (fun hub ->
                hub
                    .WithUrl($"{serverUrl}{Shared.Endpoints.Root}", fun opt ->
#if DEBUG
                        // If you have trouble with Self-Signed SSL Certificates, we can ignore validation of them
                        opt.HttpMessageHandlerFactory <- fun msg ->
                            match msg with
                            | :? HttpClientHandler as clientHandler ->
                                clientHandler.ServerCertificateCustomValidationCallback <- fun _ _ _ _ -> true
                                clientHandler :> HttpMessageHandler
                            | _ -> msg
#endif
                        // This is the important part
                        if not <| String.IsNullOrWhiteSpace(model.IdToken)
                        then opt.AccessTokenProvider <- (fun () -> Task.FromResult(model.IdToken)) 
                        )
                    .WithAutomaticReconnect()
                    .ConfigureLogging(fun logBuilder -> logBuilder.SetMinimumLevel(LogLevel.Debug))
                    .OnMessage SignalRMessage)
    
    model, cmd

We can pass the idToken into the AccessTokenProvider. Keep in mind, this function gets called for every HTTP request made by SignalR.

Lastly, we must also configure the actual entrypoint projects to handle the callback URI. Check the docs here to read up more about it.

Configuring Auth0

I won't describe a step by step guide here on how to configure Auth0, but only touch upon the most important parts. Make sure to read up on the various capabilities of Auth0 to ensure that you configure it for your usecase. That being said, I will touch upon the important points within the Auth0 config that I made to get it working for my application. Additionally, for my sample application, I didn't implement any kind of User Sign Up. I created the users directly in Auth0 just so I could directly start working on the authentication part.

In Auth0, create a new Application of type Regular Web Application. In the settings section, we need to remember the Domain, Client ID, and Client Secret. Make sure not to share the secret with anyone. I personally use the .NET tool user-secrets to manage my secrets for my hobby projects, but you can use whatever you want. Ensure that the Token Endpoint Authentication Method is set to Basic. Lastly, configure a callback URL, something like this: https://localhost:5001/callback, https://127.0.0.1:5001/callback even though we won't be using it in our server application. After that, as mentioned previously, go to the User Management section of Auth0 to create a few test users.

And that was already all the Auth0 configuration needed. Now for the server config.

Configuration of the Giraffe Server

Since Giraffe is built on top of ASP.NET Core, we only need to include the existing Authentication middleware in our pipeline, and configure it to utilize our Auth0 identity provider. To start, add the following NuGet packages Microsoft.AspNetCore.Authentication.JwtBearer, Microsoft.AspNetCore.Authentication.OpenIdConnect. After that, we can add in the authentication middleware within the configureServices function:

services
    .AddAuthentication(fun opts ->
        opts.DefaultScheme <- JwtBearerDefaults.AuthenticationScheme

        opts.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie()
    .AddOpenIdConnect("Auth0",
                      fun opts ->
                          // This is the Domain from the Auth0 application
                          opts.Authority <- "https://fabulous-tutorial.eu.auth0.com"
                              
                          opts.ClientId <- conf.["Auth0ClientId"]
                          opts.ClientSecret <- conf.["Auth0ClientSecret"]
                              
                          opts.SaveTokens <- true
                          opts.ResponseType <- OpenIdConnectResponseType.Code

                          // The callback path as configured in Auth0
                          opts.CallbackPath <- PathString("/callback")

                          opts.ClaimsIssuer <- "Auth0"

                          opts.Events <-
                              OpenIdConnectEvents(
                                  OnRedirectToIdentityProvider =
                                      fun ctx ->
                                          ctx.ProtocolMessage.SetParameter("audience", "https://fabulous-chat/")
                                          // This audience is from API in Auth0

                                          Task.CompletedTask
                              )
    )
    .AddJwtBearer(fun opts ->
        opts.SaveToken <- true
        opts.IncludeErrorDetails <- true
            
        // This is the Domain from the Auth0 application
        opts.Authority <- "https://fabulous-tutorial.eu.auth0.com/"
            
        // The audience is the Auth0 Application client ID since that is who issued the JWT token via the OIDC middleware above            
        opts.Audience <- conf.["Auth0ClientId"] 
        )
|> ignore

services.AddGiraffe() |> ignore
services.AddSignalR(ChatHub.config) |> ignore

We must also create an /authenticate route that the WebAuthenticator from Xamarin.Essentials will call. This is then where the redirect to Auth0 happens, and access tokens are handed back. All on the server side:

let authenticate : HttpHandler =
    fun (next: HttpFunc) (ctx: HttpContext) ->
        task {
            let! auth = ctx.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme)

            if ((not auth.Succeeded)
                || auth.Principal = null
                || auth.Principal.Identities
                   |> Seq.exists (fun i -> i.IsAuthenticated)
                   |> not) then

                do! ctx.ChallengeAsync("Auth0") // This scheme name maps to the scheme name registered in the AddOpenIDConnect middleware below
                return! next ctx
            else
                let! idToken = ctx.GetTokenAsync(CookieAuthenticationDefaults.AuthenticationScheme, "id_token")

                let url = $"fabulouschat://#idToken={WebUtility.UrlEncode(idToken)}"

                return! redirectTo false url next ctx
        }
            
let webApp : HttpHandler =
    choose [ GET >=> route "/" >=> text "hello"
                   GET >=> route "/callback" >=> text "callback"
                   GET >=> route "/auth" >=> authenticate ]

And lastly, we need to tell our SignalR Hub to make use of the authentication. I've opted to refactor the SignalR configuration using the builder pattern, which looks like this:

let config =
    SignalR
        .ConfigBuilder(Shared.Endpoints.Root, send, invoke)
        .LogLevel(LogLevel.Debug)
        .AfterUseRouting(fun app -> app.UseAuthorization())
        .EnableBearerAuth()
        .Build()

That should be it for changes on the server, after which we can make use of the authenticated users within our invoke and send handlers by checking the HubContext.Context.User object. It has useful properties such as IsAuthenticated, but also the entire User object with any configured claims as defined by OpenIDConnect. It also importantly contains a UserId that we can utilize to identify a unique user no matter what client they're connecting from, and can send a Fable SignalR message directly to them like this: hubContext.Clients.User(userId).Send(Response.ReceiveDirectMessage(sender, message))

Important note

Connecting Auth0 to the ASP.Net Core authentication pipeline expects to use the Cookie Authentication Scheme, but Fable.SignalR expects the default authentication scheme to be JWT Bearer Authentication. These will clash with each other as you can't specify for which routes which scheme should be considered the default. What can be done though, is how I have configured it within the AddAuthentication options. There is is possible to define the default authentication scheme for specific actions. We can define the default scheme to be JWT Bearer Authentication, but then redefine only the Sign In scheme to utilize Cookie Authentication.