最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

asp.net core - Adding SignalR Capability to Blazor Server App Secured by Entra ID - Stack Overflow

programmeradmin0浏览0评论

I am in the process of updating a Blazor Server app (.NET 6) so that it can make use of SignalR messaging. This app is currently secured by Entra ID. Thinking that I may have something misconfigured with the authentication / authorization part, I decided to isolate everything into a sandbox app and start clean. The sandbox app uses .NET 8 and the Server render mode.

What I have found is that both apps end up throwing the same exception when executing the code to start the hub:

JsonReaderException: '<' is an invalid start of a value. LineNumber: 2 | BytePositionInLine: 0.

From what I have read, the response that is coming back is HTML but JSON is expected. At first, I tried to figure out a way to get the HTML from the response, in hopes of figuring out what the actual error is, but this may not be necessary.

I have narrowed it down to this:

It seems like the user context is not being passed to the hub. If I add the AllowAnonymous attribute to the hub code, everything seems to work as expected. As soon as I remove this attribute and add the Authorize attribute, the above exception is thrown.

At this point, I am guessing that something is still mis-configured. Unfortunately, I don't know what it is and the web is not being very helpful.

I am more than happy to provide any portion of the code that I need to (I am just not sure what parts will be useful yet).

Can anyone assist?

*** EDIT #1 ***

This seems to be the relevant part of the code in the Program.cs code:

builder.Services.AddAuthorization(o => o.FallbackPolicy = o.DefaultPolicy);

When this code is present, it doesn't matter what attribute I add to the hub code (or even having no attributes at all), the exception mentioned above is thrown.

*** EDIT #2 ***

Here is the complete contents of my Program.cs file (NOTE: Other than a couple of very small additions), this is basically the same Program.cs file that is in the official samples page at .0/BlazorWebAppOidcServer/Program.cs):

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using BlazorWebAppOidcServer.Components;
using BlazorWebAppOidcServer;

const string MS_OIDC_SCHEME = "MicrosoftOidc";

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddAuthentication(MS_OIDC_SCHEME)
    .AddOpenIdConnect(MS_OIDC_SCHEME, oidcOptions =>
    {
        // For the following OIDC settings, any line that's commented out
        // represents a DEFAULT setting. If you adopt the default, you can
        // remove the line if you wish.

        // ........................................................................
        // The OIDC handler must use a sign-in scheme capable of persisting 
        // user credentials across requests.

        oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        // ........................................................................

        // ........................................................................
        // The "openid" and "profile" scopes are required for the OIDC handler 
        // and included by default. You should enable these scopes here if scopes 
        // are provided by "Authentication:Schemes:MicrosoftOidc:Scope" 
        // configuration because configuration may overwrite the scopes collection.

        //oidcOptions.Scope.Add(OpenIdConnectScope.OpenIdProfile);
        // ........................................................................

        // ........................................................................
        // The following paths must match the redirect and post logout redirect 
        // paths configured when registering the application with the OIDC provider. 
        // The default values are "/signin-oidc" and "/signout-callback-oidc".

        //oidcOptions.CallbackPath = new PathString("/signin-oidc");
        //oidcOptions.SignedOutCallbackPath = new PathString("/signout-callback-oidc");
        // ........................................................................

        // ........................................................................
        // The RemoteSignOutPath is the "Front-channel logout URL" for remote single 
        // sign-out. The default value is "/signout-oidc".

        //oidcOptions.RemoteSignOutPath = new PathString("/signout-oidc");
        // ........................................................................

        // ........................................................................
        // The following example Authority is configured for Microsoft Entra ID
        // and a single-tenant application registration. Set the {TENANT ID} 
        // placeholder to the Tenant ID. The "common" Authority 
        // .0/ should be used 
        // for multi-tenant apps. You can also use the "common" Authority for 
        // single-tenant apps, but it requires a custom IssuerValidator as shown 
        // in the comments below. 

        oidcOptions.Authority = "/{TENANT ID}/v2.0/";
        // ........................................................................

        // ........................................................................
        // Set the Client ID for the app. Set the {CLIENT ID} placeholder to
        // the Client ID.

        oidcOptions.ClientId = "{CLIENT ID}";
        // ........................................................................

        // ........................................................................
        // Setting ResponseType to "code" configures the OIDC handler to use 
        // authorization code flow. Implicit grants and hybrid flows are unnecessary
        // in this mode. In a Microsoft Entra ID app registration, you don't need to 
        // select either box for the authorization endpoint to return access tokens 
        // or ID tokens. The OIDC handler automatically requests the appropriate 
        // tokens using the code returned from the authorization endpoint.

        oidcOptions.ResponseType = OpenIdConnectResponseType.Code;
        // ........................................................................

        // ........................................................................
        // Set MapInboundClaims to "false" to obtain the original claim types from 
        // the token. Many OIDC servers use "name" and "role"/"roles" rather than 
        // the SOAP/WS-Fed defaults in ClaimTypes. Adjust these values if your 
        // identity provider uses different claim types.

        oidcOptions.MapInboundClaims = false;
        oidcOptions.TokenValidationParameters.NameClaimType = JwtRegisteredClaimNames.Name;
        oidcOptions.TokenValidationParameters.RoleClaimType = "roles";
        // ........................................................................

        // ........................................................................
        // Many OIDC providers work with the default issuer validator, but the
        // configuration must account for the issuer parameterized with "{TENANT ID}" 
        // returned by the "common" endpoint's /.well-known/openid-configuration
        // For more information, see
        // 

        //var microsoftIssuerValidator = AadIssuerValidator.GetAadIssuerValidator(oidcOptions.Authority);
        //oidcOptions.TokenValidationParameters.IssuerValidator = microsoftIssuerValidator.Validate;
        // ........................................................................

        // ........................................................................
        // OIDC connect options set later via ConfigureCookieOidcRefresh
        //
        // (1) The "offline_access" scope is required for the refresh token.
        //
        // (2) SaveTokens is set to true, which saves the access and refresh tokens
        // in the cookie, so the app can authenticate requests for weather data and
        // use the refresh token to obtain a new access token on access token
        // expiration.
        // ........................................................................
    })
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);

// ConfigureCookieOidcRefresh attaches a cookie OnValidatePrincipal callback to get
// a new access token when the current one expires, and reissue a cookie with the
// new access token saved inside. If the refresh fails, the user will be signed
// out. OIDC connect options are set for saving tokens and the offline access
// scope.
builder.Services.ConfigureCookieOidcRefresh(CookieAuthenticationDefaults.AuthenticationScheme, MS_OIDC_SCHEME);

builder.Services.AddAuthorization();

builder.Services.AddCascadingAuthenticationState();

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

//builder.Services.AddScoped<AuthenticationStateProvider, PersistingAuthenticationStateProvider>();

//builder.Services.AddScoped<IWeatherForecaster, ServerWeatherForecaster>();

builder.Services.AddHttpContextAccessor();

builder.Services.AddSignalR();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see .
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseStaticFiles();
app.UseAntifery();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.MapGroup("/authentication").MapLoginAndLogout();

app.MapHub<ChatHub>("/chat");

app.Run();

*** EDIT #3 ***

I wasn't sure of the best way to provide a sample so I included the additional files I added / changed (in addition to the Program.cs file) below. I also added a detail set of changes that I made to the starter / sample project located at .0/BlazorWebAppOidcServer.

ChatHub.cs

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;

namespace BlazorWebAppOidcServer.Hubs
{
    [Authorize(AuthenticationSchemes = "MicrosoftOidc")]

    public class ChatHub : Hub
    {
        public async Task SendMessage(string user, string message)
        {
            await Clients.All.SendAsync("ReceiveMessage", user, message);
        }

        public override Task OnConnectedAsync()
        {
            /*

            Context.User.Identity.IsAuthenticated is false here, even though the user has been authenticated.

            */

            return base.OnConnectedAsync();
        }
    }
}

Chat.razor

@page "/chat"
@using Microsoft.AspNetCore.SignalR.Client
@inject NavigationManager Navigation
@implements IAsyncDisposable

<PageTitle>Chat</PageTitle>

<div class="form-group">
    <label>
        User:
        <input @bind="userInput" />
    </label>
</div>
<div class="form-group">
    <label>
        Message:
        <input @bind="messageInput" size="50" />
    </label>
</div>
<button @onclick="Send" disabled="@(!IsConnected)">Send</button>

<hr>

<ul id="messagesList">
    @foreach (var message in messages)
    {
        <li>@message</li>
    }
</ul>

@code {
    private HubConnection? hubConnection;
    private List<string> messages = [];
    private string? userInput;
    private string? messageInput;

    protected override async Task OnInitializedAsync()
    {
        hubConnection = new HubConnectionBuilder()
            .WithUrl(Navigation.ToAbsoluteUri("/chathub"))
            .Build();

        hubConnection.On<string, string>("ReceiveMessage", (user, message) =>
        {
            var encodedMsg = $"{user}: {message}";
            messages.Add(encodedMsg);
            InvokeAsync(StateHasChanged);
        });

        await hubConnection.StartAsync();
    }

    private async Task Send()
    {
        if (hubConnection is not null)
        {
            await hubConnection.SendAsync("SendMessage", userInput, messageInput);
        }
    }

    public bool IsConnected =>
        hubConnection?.State == HubConnectionState.Connected;

    public async ValueTask DisposeAsync()
    {
        if (hubConnection is not null)
        {
            await hubConnection.DisposeAsync();
        }
    }
}

Detail List of Changes (With Comments)

NOTE: The app I pulled some of the SignalR content can be found at the URL .0/BlazorSignalRApp.

  • On line 61, replaced the string "{TENANT ID}" with the correct tenant ID for the app registration in Entra ID.
  • On line 68, replaced the string "{CLIENT ID}" with the correct client ID for the app registration in Entra ID.
  • Added an entry with the key "Authentication:Schemes:MicrosoftOidc:ClientSecret" and the value being the correct client secret for the app registration in Entra ID to the local secrets.json file for the project.

At this point, you should be able to run the app and login successfully. You can verify after logging in by clickin the User Claims link in the left-hand navigation menu.

  • Added version 1.2.0 of the package Microsoft.AspNetCore.SignalR to the project.
  • Added version 8.0.14 of the package Microsoft.AspNetCore.SignalR.Client to the project.
  • Added the folder Hubs to the project.
  • Added the class ChatHub.cs to the Hubs folder. Other than the namespace change, the code for this class file was copied as-is from the BlazorSignalRApp sample app.
  • Added the the Chat.razor file to the Components/Pages folder. The content / code of this page was copied as-is from the BlazorSignalRApp sample app.
  • Added a link to the left-hand navigation menu for the Chat page.
  • Added a line to call the method MapHub at the end of the Program.cs file (line 158).

At this point, you should be able to run the app as before and access the Chat page and send / receive a message via the SignalR hub. However, as noted in the comment in the ChatHub.cs file, the value of Context.User.Identity.IsAuthenticated is false, even though the user has already been authenticated (this assumes that the user has already logged on).

  • From here, I tried to add the Authorize attribute to the ChatHub class, specifying the authentication scheme CookieAuthenticationDefaults.AuthenticationScheme. My understanding is that the SignalR hub needs the access token and the access token should be stored in the cookie, so my thought was using this authentication scheme should make the access token available. Perhaps this is grossly incorrect. At any rate, a 401 response is generated.
  • I also tried adding the OIDC authentication scheme that is being used, MicrosoftOidc to the Authorize attribute to see if that would do anything. This resulted in the JsonReaderException exception that was originally mentioned.
发布评论

评论列表(0)

  1. 暂无评论