Modern Authentication on .NET: OpenID Connect, BFF, SPA
  • 05 Nov 2024
  • 37 Minutes to read

Modern Authentication on .NET: OpenID Connect, BFF, SPA


Article summary

Introduction

As web technologies continue to advance, so do the methods and protocols designed to secure them. The OAuth 2.0 and OpenID Connect protocols have significantly evolved in response to emerging security threats and the growing complexity of web applications. Traditional authentication methods, once effective, are now becoming outdated for modern Single Page Applications (SPAs), which face new security challenges. In this context, the Backend-For-Frontend (BFF) architectural pattern has emerged as a recommended solution for organizing interactions between SPAs and their backend systems, offering a more secure and manageable approach to authentication and session management. This article explores the BFF pattern in depth, demonstrating its practical application through a minimal solution implemented with .NET and React. By the end, you'll have a clear understanding of how to leverage the BFF pattern to enhance the security and functionality of your web applications.

Historical Context

The history of OAuth 2.0 and OpenID Connect reflects the ongoing evolution of Internet technologies. Let’s take a closer look at these protocols and their impact on modern web applications.

Introduced in 2012, the OAuth 2.0 protocol has become a widely adopted standard for authorization. It allows third-party applications to obtain limited access to user resources without exposing the user's credentials to the client. OAuth 2.0 supports several flows, each designed to flexibly adapt to various use cases.

Building on the foundation of OAuth 2.0, the OpenID Connect (OIDC) protocol emerged in 2014, adding essential authentication functionality. It provides client applications with a standard method to verify the user's identity and obtain basic information about them through a standardized access point or by acquiring an ID token in JWT (JSON Web Token) format.

Evolution of the Threat Model

With the growing capabilities and popularity of SPAs, the threat model for SPAs has also evolved. Vulnerabilities such as Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF) have become more widespread. Since SPAs often interact with the server via APIs, securely storing and using access tokens and refresh tokens has become crucial for security.

Responding to the demands of the times, the OAuth and OpenID Connect protocols continue to evolve to adapt to new challenges that arise with new technologies and the growing number of threats. At the same time, the constant evolution of threats and the improvement of security practices mean that outdated approaches no longer satisfy modern security requirements. As a result, the OpenID Connect protocol currently offers a wide range of capabilities, but many of them are already, or will soon be, considered obsolete and often unsafe. This diversity creates difficulties for SPA developers in choosing the most appropriate and secure way to interact with the OAuth 2.0 and OpenID Connect server.

In particular, the Implicit Flow can now be considered obsolete, and for any type of client, whether it's an SPA, a mobile application, or a desktop application, it is now strongly recommended to use the Authorization Code Flow along with Proof Key for Code Exchange (PKCE). There is a detailed article on this topic that describes the evolution of authentication approaches, the associated risks and ways to mitigate them.

Security of Modern SPAs

Why are modern SPAs still considered vulnerable, even when using the Authorization Code Flow with PKCE? There are several answers to this question.

JavaScript Code Vulnerabilities

JavaScript is a powerful programming language that plays a key role in modern Single Page Applications (SPAs). However, its broad capabilities and prevalence pose a potential threat. Modern SPAs built on libraries and frameworks such as React, Vue or Angular, use a vast number of libraries and dependencies. You can see them in the node_modules folder, and the number of such dependencies can be in the hundreds or even thousands. Each of these libraries may contain vulnerabilities of varying degrees of criticality, and SPA developers do not have the ability to thoroughly check the code of all the dependencies used. Often developers do not even track the full list of dependencies, as they are transitively dependent on each other. Even developing their own code to the highest standards of quality and security, one cannot be completely sure of the absence of vulnerabilities in the finished application.

Malicious JavaScript code, which can be injected into an application in various ways, through attacks such as Cross-Site Scripting (XSS) or through the compromise of third-party libraries, gains the same privileges and level of access to data as the legitimate application code. This allows malicious code to steal data from the current page, interact with the application interface, send requests to the backend, steal data from local storage (localStorage, IndexedDB), and even initiate authentication sessions itself, obtaining its own access tokens using the same Authorization Code and PKCE flow.

Spectre Vulnerability

The Spectre vulnerability exploits features of modern processor architecture to access data that should be isolated. Such vulnerabilities are particularly dangerous for SPAs.

Firstly, SPAs intensively use JavaScript to manage the application state and interact with the server. This increases the attack surface for malicious JavaScript code that can exploit Spectre vulnerabilities. Secondly, unlike traditional multi-page applications (MPAs), SPAs rarely reload, meaning the page and its loaded code remain active for a long time. This gives attackers significantly more time to perform attacks using malicious JavaScript code.

Spectre vulnerabilities allow attackers to steal access tokens stored in the memory of a JavaScript application, enabling access to protected resources by impersonating the legitimate application. Speculative execution can also be used to steal user session data, allowing attackers to continue their attacks even after the SPA is closed.

The discovery of other vulnerabilities similar to Spectre in the future cannot be ruled out.

What to Do?

Let's summarize an important interim conclusion. Modern SPAs, dependent on a large number of third-party JavaScript libraries and running in the browser environment on user devices, operate in a software and hardware environment that developers cannot fully control. Therefore, we should consider such applications inherently vulnerable.

In response to the listed threats, more experts lean towards completely avoiding storing tokens in the browser and designing the application so that access and refresh tokens are obtained and processed only by the server side of the application, and they are never passed to the browser side. In the context of a SPA with a backend, this can be achieved using the Backend-For-Frontend (BFF) architectural pattern.

The interaction scheme between the authorization server (OP), the client (RP) implementing the BFF pattern, and a third-party API (Resource Server) looks like this:
Authorization Code Flow with BFF

Using the BFF pattern to protect SPAs offers several advantages. Access and refresh tokens are stored on the server side and are never passed to the browser, preventing their theft due to vulnerabilities. Session and token management are handled on the server, allowing for better security control and more reliable authentication verification. The client application interacts with the server through the BFF, which simplifies the application logic and reduces the risk of malicious code execution.

Implementing the Backend-For-Frontend Pattern on the .NET Platform

Before we proceed to the practical implementation of BFF on the .NET platform, let's consider its necessary components and plan our actions. Let's assume we already have a configured OpenID Connect server and we need to develop a SPA that works with a backend, implement authentication using OpenID Connect, and organize the interaction between the server and client parts using the BFF pattern.

According to the document OAuth 2.0 for Browser-Based Applications, the BFF architectural pattern assumes that the backend acts as an OpenID Connect client, uses Authorization Code Flow with PKCE for authentication, obtains and stores access and refresh tokens on its side and never passes them to the SPA side in the browser. The BFF pattern also assumes the presence of an API on the backend side consisting of four main endpoints:

BFF Endpoints

  1. Check Session: serves to check for an active user authentication session. Typically called from the SPA using an asynchronous API (fetch) and, if successful, returns information about the active user. Thus, the SPA, loaded from a third source (e.g., CDN), can check the authentication status and either continue its work with the user or proceed to authentication using the OpenID Connect server.

  2. Login: initiates the authentication process on the OpenID Connect server. Typically, if the SPA fails to obtain authenticated user data at step 1 through Check Session, it redirects the browser to this URL, which then forms a complete request to the OpenID Connect server and redirects the browser there.

  3. Sign In: receives the Authorization Code sent by the server after step 2 in the case of successful authentication. Makes a direct request to the OpenID Connect server to exchange the Authorization Code + PKCE code verifier for Access and Refresh tokens. Initiates an authenticated session on the client side by issuing an authentication cookie to the user.

  4. Logout: serves to terminate the authentication session. Typically, the SPA redirects the browser to this URL, which in turn forms a request to the End Session endpoint on the OpenID Connect server to terminate the session, as well as deletes the session on the client side and the authentication cookie.

Now let's examine tools that the .NET platform provides out of the box and look that we can use to implement the BFF pattern. The .NET platform offers the Microsoft.AspNetCore.Authentication.OpenIdConnect NuGet package, which is a ready-made implementation of an OpenID Connect client supported by Microsoft. This package supports both Authorization Code Flow and PKCE, and it adds an endpoint with the relative path /signin-oidc, which already implements the necessary Sign In Endpoint functionality (see point 3 above). Thus, we need to implement the remaining three endpoints only.

For a practical integration example, we will take a test OpenID Connect server based on the Abblix OIDC Server library. However, everything mentioned below applies to any other server, including publicly available servers from Facebook, Google, Apple and any others that comply with the OpenID Connect protocol specification.

To implement the SPA on the frontend side, we will use the React library, and on the backend side, we will use .NET WebAPI. This is one of the most common technology stacks at the time of writing this article.

The overall scheme of components and their interaction looks like this:
Components and their interaction

To work with the examples from this article, you will also need to install the .NET SDK and Node.js. All examples in this article were developed and tested using .NET 8, Node.js 22 and React 18, which were current at the time of writing.

Creating a Client SPA on React with a Backend on .NET

To quickly create a client application, it's convenient to use a ready-made template. Up to version .NET 7, the SDK offered a built-in template for a .NET WebAPI application and a React SPA. Unfortunately, this template was removed in the version .NET 8. That is why the Abblix team has created own template, which includes a .NET WebApi backend, a frontend SPA based on the React library and TypeScript, built with Vite. This template is publicly available as part of the Abblix.Templates package, and you can install it by running the following command:

dotnet new install Abblix.Templates

Now we can use the template named abblix-react. Let's use it to create a new application called BffSample:

dotnet new abblix-react -n BffSample

This command creates an application consisting of a .NET WebApi backend and a React SPA client. The files related to the SPA are located in the BffSample\ClientApp folder.

After creating the project, the system will prompt you to run a command to install the dependencies:

cmd /c "cd ClientApp && npm install"

This action is necessary to install all the required dependencies for the client part of the application. For a successful project launch, it is recommended to agree and execute this command by entering Y (yes).

Let's immediately change the port number on which the BffSample application runs locally to 5003. This action is not mandatory, but it will simplify further configuration of the OpenID Connect server. To do this, open the BffSample\Properties\launchSettings.json file, find the profile named https and change the value of the applicationUrl property to https://localhost:5003.

Next, add the NuGet package implementing the OpenID Connect client by navigating to the BffSample folder and executing the following command:

dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

Set up two authentication schemes named Cookies and OpenIdConnect in the application, reading their settings from the application configuration. To do this, make changes to the BffSample\Program.cs file:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// ******************* START *******************
var configuration = builder.Configuration;

builder.Services
    .AddAuthorization()
    .AddAuthentication(options => configuration.Bind("Authentication", options))
    .AddCookie()
    .AddOpenIdConnect(options => configuration.Bind("OpenIdConnect", options));
// ******************** END ********************
var app = builder.Build();

And add the necessary settings for connecting to the OpenID Connect server in the BffSample\appsettings.json file:

{
  // ******************* START *******************
  "Authentication": {
      "DefaultScheme": "Cookies",
      "DefaultChallengeScheme": "OpenIdConnect"
  },
  "OpenIdConnect": {
      "SignInScheme": "Cookies",
      "SignOutScheme": "Cookies",
      "SaveTokens": true,
      "Scope": ["openid", "profile", "email"],
      "MapInboundClaims": false,
      "ResponseType": "code",
      "ResponseMode": "query",
      "UsePkce": true,
      "GetClaimsFromUserInfoEndpoint": true
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information",

And in the BffSample\appsettings.Development.json file:

{
  // ******************* START *******************
  "OpenIdConnect": {
      "Authority": "https://localhost:5001",
      "ClientId": "bff_sample",
      "ClientSecret": "secret"
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information",

Let's briefly review each setting and its purpose:

  • Authentication section: The DefaultScheme property sets authentication by default using the Cookies scheme, and DefaultChallengeScheme delegates the execution of authentication to the OpenIdConnect scheme when the user cannot be authenticated by the default scheme. Thus, when the user is unknown to the application, the OpenID Connect server will be called for authentication, and after that, the authenticated user will receive an authentication cookie, and all further server calls will be authenticated with it, without contacting the OpenID Connect server.

  • OpenIdConnect section:

    • SignInScheme and SignOutScheme properties specify the Cookies scheme, which will be used to save the user's information after sign in.
    • The Authority property contains the base URL of the OpenID Connect server. ClientId and ClientSecret specify the client application's identifier and secret key, which are registered on the OpenID Connect server.
    • SaveTokens indicates the need to save the tokens received as a result of authentication from the OpenID Connect server.
    • Scope contains a list of scopes that the BffClient application requests access to. In this case, the standard scopes openid (user identifier), profile (user profile), and email (email) are requested.
    • MapInboundClaims is responsible for transforming incoming claims from the OpenID Connect server into claims used in the application. A value of false means that claims will be saved in the authenticated user's session in the form in which they are received from the OpenID Connect server.
    • ResponseType with the value code indicates that the client will use the Authorization Code Flow.
    • ResponseMode specifies the transmission of the Authorization Code in the query string, which is the default method for Authorization Code Flow.
    • The UsePkce property indicates the need to use PKCE during authentication to prevent interception of the Authorization Code.
    • The GetClaimsFromUserInfoEndpoint property indicates that user profile data should be obtained from the UserInfo endpoint.

Since our application does not assume interaction with the user without authentication, we will ensure that the React SPA is loaded only after successful authentication. Of course, if the SPA is loaded from an external source such as a Static Web Host, for example from Content Delivery Network (CDN) servers or a local development server started with the npm start command (for example, when running our example in debug mode), it will not be possible to check the authentication status before loading the SPA. But, when our own .NET backend is responsible for loading the SPA, it is possible to do.

To do this, add the middleware responsible for authentication and authorization in the BffSample\Program.cs file:

app.UseRouting();
// ******************* START *******************
app.UseAuthentication();
app.UseAuthorization();
// ******************** END ********************

At the end of the BffSample\Program.cs file, where the transition to loading the SPA is directly carried out, add the requirement for authorization .RequireAuthorization():

app.MapFallbackToFile("index.html").RequireAuthorization();

Setting Up the OpenID Connect Server

As mentioned earlier, for the practical integration example, we will use a test OpenID Connect server based on the Abblix OIDC Server library. The base template for an application based on ASP.NET Core MVC with the Abblix OIDC Server library is also available in the Abblix.Templates package we installed earlier. Let's use this template to create a new application named OpenIDProviderApp:

dotnet new abblix-oidc-server -n OpenIDProviderApp

To configure the server, we need to register the BffClient application as a client on the OpenID Connect server and add a test user. To do this, add the following blocks to the OpenIDProviderApp\Program.cs file:

var userInfoStorage = new TestUserStorage(
    // ******************* START *******************
    new UserInfo(
        Subject: "1234567890",
        Name: "John Doe",
        Email: "john.doe@example.com",
        Password: "Jd!2024$3cur3")
    // ******************** END ********************
);
builder.Services.AddSingleton(userInfoStorage);

// ...

// Register and configure Abblix OIDC Server
builder.Services.AddOidcServices(options =>
{
    // Configure OIDC Server options here:
    // ******************* START *******************
    options.Clients = new[] {
        new ClientInfo("bff_sample") {
            ClientSecrets = new[] {
                new ClientSecret {
                    Sha512Hash = SHA512.HashData(Encoding.ASCII.GetBytes("secret")),
                }
            },
            TokenEndpointAuthMethod = ClientAuthenticationMethods.ClientSecretPost,
            AllowedGrantTypes = new[] { GrantTypes.AuthorizationCode },
            ClientType = ClientType.Confidential,
            OfflineAccessAllowed = true,
            PkceRequired = true,
            RedirectUris = new[] { new Uri("https://localhost:5003/signin-oidc", UriKind.Absolute) },
            PostLogoutRedirectUris = new[] { new Uri("https://localhost:5003/signout-callback-oidc", UriKind.Absolute) },
        }
    };
    // ******************** END ********************
    // The following URL leads to Login action of the AuthController
    options.LoginUri = new Uri($"/Auth/Login", UriKind.Relative);

    // The following line generates a new key for token signing. Replace it if you want to use your own keys.
    options.SigningKeys = new[] { JsonWebKeyFactory.CreateRsa(JsonWebKeyUseNames.Sig) };
});

Let's review this code in detail. We register a client with the identifier bff_sample and the secret key secret (storing it as a SHA512 hash), indicating that the token acquisition will use client authentication with the secret key sent in a POST message (ClientAuthenticationMethods.ClientSecretPost). AllowedGrantTypes specifies that the client is only allowed to use the Authorization Code Flow. ClientType defines the client as confidential, meaning it can securely store its secret key. OfflineAccessAllowed allows the client to use refresh tokens. PkceRequired mandates the use of PKCE during authentication. RedirectUris and PostLogoutRedirectUris contain lists of allowed URLs for redirection after authentication and session termination, respectively.

For any other OpenID Connect server, the settings will be similar, with differences only in how they are configured.

Implementing the basic BFF API

Earlier, we mentioned that using the Microsoft.AspNetCore.Authentication.OpenIdConnect package automatically adds the implementation of the Sign In endpoint to our sample application. Now, it’s time to implement the remaining part of the BFF API. We will use an ASP.NET MVC controller for these additional endpoints. Let's start by adding a Controllers folder and a file BffController.cs in the BffSample project with the following code inside:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;

namespace BffSample.Controllers;

[ApiController]
[Route("[controller]")]
public class BffController : Controller
{
    public const string CorsPolicyName = "Bff";

    [HttpGet("check_session")]
    [EnableCors(CorsPolicyName)]
    public ActionResult<IDictionary<string, string>> CheckSession()
    {
        // return 401 Unauthorized to force SPA redirection to Login endpoint
        if (User.Identity?.IsAuthenticated != true)
            return Unauthorized();

        return User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value);
    }

    [HttpGet("login")]
    public ActionResult<IDictionary<string, string>> Login()
    {
        // Logic to initiate the authorization code flow
        return Challenge(new AuthenticationProperties { RedirectUri = Url.Content("~/") });
    }

    [HttpPost("logout")]
    public IActionResult Logout()
    {
        // Logic to handle logging out the user
        return SignOut();
    }
}

Let's break down this class code in detail:

  • The [Route("[controller]")] attribute sets the base route for all actions in the controller. In this case, the route will match the name of the controller, meaning all paths to our API methods will start with /bff/.

  • The constant CorsPolicyName = "Bff" defines the name of the CORS (Cross-Origin Resource Sharing) policy for use in method attributes. We will refer to it later.

  • The three methods CheckSession, Login, and Logout implement the necessary BFF functionality described above. They handle GET requests at /bff/check_session, /bff/login and POST requests at /bff/logout respectively.

  • The CheckSession method checks the user's authentication status. If the user is not authenticated, it returns a 401 Unauthorized code, which should force the SPA to redirect to the authentication endpoint. If authentication is successful, the method returns a set of claims and their values. This method also includes a CORS policy binding with the name CorsPolicyName since the call to this method can be cross-domain and contain cookies used for user authentication.

  • The Login method is called by the SPA if the previous CheckSession call returned 401 Unauthorized. It ensures that the user is still not authenticated and initiates the configured Challenge process, which will result in redirection to the OpenID Connect server, user authentication using Authorization Code Flow and PKCE, and issuing an authentication cookie. After this, control returns to the root of our application "~/", which will trigger the SPA to reload and start with an authenticated user.

  • The Logout method is also called by the SPA but terminates the current authentication session. It removes the authentication cookies issued by the server part of BffSample and also calls the End Session endpoint on the OpenID Connect server side.

Configuring CORS for BFF

As mentioned above, the CheckSession method is intended for asynchronous calls from the SPA (usually using the Fetch API). The proper functioning of this method depends on the ability to send authentication cookies from the browser. If the SPA is loaded from a separate Static Web Host, such as a CDN or a dev server running on a separate port, this call becomes cross-domain. This makes configuring a CORS policy necessary, without which the SPA will not be able to call this method.

We already indicated in the controller code in the Controllers\BffController.cs file that the CORS policy named CorsPolicyName = "Bff" is to be used. It's time to configure the parameters of this policy to solve our task. Let's return to the BffSample/Program.cs file and add the following code blocks:

// ******************* START *******************
using BffSample.Controllers;
// ******************** END ********************
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

// ...

builder.Services
    .AddAuthorization()
    .AddAuthentication(options => configuration.Bind("Authentication", options))
    .AddCookie()
    .AddOpenIdConnect(options => configuration.Bind("OpenIdConnect", options));
// ******************* START *******************
builder.Services.AddCors(
    options => options.AddPolicy(
        BffController.CorsPolicyName,
        policyBuilder =>
        {
            var allowedOrigins = configuration.GetSection("CorsSettings:AllowedOrigins").Get<string[]>();

            if (allowedOrigins is { Length: > 0 })
                policyBuilder.WithOrigins(allowedOrigins);

            policyBuilder
                .WithMethods(HttpMethods.Get)
                .AllowCredentials();
        }));
// ******************** END ********************
var app = builder.Build();

This code allows the CORS policy methods to be called from SPAs loaded from sources specified in the configuration as an array of strings CorsSettings:AllowedOrigins, using the GET method and allows cookies to be sent in this call. Also, add the call to app.UseCors(...) right before app.UseAuthentication():

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// ******************* START *******************
app.UseCors(BffController.CorsPolicyName);
// ******************** END ********************
app.UseAuthentication();
app.UseAuthorization();

To ensure the CORS policy works correctly, add the corresponding setting to the BffSample\appsettings.Development.json configuration file:

{
  // ******************* START *******************
  "CorsSettings": {
    "AllowedOrigins": [ "https://localhost:3000" ]
  },
  // ******************** END ********************
 "OpenIdConnect": {
   "Authority": "https://localhost:5001",
   "ClientId": "bff_sample",

In our example, the address https://localhost:3000 is the address where the dev server with the React SPA is launched using the npm run dev command. You can find out this address in your case by opening the BffSample.csproj file and finding the value of the SpaProxyServerUrl parameter. In a real application, the CORS policy might include the address of your CDN (Content Delivery Network) or a similar service. It's important to remember that if your SPA is loaded from a different address and port than the one providing the BFF API, you must add this address to the CORS policy configuration.

Implementing Authentication via BFF in a React Application

We have implemented the BFF API on the server side. Now it is time to focus on the React SPA and add the corresponding functionality to call this API. Let's start by navigating to the BffSample\ClientApp\src\ folder, creating a components folder, and adding a Bff.tsx file with the following content:

import React, { createContext, useContext, useEffect, useState, ReactNode, FC } from 'react';

// Define the shape of the BFF context
interface BffContextProps {
    user: any;
    fetchBff: (endpoint: string, options?: RequestInit) => Promise<Response>;
    checkSession: () => Promise<void>;
    login: () => void;
    logout: () => Promise<void>;
}

// Creating a context for BFF to share state and functions across the application
const BffContext = createContext<BffContextProps>({
    user: null,
    fetchBff: async () => new Response(),
    checkSession: async () => {},
    login: () => {},
    logout: async () => {}
});

interface BffProviderProps {
    baseUrl: string;
    children: ReactNode;
}

export const BffProvider: FC<BffProviderProps> = ({ baseUrl, children }) => {
    const [user, setUser] = useState<any>(null);

    // Normalize the base URL by removing a trailing slash to avoid inconsistent URLs
    if (baseUrl.endsWith('/')) {
        baseUrl = baseUrl.slice(0, -1);
    }

    const fetchBff = async (endpoint: string, options: RequestInit = {}): Promise<Response> => {
        try {
            // The fetch function includes credentials to handle cookies, which are necessary for authentication
            return await fetch(`${baseUrl}/${endpoint}`, {
                credentials: 'include',
                ...options
            });
        } catch (error) {
            console.error(`Error during ${endpoint} call:`, error);
            throw error;
        }
    };

    // The login function redirects to the login page when user needs to authenticate
    const login = (): void => {
        window.location.replace(`${baseUrl}/login`);
    };

    // The checkSession function is responsible for verifying the user session on initial render
    const checkSession = async (): Promise<void> => {
        const response = await fetchBff('check_session');

        if (response.ok) {
            // If the session is valid, update the user state with the received claims data
            setUser(await response.json());
        } else if (response.status === 401) {
            // If the user is not authenticated, redirect him to the login page
            login();
        } else {
            console.error('Unexpected response from checking session:', response);
        }
    };

    // Function to log out the user
    const logout = async (): Promise<void> => {
        const response = await fetchBff('logout', { method: 'POST' });

        if (response.ok) {
            // Redirect to the home page after successful logout
            window.location.replace('/');
        } else {
            console.error('Logout failed:', response);
        }
    };

    // useEffect is used to run the checkSession function once the component mounts
    // This ensures the session is checked immediately when the app loads
    useEffect(() => { checkSession(); }, []);

    return (
        // Providing the BFF context with relevant values and functions to be used across the application
        <BffContext.Provider value={{ user, fetchBff, checkSession, login, logout }}>
            {children}
        </BffContext.Provider>
    );
};

// Custom hook to use the BFF context easily in other components
export const useBff = (): BffContextProps => useContext(BffContext);

// Export HOC to provide access to BFF Context
export const withBff = (Component: React.ComponentType<any>) => (props: any) =>
    <BffContext.Consumer>
        {context => <Component {...props} bffContext={context} />}
    </BffContext.Consumer>;

This file exports:

  • The BffProvider component, which creates a context for BFF and provides functions and state related to authentication and session management for the entire application.

  • The custom hook useBff(), which returns an object with the current user state and functions to work with BFF: checkSession, login, and logout. It is intended for use in functional React components.

  • The Higher-Order Component (HOC) withBff for use in class-based React components.

Next, create a UserClaims component, which will display the current user's claims upon successful authentication. Create a UserClaims.tsx file in the BffSample\ClientApp\src\components folder with the following content:

import React from 'react';
import { useBff } from './Bff';

export const UserClaims: React.FC = () => {
    const { user } = useBff();

    if (!user)
        return <div>Checking user session...</div>;

    return (
        <>
            <h2>User Claims</h2>
            {Object.entries(user).map(([claim, value]) => (
                <div key={claim}>
                    <strong>{claim}</strong>: {String(value)}
                </div>
            ))}
        </>
    );
};

This code checks for an authenticated user using the useBff() hook and displays the user's claims as a list if the user is authenticated. If the user data is not yet available, it displays the text Checking user session....

Now, let's move to the BffSample\ClientApp\src\App.tsx file. Replace its content with the necessary code. Import BffProvider from components/Bff.tsx and UserClaims from components/UserClaims.tsx, and insert the main component code:

import { BffProvider, useBff } from './components/Bff';
import { UserClaims } from './components/UserClaims';

const LogoutButton: React.FC = () => {
    const { logout } = useBff();
    return (
        <button className="logout-button" onClick={logout}>
            Logout
        </button>
    );
};

const App: React.FC = () => (
    <BffProvider baseUrl="https://localhost:5003/bff">
        <div className="card">
            <UserClaims/>
        </div>
        <div className="card">
            <LogoutButton />
        </div>
    </BffProvider>
);

export default App;

Here the baseUrl parameter specifies the base URL of our BFF API https://localhost:5003/bff. This simplification is intentional and made for simplicity. In a real application, you should provide this setting dynamically rather than hard-coding it. There are various ways to achieve this, but discussing them is beyond the scope of this article.

The Logout button allows the user to log out. It calls the logout function available through the useBff hook and redirects the user's browser to the /bff/logout endpoint, which terminates the user's session on the server side.

At this stage, you can now run the BffSample application together with the OpenIDProviderApp and test its functionality. You can use the command dotnet run -lp https in each project or your favorite IDE to start them. Both applications must be running simultaneously.

After this, open your browser and navigate to https://localhost:5003. If everything is set up correctly, the SPA will load and call /bff/check_session. The /check_session endpoint will return a 401 response, prompting the SPA to redirect the browser to /bff/login, which will then initiate authentication on the server via the OpenID Connect Authorization Code Flow using PKCE. You can observe this sequence of requests by opening the Development Console in your browser and going to the Network tab. After successfully entering the user credentials (john.doe@example.com, Jd!2024$3cur3), control will return to the SPA, and you will see the authenticated user's claims in the browser:

sub: 1234567890
sid: V14fb1VQbAFG6JXTYQp3D3Vpa8klMLcK34RpfOvRyxQ
auth_time: 1717852776
name: John Doe
email: john.doe@example.com

Additionally, clicking the Logout button will redirect the browser to /bff/logout, which will log the user out and you will see the login page again with a prompt to enter your username and password.

If you encounter any errors, you can compare your code with our GitHub repository Abblix/Oidc.Server.GettingStarted, which contains this and other examples ready to run.

Resolving HTTPS Certificate Trust Issues

When locally testing web applications configured to run over HTTPS, you may encounter browser warnings that the SSL certificate is not trusted. This issue arises because the development certificates used by ASP.NET Core are not issued by a recognized Certification Authority (CA) but are self-signed or not present in the system at all. These warnings can be eliminated by executing the following command once:

dotnet dev-certs https --trust

This command generates a self-signed certificate for localhost and installs it in your system so that it trusts this certificate. The certificate will be used by ASP.NET Core to run web applications locally. After running this command, restart your browser for the changes to take effect.

Special Note for Chrome Users: Even after installing the development certificate as trusted, some versions of Chrome may still restrict access to localhost sites for security reasons. If you encounter an error indicating that your connection is not secure and access to localhost is blocked by Chrome, you can bypass this as follows:

  • Click anywhere on the error page and type thisisunsafe or badidea, depending on your Chrome version. These keystroke sequences act as bypass commands in Chrome, allowing you to proceed to the localhost site.

It's important to use these bypass methods only in development scenarios, as they can pose real security risks.

Calling Third-Party APIs via BFF

We have successfully implemented authentication in our BffSample application. Now let's proceed to the calling a third-party API that requires an access token.

Imagine we have a separate service that provides the necessary data, such as weather forecasts, and access to it is granted only with an access token. The role of the server part of BffSample will be to act as a reverse proxy, i.e., accept and authenticate the request for data from the SPA, add the access token to it, forward this request to the weather service, and then return the response from the service back to the SPA.

Creating the ApiSample Service

Before demonstrating the remote API call through the BFF, we need to create an application that will serve as this API in our example.

BFF with YARP

To create the application, we will use a template provided by .NET. Navigate to the folder containing the OpenIDProviderApp and BffSample projects, and run the following command to create the ApiSample application:

dotnet new webapi -n ApiSample

This ASP.NET Core Minimal API application serves a single endpoint with path /weatherforecast that provides weather data in JSON format.

First of all, change the randomly assigned port number that the ApiSample application uses locally to a fixed port, 5004. As mentioned earlier, this step is not mandatory but it simplifies our setup. To do this, open the ApiSample\Properties\launchSettings.json file, find the profile named https and change the value of the applicationUrl property to https://localhost:5004.

Now let's make the weather API accessible only with an access token. Navigate to the ApiSample project folder and add the NuGet package for JWT Bearer token authentication:

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Configure the authentication scheme and authorization policy named WeatherApi in the ApiSample\Program.cs file:

// ******************* START *******************
using System.Security.Claims;
// ******************** END ********************
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ******************* START *******************
var configuration = builder.Configuration;

builder.Services
    .AddAuthentication()
    .AddJwtBearer(options => configuration.Bind("JwtBearerAuthentication", options));

const string policyName = "WeatherApi";

builder.Services.AddAuthorization(
    options => options.AddPolicy(policyName, policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireAssertion(context =>
        {
            var scopeValue = context.User.FindFirstValue("scope");
            if (string.IsNullOrEmpty(scopeValue))
                return false;

            var scope = scopeValue.Split(' ', StringSplitOptions.RemoveEmptyEntries);
            return scope.Contains("weather", StringComparer.Ordinal);
        });
    }));
// ******************** END ********************
var app = builder.Build();

This code block sets up authentication by reading the configuration from the application settings, includes authorization using JWT (JSON Web Tokens) and configures an authorization policy named WeatherApi. The WeatherApi authorization policy sets the following requirements:

  • policy.RequireAuthenticatedUser(): Ensures that only authenticated users can access protected resources.

  • policy.RequireAssertion(context => ...): The user must have a scope claim that includes the value weather. Since the scope claim can contain multiple values separated by spaces according to RFC 8693, the actual scope value is split into individual parts, and the resulting array is checked to contain the required weather value.

Together, these conditions ensure that only authenticated users with an access token authorized for the weather scope can call the endpoint protected by this policy.

We need to apply this policy to the /weatherforecast endpoint. Add the call to RequireAuthorization() as shown below:

app.MapGet("/weatherforecast", () =>
{

// ...

})
.WithName("GetWeatherForecast")
// ******************* START *******************
.WithOpenApi()
.RequireAuthorization(policyName);
// ******************** END ********************

Add the necessary configuration settings for the authentication scheme to the appsettings.Development.json file of the ApiSample application:

{
  // ******************* START *******************
  "JwtBearerAuthentication": {
    "Authority": "https://localhost:5001",
    "MapInboundClaims": false,
    "TokenValidationParameters": {
      "ValidTypes": [ "at+jwt" ],
      "ValidAudience": "https://localhost:5004",
      "ValidIssuer": "https://localhost:5001"
    }
  },
  // ******************** END ********************
  "Logging": {
    "LogLevel": {
      "Default": "Information",

Let's examine each setting in detail:

  • Authority: This is the URL pointing to the OpenID Connect authorization server that issues JWT tokens. The authentication provider configured in the ApiSample application will use this URL to obtain the information needed to verify tokens, such as signing keys.

  • MapInboundClaims: This setting controls how incoming claims from the JWT token are mapped to internal claims in ASP.NET Core. It is set to false, meaning claims will use their original names from the JWT.

  • TokenValidationParameters:

    • ValidTypes: Set to at+jwt, which according to RFC 9068 2.1 indicates an Access Token in JWT format.
    • ValidAudience: Specifies that the application will accept tokens issued for the client https://localhost:5004.
    • ValidIssuer: Specifies that the application will accept tokens issued by the server https://localhost:5001.

Additional Configuration of OpenIDProviderApp

The combination of the authentication service OpenIDProviderApp and the client application BffSample works well for providing user authentication. However, to enable calls to a remote API, we need to register ApiSample application as a resource with OpenIDProviderApp. In our example, we use the Abblix OIDC Server, which supports RFC 8707: Resource Indicators for OAuth 2.0. Therefore, we will register the ApiSample application as a resource with the scope weather. If you are using another OpenID Connect server that does not support Resource Indicators, it is still recommended to register a unique scope for this remote API (such as weather in our example).

Add the following code to the file OpenIDProviderApp\Program.cs:

// Register and configure Abblix OIDC Server
builder.Services.AddOidcServices(options => {
    // ******************* START *******************
    options.Resources =
    [
        new(new Uri("https://localhost:5004", UriKind.Absolute), new ScopeDefinition("weather")),
    ];
    // ******************** END ********************
    options.Clients = new[] {
        new ClientInfo("bff_sample") {

In this example, we register the ApiSample application, specifying its base address https://localhost:5004 as a resource and defining a specific scope named weather. In real-world applications, especially those with complex APIs consisting of many endpoints, it is advisable to define separate scopes for each individual endpoint or group of related endpoints. This approach allows for more precise access control and provides flexibility in managing access rights. For instance, you can create distinct scopes for different operations, application modules, or user access levels, enabling more granular control over who can access specific parts of your API.

Elaboration of BffSample for Proxying Requests to a Remote API

The client application BffSample now needs to do more than just request an access token for ApiSample. It must also handle requests from the SPA to the remote API. This involves adding the access token obtained from the OpenIDProviderApp service to these requests, forwarding them to the remote server, and then returning the server's responses back to the SPA. In essence, BffSample needs to function as a reverse proxy server.

Instead of manually implementing request proxying in our client application, we will use YARP (Yet Another Reverse Proxy), a ready-made product developed by Microsoft. YARP is a reverse proxy server written in .NET and available as a NuGet package.

To use YARP in the BffSample application, first add the NuGet package:

dotnet add package Yarp.ReverseProxy

Then add the following namespaces at the beginning of the file BffSample\Program.cs:

using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using System.Net.Http.Headers;
using Yarp.ReverseProxy.Transforms;

Before the call var app = builder.Build();, add the code:

builder.Services.AddHttpForwarder();

And between the calls to app.MapControllerRoute() and app.MapFallbackToFile():

app.MapForwarder(
    "/bff/{**catch-all}",
    configuration.GetValue<string>("OpenIdConnect:Resource") ?? throw new InvalidOperationException("Unable to get OpenIdConnect:Resource from current configuration"),
    builderContext =>
    {
        // Remove the "/bff" prefix from the request path
        builderContext.AddPathRemovePrefix("/bff");

        builderContext.AddRequestTransform(async transformContext =>
        {
            // Get the access token received earlier during the authentication process
            var accessToken = await transformContext.HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
            
            // Append a header with the access token to the proxy request
            transformContext.ProxyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        });
    }).RequireAuthorization();

Let's break down what this code does:

  • builder.Services.AddHttpForwarder() registers the YARP necessary services in the DI container.

  • app.MapForwarder sets up request forwarding to another server or endpoint.

  • "/bff/{**catch-all}" is the path pattern that the reverse proxy will respond to. All requests starting with /bff/ will be processed by YARP. {**catch-all} is used to capture all remaining parts of the URL after /bff/.

  • configuration.GetValue<string>("OpenIdConnect:Resource") uses the application's configuration to get the value from the OpenIdConnect:Resource section. This value specifies the resource address to which the requests will be forwarded. In our example, this value will be https://localhost:5004 - the base URL where the ApiSample application operates.

  • builderContext => ... adds the necessary transformations that YARP will perform on each incoming request from the SPA. In our case, there will be two such transformations:

    • builderContext.AddPathRemovePrefix("/bff") removes the /bff prefix from the original request path.

    • builderContext.AddRequestTransform(async transformContext => ...) adds an Authorization HTTP header to the request, containing the access token that was previously obtained during authentication. Thus, requests from the SPA to the remote API will be authenticated using the access token, even though the SPA itself does not have access to this token.

  • .RequireAuthorization() specifies that authorization is required for all forwarded requests. Only authorized users will be able to access the route /bff/{**catch-all}.

To request an access token for the resource https://localhost:5004 during authentication, add the Resource parameter with the value https://localhost:5004 to the OpenIdConnect configuration in the BffSample/appsettings.Development.json file:

  "OpenIdConnect": {
    // ******************* START *******************
    "Resource": "https://localhost:5004",
    // ******************** END ********************
    "Authority": "https://localhost:5001",
    "ClientId": "bff_sample",

Also, add another value weather to the scope array in the BffSample/appsettings.json file:

{
  "OpenIdConnect": {

    // ...

    // ******************* START *******************
    "Scope": ["openid", "profile", "email", "weather"],
    // ******************** END ********************

    // ...

  }
}
NOTES

In a real project, it is necessary to monitor the expiration of the access token. When the token is about to expire, you should either request a new one in advance using a refresh token from the authentication service or handle an access denial error from the remote API by obtaining a new token and retrying the original request. For the sake of brevity, we have deliberately omitted this aspect in this article.

Requesting the Weather API via BFF in the SPA Application

The backend is ready now. We have the ApiSample application, which implements an API with token-based authorization, and the BffSample application, which includes an embedded reverse proxy server to provide secure access to this API. The final step is to add the functionality to request this API and display the obtained data within the React SPA.

Add the file WeatherForecast.tsx in BffSample\ClientApp\src\components with the following content:

import React, { useEffect, useState } from 'react';
import { useBff } from './Bff';

interface Forecast {
    date: string;
    temperatureC: number;
    temperatureF: number;
    summary: string;
}

interface State {
    forecasts: Forecast[];
    loading: boolean;
}

export const WeatherForecast: React.FC = () => {
    const { fetchBff } = useBff();
    const [state, setState] = useState<State>({ forecasts: [], loading: true });
    const { forecasts, loading } = state;

    useEffect(() => {
        fetchBff('weatherforecast')
            .then(response => response.json())
            .then(data => setState({ forecasts: data, loading: false }));
    }, [fetchBff]);

    const contents = loading
        ? <p><em>Loading...</em></p>
        : (
            <table className="table table-striped" aria-labelledby="tableLabel">
                <thead>
                <tr>
                    <th>Date</th>
                    <th>Temp. (C)</th>
                    <th>Temp. (F)</th>
                    <th>Summary</th>
                </tr>
                </thead>
                <tbody>
                {forecasts.map((forecast, index) => (
                    <tr key={index}>
                        <td>{forecast.date}</td>
                        <td align="center">{forecast.temperatureC}</td>
                        <td align="center">{forecast.temperatureF}</td>
                        <td>{forecast.summary}</td>
                    </tr>
                ))}
                </tbody>
            </table>
        );

    return (
        <div>
            <h2 id="tableLabel">Weather forecast</h2>
            <p>This component demonstrates fetching data from the server.</p>
            {contents}
        </div>
    );
};

Let's break down this code:

  • The Forecast interface defines the structure of the weather forecast data, which includes the date, temperature in Celsius and Fahrenheit, and a summary of the weather. The State interface describes the structure of the component's state, consisting of an array of weather forecasts and a loading flag.

  • The WeatherForecast component retrieves the fetchBff function from the useBff hook and uses it to fetch weather data from the server. The component's state is managed using the useState hook, initializing with an empty array of forecasts and a loading flag set to true.

  • The useEffect hook triggers the fetchBf function when the component mounts, fetching weather forecast data from the server at the /bff/weatherforecast endpoint. Once the server's response is received and converted to JSON, the data is stored in the component's state (via setState), and the loading flag is updated to false.

  • Depending on the value of the loading flag, the component either displays a "Loading..." message or renders a table with the weather forecast data. The table includes columns for the date, temperature in Celsius and Fahrenheit, and a summary of the weather for each forecast.

Now, add the WeatherForecast component to BffSample\ClientApp\src\App.tsx:

// ******************* START *******************
import { WeatherForecast } from "./components/WeatherForecast";
// ******************** END ********************

// ...

    <div className="card">
        <UserClaims/>
    </div>
    // ******************* START *******************
    <div className="card">
        <WeatherForecast/>
    </div>
    // ******************** END ********************
    <div className="card">
        <button className="logout-button" onClick={logout}>
            Logout
        </button>
    </div>

Running and Testing

If everything has been done right, you can now start all three of our projects. Use the console command dotnet run -lp https for each application to run them with HTTPS.

After launching all three applications, open the BffSample application in your browser and authenticate using the credentials john.doe@example.com and Jd!2024$3cur3. Upon successful authentication, you should see the list of claims received from the authentication server, as seen previously. Below this, you will also see the weather forecast.

The weather forecast is provided by the separate application ApiSample, which uses an access token issued by the authentication service OpenIDProviderApp. Seeing the weather forecast in the BffSample application window indicates that our SPA successfully called the backend of BffSample, which then proxied the call to ApiSample by adding the access token. ApiSample authenticated the call and responded with a JSON containing the weather forecast.

The Complete Solution is Available on GitHub

If you encounter any issues or errors while implementing the test projects, you can refer to the complete solution available in the GitHub repository. Simply clone the repository Abblix/Oidc.Server.GettingStarted to access the fully implemented projects described in this article. This resource serves both as a troubleshooting tool and as a solid starting point for creating your own projects.

Conclusion

The evolution of authentication protocols like OAuth 2.0 and OpenID Connect reflects the broader trends in web security and browser capabilities. Moving away from outdated methods like the Implicit Flow toward more secure approaches, such as the Authorization Code Flow with PKCE, has significantly enhanced security. However, the inherent vulnerabilities of operating in uncontrolled environments make securing modern SPAs a challenging task. Storing tokens exclusively on the backend and adopting the Backend-For-Frontend (BFF) pattern is an effective strategy for mitigating risks and ensuring robust user data protection.

Developers must remain vigilant in addressing the ever-changing threat landscape by implementing new authentication methods and up-to-date architectural approaches. This proactive approach is crucial to building secure and reliable web applications. In this article, we explored and implemented a modern approach to integrating OpenID Connect, BFF, and SPA using the popular .NET and React technology stack. This approach can serve as a strong foundation for your future projects.

As we look to the future, the continued evolution of web security will demand even greater innovation in authentication and architectural patterns. We encourage you to explore our GitHub repository, contribute to the development of modern authentication solutions and stay engaged with the ongoing advancements. Thank you for your attention!


Was this article helpful?