- Published on
Securing SPA React app with Duende BFF
Introduction
Storing access tokens in the browser was never a good idea. Unfortunately, implicit flow is based on storing access token in the browser. That is the reason implicit flow is not recommended anymore. In this flow the access token is returned as part of the url and access token in urls is extremely leaky, they end up in
- browser history
- log files
- Reverse proxies
- Browser extensions
- Referrer headers, so say you access a cdn and the cdn via the referer http request header can acquire the access token.
Shown below is a schematic of the implicit flow.
To prevent access tokens from being returned in the url, the authorization code flow was used where the client app receives an auth code and then exchanges it for an access token. Shown below is the schematic of the authorization code flow.
The problem of storing and managing access tokens in the browser will still remain though. Code injections attacks allow for exfilteration of storage contents. Injection attacks is top ten in the OWASP top ten list 1
In addition, browser vendors have been debating phasing out 3rd party cookies for some time now. It seems they’ve finally decided to bite the bullet per this article from developer chrome site. This would break any application that uses the imlicit flow.
In implicit flow the client app recieves the access token and then via the silent renew mechanism calls the IDP from an iframe
to return a new token passing the cookie in the header. But now with 3rd party cookie disallowed, the iframe
will not have access to this cookie anymore. So, essentially, the client is not able to maintain a session with the server and the server says "hey, client, you aren't even logged in". 2
Using BFF architecture to solve the problem
BFF architecture introduces an intermediary layer that runs on the server and since it runs on the server it can manage tokens encrypted in HttpOnly
cookies. The client app which now runs on the browser is absolved of all the responsibility of managing tokens. The client app via a reverse proxy simply proxies over any API calls to the intermediary layer and the intermediary layer can access the tokens stored in the HttpOnly
cookie and make any calls to the external API sending the access tokens in the header or these calls can be to the local API within the intermediary layer itself and here too the access token will be sent in the header.
Shown below is the schematic of the BFF architecture.
Creating the backend part of the backend for frontend (BFF)
The entire source code is available here
But, I'll walk through the specific pieces we added so it is easy to see the additions we made.
Start by adding the Duende.BFF
nuget package 3 to a fresh dot net core web api project
First you have to register the BFF services with the DI container and add services required for YARP http forwarding.
builder.Services.AddBff()
.AddRemoteApis();
Next, just as we would do with any other server side client that requires authentication, you'll add the AddAuthentication
to register the services required for authentication. Optionally, we can explicitly specify the cookie scheme where the access token will be stored. Finally, we must register the AddOpenIdConnect
handler to handle the authentication.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = "oidc";
options.DefaultSignOutScheme = "oidc";
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.Cookie.Name = "__ReactSPA";
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = identityServerConfiguration.BaseUrl.ToLower();
options.ClientId = clientConfiguration.ClientId;
options.ClientSecret = clientConfiguration.ClientSecret;
options.ResponseType = "code";
options.Scope.Add("roles");
options.Scope.Add("subscriberSince");
options.Scope.Add("notesapi.write");
options.Scope.Add("notesapi.read");
options.Scope.Add("notesapi.fullaccess");
options.ClaimActions.Remove("aud");
options.ClaimActions.DeleteClaim("sid");
options.ClaimActions.DeleteClaim("idp");
options.ClaimActions.MapJsonKey("role", "role");
options.ClaimActions.MapJsonKey("subscriberSince", "subscriberSince");
options.TokenValidationParameters = new TokenValidationParameters
{
RoleClaimType = "role",
NameClaimType = "given_name"
};
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
});
Next, we'll add the bff middleware by calling UseBff
.
MapBffManagementEndpoints
adds support for login, logout and logout notifications.
AsBffApiEndpoint
adds support for local APIs. This includes anti-forgery protection as well as suppressing login redirects on authentication failures and instead returning 401
and 403
status codes under the appropriate circumstances.
Finally, MapRemoteBffApiEndpoint
proxies any local calls to the actual remote api passing the access token.
app.UseAuthentication();
app.UseBff();
app.UseAuthorization();
app.MapControllers().AsBffApiEndpoint();
app.MapBffManagementEndpoints();
app.MapRemoteBffApiEndpoint(
"/api/notes", "https://localhost:7094/api/Note/GetNotes")
.RequireAccessToken(TokenType.User);
Let's now test to see if we have configured everything properly. Set the startup projects as MakeBitByte.IDP
and ReactClientAppBFF
and run the solution.
Once ReactClientAppBFF
starts up, call the https://localhost:7249/bff/login
endpoint. This should redirect you to the MakeBitByte.IDP
login page. And once you login using say appa
with password P@ssw0rd
you should get redirected back the index page.
If you open your developer tools and look at the cookies you should see the __ReactSPA
cookie.
If you attempt to access the https://localhost:7249/bff/user
endpoint then you'll get an 401
error. If you look at the logs then you'll see the following error.
fail: Duende.Bff.Endpoints.BffMiddleware[1]
Anti-forgery validation failed. local path: '/bff/user'
The bff middleware adds anti-forgery protection to all calls and since we didn't add the anti-forgery teken "X-CSRF": '1'
to the header we get this error.
Notes API with authorization
This is just a regular dot net core web api project whos endpoints are behind an IDP. In other words these require an access token
.
The source code for this api is available here It requires an access token
issued by the MakeBitByte.IDP.
React client app
Finally, we'll add the frontend for the backend for frontend (BFF). This now is the react client app.
The source code for this app is available here.
I created the app with vite using the following command.
npm create vite@latest reactclientappbff -- --template react-ts
Then I added Tailwind CSS to the app using this guide.
I added react query as well as axios to the app.
npm install react-query axios
update the main.tsx
to look like so
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { QueryClient, QueryClientProvider } from "react-query";
import { BrowserRouter } from "react-router-dom";
// Create a client
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
{/* Provide the client to your App */}
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
Wrapping the App
component with QueryClientProvider
makes the queryClient
available to all the components in the app.
We'll now create a custom useAuthUser
hook. Within this hook we'll use the useQuery
hook from react-query
to fetch the claims from the /bff/user
endpoint.
So, our custom useAuthUser
hook will look like so, if you need a quick refresher on what custom hooks are or how to create them then check out this article.
import axios from "axios";
import { useEffect, useState } from "react";
import { useQuery } from "react-query";
interface AuthUser {
given_name: string | undefined;
family_name: string | undefined;
email: string;
sub: string;
roles: string[];
bffLogoutUrl: string;
}
const config = {
headers: {
"X-CSRF": "1",
},
};
export const fetchClaims = async () => {
const response = await axios.get("/bff/user", config);
return response.data;
};
function useClaims() {
const { isLoading, error, data } = useQuery("claims", fetchClaims, {
retry: false,
});
return { isLoading, error, data };
}
export function useAuthUser() {
const { isLoading, error, data } = useClaims();
const claims = data as [{ type: string; value: string }];
const [user, setUser] = useState<AuthUser | null>(null);
useEffect(() => {
if (claims) {
const given_name =
claims.find((c) => c.type === "given_name")?.value ?? "";
const family_name =
claims.find((c) => c.type === "family_name")?.value ?? "";
const email = claims.find((c) => c.type === "email")?.value ?? "";
const sub = claims.find((c) => c.type === "sub")?.value ?? "";
const roles = claims.filter((c) => c.type === "role").map((c) => c.value);
const bffLogoutUrl =
claims.find((c) => c.type === "bff:logout_url")?.value ?? "";
setUser({ given_name, family_name, email, sub, roles, bffLogoutUrl });
}
}, [claims]);
return { isLoading, error, claims, user };
}
But how do we get to this /bff/user
endpoint? We are routing to this endpoint as if this exists in our local app when in fact it exists in the ReactClientAppBFF
project. This is where the reverse proxy comes in. We'll add a reverse proxy to the vite.config.ts
.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import basicSsl from "@vitejs/plugin-basic-ssl";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), basicSsl()],
server: {
proxy: {
"/bff": {
target: "https://localhost:7249",
secure: false,
configure: (proxy, _options) => {
proxy.on("error", (err, _req, _res) => {
console.log("proxy error", err);
});
proxy.on("proxyReq", (proxyReq, req, _res) => {
console.log("Sending Request to the Target:", req.method, req.url);
});
proxy.on("proxyRes", (proxyRes, req, _res) => {
console.log(
"Received Response from the Target:",
proxyRes.statusCode,
req.url
);
});
},
},
"/signin-oidc": {
target: "https://localhost:7249",
secure: false,
},
"/signout-callback-oidc": {
target: "https://localhost:7249",
secure: false,
},
"/api/notes": {
target: "https://localhost:7249",
secure: false,
configure: (proxy, _options) => {
proxy.on("error", (err, _req, _res) => {
console.log("proxy error", err);
});
proxy.on("proxyReq", (proxyReq, req, _res) => {
console.log("Sending Request to the Target:", req.method, req.url);
});
proxy.on("proxyRes", (proxyRes, req, _res) => {
console.log(
"Received Response from the Target:",
proxyRes.statusCode,
req.url
);
});
},
},
"/local/identity": {
target: "https://localhost:7249",
secure: false,
},
},
},
});
So, now all calls to /bff/user
will be proxied to https://localhost:7249/bff/user
.
This virtual endpoint https://localhost:7249/bff/user
is added by the app.MapBffManagementEndpoints()
statement in the program.cs
file of the ReactClientAppBFF
project. Here's the extension method's implementation.
public static void MapBffManagementEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapBffManagementLoginEndpoint();
endpoints.MapBffManagementSilentLoginEndpoints();
endpoints.MapBffManagementLogoutEndpoint();
endpoints.MapBffManagementUserEndpoint();
endpoints.MapBffManagementBackchannelEndpoint();
endpoints.MapBffDiagnosticsEndpoint();
}
All calls to the /bff/user
endpoint will have the HttpOnly
cookie attached to the request and the bff
middleware in the ReactClientAppBFF
project will be able to extract the access token from the cookie and make the call to the IDP to get the claims. We specify that the claims must fetched from the IDP's user info endpoint in the AddOpenIdConnect
handler in the ReactClientAppBFF
project.
.AddOpenIdConnect("oidc", options =>
{
// code omitted for brevity
options.GetClaimsFromUserInfoEndpoint = true;
});
We get this httpOnly
cookie in our browser from the login procedure as follows. Once we call /bff/login
endpoint we get redirected to the IDP's login page. When the user is sent to the IDP's login page, the redirect_uri
is set to /signin-oidc
on the origin of the react app since the react app initiates the login process, so this then translates to https://localhost:5173/signin-oidc
.
In the vite.config.ts
where we set the proxy, you'll see that /signin-oidc
is proxied to https://localhost:7249/signin-oidc
.
"/signin-oidc": {
target: "https://localhost:7249",
secure: false,
},
https://localhost:7249/signin-oidc
is a virtual endpoint on our ReactClientAppBFF
project. Once the user has successfully logged in, they'll be returned to this endpoint with the tokens from the IDP. And, this where the bff
middleware will wrap this access token in a HttpOnly
cookie and set the HttpOnly
cookie on the response. And we get routed back to the index page of our react app.
This is the reason why we must add these two endpoints to the list of allowed uris in the RedirectUris
in the client
configuration in the IDP's Config.cs
file.
RedirectUris =
{
"https://localhost:7249/signin-oidc",
"https://localhost:5173/signin-oidc"
}
Alright, now that we have the HttpOnly
cookie, we are free to make calls to other endpoints such as /api/notes
which is proxied to https://localhost:7249/api/notes
. Again, take a look at the vite.config.ts
file where we set the proxy.
"/api/notes": {
target: "https://localhost:7249",
secure: false,
}
Remember, this call will have the cookie set in the header. If you remember, our ReactClientAppBFF
project has the following in the Program.cs
file
app.MapRemoteBffApiEndpoint(
"/api/notes", "https://localhost:7094/api/Note/GetNotes")
.RequireAccessToken(TokenType.User);
This means that any calls to /api/notes
on the ReactClientAppBFF
project which translates to https://localhost:7249/api/notes
will now be further proxied over to https://localhost:7094/api/Note/GetNotes
and the access token which is extracted from the HttpOnly
cookie will be sent in the header.
And the resultant json response will be returned to the react app which is encapsulated in the custom hook useNotes
shown below.
import axios from "axios";
import { useQuery } from "react-query";
const config = {
headers: {
"X-CSRF": "1",
},
};
const fetchNotes = async () => {
const response = await axios("/api/notes", config);
return response.data;
};
const useNotes = () => {
const { isLoading, error, data } = useQuery("notes", fetchNotes, {
retry: false,
});
return { isLoading, error, data };
};
export default useNotes;
Conclusion
Caveat emptor, goes without saying that this isn't production ready just yet, so for example, you'll want to add error handling so your app fails gracefully, and the vite reverse proxy is only good for local development. In a production scenario you'll probably want to add yarp, The primary intent of this article was to explain the main concepts behind the BFF architecture while securing a react app, and though the Duende BFF middleware makes it easy to implement the BFF architecture for securing a browser based apps, there is a fair amount of complexity under the hood and this article delves into some of those so it doesn't seem like all magic, and it always helps to know what is going on beneath the surface especially when one is attempting to debug a rather intractable problem.