Implement a secure web application using nx Standalone Angular and an ASP.NET Core server

This article shows how to implement a secure web application using Angular and ASP.NET Core. The web application implements the backend for frontend security architecture (BFF) and deploys both technical stack distributions as one web application. HTTP only secure cookies are used to persist the session. Microsoft Entra ID is used as the identity provider and the token issuer.

Code: https://github.com/damienbod/bff-aspnetcore-angular

Overview

The solution is deployed as a single OpenID Connect confidential client using the Microsoft Entra ID identity provider. The OpenID Connect client authenticates using the code flow with PKCE and a secret or a certificate. I use secrets in development and certificates in production deployments. The UI part of the solution is deployed as part of the server application. Secure HTTP only cookies are used to persist the session after a successful authentication. No security flows are implemented in the client part of the application. No sensitive data like tokens are exposed in the client browser. By removing the security from the client, the security is improved and the complexity is reduced.

Setup Angular application

The Angular application is setup using nx and a standalone Angular project. The UI needs one setup for production and one setup for development. As the application uses cookies, anti-forgery protection is added. The CSP uses nonces and this needs to be applied to all scripts including the dynamic scripts ones created by Angular. This also applies for styles.

HTTPS setup

The Angular application runs in HTTPS in development and production. The nx project needs to be setup for this. I created a development certificate and added this to the Angular project in a certs folder. The certificates are read in from the folder and used in the project.json file of the nx project. The serve configuration is used to define this. I also switched the port number.

"serve": {
  "executor": "@angular-devkit/build-angular:dev-server",
  "options": {
  "browserTarget": "ui:build",
  "sslKey": "certs/dev_localhost.key",
  "sslCert": "certs/dev_localhost.pem",
  "port": 4201
},

Production build

The Angular project is deployed as part of the server project. In ASP.NET Core, you would use the wwwroot folder and allow static files. The Angular nx project.json file defines the build where the outputPath parameter is updated to match the production deployment.

  "executor": "@angular-devkit/build-angular:browser",
  "outputs": ["{options.outputPath}"],
  "options": {
	"outputPath": "../server/wwwroot",
	"index": "./src/index.html",

CSP setup

The CSP is setup to use nonces both in development and production. This will save time fixing CSP issues before you go live. Angular creates scripts on a build or a nx serve. The scripts require the nonce. To add the server created nonce, the index.html file uses a meta tag in the header. The ngCspNonce is added to the app-root Angular tag. The nonce gets added and updated with a new value on every HTTP request.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
	<meta name="CSP_NONCE" content="**PLACEHOLDER_NONCE_SERVER**" />
    <title>ui</title>
    <base href="/" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />
  </head>
  <body>
    <app-root ngCspNonce="**PLACEHOLDER_NONCE_SERVER**"></app-root>
  </body>
</html>

You need to add the CSP_NONCE provider to the providers in the Angular project. This must also use the server created nonce.

const nonce = (
  document.querySelector('meta[name="CSP_NONCE"]') as HTMLMetaElement
)?.content;

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(appRoutes, withEnabledBlockingInitialNavigation()),
    provideHttpClient(withInterceptors([secureApiInterceptor])),
    {
      provide: CSP_NONCE,
      useValue: nonce,
    },
  ],
};

Anti-forgery protection

Cookies are uses in the authentication session. The authentication cookie is a HTTP only secure cookie only for its domain. Browser Same Site protection helps secure the session. Old browsers do not support Same Site and Anti-forgery protection is still required. You can add this protection in two ways. I use a CSRF anti-forgery cookie.

import { HttpHandlerFn, HttpRequest } from '@angular/common/http';
import { getCookie } from './getCookie';

export function secureApiInterceptor(
  request: HttpRequest<unknown>,
  next: HttpHandlerFn
) {
  const secureRoutes = [getApiUrl()];

  if (!secureRoutes.find((x) => request.url.startsWith(x))) {
    return next(request);
  }

  request = request.clone({
    headers: request.headers.set(
      'X-XSRF-TOKEN',
      getCookie('XSRF-RequestToken')
    ),
  });

  return next(request);
}

function getApiUrl() {
  const backendHost = getCurrentHost();
  return `${backendHost}/api/`;
}

function getCurrentHost() {
  const host = window.location.host;
  const url = `${window.location.protocol}//${host}`;
  return url;
}

The Anti-forgery header is added to every API call to the same domain using an Angular interceptor. The interceptor is a function and added using the HTTP client provider:

provideHttpClient(withInterceptors([secureApiInterceptor])),

Setup ASP.NET Core application

The ASP.NET Core project is setup to host the static html file from Angular and respond to all HTTP requests as defined using the APIs. The nonce is added to the index.html file. Microsoft.Identity.Web is used to authenticate the user and the application. The session is stored in a cookie. The NetEscapades.AspNetCore.SecurityHeaders nuget package is used to add the security headers and the CSP.

using BffMicrosoftEntraID.Server;
using BffMicrosoftEntraID.Server.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.UI;
using Microsoft.IdentityModel.Logging;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(serverOptions =>
{
    serverOptions.AddServerHeader = false;
});

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

services.AddScoped<MsGraphService>();
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();

var scopes = configuration.GetValue<string>("DownstreamApi:Scopes");
string[] initialScopes = scopes!.Split(' ');

services.AddMicrosoftIdentityWebAppAuthentication(configuration, "MicrosoftEntraID")
    .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
    .AddMicrosoftGraph("https://graph.microsoft.com/v1.0", initialScopes)
    .AddInMemoryTokenCaches();

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

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

builder.Services.AddReverseProxy()
   .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();

IdentityModelEventSource.ShowPII = true;

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

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

app.UseHttpsRedirection();

app.UseStaticFiles();

app.UseRouting();

app.UseNoUnauthorizedRedirect("/api");

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

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

if (app.Environment.IsDevelopment())
{
    var uiDevServer = app.Configuration.GetValue<string>("UiDevServerUrl");
    if (!string.IsNullOrEmpty(uiDevServer))
    {
        app.MapReverseProxy();
    }
}

app.MapFallbackToPage("/_Host");

app.Run();

Setup Azure App registration

The application is deployed as one. The application consists of two parts, the Angular part and the ASP.NET Core part. These are tightly coupled (business) even if the technical stacks are not. This is an OpenID Connect confidential client with a user secret or a certification for client assertion.

Use the Web client type on setup.

Development environment

Developers require a professional development setup and should use the technical stacks like the creators of the tech stacks recommend. Default development environments is the aim. The Angular nx project uses a default nx environment or best practices as the Angular community recommends. The server part of the application must proxy all UI requests to the Angular nx development environment. I use Microsoft YARP reverse proxy to implement this. This is only required for development in this setup.

Testing and running

The appsettings.json MUST be updated with your Azure tenant Azure App registration values. If using a client secret, store this in the user secrets for development, or in a key vault when deployed to Azure.

  "MicrosoftEntraID": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]",
    "TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]",
    "ClientId": "[Enter the Client Id (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]",
    "ClientSecret": "[Copy the client secret added to the app from the Azure portal]",
    "ClientCertificates": [
    ],
    // the following is required to handle Continuous Access Evaluation challenges
    "ClientCapabilities": [ "cp1" ],
    "CallbackPath": "/signin-oidc"
  },

Debugging

Start the Angular project from the ui folder

nx serve --ssl

Start the ASP.NET Core project from the server folder

dotnet run

When the localhost of the server app is opened, you can authenticate and use.

Links

https://learn.microsoft.com/en-us/aspnet/core/introduction-to-aspnet-core

https://nx.dev/getting-started/intro

https://nx.dev/getting-started/tutorials/angular-standalone-tutorial

https://github.com/AzureAD/microsoft-identity-web

https://github.com/isolutionsag/aspnet-react-bff-proxy-example

9 comments

  1. […] Implement a secure web application using nx Standalone Angular and an ASP.NET Core server (Damien Bowden) […]

  2. Thanks! Awesome.
    Just one question: Doesn’t this reverse proxy in Dev mode also do the redirect for all the api calls to the backend? Like also for instance: UserController and AccountController? Or are they somehow excluded from redirection? Because regarding the YARP config {catch-all}, this should match them all. So how does this work? 🙂

    1. Hi stan, thanks, I have the controllers first in the middleware. I think I should clean this up and make it more explicit, thanks for the feedback.

      Greetings Damien

  3. […] Implement a secure web application using nx Standalone Angular and an ASP.NET Core server – Damien Bowden […]

  4. […] Implement a secure web application using nx Standalone Angular and an ASP.NET Core server […]

  5. […] Implement a secure web application using nx Standalone Angular and an ASP.NET Core server […]

  6. kylehreed · · Reply

    Thanks for this very useful example.

    If my front end doesn’t require nonces and it doesn’t support old browsers, would it be acceptable to fall back to the index.html instead of “/_Host” ?

    I promise not to hold you to this answer :).

    1. Hi kylereed, the CSP has already a fallback with the unsafeInline and yes using a index and a static hash from the meta tag would almost be as good.

      I use the index.html always , just change it on a response 🙂

      This would also work for a PWA with the correct server URL exceptions

      Greetings Damien

  7. […] Implement a secure web application using nx Standalone Angular and an ASP.NET Core server […]

Leave a comment

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