Loading...

Implementing a Blazor-based BFF in Azure Container Apps

Image

Everyone loves a good frontend. Well, personally I'm more of a backend guy, but when I'm the end-user of a web app I like that it is intuitive, dynamic and all of that. Unfortunately while there's nothing inherently wrong with a server-side MVC-style web app often these requirements lead to coding client-side components because that offers more flexibility.

 

And what have we all learned about browser clients? They cannot be trusted to keep secrets or guarantee their integrity. (Which is why you also see server side frontend components.) My intent is not to devolve into arguments around which frameworks are better or all the knobs and levers that can be tweaked to make it right. Looking at it from a higher level a common architectural pattern that is often mentioned while discussing microservices-based apps is the Backend for Frontend, usually shortened BFF, pattern. (It isn't locked to being useful for microservices though.)

 

BFFs.jpg

 

 

 

As the term implies it's about doing "things" on the server to assist "things" on the client. You might say there are a few different options for what these things are in practice:

  • If you have multiple client types that retrieve infomation from an API you can present /api-web & /api-mobile to handle different needs server side to save on the bytes transferred to the respective client type.

  • You can proxy an API that is not to be exposed directly to an external network.

  • You can move sensitive actions like authentication to a trusted execution context.

 

The first bullet point can be solved in different ways whether it's exposing multiple endpoints or sending through an API gateway or other tricks. Which technically does not need to be solved through a BFF pattern at all. So, for this post I thought I would take a look at bullet point two and three.

 

If you are looking for a good (theoretical) treatment of the authentication parts I can recommend the "OAuth 2.0 for Browser-Based Apps" RFC:
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps

 

And if you're looking for more BFF variants than I could possibly cover here I can highly recommend Damien Bowden's GitHub repos:

https://github.com/damienbod

 

I'm not wrapping up the post that early though so what we're looking to cover here is trying to build a Blazor-based web app (with server and client code) along with the classic Weather API and deploy it to Azure Container Apps thus illustrating an end-to-end use case.

 

For the sake of full disclosure I did not start from scratch and have based myself on code from the dotnet team:

https://github.com/dotnet/blazor-samples/tree/main/8.0/BlazorWebAppOidcBff

 

That sample brings .NET Aspire along for the ride so you can do BFF locally and for that matter push to Azure as well. Right now the focus is not on what works and what doesn't in the Aspire (preview) and Azure Developer CLI combo; instead we will remove the "automagic" and figure out the details on our own. (I've detailed digging into Aspire separately and will probably do more with that at a later point in time.)

 

There are a couple of moving parts here so let's cover them step-by-step. The code can be found here: https://github.com/ahelland/Container-Apps-BFF

 

Container Apps Environment

We want to host our components as containers running in an Azure Container Apps Environment. Since I have covered this in greater detail previously I'm not repeating myself here, but I have included Bicep to create the necessary pieces. To simplify things I enabled the environment for public access, but the necessary pieces for making it private (vnet, private DNS zone, etc.) are still present so you can flip vnetInternal to true if you want to lock it down. (You need to have the necessary infra in place to access over the vnet like VPN, Dev Box or something.)

 

In the /infra folder open up playbook.dib and press play on Level-2 and Level-3 to deploy the container environment:

Playbook.png

 

After creating the Container App Environment you need to head to the Azure Portal and extract the custom domain - something like random.region.azurecontainerapps.io.

 

If you want to understand more details of this part of the setup: https://techcommunity.microsoft.com/t5/microsoft-developer-community/getting-started-with-infra-for-developers-in-azure/ba-p/4001520

 

Entra ID App Registration

Authentication usually leads to an app registration of some sort and in this case we will use Entra ID for that purpose. You can adapt the code to work with other identity providers as well, but since we're working with the Microsoft stack Entra is sort of the default choice. Like the dotnet sample this is not based on libraries specific to Entra. (There are of course reasons you might want use MSAL, Identity.Web, Graph SDK, etc. but for this demo it is not important.)

 

Head to the Entra or Azure Portal and start an app registration. (I happen to know that the Redirect URI is correct for this sample, but otherwise you would need to fire up your app locally and find out.)

 

App_Reg_01.png

The localhost redirect works locally, but you should also add one for the container app we are about to deploy. (With the name of the app and the CAE domain you can predict this in advance.)

App_Reg_02.png

 

You need to "Expose an API" to request a scope

App_Reg_03.png

 

Add a scope called "Weather.Get":

App_Reg_04.png

 

Should look like this afterwards:

App_Reg_05.png

 

To be able to call into Entra from the server side you need to generate a secret:
App_Reg_06.png

 

Before heading over to Visual Studio you should have the following variables ready:

  • TenantID
  • ClientID
  • ClientSecret

Weather API

The default API in the Visual Studio templates is one that serves up weather predictions and for our purposes this will suffice. Let's just step through the template wizard in Visual Studio:

 

WeatherAPI_01.png

WeatherAPI_02.png

WeatherAPI_03.png

Add the Microsoft.AspNetCore.Authentication.JwtBearer package through NuGet:

WeatherAPI_04.png

 

The modifications needed are basically just adding auth. All logic is directly embedded in Program.cs:

var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication() .AddJwtBearer("Bearer", jwtOptions => { // The API does not require an app registration of its own, but it does require a registration for the calling app. // These attributes can be found in the Entra ID portal when registering the client. jwtOptions.Authority = "https://sts.windows.net/{TENANT ID}/"; jwtOptions.Audience = "api://{CLIENT ID}"; }); builder.Services.AddAuthorization(); var app = builder.Build(); app.UseHttpsRedirection(); var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; app.MapGet("/weather-forecast", () => { Console.WriteLine("You got hit"); var forecast = Enumerable.Range(1, 5).Select(index => new WeatherForecast ( DateOnly.FromDateTime(DateTime.Now.AddDays(index)), Random.Shared.Next(-20, 55), summaries[Random.Shared.Next(summaries.Length)] )) .ToArray(); return forecast; }).RequireAuthorization(); app.Run(); internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) { public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); }

 

Since our API is as minimal as it is you can embed the validation parameters directly in the code and you don't have to add steps for having one version locally and another for running in the cloud. (You need to plug in the tenant ID and client ID to validate the tokens specific to your tenant.) The API just sees inbound http requests with a token attached and is less interested in how the token is aquired and how the traffic finds its way.

 

It is however useful for the local debugging to do dotnet run on the command line and take note of the value (http://localhost:5041) for the next part:

 

WeatherAPI_05.png

 

Web App

I'm no designer when it comes to frontends, but for the fun of it I thought I would use Fluent UI for Blazor. (If you want to do the exercise with the out of box Blazor components the rest of the code behaves the same - no hard dependencies here.)

 

You would need to install Fluent UI if you don't have it already by executing the following command:

dotnet new install Microsoft.FluentUI.AspNetCore.Templates

 

More info can be found here:

https://www.fluentui-blazor.net/

 

While there is nothing wrong with having the API and the BFF as part of the same solution (as separate projects) I wanted to have the two completely separate for the purpose of illustration so for the BFF let's run our Visual Studio wizards again:

BFF_01.png

BFF_02.png

BFF_03.png

 

I copied across  some files from the dotnet sample:

BFF_Web_App

CookieOidcRefresher.cs

CookieOidcServiceCollectionExtensions.cs

LoginLogoutEndpointRouteBuilderExtensions.cs

PersistingAuthenticationStateProvider.cs

ServerWeatherForecaster.cs

 

BFF_Web_App.Client

PersistentAuthenticationStateProvider.cs

RedirectToLogin.razor

UserInfo.cs

/Layout/LogInOrOut.razor

/Pages/UserClaims.razor

/Weather/ClientWeatherForecaster.cs

/Weather/IWeatherForecaster.cs

/Weather/WeatherForecast.cs

 

I changed the namespaces accordingly of course.

 

We also need some Nuget packages:

Server

Microsoft.AspNetCore.Authentication.OpenIdConnect

Microsoft.Extensions.Hosting

Microsoft.Extensions.Hosting.Abstractions

Microsoft.Extensions.ServiceDiscovery.Yarp

Microsoft.IdentityModel.Protocols.OpenIdConnect

 

Client

Microsoft.AspNetCore.Components.Authorization

 

The "meaty" part of the auth is in Program.cs (server):

builder.Services.AddAuthentication("MicrosoftOidc") .AddOpenIdConnect("MicrosoftOidc", oidcOptions => { oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; oidcOptions.CallbackPath = new PathString("/signin-oidc"); //oidcOptions.SignedOutCallbackPath = new PathString("/signout-callback-oidc"); oidcOptions.Scope.Add("api://{CLIENT ID}/Weather.Get"); oidcOptions.Authority = "https://login.microsoftonline.com/{TENANT ID}/v2.0/"; oidcOptions.ClientId = "{CLIENT ID}"; oidcOptions.ClientSecret = "{CLIENT SECRET}"; oidcOptions.ResponseType = OpenIdConnectResponseType.Code; oidcOptions.MapInboundClaims = false; oidcOptions.TokenValidationParameters.NameClaimType = JwtRegisteredClaimNames.Name; oidcOptions.TokenValidationParameters.RoleClaimType = "role"; }) .AddCookie("Cookies"); ... if (app.Environment.IsDevelopment()) { app.MapForwarder("/weather-forecast", "https://localhost:5041", transformBuilder => { transformBuilder.AddRequestTransform(async transformContext => { var accessToken = await transformContext.HttpContext.GetTokenAsync("access_token"); transformContext.ProxyRequest.Headers.Authorization = new("Bearer", accessToken); }); }).RequireAuthorization(); } else { app.MapForwarder("/weather-forecast", "http://weatherapi", transformBuilder => { transformBuilder.AddRequestTransform(async transformContext => { var accessToken = await transformContext.HttpContext.GetTokenAsync("access_token"); transformContext.ProxyRequest.Headers.Authorization = new("Bearer", accessToken); }); }).RequireAuthorization(); }

I think we can all agree that putting the client secret directly into the code is bad in so many ways, and the other identifiers while not as sensitive should also be moved out. Again, demo code not production code.

 

If you run the web app now you might get it to work, but you would not actually be invoking the auth yet since we only have the plumbing in place for that.

 

You might be thinking - why are we doing a check for which environment we are running in and switching inline between http://weatherapp & http://localhost - that looks hackish and should probably be moved into appsettings.json or something. I agree, but I did it inline for illustration purposes. This is actually one of the things .NET Aspire sets out to solve for you in a better manner. Here it highlights why it can be a hassle to develop authentication.

 

So, let's do some editing in the views. Since this is a Blazor app supporting rendering client and server side we can put the views in the project that matches our needs.

 

The home page should be available without logging in:

@PAGE "/" <PageTitle>BFF - Home</PageTitle> <h1>Hello, world!</h1> Welcome to your new Blazor-based BFF.

Taking things a step further we modify the Counter view so that only users who are logged in will be able to use the counter and the rest will get an informational message. This also means that even if we don't display a link to the page anonymous users can access this page by appending /counter to the url in the browser manually. Which is a valid and useful scenario. This demos the "no secrets in browser", but renders completely client side.

@PAGE "/counter" @rendermode InteractiveAuto @using Microsoft.AspNetCore.Authorization <PageTitle>Counter</PageTitle> <h1>Counter</h1> <AuthorizeView> <Authorized> <div role="status" style="padding-bottom: 1em;"> Current count: <FluentBadge Appearance="Appearance.Neutral">@currentCount</FluentBadge> </div> <FluentButton Appearance="Appearance.Accent" @onclick="IncrementCount">Click me</FluentButton> </Authorized> <NotAuthorized> <p>Only users who are logged in can count.</p> </NotAuthorized> </AuthorizeView> @code { private int currentCount = 0; private void IncrementCount() { currentCount++; } }

We don't have option to login yet, but as you can see the engine can figure out if we are logged in or not. (Cookies behind the scenes.)

 

The /Weather page might seem an odd duck at first. Serverside (Weather.razor) we have the following:

@PAGE "/weather" @using Microsoft.AspNetCore.Authorization @using BFF_Web_App.Client.Weather; @attribute [Authorize] @attribute [StreamRendering] @inject IWeatherForecaster WeatherForecaster <PageTitle>Weather</PageTitle> <h1>Weather</h1> <p>This component demonstrates showing data.</p> @if (forecasts == null) { <p><em>Loading...</em></p> } else { <!-- This page is rendered in SSR mode, so the FluentDataGrid component does not offer any interactivity (like sorting). --> <FluentDataGrid Id="weathergrid" Items="@items" GridTemplateColumns="1fr 1fr 1fr 2fr" TGridItem="WeatherForecast"> <PropertyColumn Title="Date" Property="@(c => c!.Date)" Align="Align.Start"/> <PropertyColumn Title="Temp. (C)" Property="@(c => c!.TemperatureC)" Align="Align.Center"/> <PropertyColumn Title="Temp. (F)" Property="@(c => c!.TemperatureF)" Align="Align.Center"/> <PropertyColumn Title="Summary" Property="@(c => c!.Summary)" Align="Align.End"/> </FluentDataGrid> } @code { private IQueryable<WeatherForecast>? items; private IEnumerable<WeatherForecast>? forecasts; protected override async Task OnInitializedAsync() { forecasts = await WeatherForecaster.GetWeatherForecastAsync(); items = Queryable.AsQueryable(forecasts); } }

This will both require you to be logged in and automatically send you to the login page if you are not authenticated. Attempting to browse to /weather manually will not escape this.

 

You will however notice that it is a two-parter. The client-side uses ClientWeatherForecaster to "bounce" the traffic to the server component - from Program.cs:

builder.Services.AddHttpClient<IWeatherForecaster, ClientWeatherForecaster>(httpClient => { httpClient.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress); });

The server in turn checks if you have a token and proxies the request to the API (as shown in the MapForwarder piece in Program.cs a few paragraphs above):

public async Task<IEnumerable<WeatherForecast>> GetWeatherForecastAsync() { var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HttpContext available from the IHttpContextAccessor!"); var accessToken = await httpContext.GetTokenAsync("access_token") ?? throw new InvalidOperationException("No access_token was saved"); using var requestMessage = new HttpRequestMessage(HttpMethod.Get, "/weather-forecast"); requestMessage.Headers.Authorization = new("Bearer", accessToken); using var response = await httpClient.SendAsync(requestMessage); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync<WeatherForecast[]>() ?? throw new IOException("No weather forecast!"); }

I would agree that it's a code path that is hard to follow at first glance, but at the same time the flow isn't entirely illogical from a higher level.

 

So far so good. But no actual authentication yet?

 

We have a LogInOrOut.razor component client side which links to login:

<NotAuthorized> <FluentNavLink Href="authentication/login" Icon="@(new Icons.Regular.Size20.Key())" IconColor="Color.Accent">Login</FluentNavLink> </NotAuthorized>

Which in turn sends you to the server side (LoginLogoutEndpointRouteBuilderExtensions.cs) for handling the redirects (since in our case the actual authentication happens in Entra ID):

group.MapGet("/login", (string? returnUrl) => TypedResults.Challenge(GetAuthProperties(returnUrl))) .AllowAnonymous();

.NET has standard components for handling the state of auth ("hey you have signed in - let me handle cookies, sessions and stuff for you") which is implemented in PersistingAuthenticationStateProvider.cs which exists both in the server and the client component so they can share the state. AuthenticationStateProvider isn't specific to BFFs or .NET 8 for that matter - it's been around for a while. Dig into the specifics if you want - we'll treat it as an out of box feature here :)

 

In addition there is a /UserClaims for listing out the claims in your token, and some UI components in a NavMenu for being able to logout, only present some items when logged in, etc. And of course there are some other files involved in making a running app as well, but reading through all the code is left as an exercise for the reader.

 

Deploying API and JWT

You can(and should) hit F5 or execute the dotnet run command to verify locally before pushing to the cloud, but assuming you didn't hit a snag you will want to deploy the apps. You can do a publish directly to Container Apps from Visual Studio if you like, but I have included Bicep code for deployment as well (which I recommend). To deploy with Bicep you should publish to the container registry (that was deployed earlier on) first. The difference between publish to registry vs environment is that to publish to environment you need to enable admin user on the registry versus making it work with managed identity when only pushing an image. (Not enabling the admin user is what you want to do in general.) Plus the detail that I'm setting an environment variable for the web app that you would have to do through the Portal otherwise.

 

It's fairly easy to get working, but the steps looks like this:

Publish_01.png

 

Yes, it's a Docker registry even if it's in Azure.

Publish_02.png

 

Select your registry:

Publish_03.png

 

Both options should work (I added a Dockerfile to the API and went with .NET SDK for the web app).

Publish_04.png

 

Go back to the "playbook" afterwards and execute level 4 to roll out.

 

If you managed to get everything right you should be able to log in and get this (random) weather prediction:

Web_App_01.png

A very exciting app it is not, but to the user it is transparent how we are doing things behind the scenes. The Web App has a regular ingress enabled so you're able to access it, but the API has been locked down to only be accessible within the container environment. The container of the web app runs in the same environment so through service discovery the two are able to communicate internally. The API still validates the token so there's no unauthenticated access, but of course you may have more fine-grained needs in a larger solution. That is however out of scope for this post.

 

What if you have a "bigger" app, or you don't want to do the frontend in Blazor? This implementation focuses on an integrated web app, but the pattern would apply for other use cases as well. The essential part is forcing the authentication and/or API call to proxy through a server side app and this can be done from other frameworks as well.

 

How about Azure API Management or other API gateways - is that better or worse than this? To be clear - the BFF pattern is not intended to replace the API gateway pattern. What we have here is a simple API that is only relevant to our app. Azure APIM has a ton of controls for offering APIs at scale whether it's throttling the traffic, translating/transforming content types, offering a developer portal and more. That could extend into another discussion on your cloud architecture whereas this is about the architecture of an individual application.

 

One could add additional backends, scopes, identity providers, etc. but for this installment I believe we're good for now :)

 

Link to code: https://github.com/ahelland/Container-Apps-BFF 

Learn more
Author image

Azure Developer Community Blog articles

Azure Developer Community Blog articles

Share post:

Related

Stay up to date with latest Microsoft Dynamics 365 and Power Platform news!

* Yes, I agree to the privacy policy