Using multiple APIs in Blazor with Azure AD 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/AzureADAuthRazorUiServiceApiCertificate

Posts in this series

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.

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 Azure AD client. The application requires user secrets for the protected downstream APIs.

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

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <UserSecretsId>BlazorAzureADWithApis.Server-B86B9EF3-5CCE-46B7-A115-E5D3ACB43477</UserSecretsId>
    <WebProject_DirectoryAccessLevelKey>1</WebProject_DirectoryAccessLevelKey>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\Client\BlazorAzureADWithApis.Client.csproj" />
    <ProjectReference Include="..\Shared\BlazorAzureADWithApis.Shared.csproj" />
  </ItemGroup>
  
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.1" />
    <PackageReference Include="Microsoft.Identity.Web.MicrosoftGraphBeta" Version="1.4.0" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.1" NoWarn="NU1605" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.1" NoWarn="NU1605" />
    <PackageReference Include="Microsoft.Identity.Web" Version="1.4.0" />
    <PackageReference Include="Microsoft.Identity.Web.UI" Version="1.4.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.

public void ConfigureServices(IServiceCollection services)
{
	services.AddScoped<GraphApiClientService>();
	services.AddScoped<ServiceApiClientService>();
	services.AddScoped<UserApiClientService>();

	services.AddMicrosoftIdentityWebApiAuthentication(Configuration)
		 .EnableTokenAcquisitionToCallDownstreamApi()
		 .AddInMemoryTokenCaches();

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

	services.AddAuthorization(options =>
	{
		options.AddPolicy("ValidateAccessTokenPolicy", validateAccessTokenPolicy =>
		{
			// Validate ClientId from token
			// only accept tokens issued ....
			validateAccessTokenPolicy.RequireClaim("azp", "ad6b0351-92b4-4ee9-ac8d-3e76e5fd1c67");
		});
	});

	services.AddControllersWithViews();
	services.AddRazorPages();
}

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

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
	// ...

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

	app.UseRouting();

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

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapRazorPages();
		endpoints.MapControllers();
		endpoints.MapFallbackToFile("index.html");
	});
}

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 System.Collections.Generic;
using System.Threading.Tasks;
using BlazorAzureADWithApis.Server.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Web.Resource;

namespace BlazorAzureADWithApis.Server.Controllers
{
    [Authorize(Policy = "ValidateAccessTokenPolicy", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    [ApiController]
    [Route("[controller]")]
    public class DelegatedUserApiCallsController : ControllerBase
    {
        private UserApiClientService _userApiClientService;
        static readonly string[] scopeRequiredByApi = new string[] { "access_as_user" };

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

        [HttpGet]
        public async Task<IEnumerable<string>> Get()
        {
            HttpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi);
            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;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading.Tasks;

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 data = await JsonSerializer.DeserializeAsync<List<string>>(
                    await response.Content.ReadAsStreamAsync());

                return data;
            }

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

Blazor Client

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

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

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="5.0.1" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="5.0.1" PrivateAssets="all" />
    <PackageReference Include="Microsoft.Authentication.WebAssembly.Msal" Version="5.0.1" />
    <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
    <PackageReference Include="System.Net.Http.Json" Version="5.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;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

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.

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
        <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>

The RedirectToLogin component is used to redirect to the authentication provider, in our case Azure AD.

@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 Azure AD. 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 Azure AD 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 acces token for this API. The delegated access token then calls the downstream API using the delegated acces 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

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

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

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

<span>%d</span> bloggers like this: