Skip to content
This repository was archived by the owner on Jan 24, 2025. It is now read-only.

Commit 0b88db5

Browse files
authored
Merge pull request #180 from DuendeSoftware/joe/optional-yarp-access-tokens
Joe/optional-yarp-access-tokens
2 parents cbc89eb + e94f4e7 commit 0b88db5

File tree

9 files changed

+178
-28
lines changed

9 files changed

+178
-28
lines changed

samples/JS.Yarp/Startup.cs

+11-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,17 @@ public void ConfigureServices(IServiceCollection services)
5252
{
5353
Path = "/anon_api/{**catch-all}"
5454
}
55-
}.WithAntiforgeryCheck()
55+
}.WithAntiforgeryCheck(),
56+
new RouteConfig()
57+
{
58+
RouteId = "api_optional_user",
59+
ClusterId = "cluster1",
60+
61+
Match = new()
62+
{
63+
Path = "/optional_user_api/{**catch-all}"
64+
}
65+
}.WithOptionalUserAccessToken().WithAntiforgeryCheck()
5666
},
5767
new[]
5868
{

samples/JS.Yarp/wwwroot/app.js

+15
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ async function callUserToken() {
5454
}
5555
}
5656

57+
async function callOptionalUserToken() {
58+
var req = new Request("/optional_user_api", {
59+
headers: new Headers({
60+
'X-CSRF': '1'
61+
})
62+
})
63+
var resp = await fetch(req);
64+
65+
log("API Result: " + resp.status);
66+
if (resp.ok) {
67+
showApi(await resp.json());
68+
}
69+
}
70+
5771
async function callClientToken() {
5872
var req = new Request("/client_api", {
5973
headers: new Headers({
@@ -88,6 +102,7 @@ document.querySelector(".login").addEventListener("click", login, false);
88102
document.querySelector(".logout").addEventListener("click", logout, false);
89103

90104
document.querySelector(".call_user").addEventListener("click", callUserToken, false);
105+
document.querySelector(".call_optional_user").addEventListener("click", callOptionalUserToken, false);
91106
document.querySelector(".call_client").addEventListener("click", callClientToken, false);
92107
document.querySelector(".call_anon").addEventListener("click", callNoToken, false);
93108

samples/JS.Yarp/wwwroot/index.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ <h1>YARP-first client</h1>
1414

1515
<div class="row">
1616
<ul class="list-unstyled list-inline">
17-
<li><a class="btn btn-primary" href="index.html">Home</a></li>
1817
<li><button class="btn btn-default login">Login</button></li>
1918
<li><button class="btn btn-primary call_user">Call YARP endpoint (user token)</button></li>
19+
<li><button class="btn btn-primary call_optional_user">Call YARP endpoint (optional user token)</button></li>
2020
<li><button class="btn btn-primary call_client">Call YARP endpoint (client token)</button></li>
2121
<li><button class="btn btn-primary call_anon">Call YARP endpoint (no token)</button></li>
2222
<li><button class="btn btn-info logout">Logout</button></li>

src/Duende.Bff.Yarp/AccessTokenTransformProvider.cs

+49-11
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Diagnostics.CodeAnalysis;
67
using System.Linq;
8+
using System.Threading.Tasks;
79
using Duende.AccessTokenManagement;
10+
using Duende.Bff.Logging;
811
using Microsoft.Extensions.Logging;
912
using Microsoft.Extensions.Options;
1013
using Yarp.ReverseProxy.Transforms;
@@ -18,18 +21,21 @@ namespace Duende.Bff.Yarp;
1821
public class AccessTokenTransformProvider : ITransformProvider
1922
{
2023
private readonly BffOptions _options;
24+
private readonly ILogger<AccessTokenTransformProvider> _logger;
2125
private readonly ILoggerFactory _loggerFactory;
2226
private readonly IDPoPProofService _dPoPProofService;
2327

2428
/// <summary>
2529
/// ctor
2630
/// </summary>
2731
/// <param name="options"></param>
32+
/// <param name="logger"></param>
2833
/// <param name="loggerFactory"></param>
2934
/// <param name="dPoPProofService"></param>
30-
public AccessTokenTransformProvider(IOptions<BffOptions> options, ILoggerFactory loggerFactory, IDPoPProofService dPoPProofService)
35+
public AccessTokenTransformProvider(IOptions<BffOptions> options, ILogger<AccessTokenTransformProvider> logger, ILoggerFactory loggerFactory, IDPoPProofService dPoPProofService)
3136
{
3237
_options = options.Value;
38+
_logger = logger;
3339
_loggerFactory = loggerFactory;
3440
_dPoPProofService = dPoPProofService;
3541
}
@@ -44,17 +50,17 @@ public void ValidateCluster(TransformClusterValidationContext context)
4450
{
4551
}
4652

47-
/// <inheritdoc />
48-
public void Apply(TransformBuilderContext transformBuildContext)
53+
private static bool GetMetadataValue(TransformBuilderContext transformBuildContext, string metadataName, [NotNullWhen(true)] out string? metadata)
4954
{
50-
var routeValue = transformBuildContext.Route.Metadata?.GetValueOrDefault(Constants.Yarp.TokenTypeMetadata);
55+
var routeValue = transformBuildContext.Route.Metadata?.GetValueOrDefault(metadataName);
5156
var clusterValue =
52-
transformBuildContext.Cluster?.Metadata?.GetValueOrDefault(Constants.Yarp.TokenTypeMetadata);
57+
transformBuildContext.Cluster?.Metadata?.GetValueOrDefault(metadataName);
5358

5459
// no metadata
5560
if (string.IsNullOrEmpty(routeValue) && string.IsNullOrEmpty(clusterValue))
5661
{
57-
return;
62+
metadata = null;
63+
return false;
5864
}
5965

6066
var values = new HashSet<string>();
@@ -64,19 +70,51 @@ public void Apply(TransformBuilderContext transformBuildContext)
6470
if (values.Count > 1)
6571
{
6672
throw new ArgumentException(
67-
"Mismatching Duende.Bff.Yarp.TokenType route or cluster metadata values found");
73+
$"Mismatching {metadataName} route and cluster metadata values found");
6874
}
69-
70-
if (!TokenType.TryParse(values.First(), true, out TokenType tokenType))
75+
metadata = values.First();
76+
return true;
77+
}
78+
79+
/// <inheritdoc />
80+
public void Apply(TransformBuilderContext transformBuildContext)
81+
{
82+
TokenType tokenType;
83+
bool optional;
84+
if(GetMetadataValue(transformBuildContext, Constants.Yarp.OptionalUserTokenMetadata, out var optionalTokenMetadata))
85+
{
86+
if (GetMetadataValue(transformBuildContext, Constants.Yarp.TokenTypeMetadata, out var tokenTypeMetadata))
87+
{
88+
transformBuildContext.AddRequestTransform(ctx =>
89+
{
90+
ctx.HttpContext.Response.StatusCode = 500;
91+
_logger.InvalidRouteConfiguration(transformBuildContext.Route.ClusterId, transformBuildContext.Route.RouteId);
92+
93+
return ValueTask.CompletedTask;
94+
});
95+
return;
96+
}
97+
optional = true;
98+
tokenType = TokenType.User;
99+
}
100+
else if (GetMetadataValue(transformBuildContext, Constants.Yarp.TokenTypeMetadata, out var tokenTypeMetadata))
71101
{
72-
throw new ArgumentException("Invalid value for Duende.Bff.Yarp.TokenType metadata");
102+
optional = false;
103+
if (!TokenType.TryParse(tokenTypeMetadata, true, out tokenType))
104+
{
105+
throw new ArgumentException("Invalid value for Duende.Bff.Yarp.TokenType metadata");
106+
}
107+
}
108+
else
109+
{
110+
return;
73111
}
74112

75113
transformBuildContext.AddRequestTransform(async transformContext =>
76114
{
77115
transformContext.HttpContext.CheckForBffMiddleware(_options);
78116

79-
var token = await transformContext.HttpContext.GetManagedAccessToken(tokenType);
117+
var token = await transformContext.HttpContext.GetManagedAccessToken(tokenType, optional);
80118

81119
var accessTokenTransform = new AccessTokenRequestTransform(
82120
_dPoPProofService,

src/Duende.Bff.Yarp/ProxyConfigExtensions.cs

+12
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ public static RouteConfig WithAccessToken(this RouteConfig config, TokenType tok
2121
{
2222
return config.WithMetadata(Constants.Yarp.TokenTypeMetadata, tokenType.ToString());
2323
}
24+
25+
/// <summary>
26+
/// Adds BFF access token metadata to a route configuration, indicating that
27+
/// the route should use the user access token if the user is authenticated,
28+
/// but fall back to an anonymous request if not.
29+
/// </summary>
30+
/// <param name="config"></param>
31+
/// <returns></returns>
32+
public static RouteConfig WithOptionalUserAccessToken(this RouteConfig config)
33+
{
34+
return config.WithMetadata(Constants.Yarp.OptionalUserTokenMetadata, "true");
35+
}
2436

2537
/// <summary>
2638
/// Adds anti-forgery metadata to a route configuration

src/Duende.Bff/Constants.cs

+7-2
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,19 @@ public static class Constants
1313
public static class Yarp
1414
{
1515
/// <summary>
16-
/// Name of toke type metadata
16+
/// Name of token type (User, Client, UserOrClient) metadata
1717
/// </summary>
1818
public const string TokenTypeMetadata = "Duende.Bff.Yarp.TokenType";
1919

2020
/// <summary>
21-
/// Name of toke type metadata
21+
/// Name of Anti-forgery check metadata
2222
/// </summary>
2323
public const string AntiforgeryCheckMetadata = "Duende.Bff.Yarp.AntiforgeryCheck";
24+
25+
/// <summary>
26+
/// Name of optional user token metadata
27+
/// </summary>
28+
public const string OptionalUserTokenMetadata = "Duende.Bff.Yarp.OptionalUserToken";
2429
}
2530

2631
/// <summary>

src/Duende.Bff/General/Log.cs

+10
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ internal static class EventIds
1818
public static readonly EventId BackChannelLogout = new (2, "BackChannelLogout");
1919
public static readonly EventId BackChannelLogoutError = new (3, "BackChannelLogoutError");
2020
public static readonly EventId AccessTokenMissing = new (4, "AccessTokenMissing");
21+
public static readonly EventId InvalidRouteConfiguration = new (5, "InvalidRouteConfiguration");
2122
}
2223

2324
internal static class Log
@@ -42,6 +43,10 @@ internal static class Log
4243
EventIds.AccessTokenMissing,
4344
"Access token is missing. token type: '{tokenType}', local path: '{localpath}', detail: '{detail}'");
4445

46+
private static readonly Action<ILogger, string, string, Exception?> _invalidRouteConfiguration = LoggerMessage.Define<string, string>(
47+
LogLevel.Warning,
48+
EventIds.InvalidRouteConfiguration,
49+
"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}'");
4550

4651
public static void AntiForgeryValidationFailed(this ILogger logger, string localPath)
4752
{
@@ -62,4 +67,9 @@ public static void AccessTokenMissing(this ILogger logger, string tokenType, str
6267
{
6368
_accessTokenMissing(logger, tokenType, localPath, detail, null);
6469
}
70+
71+
public static void InvalidRouteConfiguration(this ILogger logger, string? clusterId, string routeId)
72+
{
73+
_invalidRouteConfiguration(logger, clusterId ?? "no cluster id", routeId, null);
74+
}
6575
}

test/Duende.Bff.Tests/Endpoints/YarpRemoteEndpointTests.cs

+44-12
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,30 @@ public async Task anonymous_call_to_user_token_requirement_route_should_fail()
5353
}
5454

5555
[Fact]
56-
public async Task authenticated_GET_should_forward_user_to_api()
56+
public async Task anonymous_call_to_optional_user_token_route_should_succeed()
57+
{
58+
var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_optional_user/test"));
59+
req.Headers.Add("x-csrf", "1");
60+
var response = await BffHost.BrowserClient.SendAsync(req);
61+
62+
response.IsSuccessStatusCode.Should().BeTrue();
63+
response.Content.Headers.ContentType.MediaType.Should().Be("application/json");
64+
var json = await response.Content.ReadAsStringAsync();
65+
var apiResult = JsonSerializer.Deserialize<ApiResponse>(json);
66+
apiResult.Method.Should().Be("GET");
67+
apiResult.Path.Should().Be("/api_optional_user/test");
68+
apiResult.Sub.Should().BeNull();
69+
apiResult.ClientId.Should().BeNull();
70+
}
71+
72+
[Theory]
73+
[InlineData("/api_user/test")]
74+
[InlineData("/api_optional_user/test")]
75+
public async Task authenticated_GET_should_forward_user_to_api(string route)
5776
{
5877
await BffHost.BffLoginAsync("alice");
5978

60-
var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_user/test"));
79+
var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url(route));
6180
req.Headers.Add("x-csrf", "1");
6281
var response = await BffHost.BrowserClient.SendAsync(req);
6382

@@ -66,17 +85,19 @@ public async Task authenticated_GET_should_forward_user_to_api()
6685
var json = await response.Content.ReadAsStringAsync();
6786
var apiResult = JsonSerializer.Deserialize<ApiResponse>(json);
6887
apiResult.Method.Should().Be("GET");
69-
apiResult.Path.Should().Be("/api_user/test");
88+
apiResult.Path.Should().Be(route);
7089
apiResult.Sub.Should().Be("alice");
7190
apiResult.ClientId.Should().Be("spa");
7291
}
7392

74-
[Fact]
75-
public async Task authenticated_PUT_should_forward_user_to_api()
93+
[Theory]
94+
[InlineData("/api_user/test")]
95+
[InlineData("/api_optional_user/test")]
96+
public async Task authenticated_PUT_should_forward_user_to_api(string route)
7697
{
7798
await BffHost.BffLoginAsync("alice");
7899

79-
var req = new HttpRequestMessage(HttpMethod.Put, BffHost.Url("/api_user/test"));
100+
var req = new HttpRequestMessage(HttpMethod.Put, BffHost.Url(route));
80101
req.Headers.Add("x-csrf", "1");
81102
var response = await BffHost.BrowserClient.SendAsync(req);
82103

@@ -85,17 +106,19 @@ public async Task authenticated_PUT_should_forward_user_to_api()
85106
var json = await response.Content.ReadAsStringAsync();
86107
var apiResult = JsonSerializer.Deserialize<ApiResponse>(json);
87108
apiResult.Method.Should().Be("PUT");
88-
apiResult.Path.Should().Be("/api_user/test");
109+
apiResult.Path.Should().Be(route);
89110
apiResult.Sub.Should().Be("alice");
90111
apiResult.ClientId.Should().Be("spa");
91112
}
92-
93-
[Fact]
94-
public async Task authenticated_POST_should_forward_user_to_api()
113+
114+
[Theory]
115+
[InlineData("/api_user/test")]
116+
[InlineData("/api_optional_user/test")]
117+
public async Task authenticated_POST_should_forward_user_to_api(string route)
95118
{
96119
await BffHost.BffLoginAsync("alice");
97120

98-
var req = new HttpRequestMessage(HttpMethod.Post, BffHost.Url("/api_user/test"));
121+
var req = new HttpRequestMessage(HttpMethod.Post, BffHost.Url(route));
99122
req.Headers.Add("x-csrf", "1");
100123
var response = await BffHost.BrowserClient.SendAsync(req);
101124

@@ -104,7 +127,7 @@ public async Task authenticated_POST_should_forward_user_to_api()
104127
var json = await response.Content.ReadAsStringAsync();
105128
var apiResult = JsonSerializer.Deserialize<ApiResponse>(json);
106129
apiResult.Method.Should().Be("POST");
107-
apiResult.Path.Should().Be("/api_user/test");
130+
apiResult.Path.Should().Be(route);
108131
apiResult.Sub.Should().Be("alice");
109132
apiResult.ClientId.Should().Be("spa");
110133
}
@@ -189,5 +212,14 @@ public async Task response_status_403_from_remote_endpoint_should_return_403_fro
189212

190213
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
191214
}
215+
216+
[Fact]
217+
public async Task invalid_configuration_of_routes_should_return_500()
218+
{
219+
var req = new HttpRequestMessage(HttpMethod.Get, BffHost.Url("/api_invalid/test"));
220+
var response = await BffHost.BrowserClient.SendAsync(req);
221+
222+
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
223+
}
192224
}
193225
}

test/Duende.Bff.Tests/TestHosts/YarpBffHost.cs

+29-1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,18 @@ private void ConfigureServices(IServiceCollection services)
101101
}.WithAntiforgeryCheck()
102102
.WithAccessToken(TokenType.User),
103103

104+
new RouteConfig()
105+
{
106+
RouteId = "api_optional_user",
107+
ClusterId = "cluster1",
108+
109+
Match = new()
110+
{
111+
Path = "/api_optional_user/{**catch-all}"
112+
}
113+
}.WithAntiforgeryCheck()
114+
.WithOptionalUserAccessToken(),
115+
104116
new RouteConfig()
105117
{
106118
RouteId = "api_client",
@@ -123,7 +135,23 @@ private void ConfigureServices(IServiceCollection services)
123135
Path = "/api_user_or_client/{**catch-all}"
124136
}
125137
}.WithAntiforgeryCheck()
126-
.WithAccessToken(TokenType.UserOrClient)
138+
.WithAccessToken(TokenType.UserOrClient),
139+
140+
// This route configuration is invalid. WithAccessToken says
141+
// that the access token is required, while
142+
// WithOptionalUserAccessToken says that it is optional.
143+
// Calling this endpoint results in a run time error.
144+
new RouteConfig()
145+
{
146+
RouteId = "api_invalid",
147+
ClusterId = "cluster1",
148+
149+
Match = new()
150+
{
151+
Path = "/api_invalid/{**catch-all}"
152+
}
153+
}.WithOptionalUserAccessToken()
154+
.WithAccessToken(TokenType.User),
127155
},
128156

129157
new[]

0 commit comments

Comments
 (0)