Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Joe/optional-yarp-access-tokens #180

Merged
merged 5 commits into from
Jul 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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