Skip to content

Commit

Permalink
BFF: Simplify the wireup of YARP.
Browse files Browse the repository at this point in the history
  • Loading branch information
Erwinvandervalk committed Feb 4, 2025
1 parent 86495c6 commit 355bcf7
Show file tree
Hide file tree
Showing 42 changed files with 563 additions and 693 deletions.
101 changes: 101 additions & 0 deletions bff/docs/upgrade-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Upgrade guide

## From v2.x => v3.x

If you rely on the default extension methods for wiring up the BFF, then V3 should be a drop-in replacement.

### Migrating from custom implementations of IHttpMessageInvokerFactory

In Duende.BFF V2, there was an interface called IHttpMessageInvokerFactory. This class was responsible for creating
and wiring up yarp's HttpMessageInvoker. This interface has been removed in favor yarp's IForwarderHttpClientFactory.

One common scenario for creating a custom implementation of this class was for mocking the http client
during unit testing.

If you wish to inject a http handler for unit testing, you should now inject a custom IForwarderHttpClientFactory. For example:

``` c#
// A Forwarder factory that forwards the messages to a message handler (which can be easily retrieved from a testhost)
public class BackChannelHttpMessageInvokerFactory(HttpMessageHandler backChannel)
: IForwarderHttpClientFactory
{
public HttpMessageInvoker CreateClient(ForwarderHttpClientContext context) =>
new HttpMessageInvoker(backChannel);
}

// Wire up the forwarder in your application's test host:
services.AddSingleton<IForwarderHttpClientFactory>(
new BackChannelHttpMessageInvokerFactory(_apiHost.Server.CreateHandler()));


```

### Migrating from custom implementations IHttpTransformerFactory
The *IHttpTransformerFactory* was a way to globally configure the YARP tranform pipeline. In V3, the way that
the default *endpoints.MapRemoteBffApiEndpoint()* method builds up the YARP transform has been simplified
significantly. Most of the logic has been pushed down to the *AccessTokenRequestTransform*.

Here are common scenario's for implementing your own *IHttpTransformerFactory* and how to upgrade:

**Replacing defaults**

If you used a custom implementation of IHttpTransformerFactory to change the default behavior of *MapRemoteBffApiEndpoint()*,
for example to add additional transforms, then you can now inject a custom delegate into the di container:

```
services.AddSingleton<BffYarpTransformBuilder>(CustomDefaultYarpTransforms);
//...
// This is an example of how to add a response header to ALL invocations of MapRemoteBffApiEndpoint()
private void CustomDefaultBffTransformBuilder(string localpath, TransformBuilderContext context)
{
context.AddResponseHeader("added-by-custom-default-transform", "some-value");
DefaultBffYarpTransformerBuilders.DirectProxyWithAccessToken(localpath, context);
}
```

Another way of doing this is to create a custom extensionmethod *MyCustomMapRemoteBffApiEndpoint()* that wraps
the MapRemoteBffApiEndpoint() and use that everywhere in your application. This is a great way to add other defaults
that should apply to all endpoints, such as requiring a specific type of access token.

**Configuring transforms for a single route**
Another common usecase for overriding the IHttpTransformerFactory was to have a custom transform for a single route, by
applying a switch statement and testing for specific routes.

Now, there is an overload on the *endpoints.MapRemoteBffApiEndpoint()* that allows you to configure the pipeline directly:

``` c#

endpoints.MapRemoteBffApiEndpoint(
"/local-path",
_apiHost.Url(),
context =>
{
// do something custom: IE: copy request headers
context.CopyRequestHeaders = true;

// wire up the default transformer logic
DefaultTransformers.DirectProxyWithAccessToken("/local-path", context);
})
// Continue with normal BFF configuration, for example, allowing optional user access tokens
.WithOptionalUserAccessToken();

```

### Removed method RemoteApiEndpoint.Map(localpath, apiAddress).
The Map method was no longer needed as most of the logic had been moved to either the MapRemoteBffApiEndpoint and the DefaultTransformers. The map method also wasn't very explicit about what it did and a number of test scenario's tried to verify if it wasn't called wrongly. You are now expected to call the method MapRemoteBffApiEndpoint. This method now has a nullable parameter that allows you to inject your own transformers.

### AccessTokenRetrievalContext properties are now typed
The LocalPath and ApiAddress properties are now typed. They used to be strings. If you rely on these, for example for implementing
a custom IAccessTokenRetriever, then you should adjust their usage accordingly.

/// <summary>
/// The locally requested path.
/// </summary>
public required PathString LocalPath { get; set; }

/// <summary>
/// The remote address of the API.
/// </summary>
public required Uri ApiAddress { get; set; }
3 changes: 2 additions & 1 deletion bff/samples/Hosts.Tests/TestInfra/AppHostFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ await resourceNotificationService.WaitForResourceAsync(
.WaitAsync(TimeSpan.FromSeconds(30));
}


#else
_app = null!;
#endif //#DEBUG_NCRUNCH
}

Expand Down
4 changes: 2 additions & 2 deletions bff/samples/Hosts.Tests/TestInfra/BffClient.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// // Copyright (c) Duende Software. All rights reserved.
// // See LICENSE in the project root for license information.
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.

using System.Text.Json;
using AngleSharp;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// // Copyright (c) Duende Software. All rights reserved.
// // See LICENSE in the project root for license information.
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.

namespace Hosts.Tests.TestInfra;

Expand Down
4 changes: 2 additions & 2 deletions bff/samples/IdentityServer/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// // Copyright (c) Duende Software. All rights reserved.
// // See LICENSE in the project root for license information.
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.

using IdentityServerHost;
using Serilog;
Expand Down
4 changes: 2 additions & 2 deletions bff/samples/IdentityServer/ServiceDiscoveringClientStore.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// // Copyright (c) Duende Software. All rights reserved.
// // See LICENSE in the project root for license information.
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.

using Duende.IdentityModel;
using Duende.IdentityServer.Models;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

<ItemGroup>
Expand Down
4 changes: 3 additions & 1 deletion bff/src/Duende.Bff.Blazor/Duende.Bff.Blazor.csproj
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>

</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
Expand Down
104 changes: 71 additions & 33 deletions bff/src/Duende.Bff.Yarp/AccessTokenRequestTransform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,72 @@
// See LICENSE in the project root for license information.

using System;
using System.Collections.Generic;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Duende.AccessTokenManagement;
using Duende.AccessTokenManagement.OpenIdConnect;
using Duende.Bff.Logging;
using Duende.IdentityModel;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Yarp.ReverseProxy.Model;
using Yarp.ReverseProxy.Transforms;

namespace Duende.Bff.Yarp;

/// <summary>
/// Adds an access token to outgoing requests
/// </summary>
public class AccessTokenRequestTransform : RequestTransform
public class AccessTokenRequestTransform(
IDPoPProofService proofService,
ILogger<AccessTokenRequestTransform> logger) : RequestTransform
{
private readonly IDPoPProofService _dPoPProofService;
private readonly ILogger<AccessTokenRequestTransform> _logger;
private readonly AccessTokenResult _token;
private readonly string? _routeId;
private readonly TokenType? _tokenType;

/// <summary>
/// ctor
/// </summary>
/// <param name="proofService"></param>
/// <param name="logger"></param>
/// <param name="accessToken"></param>
/// <param name="routeId"></param>
/// <param name="tokenType"></param>
public AccessTokenRequestTransform(
IDPoPProofService proofService,
ILogger<AccessTokenRequestTransform> logger,
AccessTokenResult accessToken,
string? routeId = null,
TokenType? tokenType = null)
{
_dPoPProofService = proofService;
_logger = logger;
_token = accessToken ?? throw new ArgumentNullException(nameof(accessToken));
_routeId = routeId;
_tokenType = tokenType;
}

/// <inheritdoc />
public override async ValueTask ApplyAsync(RequestTransformContext context)
{
switch (_token)
var endpoint = context.HttpContext.GetEndpoint();
if (endpoint == null)
{
throw new InvalidOperationException("endpoint not found");
}
UserTokenRequestParameters? userAccessTokenParameters = null;

context.HttpContext.RequestServices.CheckLicense();

// Get the metadata
var metadata =
// Either from the endpoint directly, when using mapbff
endpoint.Metadata.GetMetadata<BffRemoteApiEndpointMetadata>()
// or from yarp
?? GetBffMetadataFromYarp(endpoint)
?? throw new InvalidOperationException("API endpoint is missing BFF metadata");

if (metadata.BffUserAccessTokenParameters != null)
{
userAccessTokenParameters = metadata.BffUserAccessTokenParameters.ToUserAccessTokenRequestParameters();
}

if (context.HttpContext.RequestServices.GetRequiredService(metadata.AccessTokenRetriever)
is not IAccessTokenRetriever accessTokenRetriever)
{
throw new InvalidOperationException("TokenRetriever is not an IAccessTokenRetriever");
}

var accessTokenContext = new AccessTokenRetrievalContext()
{
HttpContext = context.HttpContext,
Metadata = metadata,
UserTokenRequestParameters = userAccessTokenParameters,
ApiAddress = new Uri(context.DestinationPrefix),
LocalPath = context.HttpContext.Request.Path
};
var result = await accessTokenRetriever.GetAccessToken(accessTokenContext);

switch (result)
{
case BearerTokenResult bearerToken:
ApplyBearerToken(context, bearerToken);
Expand All @@ -57,7 +76,7 @@ public override async ValueTask ApplyAsync(RequestTransformContext context)
await ApplyDPoPToken(context, dpopToken);
break;
case AccessTokenRetrievalError tokenError:
ApplyError(context, tokenError, _routeId ?? "Unknown Route", _tokenType);
ApplyError(context, tokenError, metadata.RequiredTokenType);
break;
case NoAccessTokenResult noToken:
break;
Expand All @@ -66,12 +85,31 @@ public override async ValueTask ApplyAsync(RequestTransformContext context)
}
}

private void ApplyError(RequestTransformContext context, AccessTokenRetrievalError tokenError, string routeId, TokenType? tokenType)
private static BffRemoteApiEndpointMetadata? GetBffMetadataFromYarp(Endpoint endpoint)
{
var yarp = endpoint.Metadata.GetMetadata<RouteModel>();
if (yarp == null)
return null;

TokenType? requiredTokenType = null;
if (Enum.TryParse<TokenType>(yarp.Config?.Metadata?.GetValueOrDefault(Constants.Yarp.TokenTypeMetadata), true, out var type))
{
requiredTokenType = type;
}

return new BffRemoteApiEndpointMetadata()
{
OptionalUserToken = yarp.Config?.Metadata?.GetValueOrDefault(Constants.Yarp.OptionalUserTokenMetadata) == "true",
RequiredTokenType = requiredTokenType
};
}

private void ApplyError(RequestTransformContext context, AccessTokenRetrievalError tokenError, TokenType? tokenType)
{
// short circuit forwarder and return 401
context.HttpContext.Response.StatusCode = 401;

_logger.AccessTokenMissing(tokenType?.ToString() ?? "Unknown token type", routeId, tokenError.Error);
logger.AccessTokenMissing(tokenType?.ToString() ?? "Unknown token type", context.HttpContext.Request.Path, tokenError.Error);
}

private void ApplyBearerToken(RequestTransformContext context, BearerTokenResult token)
Expand All @@ -85,7 +123,7 @@ private async Task ApplyDPoPToken(RequestTransformContext context, DPoPTokenResu
ArgumentNullException.ThrowIfNull(token.DPoPJsonWebKey, nameof(token.DPoPJsonWebKey));

var baseUri = new Uri(context.DestinationPrefix);
var proofToken = await _dPoPProofService.CreateProofTokenAsync(new DPoPProofRequest
var proofToken = await proofService.CreateProofTokenAsync(new DPoPProofRequest
{
AccessToken = token.AccessToken,
DPoPJsonWebKey = token.DPoPJsonWebKey,
Expand Down
17 changes: 4 additions & 13 deletions bff/src/Duende.Bff.Yarp/AccessTokenTransformProvider.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// // Copyright (c) Duende Software. All rights reserved.
// // See LICENSE in the project root for license information.
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.

using System;
using System.Collections.Generic;
Expand Down Expand Up @@ -79,8 +79,6 @@ private static bool GetMetadataValue(TransformBuilderContext transformBuildConte
/// <inheritdoc />
public void Apply(TransformBuilderContext transformBuildContext)
{
TokenType tokenType;
bool optional;
if(GetMetadataValue(transformBuildContext, Constants.Yarp.OptionalUserTokenMetadata, out var optionalTokenMetadata))
{
if (GetMetadataValue(transformBuildContext, Constants.Yarp.TokenTypeMetadata, out var tokenTypeMetadata))
Expand All @@ -94,13 +92,10 @@ public void Apply(TransformBuilderContext transformBuildContext)
});
return;
}
optional = true;
tokenType = TokenType.User;
}
else if (GetMetadataValue(transformBuildContext, Constants.Yarp.TokenTypeMetadata, out var tokenTypeMetadata))
{
optional = false;
if (!TokenType.TryParse(tokenTypeMetadata, true, out tokenType))
if (!Enum.TryParse<TokenType>(tokenTypeMetadata, true, out _))
{
throw new ArgumentException("Invalid value for Duende.Bff.Yarp.TokenType metadata");
}
Expand All @@ -114,13 +109,9 @@ public void Apply(TransformBuilderContext transformBuildContext)
{
transformContext.HttpContext.CheckForBffMiddleware(_options);

var token = await transformContext.HttpContext.GetManagedAccessToken(tokenType, optional);

var accessTokenTransform = new AccessTokenRequestTransform(
_dPoPProofService,
_loggerFactory.CreateLogger<AccessTokenRequestTransform>(),
token,
transformBuildContext?.Route?.RouteId, tokenType);
_loggerFactory.CreateLogger<AccessTokenRequestTransform>());

await accessTokenTransform.ApplyAsync(transformContext);
});
Expand Down
4 changes: 2 additions & 2 deletions bff/src/Duende.Bff.Yarp/ActivityPropagationHandler.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// // Copyright (c) Duende Software. All rights reserved.
// // See LICENSE in the project root for license information.
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.

using System;
using System.Diagnostics;
Expand Down
4 changes: 2 additions & 2 deletions bff/src/Duende.Bff.Yarp/AntiforgeryMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// // Copyright (c) Duende Software. All rights reserved.
// // See LICENSE in the project root for license information.
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.

using System;
using System.Threading.Tasks;
Expand Down
Loading

0 comments on commit 355bcf7

Please sign in to comment.