dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.27k stars 9.96k forks source link

Signal R Cookie Authentication not working with Blazor Server #48628

Open cambo2015 opened 1 year ago

cambo2015 commented 1 year ago

Is there an existing issue for this?

Describe the bug

Blazor should connect automatically to Signal R Hub when using the Authorize attribute but it does not and gives a 401 unauthorized.

Hub Code

    [Authorize]
    public class ChatHub : Hub
    {
        public static HashSet<string> ConnectedUsers = new HashSet<string>();
        public const string HubUrl = "/chat";
        public async Task SendMessage(string user, string message, string room, bool join)
        {
            if (join)
            {
                await JoinRoom(room).ConfigureAwait(false);
                await Clients.Group(room).SendAsync("ReceiveMessage", user, " joined to " + room).ConfigureAwait(true);
            }
            else
            {
                await Clients.Group(room).SendAsync("ReceiveMessage", user, message).ConfigureAwait(true);
            }
            ConnectedUsers.Add(user);
        }

        public Task JoinRoom(string roomName)
        {
            return Groups.AddToGroupAsync(Context.ConnectionId, roomName);
        }

        public Task LeaveRoom(string roomName)
        {
            return Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
        }
}

Blazor server page code

protected override async Task OnInitializedAsync()
{
         _hubConnection = new HubConnectionBuilder()
        .WithUrl(NavigationManager.ToAbsoluteUri(alpha_beta_backend.Hubs.ChatHub.HubUrl), options =>
         {
             options.UseDefaultCredentials = true;
         })
         .Build();
         _hubConnection.On<string, string>("ReceiveMessage", RecieveMessage);
}    
private async Task ConnectToRoom(string uEmail)
    {
        if (_hubConnection != null)
        {
            currentRoom = uEmail; //room is called what the userName is which is the email
            @* Connect to room *@
            await _hubConnection.StartAsync();
            await _hubConnection.SendAsync("JoinRoom", uEmail);
            messages.Add(new Message("Bot", "Connected", false));
            _connected = true;

        }
    }

Expected Behavior

Signal R should connect automatically when using the [Authorize] attribute on the Hub Class as stated here: https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz?view=aspnetcore-7.0 "Cookie authentication: When using the browser client, no extra configuration is needed. If the user is logged in to an app, the SignalR connection automatically inherits this authentication."

Steps To Reproduce

dotnet 7 on m1 macos

Exceptions (if any)

The exception

System.Net.Http.HttpRequestException: Response status code does not indicate success: 401 (Unauthorized).
   at System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()

.NET Version

7

Anything else?

No response

davidfowl commented 1 year ago

Is this Blazor server?

cambo2015 commented 1 year ago

Yes. It is

BrennanConroy commented 1 year ago

When using the browser client, no extra configuration is needed.

This doesn't apply when using Blazor Server. Your code is running on the server not the browser.

You need to capture the cookie and pass it to your SignalR connection to make it work. See https://stackoverflow.com/questions/66899815/signalr-cookie-authentication-not-working-in-blazor-server for an example.

cambo2015 commented 1 year ago

Thank you!

cambo2015 commented 1 year ago

I thought I had it. How would I send the cookie? It's HTTP Only, isn't it? Here is how I am trying to access it but I read somewhere it is not good to use httpContext in a blazor page. What is the solution?

var c =httpContext.Request.Cookies[".AspNetCore.Identity.Application"];
davidfowl commented 1 year ago

@BrennanConroy we should document this (as much as it bugs me), it's the only way to do this in the short term.

BrennanConroy commented 1 year ago

@dotnet/aspnet-blazor-eng

ghost commented 1 year ago

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

Runaho commented 1 year ago

Hello @mkArtakMSFT @cambo2015 πŸ‘‹πŸ» , I had the same problem and came up with a different solution. Thinking more like a ticket, you can try to auth the person only at the socket entry with a short JWT.

I created Custom JWT Service It has GenerateToken and ValidateToken methods and i wrote a middleware: Use Hubs With JWT Middleware. If the url starts with /hubs, I check the token myself in the middleware and if it is valid, I add the user's claims to the context. It's a bit of a long solution but it gets the job done.

There is codes;

Middleware

public class JwtMiddleware
{
    private readonly RequestDelegate _next;

    public JwtMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context, IJwtService jwtService)
    {
        if (!context.Request.Path.StartsWithSegments("/hubs"))
        {
            await _next(context);
            return;
        }

        // Get the JWT token from the request, e.g., from the "Authorization" header
        string token = context.Request.Headers["Authorization"];

        if(token==null && context.Request.Query.Keys.Any(x=>x=="access_token"))
        {
            try
            {
                var queryToken = context.Request.Query["access_token"];
                token = queryToken;
            }
            catch
            {
                throw new Exception("Socket access_token parsing from querystring exception");
            }

        }
        // Validate the token and perform authentication logic
        var state = await AuthenticateWithJwt(token, jwtService);

        if (state.Item1)
        {
            context.User = state.Item2.ToClaimsPrincipal();
            await _next(context);
        }
        else
        {
            context.Response.StatusCode = 401; // Unauthorized
        }
    }

    private async Task<Tuple<bool, UserSession?>> AuthenticateWithJwt(string token, IJwtService jwtService)
    {
        if (string.IsNullOrEmpty(token))
        {
            return new Tuple<bool, UserSession?>(false, null);
        }

        token = token.Replace("Bearer ", string.Empty);

        // Validate the JWT token and perform authentication logic using your JwtService
        var userSession = await jwtService.ValidateToken(token);

        // Check if the user session is valid and authorized
        // You can add your own logic here based on the user session data
        bool isAuthenticated = userSession != null;

        return new Tuple<bool, UserSession?>(isAuthenticated, userSession);
    }

}

public static class JwtMiddlewareExtensions
{
    public static IApplicationBuilder UseHubsJwtMiddleware(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<JwtMiddleware>();
    }
}

Program.cs


services.AddScoped<IJwtService, JwtService>();

app.UseCookiePolicy();
app.UseAuthentication();
app.UseHubsJwtMiddleware();

app.UseRouting();

app.MapHub<TestHub>("/hubs/test");

JwtService


public interface IJwtService
{
    string GenerateToken(string userId, List<UserRole> roles, string name);
    Task<UserSession> ValidateToken(string token);
}

public class JwtService : IJwtService
{
    private readonly JwtSettings _jwtSettings;

    public JwtService(IOptions<JwtSettings> jwtSettings)
    {
        _jwtSettings = jwtSettings.Value;
    }

    public string GenerateToken(string userId, List<UserRole> roles, string name)
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes(_jwtSettings.SecretKey);
        var claims = new[]
            {
                new Claim(ClaimTypes.Name, name),
                new Claim(ClaimTypes.NameIdentifier, userId),
            };
        roles.ForEach(role => claims = claims.Append(new Claim(ClaimTypes.Role, role.ToString())).ToArray());
        var claimsIdentity = new ClaimsIdentity(claims);
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = claimsIdentity,

            Expires = DateTime.UtcNow.AddDays(7),
            SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
        };
        var token = tokenHandler.CreateToken(tokenDescriptor);
        return tokenHandler.WriteToken(token);
    }

    public async Task<UserSession> ValidateToken(string token)
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes(_jwtSettings.SecretKey);

        try
        {
            // Configure the token validation parameters
            var tokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = false, // Update with your issuer validation logic if needed
                ValidateAudience = false, // Update with your audience validation logic if needed
                ClockSkew = TimeSpan.Zero // Adjust if needed
            };

            // Validate the token and retrieve the claims
            var claimsPrincipal = tokenHandler.ValidateToken(token, tokenValidationParameters, out var validatedToken);

            // Retrieve the user session from the validated claims
            var userSession = new UserSession
            {
                PublicId = Guid.Parse(claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier).Value),
                FullName = claimsPrincipal.FindFirst(ClaimTypes.Name).Value,
                Roles = claimsPrincipal.FindAll(ClaimTypes.Role).Select(c => Enum.Parse<UserRole>(c.Value)).ToList()
            };

            return userSession;
        }
        catch (Exception ex)
        {
            return null;
        }
    }
}

UserSession.cs

public class UserSession
{
    public Guid PublicId { get; set; }
    public string FullName { get; set; }
    public List<UserRole> Roles { get; set; }

    public ClaimsPrincipal ToClaimsPrincipal()
    {
        var claims = new[]
        {
                new Claim(ClaimTypes.Name, FullName),
                new Claim(ClaimTypes.NameIdentifier, PublicId.ToString())
        };

        Roles.ForEach(role => claims = claims.Append(new Claim(ClaimTypes.Role, role.ToString())).ToArray());
        return new ClaimsPrincipal(new ClaimsIdentity(claims, "ServerAuthenticationClaims"));
    }

    public UserSession()
    {

    }
    public UserSession(ClaimsPrincipal claimsPrincipal)
    {
        PublicId = Guid.Parse(claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier).Value);
        FullName = claimsPrincipal.FindFirst(ClaimTypes.Name).Value;
        Roles = claimsPrincipal.FindAll(ClaimTypes.Role).Select(x => Enum.Parse<UserRole>(x.Value)).ToList();
    }
}

There is hub code;

 user = await UserService.GetUser();
 var token = JwtService.GenerateToken(user.PublicId.ToString(),user.Role,user.FullName);
 var uri = NavigationManager.ToAbsoluteUri("/hubs/test");
 hubConnection = new HubConnectionBuilder()
 .WithUrl(uri, options =>
 {
    options.Headers["Authorization"] = "Bearer " + token;
 })
 .WithAutomaticReconnect()
 .Build();

You need those packages;

davidfowl commented 1 year ago

If you're using JWTs, then the solution is much simpler no? Just flow the JWT through as well. This shows doing it for the cookie, but the same applies for the auth header...

Runaho commented 1 year ago

If you're using JWTs, then the solution is much simpler no? Just flow the JWT through as well. This shows doing it for the cookie, but the same applies for the auth header...

As far as I can see, Blazor Server opens other sockets on the server. (If this is really the case, there is not much point in opening another socket. It seems to me that defining a singleton Memory State can do the same job. We discussed a similar topic here: https://github.com/dotnet/AspNetCore.Docs/pull/29749)

When I connect to the another hub via razor in Blazor Server, I don't see a new socket connection on the network tab directly or another socket connection inside Blazor's own socket.

I think this actually shows why authorize is not working. Since the socket is not opened on client, auth does not work because it cannot access the client where auth informations as stored like Cookies, Protected Session Storage or else.

Can you enlighten us as you are more knowledgeable about in this subject? @davidfowl

You are right, if the auth method is JWT, the token can be sent directly. But if not, we can create a token with a JWT with a short lifetime and use the token as a ticket, I am actually doing this in the example I gave.

garrettlondon1 commented 11 months ago

@Runaho you make a great point and this is overlooked. The main ComponentHub (or whatever default SignalR hub blazor server DOM updates run on), is successfully connected on the client, but we cannot tap into that to send our own events if we want realtime functionality in a Blazor Server app that has to be scaled (cannot support singleton shared state on one instance). Now I'm curious, is the SignalR Hub that Blazor Server runs on Authorized?

If you want to authorize another SignalR hub and you use Cookie auth, you have to do some hacky way in the _Host.cshtml

image

What I have done is pass the cookies from the .cshtml page, (the only place where httpcontext is safe to retrieve from), to the app. Once in App.razor, the cookies are stored in a Scoped appstate.

However, this needs a nasty implementation on the HubConnectionBuilder as seen below:

hubConnection = new HubConnectionBuilder()
                             .WithUrl(navigation.ToAbsoluteUri(SignalRHub.HubUrl), options =>
                             {
                                 options.UseDefaultCredentials = true;
                                 var cookieCount = User.Cookies.Count();
                                 var cookieContainer = new CookieContainer(cookieCount);
                                 foreach (var cookie in User.Cookies)
                                     cookieContainer.Add(new Cookie(
                                         cookie.Key,
                                         WebUtility.UrlEncode(cookie.Value),
                                         path: "/",
                                         domain: navigation.ToAbsoluteUri("/").Host));
                                 options.Cookies = cookieContainer;
                             foreach (var header in User.Cookies)
                                 options.Headers.Add(header.Key, header.Value);

                             options.HttpMessageHandlerFactory = (input) =>
                             {
                                 var clientHandler = new HttpClientHandler
                                 {
                                     PreAuthenticate = true,
                                     CookieContainer = cookieContainer,
                                     UseCookies = true,
                                     UseDefaultCredentials = true,
                                 };
                                 return clientHandler;
                             };
                         })
                         .WithAutomaticReconnect()
                         .Build();


This is the only thing I've found to work for authorized SignalR Hubs on Blazor Server applications using Cookie Auth.

I think this issue needs to be resurfaced and with .NET 8, there is **so much** focus on WASM when Blazor SSR + Websocket interactivity is equally as exciting to a large part of the community, especially that don't understand auth well. 

We need guidance as a community from Microsoft how to deal with this new Cascading HttpContext in SSR components, mix it with Websockets when you need, and call Web API controllers returning RazorComponentResult with HTMX if you don't want to rely on websockets for an API call.

If these three things interacted with auth, in a clear and concise manner, Blazor would be unstoppable.
Runaho commented 11 months ago

Hey @garrettlondon1 , About auth solution you found looks good but I will choice the ticket approach. I'm thinking like it's has more control over it.

BTW; You are right that we can't access the socket directly and add/trigger events but I managed to write a state manager with a single instance and connected other clients to it and wrote a chat application. We also tested it in production and saw that it did not cause problems with 100+ active users. When you implement this example, you are writing a pusher-like structure. You don't need to open an extra socket. if you want you can go to the link and test it. Blazor Single Instance State

garrettlondon1 commented 11 months ago

Hey @garrettlondon1 , About auth solution you found looks good but I will choice the ticket approach. I'm thinking like it's has more control over it.

BTW; You are right that we can't access the socket directly and add/trigger events but I managed to write a state manager with a single instance and connected other clients to it and wrote a chat application. We also tested it in production and saw that it did not cause problems with 100+ active users. When you implement this example, you are writing a pusher-like structure. You don't need to open an extra socket. if you want you can go to the link and test it. Blazor Single Instance State

cannot tap into that to send our own events if we want realtime functionality in a Blazor Server app that has to be scaled (cannot support singleton shared state on one instance).

Yes, noted this in my original reply.. once you want to scale your service, you have to rewrite it. You are locking yourself into a singleton implementation of whatever you are doing.

ghost commented 9 months ago

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

joesun99 commented 7 months ago

When using the browser client, no extra configuration is needed.

This doesn't apply when using Blazor Server. Your code is running on the server not the browser.

You need to capture the cookie and pass it to your SignalR connection to make it work. See https://stackoverflow.com/questions/66899815/signalr-cookie-authentication-not-working-in-blazor-server for an example.

Didn't manage with provided link. Problem is If i use @rendermode @(new InteractiveServerRenderMode(prerender: false)) Then HttpContext is null and can't pass cookies to HubConnectionBuilder. If I use prerender: true OnInitializedAsync is called twice and HttpContext is null on second call.

NielsPilgaard commented 7 months ago

https://github.com/dotnet/aspnetcore/issues/48628#issuecomment-1580937789

BrennanConroy we should document this (as much as it bugs me), it's the only way to do this in the short term.

Has there been made any progress on this documentation @davidfowl? 😊

njannink commented 7 months ago

with Net8 Blazor server what is now the suggested way how to do SignalR authentication? Eg based on the example

https://github.com/dotnet/blazor-samples/tree/main/8.0/BlazorWebAppOidc

mithril52 commented 4 months ago

#48628 (comment)

BrennanConroy we should document this (as much as it bugs me), it's the only way to do this in the short term.

Has there been made any progress on this documentation @davidfowl? 😊

Really needing an official solid answer on this. I've looked as some of the above responses, but I'm missing some pieces I think and can't get it put together into a working solution.

NielsPilgaard commented 4 months ago

Really needing an official solid answer on this. I've looked as some of the above responses, but I'm missing some pieces I think and can't get it put together into a working solution.

@mithril52 I've managed to make it work by saving Cookies via a Blazor Server CircuitHandler, so the current circuit remembers cookies:

https://github.com/NielsPilgaard/Jordnaer/blob/main/src/web/Jordnaer/Features/Authentication/UserCircuitHandler.cs

Remember to register the CircuitHandler: https://github.com/NielsPilgaard/Jordnaer/blob/main/src/web/Jordnaer/Features/Authentication/CircuitHandlerExtensions.cs#L12

Edit: I've managed to make this work with Azure App Service session affinity as well, as of 5 minutes ago πŸ˜„

nwoolls commented 3 months ago

Not to complicate this any more than it already is - but what is the recommended approach when using auto-render mode? I would like to see that documented as well. It seems like the "silver bullet" for Blazor that also complicated many / most things surrounding authN / authZ.

davidfowl commented 3 months ago

"Silver bullet" for auto render mode is using cookies

nwoolls commented 3 months ago

"Silver bullet" for auto render mode is using cookies

Do you mean using something like was linked further up this page here?

If I use auto-render mode and the OOTB WithUrl() without specifying any options, that works once the application has downloaded and is run as WASM. Until the WASM has downloaded, an error occurs server-side:

2024-06-07T14:07:19.5507190 fail: Microsoft.AspNetCore.Components.Server.Circuits.CircuitHost[111]
      Unhandled exception in circuit 'qhwibkG-6HmvIWaqctLo2pPeu8aMncE9lNtu0w5ojco'.
      System.IO.InvalidDataException: Invalid negotiation response received.
       ---> System.Text.Json.JsonReaderException: '<' is an invalid start of a value. LineNumber: 0 | BytePositionInLine: 0.
         at System.Text.Json.ThrowHelper.ThrowJsonReaderException(Utf8JsonReader& json, ExceptionResource resource, Byte nextByte, ReadOnlySpan`1 bytes)
         at System.Text.Json.Utf8JsonReader.ConsumeValue(Byte marker)
         at System.Text.Json.Utf8JsonReader.ReadFirstToken(Byte first)
         at System.Text.Json.Utf8JsonReader.ReadSingleSegment()
         at System.Text.Json.Utf8JsonReader.Read()

I appreciate you taking the time to respond, just looking for which of these threads and suggested workarounds to follow until there is official documentation from MS on this (currently backlogged).

garrettlondon1 commented 3 months ago

@nwoolls are you using InteractiveServer at all?

nwoolls commented 3 months ago

@nwoolls are you using InteractiveServer at all?

Right now the application is auto-render throughout. So interactive server until the WASM has downloaded. I've added the chat sample from MS SignalR docs verbatim. I'm also logging in using the OIDC BFF Blazor docs. If I add [Authorize] to the chat hub, things work fine once the chat Razor page is rendered as WASM. Until then, I get the above error.

I see a few samples above about capturing and passing cookies, but they use _Host.cshtml which doesn't exist in the new Blazor template AFAIK.

garrettlondon1 commented 3 months ago

You should use a custom CircuitHandler in namespace Microsoft.AspNetCore.Components.Server.Circuits

public class InitializeCircuitHandler(
    ICurrentUser currentUser,
    IHubConnectionService hubConnectionService,
    ILogger logger)
    : CircuitHandler
{
    public override async Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        try
        {
            await currentUser.InitUserAsync();
            await hubConnectionService.InitHubConnection(cancellationToken);
        }
        catch (Exception ex)
        {
            // log
        }
    }
}
public class HubConnectionService(
    NavigationManager navigationManager,
    IHttpContextAccessor httpContextAccessor)
    : IHubConnectionService
{
    public HubConnection HubConnection { get; private set; }

    public async Task InitHubConnection(CancellationToken cancellationToken = default)
    {
        var cookies = new Dictionary<string, string>();
        httpContextAccessor.HttpContext.Request.Cookies.ToList().ForEach(x => cookies.Add(x.Key, x.Value));

        this.HubConnection = new HubConnectionBuilder()
                             .WithUrl(navigationManager.ToAbsoluteUri(SignalRHub.HubUrl), options =>
                             {
                                 options.UseDefaultCredentials = true;
                                 var cookieContainer = cookies.Any() 
                                    ? new CookieContainer(cookies.Count)
                                    : new CookieContainer();
                                 foreach (var cookie in cookies)
                                     cookieContainer.Add(new Cookie(
                                         cookie.Key,
                                         WebUtility.UrlEncode(cookie.Value),
                                         path: "/",
                                         domain: navigationManager.ToAbsoluteUri("/").Host));
                                 options.Cookies = cookieContainer;

                                 foreach (var header in cookies)
                                     options.Headers.Add(header.Key, header.Value);

                                 options.HttpMessageHandlerFactory = (input) =>
                                 {
                                     var clientHandler = new HttpClientHandler
                                     {
                                         PreAuthenticate = true,
                                         CookieContainer = cookieContainer,
                                         UseCookies = true,
                                         UseDefaultCredentials = true,
                                     };
                                     return clientHandler;
                                 };
                             })
                             .WithAutomaticReconnect()
                             .Build();

            await this.HubConnection.StartAsync(cancellationToken);
    }

    public async ValueTask DisposeAsync()
    {
        if (this.HubConnection != null)
        {
            await this.HubConnection.DisposeAsync();
        }
    }
}

This code works in my InteractiveServer application perfectly.

The _Host.cshtml is just the safe place to get cookies in Blazor Server .NET7.

Now, since the app is SSR enabled, you can get cookies from HttpContext when circuit is initializing in the CircuitHandler custom implementation.

Try setting a breakpoint in a custom CircuitHandler and see when the websocket gets initialized

garrettlondon1 commented 3 months ago

My understanding about the CircuitHandler is that HttpContext is safe here. If that's not true, it would be great to know :D

garrettlondon1 commented 3 months ago

It also depends on how you subscribe to the hub..

If you subscribe to the Hub from only WASM components, why would your websocket initialize the HubConnection at all.. If you subscribe from interactive server components, you have to initialize on the circuit

Why use Auto mode on a component that subscribes to the Hub?

nwoolls commented 3 months ago

@garrettlondon1 what I'm looking for is a way to use SignalR, with authN / authZ, from a modern .NET 8 Blazor application that uses auto-render mode throughout. I am using auto-render mode so that, as MS advertises, rendering is fast but has the overhead of an initial WebSocket connection, and then - once the WASM is downloaded - the application switches to in-browser and no longer uses a WebSocket for interactivity.

In this case, I still want to use SignalR for other interactivity, such as notifications / toasts.

Given the above, I'm looking for an MS recommended way of handling this, so that one codebase can handle SignalR w/ authN / authZ, with both interactive server or interactive WASM with the same component.

Until then, what I'm leaning towards as a workaround is using a plain WASM component for this, as the notifications likely won't be immediately needed anyway. But that seems like a workaround rather than a proper solution.

garrettlondon1 commented 3 months ago

The code I provided above is targeted for a .NET8 Blazor Web App.

My understanding is that if you are using HubConnection from WASM and InteractiveServer.. you need to initialize it in both places.

You can either pass the token or pass the cookies into the HubConnectionBuilder.. but the HubConnection on your circuit is different than the HubConnection on the client.

In my opinion, you should only subscribe to SignalR events on a custom hub in one rendermode.

CrahunGit commented 2 weeks ago

You should use a custom CircuitHandler in namespace Microsoft.AspNetCore.Components.Server.Circuits

public class InitializeCircuitHandler(
    ICurrentUser currentUser,
    IHubConnectionService hubConnectionService,
    ILogger logger)
    : CircuitHandler
{
    public override async Task OnCircuitOpenedAsync(Circuit circuit, CancellationToken cancellationToken)
    {
        try
        {
            await currentUser.InitUserAsync();
            await hubConnectionService.InitHubConnection(cancellationToken);
        }
        catch (Exception ex)
        {
            // log
        }
    }
}
public class HubConnectionService(
    NavigationManager navigationManager,
    IHttpContextAccessor httpContextAccessor)
    : IHubConnectionService
{
    public HubConnection HubConnection { get; private set; }

    public async Task InitHubConnection(CancellationToken cancellationToken = default)
    {
        var cookies = new Dictionary<string, string>();
        httpContextAccessor.HttpContext.Request.Cookies.ToList().ForEach(x => cookies.Add(x.Key, x.Value));

        this.HubConnection = new HubConnectionBuilder()
                             .WithUrl(navigationManager.ToAbsoluteUri(SignalRHub.HubUrl), options =>
                             {
                                 options.UseDefaultCredentials = true;
                                 var cookieContainer = cookies.Any() 
                                    ? new CookieContainer(cookies.Count)
                                    : new CookieContainer();
                                 foreach (var cookie in cookies)
                                     cookieContainer.Add(new Cookie(
                                         cookie.Key,
                                         WebUtility.UrlEncode(cookie.Value),
                                         path: "/",
                                         domain: navigationManager.ToAbsoluteUri("/").Host));
                                 options.Cookies = cookieContainer;

                                 foreach (var header in cookies)
                                     options.Headers.Add(header.Key, header.Value);

                                 options.HttpMessageHandlerFactory = (input) =>
                                 {
                                     var clientHandler = new HttpClientHandler
                                     {
                                         PreAuthenticate = true,
                                         CookieContainer = cookieContainer,
                                         UseCookies = true,
                                         UseDefaultCredentials = true,
                                     };
                                     return clientHandler;
                                 };
                             })
                             .WithAutomaticReconnect()
                             .Build();

            await this.HubConnection.StartAsync(cancellationToken);
    }

    public async ValueTask DisposeAsync()
    {
        if (this.HubConnection != null)
        {
            await this.HubConnection.DisposeAsync();
        }
    }
}

This code works in my InteractiveServer application perfectly.

The _Host.cshtml is just the safe place to get cookies in Blazor Server .NET7.

Now, since the app is SSR enabled, you can get cookies from HttpContext when circuit is initializing in the CircuitHandler custom implementation.

Try setting a breakpoint in a custom CircuitHandler and see when the websocket gets initialized

Tried lot and lot of things but this is the only way I could connect to my hub on server. Really nice job. Thank you very much.