diff --git a/samples/JS.Yarp/Startup.cs b/samples/JS.Yarp/Startup.cs
index ac9ba711..8ca4bcc1 100644
--- a/samples/JS.Yarp/Startup.cs
+++ b/samples/JS.Yarp/Startup.cs
@@ -52,7 +52,17 @@ public void ConfigureServices(IServiceCollection services)
{
Path = "/anon_api/{**catch-all}"
}
- }.WithAntiforgeryCheck()
+ }.WithAntiforgeryCheck(),
+ new RouteConfig()
+ {
+ RouteId = "api_optional_user",
+ ClusterId = "cluster1",
+
+ Match = new()
+ {
+ Path = "/optional_user_api/{**catch-all}"
+ }
+ }.WithOptionalUserAccessToken().WithAntiforgeryCheck()
},
new[]
{
diff --git a/samples/JS.Yarp/wwwroot/app.js b/samples/JS.Yarp/wwwroot/app.js
index 345db46f..e6d16062 100644
--- a/samples/JS.Yarp/wwwroot/app.js
+++ b/samples/JS.Yarp/wwwroot/app.js
@@ -54,6 +54,20 @@ async function callUserToken() {
}
}
+async function callOptionalUserToken() {
+ var req = new Request("/optional_user_api", {
+ headers: new Headers({
+ 'X-CSRF': '1'
+ })
+ })
+ var resp = await fetch(req);
+
+ log("API Result: " + resp.status);
+ if (resp.ok) {
+ showApi(await resp.json());
+ }
+}
+
async function callClientToken() {
var req = new Request("/client_api", {
headers: new Headers({
@@ -88,6 +102,7 @@ document.querySelector(".login").addEventListener("click", login, false);
document.querySelector(".logout").addEventListener("click", logout, false);
document.querySelector(".call_user").addEventListener("click", callUserToken, false);
+document.querySelector(".call_optional_user").addEventListener("click", callOptionalUserToken, false);
document.querySelector(".call_client").addEventListener("click", callClientToken, false);
document.querySelector(".call_anon").addEventListener("click", callNoToken, false);
diff --git a/samples/JS.Yarp/wwwroot/index.html b/samples/JS.Yarp/wwwroot/index.html
index 4a4c1be6..9d9b2057 100644
--- a/samples/JS.Yarp/wwwroot/index.html
+++ b/samples/JS.Yarp/wwwroot/index.html
@@ -14,9 +14,9 @@
YARP-first client
- - Home
+
diff --git a/src/Duende.Bff.Yarp/AccessTokenTransformProvider.cs b/src/Duende.Bff.Yarp/AccessTokenTransformProvider.cs
index 71ad64eb..30f31089 100644
--- a/src/Duende.Bff.Yarp/AccessTokenTransformProvider.cs
+++ b/src/Duende.Bff.Yarp/AccessTokenTransformProvider.cs
@@ -3,8 +3,11 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
+using System.Threading.Tasks;
using Duende.AccessTokenManagement;
+using Duende.Bff.Logging;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Yarp.ReverseProxy.Transforms;
@@ -18,6 +21,7 @@ namespace Duende.Bff.Yarp;
public class AccessTokenTransformProvider : ITransformProvider
{
private readonly BffOptions _options;
+ private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IDPoPProofService _dPoPProofService;
@@ -25,11 +29,13 @@ public class AccessTokenTransformProvider : ITransformProvider
/// ctor
///
///
+ ///
///
///
- public AccessTokenTransformProvider(IOptions options, ILoggerFactory loggerFactory, IDPoPProofService dPoPProofService)
+ public AccessTokenTransformProvider(IOptions options, ILogger logger, ILoggerFactory loggerFactory, IDPoPProofService dPoPProofService)
{
_options = options.Value;
+ _logger = logger;
_loggerFactory = loggerFactory;
_dPoPProofService = dPoPProofService;
}
@@ -44,17 +50,17 @@ public void ValidateCluster(TransformClusterValidationContext context)
{
}
- ///
- public void Apply(TransformBuilderContext transformBuildContext)
+ private static bool GetMetadataValue(TransformBuilderContext transformBuildContext, string metadataName, [NotNullWhen(true)] out string? metadata)
{
- var routeValue = transformBuildContext.Route.Metadata?.GetValueOrDefault(Constants.Yarp.TokenTypeMetadata);
+ var routeValue = transformBuildContext.Route.Metadata?.GetValueOrDefault(metadataName);
var clusterValue =
- transformBuildContext.Cluster?.Metadata?.GetValueOrDefault(Constants.Yarp.TokenTypeMetadata);
+ transformBuildContext.Cluster?.Metadata?.GetValueOrDefault(metadataName);
// no metadata
if (string.IsNullOrEmpty(routeValue) && string.IsNullOrEmpty(clusterValue))
{
- return;
+ metadata = null;
+ return false;
}
var values = new HashSet();
@@ -64,19 +70,51 @@ public void Apply(TransformBuilderContext transformBuildContext)
if (values.Count > 1)
{
throw new ArgumentException(
- "Mismatching Duende.Bff.Yarp.TokenType route or cluster metadata values found");
+ $"Mismatching {metadataName} route and cluster metadata values found");
}
-
- if (!TokenType.TryParse(values.First(), true, out TokenType tokenType))
+ metadata = values.First();
+ return true;
+ }
+
+ ///
+ 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))
+ {
+ transformBuildContext.AddRequestTransform(ctx =>
+ {
+ ctx.HttpContext.Response.StatusCode = 500;
+ _logger.InvalidRouteConfiguration(transformBuildContext.Route.ClusterId, transformBuildContext.Route.RouteId);
+
+ return ValueTask.CompletedTask;
+ });
+ return;
+ }
+ optional = true;
+ tokenType = TokenType.User;
+ }
+ else if (GetMetadataValue(transformBuildContext, Constants.Yarp.TokenTypeMetadata, out var tokenTypeMetadata))
{
- throw new ArgumentException("Invalid value for Duende.Bff.Yarp.TokenType metadata");
+ optional = false;
+ if (!TokenType.TryParse(tokenTypeMetadata, true, out tokenType))
+ {
+ throw new ArgumentException("Invalid value for Duende.Bff.Yarp.TokenType metadata");
+ }
+ }
+ else
+ {
+ return;
}
transformBuildContext.AddRequestTransform(async transformContext =>
{
transformContext.HttpContext.CheckForBffMiddleware(_options);
- var token = await transformContext.HttpContext.GetManagedAccessToken(tokenType);
+ var token = await transformContext.HttpContext.GetManagedAccessToken(tokenType, optional);
var accessTokenTransform = new AccessTokenRequestTransform(
_dPoPProofService,
diff --git a/src/Duende.Bff.Yarp/ProxyConfigExtensions.cs b/src/Duende.Bff.Yarp/ProxyConfigExtensions.cs
index 4d0f5566..4373e4fb 100644
--- a/src/Duende.Bff.Yarp/ProxyConfigExtensions.cs
+++ b/src/Duende.Bff.Yarp/ProxyConfigExtensions.cs
@@ -21,6 +21,18 @@ public static RouteConfig WithAccessToken(this RouteConfig config, TokenType tok
{
return config.WithMetadata(Constants.Yarp.TokenTypeMetadata, tokenType.ToString());
}
+
+ ///
+ /// Adds BFF access token metadata to a route configuration, indicating that
+ /// the route should use the user access token if the user is authenticated,
+ /// but fall back to an anonymous request if not.
+ ///
+ ///
+ ///
+ public static RouteConfig WithOptionalUserAccessToken(this RouteConfig config)
+ {
+ return config.WithMetadata(Constants.Yarp.OptionalUserTokenMetadata, "true");
+ }
///
/// Adds anti-forgery metadata to a route configuration
diff --git a/src/Duende.Bff/Constants.cs b/src/Duende.Bff/Constants.cs
index c5ece81c..88d87841 100644
--- a/src/Duende.Bff/Constants.cs
+++ b/src/Duende.Bff/Constants.cs
@@ -13,14 +13,19 @@ public static class Constants
public static class Yarp
{
///
- /// Name of toke type metadata
+ /// Name of token type (User, Client, UserOrClient) metadata
///
public const string TokenTypeMetadata = "Duende.Bff.Yarp.TokenType";
///
- /// Name of toke type metadata
+ /// Name of Anti-forgery check metadata
///
public const string AntiforgeryCheckMetadata = "Duende.Bff.Yarp.AntiforgeryCheck";
+
+ ///
+ /// Name of optional user token metadata
+ ///
+ public const string OptionalUserTokenMetadata = "Duende.Bff.Yarp.OptionalUserToken";
}
///
diff --git a/src/Duende.Bff/General/Log.cs b/src/Duende.Bff/General/Log.cs
index d526cc47..d8049bb2 100644
--- a/src/Duende.Bff/General/Log.cs
+++ b/src/Duende.Bff/General/Log.cs
@@ -18,6 +18,7 @@ internal static class EventIds
public static readonly EventId BackChannelLogout = new (2, "BackChannelLogout");
public static readonly EventId BackChannelLogoutError = new (3, "BackChannelLogoutError");
public static readonly EventId AccessTokenMissing = new (4, "AccessTokenMissing");
+ public static readonly EventId InvalidRouteConfiguration = new (5, "InvalidRouteConfiguration");
}
internal static class Log
@@ -42,6 +43,10 @@ internal static class Log
EventIds.AccessTokenMissing,
"Access token is missing. token type: '{tokenType}', local path: '{localpath}', detail: '{detail}'");
+ private static readonly Action _invalidRouteConfiguration = LoggerMessage.Define(
+ LogLevel.Warning,
+ EventIds.InvalidRouteConfiguration,
+ "Invalid route configuration. Cannot combine a required access token (a call to WithAccessToken) and an optional access token (a call to WithOptionalUserAccessToken). clusterId: '{clusterId}', routeId: '{routeId}'");
public static void AntiForgeryValidationFailed(this ILogger logger, string localPath)
{
@@ -62,4 +67,9 @@ public static void AccessTokenMissing(this ILogger logger, string tokenType, str
{
_accessTokenMissing(logger, tokenType, localPath, detail, null);
}
+
+ public static void InvalidRouteConfiguration(this ILogger logger, string? clusterId, string routeId)
+ {
+ _invalidRouteConfiguration(logger, clusterId ?? "no cluster id", routeId, null);
+ }
}
\ No newline at end of file
diff --git a/test/Duende.Bff.Tests/Endpoints/YarpRemoteEndpointTests.cs b/test/Duende.Bff.Tests/Endpoints/YarpRemoteEndpointTests.cs
index b54448e9..3a2e7839 100644
--- a/test/Duende.Bff.Tests/Endpoints/YarpRemoteEndpointTests.cs
+++ b/test/Duende.Bff.Tests/Endpoints/YarpRemoteEndpointTests.cs
@@ -53,11 +53,30 @@ public async Task anonymous_call_to_user_token_requirement_route_should_fail()
}
[Fact]
- public async Task authenticated_GET_should_forward_user_to_api()
+ public async Task anonymous_call_to_optional_user_token_route_should_succeed()
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_optional_user/test"));
+ req.Headers.Add("x-csrf", "1");
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.IsSuccessStatusCode.Should().BeTrue();
+ response.Content.Headers.ContentType.MediaType.Should().Be("application/json");
+ var json = await response.Content.ReadAsStringAsync();
+ var apiResult = JsonSerializer.Deserialize(json);
+ apiResult.Method.Should().Be("GET");
+ apiResult.Path.Should().Be("/api_optional_user/test");
+ apiResult.Sub.Should().BeNull();
+ apiResult.ClientId.Should().BeNull();
+ }
+
+ [Theory]
+ [InlineData("/api_user/test")]
+ [InlineData("/api_optional_user/test")]
+ public async Task authenticated_GET_should_forward_user_to_api(string route)
{
await BffHost.BffLoginAsync("alice");
- var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_user/test"));
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url(route));
req.Headers.Add("x-csrf", "1");
var response = await BffHost.BrowserClient.SendAsync(req);
@@ -66,17 +85,19 @@ public async Task authenticated_GET_should_forward_user_to_api()
var json = await response.Content.ReadAsStringAsync();
var apiResult = JsonSerializer.Deserialize(json);
apiResult.Method.Should().Be("GET");
- apiResult.Path.Should().Be("/api_user/test");
+ apiResult.Path.Should().Be(route);
apiResult.Sub.Should().Be("alice");
apiResult.ClientId.Should().Be("spa");
}
- [Fact]
- public async Task authenticated_PUT_should_forward_user_to_api()
+ [Theory]
+ [InlineData("/api_user/test")]
+ [InlineData("/api_optional_user/test")]
+ public async Task authenticated_PUT_should_forward_user_to_api(string route)
{
await BffHost.BffLoginAsync("alice");
- var req = new HttpRequestMessage(HttpMethod.Put, BffHost.Url("/api_user/test"));
+ var req = new HttpRequestMessage(HttpMethod.Put, BffHost.Url(route));
req.Headers.Add("x-csrf", "1");
var response = await BffHost.BrowserClient.SendAsync(req);
@@ -85,17 +106,19 @@ public async Task authenticated_PUT_should_forward_user_to_api()
var json = await response.Content.ReadAsStringAsync();
var apiResult = JsonSerializer.Deserialize(json);
apiResult.Method.Should().Be("PUT");
- apiResult.Path.Should().Be("/api_user/test");
+ apiResult.Path.Should().Be(route);
apiResult.Sub.Should().Be("alice");
apiResult.ClientId.Should().Be("spa");
}
-
- [Fact]
- public async Task authenticated_POST_should_forward_user_to_api()
+
+ [Theory]
+ [InlineData("/api_user/test")]
+ [InlineData("/api_optional_user/test")]
+ public async Task authenticated_POST_should_forward_user_to_api(string route)
{
await BffHost.BffLoginAsync("alice");
- var req = new HttpRequestMessage(HttpMethod.Post, BffHost.Url("/api_user/test"));
+ var req = new HttpRequestMessage(HttpMethod.Post, BffHost.Url(route));
req.Headers.Add("x-csrf", "1");
var response = await BffHost.BrowserClient.SendAsync(req);
@@ -104,7 +127,7 @@ public async Task authenticated_POST_should_forward_user_to_api()
var json = await response.Content.ReadAsStringAsync();
var apiResult = JsonSerializer.Deserialize(json);
apiResult.Method.Should().Be("POST");
- apiResult.Path.Should().Be("/api_user/test");
+ apiResult.Path.Should().Be(route);
apiResult.Sub.Should().Be("alice");
apiResult.ClientId.Should().Be("spa");
}
@@ -189,5 +212,14 @@ public async Task response_status_403_from_remote_endpoint_should_return_403_fro
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
+
+ [Fact]
+ public async Task invalid_configuration_of_routes_should_return_500()
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_invalid/test"));
+ var response = await BffHost.BrowserClient.SendAsync(req);
+
+ response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
+ }
}
}
diff --git a/test/Duende.Bff.Tests/TestHosts/YarpBffHost.cs b/test/Duende.Bff.Tests/TestHosts/YarpBffHost.cs
index d7e87c32..77309206 100644
--- a/test/Duende.Bff.Tests/TestHosts/YarpBffHost.cs
+++ b/test/Duende.Bff.Tests/TestHosts/YarpBffHost.cs
@@ -101,6 +101,18 @@ private void ConfigureServices(IServiceCollection services)
}.WithAntiforgeryCheck()
.WithAccessToken(TokenType.User),
+ new RouteConfig()
+ {
+ RouteId = "api_optional_user",
+ ClusterId = "cluster1",
+
+ Match = new()
+ {
+ Path = "/api_optional_user/{**catch-all}"
+ }
+ }.WithAntiforgeryCheck()
+ .WithOptionalUserAccessToken(),
+
new RouteConfig()
{
RouteId = "api_client",
@@ -123,7 +135,23 @@ private void ConfigureServices(IServiceCollection services)
Path = "/api_user_or_client/{**catch-all}"
}
}.WithAntiforgeryCheck()
- .WithAccessToken(TokenType.UserOrClient)
+ .WithAccessToken(TokenType.UserOrClient),
+
+ // This route configuration is invalid. WithAccessToken says
+ // that the access token is required, while
+ // WithOptionalUserAccessToken says that it is optional.
+ // Calling this endpoint results in a run time error.
+ new RouteConfig()
+ {
+ RouteId = "api_invalid",
+ ClusterId = "cluster1",
+
+ Match = new()
+ {
+ Path = "/api_invalid/{**catch-all}"
+ }
+ }.WithOptionalUserAccessToken()
+ .WithAccessToken(TokenType.User),
},
new[]