Managing OpenAPI Specifications with Backend For Frontend and Swagger UI
ASP.NET Core web application development can span from entirely server-side rendered UIs to front-end single-page applications dominating the user experience. More often than not, you’ll likely have some client side code that fetches information from a backend resource, whether an endpoint or a multipurpose API. As the .NET Identity company, Duende recommends securing these calls, but you may be asking, how?
Security recommendations have evolved with the popularity of JavaScript and the initial explosion of single-page application frameworks. As of this post, the best current practices recommend developers secure their browser-based application with Backend For Frontend (BFF) and adopt a “no tokens in the browser” policy. Realizing that a browser client’s storage is a potential attack vector means malicious parties can target high-value resources such as refresh and access tokens stored locally, leading to compromised security models.
In this post, we’ll briefly recap the BFF pattern and then proceed to a sample of revealing your OpenAPI specifications to users either with Swagger’s UI or client SDK generators.
But first, let’s talk about BFF for folks who are first learning about the pattern.
What is Backend For Frontend (BFF)?
In a JavaScript-heavy user experience, the code executing on the client must make fetch
requests to a remote resource. The remote resources may include backend endpoints, remote APIs, or third-party services, with each resource requiring authentication and authorization.
For developers, securing APIs relies on tokens supplied by some combination of OAuth and OpenID. From a client perspective, tokens must be passed along to the resource to complete the request. But where do clients get these tokens?
Before BFF, after a successful authentication attempt, developers stored a user’s tokens in the client and managed them with front-end code. Today, security experts classify the client and its storage facilities as high-risk locations. If a user’s machine were compromised, an attacker could exfiltrate these tokens and use them in nefarious ways.
With BFF, a dedicated backend manages all tokens on the server. The client and server use HTTP-only cookies to create a trusted connection. When a client requests a resource from an API endpoint, it must first go through the BFF, which attaches tokens to requests before proxying them to their destination. By relying on Cross-Origin Resource Sharing protections built into modern browsers, BFF can also protect against common attacks, such as Cross-Site Forgery.
The use of BFF has some implications for developing a web application. From the perspective of the JavaScript code that runs in the browser, all APIs are compiled into a single unified API surface, whether you have one API or many. The most beneficial implication is that frontend code no longer needs to manage tokens, and all requests are securely and adequately authenticated, easing the burden on development teams.
Learn more about BFF in our documentation.
In the next section, we’ll examine how to combine the OpenAPI specifications generated by each API to improve the user experience for clients who consume it.
The Architecture of a BFF-Powered Solution
We’ll build a BFF solution with one Javascript-powered client application and two remote APIs providing data for the experience.
You can find the source code for this article on GitHub.
The implementation details of each aren’t essential, but how we configure them to work together is. Each API will expose an OpenAPI specification describing the available endpoints, including the HTTP method, expected request, and possible responses. A Swagger UI will consume these specifications as part of the BFF host. JavaScript and TypeScript clients can also use these specifications to generate typed contracts.
Before examining the code, let’s briefly discuss why OpenAPI is essential to development teams.
Why is OpenAPI part of a modern development solution?
OpenAPI (formerly known as Swagger) is a specification for describing HTTP APIs. It defines a standard format for describing API endpoints, request formats, and response codes. OpenAPI is crucial because it allows developers to understand and use APIs easily and facilitates the generation of documentation and code clients.
With the popularity of polyglot development, it’s become very popular to generate type-safe clients based on OpenAPI specifications provided by the server. The benefits are clear: Compile-time safety and idiomaticity for all API calls.
Some teams will manually create their OpenAPI contracts and generate client and server contracts from them. In contrast, others generate the OpenAPI contract from the server using tools like Swagger Codegen or Kiota. If your backend exposes a single (local) API built using .NET, exposing an OpenAPI contract using ASP.NET Core built-in functionality is straightforward.
OpenAPI is a highly flexible solution for a complex world, and there are many reasons why OpenAPI matters to modern developers. The outcomes can vary depending on the quality of the specifications provided and how consumers use them.
Working with ASP.NET Core APIs and OpenAPI
We’ll use .NET Aspire to coordinate all the elements described in the previous section. That includes one client application and two backend APIs. Here is our .NET Aspire App Host.
using Microsoft.Extensions.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var api1 = builder.AddProject<Projects.OpenApi_Api1>(Services.Api1.ToString());
var api2 = builder.AddProject<Projects.OpenApi_Api2>(Services.Api2.ToString());
var bff = builder.AddProject<Projects.OpenApi_Bff>(Services.Bff.ToString());
bff.WithReference(api1)
.WithReference(api2)
.WithReference(bff);
builder.Build().Run();
Let’s start by registering our two API endpoints in our Program
of the BFF Host. Note that we’ll use .NET Aspire’s service discovery to map named resources to their final destination so that URIs in C# may look strange at first glance.
app.MapRemoteBffApiEndpoint("/api1", "https://api1")
.WithOptionalUserAccessToken();
app.MapRemoteBffApiEndpoint("/api2", "https://api2")
.WithOptionalUserAccessToken();
We now have two APIs we can call from JavaScript, which our client can access through the paths/api1
and /api2,
respectively. The C# registration marks the user access token as optional because the OpenAPI specification endpoint allows anonymous access. In contrast, each target endpoint can still authorize and reject incoming requests.
These APIs also use the OpenAPI functionality to produce their respective specifications. In the API projects, we call AddOpenApi
and register the OpenAPI services.
public static TBuilder AddDefaultOpenApiConfig<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi(options =>
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>()
);
return builder;
}
The BearerSecuritySchemeTransformer
in our sample makes sure the requirement for bearer token authentication is added to the OpenAPI specification for both APIs.
Along with the OpenAPI services, we must register the middleware in the ASP.NET Core pipeline that exposes our OpenAPI endpoint.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
Now, back in our BFF host, we can register the Swagger UI and our new OpenAPI specification JSON files from each API.
app.UseSwaggerUI(c =>
{
// Add all swagger endpoints for all APIs
c.SwaggerEndpoint("/api1/openapi/v1.json", "Api #1");
c.SwaggerEndpoint("/api2/openapi/v1.json", "Api #2");
});
We need one more line in our UseSwaggerUI
method to satisfy the CSRF protection provided by BFF.
// Inject a javascript function to add a CSRF header to all requests
c.UseRequestInterceptor("function(request){ request.headers['X-CSRF'] = '1';return request;}");
While most of our infrastructure is now connected, we must take additional steps to make everything work. In the next section, we’ll see how to transform each specification so that endpoints map correctly through our BFF and match the expected paths.
Note: Users must authenticate to access endpoints through the Swagger UI. The final sample implements a JavaScript-based login button injected into the Swagger UI. Forcing authentication can be a developer’s implementation choice.
OpenAPI Specification Transformers for BFF Hosts
While we’ve taken some essential steps in our solution, right now, the swagger documents are direct proxies from the server, which has several problems:
- Server URL: The server URLs in these documents represent the direct URL to the API. Clients shouldn’t access the API directly but through the BFF.
- Security: The APIs are protected using bearer tokens. However, the frontend shouldn’t send bearer tokens but should send authentication cookies instead.
- Local Path: The URL the frontend calls must include the local path prefix. Therefore, we must append the local path to each URL.
So, we’ll have to modify the OpenAPI specifications. There are several ways to handle the transformation. We’ll demonstrate how to do this using a Yarp transform, but you could also implement an endpoint directly that performs this function.
The following code adds a Yarp transform to ALL requests by changing the default BffYarpTransformBuilder
. We’ll check if we’re proxying an OpenAPI document inside this transform and adjust accordingly.
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
using OpenApi.BffOpenApiDocumentParser;
using Yarp.ReverseProxy.Transforms;
namespace OpenApi.Bff;
/// <summary>
/// TransformOpenApiDocumentForBff the openapi document as it's being streamed.
/// </summary>
/// <param name="basePath"></param>
public class OpenApiResponseTransform(string basePath) : ResponseTransform
{
public override async ValueTask ApplyAsync(ResponseTransformContext context)
{
// Check if the request path matches /openapi/{document}.json / .yaml
if (ProxyingOpenApiDocument(context))
{
if (context.ProxyResponse == null)
// nothing to do if no response from the proxy
return;
var outputStream = context.HttpContext.Response.Body;
// This line is needed because we're going to modify the output stream.
// If we don't do this, it's going to send both the original and the modified stream.
context.SuppressResponseBody = true;
var openApiDocumentStream = await context.ProxyResponse.Content.ReadAsStreamAsync();
await OpenApiTransformer.TransformOpenApiDocumentForBff(openApiDocumentStream, outputStream,
Services.Bff.ActualUri(), basePath);
}
}
private bool ProxyingOpenApiDocument(ResponseTransformContext context)
{
return context.HttpContext.Request.Path.StartsWithSegments($"{basePath}/openapi", out var remainingPath) &&
remainingPath.HasValue &&
(remainingPath.Value.EndsWith(".json") || remainingPath.Value.EndsWith(".yaml"));
}
}
Next, we’ll need to add this implementation to our services collection, which will now check all requests proxied through the BFF and whether they are OpenAPI specifications.
builder.Services.AddSingleton<BffYarpTransformBuilder>((path, c) =>
{
DefaultBffYarpTransformerBuilders.DirectProxyWithAccessToken(path, c);
c.ResponseTransforms.Add(new OpenApiResponseTransform(path));
});
Rerunning our BFF Host and navigating to the UI, we can see our API specifications in the Swagger UI dropdown.
This result is excellent, but what if we wanted a single specification that combines all APIs into one all-encompassing specification?
Combining All OpenAI Specifications into One
To combine all OpenAPI specifications into a single one, we can write a customer combiner class that merges all known OpenAPI specifications at runtime.
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Microsoft.OpenApi.Extensions;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Readers;
namespace OpenApi.Bff.OpenApi
{
public class OpenApiDocumentCombiner(HttpClient client, IOptions<OpenApiDocumentCombinerOptions> o)
{
private static readonly OpenApiStreamReader OpenApiStreamReader = new();
public async Task<FileStreamHttpResult> CombineDocuments(CancellationToken cancellationToken)
{
var doc = new OpenApiDocument();
if (o.Value.ServerUri != null)
{
doc.Servers.Add(new OpenApiServer
{
Url = o.Value.ServerUri.ToString()
});
}
doc.Paths = new OpenApiPaths();
doc.Components = new OpenApiComponents();
foreach (var source in o.Value.Documents)
{
var stream = await client.GetStreamAsync(source.DocumentUri, cancellationToken);
var docToMerge = OpenApiStreamReader.Read(stream, out _);
foreach (var path in docToMerge.Paths ?? [])
{
doc.Paths[source.LocalPath + path.Key] = path.Value;
}
foreach (var schema in docToMerge.Components.Schemas)
{
doc.Components.Schemas[schema.Key] = schema.Value;
}
foreach (var response in docToMerge.Components.Responses)
{
doc.Components.Responses[response.Key] = response.Value;
}
foreach (var parameter in docToMerge.Components.Parameters)
{
doc.Components.Parameters[parameter.Key] = parameter.Value;
}
foreach (var example in docToMerge.Components.Examples)
{
doc.Components.Examples[example.Key] = example.Value;
}
foreach (var requestBody in docToMerge.Components.RequestBodies)
{
doc.Components.RequestBodies[requestBody.Key] = requestBody.Value;
}
foreach (var header in docToMerge.Components.Headers)
{
doc.Components.Headers[header.Key] = header.Value;
}
//// We intentionally don't copy the security schemes.
//foreach (var securityScheme in docToMerge.Components.SecuritySchemes)
//{
// doc.Components.SecuritySchemes[securityScheme.Key] = securityScheme.Value;
//}
foreach (var link in docToMerge.Components.Links)
{
doc.Components.Links[link.Key] = link.Value;
}
foreach (var callback in docToMerge.Components.Callbacks)
{
doc.Components.Callbacks[callback.Key] = callback.Value;
}
}
var memoryStream = new MemoryStream();
doc.Serialize(memoryStream, OpenApiSpecVersion.OpenApi3_0, OpenApiFormat.Json);
memoryStream.Position = 0;
return TypedResults.Stream(memoryStream);
}
}
}
In this class, you also have an opportunity to transform, change, or remove any sections not applicable to the final result. In our case, we remove the security schemes since the BFF host will handle security for the solution.
We must now register our new specification endpoint and modify our Swagger UI registration. Let’s start with our endpoint.
app.MapGet("/swagger/combined/v1.json",
async (OpenApiDocumentCombiner c, CancellationToken ct) => await c.CombineDocuments(ct));
Finally, let’s update the Swagger UI registration with our newly combined specifications.
app.UseSwaggerUI(c =>
{
// Inject a javascript function to add a CSRF header to all requests
c.UseRequestInterceptor("function(request){ request.headers['X-CSRF'] = '1';return request;}");
// Add all swagger endpoints for all APIs
c.SwaggerEndpoint("/api1/openapi/v1.json", "API #1");
c.SwaggerEndpoint("/api2/openapi/v1.json", "API #2");
c.SwaggerEndpoint("/swagger/combined/v1.json", "Combined");
});
When we run our solution now, we can select a “Combined” specification in our Swagger UI.
Most importantly, when we use the APIs from our client application, it all still works.
We can call our endpoints securely like we always could with BFF while allowing developers to explore the API surface we provide. It couldn’t be easier.
Conclusion
In this article, adapted from our BFF sample, we showed how you can take multiple approaches to exposing your OpenAPI specifications through a BFF host. We could transform specifications by modifying endpoint definitions and combining all into a single-use specification.
OpenAPI can help teams better document and share APIs, whether it’s for developers or automated client generation. By using the functionality of the BFF, we can build any solution we can imagine while still adhering to modern and current security practices.
We hope you enjoyed this post. We recommend you check out the entire sample, which shows all the elements combined with a few additional implementation details left out for brevity. As always, we’d love to hear from you, so feel free to leave a comment below!