The YARP reverse proxy library provides an easy way to forward API calls through an intermediary service. It can be particularly useful for BFF services for front end clients. By default, API requests are forwarded with their existing headers, which can be a problem if the end API requires an auth token that isn’t available to the originating client. In the case of a BFF the API call will generally originate at the client side application running in untrusted browser space which will be using cookie authentication to the service and so won’t have a token to send.
To add or change headers on YARP handled requests transforms can be applied through the proxy configuration which are then processed on each request, similar to middleware for normal web requests. Requests sent to different API clusters can be handled separately to apply different transforms to each. For example, I may have calls to different APIs that require tokens with different scopes, audiences, issuers, user tokens that include a sub claim, or even something completely different like an Azure Function Key instead of an Authorization header.
Basic Transforms in Configuration
Since YARP is frequently set up primarily through configuration settings, it makes sense that there are ways to add transforms from there. Within a Route
configuration the Transforms
property accepts an array of transforms with associated arguments. For example, a path transform to remove a prefix used by the caller:
"internalApiRoute": {
"ClusterId": "internalApi",
"AuthorizationPolicy": "default",
"Match": {
"Path": "api/internal/{*any}"
},
"Transforms": [
{ "PathRemovePrefix": "api/internal" }
]
}
Manually Modifying Requests
More complex transformations that can’t be defined declaratively in configuration can be applied in code. As part of the startup configuration, YARP allows setting transform handlers which execute on each request and allow modification of the request before sending it on to the configured cluster. Within the AddTransforms
action the ClusterId
can be used to differentiate between requests going to different services and provide them with different tokens or other changes.
var proxyBuilder = builder.Services.AddReverseProxy()
.AddTransforms(builderContext =>
{
var clusterId = builderContext.Cluster?.ClusterId;
// add cluster specific changes
});
In this example, three different APIs are being targeted. The userApi requires a user access token, machineApi requires a client credential access token, and the functionApi is an Azure Function requiring a key in an x-functions-key
header.
var proxyBuilder = builder.Services.AddReverseProxy()
.AddTransforms(builderContext =>
{
var clusterId = builderContext.Cluster?.ClusterId;
if (clusterId == "userApi")
{
builderContext.AddPathRemovePrefix("/user");
builderContext.AddRequestTransform(async transformContext =>
{
var token = await transformContext.HttpContext.GetUserAccessTokenAsync();
transformContext.ProxyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
});
}
else if (clusterId == "machineApi")
{
builderContext.AddRequestTransform(async transformContext =>
{
var provider = builderContext.Services.CreateScope().ServiceProvider.GetRequiredService<IClientCredentialsTokenManagementService>();
var token = await provider.GetAccessTokenAsync(ClientTokenName);
transformContext.ProxyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
});
}
else if (clusterId == "functionApi")
{
builderContext.AddRequestTransform(transformContext =>
{
var config = transformContext.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
var functionKey = config.GetValue<string>("Functions:FunctionKey");
transformContext.ProxyRequest.Headers.Add("x-functions-key", functionKey);
return ValueTask.CompletedTask;
});
}
});
proxyBuilder.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
Note that the user token is available directly from the HttpContext
using a framework extension method, but the client credential token may be created and provided in a number of different ways. In this example I’m using Duende extensions that provide automatic retrieval, caching, and expiration of tokens based on some configuration through IClientCredentialsTokenManagementService
. That configuration is set up like this:
private const string ClientTokenName = $"{OpenIdConnectTokenManagementDefaults.ClientCredentialsClientNamePrefix}oidc";
builder.Services.AddClientCredentialsTokenManagement()
.AddClient(ClientTokenName, opt =>
{
opt.TokenEndpoint= $"{Authority.TrimEnd('/')}/connect/token";
opt.ClientId = ClientId;
opt.ClientSecret = ClientSecret;
opt.Scope = ApiScope;
});
The function key set up is simpler since it isn’t being fetched dynamically but just read directly from config.
Duende BFF Framework Integration
The additional YARP library on top of the Duende BFF Framework provides direct integration with YARP and allows some of the above code to be set up automatically. Here, the function section stays the same but everything else in AddTransforms
can be removed. Note that for the userApi I also moved path prefix removal from code to the equivalent config.
var proxyBuilder = builder.Services.AddReverseProxy()
.AddBffExtensions()
.AddTransforms(builderContext =>
{
var cluster = builderContext.Cluster?.ClusterId;
if (cluster == "functionApi")
{
builderContext.AddRequestTransform(transformContext =>
{
var config = transformContext.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
var functionKey = config.GetValue<string>("Functions:FunctionKey");
transformContext.ProxyRequest.Headers.Add("x-functions-key", functionKey);
return ValueTask.CompletedTask;
});
}
});
proxyBuilder.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
Here is the complete final configuration section which importantly includes the additional Duende configuration under the Metadata sections.
"ReverseProxy": {
// Routes tell the proxy which requests to forward
"Routes": {
"userApiRoute": {
"ClusterId": "userApi",
"AuthorizationPolicy": "default",
"Match": {
"Path": "user/{*any}"
},
"Transforms": [
{ "PathRemovePrefix": "user" }
],
"Metadata": {
"Duende.Bff.Yarp.TokenType": "User"
}
},
"machineApiRoute": {
"ClusterId": "machineApi",
"AuthorizationPolicy": "default",
"Match": {
"Path": "api/machine/{*any}"
},
"Transforms": [
{ "PathRemovePrefix": "api/machine" }
],
"Metadata": {
"Duende.Bff.Yarp.TokenType": "Client"
}
},
"functionApiRoute": {
"ClusterId": "functionApi",
"AuthorizationPolicy": "default",
"Match": {
"Path": "func/{*any}"
},
"Transforms": [
{ "PathRemovePrefix": "func" }
]
}
},
// Clusters tell the proxy where and how to forward requests
"Clusters": {
"userApi": {
"Destinations": {
"primary": {
"Address": "https://api1.example.com"
}
}
},
"machineApi": {
"Destinations": {
"primary": {
"Address": "https://api2.example.com"
}
}
},
"functionApi": {
"Destinations": {
"primary": {
"Address": "https://functions.example.com"
}
}
}
}
}
Code Versions
Example code is using .NET 7.0 with these library versions:
- Duende.BFF.Yarp 2.0.0
- Microsoft.AspNetCore.Authentication.OpenIdConnect 7.0.2
- Duende.BFF 2.0.0
- Yarp.ReverseProxy 1.1.1