- 05 Nov 2024
- 37 Minutes to read
- Print
Modern Authentication on .NET: OpenID Connect, BFF, SPA
- Updated on 05 Nov 2024
- 37 Minutes to read
- Print
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:
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:
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.
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.
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.
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:
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: TheDefaultScheme
property sets authentication by default using theCookies
scheme, andDefaultChallengeScheme
delegates the execution of authentication to theOpenIdConnect
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
andSignOutScheme
properties specify theCookies
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
andClientSecret
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 theBffClient
application requests access to. In this case, the standard scopesopenid
(user identifier),profile
(user profile), andemail
(email) are requested.MapInboundClaims
is responsible for transforming incoming claims from the OpenID Connect server into claims used in the application. A value offalse
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 valuecode
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
, andLogout
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 a401 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 nameCorsPolicyName
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 previousCheckSession
call returned401 Unauthorized
. It ensures that the user is still not authenticated and initiates the configuredChallenge
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 ofBffSample
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
, andlogout
. 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
orbadidea
, depending on your Chrome version. These keystroke sequences act as bypass commands in Chrome, allowing you to proceed to thelocalhost
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.
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 ascope
claim that includes the valueweather
. Since thescope
claim can contain multiple values separated by spaces according to RFC 8693, the actualscope
value is split into individual parts, and the resulting array is checked to contain the requiredweather
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 theApiSample
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 tofalse
, meaning claims will use their original names from the JWT.TokenValidationParameters
:ValidTypes
: Set toat+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 clienthttps://localhost:5004
.ValidIssuer
: Specifies that the application will accept tokens issued by the serverhttps://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 theOpenIdConnect:Resource
section. This value specifies the resource address to which the requests will be forwarded. In our example, this value will behttps://localhost:5004
- the base URL where theApiSample
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 anAuthorization
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 ********************
// ...
}
}
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. TheState
interface describes the structure of the component's state, consisting of an array of weather forecasts and a loading flag.The
WeatherForecast
component retrieves thefetchBff
function from theuseBff
hook and uses it to fetch weather data from the server. The component's state is managed using theuseState
hook, initializing with an empty array of forecasts and a loading flag set to true.The
useEffect
hook triggers thefetchBf
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 (viasetState
), and the loading flag is updated tofalse
.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!