Using multiple APIs in Blazor with Microsoft Entra ID authentication

The post shows how to create a Blazor application which is hosted in an ASP.NET Core application and provides a public API which uses multiple downstream APIs. Both the Blazor client and the Blazor API are protected by Azure AD authentication. The Blazor UI Client is protected like any single page application. This is a public client which cannot keep a secret.

Each downstream API uses a different type of access token in this demo. One API delegates to a second API using the on behalf of flow. The second API uses a client credentials flow for APP to APP access and the third API uses a delegated Graph API. Only the API created for the Blazor WASM application is public. All other APIs require a secret to access the API. A certificate could also be used instead of a secret.

Code: https://github.com/damienbod/MicrosoftEntraIDAuthMicrosoftIdentityWeb

Posts in this series

History

  • 2023-11-29 Updated to .NET 8

Setup

The applications are setup very similar to the previous post in this series. Four Azure App Registrations are setup for the different applications. The Blazor WASM client uses a public SPA Azure App registration. This has one API exposed here, the access_as_user scope from the Blazor server Azure App registration. The WASM SPA has no access to the further downstream APIs. We want to have as few as possible access tokens in the browser. The Blazor Server application uses a secret to access the downstream APIs which are exposed in the API Azure App registration.

NOTE: It is no longer recommended to implement security in the public client. BFF architecture is now the recommended way to implement authentication and OIDC flows.

Blazor Server

The Blazor server (API) and client (UI) applications were setup using the Visual Studio templates. The Client application is hosted as part of the server and so deployed together. The Blazor server application is otherwise a simple API project. The API uses Microsoft.Identity.Web as the Microsoft Entra ID client. The application requires user secrets for the protected downstream APIs.

<Project Sdk="Microsoft.NET.Sdk.Web">

	<PropertyGroup>
		<TargetFramework>net8.0</TargetFramework>
		<Nullable>enable</Nullable>
		<UserSecretsId>7b7a3ab3-3ad6-4820-a521-dcdaf28f15cb</UserSecretsId>
		<ImplicitUsings>enable</ImplicitUsings>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.0" />
	</ItemGroup>

	<ItemGroup>
		<ProjectReference Include="..\Client\BlazorAzureADWithApis.Client.csproj" />
		<ProjectReference Include="..\Shared\BlazorAzureADWithApis.Shared.csproj" />
	</ItemGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.0" NoWarn="NU1605" />
		<PackageReference Include="Microsoft.Identity.Web.GraphServiceClient" Version="2.16.0" />
		<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="13.4.0" />
		<PackageReference Include="Microsoft.Identity.Web" Version="2.16.0" />
		<PackageReference Include="Microsoft.Identity.Web.UI" Version="2.16.0" />
		<PackageReference Include="NetEscapades.AspNetCore.SecurityHeaders" Version="0.21.0" />
	</ItemGroup>

</Project>

The ConfigureServices method adds the required services for the Azure AD API authorization. The access to the downstream APIs are implemented as scoped services. The ValidateAccessTokenPolicy policy is used to validate the access token used for the public API in this project. This is the API which the Blazor WASM client uses.

var services = builder.Services;
var configuration = builder.Configuration;
var env = builder.Environment;

services.AddScoped<MsGraphDelegatedService>();
services.AddScoped<MsGraphApplicationService>();
services.AddTransient<IClaimsTransformation, GraphApiClaimsTransformation>();
services.AddScoped<CaeClaimsChallengeService>();

services.AddAntiforgery(options =>
{
    options.HeaderName = "X-XSRF-TOKEN";
    options.Cookie.Name = "__Host-X-XSRF-TOKEN";
    options.Cookie.SameSite = SameSiteMode.Strict;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});

services.AddHttpClient();
services.AddOptions();

string[]? initialScopes = configuration.GetValue<string>("UserApiOne:ScopeForAccessToken")?.Split(' ');

services.AddMicrosoftIdentityWebAppAuthentication(configuration)
    .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
    .AddMicrosoftGraph()
    .AddInMemoryTokenCaches();

services.AddControllersWithViews(options =>
    options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));

services.AddAuthorization(options =>
{
    // By default, all incoming requests will be authorized according to the default policy
    options.FallbackPolicy = options.DefaultPolicy;
    options.AddPolicy("DemoAdmins", Policies.DemoAdminsPolicy());
    options.AddPolicy("DemoUsers", Policies.DemoUsersPolicy());
});

services.AddRazorPages().AddMvcOptions(options =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
}).AddMicrosoftIdentityUI();

The Configure method adds the middleware for the APIs like any ASP.NET Core API. It also adds the middleware for the Blazor UI.

var app = builder.Build();

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseWebAssemblyDebugging();
}
else
{
    app.UseExceptionHandler("/Error");
}

app.UseSecurityHeaders(
    SecurityHeadersDefinitions.GetHeaderPolicyCollection(env.IsDevelopment(),
        configuration["AzureAd:Instance"]));

app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();

app.UseRouting();

app.UseNoUnauthorizedRedirect("/api");

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();
app.MapControllers();
app.MapNotFound("/api/{**segment}");
app.MapFallbackToPage("/_Host");

app.Run();

A user secret is required for the protected downstream APIs. These APIs cannot be accessed from the public Blazor UI. The less access tokens you use in the public zone, the better.

{ 
  "AzureAd": { 
    "ClientSecret": "your client secret from the API App registration"
   } 
}

The DelegatedUserApiCallsController is the API which can be used to access the downstream API. This API accepts access tokens which the Blazor UI requested. The controller calls the API services for further API calls, in this case a delegated user API request.

using BlazorAzureADWithApis.Server.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Web;

namespace BlazorAzureADWithApis.Server.Controllers;

[Authorize(Policy = "ValidateAccessTokenPolicy", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[AuthorizeForScopes(Scopes = new string[] { "api://2b50a014-f353-4c10-aace-024f19a55569/access_as_user" })]
[ApiController]
[Route("[controller]")]
public class DelegatedUserApiCallsController : ControllerBase
{
    private readonly UserApiClientService _userApiClientService;

    public DelegatedUserApiCallsController(UserApiClientService userApiClientService)
    {
        _userApiClientService = userApiClientService;
    }

    [HttpGet]
    public async Task<IEnumerable<string>?> Get()
    {
        return await _userApiClientService.GetApiDataAsync();
    }
}

The service uses the IHTTPClientFactory to manage the HttpClient connections. The ITokenAcquisition interface is used to get the access tokens for the downstream API using the correct scope. The downstream APIs are implemented to require a secret.

using Microsoft.Identity.Web;
using System.Net.Http.Headers;
using System.Text.Json;

namespace BlazorAzureADWithApis.Server.Services;

public class UserApiClientService
{
    private readonly IHttpClientFactory _clientFactory;
    private readonly ITokenAcquisition _tokenAcquisition;

    public UserApiClientService(
        ITokenAcquisition tokenAcquisition,
        IHttpClientFactory clientFactory)
    {
        _clientFactory = clientFactory;
        _tokenAcquisition = tokenAcquisition;
    }

    public async Task<IEnumerable<string>?> GetApiDataAsync()
    {
        var client = _clientFactory.CreateClient();

        var scopes = new List<string> { "api://b2a09168-54e2-4bc4-af92-a710a64ef1fa/access_as_user" };
        var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes);

        client.BaseAddress = new Uri("https://localhost:44395");
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

        var response = await client.GetAsync("ApiForUserData");
        if (response.IsSuccessStatusCode)
        {
            var stream = await response.Content.ReadAsStreamAsync();
            var payload = await JsonSerializer.DeserializeAsync<List<string>>(stream);

            return payload;
        }

        throw new ApplicationException("oh no...");
    }
}

Blazor Client

The Blazor Client project implements the WASM UI. This project uses the Microsoft.Authentication.WebAssembly.Msal to authenticate against Microsoft Entra ID.

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.Authentication.WebAssembly.Msal" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
    <PackageReference Include="System.Net.Http.Json" Version="8.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Shared\BlazorAzureADWithApis.Shared.csproj" />
  </ItemGroup>

  <ItemGroup>
    <ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
  </ItemGroup>

</Project>

The Program class builds the services for the WASM application in the static Main method. The scope which will be used to access the API for this WASM client is defined here. The IHttpClientFactory is used to create HttpClient instances for the API calls. The app.settings.json configuration is saved in the wwwroot folder. The BlazorAzureADWithApis.ServerAPI HttpClient uses the BaseAddressAuthorizationMessageHandler to add the access tokens in the Http Header for the API calls.

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

namespace BlazorAzureADWithApis.Client;

public class Program
{
    public static async Task Main(string[] args)
    {
        var builder = WebAssemblyHostBuilder.CreateDefault(args);
        builder.RootComponents.Add<App>("#app");

        builder.Services.AddHttpClient("BlazorAzureADWithApis.ServerAPI", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
            .AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();

        // Supply HttpClient instances that include access tokens when making requests to the server project
        builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("BlazorAzureADWithApis.ServerAPI"));

        builder.Services.AddMsalAuthentication(options =>
        {
            builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
            options.ProviderOptions.DefaultAccessTokenScopes.Add("api://2b50a014-f353-4c10-aace-024f19a55569/access_as_user");
        });

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

The App.razor component defines how the application should login and the component to use for this.

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

The RedirectToLogin component is used to redirect to the authentication provider, in our case Microsoft Entra ID.

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

A razor component can then be used to call the API client which uses the access token acquired from Microsoft Entra ID. This uses the HttpClient which was defined in the Main method and uses the BaseAddressAuthorizationMessageHandler.

@page "/delegateduserapicall"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@attribute [Authorize]
@inject HttpClient Http

<h1>Data from Delegated User API</h1>

@if (apiData == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Data</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var data in apiData)
            {
                <tr>
                    <td>@data</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private string[]? apiData;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            apiData = await Http.GetFromJsonAsync<string[]>("DelegatedUserApiCalls");
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }
    }
}

Running the code

Start the three applications from Visual Studio and click the login link. A popup will open and you can login to Microsoft Entra ID and give your consent for this client. Before this will work, you will need to setup your own Azure App registrations and set the configurations in the projects. Also add your user secret to the Blazor Server, User and Service API projects.

The API can be called using the access token for this API. The delegated access token then calls the downstream API using the delegated access token and the data is returned.

Links

https://docs.microsoft.com/en-us/aspnet/core/blazor/security

https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/hosted-with-azure-active-directory

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki

https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-protected-web-api-verification-scope-app-roles

11 comments

  1. […] Using multiple APIs in Blazor with Azure AD authentication (Damien Bowden) […]

  2. […] Using multiple APIs in Blazor with Azure AD authentication – Damien Bowden […]

  3. Dimitri's avatar
    Dimitri · · Reply

    Hi Damien,

    thank you so much for the series, it is very documented and helpful !
    Are you planning to make one around the usage of Azure API management as well ?

    1. damienbod's avatar

      Hi Dimitri

      thanks,

      maybe, I was thinking about this, wondering about the use case of API management, where it would add value, reduce the overall costs or increase the quality of the solution.

      Greetings Damien

  4. Mathias Fritsch's avatar
    Mathias Fritsch · · Reply

    Hi Damien,

    nice post! It realy helped me.

    Where is claim azp configured? Is it part of the client app registration or the server API registration? I think some screenshots of the azure AD configuration pages would help

    Mathias

  5. Jean.R (@JeanR99103970)'s avatar

    Hi Damien, great post ! Same here i don’t get how to get the azp claim in Azure ad o_0
    i replace it with “appid” for the check, but i must admit that i’m not sure if it’s equal for the security…

    1. damienbod's avatar

      Hi Jean.R, thanks

      I guess you use the version 1 API, open the manifest file of the Azure App registration and check th<t version 2 is set

      Greetings Damien

  6. Liam Phillips's avatar
    Liam Phillips · · Reply

    Hi Damien, Great post. I’m trying something along these lines where I have a Blazor WASM with Server API (accessing SQL Azure using EF) and I also need to Call the GraphAPI. I’ve set up the Client (SPA) and Server API (Web) App registrations and can authenticate successfully with our AAD but keep getting 403 Forbidden when I try to call the Server API (similar to your Direct API call) and also when trying to call Graph. API. Am I missing something in terms of configurations on the App Reg’s?

    1. damienbod's avatar

      Hi Liam you need to use V2 access tokens and give consent in the App registration. Depending on the app type, you use a Spa type (no secret) or Web (with secret/certificate) and consent needs to be granted. See if the Microsoft.Identity.Web WIKI docs in github help, this describes setting up the App registration. Only delegated scopes can be used. Let me know if this works, otherwise maybe we could share manifests and I can compare.

      Greetings Damien

      1. Liam Phillips's avatar
        Liam Phillips · ·

        Thanks for the quick reply Damien. Appreciated.
        In my Server API App Reg. (which is of type Web Platform) I have delegated access to User.Read and User.Read.All and given admin consent to both. I have enabled both access tokens and id tokens. Relatively new to all this so not sure how to go about using V2 access tokens. Can’t find much online about it. I have no problem sharing app reg manifests if you want to take a look . Just let me know how you would like to go about it .

        Thanks again!

      2. Liam Phillips's avatar
        Liam Phillips · ·

        Hi Damien, Tried a few more options over the weekend with different combinations of settings on the app registrations but still getting 403 when trying to access anything that requires a token. I can see the bearer token being assigned in my dev tools once I’m authenticated with AAD but even when I try to use it in Swagger UI with the API I still get 403. Any ideas ? Really need to get this working for a project I’m working so any help you can provide would be appreciated !

Leave a reply to Dimitri Cancel reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.