DEV Community

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

Posted on • Edited on

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

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 (5.0 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. Even better, the latest .NET Auth library (Microsoft.Identity.Web) has a built-in template for WASM + AAD/B2C. You can install it using the following command:

dotnet new -i Microsoft.Identity.Web.ProjectTemplates::1.9.1

Open your favorite CMD and type the following - make sure you add your own application name:

dotnet new blazorwasm2 -au IndividualB2C - -o {APP NAME}

In my case this was:

dotnet new blazorwasm -au IndividualB2C -o BlazorWeb

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 to update the Redirect URI for our Blazor app. In the Authentication tab, click the Add a platform button, select Single Page App 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. 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 didn't 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);
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)}");
    }
}

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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 webapi2 -o <yourAPIName> --auth IndividualB2C

For the authentication/authorization bit, we're using 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="1.*" />

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 or update the following information

"AzureAdB2C": {
    "Instance": "https://cmatdevb2c.b2clogin.com/",
    "ClientId": "80978f9b-a2f9-44bf-8ae7-3c5099ff12b2",
    "Domain": "cmatdevb2c.onmicrosoft.com",
    "SignUpSignInPolicyId": "b2c_1_susi"
}
Enter fullscreen mode Exit fullscreen mode

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

private readonly string MyAllowSpecificOrigins = "localHostAccess";

public void ConfigureServices(IServiceCollection services)
{    
 services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddMicrosoftIdentityWebApi(Configuration.GetSection("AzureAdB2C"));

    services.AddControllers();

    //DO NOT FORGET CORS
    services.AddCors(options =>
    {
        options.AddPolicy(name: MyAllowSpecificOrigins,
           builder =>
           {
               builder
                   .AllowAnyOrigin()
                   .AllowAnyMethod()
                   .AllowAnyHeader();
           });
    });
}
Enter fullscreen mode Exit fullscreen mode

We also need to update the Configure() method in Startup.cs to wire up the authentication, authorization and CORS. Add the following 3 lines right after the app.UseRouting(); line:

app.UseCors(MyAllowSpecificOrigins);
app.UseAuthentication();
app.UseAuthorization();
Enter fullscreen mode Exit fullscreen mode

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 [RequiredScope(new [] { "access_as_user" })] attribute. Here, we want to ensure that any incoming calls contain an access token with the correct scope!

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 });
    }
}
Enter fullscreen mode Exit fullscreen mode

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;

namespace BlazorWeb
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var api_scope = @"https://cmatdevb2c.onmicrosoft.com/80978f9b-a2f9-44bf-8ae7-3c5099ff12b2/access_as_user";

            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("app");

            builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

            builder.Services.AddScoped<CustomAuthorizationMessageHandler>();

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

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

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

            await builder.Build().RunAsync();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Make sure to update the api_scope value with your own. Finally, we can update the FetchData.razor code to grab the data from our API instead of the local sample file. At the very top of the razor page, add these 2 lines:

@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject IHttpClientFactory ClientFactory
Enter fullscreen mode Exit fullscreen mode

Then, we can update the OnInitializedAsync() method with the following code:

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

            forecasts = await client.GetFromJsonAsync<WeatherForecast[]>(@"http://localhost:8080/WeatherForecast");
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }

    }
Enter fullscreen mode Exit fullscreen mode

You'll notice that use the injected ClientFactory to get access to our own HttpClient that makes use of the CustomAuthorizationMessageHandler. With this we don't have to worry about add Auth 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 custom APIs (MS Graph or your own). In this blog post we examined the steps necessary to configure end-to-end authentication and authorization in our Blazor WASM (self-hosted) 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:

8-10am PT Tuesdays
8-10am PT Fridays

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

Top comments (25)

Collapse
 
woodpk profile image
woodpk • Edited

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

Collapse
 
christosmatskas profile image
Christos Matskas

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

Collapse
 
woodpk profile image
woodpk

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 ---

Thread Thread
 
christosmatskas profile image
Christos Matskas

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

Thread Thread
 
woodpk profile image
woodpk

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

Collapse
 
christosmatskas profile image
Christos Matskas

Updated... thanks for your feedback!

Collapse
 
gearsolutionschile profile image
gearsolutionschile

Hello, here in Chile. Make a blazor web assembly application connected to azure B2C but I have a problem sometime the token expires and if you give the page refresh an error like this appears.
Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer [100]
Unhandled exception rendering component: null_or_empty_id_token: The idToken is null or empty. Please review the trace to determine the root cause. Raw ID Token Value: undefined
ClientAuthError: null_or_empty_id_token: The idToken is null or empty. Please review the trace to determine the root cause. Raw ID Token Value: undefined
any ideas?

Collapse
 
thatmouse profile image
ThatMouse

Do you have a Blazor Server how-to? There are wildly different ideas out there on connecting to Azure AD. Some using a template/scaffolding approach which is hard to follow without starting a new project. Some using Microsoft.AspNetCore.Authentication.AzureADB2C.UI and some using Microsoft.Identity.Web.

Collapse
 
christosmatskas profile image
Christos Matskas

Hi @thatmouse - here's the official blog post we published on Blazor Server + AAD.
developer.microsoft.com/en-us/micr...

Our guidance is to use the Microsoft.Identity.Web going forward.

Collapse
 
thatmouse profile image
ThatMouse

Thank you, I will focus on that project, it's on github. It uses the new project template so it is not clear what magic that template does.

Collapse
 
danich93 profile image
Dan

Nice tutorial. The sad part about MSAL for webassembly at the moment is that it only really works out of the box for simple scenarios like this one. Currently it can't even support password reset without additional hacks. The same goes for profile edit/registration policy. Those simply don't work out of the box.

Collapse
 
mihaimyh profile image
Mihai Dumitru

Hi, is there a way to have custom roles defined in B2C and to be used within Blazor Wasm and Api? For example a role for an admin user which exposes other parts of the UI and can call specific API endpoints?

Collapse
 
christosmatskas profile image
Christos Matskas

B2C doesn't provide roles. However, JP has been working on a project to create a B2C compatible role-management solution. Check out our YT video on this: youtu.be/I_ddlSOHvwk

Collapse
 
lockhartlimited profile image
Lockhart Software

Hi Christos

Thanks for your excellent articles.

When will Microsoft.Identity.Web be available for Blazor WASM?

Thread Thread
 
christosmatskas profile image
Christos Matskas

Microsoft.Identity.Web is a server-side library and therefore can't be used in Blazor WASM which is client-side. Blazor WASM uses a flavor of @azure/msal-browser which is designed for JS (client-side apps). I hope this makes sense but feel free to reach out if you have more questions. Make sure to join our Discord as well for more Identity Qs aka.ms/425Show/discord/join

Thread Thread
 
lockhartlimited profile image
Lockhart Software

Thanks Christos. This makes sense.

Collapse
 
guillemsolersuetta profile image
guillem-soler-suetta

I want to implement a Login/Sigin in a Blazor Server App but they require that the UI needs to be from the same domain in other words that when I Sign In I don't want to use a template or Azure redirection. It has to reach for another Net Core Api that connects with Azure AD B2C. How this could be done? Some mentioned ROPC but Microsoft don't recommend it, and other pointed me to this samples github.com/Azure-Samples/active-di... but I don't know if one of those could be use to do what I need.

Thank you very much in advance.

Collapse
 
christosmatskas profile image
Christos Matskas

Hi @guillem, thanks for reaching out. If your front end app (Blazor Server) doesn't need call the api securely, then you can skip authentication in the front end all together. However, I'm struggling to understand your scenario. Are you planning on taking the user's credentials in your front end and pass them to the API? Is the plan for your API to authenticate and validate the users using B2C? Thanks, CM

Collapse
 
nhwilly profile image
nhwilly • Edited

Very cool stuff. Thank you for doing this. I am having a problem setting up the API as it is complaining about

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftWebApi(options =>
 {
Enter fullscreen mode Exit fullscreen mode

It can't find the AddMicrosoftWebApi method. I can't find any reference to it, either. Is there a new approach? I checked the github code and it hasn't changed.

I'm using: Assembly Microsoft.AspNetCore.Authentication, Version=5.0.0.0

Thanks in advance.

Edit: I loaded the old code and it's there, so it must have disappeared in a subsequent release.

Collapse
 
nhwilly profile image
nhwilly

I think I found it, but I don't know why it wasn't showing up earlier. I know I looked.

It's called AddMicrosoftIdentityWebApi now.

Collapse
 
finnurhrafn profile image
FinnurHrafn

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 :)

Collapse
 
christosmatskas profile image
Christos Matskas

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

Collapse
 
victorioberra profile image
Victorio Berra

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

Collapse
 
waterninja profile image
waterninja

Are you available for consulting Christos?
I just cannot get this to work.

Collapse
 
waterninja profile image
waterninja • Edited

I found the problems, after a lot of debugging, I finally got the project down to some sensible errors. In Program.cs, in the API, I mistyped my scope and I had a Json Deserialization problem with the new .Net 7 template on the Wasm side. Out of the box from the .Net 7 template setup, the WeatherForecast class uses a "DateOnly" property. GetFromJsonAsync failed when deserializing DateTime to DateOnly in FetchData.razor