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.52k stars 10.04k forks source link

SignalR cross-origin negotiate fails from Blazor Webassembly with cookie authentication #22767

Closed michael-gould closed 4 years ago

michael-gould commented 4 years ago

I'm developing a Blazor Webassembly PWA, and want to connect to a Signalr hub that's on a different subdomain, using cookie authentication.

I'm developing with Visual Studio 16.6.1 on Windows 10, Net SDK 3.1.301. Servers are hosted on Centos 8.1, using Asp.Net Core 3.1 applications on the servers and Blazor WebAssembly 3.2.0 for the web client. The SignalR client is using NuGet package Microsoft.AspNetCore.SignalR.Client 3.1.5 The SignalR Hub server uses Microsoft.Azure.SignalR 1.4.3

The application running in Blazor Webassembly loads from app.example.com, and the user logs in and is issued with a secure HttpOnly authentication cookie. The SignalR client then tries to connect to signalr.example.com, where the SignalRHub is hosted, but the negotiate step fails with a 401 Unauthorized. The server at signalr.example.com is set up to accept authentication cookies issued from app.example.com, with the required CORS policies, and using Azure Blob storage and Azure keyvault to provide data protection service that's shared by both servers. I have verified that I'm able to POST successfully to the negotiate endpoint at signalr.example.com, provided I specifically request for credentials to be included, using: requestMessage.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); Here is my example client code, in a razor page:

@page "/test"
@using Microsoft.AspNetCore.SignalR.Client
@using Microsoft.AspNetCore.Http.Connections
@inject HttpClient HttpClient

<h1>Test Page</h1>
This page tests cross-origin SignalR HubConnection
<p></p>

<h4>SignalR HubConnection Result</h4>
<p>@signalRResult</p>

<h4>POST Negotiate Result</h4>
<p>@negotiateResult</p>

@code {
    private string negotiateResult;
    private string signalRResult;

    protected override async Task OnInitializedAsync()
    {
        await TestSignalRHubConnection();
        await TestPostNegotiate();
    }

    private async Task TestSignalRHubConnection()
    {
        try
        {
            var hubConnection = new HubConnectionBuilder()
            .WithUrl("https://signalr.example.com/messagehub/", options =>
                {
                //options.SkipNegotiation = true;
                //options.Transports = HttpTransportType.WebSockets;
                })
            .Build();
            await hubConnection.StartAsync();
            signalRResult = "OK";
        }
        catch (Exception e)
        {
            signalRResult = e.Message;
        }
    }

    private async Task TestPostNegotiate()
    {
        HttpRequestMessage requestMessage = new HttpRequestMessage()
        {
            Method = HttpMethod.Post,
            RequestUri = new Uri("https://signalr.example.com/messagehub/negotiate?negotiateVersion=1"),
        };
        requestMessage.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
        var response = await HttpClient.SendAsync(requestMessage);
        var responseStatusCode = response.StatusCode;
        if (responseStatusCode == System.Net.HttpStatusCode.OK)
        {
            negotiateResult = await response.Content.ReadAsStringAsync();
        }
        else
        {
            negotiateResult = response.ReasonPhrase;
        }
    }

}

Here's the result when I run the code as-is:

SignalR HubConnection Result
Response status code does not indicate success: 401 (Unauthorized).

POST Negotiate Result
{"negotiateVersion":1,"connectionId":"xxxxxx","connectionToken":"yyyyyyy","availableTransports":[{"transport":"WebSockets","transferFormats":["Text","Binary"]},{"transport":"ServerSentEvents","transferFormats":["Text"]},{"transport":"LongPolling","transferFormats":["Text","Binary"]}]}

I've checked on the requests coming into the server and the authentication cookie is missing from the POST to /messagehub/negotiate coming from HubConnection.

If I uncomment the two lines to skip negotiation and force use of websockets then the SignalRConnection works, so it appears that it’s just the negotiate step that is failing. However this approach isn't compatible with using the Azure SignalR service so is not a good solution.

Also if I comment out the use of: requestMessage.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); then the direct POST to /messagehub/negotiate also fails with 401 Unauthorized.

I can’t set the authentication cookie manually in the options on HubConnectionBuilder.WithUrl(), because the HttpOnly authentication cookie can’t be read from the client code.

There doesn’t appear to be any way to tell the HubConnection to use BrowserRequestCredentials.Include so that the authentication cookie is sent with the POST to /messagehub/negotiate. If I've just missed something it would be great to hear about it! Thanks

javiercn commented 4 years ago

@michael-gould thanks for contacting us.

@BrennanConroy is there a way for configuring this in the underlying signalr client?

Seems like what's missing here is the ability to configure the underlying websocket to do the right thing

michael-gould commented 4 years ago

Hi, thanks for getting back on this.

I don't think the websocket connection is the issue. It works if I skip the negotiation step and just use websockets as the transport. The problem is not having the authentication cookie attached to the Http POST to /negotiate. Thanks

BrennanConroy commented 4 years ago

is there a way for configuring this in the underlying signalr client?

Yes, you need to use the HttpMessageHandlerFactory option and provide your own HttpMessageHandler that sets options on the request. I'm guessing WASM sets "omit" by default for credentials?

javiercn commented 4 years ago

@BrennanConroy likely. Can you post a snippet here on how to do that?

@guardrex can we get this documented?

michael-gould commented 4 years ago

Thanks - I'll give it a try.

guardrex commented 4 years ago

Is this Blazor PWA-only (thus only for the Blazor PWA doc) or is it for all Blazor WASM scenarios (thus for the Blazor Hosting model configuration topic in its Blazor WASM config area)?

If for Blazor WASM generally, I will cross-link to it in the Blazor PWA doc.

michael-gould commented 4 years ago

I think this would apply to any Blazor WASM scenario.

guardrex commented 4 years ago

The only current coverage (I think) is this ...

| HttpMessageHandlerFactory | null | A delegate that can be used to configure or replace the HttpMessageHandler used to send HTTP requests. Not used for WebSocket connections. This delegate must return a non-null value, and it receives the default value as a parameter. Either modify settings on that default value and return it, or return a new HttpMessageHandler instance. When replacing the handler make sure to copy the settings you want to keep from the provided handler, otherwise, the configured options (such as Cookies and Headers) won't apply to the new handler. |

Reference: https://docs.microsoft.com/en-us/aspnet/core/signalr/configuration?view=aspnetcore-3.1&tabs=dotnet#configure-additional-options-1

Because the advice speaks about copying certain settings that I'm unaware of, it's best if one of you give me a specific example if you want a code example in the text. Otherwise, it's only safe for me to mention it (briefly and similar to the way that you, @BrennanConroy, stated it :point_up:) and cross-link to that SignalR topic section.

campersau commented 4 years ago

This works for me:

var client = new HubConnectionBuilder()
    .WithUrl(new Uri("http://signalr.example.com"), options =>
    {
        options.HttpMessageHandlerFactory = innerHandler => new IncludeRequestCredentialsMessagHandler { InnerHandler = innerHandler };
    }).Build();
public class IncludeRequestCredentialsMessagHandler : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
        return base.SendAsync(request, cancellationToken);
    }
}
guardrex commented 4 years ago

I'll use that if @BrennanConroy gives it an upvote :+1:.

BrennanConroy commented 4 years ago

I'm very happy with that example :+1:

michael-gould commented 4 years ago

Beautiful - that worked a treat.

Thanks so much for your help.

mkArtakMSFT commented 4 years ago

@guardrex has this been documented already?

guardrex commented 4 years ago

@mkArtakMSFT

https://docs.microsoft.com/en-us/aspnet/core/blazor/fundamentals/additional-scenarios?view=aspnetcore-3.1#signalr-cross-origin-negotiation-for-authentication

https://github.com/dotnet/AspNetCore.Docs/pull/18742