ASP.NET Core Blazor WebAssembly additional security scenarios

Attach tokens to outgoing requests

AuthorizationMessageHandler is a DelegatingHandler used to attach access tokens to outgoing HttpResponseMessage instances. Tokens are acquired using the IAccessTokenProvider service, which is registered by the framework. If a token can't be acquired, an AccessTokenNotAvailableException is thrown. AccessTokenNotAvailableException has a Redirect method that can be used to navigate the user to the identity provider to acquire a new token.

For convenience, the framework provides the BaseAddressAuthorizationMessageHandler preconfigured with the app's base address as an authorized URL. Access tokens are only added when the request URI is within the app's base URI. When outgoing request URIs aren't within the app's base URI, use a custom AuthorizationMessageHandler class (recommended) or configure the AuthorizationMessageHandler.

Note

In addition to the client app configuration for server API access, the server API must also allow cross-origin requests (CORS) when the client and the server don't reside at the same base address. For more information on server-side CORS configuration, see the Cross-origin resource sharing (CORS) section later in this article.

In the following example:

using System.Net.Http;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

...

builder.Services.AddHttpClient("ServerAPI", 
        client => client.BaseAddress = new Uri("https://www.example.com/base"))
    .AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();

builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>()
    .CreateClient("ServerAPI"));

For a hosted Blazor solution based on the Blazor WebAssembly project template, request URIs are within the app's base URI by default. Therefore, IWebAssemblyHostEnvironment.BaseAddress (new Uri(builder.HostEnvironment.BaseAddress)) is assigned to the HttpClient.BaseAddress in an app generated from the project template.

The configured HttpClient is used to make authorized requests using the try-catch pattern:

@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject HttpClient Http

...

protected override async Task OnInitializedAsync()
{
    private ExampleType[] examples;

    try
    {
        examples = 
            await Http.GetFromJsonAsync<ExampleType[]>("ExampleAPIMethod");

        ...
    }
    catch (AccessTokenNotAvailableException exception)
    {
        exception.Redirect();
    }
}

Custom AuthorizationMessageHandler class

This guidance in this section is recommended for client apps that make outgoing requests to URIs that aren't within the app's base URI.

In the following example, a custom class extends AuthorizationMessageHandler for use as the DelegatingHandler for an HttpClient. ConfigureHandler configures this handler to authorize outbound HTTP requests using an access token. The access token is only attached if at least one of the authorized URLs is a base of the request URI (HttpRequestMessage.RequestUri).

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

public class CustomAuthorizationMessageHandler : AuthorizationMessageHandler
{
    public CustomAuthorizationMessageHandler(IAccessTokenProvider provider, 
        NavigationManager navigationManager)
        : base(provider, navigationManager)
    {
        ConfigureHandler(
            authorizedUrls: new[] { "https://www.example.com/base" },
            scopes: new[] { "example.read", "example.write" });
    }
}

In Program.Main (Program.cs), CustomAuthorizationMessageHandler is registered as a scoped service and is configured as the DelegatingHandler for outgoing HttpResponseMessage instances made by a named HttpClient:

builder.Services.AddScoped<CustomAuthorizationMessageHandler>();

builder.Services.AddHttpClient("ServerAPI",
        client => client.BaseAddress = new Uri("https://www.example.com/base"))
    .AddHttpMessageHandler<CustomAuthorizationMessageHandler>();

For a hosted Blazor solution based on the Blazor WebAssembly project template, IWebAssemblyHostEnvironment.BaseAddress (new Uri(builder.HostEnvironment.BaseAddress)) is assigned to the HttpClient.BaseAddress by default.

The configured HttpClient is used to make authorized requests using the try-catch pattern. Where the client is created with CreateClient (Microsoft.Extensions.Http package), the HttpClient is supplied instances that include access tokens when making requests to the server API. If the request URI is a relative URI, as it is in the following example (ExampleAPIMethod), it's combined with the BaseAddress when the client app makes the request:

@inject IHttpClientFactory ClientFactory

...

@code {
    private ExampleType[] examples;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            var client = ClientFactory.CreateClient("ServerAPI");

            examples = 
                await client.GetFromJsonAsync<ExampleType[]>("ExampleAPIMethod");
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }
    }
}

Configure AuthorizationMessageHandler

AuthorizationMessageHandler can be configured with authorized URLs, scopes, and a return URL using the ConfigureHandler method. ConfigureHandler configures the handler to authorize outbound HTTP requests using an access token. The access token is only attached if at least one of the authorized URLs is a base of the request URI (HttpRequestMessage.RequestUri). If the request URI is a relative URI, it's combined with the BaseAddress.

In the following example, AuthorizationMessageHandler configures an HttpClient in Program.Main (Program.cs):

using System.Net.Http;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

...

builder.Services.AddScoped(sp => new HttpClient(
    sp.GetRequiredService<AuthorizationMessageHandler>()
    .ConfigureHandler(
        authorizedUrls: new[] { "https://www.example.com/base" },
        scopes: new[] { "example.read", "example.write" }))
    {
        BaseAddress = new Uri("https://www.example.com/base")
    });

For a hosted Blazor solution based on the Blazor WebAssembly project template, IWebAssemblyHostEnvironment.BaseAddress is assigned to the following by default:

  • The HttpClient.BaseAddress (new Uri(builder.HostEnvironment.BaseAddress)).
  • A URL of the authorizedUrls array.

Typed HttpClient

A typed client can be defined that handles all of the HTTP and token acquisition concerns within a single class.

WeatherForecastClient.cs:

using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using static {APP ASSEMBLY}.Data;

public class WeatherForecastClient
{
    private readonly HttpClient http;
 
    public WeatherForecastClient(HttpClient http)
    {
        this.http = http;
    }
 
    public async Task<WeatherForecast[]> GetForecastAsync()
    {
        var forecasts = new WeatherForecast[0];

        try
        {
            forecasts = await http.GetFromJsonAsync<WeatherForecast[]>(
                "WeatherForecast");
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }

        return forecasts;
    }
}

The placeholder {APP ASSEMBLY} is the app's assembly name (for example, using static BlazorSample.Data;).

Program.Main (Program.cs):

using System.Net.Http;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

...

builder.Services.AddHttpClient<WeatherForecastClient>(
        client => client.BaseAddress = new Uri("https://www.example.com/base"))
    .AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();

For a hosted Blazor solution based on the Blazor WebAssembly project template, IWebAssemblyHostEnvironment.BaseAddress (new Uri(builder.HostEnvironment.BaseAddress)) is assigned to the HttpClient.BaseAddress by default.

FetchData component (Pages/FetchData.razor):

@inject WeatherForecastClient Client

...

protected override async Task OnInitializedAsync()
{
    forecasts = await Client.GetForecastAsync();
}

Configure the HttpClient handler

The handler can be further configured with ConfigureHandler for outbound HTTP requests.

Program.Main (Program.cs):

builder.Services.AddHttpClient<WeatherForecastClient>(
        client => client.BaseAddress = new Uri("https://www.example.com/base"))
    .AddHttpMessageHandler(sp => sp.GetRequiredService<AuthorizationMessageHandler>()
    .ConfigureHandler(
        authorizedUrls: new [] { "https://www.example.com/base" },
        scopes: new[] { "example.read", "example.write" }));

For a hosted Blazor solution based on the Blazor WebAssembly project template, IWebAssemblyHostEnvironment.BaseAddress is assigned to the following by default:

  • The HttpClient.BaseAddress (new Uri(builder.HostEnvironment.BaseAddress)).
  • A URL of the authorizedUrls array.

Unauthenticated or unauthorized web API requests in an app with a secure default client

If the Blazor WebAssembly app ordinarily uses a secure default HttpClient, the app can also make unauthenticated or unauthorized web API requests by configuring a named HttpClient:

Program.Main (Program.cs):

builder.Services.AddHttpClient("ServerAPI.NoAuthenticationClient", 
    client => client.BaseAddress = new Uri("https://www.example.com/base"));

For a hosted Blazor solution based on the Blazor WebAssembly project template, IWebAssemblyHostEnvironment.BaseAddress (new Uri(builder.HostEnvironment.BaseAddress)) is assigned to the HttpClient.BaseAddress by default.

The preceding registration is in addition to the existing secure default HttpClient registration.

A component creates the HttpClient from the IHttpClientFactory (Microsoft.Extensions.Http package) to make unauthenticated or unauthorized requests:

@inject IHttpClientFactory ClientFactory

...

@code {
    private WeatherForecast[] forecasts;

    protected override async Task OnInitializedAsync()
    {
        var client = ClientFactory.CreateClient("ServerAPI.NoAuthenticationClient");

        forecasts = await client.GetFromJsonAsync<WeatherForecast[]>(
            "WeatherForecastNoAuthentication");
    }
}

Note

The controller in the server API, WeatherForecastNoAuthenticationController for the preceding example, isn't marked with the [Authorize] attribute.

The decision whether to use a secure client or an insecure client as the default HttpClient instance is up to the developer. One way to make this decision is to consider the number of authenticated versus unauthenticated endpoints that the app contacts. If the majority of the app's requests are to secure API endpoints, use the authenticated HttpClient instance as the default. Otherwise, register the unauthenticated HttpClient instance as the default.

An alternative approach to using the IHttpClientFactory is to create a typed client for unauthenticated access to anonymous endpoints.

Request additional access tokens

Access tokens can be manually obtained by calling IAccessTokenProvider.RequestAccessToken. In the following example, an additional scope is required by an app for the default HttpClient. The Microsoft Authentication Library (MSAL) example configures the scope with MsalProviderOptions:

Program.Main (Program.cs):

builder.Services.AddMsalAuthentication(options =>
{
    ...

    options.ProviderOptions.AdditionalScopesToConsent.Add("{CUSTOM SCOPE 1}");
    options.ProviderOptions.AdditionalScopesToConsent.Add("{CUSTOM SCOPE 2}");
}

The {CUSTOM SCOPE 1} and {CUSTOM SCOPE 2} placeholders in the preceding example are custom scopes.

The IAccessTokenProvider.RequestToken method provides an overload that allows an app to provision an access token with a given set of scopes.

In a Razor component:

@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject IAccessTokenProvider TokenProvider

...

var tokenResult = await TokenProvider.RequestAccessToken(
    new AccessTokenRequestOptions
    {
        Scopes = new[] { "{CUSTOM SCOPE 1}", "{CUSTOM SCOPE 2}" }
    });

if (tokenResult.TryGetToken(out var token))
{
    ...
}

The {CUSTOM SCOPE 1} and {CUSTOM SCOPE 2} placeholders in the preceding example are custom scopes.

AccessTokenResult.TryGetToken returns:

  • true with the token for use.
  • false if the token isn't retrieved.

Cross-origin resource sharing (CORS)

When sending credentials (authorization cookies/headers) on CORS requests, the Authorization header must be allowed by the CORS policy.

The following policy includes configuration for:

  • Request origins (http://localhost:5000, https://localhost:5001).
  • Any method (verb).
  • Content-Type and Authorization headers. To allow a custom header (for example, x-custom-header), list the header when calling WithHeaders.
  • Credentials set by client-side JavaScript code (credentials property set to include).
app.UseCors(policy => 
    policy.WithOrigins("http://localhost:5000", "https://localhost:5001")
    .AllowAnyMethod()
    .WithHeaders(HeaderNames.ContentType, HeaderNames.Authorization, "x-custom-header")
    .AllowCredentials());

A hosted Blazor solution based on the Blazor WebAssembly project template uses the same base address for the client and server apps. The client app's HttpClient.BaseAddress is set to a URI of builder.HostEnvironment.BaseAddress by default. CORS configuration is not required in the default configuration of a hosted Blazor solution. Additional client apps that aren't hosted by the server project and don't share the server app's base address do require CORS configuration in the server project.

For more information, see Enable Cross-Origin Requests (CORS) in ASP.NET Core and the sample app's HTTP Request Tester component (Components/HTTPRequestTester.razor).

Handle token request errors

When a Single Page Application (SPA) authenticates a user using OpenID Connect (OIDC), the authentication state is maintained locally within the SPA and in the Identity Provider (IP) in the form of a session cookie that's set as a result of the user providing their credentials.

The tokens that the IP emits for the user typically are valid for short periods of time, about one hour normally, so the client app must regularly fetch new tokens. Otherwise, the user would be logged-out after the granted tokens expire. In most cases, OIDC clients are able to provision new tokens without requiring the user to authenticate again thanks to the authentication state or "session" that is kept within the IP.

There are some cases in which the client can't get a token without user interaction, for example, when for some reason the user explicitly logs out from the IP. This scenario occurs if a user visits https://login.microsoftonline.com and logs out. In these scenarios, the app doesn't know immediately that the user has logged out. Any token that the client holds might no longer be valid. Also, the client isn't able to provision a new token without user interaction after the current token expires.

These scenarios aren't specific to token-based authentication. They are part of the nature of SPAs. An SPA using cookies also fails to call a server API if the authentication cookie is removed.

When an app performs API calls to protected resources, you must be aware of the following:

  • To provision a new access token to call the API, the user might be required to authenticate again.
  • Even if the client has a token that seems to be valid, the call to the server might fail because the token was revoked by the user.

When the app requests a token, there are two possible outcomes:

  • The request succeeds, and the app has a valid token.
  • The request fails, and the app must authenticate the user again to obtain a new token.

When a token request fails, you need to decide whether you want to save any current state before you perform a redirection. Several approaches exist with increasing levels of complexity:

  • Store the current page state in session storage. During the OnInitializedAsync lifecycle method (OnInitializedAsync), check if state can be restored before continuing.
  • Add a query string parameter and use that as a way to signal the app that it needs to re-hydrate the previously saved state.
  • Add a query string parameter with a unique identifier to store data in session storage without risking collisions with other items.

The following example shows how to:

  • Preserve state before redirecting to the login page.
  • Recover the previous state afterward authentication using the query string parameter.
...
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject IAccessTokenProvider TokenProvider
...

<EditForm Model="User" @onsubmit="OnSaveAsync">
    <label>User
        <InputText @bind-Value="User.Name" />
    </label>
    <label>Last name
        <InputText @bind-Value="User.LastName" />
    </label>
</EditForm>

@code {
    public class Profile
    {
        public string Name { get; set; }
        public string LastName { get; set; }
    }

    public Profile User { get; set; } = new Profile();

    protected async override Task OnInitializedAsync()
    {
        var currentQuery = new Uri(Navigation.Uri).Query;

        if (currentQuery.Contains("state=resumeSavingProfile"))
        {
            User = await JS.InvokeAsync<Profile>("sessionStorage.getState", 
                "resumeSavingProfile");
        }
    }

    public async Task OnSaveAsync()
    {
        var http = new HttpClient();
        http.BaseAddress = new Uri(Navigation.BaseUri);

        var resumeUri = Navigation.Uri + $"?state=resumeSavingProfile";

        var tokenResult = await TokenProvider.RequestAccessToken(
            new AccessTokenRequestOptions
            {
                ReturnUrl = resumeUri
            });

        if (tokenResult.TryGetToken(out var token))
        {
            http.DefaultRequestHeaders.Add("Authorization", 
                $"Bearer {token.Value}");
            await http.PostAsJsonAsync("Save", User);
        }
        else
        {
            await JS.InvokeVoidAsync("sessionStorage.setState", 
                "resumeSavingProfile", User);
            Navigation.NavigateTo(tokenResult.RedirectUrl);
        }
    }
}

Save app state before an authentication operation

During an authentication operation, there are cases where you want to save the app state before the browser is redirected to the IP. This can be the case when you're using a state container and want to restore the state after the authentication succeeds. You can use a custom authentication state object to preserve app-specific state or a reference to it and restore that state after the authentication operation successfully completes. The following example demonstrates the approach.

A state container class is created in the app with properties to hold the app's state values. In the following example, the container is used to maintain the counter value of the default Blazor project template's Counter component (Pages/Counter.razor). Methods for serializing and deserializing the container are based on System.Text.Json.

using System.Text.Json;

public class StateContainer
{
    public int CounterValue { get; set; }

    public string GetStateForLocalStorage()
    {
        return JsonSerializer.Serialize(this);
    }

    public void SetStateFromLocalStorage(string locallyStoredState)
    {
        var deserializedState = 
            JsonSerializer.Deserialize<StateContainer>(locallyStoredState);

        CounterValue = deserializedState.CounterValue;
    }
}

The Counter component uses the state container to maintain the currentCount value outside of the component:

@page "/counter"
@inject StateContainer State

<h1>Counter</h1>

<p>Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    protected override void OnInitialized()
    {
        if (State.CounterValue > 0)
        {
            currentCount = State.CounterValue;
        }
    }

    private void IncrementCount()
    {
        currentCount++;
        State.CounterValue = currentCount;
    }
}

Create an ApplicationAuthenticationState from RemoteAuthenticationState. Provide an Id property, which serves as an identifier for the locally-stored state.

ApplicationAuthenticationState.cs:

using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

public class ApplicationAuthenticationState : RemoteAuthenticationState
{
    public string Id { get; set; }
}

The Authentication component (Pages/Authentication.razor) saves and restores the app's state using local session storage with the StateContainer serialization and deserialization methods, GetStateForLocalStorage and SetStateFromLocalStorage:

@page "/authentication/{action}"
@inject IJSRuntime JS
@inject StateContainer State
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication

<RemoteAuthenticatorViewCore Action="@Action"
                             TAuthenticationState="ApplicationAuthenticationState"
                             AuthenticationState="AuthenticationState"
                             OnLogInSucceeded="RestoreState"
                             OnLogOutSucceeded="RestoreState" />

@code {
    [Parameter]
    public string Action { get; set; }

    public ApplicationAuthenticationState AuthenticationState { get; set; } =
        new ApplicationAuthenticationState();

    protected async override Task OnInitializedAsync()
    {
        if (RemoteAuthenticationActions.IsAction(RemoteAuthenticationActions.LogIn,
            Action) ||
            RemoteAuthenticationActions.IsAction(RemoteAuthenticationActions.LogOut,
            Action))
        {
            AuthenticationState.Id = Guid.NewGuid().ToString();

            await JS.InvokeVoidAsync("sessionStorage.setItem",
                AuthenticationState.Id, State.GetStateForLocalStorage());
        }
    }

    private async Task RestoreState(ApplicationAuthenticationState state)
    {
        if (state.Id != null)
        {
            var locallyStoredState = await JS.InvokeAsync<string>(
                "sessionStorage.getItem", state.Id);

            if (locallyStoredState != null)
            {
                State.SetStateFromLocalStorage(locallyStoredState);
                await JS.InvokeVoidAsync("sessionStorage.removeItem", state.Id);
            }
        }
    }
}

This example uses Azure Active Directory (AAD) for authentication. In Program.Main (Program.cs):

  • The ApplicationAuthenticationState is configured as the Microsoft Autentication Library (MSAL) RemoteAuthenticationState type.
  • The state container is registered in the service container.
builder.Services.AddMsalAuthentication<ApplicationAuthenticationState>(options =>
{
    builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
});

builder.Services.AddSingleton<StateContainer>();

Customize app routes

By default, the Microsoft.AspNetCore.Components.WebAssembly.Authentication library uses the routes shown in the following table for representing different authentication states.

Route Purpose
authentication/login Triggers a sign-in operation.
authentication/login-callback Handles the result of any sign-in operation.
authentication/login-failed Displays error messages when the sign-in operation fails for some reason.
authentication/logout Triggers a sign-out operation.
authentication/logout-callback Handles the result of a sign-out operation.
authentication/logout-failed Displays error messages when the sign-out operation fails for some reason.
authentication/logged-out Indicates that the user has successfully logout.
authentication/profile Triggers an operation to edit the user profile.
authentication/register Triggers an operation to register a new user.

The routes shown in the preceding table are configurable via RemoteAuthenticationOptions<TRemoteAuthenticationProviderOptions>.AuthenticationPaths. When setting options to provide custom routes, confirm that the app has a route that handles each path.

In the following example, all the paths are prefixed with /security.

Authentication component (Pages/Authentication.razor):

@page "/security/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication

<RemoteAuthenticatorView Action="@Action" />

@code{
    [Parameter]
    public string Action { get; set; }
}

Program.Main (Program.cs):

builder.Services.AddApiAuthorization(options => { 
    options.AuthenticationPaths.LogInPath = "security/login";
    options.AuthenticationPaths.LogInCallbackPath = "security/login-callback";
    options.AuthenticationPaths.LogInFailedPath = "security/login-failed";
    options.AuthenticationPaths.LogOutPath = "security/logout";
    options.AuthenticationPaths.LogOutCallbackPath = "security/logout-callback";
    options.AuthenticationPaths.LogOutFailedPath = "security/logout-failed";
    options.AuthenticationPaths.LogOutSucceededPath = "security/logged-out";
    options.AuthenticationPaths.ProfilePath = "security/profile";
    options.AuthenticationPaths.RegisterPath = "security/register";
});

If the requirement calls for completely different paths, set the routes as described previously and render the RemoteAuthenticatorView with an explicit action parameter:

@page "/register"

<RemoteAuthenticatorView Action="@RemoteAuthenticationActions.Register" />

You're allowed to break the UI into different pages if you choose to do so.

Customize the authentication user interface

RemoteAuthenticatorView includes a default set of UI pieces for each authentication state. Each state can be customized by passing in a custom RenderFragment. To customize the displayed text during the initial login process, can change the RemoteAuthenticatorView as follows.

Authentication component (Pages/Authentication.razor):

@page "/security/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication

<RemoteAuthenticatorView Action="@Action">
    <LoggingIn>
        You are about to be redirected to https://login.microsoftonline.com.
    </LoggingIn>
</RemoteAuthenticatorView>

@code{
    [Parameter]
    public string Action { get; set; }
}

The RemoteAuthenticatorView has one fragment that can be used per authentication route shown in the following table.

Route Fragment
authentication/login <LoggingIn>
authentication/login-callback <CompletingLoggingIn>
authentication/login-failed <LogInFailed>
authentication/logout <LogOut>
authentication/logout-callback <CompletingLogOut>
authentication/logout-failed <LogOutFailed>
authentication/logged-out <LogOutSucceeded>
authentication/profile <UserProfile>
authentication/register <Registering>

Customize the user

Users bound to the app can be customized.

Customize the user with a payload claim

In the following example, the app's authenticated users receive an amr claim for each of the user's authentication methods. The amr claim identifies how the subject of the token was authenticated in Microsoft Identity Platform v1.0 payload claims. The example uses a custom user account class based on RemoteUserAccount.

Create a class that extends the RemoteUserAccount class. The following example sets the AuthenticationMethod property to the user's array of amr JSON property values. AuthenticationMethod is populated automatically by the framework when the user is authenticated.

using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

public class CustomUserAccount : RemoteUserAccount
{
    [JsonPropertyName("amr")]
    public string[] AuthenticationMethod { get; set; }
}

Create a factory that extends AccountClaimsPrincipalFactory<TAccount> to create claims from the user's authentication methods stored in CustomUserAccount.AuthenticationMethod:

using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;

public class CustomAccountFactory 
    : AccountClaimsPrincipalFactory<CustomUserAccount>
{
    public CustomAccountFactory(NavigationManager navigationManager, 
        IAccessTokenProviderAccessor accessor) : base(accessor)
    {
    }
  
    public async override ValueTask<ClaimsPrincipal> CreateUserAsync(
        CustomUserAccount account, RemoteAuthenticationUserOptions options)
    {
        var initialUser = await base.CreateUserAsync(account, options);

        if (initialUser.Identity.IsAuthenticated)
        {
            foreach (var value in account.AuthenticationMethod)
            {
                ((ClaimsIdentity)initialUser.Identity)
                    .AddClaim(new Claim("amr", value));
            }
        }

        return initialUser;
    }
}

Register the CustomAccountFactory for the authentication provider in use. Any of the following registrations are valid:

  • AddOidcAuthentication:

    using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
    
    ...
    
    builder.Services.AddOidcAuthentication<RemoteAuthenticationState, 
        CustomUserAccount>(options =>
        {
            ...
        })
        .AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, 
            CustomUserAccount, CustomAccountFactory>();
    
  • AddMsalAuthentication:

    using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
    
    ...
    
    builder.Services.AddMsalAuthentication<RemoteAuthenticationState, 
        CustomUserAccount>(options =>
        {
            ...
        })
        .AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, 
            CustomUserAccount, CustomAccountFactory>();
    
  • AddApiAuthorization:

    using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
    
    ...
    
    builder.Services.AddApiAuthorization<RemoteAuthenticationState, 
        CustomUserAccount>(options =>
        {
            ...
        })
        .AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, 
            CustomUserAccount, CustomAccountFactory>();
    

AAD security groups and roles with a custom user account class

For an additional example that works with AAD security groups and AAD Administrator Roles and a custom user account class, see ASP.NET Core Blazor WebAssembly with Azure Active Directory groups and roles.

Support prerendering with authentication

After following the guidance in one of the Blazor WebAssembly security app topics, use the following instructions to create an app that:

  • Prerenders paths for which authorization isn't required.
  • Doesn't prerender paths for which authorization is required.

In the client (Client) app's Program class (Program.cs), factor common service registrations into a separate method (for example, ConfigureCommonServices). Common services are those that the developer registers for use by both the client and server (Server) apps.

public class Program
{
    public static async Task Main(string[] args)
    {
        var builder = WebAssemblyHostBuilder.CreateDefault(args);
        builder.RootComponents.Add...;

        // Services that only the client app uses
        builder.Services.AddScoped( ... );

        ConfigureCommonServices(builder.Services);

        await builder.Build().RunAsync();
    }

    public static void ConfigureCommonServices(IServiceCollection services)
    {
        // Common service registrations that both apps use
        services.Add...;
    }
}

In the server app's Startup.ConfigureServices, register the following additional services and call ConfigureCommonServices:

using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

public void ConfigureServices(IServiceCollection services)
{
    ...

    services.AddRazorPages();
    services.AddScoped<AuthenticationStateProvider, 
        ServerAuthenticationStateProvider>();
    services.AddScoped<SignOutSessionStateManager>();

    Client.Program.ConfigureCommonServices(services);
}

In the server app's Startup.Configure method, replace endpoints.MapFallbackToFile("index.html") with endpoints.MapFallbackToPage("/_Host"):

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
    endpoints.MapFallbackToPage("/_Host");
});

In the server app, create a Pages folder if it doesn't exist. Create a _Host.cshtml page inside the server app's Pages folder. Paste the contents from the client app's wwwroot/index.html file into the Pages/_Host.cshtml file. Update the file's contents:

  • Add @page "_Host" to the top of the file.

  • Replace the <div id="app">Loading...</div> tag with the following:

    <div id="app">
        @if (!HttpContext.Request.Path.StartsWithSegments("/authentication"))
        {
            <component type="typeof({CLIENT APP ASSEMBLY NAME}.App)" 
                render-mode="Static" />
        }
        else
        {
            <text>Loading...</text>
        }
    </div>
    

    In the preceding example, the placeholder {CLIENT APP ASSEMBLY NAME} is the client app's assembly name (for example BlazorSample.Client).

  • Add @page "_Host" to the top of the file.

  • Replace the <app>Loading...</app> tag with the following:

    <app>
        @if (!HttpContext.Request.Path.StartsWithSegments("/authentication"))
        {
            <component type="typeof({CLIENT APP ASSEMBLY NAME}.App)" 
                render-mode="Static" />
        }
        else
        {
            <text>Loading...</text>
        }
    </app>
    

    In the preceding example, the placeholder {CLIENT APP ASSEMBLY NAME} is the client app's assembly name (for example BlazorSample.Client).

Options for hosted apps and third-party login providers

When authenticating and authorizing a hosted Blazor WebAssembly app with a third-party provider, there are several options available for authenticating the user. Which one you choose depends on your scenario.

For more information, see Persist additional claims and tokens from external providers in ASP.NET Core.

Authenticate users to only call protected third party APIs

Authenticate the user with a client-side OAuth flow against the third-party API provider:

builder.services.AddOidcAuthentication(options => { ... });

In this scenario:

  • The server hosting the app doesn't play a role.
  • APIs on the server can't be protected.
  • The app can only call protected third-party APIs.

Authenticate users with a third-party provider and call protected APIs on the host server and the third party

Configure Identity with a third-party login provider. Obtain the tokens required for third-party API access and store them.

When a user logs in, Identity collects access and refresh tokens as part of the authentication process. At that point, there are a couple of approaches available for making API calls to third-party APIs.

Use a server access token to retrieve the third-party access token

Use the access token generated on the server to retrieve the third-party access token from a server API endpoint. From there, use the third-party access token to call third-party API resources directly from Identity on the client.

We don't recommend this approach. This approach requires treating the third-party access token as if it were generated for a public client. In OAuth terms, the public app doesn't have a client secret because it can't be trusted to store secrets safely, and the access token is produced for a confidential client. A confidential client is a client that has a client secret and is assumed to be able to safely store secrets.

  • The third-party access token might be granted additional scopes to perform sensitive operations based on the fact that the third-party emitted the token for a more trusted client.
  • Similarly, refresh tokens shouldn't be issued to a client that isn't trusted, as doing so gives the client unlimited access unless other restrictions are put into place.

Make API calls from the client to the server API in order to call third-party APIs

Make an API call from the client to the server API. From the server, retrieve the access token for the third-party API resource and issue whatever call is necessary.

While this approach requires an extra network hop through the server to call a third-party API, it ultimately results in a safer experience:

  • The server can store refresh tokens and ensure that the app doesn't lose access to third-party resources.
  • The app can't leak access tokens from the server that might contain more sensitive permissions.

Use OpenID Connect (OIDC) v2.0 endpoints

The authentication library and Blazor project templates use OpenID Connect (OIDC) v1.0 endpoints. To use a v2.0 endpoint, configure the JWT Bearer JwtBearerOptions.Authority option. In the following example, AAD is configured for v2.0 by appending a v2.0 segment to the Authority property:

builder.Services.Configure<JwtBearerOptions>(
    AzureADDefaults.JwtBearerAuthenticationScheme, 
    options =>
    {
        options.Authority += "/v2.0";
    });

Alternatively, the setting can be made in the app settings (appsettings.json) file:

{
  "Local": {
    "Authority": "https://login.microsoftonline.com/common/oauth2/v2.0/",
    ...
  }
}

If tacking on a segment to the authority isn't appropriate for the app's OIDC provider, such as with non-AAD providers, set the Authority property directly. Either set the property in JwtBearerOptions or in the app settings file (appsettings.json) with the Authority key.

The list of claims in the ID token changes for v2.0 endpoints. For more information, see Why update to Microsoft identity platform (v2.0)?.

Configure and use gRPC in components

To configure a Blazor WebAssembly app to use the ASP.NET Core gRPC framework:

using System.Net.Http;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Grpc.Net.Client;
using Grpc.Net.Client.Web;
using {APP ASSEMBLY}.Shared;

...

builder.Services.AddScoped(sp =>
{
    var baseAddressMessageHandler = 
        sp.GetRequiredService<BaseAddressAuthorizationMessageHandler>();
    baseAddressMessageHandler.InnerHandler = new HttpClientHandler();
    var grpcWebHandler = 
        new GrpcWebHandler(GrpcWebMode.GrpcWeb, baseAddressMessageHandler);
    var channel = GrpcChannel.ForAddress(builder.HostEnvironment.BaseAddress, 
        new GrpcChannelOptions { HttpHandler = grpcWebHandler });

    return new Greeter.GreeterClient(channel);
});

The placeholder {APP ASSEMBLY} is the app's assembly name (for example, BlazorSample). Place the .proto file in the Shared project of the hosted Blazor solution.

A component in the client app can make gRPC calls using the gRPC client (Pages/Grpc.razor):

@page "/grpc"
@attribute [Authorize]
@using Microsoft.AspNetCore.Authorization
@using {APP ASSEMBLY}.Shared
@inject Greeter.GreeterClient GreeterClient

<h1>Invoke gRPC service</h1>

<p>
    <input @bind="name" placeholder="Type your name" />
    <button @onclick="GetGreeting" class="btn btn-primary">Call gRPC service</button>
</p>

Server response: <strong>@serverResponse</strong>

@code {
    private string name = "Bert";
    private string serverResponse;

    private async Task GetGreeting()
    {
        try
        {
            var request = new HelloRequest { Name = name };
            var reply = await GreeterClient.SayHelloAsync(request);
            serverResponse = reply.Message;
        }
        catch (Grpc.Core.RpcException ex)
            when (ex.Status.DebugException is 
                AccessTokenNotAvailableException tokenEx)
        {
            tokenEx.Redirect();
        }
    }
}

The placeholder {APP ASSEMBLY} is the app's assembly name (for example, BlazorSample). To use the Status.DebugException property, use Grpc.Net.Client version 2.30.0 or later.

For more information, see Use gRPC in browser apps.

Build a custom version of the Authentication.MSAL JavaScript library

If an app requires a custom version of the Microsoft Authentication Library for JavaScript (MSAL.js), perform the following steps:

  1. Confirm the the system has the latest developer .NET SDK or obtain and install the latest developer SDK from .NET Core SDK: Installers and Binaries. Configuration of internal NuGet feeds isn't required for this scenario.
  2. Set up the dotnet/aspnetcore GitHub repository for development per the docs at Build ASP.NET Core from Source. Fork and clone or download a ZIP archive of the dotnet/aspnetcore GitHub repository.
  3. Open the the src/Components/WebAssembly/Authentication.Msal/src/Interop/package.json file and set the desired version of @azure/msal-browser. For a list of released versions, visit the @azure/msal-browser npm website and select the Versions tab.
  4. Build the Authentication.Msal project in the src/Components/WebAssembly/Authentication.Msal/src folder with the yarn build command in a command shell.
  5. If the app uses compressed assets (Brotli/Gzip), compress the Interop/dist/Release/AuthenticationService.js file.
  6. Copy the AuthenticationService.js file and compressed versions (.br/.gz) of the file, if produced, from the Interop/dist/Release folder into the app's publish/wwwroot/_content/Microsoft.Authentication.WebAssembly.Msal folder in the app's published assets.

Additional resources