loading...
Cover image for Secure ASP.NET Blazor WASM apps and APIs with Azure AD B2C
The 425 Show

Secure ASP.NET Blazor WASM apps and APIs with Azure AD B2C

christosmatskas profile image Christos Matskas Updated on ・11 min read

Today's blog post is a bit on the cutting edge of .NET Core and Identity. We will be creating a secure Blazor Client (WASM) web app that authenticates users against Azure AD B2C using local accounts and Google and then communicate securely with a .NET Core API to pull some data. There are a few moving components here:

  • ASP.NET Core Blazor
  • ASP.NET Core API
  • Azure AD B2C

Unlike traditional ASP.NET Core web apps, Blazor WASM has some idiosyncrasies when it comes to authentication and token acquisition which we will highlight as we build the solution

This blog post comes at the back of our Tuesday Twitch stream were we attempted to build this live with our special guest Jon Gallant from the Azure SDKs team. You can watch the video on our Twich channel

Prerequisites

There are a couple of prerequisites if you want to follow along

  • Latest .NET Core SDK (3.1.6 or later)
  • Your favorite .NET Core IDE
  • The latest Blazor templates. You can install the latest Blazor templates for the .NET CLI with the following command:

dotnet new --install Microsoft.AspNetCore.Components.WebAssembly.Templates::3.2.1

Create the App Registration for the Client app (web app)

Log in to the Azure, go to your AD B2C portal and create a new App Registration.

Alt Text

Next, we need to create a Sign in + Sign out Policy or flow in B2C. This is what creates the endpoint for our users to log in and also defines the behavior and data we collect and return during the sign in/signup process. The nice thing is that B2C does most of this automatically for us so we don't have to worry about the underlying details. At a later time, you can revisit your policies and customize them to better match your needs as well as the look and feel of your app. For now, we'll go with the defaults. At the root of your B2C tenant portal, select User Flows and then click on the New user flow button

Alt Text

Select the Sign up and sign in user flow and go with the Recommended version

Alt Text

Select an appropriate Name(no spaces!!), add an external provider (optional), select the Display Name (minimum) attribute for the Return Claim, press OK and Create

Alt Text

The Display Name in the return ID token will be used to populate the Name property in the Context.User.Identity object in our web app. The other properties are optional so you can choose what to pull in your token beyond the Display Name

Go back to the app registration we just created as we will need the details for creating our Blazor WASM app on the next step

Create the Blazor Web App

We will use the .NET CLI to create the Blazor WASM app because it's easier when it comes to configuring authentication. Open your favorite CMD and type the following, once you've updated the command parameters with your tenant and app details:

dotnet new blazorwasm -au IndividualB2C --aad-b2c-instance "{AAD B2C INSTANCE}" --client-id "{CLIENT ID}" --domain "{TENANT DOMAIN}" -o {APP NAME} -ssp "{SIGN UP OR SIGN IN POLICY}"

In my case this was:

dotnet new blazorwasm -au IndividualB2C --aad-b2c-instance "https://cmatdevb2c.b2clogin.com/" --client-id "ddb4a4c0-d2ae-4c02-ab83-f7be17f587e1" --domain "cmatdevb2c.onmicrosoft.com" -o BlazorWeb -ssp "B2C_1_sisu_simple"

Press Enter and watch the magic happen:

Alt Text

At this point, we need to go back to our App registration on the B2C portal and update the Authentication tab with the platform and the Redirect URI for our Blazor web app. In the Authentication tab, click the Add a platform button, select Web and configure the Redirect URIs with

  • http://localhost:5000/authentication/login-callback
  • https://localhost:5001/authentication/login-callback

The above URIs are the ones that our Blazor app will be listening for the auth result. If you plan to use a different port with Kestrel, make sure to update the port numbers here as well.

Finally, we need to configure Implicit grant so that during login it we can grab both the ID and Access tokens. If you're wonder about the Access Token, we're doing it now ahead of time as we will need to when it's time to access our API data. Make sure to press Save in the end.

Alt Text

We can now run our Blazor app and test that our signup and login works as expected. Since we are using local accounts, we'll have to sign up before being able to log in. Subsequent logins are much simpler. And if you have social media accounts configured in the your user flow, then these will appear as an option here as well

Alt Text

Let's look at the code!

Because we used the template, we didnt' have to write any code. However, this is not always the case and there are scenarios that we may want to add authentication to an existing application. So, let's look at the components necessary for authentication:

First, we need to add the MSAL library. This is a NuGet package specially designed for Blazor WASM apps, unlike the new Microsoft.Identity.Web library that work's with Blazor Server. Open your *.csproj and add the following reference:

<PackageReference Include="Microsoft.Authentication.WebAssembly.Msal" Version="3.2.1" />

Next, open the Program.cs file and add the following code:

builder.Services.AddMsalAuthentication(options =>
{
    builder.Configuration.Bind("AzureAdB2C", options.ProviderOptions.Authentication);
});

Replace the code in App.razor with the following:

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if (!context.User.Identity.IsAuthenticated)
                    {
                        <RedirectToLogin />
                    }
                    else
                    {
                        <p>You are not authorized to access this resource.</p>
                    }
                </NotAuthorized>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

In the _Imports.razor, add the appropriate import as per below:
@using Microsoft.AspNetCore.Components.Authorization

Inside index.html, add a new script before the end of the <body> tag

<script src="_content/Microsoft.Authentication.WebAssembly.Msal/AuthenticationService.js"></script>

In the appsettings.json add a section for the Azure AD B2C settints

{
  "AzureAdB2C": {
    "Authority": "https://cmatdevb2c.b2clogin.com/cmatdevb2c.onmicrosoft.com/B2C_1_sisu_simple",
    "ClientId": "<your client id>",
    "ValidateAuthority": false
  }
}

In the Shared folder, we need to add a new page RedirectToLogin.razor with the following code

@inject NavigationManager Navigation
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@code {
    protected override void OnInitialized()
    {
        Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}");
    }
}

Next, we need to update the MainLayout.razor with a login prompt as well as a greeting for our authenticated users. For that, we will use a LoginDisplay component. So for now, add the <LoginDisplay /> where it makes sense for your app. I put it at the top of the main content

@inherits LayoutComponentBase

<div class="sidebar">
    <NavMenu />
</div>

<div class="main">
    <div class="top-row px-4 auth">
        <LoginDisplay />
        <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
    </div>

    <div class="content px-4">
        @Body
    </div>
</div>

As mentioned in the previews step, we need to create a LoginDisplay.razor component in the Shared folder. The code for this component is:

@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication

@inject NavigationManager Navigation
@inject SignOutSessionStateManager SignOutManager

<AuthorizeView>
    <Authorized>
        Hello, @context.User.Identity.Name!
        <button class="nav-link btn btn-link" @onclick="BeginLogout">Log out</button>
    </Authorized>
    <NotAuthorized>
        <a href="authentication/login">Log in</a>
    </NotAuthorized>
</AuthorizeView>

@code{
    private async Task BeginLogout(MouseEventArgs args)
    {
        await SignOutManager.SetSignOutState();
        Navigation.NavigateTo("authentication/logout");
    }
}

Finally, in the Pages folder, add a new Authentication.razor page with the following code:

@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
<RemoteAuthenticatorView Action="@Action" />

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

Following the steps in the section, you can implement authentication in your Blazor WASM app. This is great but there is a good chance that your app will be using one or more APIs to pull some data in. So let's see what we need to change in the code to make this work. But first, we need an API that works with B2C as well :)

Create the API App registration

To allow our API to validate tokens against B2C we need an app registration. In the B2C portal, create a new App Registration. You don't need a Redirect URI so provide a name and press Create. Next, open the Expose and API tab and Add a scope. Use acess_as_user for the scope name

Alt Text

That's all we need from our API. With this change however, we also need to update the client app registration to allow it to request the appropriate scopes when users login. Open the client app registration and go to the API Permissions tab. Click the Add a permission button and select My APIs. Find the API you just created, click on it select the access_as_user permission. Click the Add permissions button

Alt Text

Our B2C is now to rock 'n roll some tokens! Back to our code.

Create the .NET Core API

For the sake of simplicity, I'm going to create a simple API using the default template -> yes, the one that serves random weather data :)

dotnet new api -o <yourAPIName>

Alt Text

For the authentication/authorization bit, we're going to use the new Microsoft.Identity.Web library that simplifies significantly the whole process.

Open the *.csproj file and add the following package reference:

<PackageReference Include="Microsoft.Identity.Web" Version="0.2.3-preview" />

For authentication to work, we need to tell Microsoft.Identity.Web which tenant and app registration to use. In the appsettings.json of our API, we need to add the following information

"AzureAdB2C": {
    "Instance": "https://cmatdevb2c.onmicrosoft.com",
    "ClientId": "80978f9b-a2f9-44bf-8ae7-3c5099ff12b2",
    "Domain": "cmatdevb2c.onmicrosoft.com",
    "TenantId": "cmatdevb2c.onmicrosoft.com"
  },
  "MetadataAddress": "https://cmatdevb2c.b2clogin.com/cmatdevb2c.onmicrosoft.com/B2C_1_sisu_simple/v2.0/.well-known/openid-configuration",
  "TokenAudience": "80978f9b-a2f9-44bf-8ae7-3c5099ff12b2"

Beyond the standard AzureAdB2C settings, I've also included the MetadataAddress and TokenAudience information. That's because at this stage the Microsoft.Identity.Web library is defaulting to different values and has to be explicitly told how and where to get the right user information. The TokenAudience is set to the API Client Id.

We can now wire up the authentication middleware in Startup.cs. Update the ConfigureServices() method with the following code:

public void ConfigureServices(IServiceCollection services)
{    
 services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftWebApi(options =>
    {
        options.MetadataAddress= Configuration["MetadataAddress"];
        options.Audience = Configuration["TokenAudience"];
        options.TokenValidationParameters = new TokenValidationParameters 
            {
                ValidateAudience = true, 
                ValidateIssuer = false 
            };
        Configuration.Bind("AzureAdB2C", options);
        options.TokenValidationParameters.NameClaimType = "name";
    },
    options => { Configuration.Bind("AzureAdB2C", options); });

    services.AddControllers();

    services.AddAuthorization(options =>
    {
        options.AddPolicy("AccessAsUser",
            policy => policy.Requirements.Add(new ScopesRequirement("access_as_user")));
    });
}

You probably noticed that we add a Policy to our authorization. For this to work, we need to define a new class that implements the IAuthorizationRequirement interface. Create a new ScopesRequirement.cs class and add the following code:

using Microsoft.AspNetCore.Authorization;
using Microsoft.Identity.Web;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

namespace API
{
    public class ScopesRequirement : AuthorizationHandler<ScopesRequirement>, IAuthorizationRequirement
    {
        string[] _acceptedScopes;

        public ScopesRequirement(params string[] acceptedScopes)
        {
            _acceptedScopes = acceptedScopes;
        }

        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
                                                        ScopesRequirement requirement)
        {
            // If there are no scopes, do not process
            if (!context.User.Claims.Any(x => x.Type == ClaimConstants.Scope)
               && !context.User.Claims.Any(y => y.Type == ClaimConstants.Scp))
            {
                return Task.CompletedTask;
            }

            Claim scopeClaim = context?.User?.FindFirst(ClaimConstants.Scp);

            if (scopeClaim == null)
                scopeClaim = context?.User?.FindFirst(ClaimConstants.Scope);

            if (scopeClaim != null && scopeClaim.Value.Split(' ').Intersect(requirement._acceptedScopes).Any())
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}

The above code is used to inspect each incoming HTTP request to retrieve the defined scope. If the scope is found, a context.Succeed to mark that the scope requirement was successfully evaluated. Failure to find the scope will result in a 403 - Unauthorized error.

We also have to update the Configure() method with the following code snippets

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    IdentityModelEventSource.ShowPII = true; //handy for debugging
}
...
app.UseCors(policy => 
       policy.WithOrigins("http://localhost:5000", "https://localhost:5001")
       .AllowAnyMethod()
       .WithHeaders(HeaderNames.ContentType, HeaderNames.Authorization)
       .AllowCredentials());

app.UseAuthentication();

Note: if you don't define a CORS policy, your solution is going to be dead in the water. You need to explicitly say what Origin URLs and which Headers to allow in your app or otherwise you'll be met with the dreaded CORS error even if everything else is wired correctly.

The final bit in our API is to configure the controller(s) with the appropriate Authorize attribute to ensure that only authenticated requests can access the various actions. In the WeatherForecastController.cs class, decorate the Get() method with the [Authorize("AccessAsUser")] attribute. Here, we make use of our authorization policy that we defined in the

Configure the Blazor app to retrieve data securely from the API

In Blazor WASM apps, there is a way to configure the code so that outgoing HTTP requests are wired with the appropriate authorization headers automatically. There is a whole section in our docs that explains how and why things work in the way they do, so feel free to read about it. If, however, all you care about is the code changes necessary to make this happen, skip the doc and read on...

First we need to add a NuGet package. Open your *.csproj and add the following package reference:

<PackageReference Include="Microsoft.Extensions.Http" Version="3.1.7" />

Because the API that we're using is not residing in the same Base URL as our Blazor app (simulating a real scenario), we need to do some changes In our Blazor WASM app. First we need to add a new class CustomAuthorizationMessageHandler.cs, as per the docs, to rewire the automatic HTTP Message handler with our custom settings

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

public class CustomAuthorizationMessageHandler : AuthorizationMessageHandler
{

    private static string scope = @"https://cmatdevb2c.onmicrosoft.com/80978f9b-a2f9-44bf-8ae7-3c5099ff12b2/access_as_user";
    public CustomAuthorizationMessageHandler(IAccessTokenProvider provider, 
        NavigationManager navigationManager)
        : base(provider, navigationManager)
    {
        ConfigureHandler(
            authorizedUrls: new[] { "http://localhost:8080" },
            scopes: new[] { scope });
    }
}

You can get the value for the scope variable from the API Permissions in your Client App Registration:

Alt Text

The authorizedUrls value needs to match your API URL. So make sure to set the right value here. My API is configured to run on localhost port 8080.

With our custom MessageHandler in place, we can now update the Program.cs to make use of it. The update code should look like the following:

using System;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

namespace BlazorWeb
{
    public class Program
    {
        private static string scope = @"https://cmatdevb2c.onmicrosoft.com/80978f9b-a2f9-44bf-8ae7-3c5099ff12b2/access_as_user";
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("app");
            builder.Services.AddScoped<CustomAuthorizationMessageHandler>();

            builder.Services.AddHttpClient("ServerAPI", 
                client => client.BaseAddress = new Uri("http://localhost:8080"))
                .AddHttpMessageHandler<CustomAuthorizationMessageHandler>();

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

            builder.Services.AddMsalAuthentication(options =>
            {
                builder.Configuration.Bind("AzureAdB2C", options.ProviderOptions.Authentication);
                options.ProviderOptions.DefaultAccessTokenScopes.Add(scope);
            });

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

Make sure to update the scope and BaseAddress values with your own. Finally, we can update the FetchData.razor code to grab the data from our API instead of the local sample file:

protected override async Task OnInitializedAsync()
    {
        forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>(@"http://localhost:8080/WeatherForecast");
    }

You'll notice that do didn't have to mess with the injected HttpClient to add headers etc. It's all done for us via the middleware. Sweet, right?

I need teh codez

Just need to code? You can grab the working sample from GitHub

What about an Azure AD version?

We got you covered. If you want to see this exact same code working with AAD only, then check out the repo

Summary

ASP.NET Blazor works great with Azure AD or Azure AD B2C to provide authentication and allow secure access to APIs (MS Graph or custom). In this blog post we examined the steps necessary to configure end-to-end authentication and authorization in our Blazor WASM and .NET Core API apps with Azure AD B2C.

Get in touch!

Do you have a cool app (any app, any platform) that uses either AAD or AAD B2C? Do you want to show us

We stream live twice a week at twitch.tv/425show! Join us:

10a - 1p eastern US time Tuesdays
11a - 12n eastern US time Thursdays for Community Hour

Be sure to send your questions to us here, on twitter or email: iddevsoc@service.microsoft.com!

Posted on by:

christosmatskas profile

Christos Matskas

@christosmatskas

Program Manager in the Microsoft Identity Dev Advocacy team. Programmer, speaker and all around geek

Discussion

markdown guide
 

In your instructions, you did not include the need to add the class ScopesRequirement(). It would be nice to have a description of the class, why it is there, and what it does.

You did not mention that you have this package installed: Microsoft.Extensions.Http

 

Hi @woodpk ! You're absolutely right! Great catch, I will update the blog asap. This is a gross omission on my behalf :(

 

I may need some direction in getting my implementation of your instructions to work correctly. I have been able to link ADB2C with the blazor client project, but when attempting to route through ADB2C to access the .net API, i am getting an error telling me that the config resource at that endpoint does not exist, but it also gives a "DENY" error:

System.InvalidOperationException: IDX20803: Unable to obtain configuration from: 'PhoenixRisingCounselingServices.b2...'.
---> System.IO.IOException: IDX20807: Unable to retrieve document from: 'PhoenixRisingCounselingServices.b2...'. HttpResponseMessage: 'StatusCode: 404, ReasonPhrase: 'Not Found', Version: 1.1, Content: System.Net.Http.HttpConnectionResponseContent, Headers:

Message in the

tag is:

404 - File or directory not found.


The resource you are looking for might have been removed, had its name changed, or is temporarily unavailable.

And here is the end of the stack trace:
at Microsoft.IdentityModel.Protocols.HttpDocumentRetriever.GetDocumentAsync(String address, CancellationToken cancel)
at Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever.GetAsync(String address, IDocumentRetriever retriever, CancellationToken cancel)
at Microsoft.IdentityModel.Protocols.ConfigurationManager`1.GetConfigurationAsync(CancellationToken cancel)
--- End of inner exception stack trace ---

Hi @woodpk , there seems to be some typo or issue with your metadata endpoint. Can you doublecheck and get back to me... feel free to email me at iddevsoc@service.microsoft.com to work this out

Don't know why it cut that text off... i'll email as that might work out better.

 

Updated... thanks for your feedback!

 

Thanks for this detailed summary. After going through the settings on the B2C portal and building the WASM client with the supplied command I started the app and tried to log in:
Then I got: "Checking login state..." and nothing more. After some head scratching I hit F12 and got a clue to what was wrong.
In the appsettings.json the line for "Authority" was incorrect. The forward slash after the xxx.b2clogin.com was missing. Adding that fixed the problem. It seems I omitted the forward slash.
I just learned that the instructions have to be followed to the letter :)

 

HI @finnurhrafn , thanks for the kind comments and the feedback. And I'm glad you were able to resolve the issue. I have to agree that attention to detail is important :) - moreso in programming than anything else! Keep up the great work and ping us if you have any more issues

 

Why do you not validate the issuer? ValidateIssuer = false?