diff --git a/Duende.Bff.sln b/Duende.Bff.sln
index 63aced7..9aa9dc9 100644
--- a/Duende.Bff.sln
+++ b/Duende.Bff.sln
@@ -1,4 +1,4 @@
-
+
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34414.90
@@ -43,11 +43,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Duende.Bff.Blazor", "src\Du
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Duende.Bff.Blazor.Client", "src\Duende.Bff.Blazor.Client\Duende.Bff.Blazor.Client.csproj", "{DDB9C401-6B1F-4727-A4CB-932034FBF94E}"
EndProject
-EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Blazor.Wasm.Bff", "samples\Blazor.Wasm\Blazor.Wasm.Bff\Blazor.Wasm.Bff.csproj", "{BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Blazor.Wasm.Client", "samples\Blazor.Wasm\Blazor.Wasm.Client\Blazor.Wasm.Client.csproj", "{4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duende.Bff.Blazor.Client.UnitTests", "test\Duende.Bff.Blazor.Client.UnitTests\Duende.Bff.Blazor.Client.UnitTests.csproj", "{001840D4-8B83-4A8C-AF2C-5429D4F9A370}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duende.Bff.Blazor.UnitTests", "test\Duende.Bff.Blazor.UnitTests\Duende.Bff.Blazor.UnitTests.csproj", "{2A04808A-A06C-4F10-87B9-2D12E065F729}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -250,6 +253,30 @@ Global
{DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|x64.Build.0 = Release|Any CPU
{DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|x86.ActiveCfg = Release|Any CPU
{DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|x86.Build.0 = Release|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|x64.Build.0 = Debug|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|x86.Build.0 = Debug|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|Any CPU.Build.0 = Release|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|x64.ActiveCfg = Release|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|x64.Build.0 = Release|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|x86.ActiveCfg = Release|Any CPU
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|x86.Build.0 = Release|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|x64.Build.0 = Debug|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|x86.Build.0 = Debug|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|x64.ActiveCfg = Release|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|x64.Build.0 = Release|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|x86.ActiveCfg = Release|Any CPU
+ {2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -271,6 +298,8 @@ Global
{CBB98134-92F5-487D-8CA3-84C19FF46775} = {E14F66D1-EA3E-40C6-835A-91A4382D4646}
{E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84} = {3C549079-A502-4B40-B051-5278915AE91B}
{DDB9C401-6B1F-4727-A4CB-932034FBF94E} = {3C549079-A502-4B40-B051-5278915AE91B}
+ {001840D4-8B83-4A8C-AF2C-5429D4F9A370} = {B2A776DB-385B-4AD4-96A5-61746FD909C3}
+ {2A04808A-A06C-4F10-87B9-2D12E065F729} = {B2A776DB-385B-4AD4-96A5-61746FD909C3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3DAD5980-4688-4794-9CF0-6F3CB67194E7}
diff --git a/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs b/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs
index 5e73bf3..5f3b8d6 100644
--- a/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs
+++ b/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs
@@ -4,31 +4,38 @@
namespace Duende.Bff.Blazor.Client;
///
-/// Options for Blazor BFF
+/// Options for Blazor BFF
///
public class BffBlazorOptions
{
///
- /// The base path to use for remote APIs.
+ /// The base path to use for remote APIs.
///
public string RemoteApiPath { get; set; } = "remote-apis/";
///
- /// The base address to use for remote APIs. If unset (the default), the
- /// blazor hosting environment's base address is used.
+ /// The base address to use for remote APIs. If unset (the default), the
+ /// blazor hosting environment's base address is used.
///
public string? RemoteApiBaseAddress { get; set; } = null;
///
- /// The delay, in milliseconds, before the AuthenticationStateProvider
- /// will start polling the /bff/user endpoint. Defaults to 1000 ms.
+ /// The base address to use for the state provider's calls to the /bff/user
+ /// endpoint. If unset (the default), the blazor hosting environment's base
+ /// address is used.
+ ///
+ public string? StateProviderBaseAddress { get; set; } = null;
+
+ ///
+ /// The delay, in milliseconds, before the AuthenticationStateProvider will
+ /// start polling the /bff/user endpoint. Defaults to 1000 ms.
///
public int StateProviderPollingDelay { get; set; } = 1000;
///
- /// The delay, in milliseconds, between polling requests by the
- /// AuthenticationStateProvider to the /bff/user endpoint. Defaults to
- /// 5000 ms.
+ /// The delay, in milliseconds, between polling requests by the
+ /// AuthenticationStateProvider to the /bff/user endpoint. Defaults to 5000
+ /// ms.
///
public int StateProviderPollingInterval { get; set; } = 5000;
}
\ No newline at end of file
diff --git a/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs b/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs
index 60fa9bc..9870aa9 100644
--- a/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs
+++ b/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs
@@ -1,10 +1,8 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
-using System.Net.Http.Json;
-using System.Security.Claims;
+using Duende.Bff.Blazor.Client.Internals;
using Microsoft.Extensions.Logging;
-using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Options;
@@ -12,57 +10,49 @@ namespace Duende.Bff.Blazor.Client;
public class BffClientAuthenticationStateProvider : AuthenticationStateProvider
{
- private static readonly TimeSpan UserCacheRefreshInterval = TimeSpan.FromSeconds(60);
-
- private readonly HttpClient _client;
- private readonly ILogger _logger;
+ public const string HttpClientName = "Duende.Bff.Blazor.Client:StateProvider";
+
+ private readonly IGetUserService _getUserService;
+ private readonly TimeProvider _timeProvider;
private readonly BffBlazorOptions _options;
-
- private DateTimeOffset _userLastCheck = DateTimeOffset.MinValue;
- private ClaimsPrincipal _cachedUser = new(new ClaimsIdentity());
+ private readonly ILogger _logger;
///
- /// An intended for use in
- /// Blazor WASM. It polls the /bff/user endpoint to monitor session
- /// state.
+ /// An intended for use in Blazor
+ /// WASM. It polls the /bff/user endpoint to monitor session state.
///
public BffClientAuthenticationStateProvider(
- PersistentComponentState state,
- IHttpClientFactory factory,
+ IGetUserService getUserService,
+ TimeProvider timeProvider,
IOptions options,
ILogger logger)
{
- _client = factory.CreateClient("BffAuthenticationStateProvider");
- _logger = logger;
- _cachedUser = GetPersistedUser(state);
- if (_cachedUser.Identity?.IsAuthenticated == true)
- {
- _userLastCheck = DateTimeOffset.Now;
- }
-
+ _getUserService = getUserService;
+ _timeProvider = timeProvider;
_options = options.Value;
+ _logger = logger;
}
public override async Task GetAuthenticationStateAsync()
{
- var user = await GetUser();
+ _getUserService.InitializeCache();
+ var user = await _getUserService.GetUserAsync();
var state = new AuthenticationState(user);
- // Periodically
if (user.Identity is { IsAuthenticated: true })
{
_logger.LogInformation("starting background check..");
- Timer? timer = null;
+ ITimer? timer = null;
- timer = new Timer(async _ =>
+ async void TimerCallback(object? _)
{
- var currentUser = await GetUser(false);
+ var currentUser = await _getUserService.GetUserAsync(false);
// Always notify that auth state has changed, because the user
// management claims (usually) change over time.
//
// Future TODO - Someday we may want an extensibility point. If the
// user management claims have been customized, then auth state
- // wouldn't always change. In that case, we'd want to only fire
+ // might not always change. In that case, we'd want to only fire
// if the user actually had changed.
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(currentUser)));
@@ -75,73 +65,13 @@ public override async Task GetAuthenticationStateAsync()
await timer.DisposeAsync();
}
}
- }, null, _options.StateProviderPollingDelay, _options.StateProviderPollingInterval);
- }
-
- return state;
- }
-
- private async ValueTask GetUser(bool useCache = true)
- {
- var now = DateTimeOffset.Now;
- if (useCache && now < _userLastCheck + UserCacheRefreshInterval)
- {
- _logger.LogDebug("Taking user from cache");
- return _cachedUser;
- }
-
- _logger.LogDebug("Fetching user");
- _cachedUser = await FetchUser();
- _userLastCheck = now;
-
- return _cachedUser;
- }
-
- // TODO - Consider using ClaimLite instead here
- record ClaimRecord(string Type, object Value);
-
- private async Task FetchUser()
- {
- try
- {
- _logger.LogInformation("Fetching user information.");
- var response = await _client.GetAsync("bff/user?slide=false");
- response.EnsureSuccessStatusCode();
- var claims = await response.Content.ReadFromJsonAsync>();
-
- var identity = new ClaimsIdentity(
- nameof(BffClientAuthenticationStateProvider),
- "name",
- "role");
-
- if (claims != null)
- {
- foreach (var claim in claims)
- {
- identity.AddClaim(new Claim(claim.Type, claim.Value.ToString() ?? "no value"));
- }
}
- return new ClaimsPrincipal(identity);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Fetching user failed.");
- }
-
- return new ClaimsPrincipal(new ClaimsIdentity());
- }
-
- private ClaimsPrincipal GetPersistedUser(PersistentComponentState state)
- {
- if (!state.TryTakeFromJson(nameof(ClaimsPrincipalLite), out var lite) || lite is null)
- {
- _logger.LogDebug("Failed to load persisted user.");
- return new ClaimsPrincipal(new ClaimsIdentity());
+ timer = _timeProvider.CreateTimer(TimerCallback,
+ null,
+ TimeSpan.FromMilliseconds(_options.StateProviderPollingDelay),
+ TimeSpan.FromMilliseconds(_options.StateProviderPollingInterval));
}
-
- _logger.LogDebug("Persisted user loaded.");
-
- return lite.ToClaimsPrincipal();
+ return state;
}
-}
\ No newline at end of file
+}
diff --git a/src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj b/src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj
index eca5b9d..8ac5946 100644
--- a/src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj
+++ b/src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj
@@ -16,4 +16,9 @@
+
+
+
+
+
diff --git a/src/Duende.Bff.Blazor.Client/Internals/GetUserService.cs b/src/Duende.Bff.Blazor.Client/Internals/GetUserService.cs
new file mode 100644
index 0000000..b5629fc
--- /dev/null
+++ b/src/Duende.Bff.Blazor.Client/Internals/GetUserService.cs
@@ -0,0 +1,93 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net.Http.Json;
+using System.Security.Claims;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Duende.Bff.Blazor.Client.Internals;
+
+internal class GetUserService : IGetUserService
+{
+ private readonly HttpClient _client;
+ private readonly IPersistentUserService _persistentUserService;
+ private readonly TimeProvider _timeProvider;
+ private readonly BffBlazorOptions _options;
+ private readonly ILogger _logger;
+
+ private DateTimeOffset _userLastCheck = DateTimeOffset.MinValue;
+ private ClaimsPrincipal _cachedUser = new(new ClaimsIdentity());
+
+ public GetUserService(
+ IHttpClientFactory clientFactory,
+ IPersistentUserService persistentUserService,
+ TimeProvider timeProvider,
+ IOptions options,
+ ILogger logger)
+ {
+ _client = clientFactory.CreateClient(BffClientAuthenticationStateProvider.HttpClientName);
+ _persistentUserService = persistentUserService;
+ _timeProvider = timeProvider;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public void InitializeCache()
+ {
+ _cachedUser = _persistentUserService.GetPersistedUser();
+ if (_cachedUser.Identity?.IsAuthenticated == true)
+ {
+ _userLastCheck = _timeProvider.GetUtcNow();
+ }
+ }
+
+ public async ValueTask GetUserAsync(bool useCache = true)
+ {
+ var now = _timeProvider.GetUtcNow();
+ if (useCache && now < _userLastCheck.AddMilliseconds(_options.StateProviderPollingDelay))
+ {
+ _logger.LogDebug("Taking user from cache");
+ return _cachedUser;
+ }
+
+ _logger.LogDebug("Fetching user");
+ _cachedUser = await FetchUser();
+ _userLastCheck = now;
+
+ return _cachedUser;
+ }
+
+ // TODO - Consider using ClaimLite instead here
+ record ClaimRecord(string Type, object Value);
+
+ internal async Task FetchUser()
+ {
+ try
+ {
+ _logger.LogInformation("Fetching user information.");
+ var claims = await _client.GetFromJsonAsync>("bff/user?slide=false");
+
+ var identity = new ClaimsIdentity(
+ nameof(BffClientAuthenticationStateProvider),
+ "name",
+ "role");
+
+ if (claims != null)
+ {
+ foreach (var claim in claims)
+ {
+ identity.AddClaim(new Claim(claim.Type, claim.Value.ToString() ?? "no value"));
+ }
+ }
+
+ return new ClaimsPrincipal(identity);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Fetching user failed.");
+ }
+
+ return new ClaimsPrincipal(new ClaimsIdentity());
+ }
+}
\ No newline at end of file
diff --git a/src/Duende.Bff.Blazor.Client/Internals/IGetUserService.cs b/src/Duende.Bff.Blazor.Client/Internals/IGetUserService.cs
new file mode 100644
index 0000000..752e32f
--- /dev/null
+++ b/src/Duende.Bff.Blazor.Client/Internals/IGetUserService.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Security.Claims;
+
+namespace Duende.Bff.Blazor.Client.Internals;
+
+///
+/// Internal service for retrieval of user info in the authentication state provider.
+///
+public interface IGetUserService
+{
+ ///
+ /// Gets the user.
+ ///
+ ValueTask GetUserAsync(bool useCache = true);
+
+ ///
+ /// Initializes the cache.
+ ///
+ void InitializeCache();
+}
\ No newline at end of file
diff --git a/src/Duende.Bff.Blazor.Client/Internals/IPersistentUserService.cs b/src/Duende.Bff.Blazor.Client/Internals/IPersistentUserService.cs
new file mode 100644
index 0000000..bb9c373
--- /dev/null
+++ b/src/Duende.Bff.Blazor.Client/Internals/IPersistentUserService.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Security.Claims;
+
+namespace Duende.Bff.Blazor.Client.Internals;
+
+///
+/// A service for interacting with the user persisted in PersistentComponentState in blazor.
+///
+public interface IPersistentUserService
+{
+ ///
+ /// Retrieves a ClaimsPrincipal from PersistentComponentState. If there is no persisted user, returns an anonymous
+ /// user.
+ ///
+ ///
+ ClaimsPrincipal GetPersistedUser();
+}
\ No newline at end of file
diff --git a/src/Duende.Bff.Blazor.Client/Internals/PersistentUserService.cs b/src/Duende.Bff.Blazor.Client/Internals/PersistentUserService.cs
new file mode 100644
index 0000000..af2d98d
--- /dev/null
+++ b/src/Duende.Bff.Blazor.Client/Internals/PersistentUserService.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Security.Claims;
+using Microsoft.AspNetCore.Components;
+using Microsoft.Extensions.Logging;
+
+namespace Duende.Bff.Blazor.Client.Internals;
+
+///
+/// This class wraps our usage of the PersistentComponentState, mostly to facilitate testing.
+///
+///
+///
+internal class PersistentUserService(PersistentComponentState state, ILogger logger) : IPersistentUserService
+{
+ ///
+ public ClaimsPrincipal GetPersistedUser()
+ {
+ if (!state.TryTakeFromJson(nameof(ClaimsPrincipalLite), out var lite) || lite is null)
+ {
+ logger.LogDebug("Failed to load persisted user.");
+ return new ClaimsPrincipal(new ClaimsIdentity());
+ }
+
+ logger.LogDebug("Persisted user loaded.");
+
+ return lite.ToClaimsPrincipal();
+ }
+}
\ No newline at end of file
diff --git a/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs b/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs
index 17ab858..744dd8c 100644
--- a/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs
+++ b/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
+using Duende.Bff.Blazor.Client.Internals;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
@@ -10,6 +11,10 @@ namespace Duende.Bff.Blazor.Client;
public static class ServiceCollectionExtensions
{
+ ///
+ /// Adds Duende.BFF services to a Blazor Client (wasm) application.
+ ///
+ /// A callback used to set .
public static IServiceCollection AddBffBlazorClient(this IServiceCollection services,
Action? configureAction = null)
{
@@ -20,17 +25,34 @@ public static IServiceCollection AddBffBlazorClient(this IServiceCollection serv
services
.AddAuthorizationCore()
+ .AddScoped()
+ .AddScoped()
.AddScoped()
+ // TODO - Should this have a different lifetime?
.AddTransient()
- .AddHttpClient("BffAuthenticationStateProvider", (sp, client) =>
+ .AddHttpClient(BffClientAuthenticationStateProvider.HttpClientName, (sp, client) =>
{
- var baseAddress = GetBaseAddress(sp);
+ var baseAddress = GetStateProviderBaseAddress(sp);
client.BaseAddress = new Uri(baseAddress);
}).AddHttpMessageHandler();
return services;
}
+ private static string GetStateProviderBaseAddress(IServiceProvider sp)
+ {
+ var opt = sp.GetRequiredService>();
+ if (opt.Value.StateProviderBaseAddress != null)
+ {
+ return opt.Value.StateProviderBaseAddress;
+ }
+ else
+ {
+ var hostEnv = sp.GetRequiredService();
+ return hostEnv.BaseAddress;
+ }
+ }
+
private static string GetBaseAddress(IServiceProvider sp)
{
var opt = sp.GetRequiredService>();
@@ -96,6 +118,17 @@ private static void SetBaseAddress(IServiceProvider sp, HttpClient client)
client.BaseAddress = new Uri(new Uri(baseAddress), remoteApiPath);
}
+ ///
+ /// Adds a named for use when invoking remote APIs
+ /// proxied through Duende.Bff and configures the client with a callback.
+ ///
+ /// The name of that to
+ /// configure. A common use case is to use the same named client in multiple
+ /// render contexts that are automatically switched between via interactive
+ /// render modes. In that case, ensure both the client and server project
+ /// define the HttpClient appropriately.
+ /// A configuration callback used to set up
+ /// the .
public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, string clientName,
Action configureClient)
{
@@ -103,6 +136,18 @@ public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection
.AddHttpMessageHandler();
}
+ ///
+ /// Adds a named for use when invoking remote APIs
+ /// proxied through Duende.Bff and configures the client with a callback
+ /// that has access to the underlying service provider.
+ ///
+ /// The name of that to
+ /// configure. A common use case is to use the same named client in multiple
+ /// render contexts that are automatically switched between via interactive
+ /// render modes. In that case, ensure both the client and server project
+ /// define the HttpClient appropriately.
+ /// A configuration callback used to set up
+ /// the .
public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, string clientName,
Action? configureClient = null)
{
@@ -110,6 +155,17 @@ public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection
.AddHttpMessageHandler();
}
+ ///
+ /// Adds a typed for use when invoking remote APIs
+ /// proxied through Duende.Bff and configures the client with a callback.
+ ///
+ /// The name of that to
+ /// configure. A common use case is to use the same named client in multiple
+ /// render contexts that are automatically switched between via interactive
+ /// render modes. In that case, ensure both the client and server project
+ /// define the HttpClient appropriately.
+ /// A configuration callback used to set up
+ /// the .
public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services,
Action configureClient)
where T : class
@@ -118,6 +174,18 @@ public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollecti
.AddHttpMessageHandler();
}
+ ///
+ /// Adds a typed for use when invoking remote APIs
+ /// proxied through Duende.Bff and configures the client with a callback
+ /// that has access to the underlying service provider.
+ ///
+ /// The name of that to
+ /// configure. A common use case is to use the same named client in multiple
+ /// render contexts that are automatically switched between via interactive
+ /// render modes. In that case, ensure both the client and server project
+ /// define the HttpClient appropriately.
+ /// A configuration callback used to set up
+ /// the .
public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services,
Action? configureClient = null)
where T : class
diff --git a/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs b/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs
index f321c25..2957a0e 100644
--- a/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs
+++ b/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs
@@ -3,7 +3,6 @@
using System.Diagnostics;
using System.Security.Claims;
-using Duende.Bff.Blazor.Client;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
diff --git a/src/Duende.Bff.Blazor/ServerSideTokenStore.cs b/src/Duende.Bff.Blazor/ServerSideTokenStore.cs
index 099c9d8..b7562f2 100644
--- a/src/Duende.Bff.Blazor/ServerSideTokenStore.cs
+++ b/src/Duende.Bff.Blazor/ServerSideTokenStore.cs
@@ -10,7 +10,7 @@
namespace Duende.Bff.Blazor;
///
-/// A token store that retrieves tokens from server side sessions.
+/// A token store that retrieves tokens from server side sessions.
///
public class ServerSideTokenStore(
IStoreTokensInAuthenticationProperties tokensInAuthProperties,
diff --git a/src/Duende.Bff.Shared/ClaimLite.cs b/src/Duende.Bff.Shared/ClaimLite.cs
index ee7fd86..6d9a989 100644
--- a/src/Duende.Bff.Shared/ClaimLite.cs
+++ b/src/Duende.Bff.Shared/ClaimLite.cs
@@ -4,22 +4,22 @@
namespace Duende.Bff;
///
-/// Serialization friendly claim
+/// Serialization friendly claim
///
public class ClaimLite
{
///
- /// The type
+ /// The type
///
public string Type { get; init; } = default!;
///
- /// The value
+ /// The value
///
public string Value { get; init; } = default!;
///
- /// The value type
+ /// The value type
///
public string? ValueType { get; init; }
}
\ No newline at end of file
diff --git a/src/Duende.Bff.Shared/ClaimsLiteExtensions.cs b/src/Duende.Bff.Shared/ClaimsLiteExtensions.cs
index d113d97..ba44f60 100644
--- a/src/Duende.Bff.Shared/ClaimsLiteExtensions.cs
+++ b/src/Duende.Bff.Shared/ClaimsLiteExtensions.cs
@@ -8,7 +8,7 @@ namespace Duende.Bff;
public static class ClaimsLiteExtensions
{
///
- /// Converts a ClaimsPrincipalLite to ClaimsPrincipal
+ /// Converts a ClaimsPrincipalLite to ClaimsPrincipal
///
public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsPrincipalLite principal)
{
@@ -21,7 +21,7 @@ public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsPrincipalLite princip
}
///
- /// Converts a ClaimsPrincipal to ClaimsPrincipalLite
+ /// Converts a ClaimsPrincipal to ClaimsPrincipalLite
///
public static ClaimsPrincipalLite ToClaimsPrincipalLite(this ClaimsPrincipal principal)
{
diff --git a/src/Duende.Bff.Shared/ClaimsPrincipalLite.cs b/src/Duende.Bff.Shared/ClaimsPrincipalLite.cs
index ee880aa..455f811 100644
--- a/src/Duende.Bff.Shared/ClaimsPrincipalLite.cs
+++ b/src/Duende.Bff.Shared/ClaimsPrincipalLite.cs
@@ -4,27 +4,27 @@
namespace Duende.Bff;
///
-/// Serialization friendly ClaimsPrincipal
+/// Serialization friendly ClaimsPrincipal
///
public class ClaimsPrincipalLite
{
///
- /// The authentication type
+ /// The authentication type
///
public string? AuthenticationType { get; init; }
///
- /// The name claim type
+ /// The name claim type
///
public string? NameClaimType { get; init; }
///
- /// The role claim type
+ /// The role claim type
///
public string? RoleClaimType { get; init; }
///
- /// The claims
+ /// The claims
///
public ClaimLite[] Claims { get; init; } = default!;
}
\ No newline at end of file
diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/AntiforgeryHandlerTests.cs b/test/Duende.Bff.Blazor.Client.UnitTests/AntiforgeryHandlerTests.cs
new file mode 100644
index 0000000..5e4ed83
--- /dev/null
+++ b/test/Duende.Bff.Blazor.Client.UnitTests/AntiforgeryHandlerTests.cs
@@ -0,0 +1,30 @@
+using NSubstitute;
+using Shouldly;
+
+namespace Duende.Bff.Blazor.Client.UnitTests;
+
+public class AntiforgeryHandlerTests
+{
+ [Fact]
+ public async Task Adds_expected_header()
+ {
+ var sut = new TestAntiforgeryHandler()
+ {
+ InnerHandler = Substitute.For()
+ };
+
+ var request = new HttpRequestMessage();
+
+ await sut.SendAsync(request, CancellationToken.None);
+
+ request.Headers.ShouldContain(h => h.Key == "X-CSRF" && h.Value.Contains("1"));
+ }
+}
+
+public class TestAntiforgeryHandler : AntiforgeryHandler
+{
+ public new Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return base.SendAsync(request, cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/BffClientAuthenticationStateProviderTests.cs b/test/Duende.Bff.Blazor.Client.UnitTests/BffClientAuthenticationStateProviderTests.cs
new file mode 100644
index 0000000..3c5e417
--- /dev/null
+++ b/test/Duende.Bff.Blazor.Client.UnitTests/BffClientAuthenticationStateProviderTests.cs
@@ -0,0 +1,146 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Security.Claims;
+using Duende.Bff.Blazor.Client.Internals;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Time.Testing;
+using NSubstitute;
+using Shouldly;
+
+namespace Duende.Bff.Blazor.Client.UnitTests;
+
+public class BffClientAuthenticationStateProviderTests
+{
+ [Fact]
+ public async Task when_UserService_gives_anonymous_user_GetAuthState_returns_anonymous()
+ {
+ var userService = Substitute.For();
+ userService.GetUserAsync().Returns(new ClaimsPrincipal(new ClaimsIdentity()));
+ var sut = new BffClientAuthenticationStateProvider(
+ userService,
+ new FakeTimeProvider(),
+ TestMocks.MockOptions(),
+ Substitute.For>());
+
+ var authState = await sut.GetAuthenticationStateAsync();
+ authState.User.Identity?.IsAuthenticated.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task when_UserService_returns_persisted_user_GetAuthState_returns_that_user()
+ {
+ var expectedName = "test-user";
+ var userService = Substitute.For();
+ userService.GetUserAsync().Returns(new ClaimsPrincipal(new ClaimsIdentity(
+ new []{ new Claim("name", expectedName) },
+ "pwd", "name", "role")));
+ var sut = new BffClientAuthenticationStateProvider(
+ userService,
+ new FakeTimeProvider(),
+ TestMocks.MockOptions(),
+ Substitute.For>());
+
+ var authState = await sut.GetAuthenticationStateAsync();
+ authState.User.Identity?.IsAuthenticated.ShouldBeTrue();
+ authState.User.Identity?.Name.ShouldBe(expectedName);
+ await userService.Received(1).GetUserAsync();
+ }
+
+ [Fact]
+ public async Task after_configured_delay_UserService_is_called_again_and_state_notification_is_called()
+ {
+ var expectedName = "test-user";
+ var userService = Substitute.For();
+ var time = new FakeTimeProvider();
+ userService.GetUserAsync().Returns(new ClaimsPrincipal(new ClaimsIdentity(
+ new []{ new Claim("name", expectedName) },
+ "pwd", "name", "role")));
+ var sut = new BffClientAuthenticationStateProvider(
+ userService,
+ time,
+ TestMocks.MockOptions(new BffBlazorOptions
+ {
+ StateProviderPollingDelay = 2000,
+ StateProviderPollingInterval = 10000
+
+ }),
+ Substitute.For>());
+
+ var authState = await sut.GetAuthenticationStateAsync();
+
+ // Initially, we have called the user service once to initialize
+ await userService.Received(1).GetUserAsync();
+
+ // Advance time within the polling delay, and note that we still haven't made additional calls
+ time.Advance(TimeSpan.FromSeconds(1)); // t = 1
+ await userService.Received(1).GetUserAsync();
+
+ // Advance time past the polling delay, and note that we make an additional call
+ time.Advance(TimeSpan.FromSeconds(2)); // t = 3
+ await userService.Received(1).GetUserAsync(true);
+ await userService.Received(1).GetUserAsync(false);
+
+ // Advance time within the polling interval, but more than the polling delay
+ // We don't expect additional calls yet
+ time.Advance(TimeSpan.FromSeconds(3)); // t = 6
+ await userService.Received(1).GetUserAsync(true);
+ await userService.Received(1).GetUserAsync(false);
+
+ // Advance time past the polling interval, and note that we make an additional call
+ time.Advance(TimeSpan.FromSeconds(10)); // t = 16
+ await userService.Received(1).GetUserAsync(true);
+ await userService.Received(2).GetUserAsync(false);
+ }
+
+ [Fact]
+ public async Task timer_stops_when_user_logs_out()
+ {
+ var expectedName = "test-user";
+ var userService = Substitute.For();
+ var time = new FakeTimeProvider();
+
+ var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
+ anonymousUser.Identity?.IsAuthenticated.ShouldBeFalse();
+
+ var cachedUser = new ClaimsPrincipal(new ClaimsIdentity(
+ [
+ new Claim("name", expectedName),
+ new Claim("source", "cache")
+ ], "pwd", "name", "role"));
+
+ var fetchedUser = new ClaimsPrincipal(new ClaimsIdentity(
+ [
+ new Claim("name", expectedName),
+ new Claim("source", "fetch")
+ ], "pwd", "name", "role"));
+
+ userService.GetUserAsync(true).Returns(cachedUser);
+ userService.GetUserAsync(false).Returns(fetchedUser, anonymousUser);
+ var sut = new BffClientAuthenticationStateProvider(
+ userService,
+ time,
+ TestMocks.MockOptions(new BffBlazorOptions
+ {
+ StateProviderPollingDelay = 2000,
+ StateProviderPollingInterval = 10000
+
+ }),
+ Substitute.For>());
+
+ var authState = await sut.GetAuthenticationStateAsync();
+ time.Advance(TimeSpan.FromSeconds(5));
+ await userService.Received(1).GetUserAsync(true);
+ await userService.Received(1).GetUserAsync(false);
+
+ time.Advance(TimeSpan.FromSeconds(10));
+ await userService.Received(1).GetUserAsync(true);
+ await userService.Received(2).GetUserAsync(false);
+
+
+ time.Advance(TimeSpan.FromSeconds(50));
+ await userService.Received(1).GetUserAsync(true);
+ await userService.Received(2).GetUserAsync(false);
+
+ }
+}
\ No newline at end of file
diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/Duende.Bff.Blazor.Client.UnitTests.csproj b/test/Duende.Bff.Blazor.Client.UnitTests/Duende.Bff.Blazor.Client.UnitTests.csproj
new file mode 100644
index 0000000..5626771
--- /dev/null
+++ b/test/Duende.Bff.Blazor.Client.UnitTests/Duende.Bff.Blazor.Client.UnitTests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/GetUserServiceTests.cs b/test/Duende.Bff.Blazor.Client.UnitTests/GetUserServiceTests.cs
new file mode 100644
index 0000000..93b1333
--- /dev/null
+++ b/test/Duende.Bff.Blazor.Client.UnitTests/GetUserServiceTests.cs
@@ -0,0 +1,161 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+using System.Security.Claims;
+using System.Text.Json;
+using Duende.Bff.Blazor.Client.Internals;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Time.Testing;
+using NSubstitute;
+using Shouldly;
+
+namespace Duende.Bff.Blazor.Client.UnitTests;
+
+public class GetUserServiceTests
+{
+ record ClaimRecord(string type, object value);
+
+ [Fact]
+ public async Task FetchUser_maps_claims_into_ClaimsPrincipal()
+ {
+ var claims = new List
+ {
+ new("name", "example-user"),
+ new("role", "admin"),
+ new("foo", "bar")
+ };
+ var json = JsonSerializer.Serialize(claims);
+ var factory = TestMocks.MockHttpClientFactory(json, HttpStatusCode.OK);
+ var sut = new GetUserService(
+ factory,
+ Substitute.For(),
+ new FakeTimeProvider(),
+ TestMocks.MockOptions(),
+ Substitute.For>());
+
+ var result = await sut.FetchUser();
+
+ result.IsInRole("admin").ShouldBeTrue();
+ result.IsInRole("garbage").ShouldBeFalse();
+ result.Identity.ShouldNotBeNull();
+ result.Identity.Name.ShouldBe("example-user");
+ result.FindFirst("foo").ShouldNotBeNull()
+ .Value.ShouldBe("bar");
+ }
+
+ [Fact]
+ public async Task FetchUser_returns_anonymous_when_http_request_fails()
+ {
+ var factory = TestMocks.MockHttpClientFactory("Internal Server Error", HttpStatusCode.InternalServerError);
+ var sut = new GetUserService(
+ factory,
+ Substitute.For(),
+ new FakeTimeProvider(),
+ TestMocks.MockOptions(),
+ Substitute.For>());
+ var errorResult = await sut.FetchUser();
+ errorResult.Identity?.IsAuthenticated.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task GetUser_returns_persisted_user_if_refresh_not_required()
+ {
+ var startTime = new DateTimeOffset(2024, 07, 26, 12, 00, 00, TimeSpan.Zero);
+ var timeProvider = new FakeTimeProvider();
+
+ var persistentUserService = Substitute.For();
+ persistentUserService.GetPersistedUser().Returns(new ClaimsPrincipal(new ClaimsIdentity(
+ [
+ new Claim("name", "example-user"),
+ new Claim("role", "admin"),
+ new Claim("foo", "bar")
+ ],
+ "pwd", "name", "role")));
+
+ var sut = new GetUserService(
+ Substitute.For(),
+ persistentUserService,
+ timeProvider,
+ TestMocks.MockOptions(),
+ Substitute.For>());
+
+ timeProvider.SetUtcNow(startTime);
+ sut.InitializeCache();
+ var user = await sut.GetUserAsync(useCache: true);
+
+ user.Identity.ShouldNotBeNull();
+ user.Identity.IsAuthenticated.ShouldBeTrue();
+ user.IsInRole("admin").ShouldBeTrue();
+ user.IsInRole("bogus").ShouldBeFalse();
+ user.FindFirst("foo")?.Value.ShouldBe("bar");
+
+ timeProvider.SetUtcNow(startTime.AddMilliseconds(999)); // Slightly less than the refresh interval
+ user = await sut.GetUserAsync(useCache: true);
+
+ user.Identity.ShouldNotBeNull();
+ user.Identity.IsAuthenticated.ShouldBeTrue();
+ user.IsInRole("admin").ShouldBeTrue();
+ user.IsInRole("bogus").ShouldBeFalse();
+ user.FindFirst("foo")?.Value.ShouldBe("bar");
+ }
+
+ [Fact]
+ public async Task GetUser_fetches_user_if_no_persisted_user()
+ {
+ var startTime = new DateTimeOffset(2024, 07, 26, 12, 00, 00, TimeSpan.Zero);
+ var timeProvider = new FakeTimeProvider();
+
+ var claims = new List
+ {
+ new("name", "example-user"),
+ new("role", "admin"),
+ new("foo", "bar")
+ };
+ var json = JsonSerializer.Serialize(claims);
+ var sut = new GetUserService(
+ TestMocks.MockHttpClientFactory(json, HttpStatusCode.OK),
+ Substitute.For(),
+ timeProvider,
+ TestMocks.MockOptions(),
+ Substitute.For>());
+
+ timeProvider.SetUtcNow(startTime);
+ var user = await sut.GetUserAsync(useCache: true);
+
+ user.Identity.ShouldNotBeNull();
+ user.Identity.IsAuthenticated.ShouldBeTrue();
+ user.IsInRole("admin").ShouldBeTrue();
+ user.IsInRole("bogus").ShouldBeFalse();
+ user.FindFirst("foo")?.Value.ShouldBe("bar");
+ }
+}
+
+public class MockHttpMessageHandler : HttpMessageHandler
+{
+ private readonly string _response;
+ private readonly HttpStatusCode _statusCode;
+
+ public string? RequestContent { get; private set; }
+
+ public MockHttpMessageHandler(string response, HttpStatusCode statusCode)
+ {
+ _response = response;
+ _statusCode = statusCode;
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ if (request.Content != null) // Could be a GET-request without a body
+ {
+ RequestContent = await request.Content.ReadAsStringAsync();
+ }
+ return new HttpResponseMessage
+ {
+ StatusCode = _statusCode,
+ Content = new StringContent(_response)
+ };
+ }
+}
+
diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/ServiceCollectionExtensionsTests.cs b/test/Duende.Bff.Blazor.Client.UnitTests/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..2d1b04e
--- /dev/null
+++ b/test/Duende.Bff.Blazor.Client.UnitTests/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,191 @@
+using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using Shouldly;
+
+namespace Duende.Bff.Blazor.Client.UnitTests;
+
+public class ServiceCollectionExtensionsTests
+{
+ [Theory]
+ [InlineData("https://example.com/", "https://example.com/")]
+ [InlineData("https://example.com", "https://example.com/")]
+ public void When_base_address_option_is_set_AddBffBlazorClient_configures_HttpClient_base_address(string configuredRemoteAddress, string expectedBaseAddress)
+ {
+ var sut = new ServiceCollection();
+ sut.AddBffBlazorClient();
+ sut.Configure(opt =>
+ {
+ opt.StateProviderBaseAddress = configuredRemoteAddress;
+ });
+
+
+ var sp = sut.BuildServiceProvider();
+ var httpClientFactory = sp.GetService();
+ var httpClient = httpClientFactory?.CreateClient(BffClientAuthenticationStateProvider.HttpClientName);
+ httpClient.ShouldNotBeNull();
+ httpClient.BaseAddress.ShouldNotBeNull();
+ httpClient.BaseAddress.AbsoluteUri.ShouldBe(expectedBaseAddress);
+ }
+
+ [Fact]
+ public void When_base_address_option_is_default_AddBffBlazorClient_configures_HttpClient_base_address_from_host_env()
+ {
+ var expectedBaseAddress = "https://example.com/";
+
+ var sut = new ServiceCollection();
+ sut.AddBffBlazorClient();
+ var env = Substitute.For();
+ env.BaseAddress.Returns(expectedBaseAddress);
+ sut.AddSingleton(env);
+
+ var sp = sut.BuildServiceProvider();
+ var httpClientFactory = sp.GetService();
+ var httpClient = httpClientFactory?.CreateClient(BffClientAuthenticationStateProvider.HttpClientName);
+ httpClient.ShouldNotBeNull();
+ httpClient.BaseAddress.ShouldNotBeNull();
+ httpClient.BaseAddress.AbsoluteUri.ShouldBe(expectedBaseAddress);
+ }
+
+ [Theory]
+ [InlineData("https://example.com/", "remote-apis", "https://example.com/remote-apis/")]
+ [InlineData("https://example.com/", null, "https://example.com/remote-apis/")]
+ [InlineData("https://example.com", null, "https://example.com/remote-apis/")]
+ [InlineData("https://example.com", "custom/route/to/apis", "https://example.com/custom/route/to/apis/")]
+ [InlineData("https://example.com/with/base/path", "custom/route/to/apis", "https://example.com/with/base/path/custom/route/to/apis/")]
+ [InlineData("https://example.com/with/base/path/", "custom/route/to/apis", "https://example.com/with/base/path/custom/route/to/apis/")]
+ [InlineData("https://example.com/with/base/path", "/custom/route/to/apis", "https://example.com/with/base/path/custom/route/to/apis/")]
+ [InlineData("https://example.com/with/base/path/", "/custom/route/to/apis", "https://example.com/with/base/path/custom/route/to/apis/")]
+ [InlineData("https://example.com/with/base/path", null, "https://example.com/with/base/path/remote-apis/")]
+ public void AddRemoteApiHttpClient_configures_HttpClient_base_address(string configuredRemoteAddress, string? configuredRemotePath, string expectedBaseAddress)
+ {
+ var sut = new ServiceCollection();
+ sut.AddBffBlazorClient();
+ sut.AddRemoteApiHttpClient("clientName");
+ sut.Configure(opt =>
+ {
+ if (configuredRemoteAddress != null)
+ {
+ opt.RemoteApiBaseAddress = configuredRemoteAddress;
+ }
+ if (configuredRemotePath != null)
+ {
+ opt.RemoteApiPath = configuredRemotePath;
+ }
+ });
+
+
+ var sp = sut.BuildServiceProvider();
+ var httpClientFactory = sp.GetService();
+ var httpClient = httpClientFactory?.CreateClient("clientName");
+ httpClient.ShouldNotBeNull();
+ httpClient.BaseAddress.ShouldNotBeNull();
+ httpClient.BaseAddress.AbsoluteUri.ShouldBe(expectedBaseAddress);
+ }
+
+ [Fact]
+ public void When_base_address_option_is_default_AddRemoteApiHttpClient_configures_HttpClient_base_address_from_host_env()
+ {
+ var hostBaseAddress = "https://example.com/";
+ var expectedBaseAddress = "https://example.com/remote-apis/";
+
+ var sut = new ServiceCollection();
+ sut.AddBffBlazorClient();
+ sut.AddRemoteApiHttpClient("clientName");
+ var env = Substitute.For();
+ env.BaseAddress.Returns(hostBaseAddress);
+ sut.AddSingleton(env);
+
+ var sp = sut.BuildServiceProvider();
+ var httpClientFactory = sp.GetService();
+ var httpClient = httpClientFactory?.CreateClient("clientName");
+ httpClient.ShouldNotBeNull();
+ httpClient.BaseAddress.ShouldNotBeNull();
+ httpClient.BaseAddress.AbsoluteUri.ShouldBe(expectedBaseAddress);
+ }
+
+ [Fact]
+ public void When_base_address_option_is_default_AddRemoteApiHttpClient_configures_HttpClient_base_address_from_host_env_and_config_callback_is_respected()
+ {
+ var hostBaseAddress = "https://example.com/";
+ var expectedBaseAddress = "https://example.com/remote-apis/";
+
+ var sut = new ServiceCollection();
+ sut.AddBffBlazorClient();
+ sut.AddRemoteApiHttpClient("clientName", c => c.Timeout = TimeSpan.FromSeconds(321));
+ var env = Substitute.For();
+ env.BaseAddress.Returns(hostBaseAddress);
+ sut.AddSingleton(env);
+
+ var sp = sut.BuildServiceProvider();
+ var httpClientFactory = sp.GetService();
+ var httpClient = httpClientFactory?.CreateClient("clientName");
+ httpClient.ShouldNotBeNull();
+ httpClient.BaseAddress.ShouldNotBeNull();
+ httpClient.BaseAddress.AbsoluteUri.ShouldBe(expectedBaseAddress);
+ httpClient.Timeout.ShouldBe(TimeSpan.FromSeconds(321));
+ }
+
+ [Fact]
+ public void When_base_address_option_is_default_AddRemoteApiHttpClient_for_typed_clients_configures_HttpClient_base_address_from_host_env()
+ {
+ var hostBaseAddress = "https://example.com/";
+ var expectedBaseAddress = "https://example.com/remote-apis/";
+
+ var sut = new ServiceCollection();
+ sut.AddBffBlazorClient();
+ sut.AddTransient();
+ sut.AddRemoteApiHttpClient();
+ var env = Substitute.For();
+ env.BaseAddress.Returns(hostBaseAddress);
+ sut.AddSingleton(env);
+
+ var sp = sut.BuildServiceProvider();
+ var wrapper = sp.GetService();
+ var httpClient = wrapper?.Client;
+ httpClient.ShouldNotBeNull();
+ httpClient.BaseAddress.ShouldNotBeNull();
+ httpClient.BaseAddress.AbsoluteUri.ShouldBe(expectedBaseAddress);
+ }
+
+ [Fact]
+ public void When_base_address_option_is_default_AddRemoteApiHttpClient_for_typed_clients_configures_HttpClient_base_address_from_host_env_and_config_callback_is_respected()
+ {
+ var hostBaseAddress = "https://example.com/";
+ var expectedBaseAddress = "https://example.com/remote-apis/";
+
+ var sut = new ServiceCollection();
+ sut.AddBffBlazorClient();
+ sut.AddTransient();
+ sut.AddRemoteApiHttpClient(c => c.Timeout = TimeSpan.FromSeconds(321));
+ var env = Substitute.For();
+ env.BaseAddress.Returns(hostBaseAddress);
+ sut.AddSingleton(env);
+
+ var sp = sut.BuildServiceProvider();
+ var wrapper = sp.GetService();
+ var httpClient = wrapper?.Client;
+ httpClient.ShouldNotBeNull();
+ httpClient.BaseAddress.ShouldNotBeNull();
+ httpClient.BaseAddress.AbsoluteUri.ShouldBe(expectedBaseAddress);
+ httpClient.Timeout.ShouldBe(TimeSpan.FromSeconds(321));
+ }
+
+ private class ResolvesTypedClients(HttpClient client)
+ {
+ public HttpClient Client { get; } = client;
+ }
+
+ [Fact]
+ public void AddBffBlazorClient_can_set_options_with_callback()
+ {
+ var expectedConfiguredValue = "some-path";
+ var sut = new ServiceCollection();
+ sut.AddBffBlazorClient(opt => opt.RemoteApiPath = expectedConfiguredValue);
+ var sp = sut.BuildServiceProvider();
+ var opts = sp.GetService>();
+ opts.ShouldNotBeNull();
+ opts.Value.RemoteApiPath.ShouldBe(expectedConfiguredValue);
+ }
+}
\ No newline at end of file
diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/TestMocks.cs b/test/Duende.Bff.Blazor.Client.UnitTests/TestMocks.cs
new file mode 100644
index 0000000..4a02170
--- /dev/null
+++ b/test/Duende.Bff.Blazor.Client.UnitTests/TestMocks.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+
+namespace Duende.Bff.Blazor.Client.UnitTests;
+
+public static class TestMocks
+{
+ public static IHttpClientFactory MockHttpClientFactory(string response, HttpStatusCode status)
+ {
+ var httpClient = new HttpClient(new MockHttpMessageHandler(response, status))
+ {
+ // Just have to set something that looks reasonably like a URL so that the HttpClient's internal validation
+ // doesn't blow up
+ BaseAddress = new Uri("https://example.com")
+ };
+ var factory = Substitute.For();
+ factory.CreateClient(BffClientAuthenticationStateProvider.HttpClientName).Returns(httpClient);
+ return factory;
+ }
+
+ public static IOptions MockOptions(BffBlazorOptions? opt = null)
+ {
+ var result = Substitute.For>();
+ result.Value.Returns(opt ?? new BffBlazorOptions());
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/test/Duende.Bff.Blazor.UnitTests/Duende.Bff.Blazor.UnitTests.csproj b/test/Duende.Bff.Blazor.UnitTests/Duende.Bff.Blazor.UnitTests.csproj
new file mode 100644
index 0000000..2c104e3
--- /dev/null
+++ b/test/Duende.Bff.Blazor.UnitTests/Duende.Bff.Blazor.UnitTests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Duende.Bff.Blazor.UnitTests/ServerSideTokenStoreTests.cs b/test/Duende.Bff.Blazor.UnitTests/ServerSideTokenStoreTests.cs
new file mode 100644
index 0000000..ec82a69
--- /dev/null
+++ b/test/Duende.Bff.Blazor.UnitTests/ServerSideTokenStoreTests.cs
@@ -0,0 +1,84 @@
+using System.Security.Claims;
+using Duende.AccessTokenManagement.OpenIdConnect;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using Shouldly;
+
+namespace Duende.Bff.Blazor.UnitTests;
+
+public class ServerSideTokenStoreTests
+{
+ private ClaimsPrincipal CreatePrincipal(string sub, string sid)
+ {
+ return new ClaimsPrincipal(new ClaimsIdentity([
+ new Claim("sub", sub),
+ new Claim("sid", sid)
+ ], "pwd", "name", "role"));
+ }
+
+ [Fact]
+ public async Task Can_add_retrieve_and_remove_tokens()
+ {
+ var user = CreatePrincipal("sub", "sid");
+ var props = new AuthenticationProperties();
+ var expectedToken = new UserToken()
+ {
+ AccessToken = "expected-access-token"
+ };
+
+ // Create shared dependencies
+ var sessionStore = new InMemoryUserSessionStore();
+ var dataProtection = new EphemeralDataProtectionProvider();
+
+ // Use the ticket store to save the user's initial session
+ // Note that we don't yet have tokens in the session
+ var sessionService = new ServerSideTicketStore(sessionStore, dataProtection, Substitute.For>());
+ sessionService.StoreAsync(new AuthenticationTicket(
+ user,
+ props,
+ "test"
+ ));
+
+ var tokensInProps = MockStoreTokensInAuthProps();
+ var sut = new ServerSideTokenStore(
+ tokensInProps,
+ sessionStore,
+ dataProtection,
+ Substitute.For>());
+
+ await sut.StoreTokenAsync(user, expectedToken);
+ var actualToken = await sut.GetTokenAsync(user);
+
+ actualToken.ShouldNotBe(null);
+ actualToken.AccessToken.ShouldBe(expectedToken.AccessToken);
+
+ await sut.ClearTokenAsync(user);
+
+ var resultAfterClearing = await sut.GetTokenAsync(user);
+ resultAfterClearing.AccessToken.ShouldBeNull();
+ }
+
+ private static StoreTokensInAuthenticationProperties MockStoreTokensInAuthProps()
+ {
+ var tokenManagementOptionsMonitor = Substitute.For>();
+ var tokenManagementOptions = new UserTokenManagementOptions { UseChallengeSchemeScopedTokens = false };
+ tokenManagementOptionsMonitor.CurrentValue.Returns(tokenManagementOptions);
+
+ var cookieOptionsMonitor = Substitute.For>();
+ var cookieAuthenticationOptions = new CookieAuthenticationOptions();
+ cookieOptionsMonitor.CurrentValue.Returns(cookieAuthenticationOptions);
+
+ var schemeProvider = Substitute.For();
+ schemeProvider.GetDefaultSignInSchemeAsync().Returns(new AuthenticationScheme("TestScheme", null, typeof(IAuthenticationHandler)));
+
+ return new StoreTokensInAuthenticationProperties(
+ tokenManagementOptionsMonitor,
+ cookieOptionsMonitor,
+ schemeProvider,
+ Substitute.For>());
+ }
+}
\ No newline at end of file