August 15: Authenticating Apps in 2023 – A Closer Look

(If you want to understand why you care about any of this, see my earlier post.)

In this guide, we’ll walk through the process of creating an Azure-hosted React app that authenticates to Azure AD. This is the front end architecture for the Identity Bridge.

We’ll be using the backend-for-frontend (BFF) architecture, which is the same architecture used by the SPA templates in ASP.NET Core. It’s also the architecture of an Azure AD BFF Proxy sample written by Doğan Erişen earlier this year. [On September 18th, 2023, Damien Bowd published this post describing the use of the BFF architecture to secure an Angular SPA with Auth0 using ASP.NET Core.]

In our implementation, C# handles Microsoft Identity Web Confidential Client auth events on the server side, while the recent SPA Authorization Code flow streamlines React Public Client auth via the msal-react library.

Let’s begin with the "dotnet new react" command and add Vite, Typescript, and Azure AD to the mix.

Note: On August 9, 2023, Microsoft released Visual Studio 2022 17.8 Preview 1 which supports Vite and TypeScript in the React and ASP.NET Core template. However, this template currently has two issues. First, Microsoft.AspNetCore.SpaProxy (8.0.0-preview.7.23375.9) fails to load and has been removed from the template, causing apps built with the template to require manual retries at launch. Second, the .CSPROJ files are missing elements needed for Azure deployment (reported to Microsoft and “under investigation“). Until these issues are resolved, this post will show you how to add Vite and TypeScript to the existing template.

1. Start with the dotnet new react template for the latest Single Page Application support in ASP.NET Core 7.

2. Publish this app to an Azure Web App and confirm operation.

Upgrade from CRA to Vite

Robin Wieruch provides helpful initial steps for this upgrade.

1. Install Vite and all React related libraries as development dependencies.

npm install vite @vitejs/plugin-react --save-dev

2. Uninstall the create-react-app dependency.

npm uninstall react-scripts

3. Update “package.json” to use Vite scripts.

"scripts": {
  "start": "vite",
  "build": "vite build",
  "serve": "vite preview"
},

4. Rename “.js” files containing React components to “.jsx“.

5. Create a “vite.config.js” file in the /ClientApp directory to use the Vite React plugin and set the output directory.

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(() => {
     return {
          build: {
               outDir: 'build'
          },
          plugins: [react()]
     };
});

6. Move files from “/ClientApp/public” to “/ClientApp” and adjust references in “index.html” accordingly.

7. Update the “index.html” file to link to “src/index.jsx“.

<script type="module" src="src/index.jsx"></script>

HTTPS Support

Configure HTTPS support slightly differently in Vite.

1. In “vite.config.js“, use the ASP.NET Core HTTPS certificate for HTTPS support.

server: {
     port: 3000,
     strictPort: true,
     https: {
          key: fs.readFileSync(keyFilePath),
          cert: fs.readFileSync(certFilePath)
     }
}

2. Ensure the “SPAProxyServerUrl” in “[your project name].csproj” matches your configured port in Vite.

<SpaProxyServerUrl>https://localhost:3000</SpaProxyServerUrl>

3. In “.env.development“, ensure that PORT and HTTPS settings match the configurations above:

PORT=3000
HTTPS=true

Proxy Support

Microsoft’s ASP.NET Core Single Page Application (SPA) model uses a frontend proxy for JavaScript / React and a backend C# / ASP.NET server for launching the frontend proxy. To communicate with the backend server, the frontend proxy needs to be configured to forward certain requests. This proxy configuration differs slightly in Vite.

In “vite.config.ts“, configure the proxy to communicate with the ASP.NET process running at the HTTPS endpoint defined in your “launchsettings.json ” “applicationUrl” value:

let proxyConfig = {
    target: 'https://localhost:7066', // <-- your .NET process
    secure: false,
    configure: (proxy, _options) => {
        // proxy configuration details ...
    }
}
export default defineConfig(() => {
    return {
        build: {
            outDir: 'build',
        },
        server: {
            port: 3000, // <-- you Single Page Application process
            strictPort: true,
            https: {
                key: fs.readFileSync(keyFilePath),
                cert: fs.readFileSync(certFilePath)
            },
            proxy: {
                '/MicrosoftIdentity/Account/SignIn': proxyConfig,       // MicrosoftIdentity controller
                '/MicrosoftIdentity/Account/SignOut': proxyConfig,      // MicrosoftIdentity controller
                // other proxy mappings ...
            }
        },
        plugins: [react()],
    };
});

Publish and Test

Now publish to Azure, confirm operation, and have a small celebration.

Upgrade to TypeScript

I prefer finding errors quickly (ideally as I type) and like the linting and type checking capabilities of TypeScript. Here are the steps to upgrade to TypeScript. Again, credit to Robin Wieruch for another concise and authoritative guide.

1. Install TypeScript and its dependencies.

npm install typescript @types/react @types/react-dom --save-dev

2. tsconfig.json: add a TypeScript config file for the browser environment.

{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "allowJs": false,
    "skipLibCheck": true,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

3. tsconfig.node.json: add a TypeScript config file for the Node environment.

{
  "compilerOptions": {
    "composite": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "allowSyntheticDefaultImports": true
  },
  "include": ["vite.config.ts"]
}

4. rename all .JSX files to .TSX and vite.config.js to vite.config.ts.

5. index.html: reference the renamed /src/index.tsx file.

Fix errors

The app runs; however, your Visual Studio Error List now has many (I had 240) errors blocking deployment. Installing packages, commenting out lines, making function parameters and return values explicit, and rebooting will address errors.

I installed the following packages:

npm install @types/jest @types/node dotenv vite-plugin-svgr --save-dev

I commented out serviceWorkerRegistration and reportWebVitals lines:

//import * as serviceWorkerRegistration from './serviceWorkerRegistration';
//import reportWebVitals from './reportWebVitals';

const baseUrl = document.getElementsByTagName('base')[0].getAttribute('href');
const rootElement = document.getElementById('root');
const root = createRoot(rootElement!);

root.render(
  <BrowserRouter basename={baseUrl!}>
    <App />
  </BrowserRouter>);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://cra.link/PWA
//serviceWorkerRegistration.unregister();

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
//reportWebVitals();

Publish and test

With no errors, you can now publish to Azure and confirm operation.

Azure AD Authentication

For Azure AD authentication, configure “appsettings.json” based on your Azure Portal application settings.

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": [YOUR DOMAIN HERE],
    "TenantId": "organizations",
    "ClientId": [YOUR CLIENT ID HERE],
    "CallbackPath": "/signin-oidc",
    "ClientSecret": [YOUR CLIENT SECRET HERE],
    "ResponseType": "code",
    "WithSpaAuthCode": true,
    "EnablePiiLogging": true
  }
}

Program.cs

Configure enough ASP.NET Core UI to allow Microsoft Identity Web to perform Azure AD authentication while using React for the main UI.

var builder = WebApplication.CreateBuilder(args);

// soonest possible time: add session options to enable hybrid SPA
builder.Services.AddSession(options =>
{
    options.Cookie.IsEssential = true;
});
// Add services to the container.
string[] initialScopes = builder.Configuration.GetSection("DownstreamApi:Scopes")?.Value?.Split(' ')!;
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApp(builder.Configuration, Constants.AzureAd, OpenIdConnectDefaults.AuthenticationScheme, CookieAuthenticationDefaults.AuthenticationScheme, true)   // setting up the Microsoft.Identity.Web middleware
    .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
    .AddMicrosoftGraph(builder.Configuration.GetSection("DownstreamApi"))
    .AddDistributedTokenCaches();

builder.Services.AddMvc()
    .AddMicrosoftIdentityUI();

var app = builder.Build();

// soonest possible time: use session to enable hybrid SPA
app.UseSession();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller}/{action=Index}/{id?}");

app.MapFallbackToFile("index.html");

app.Run();

vite.config.ts

This configures the React process to forward authentication related requests to the authentication process to the ASP.NET process.

// https://vitejs.dev/config/server-options.html#server-proxy
let proxyConfig = {
    target: 'https://localhost:7066', // <-- your .NET process
    secure: false,
    configure: (proxy, _options) => {
        proxy.on('error', (err, _req, _res) => {
            console.log('proxy error', err);
        });
        proxy.on('proxyReq', (proxyReq, req, _res) => {
            console.log('Sending Request to .NET:', req.method, req.url);
        });
        proxy.on('proxyRes', (proxyRes, req, _res) => {
            console.log('Received Response from .NET:', proxyRes.statusCode, req.url);
        });
    }
}
export default defineConfig(() => {
    return {
        build: {
            outDir: 'build',
        },
        server: {
            port: 3000, // <-- you Single Page Application process
            strictPort: true,
            https: {
                key: fs.readFileSync(keyFilePath),
                cert: fs.readFileSync(certFilePath)
            },
            proxy: {
                '/challenge': proxyConfig,                              // ChallengeController
                '/Challenge': proxyConfig,                              // ChallengeController
                '/MicrosoftIdentity/Account/SignIn': proxyConfig,       // MicrosoftIdentity controller
                '/MicrosoftIdentity/Account/SignOut': proxyConfig,      // MicrosoftIdentity controller
                '/MicrosoftIdentity/Account/Challenge': proxyConfig,    // MicrosoftIdentity controller
                '/MicrosoftIdentity/Account/SignedOut': proxyConfig,    // Program.cs rewriter
                '/signin-oidc': proxyConfig,                            // MicrosoftIdentity handlers
                '/signout-callback-oidc': proxyConfig,                  // MicrosoftIdentity handlers
                '/weatherforecast': proxyConfig,                        // WeatherForecastController
            }
        },
        plugins: [react()],
    };
});

Sign In

Initiate sign-in by redirecting the browser to the Microsoft Identity Account Controller:

function authenticate(): boolean {
    let tenantURL: string = window.location.href;
    tenantURL += "MicrosoftIdentity/Account/Challenge";
    let url: URL = new URL(tenantURL);
    url.searchParams.append("redirectUri", window.location.origin);
    url.searchParams.append("scope", "openid offline_access profile user.read contacts.read CrossTenantInformation.ReadBasic.All");
    url.searchParams.append("domainHint", "organizations");
    url.searchParams.append("loginHint", "arvind@mindline1.onmicrosoft.com");
    window.location.assign(url.href);
    return false;
}

Authorization Code Flow with Spacode

The authorization code flow, preferred to implicit flow, supports spacode retrieval to streamline hybrid authentication. The above “appsettings.json” entries set up this flow.

For detailed information on adding authentication event handling in “program.cs”, refer to these links:

Sign Out

Logging out involves a few steps for a smoother experience:

1. Refer to this post by Bill Fiddes to bypass the user selection screen on logout by redirecting to the logout endpoint.

2. Use event handlers in Program.cs to manage the login_hint claim.

3. Adjust the signout link in Program.cs to redirect to home page after sign out.

app.UseRewriter(new RewriteOptions().Add(
    context =>
    {
        if (context.HttpContext.Request.Path == "/MicrosoftIdentity/Account/SignedOut")
        {
            context.HttpContext.Response.Redirect("/");
        }
    }));

Wrapping Up

By following these steps, you can create an Azure-hosted React app that successfully authenticates to Azure AD. This walkthrough simplifies the process and clarifies various steps to ensure a smoother development experience.