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