Skip to content

Commit

Permalink
Merge pull request #180 from DuendeSoftware/joe/optional-yarp-access-…
Browse files Browse the repository at this point in the history
…tokens

Joe/optional-yarp-access-tokens
  • Loading branch information
brockallen authored Jul 6, 2023
2 parents cbc89eb + e94f4e7 commit 0b88db5
Show file tree
Hide file tree
Showing 9 changed files with 178 additions and 28 deletions.
12 changes: 11 additions & 1 deletion samples/JS.Yarp/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
{
Expand Down
15 changes: 15 additions & 0 deletions samples/JS.Yarp/wwwroot/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion samples/JS.Yarp/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ <h1>YARP-first client</h1>

<div class="row">
<ul class="list-unstyled list-inline">
<li><a class="btn btn-primary" href="index.html">Home</a></li>
<li><button class="btn btn-default login">Login</button></li>
<li><button class="btn btn-primary call_user">Call YARP endpoint (user token)</button></li>
<li><button class="btn btn-primary call_optional_user">Call YARP endpoint (optional user token)</button></li>
<li><button class="btn btn-primary call_client">Call YARP endpoint (client token)</button></li>
<li><button class="btn btn-primary call_anon">Call YARP endpoint (no token)</button></li>
<li><button class="btn btn-info logout">Logout</button></li>
Expand Down
60 changes: 49 additions & 11 deletions src/Duende.Bff.Yarp/AccessTokenTransformProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,18 +21,21 @@ namespace Duende.Bff.Yarp;
public class AccessTokenTransformProvider : ITransformProvider
{
private readonly BffOptions _options;
private readonly ILogger<AccessTokenTransformProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IDPoPProofService _dPoPProofService;

/// <summary>
/// ctor
/// </summary>
/// <param name="options"></param>
/// <param name="logger"></param>
/// <param name="loggerFactory"></param>
/// <param name="dPoPProofService"></param>
public AccessTokenTransformProvider(IOptions<BffOptions> options, ILoggerFactory loggerFactory, IDPoPProofService dPoPProofService)
public AccessTokenTransformProvider(IOptions<BffOptions> options, ILogger<AccessTokenTransformProvider> logger, ILoggerFactory loggerFactory, IDPoPProofService dPoPProofService)
{
_options = options.Value;
_logger = logger;
_loggerFactory = loggerFactory;
_dPoPProofService = dPoPProofService;
}
Expand All @@ -44,17 +50,17 @@ public void ValidateCluster(TransformClusterValidationContext context)
{
}

/// <inheritdoc />
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<string>();
Expand All @@ -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;
}

/// <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))
{
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,
Expand Down
12 changes: 12 additions & 0 deletions src/Duende.Bff.Yarp/ProxyConfigExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ public static RouteConfig WithAccessToken(this RouteConfig config, TokenType tok
{
return config.WithMetadata(Constants.Yarp.TokenTypeMetadata, tokenType.ToString());
}

/// <summary>
/// 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.
/// </summary>
/// <param name="config"></param>
/// <returns></returns>
public static RouteConfig WithOptionalUserAccessToken(this RouteConfig config)
{
return config.WithMetadata(Constants.Yarp.OptionalUserTokenMetadata, "true");
}

/// <summary>
/// Adds anti-forgery metadata to a route configuration
Expand Down
9 changes: 7 additions & 2 deletions src/Duende.Bff/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@ public static class Constants
public static class Yarp
{
/// <summary>
/// Name of toke type metadata
/// Name of token type (User, Client, UserOrClient) metadata
/// </summary>
public const string TokenTypeMetadata = "Duende.Bff.Yarp.TokenType";

/// <summary>
/// Name of toke type metadata
/// Name of Anti-forgery check metadata
/// </summary>
public const string AntiforgeryCheckMetadata = "Duende.Bff.Yarp.AntiforgeryCheck";

/// <summary>
/// Name of optional user token metadata
/// </summary>
public const string OptionalUserTokenMetadata = "Duende.Bff.Yarp.OptionalUserToken";
}

/// <summary>
Expand Down
10 changes: 10 additions & 0 deletions src/Duende.Bff/General/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ILogger, string, string, Exception?> _invalidRouteConfiguration = LoggerMessage.Define<string, string>(
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)
{
Expand All @@ -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);
}
}
56 changes: 44 additions & 12 deletions test/Duende.Bff.Tests/Endpoints/YarpRemoteEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiResponse>(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);

Expand All @@ -66,17 +85,19 @@ public async Task authenticated_GET_should_forward_user_to_api()
var json = await response.Content.ReadAsStringAsync();
var apiResult = JsonSerializer.Deserialize<ApiResponse>(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);

Expand All @@ -85,17 +106,19 @@ public async Task authenticated_PUT_should_forward_user_to_api()
var json = await response.Content.ReadAsStringAsync();
var apiResult = JsonSerializer.Deserialize<ApiResponse>(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);

Expand All @@ -104,7 +127,7 @@ public async Task authenticated_POST_should_forward_user_to_api()
var json = await response.Content.ReadAsStringAsync();
var apiResult = JsonSerializer.Deserialize<ApiResponse>(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");
}
Expand Down Expand Up @@ -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);
}
}
}
30 changes: 29 additions & 1 deletion test/Duende.Bff.Tests/TestHosts/YarpBffHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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[]
Expand Down

0 comments on commit 0b88db5

Please sign in to comment.