asp.net-corejwtsignalr

Two blazor server apps sharing same Authentication Library Project and same Library Project for SignalR communication


I have below project structure:

  1. Project A (AuthenticationLibrary Project)
  2. Project B (Library Project Contains SignalR hub Implementation)
  3. Project C (Blazor Server Project)
  4. Project D (Blazor Server Project)

In Project A I have below implementation for Login:

public async Task LoginUser()
{
    if (Input.Username == "" && Input.Email == "")
    {
        errorMessage = "Username or Email is required for sign in";
        return;
    }

    if (Input.Password == "")
    {
        errorMessage = "Password is required for sign in";
        return;
    }

    try
    {
        // This doesn't count login failures towards account lockout
        // To enable password failures to trigger account lockout, set lockoutOnFailure: true
        ApplicationUser? userObj;

        if (emailUsername_Switch)
            userObj = await UserManager.FindByEmailAsync(Input.Email);
        else
            userObj = await UserManager.FindByNameAsync(Input.Username);

        if (userObj == null)
        {
            errorMessage = "Unable to find user in the system. Please Register or check username/email.";
            return;
        }

        Guid userId = new Guid();
        if (userObj != null)
        {
            userId = userObj.Id;
        }

        isLoading = true;

        if (await SignInManager.CanSignInAsync(userObj))
        {
            var result = await SignInManager.CheckPasswordSignInAsync(userObj, Input.Password, true);
            if (result == Microsoft.AspNetCore.Identity.SignInResult.Success)
            {
                Guid key = Guid.NewGuid();
                BlazorCookieLoginMiddleware.Logins[key] = new LoginInfo { Email = userObj.Email, UserName = userObj.UserName, Password = Input.Password };

                // Generate JWT Token
                var token = TokenService.GenerateToken(Input.Username);
                
                // Store token in localStorage (or use other storage methods as required)
                // Save the token to localStorage
                await LocalStorageService.SetItemAsync("jwt_token", token);

                NavigationManager.NavigateTo($"/login?key={key}", true);
            }
            else if (result == Microsoft.AspNetCore.Identity.SignInResult.LockedOut)
            {
                errorMessage = "User account locked please contact administrator";
            }
            else if (result == Microsoft.AspNetCore.Identity.SignInResult.Failed)
            {
                // Handle failure
                // Get the number of attempts left
                var attemptsLeft = UserManager.Options.Lockout.MaxFailedAccessAttempts - await UserManager.GetAccessFailedCountAsync(userObj);
                errorMessage = $"Invalid Login Attempt. Remaining Attempts : {attemptsLeft}";
            }
        }
        else
        {
            errorMessage = "Your account is blocked";
        }
    }
    catch (Exception ex)
    {
        errorMessage = "Error: Invalid login attempt. Please check again.";
    }
    isLoading = false;
}

Below is my TokenService:

public class TokenService
{
    private readonly string _secretKey;
    private readonly string _issuer;
    private readonly string _audience;

    public TokenService(string secretKey, string issuer, string audience)
    {
        _secretKey = secretKey;
        _issuer = issuer;
        _audience = audience;
    }

    // Method to generate JWT token
    public string GenerateToken(string username)
    {
        var claims = new[]
        {
        new Claim(ClaimTypes.Name, username),
        // Add other claims as needed
    };

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secretKey));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: _issuer,
            audience: _audience,
            claims: claims,
            expires: DateTime.Now.AddHours(1),
            signingCredentials: creds
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    // Method to validate token and extract user info
    public ClaimsPrincipal ValidateToken(string token)
    {
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secretKey));
        var handler = new JwtSecurityTokenHandler();
        try
        {
            var principal = handler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidateAudience = true,
                ValidIssuer = _issuer,
                ValidAudience = _audience,
                IssuerSigningKey = key
            }, out var validatedToken);

            return principal;
        }
        catch
        {
            return null;
        }
    }
}

In Project B I have below implementation for SignalR:

[Authorize]
public class NotificationHub : Hub
{
    private static readonly ConcurrentDictionary<string, List<string>> UserConnections = new();

    [Authorize]
    public override Task OnConnectedAsync()
    {
        var userEmail = Context.User?.FindFirst(ClaimTypes.Email)?.Value;
        //var userName = Context.User?.Identity?.Name; // Assuming the username is stored in the Name claim

        if (!string.IsNullOrEmpty(userEmail))
        {
            UserConnections.AddOrUpdate(
               userEmail,
               new List<string> { Context.ConnectionId }, // Add a new list with the current connection ID
               (key, existingConnections) =>
               {
                   if (!existingConnections.Contains(Context.ConnectionId))
                   {
                       existingConnections.Add(Context.ConnectionId); // Add the connection ID to the existing list
                   }
                   return existingConnections;
               });
        }

        return base.OnConnectedAsync();
    }

    [Authorize]
    public override Task OnDisconnectedAsync(Exception exception)
    {
        var userEmail = Context.User?.FindFirst(ClaimTypes.Email)?.Value;
        var connectionID = Context.ConnectionId;

        if (!string.IsNullOrEmpty(userEmail))
        {
            if (UserConnections.TryGetValue(userEmail, out var connections))
            {
                // Remove the specific connection ID
                connections.Remove(Context.ConnectionId);

                // If no more connections exist for this user, remove the user entry from the dictionary
                if (connections.Count == 0)
                {
                    UserConnections.TryRemove(userEmail, out _);
                }
            }
        }

        return base.OnDisconnectedAsync(exception);
    }

    [Authorize]
    public async Task SubscribeToTouchSiteGroup(Guid siteId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, SignalR_Method.TouchSiteGroup + "_" + siteId);
    }

    [Authorize]
    public async Task UnSubscribeFromTouchSiteGroup(Guid siteId)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, SignalR_Method.TouchSiteGroup + "_" + siteId);
    }
}

JWT Local Storage Service:

public class LocalStorageService
{
    private readonly IJSRuntime _jsRuntime;

    public LocalStorageService(IJSRuntime jsRuntime)
    {
        _jsRuntime = jsRuntime;
    }

    public async Task SetItemAsync(string key, string value)
    {
        await _jsRuntime.InvokeVoidAsync("localStorageHelper.setItem", key, value);
    }

    public async Task<string> GetItemAsync(string key)
    {
        return await _jsRuntime.InvokeAsync<string>("localStorageHelper.getItem", key);
    }

    public async Task RemoveItemAsync(string key)
    {
        await _jsRuntime.InvokeVoidAsync("localStorageHelper.removeItem", key);
    }

    public async Task ClearAsync()
    {
        await _jsRuntime.InvokeVoidAsync("localStorageHelper.clear");
    }
}

JS script for JWT Local Storage:

//////////////////////////////////Code for JWT Local Storage///////////////////////////////////////////

window.localStorageHelper = {
    setItem: function (key, value) {
        localStorage.setItem(key, value);
    },
    getItem: function (key) {
        return localStorage.getItem(key);
    },
    removeItem: function (key) {
        localStorage.removeItem(key);
    },
    clear: function () {
        localStorage.clear();
    }
};

Then I have centralised service that will be used for sharing data and starting SignalR Connection: (string baseUrl, string hubPath) are passed as parameters from each app specifying to open hubconnection for receiving. Means Project C will open for Project D baseURL.

public class ProductNotificationHubService
{
    private readonly LocalStorageService _localStorageService;

    private HubConnection? _hubConnection;
    
    /// <summary>
    /// 
    /// </summary>
    /// <param name="cacheService"></param>
    /// <param name="productService"></param>
    /// <param name="lockManagerService"></param>
    public ProductNotificationHubService(LocalStorageService localStorageService)
    {
        _localStorageService = localStorageService;
    }

    /// <summary>
    /// 
    /// </summary>
    /// <param name="baseUrl"></param>
    /// <param name="hubPath"></param>
    /// <param name="subscribeToGroup"></param>
    /// <param name="siteId"></param>
    /// <param name="userName"></param>
    /// <returns></returns>
    public async Task<bool> InitializeHubAsync(string baseUrl, string hubPath, string subscribeToGroup, Guid? siteId, string userName)
    {
        UserName = userName;

        // Retrieve token from localStorage
        var token = await _localStorageService.GetItemAsync("jwt_token");

        // Initialize the SignalR connection and pass the token
        _hubConnection = new HubConnectionBuilder()
            .WithUrl(new Uri(new Uri(baseUrl), hubPath), options =>
            {
                options.AccessTokenProvider = () => Task.FromResult(token);
            })
            .WithAutomaticReconnect()
            .Build();

        _hubConnection.On<Guid?, ProductModel, string>(SignalR_Method.TouchProductReceiveNotification, async (siteID, product, messageType) =>
        {
            if (_subscribedMessageTypes.Contains(messageType))
            {
                await HandleNotificationAsync(siteID, product, messageType);
            }
        });

        try
        {
            await _hubConnection.StartAsync();
            await _hubConnection.InvokeAsync(subscribeToGroup, siteId);
            return true;
        }
        catch (Exception ex)
        {
            return false;
            // Handle exception (e.g., log it)
        }
    }
}

Now in Project C and Project D I have below code when calling and starting signalR connection:

var baseUrl = "https://localhost:7140"; // Project D's URL in project C for listening
var hubPath = "/notificationHub"; // Path to your hub

await ProductNotificationHubService.InitializeHubAsync(baseUrl, hubPath, SignalR_Method.SubscribeToTouchSiteGroup, Site.Site_ID, _userInfo.UserName)

Below is the Program.cs file for both Project D and Project C:

builder.Services.AddScoped<LocalStorageService>();

// Register TokenService directly
builder.Services.AddSingleton<TokenService>(provider =>
    new TokenService(
        builder.Configuration.GetSection("TokenSettings").GetValue<string>("Key"),
        builder.Configuration.GetSection("TokenSettings").GetValue<string>("Issuer"),
        builder.Configuration.GetSection("TokenSettings").GetValue<string>("Audience")
));

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = IdentityConstants.ApplicationScheme;
    options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
    options.RequireAuthenticatedSignIn = true;
})
// Cookie based authentication for login validation
.AddCookie(options =>
{
    options.LoginPath = "/Account/Login/";
    options.LogoutPath = "/Account/Logout/";
    options.AccessDeniedPath = "/Account/AccessDenied";
    options.ReturnUrlParameter = CookieAuthenticationDefaults.ReturnUrlParameter;
    options.Cookie.HttpOnly = true;
    options.SlidingExpiration = true;
    options.ExpireTimeSpan = TimeSpan.FromSeconds(30);
})
// JWT Bearer Authentication for API or SignalR clients
.AddJwtBearer(options =>
{
    options.SaveToken = true;
    options.RequireHttpsMetadata = false;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidIssuer = builder.Configuration.GetSection("TokenSettings").GetValue<string>("Issuer"),
        ValidateIssuer = true,
        ValidAudience = builder.Configuration.GetSection("TokenSettings").GetValue<string>("Audience"),
        ValidateAudience = true,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration.GetSection("TokenSettings").GetValue<string>("Key"))),
        ValidateIssuerSigningKey = true,
        ValidateLifetime = true,
        ClockSkew = TimeSpan.Zero
    };
    options.IncludeErrorDetails = true;

    // Use Authorization header for SignalR
    options.Events = new JwtBearerEvents
    {
        OnMessageReceived = context =>
        {
            // Extract token from query string for SignalR
            var accessToken = context.Request.Query["access_token"];
            if (!string.IsNullOrEmpty(accessToken))
            {
                context.Token = accessToken;
            }
            return Task.CompletedTask;
        }
    };
}).AddIdentityCookies();

Now please note that my current authentication is working for both apps using cookies auth which I want to continue to use. Only want to use JWT authentication for SignalR to be able to get Context details (var userEmail = Context.User?.FindFirst(ClaimTypes.Email)?.Value;).

Now in this the issue I am facing is still the same the JWT token gets added to local storage however, in Program.cs file I always get empty string for below:

// Extract token from query string for SignalR
var accessToken = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(accessToken))
{
   context.Token = accessToken;
}

And context is still blank, I feel I am missing something please advise ?


Solution

  • Since you are using custom jwt token service, you need to add custom scheme for it. Please change your settings like below.

    builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = IdentityConstants.ApplicationScheme;
        options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
        options.RequireAuthenticatedSignIn = true;
    })
    // add below settings for signalr
    .AddJwtBearer("SignalRJwtScheme", options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration.GetSection("TokenSettings").GetValue<string>("Issuer"),
            ValidAudience = builder.Configuration.GetSection("TokenSettings").GetValue<string>("Audience"),
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration.GetSection("TokenSettings").GetValue<string>("Key"))),
        };
    })
    // Cookie based authentication for login validation
    .AddCookie(options =>
    {
        options.LoginPath = "/Account/Login/";
        options.LogoutPath = "/Account/Logout/";
        options.AccessDeniedPath = "/Account/AccessDenied";
        options.ReturnUrlParameter = CookieAuthenticationDefaults.ReturnUrlParameter;
        options.Cookie.HttpOnly = true;
        options.SlidingExpiration = true;
        options.ExpireTimeSpan = TimeSpan.FromSeconds(30);
    })
    .AddIdentityCookies();
    

    And use it in your signalr hub.

    [Authorize(AuthenticationSchemes = "SignalRJwtScheme")]
    public class NotificationHub : Hub
    

    Test Result

    enter image description here