Implement a PWA using Blazor with BFF security and Azure B2C

The article shows how to implement a progressive web application (PWA) using Blazor which is secured using the backend for frontend architecture and Azure B2C as the identity provider.

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

Setup and challenges with PWAs

The application is setup to implement all security in the trusted backend and reduce the security risks of the overall software. We use Azure B2C as an identity provider. When implementing and using BFF security architecture, cookies are used to secure the Blazor WASM UI and its backend. Microsoft.Identity.Web is used to implement the authentication as recommended by Microsoft for server rendered applications. Anti-forgery tokens as well as all the other cookie protections can be used to reduce the risk of CSRF attacks. This requires that the WASM application is hosted in an ASP.NET Core razor page and the dynamic data can be added. With PWA applications, this is not possible. To work around this, CORS preflight and custom headers can be used to protect against this as well as same site. The anti-forgery cookies need to be removed to support PWAs. Using CORS preflight has some disadvantages compared to anti-forgery cookies but works good.

Setup Blazor BFF with Azure B2C for PWA

The application is setup using the Blazor.BFF.AzureB2C.Template Nuget package. This uses anti-forgery cookies. All of the anti-forgery protection can be completely removed. The Azure App registrations and the Azure B2C user flows need to be setup and the application should work (without PWA support).

To setup the PWA support, you need to add an index.html file to the wwwroot of the Blazor client and a service worker JS script to implement the PWA. The index.html file adds what is required and the serviceWorkerRegistration.js script is linked.

<!DOCTYPE html>
<html>
<!-- PWA / Offline Version -->
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <base href="/" />
    <title>PWA Blazor Azure B2C Cookie</title>
    <base href="~/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="BlazorHosted.Client.styles.css" rel="stylesheet" />
    <link href="manifest.json" rel="manifest" />
    <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />

    <body>
        <div id="app">
            <div class="spinner d-flex align-items-center justify-content-center spinner">
                <div class="spinner-border text-success" role="status">
                    <span class="sr-only">Loading...</span>
                </div>
            </div>
        </div>

        <div id="blazor-error-ui">
            An unhandled error has occurred.
            <a href="" class="reload">Reload</a>
            <a class="dismiss">🗙</a>
        </div>

        <script src="_framework/blazor.webassembly.js"></script>
        <script src="serviceWorkerRegistration.js"></script>
    </body>

</html>

The serviceWorker.published.js script is pretty standard except that the OpenID Connect redirects and signout URLs need to be excluded from the PWA and always rendered from the trusted backend. The registration script references the service worker so that the inline Javascript is removed from the html because we do not allow unsafe inline scripts anywhere in an application if possible.

navigator.serviceWorker.register('service-worker.js');

The service worker excludes all the required authentication URLs and any other required server URLs. The published script registers the PWA.

Note: if you would like to test the PWA locally without deploying the application, you can reference the published script directly and it will run locally. You need to be carefully testing as the script and the cache needs to be emptied before testing each time.

// Caution! Be sure you understand the caveats before publishing an application with
// offline support. See https://aka.ms/blazor-offline-considerations

self.importScripts('./service-worker-assets.js');
self.addEventListener('install', event => event.waitUntil(onInstall(event)));
self.addEventListener('activate', event => event.waitUntil(onActivate(event)));
self.addEventListener('fetch', event => event.respondWith(onFetch(event)));

const cacheNamePrefix = 'offline-cache-';
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
const offlineAssetsInclude = [/\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/];
const offlineAssetsExclude = [/^service-worker\.js$/];

async function onInstall(event) {
    console.info('Service worker: Install');

    // Fetch and cache all matching items from the assets manifest
    const assetsRequests = self.assetsManifest.assets
        .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))
        .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))
        .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' }));

    await caches.open(cacheName).then(cache => cache.addAll(assetsRequests));
}

async function onActivate(event) {
    console.info('Service worker: Activate');

    // Delete unused caches
    const cacheKeys = await caches.keys();
    await Promise.all(cacheKeys
        .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
        .map(key => caches.delete(key)));
}

async function onFetch(event) {
    let cachedResponse = null;
    if (event.request.method === 'GET') {
        // For all navigation requests, try to serve index.html from cache
        // If you need some URLs to be server-rendered, edit the following check to exclude those URLs
        const shouldServeIndexHtml = event.request.mode === 'navigate'
            && !event.request.url.includes('/signin-oidc')
            && !event.request.url.includes('/signout-callback-oidc')
            && !event.request.url.includes('/api/Account/Login')
            && !event.request.url.includes('/api/Account/Logout')
            && !event.request.url.includes('/HostAuthentication/');

        const request = shouldServeIndexHtml ? 'index.html' : event.request;
        const cache = await caches.open(cacheName);
        cachedResponse = await cache.match(request);
    }

    return cachedResponse || fetch(event.request, { credentials: 'include' });
}

The ServiceWorkerAssetsManifest definition needs to be added to the client project.

<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>

Now the PWA should work. The next step is to add the extra CSRF protection.

Setup CSRF protection using CORS preflight

CORS preflight can be used to protect against CSRF as well as same site. All API calls should include a custom HTTP header and this needs to be controlled on the APIs that the header exists.

The can be implemented in the Blazor WASM client by using a CSRF middleware protection.

public class CsrfProtectionCorsPreflightAttribute : ActionFilterAttribute
{
	public override void OnActionExecuting(ActionExecutingContext context)
	{
		var header = context.HttpContext
			.Request
			.Headers
			.Any(p => p.Key.ToLower() == "x-force-cors-preflight");
			
		if (!header)
		{
			// "X-FORCE-CORS-PREFLIGHT header is missing"
			context.Result = new UnauthorizedObjectResult("X-FORCE-CORS-PREFLIGHT header is missing");
			return;
		}
	}
}

In the Blazor client, the middleware can be added to all HttpClient instances used in the Blazor WASM.

builder.Services.AddHttpClient("default", client =>
{
	client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
	client.DefaultRequestHeaders
		.Accept
		.Add(new MediaTypeWithQualityHeaderValue("application/json"));
		
}).AddHttpMessageHandler<CsrfProtectionMessageHandler>(); 

builder.Services.AddHttpClient("authorizedClient", client =>
{
	client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
	client.DefaultRequestHeaders
		.Accept
		.Add(new MediaTypeWithQualityHeaderValue("application/json"));
		
}).AddHttpMessageHandler<AuthorizedHandler>()
.AddHttpMessageHandler<CsrfProtectionMessageHandler>();

The CSRF CORS preflight header can be validated using an ActionFilter in the ASP.NET Core backend application. This is not the only way of doing this. The CsrfProtectionCorsPreflightAttribute implements the ActionFilterAttribute so only the OnActionExecuting needs to be implemented. The custom header is validated and if it fails, an unauthorized result is returned. It does not matter if you give the reason why, unless you want to obfuscate this a bit.

public class CsrfProtectionCorsPreflightAttribute : ActionFilterAttribute
{
	public override void OnActionExecuting(ActionExecutingContext context)
	{
		var header = context.HttpContext
			.Request
			.Headers
			.Any(p => p.Key.ToLower() == "x-force-cors-preflight");
			
		if (!header)
		{
			// "X-FORCE-CORS-PREFLIGHT header is missing"
			context.Result = new UnauthorizedObjectResult("X-FORCE-CORS-PREFLIGHT header is missing");
			return;
		}
	}
}

The CSRF can then be applied anywhere this is required. All secured routes where cookies are used should enforce this.

[CsrfProtectionCorsPreflight]
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
[ApiController]
[Route("api/[controller]")]
public class DirectApiController : ControllerBase
{
	[HttpGet]
	public IEnumerable<string> Get()
	{
		return new List<string> { "some data", "more data", "loads of data" };
	}
}

Now the PWA works using the server rendered application and protected using BFF with all security in the trusted backend.

Problems with this solution and Blazor

The custom header cannot be applied and added when sending direct links, redirects or forms which don’t use Javascript. Anywhere a form is implemented and requires the CORS preflight protection, a HttpClient which adds the header needs to be used.

This is a problem with the Azure B2C signin and signout. The signin redirects the whole application, but this is not so much a problem because when signing in, the identity has no cookie with sensitive data, or should have none. The signout only works correctly with Azure B2C with a form request from the whole application and not HttpClient API call using Javascript. The CORS preflight header cannot be applied to an Azure B2C identity provider signout request, if you require the session to be ended on Azure B2C. If you only require a local logout, then the HttpClient can be used.

Note: Same site protection also exists for modern browsers, so this double CSRF fallback is not really critical, if the same site is implemented correctly and using a browser which enforces this.

Links

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

Managing Azure B2C users with Microsoft Graph API

https://docs.microsoft.com/en-us/graph/sdks/choose-authentication-providers?tabs=CS#client-credentials-provider

https://github.com/search?q=Microsoft.Identity.Web

https://github.com/damienbod/Blazor.BFF.AzureB2C.Template

4 comments

  1. […] Implement a PWA using Blazor with BFF security and Azure B2C [#.NET Core #App Service #ASP.NET Core #Azure #Azure AD #Azure B2C #OAuth2 #Security #BFF #Blazor #OIDC #PWA] […]

  2. […] Implement a PWA using Blazor with BFF security and Azure B2C (Damien Bowden) […]

  3. Андрей Чебукин · · Reply

    Can I open a popup where all sign-in/sign-out logic happens in order not to reload the Blazor app?

    1. Guess you could, never tried this, Greetings Damien

Leave a Reply to Dew Drop – January 31, 2022 (#3610) – Morning Dew by Alvin Ashcraft Cancel 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 )

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.

%d bloggers like this: