From d37bd36f33af79bb7aa3ae5940feece62469b4a7 Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Mon, 19 Aug 2024 19:58:35 -0500 Subject: [PATCH 1/7] Added Duende.Bff.Blazor --- .gitignore | 3 + Directory.Build.targets | 20 ++- Duende.Bff.sln | 60 +++---- build/Program.cs | 2 + .../AntiforgeryHandler.cs | 14 ++ .../BffBlazorOptions.cs | 34 ++++ .../BffClientAuthenticationStateProvider.cs | 147 ++++++++++++++++++ .../Duende.Bff.Blazor.Client.csproj | 19 +++ .../ServiceCollectionExtensions.cs | 128 +++++++++++++++ src/Duende.Bff.Blazor/BffBuilderExtensions.cs | 21 +++ .../CaptureManagementClaimsCookieEvents.cs | 41 +++++ .../Duende.Bff.Blazor.csproj | 14 ++ .../PersistingAuthenticationStateProvider.cs | 92 +++++++++++ src/Duende.Bff.Blazor/ServerSideTokenStore.cs | 79 ++++++++++ src/Duende.Bff.Shared/ClaimLite.cs | 25 +++ src/Duende.Bff.Shared/ClaimsLiteExtensions.cs | 44 ++++++ src/Duende.Bff.Shared/ClaimsPrincipalLite.cs | 30 ++++ .../Duende.Bff.Shared.csproj | 10 ++ src/Duende.Bff.Shared/README.md | 9 ++ .../BffServiceCollectionExtensions.cs | 3 + src/Duende.Bff/Duende.Bff.csproj | 2 + .../Logout/DefaultLogoutService.cs | 29 +++- .../User/DefaultClaimsService.cs | 68 ++++++++ .../User/DefaultUserService.cs | 83 ++++------ .../EndpointServices/User/IClaimsService.cs | 33 ++++ .../AuthenticationTicketExtensions.cs | 84 +--------- .../TicketStore/ServerSideTicketStore.cs | 10 +- .../TestFramework/ApiResponse.cs | 2 +- 28 files changed, 931 insertions(+), 175 deletions(-) create mode 100644 src/Duende.Bff.Blazor.Client/AntiforgeryHandler.cs create mode 100644 src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs create mode 100644 src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs create mode 100644 src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj create mode 100644 src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs create mode 100644 src/Duende.Bff.Blazor/BffBuilderExtensions.cs create mode 100644 src/Duende.Bff.Blazor/CaptureManagementClaimsCookieEvents.cs create mode 100644 src/Duende.Bff.Blazor/Duende.Bff.Blazor.csproj create mode 100644 src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs create mode 100644 src/Duende.Bff.Blazor/ServerSideTokenStore.cs create mode 100644 src/Duende.Bff.Shared/ClaimLite.cs create mode 100644 src/Duende.Bff.Shared/ClaimsLiteExtensions.cs create mode 100644 src/Duende.Bff.Shared/ClaimsPrincipalLite.cs create mode 100644 src/Duende.Bff.Shared/Duende.Bff.Shared.csproj create mode 100644 src/Duende.Bff.Shared/README.md create mode 100644 src/Duende.Bff/EndpointServices/User/DefaultClaimsService.cs create mode 100644 src/Duende.Bff/EndpointServices/User/IClaimsService.cs diff --git a/.gitignore b/.gitignore index c40c1def..869b90fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# MacOs +.DS_Store + # Rider .idea diff --git a/Directory.Build.targets b/Directory.Build.targets index 0687475f..f6ea513e 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,9 +1,10 @@ 8.0.0 - 8.0.0 + 8.0.8 + 7.1.2 2.1.0 - 7.0.4 + 7.0.6 @@ -13,15 +14,26 @@ - + + + + + + + + + + + - + + diff --git a/Duende.Bff.sln b/Duende.Bff.sln index 68db5315..63aced70 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 @@ -39,7 +39,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JS8.DPoP", "samples\JS8.DPo EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JS8.EF", "samples\JS8.EF\JS8.EF.csproj", "{CBB98134-92F5-487D-8CA3-84C19FF46775}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Blazor.Wasm", "Blazor.Wasm", "{7E6EA8BA-EE8B-450E-AE89-C4604C0DD326}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Duende.Bff.Blazor", "src\Duende.Bff.Blazor\Duende.Bff.Blazor.csproj", "{E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}" +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 @@ -223,30 +226,30 @@ Global {CBB98134-92F5-487D-8CA3-84C19FF46775}.Release|x64.Build.0 = Release|Any CPU {CBB98134-92F5-487D-8CA3-84C19FF46775}.Release|x86.ActiveCfg = Release|Any CPU {CBB98134-92F5-487D-8CA3-84C19FF46775}.Release|x86.Build.0 = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x64.ActiveCfg = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x64.Build.0 = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x86.ActiveCfg = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x86.Build.0 = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|Any CPU.Build.0 = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x64.ActiveCfg = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x64.Build.0 = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x86.ActiveCfg = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x86.Build.0 = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x64.ActiveCfg = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x64.Build.0 = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x86.ActiveCfg = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x86.Build.0 = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|Any CPU.Build.0 = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|x64.ActiveCfg = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|x64.Build.0 = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|x86.ActiveCfg = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|x86.Build.0 = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|x64.ActiveCfg = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|x64.Build.0 = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|x86.ActiveCfg = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|x86.Build.0 = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|Any CPU.Build.0 = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|x64.ActiveCfg = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|x64.Build.0 = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|x86.ActiveCfg = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|x86.Build.0 = Release|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|x64.ActiveCfg = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|x64.Build.0 = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|x86.ActiveCfg = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|x86.Build.0 = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|Any CPU.Build.0 = Release|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|x64.ActiveCfg = Release|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -266,9 +269,8 @@ Global {B37CA136-3F20-4D8A-9677-E3A9C9D893EF} = {E14F66D1-EA3E-40C6-835A-91A4382D4646} {D8757F0F-254E-495F-961F-0192F8C97E3F} = {E14F66D1-EA3E-40C6-835A-91A4382D4646} {CBB98134-92F5-487D-8CA3-84C19FF46775} = {E14F66D1-EA3E-40C6-835A-91A4382D4646} - {7E6EA8BA-EE8B-450E-AE89-C4604C0DD326} = {E14F66D1-EA3E-40C6-835A-91A4382D4646} - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3} = {7E6EA8BA-EE8B-450E-AE89-C4604C0DD326} - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4} = {7E6EA8BA-EE8B-450E-AE89-C4604C0DD326} + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84} = {3C549079-A502-4B40-B051-5278915AE91B} + {DDB9C401-6B1F-4727-A4CB-932034FBF94E} = {3C549079-A502-4B40-B051-5278915AE91B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3DAD5980-4688-4794-9CF0-6F3CB67194E7} diff --git a/build/Program.cs b/build/Program.cs index 8fa20985..45d975c2 100644 --- a/build/Program.cs +++ b/build/Program.cs @@ -59,6 +59,8 @@ internal static async Task Main(string[] args) Run("dotnet", $"pack ./src/Duende.Bff/Duende.Bff.csproj -c Release -o {Directory.CreateDirectory(packOutput).FullName} --no-build --nologo"); Run("dotnet", $"pack ./src/Duende.Bff.EntityFramework/Duende.Bff.EntityFramework.csproj -c Release -o {Directory.CreateDirectory(packOutput).FullName} --no-build --nologo"); Run("dotnet", $"pack ./src/Duende.Bff.Yarp/Duende.Bff.Yarp.csproj -c Release -o {Directory.CreateDirectory(packOutput).FullName} --no-build --nologo"); + Run("dotnet", $"pack ./src/Duende.Bff.Blazor/Duende.Bff.Blazor.csproj -c Release -o {Directory.CreateDirectory(packOutput).FullName} --no-build --nologo"); + Run("dotnet", $"pack ./src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj -c Release -o {Directory.CreateDirectory(packOutput).FullName} --no-build --nologo"); }); Target(Targets.SignPackage, DependsOn(Targets.Pack, Targets.RestoreTools), () => diff --git a/src/Duende.Bff.Blazor.Client/AntiforgeryHandler.cs b/src/Duende.Bff.Blazor.Client/AntiforgeryHandler.cs new file mode 100644 index 00000000..d29ab014 --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/AntiforgeryHandler.cs @@ -0,0 +1,14 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.Bff.Blazor.Client; + +public class AntiforgeryHandler : DelegatingHandler +{ + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + request.Headers.Add("X-CSRF", "1"); + return base.SendAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs b/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs new file mode 100644 index 00000000..5e73bf3d --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs @@ -0,0 +1,34 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.Bff.Blazor.Client; + +/// +/// Options for Blazor BFF +/// +public class BffBlazorOptions +{ + /// + /// 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. + /// + public string? RemoteApiBaseAddress { 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. + /// + 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 new file mode 100644 index 00000000..60fa9bc8 --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs @@ -0,0 +1,147 @@ +// 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.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Options; + +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; + private readonly BffBlazorOptions _options; + + private DateTimeOffset _userLastCheck = DateTimeOffset.MinValue; + private ClaimsPrincipal _cachedUser = new(new ClaimsIdentity()); + + /// + /// An intended for use in + /// Blazor WASM. It polls the /bff/user endpoint to monitor session + /// state. + /// + public BffClientAuthenticationStateProvider( + PersistentComponentState state, + IHttpClientFactory factory, + IOptions options, + ILogger logger) + { + _client = factory.CreateClient("BffAuthenticationStateProvider"); + _logger = logger; + _cachedUser = GetPersistedUser(state); + if (_cachedUser.Identity?.IsAuthenticated == true) + { + _userLastCheck = DateTimeOffset.Now; + } + + _options = options.Value; + } + + public override async Task GetAuthenticationStateAsync() + { + var user = await GetUser(); + var state = new AuthenticationState(user); + + // Periodically + if (user.Identity is { IsAuthenticated: true }) + { + _logger.LogInformation("starting background check.."); + Timer? timer = null; + + timer = new Timer(async _ => + { + var currentUser = await GetUser(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 + // if the user actually had changed. + NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(currentUser))); + + if (currentUser!.Identity!.IsAuthenticated == false) + { + _logger.LogInformation("user logged out"); + + if (timer != null) + { + 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()); + } + + _logger.LogDebug("Persisted user loaded."); + + return lite.ToClaimsPrincipal(); + } +} \ 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 new file mode 100644 index 00000000..eca5b9df --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs b/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..17ab858f --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,128 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Duende.Bff.Blazor.Client; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddBffBlazorClient(this IServiceCollection services, + Action? configureAction = null) + { + if (configureAction != null) + { + services.Configure(configureAction); + } + + services + .AddAuthorizationCore() + .AddScoped() + .AddTransient() + .AddHttpClient("BffAuthenticationStateProvider", (sp, client) => + { + var baseAddress = GetBaseAddress(sp); + client.BaseAddress = new Uri(baseAddress); + }).AddHttpMessageHandler(); + + return services; + } + + private static string GetBaseAddress(IServiceProvider sp) + { + var opt = sp.GetRequiredService>(); + if (opt.Value.RemoteApiBaseAddress != null) + { + return opt.Value.RemoteApiBaseAddress; + } + else + { + var hostEnv = sp.GetRequiredService(); + return hostEnv.BaseAddress; + } + } + + private static string GetRemoteApiPath(IServiceProvider sp) + { + var opt = sp.GetRequiredService>(); + return opt.Value.RemoteApiPath; + } + + private static Action SetBaseAddress( + Action? configureClient) + { + return (sp, client) => + { + SetBaseAddress(sp, client); + configureClient?.Invoke(sp, client); + }; + } + + private static Action SetBaseAddress( + Action? configureClient) + { + return (sp, client) => + { + SetBaseAddress(sp, client); + configureClient?.Invoke(client); + }; + } + + private static void SetBaseAddress(IServiceProvider sp, HttpClient client) + { + var baseAddress = GetBaseAddress(sp); + if (!baseAddress.EndsWith("/")) + { + baseAddress += "/"; + } + + var remoteApiPath = GetRemoteApiPath(sp); + if (!string.IsNullOrEmpty(remoteApiPath)) + { + if (remoteApiPath.StartsWith("/")) + { + remoteApiPath = remoteApiPath.Substring(1); + } + + if (!remoteApiPath.EndsWith("/")) + { + remoteApiPath += "/"; + } + } + + client.BaseAddress = new Uri(new Uri(baseAddress), remoteApiPath); + } + + public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, string clientName, + Action configureClient) + { + return services.AddHttpClient(clientName, SetBaseAddress(configureClient)) + .AddHttpMessageHandler(); + } + + public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, string clientName, + Action? configureClient = null) + { + return services.AddHttpClient(clientName, SetBaseAddress(configureClient)) + .AddHttpMessageHandler(); + } + + public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, + Action configureClient) + where T : class + { + return services.AddHttpClient(SetBaseAddress(configureClient)) + .AddHttpMessageHandler(); + } + + public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, + Action? configureClient = null) + where T : class + { + return services.AddHttpClient(SetBaseAddress(configureClient)) + .AddHttpMessageHandler(); + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor/BffBuilderExtensions.cs b/src/Duende.Bff.Blazor/BffBuilderExtensions.cs new file mode 100644 index 00000000..5e8de8ae --- /dev/null +++ b/src/Duende.Bff.Blazor/BffBuilderExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; + +namespace Duende.Bff.Blazor; + +public static class BffBuilderExtensions +{ + public static BffBuilder AddBlazorServer(this BffBuilder builder) + { + builder.Services.AddOpenIdConnectAccessTokenManagement() + .AddBlazorServerAccessTokenManagement(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + return builder; + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor/CaptureManagementClaimsCookieEvents.cs b/src/Duende.Bff.Blazor/CaptureManagementClaimsCookieEvents.cs new file mode 100644 index 00000000..1790f9c8 --- /dev/null +++ b/src/Duende.Bff.Blazor/CaptureManagementClaimsCookieEvents.cs @@ -0,0 +1,41 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Duende.Bff.Blazor; + +/// +/// This subclass invokes the BFF to retrieve management claims and add them to the +/// session. This is useful in interactive render modes where components are +/// initialled rendered server side. +/// +public class CaptureManagementClaimsCookieEvents : CookieAuthenticationEvents +{ + private readonly IClaimsService _claimsService; + + public CaptureManagementClaimsCookieEvents(IClaimsService claimsService) + { + _claimsService = claimsService; + } + + public override async Task ValidatePrincipal(CookieValidatePrincipalContext context) + { + var managementClaims = await _claimsService.GetManagementClaimsAsync( + context.Request.PathBase, + context.Principal, context.Properties); + + if (context.Principal?.Identity is ClaimsIdentity id) + { + foreach (var claim in managementClaims) + { + if (context.Principal.Claims.Any(c => c.Type == claim.type) != true) + { + id.AddClaim(new Claim(claim.type, claim.value?.ToString() ?? string.Empty)); + } + } + } + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor/Duende.Bff.Blazor.csproj b/src/Duende.Bff.Blazor/Duende.Bff.Blazor.csproj new file mode 100644 index 00000000..dbe67c50 --- /dev/null +++ b/src/Duende.Bff.Blazor/Duende.Bff.Blazor.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs b/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs new file mode 100644 index 00000000..f321c252 --- /dev/null +++ b/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs @@ -0,0 +1,92 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +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; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.Logging; + +// This is based on the PersistingServerAuthenticationStateProvider from ASP.NET +// 8's templates. + +// Future TODO - In .NET 9, the types added by the template are getting moved +// into ASP.NET itself, so we could potentially extend those instead of copying +// the template. + +namespace Duende.Bff.Blazor; + +// This is a server-side AuthenticationStateProvider that uses +// PersistentComponentState to flow the authentication state to the client which +// is then used to initialize the authentication state in the WASM application. +public sealed class BffServerAuthenticationStateProvider : ServerAuthenticationStateProvider, IDisposable +{ + private readonly IClaimsService _claimsService; + private readonly PersistentComponentState _state; + private readonly NavigationManager _navigation; + private readonly ILogger _logger; + + private readonly PersistingComponentStateSubscription _subscription; + + private Task? _authenticationStateTask; + + public BffServerAuthenticationStateProvider( + IClaimsService claimsService, + PersistentComponentState persistentComponentState, + NavigationManager navigation, + ILogger logger) + { + _claimsService = claimsService; + _state = persistentComponentState; + _navigation = navigation; + _logger = logger; + + AuthenticationStateChanged += OnAuthenticationStateChanged; + _subscription = _state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly); + } + + private void OnAuthenticationStateChanged(Task task) + { + _authenticationStateTask = task; + } + + private async Task OnPersistingAsync() + { + if (_authenticationStateTask is null) + { + throw new UnreachableException($"Authentication state not set in {nameof(OnPersistingAsync)}()."); + } + + var authenticationState = await _authenticationStateTask; + + var claims = authenticationState.User.Claims + .Select(c => new ClaimLite + { + Type = c.Type, + Value = c.Value?.ToString() ?? string.Empty, + ValueType = c.ValueType == ClaimValueTypes.String ? null : c.ValueType + }).ToArray(); + + var principal = new ClaimsPrincipalLite + { + AuthenticationType = authenticationState.User.Identity!.AuthenticationType, + NameClaimType = authenticationState.User.Identities.First().NameClaimType, + RoleClaimType = authenticationState.User.Identities.First().RoleClaimType, + Claims = claims + }; + + _logger.LogDebug("Persisting Authentication State"); + + _state.PersistAsJson(nameof(ClaimsPrincipalLite), principal); + } + + + public void Dispose() + { + _subscription.Dispose(); + AuthenticationStateChanged -= OnAuthenticationStateChanged; + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor/ServerSideTokenStore.cs b/src/Duende.Bff.Blazor/ServerSideTokenStore.cs new file mode 100644 index 00000000..099c9d86 --- /dev/null +++ b/src/Duende.Bff.Blazor/ServerSideTokenStore.cs @@ -0,0 +1,79 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Claims; +using Duende.AccessTokenManagement.OpenIdConnect; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; + +namespace Duende.Bff.Blazor; + +/// +/// A token store that retrieves tokens from server side sessions. +/// +public class ServerSideTokenStore( + IStoreTokensInAuthenticationProperties tokensInAuthProperties, + IUserSessionStore sessionStore, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) : IUserTokenStore +{ + private readonly IDataProtector protector = + dataProtectionProvider.CreateProtector(ServerSideTicketStore.DataProtectorPurpose); + + public async Task GetTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters? parameters = null) + { + logger.LogDebug("Retrieving token for user {user}", user.Identity?.Name); + var session = await GetSession(user); + var ticket = session.Deserialize(protector, logger) ?? + throw new InvalidOperationException("Failed to deserialize authentication ticket from session"); + + return tokensInAuthProperties.GetUserToken(ticket.Properties, parameters); + } + + private async Task GetSession(ClaimsPrincipal user) + { + var sub = user.FindFirst("sub")?.Value ?? throw new InvalidOperationException("no sub claim"); + var sid = user.FindFirst("sid")?.Value ?? throw new InvalidOperationException("no sid claim"); + + logger.LogDebug("Retrieving session {sid} for sub {sub}", sid, sub); + + var sessions = await sessionStore.GetUserSessionsAsync(new UserSessionsFilter + { + SubjectId = sub, + SessionId = sid + }); + + if (sessions.Count == 0) throw new InvalidOperationException("No ticket found"); + if (sessions.Count > 1) throw new InvalidOperationException("Multiple tickets found"); + + return sessions.First(); + } + + public async Task StoreTokenAsync(ClaimsPrincipal user, UserToken token, + UserTokenRequestParameters? parameters = null) + { + logger.LogDebug("Storing token for user {user}", user.Identity?.Name); + await UpdateTicket(user, + ticket => { tokensInAuthProperties.SetUserToken(token, ticket.Properties, parameters); }); + } + + public async Task ClearTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters? parameters = null) + { + logger.LogDebug("Removing token for user {user}", user.Identity?.Name); + await UpdateTicket(user, ticket => { tokensInAuthProperties.RemoveUserToken(ticket.Properties, parameters); }); + } + + protected async Task UpdateTicket(ClaimsPrincipal user, Action updateAction) + { + var session = await GetSession(user); + var ticket = session.Deserialize(protector, logger) ?? + throw new InvalidOperationException("Failed to deserialize authentication ticket from session"); + + updateAction(ticket); + + session.Ticket = ticket.Serialize(protector); + + await sessionStore.UpdateUserSessionAsync(session.Key, session); + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Shared/ClaimLite.cs b/src/Duende.Bff.Shared/ClaimLite.cs new file mode 100644 index 00000000..ee7fd863 --- /dev/null +++ b/src/Duende.Bff.Shared/ClaimLite.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.Bff; + +/// +/// Serialization friendly claim +/// +public class ClaimLite +{ + /// + /// The type + /// + public string Type { get; init; } = default!; + + /// + /// The value + /// + public string Value { get; init; } = default!; + + /// + /// 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 new file mode 100644 index 00000000..d113d978 --- /dev/null +++ b/src/Duende.Bff.Shared/ClaimsLiteExtensions.cs @@ -0,0 +1,44 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Claims; + +namespace Duende.Bff; + +public static class ClaimsLiteExtensions +{ + /// + /// Converts a ClaimsPrincipalLite to ClaimsPrincipal + /// + public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsPrincipalLite principal) + { + var claims = principal.Claims.Select(x => new Claim(x.Type, x.Value, x.ValueType ?? ClaimValueTypes.String)) + .ToArray(); + var id = new ClaimsIdentity(claims, principal.AuthenticationType, principal.NameClaimType, + principal.RoleClaimType); + + return new ClaimsPrincipal(id); + } + + /// + /// Converts a ClaimsPrincipal to ClaimsPrincipalLite + /// + public static ClaimsPrincipalLite ToClaimsPrincipalLite(this ClaimsPrincipal principal) + { + var claims = principal.Claims.Select( + x => new ClaimLite + { + Type = x.Type, + Value = x.Value, + ValueType = x.ValueType == ClaimValueTypes.String ? null : x.ValueType + }).ToArray(); + + return new ClaimsPrincipalLite + { + AuthenticationType = principal.Identity!.AuthenticationType, + NameClaimType = principal.Identities.First().NameClaimType, + RoleClaimType = principal.Identities.First().RoleClaimType, + Claims = claims + }; + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Shared/ClaimsPrincipalLite.cs b/src/Duende.Bff.Shared/ClaimsPrincipalLite.cs new file mode 100644 index 00000000..ee880aa3 --- /dev/null +++ b/src/Duende.Bff.Shared/ClaimsPrincipalLite.cs @@ -0,0 +1,30 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.Bff; + +/// +/// Serialization friendly ClaimsPrincipal +/// +public class ClaimsPrincipalLite +{ + /// + /// The authentication type + /// + public string? AuthenticationType { get; init; } + + /// + /// The name claim type + /// + public string? NameClaimType { get; init; } + + /// + /// The role claim type + /// + public string? RoleClaimType { get; init; } + + /// + /// The claims + /// + public ClaimLite[] Claims { get; init; } = default!; +} \ No newline at end of file diff --git a/src/Duende.Bff.Shared/Duende.Bff.Shared.csproj b/src/Duende.Bff.Shared/Duende.Bff.Shared.csproj new file mode 100644 index 00000000..d76ee03e --- /dev/null +++ b/src/Duende.Bff.Shared/Duende.Bff.Shared.csproj @@ -0,0 +1,10 @@ + + + + net8.0 + enable + enable + Duende.Bff + + + diff --git a/src/Duende.Bff.Shared/README.md b/src/Duende.Bff.Shared/README.md new file mode 100644 index 00000000..fe785791 --- /dev/null +++ b/src/Duende.Bff.Shared/README.md @@ -0,0 +1,9 @@ +This project contains code that needs to be shared across Duende.Bff and +Duende.Bff.Blazor.Client. We can't depend on Duende.Bff in +Duende.Bff.Blazor.Client because the Duende.Bff has a framework reference to +aspnetcore and Duende.Bff.Blazor.Client is intended to be consumed in blazor +wasm applications. + +We can't depend on the Duende.Bff.Blazor.Client from Duende.Bff, because that +would bring all the blazor client work into the main package - we want that to +be opt in. \ No newline at end of file diff --git a/src/Duende.Bff/Configuration/BffServiceCollectionExtensions.cs b/src/Duende.Bff/Configuration/BffServiceCollectionExtensions.cs index 27656177..ff49ed43 100644 --- a/src/Duende.Bff/Configuration/BffServiceCollectionExtensions.cs +++ b/src/Duende.Bff/Configuration/BffServiceCollectionExtensions.cs @@ -47,6 +47,9 @@ public static BffBuilder AddBff(this IServiceCollection services, Action(); services.AddTransient(); + // Claims for user endpoint + services.AddTransient(); + // session management services.TryAddTransient(); diff --git a/src/Duende.Bff/Duende.Bff.csproj b/src/Duende.Bff/Duende.Bff.csproj index c909fa89..24816306 100644 --- a/src/Duende.Bff/Duende.Bff.csproj +++ b/src/Duende.Bff/Duende.Bff.csproj @@ -14,5 +14,7 @@ + + \ No newline at end of file diff --git a/src/Duende.Bff/EndpointServices/Logout/DefaultLogoutService.cs b/src/Duende.Bff/EndpointServices/Logout/DefaultLogoutService.cs index 6e0d3bfd..49d9ace7 100644 --- a/src/Duende.Bff/EndpointServices/Logout/DefaultLogoutService.cs +++ b/src/Duende.Bff/EndpointServices/Logout/DefaultLogoutService.cs @@ -1,6 +1,7 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using Duende.AccessTokenManagement.OpenIdConnect; using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -31,7 +32,12 @@ public class DefaultLogoutService : ILogoutService /// The return URL validator /// protected readonly IReturnUrlValidator ReturnUrlValidator; - + + /// + /// Service to interact with the token endpoint. + /// + protected readonly IUserTokenEndpointService TokenEndpoint; + /// /// The logger /// @@ -40,18 +46,16 @@ public class DefaultLogoutService : ILogoutService /// /// Ctor /// - /// - /// - /// - /// public DefaultLogoutService(IOptions options, IAuthenticationSchemeProvider authenticationAuthenticationSchemeProviderProvider, IReturnUrlValidator returnUrlValidator, + IUserTokenEndpointService tokenEndpoint, ILogger logger) { Options = options.Value; AuthenticationSchemeProvider = authenticationAuthenticationSchemeProviderProvider; ReturnUrlValidator = returnUrlValidator; + TokenEndpoint = tokenEndpoint; Logger = logger; } @@ -88,6 +92,21 @@ public virtual async Task ProcessRequestAsync(HttpContext context) } } + if (Options.RevokeRefreshTokenOnLogout && result.Ticket != null) + { + var refreshToken = result.Ticket.Properties.GetTokenValue("refresh_token"); + if (!String.IsNullOrWhiteSpace(refreshToken)) + { + await TokenEndpoint.RevokeRefreshTokenAsync(new UserToken { RefreshToken = refreshToken }, new UserTokenRequestParameters()); + + Logger.LogDebug("Refresh token revoked for sub {sub} and sid {sid}", result.Ticket.GetSubjectId(), result.Ticket.GetSessionId()); + } + else + { + Logger.LogTrace("Refresh token not found for sub {sub} and sid {sid}", result.Ticket.GetSubjectId(), result.Ticket.GetSessionId()); + } + } + // get rid of local cookie first var signInScheme = await AuthenticationSchemeProvider.GetDefaultSignInSchemeAsync(); await context.SignOutAsync(signInScheme?.Name); diff --git a/src/Duende.Bff/EndpointServices/User/DefaultClaimsService.cs b/src/Duende.Bff/EndpointServices/User/DefaultClaimsService.cs new file mode 100644 index 00000000..6b8b302f --- /dev/null +++ b/src/Duende.Bff/EndpointServices/User/DefaultClaimsService.cs @@ -0,0 +1,68 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using IdentityModel; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace Duende.Bff; + +/// +public class DefaultClaimsService : IClaimsService +{ + private readonly BffOptions Options; + + /// + /// Ctor. + /// + /// + public DefaultClaimsService(IOptions options) + { + Options = options.Value; + } + + /// + public Task> GetManagementClaimsAsync(PathString pathBase, ClaimsPrincipal? principal, AuthenticationProperties? properties) + { + var claims = new List(); + + var sessionId = principal?.FindFirst(JwtClaimTypes.SessionId)?.Value; + if (!String.IsNullOrWhiteSpace(sessionId)) + { + claims.Add(new ClaimRecord( + Constants.ClaimTypes.LogoutUrl, + pathBase + Options.LogoutPath.Value + $"?sid={UrlEncoder.Default.Encode(sessionId)}")); + } + + if (properties != null) + { + if (properties.ExpiresUtc.HasValue) + { + var expiresInSeconds = + properties.ExpiresUtc.Value.Subtract(DateTimeOffset.UtcNow).TotalSeconds; + claims.Add(new ClaimRecord( + Constants.ClaimTypes.SessionExpiresIn, + Math.Round(expiresInSeconds))); + } + + if (properties.Items.TryGetValue(OpenIdConnectSessionProperties.SessionState, out var sessionState) && sessionState is not null) + { + claims.Add(new ClaimRecord(Constants.ClaimTypes.SessionState, sessionState)); + } + } + + return Task.FromResult>(claims); + } + + /// + public Task> GetUserClaimsAsync(ClaimsPrincipal? principal, AuthenticationProperties? properties) => + Task.FromResult(principal?.Claims.Select(x => new ClaimRecord(x.Type, x.Value)) ?? Enumerable.Empty()); +} diff --git a/src/Duende.Bff/EndpointServices/User/DefaultUserService.cs b/src/Duende.Bff/EndpointServices/User/DefaultUserService.cs index 105dbc32..8c6ceadf 100644 --- a/src/Duende.Bff/EndpointServices/User/DefaultUserService.cs +++ b/src/Duende.Bff/EndpointServices/User/DefaultUserService.cs @@ -1,16 +1,12 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. -using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using System; using System.Collections.Generic; using System.Linq; using System.Text; -using System.Text.Encodings.Web; using System.Text.Json; using System.Threading.Tasks; using Duende.Bff.Logging; @@ -23,6 +19,11 @@ namespace Duende.Bff; /// public class DefaultUserService : IUserService { + /// + /// The claims service + /// + protected readonly IClaimsService Claims; + /// /// The options /// @@ -36,10 +37,12 @@ public class DefaultUserService : IUserService /// /// Ctor /// + /// /// /// - public DefaultUserService(IOptions options, ILoggerFactory loggerFactory) + public DefaultUserService(IClaimsService claims, IOptions options, ILoggerFactory loggerFactory) { + Claims = claims; Options = options.Value; Logger = loggerFactory.CreateLogger(LogCategories.ManagementEndpoints); } @@ -70,9 +73,18 @@ public virtual async Task ProcessRequestAsync(HttpContext context) } else { - var claims = new List(); - claims.AddRange(GetUserClaims(result)); - claims.AddRange(GetManagementClaims(context, result)); + // In blazor, it is sometimes necessary to copy management claims + // into the session. So, we don't want duplicate mgmt claims. + // Instead, they should overwrite the existing mgmt claims (in case + // they changed when the session slid, etc) + var claims = (await GetUserClaimsAsync(result)).ToList(); + var mgmtClaims = await GetManagementClaimsAsync(context, result); + + foreach (var claim in mgmtClaims) + { + claims.RemoveAll(c => c.type == claim.type); + claims.Add(claim); + } var json = JsonSerializer.Serialize(claims); @@ -89,10 +101,8 @@ public virtual async Task ProcessRequestAsync(HttpContext context) /// /// /// - protected virtual IEnumerable GetUserClaims(AuthenticateResult authenticateResult) - { - return authenticateResult.Principal?.Claims.Select(x => new ClaimRecord(x.Type, x.Value)) ?? Enumerable.Empty(); - } + protected virtual Task> GetUserClaimsAsync(AuthenticateResult authenticateResult) => + Claims.GetUserClaimsAsync(authenticateResult.Principal, authenticateResult.Properties); /// /// Collect management claims @@ -100,44 +110,15 @@ protected virtual IEnumerable GetUserClaims(AuthenticateResult auth /// /// /// - protected virtual IEnumerable GetManagementClaims(HttpContext context, AuthenticateResult authenticateResult) + protected virtual Task> GetManagementClaimsAsync(HttpContext context, AuthenticateResult authenticateResult) { - var claims = new List(); - - var pathBase = context.Request.PathBase; - - var sessionId = authenticateResult.Principal?.FindFirst(JwtClaimTypes.SessionId)?.Value; - if (!String.IsNullOrWhiteSpace(sessionId)) - { - claims.Add(new ClaimRecord( - Constants.ClaimTypes.LogoutUrl, - pathBase + Options.LogoutPath.Value + $"?sid={UrlEncoder.Default.Encode(sessionId)}")); - } - - if (authenticateResult.Properties != null) - { - if (authenticateResult.Properties.ExpiresUtc.HasValue) - { - var expiresInSeconds = - authenticateResult.Properties.ExpiresUtc.Value.Subtract(DateTimeOffset.UtcNow).TotalSeconds; - claims.Add(new ClaimRecord( - Constants.ClaimTypes.SessionExpiresIn, - Math.Round(expiresInSeconds))); - } - - if (authenticateResult.Properties.Items.TryGetValue(OpenIdConnectSessionProperties.SessionState, out var sessionState) && sessionState is not null) - { - claims.Add(new ClaimRecord(Constants.ClaimTypes.SessionState, sessionState)); - } - } - - return claims; + return Claims.GetManagementClaimsAsync(context.Request.PathBase, authenticateResult.Principal, authenticateResult.Properties); } - - /// - /// Serialization-friendly claim - /// - /// - /// - protected record ClaimRecord(string type, object value); -} \ No newline at end of file +} + +/// +/// Serialization-friendly claim +/// +/// +/// +public record ClaimRecord(string type, object value); \ No newline at end of file diff --git a/src/Duende.Bff/EndpointServices/User/IClaimsService.cs b/src/Duende.Bff/EndpointServices/User/IClaimsService.cs new file mode 100644 index 00000000..bb08a8ef --- /dev/null +++ b/src/Duende.Bff/EndpointServices/User/IClaimsService.cs @@ -0,0 +1,33 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Security.Claims; + +namespace Duende.Bff; + +/// +/// Interface for a service that retrieves user and management claims. +/// +public interface IClaimsService +{ + /// + /// Gets claims associated with the user's session. + /// + /// + /// + /// + Task> GetUserClaimsAsync(ClaimsPrincipal? principal, AuthenticationProperties? properties); + + /// + /// Gets claims that facilitate session and token management. + /// + /// + /// + /// + /// + Task> GetManagementClaimsAsync(PathString pathBase, ClaimsPrincipal? principal, AuthenticationProperties? properties); +} diff --git a/src/Duende.Bff/Extensions/AuthenticationTicketExtensions.cs b/src/Duende.Bff/Extensions/AuthenticationTicketExtensions.cs index 658ec962..dee932ac 100644 --- a/src/Duende.Bff/Extensions/AuthenticationTicketExtensions.cs +++ b/src/Duende.Bff/Extensions/AuthenticationTicketExtensions.cs @@ -43,7 +43,7 @@ public static string GetSubjectId(this AuthenticationTicket ticket) { return ticket.Principal.FindFirst(JwtClaimTypes.SessionId)?.Value; } - + /// /// Extracts the issuance time /// @@ -51,7 +51,7 @@ public static DateTime GetIssued(this AuthenticationTicket ticket) { return ticket.Properties.IssuedUtc?.UtcDateTime ?? DateTime.UtcNow; } - + /// /// Extracts the expiration time /// @@ -59,39 +59,6 @@ public static DateTime GetIssued(this AuthenticationTicket ticket) { return ticket.Properties.ExpiresUtc?.UtcDateTime; } - - /// - /// Converts a ClaimsPrincipalLite to ClaimsPrincipal - /// - private static ClaimsPrincipal ToClaimsPrincipal(this ClaimsPrincipalLite principal) - { - var claims = principal.Claims.Select(x => new Claim(x.Type, x.Value, x.ValueType ?? ClaimValueTypes.String)).ToArray(); - var id = new ClaimsIdentity(claims, principal.AuthenticationType, principal.NameClaimType, principal.RoleClaimType); - - return new ClaimsPrincipal(id); - } - - /// - /// Converts a ClaimsPrincipal to ClaimsPrincipalLite - /// - private static ClaimsPrincipalLite ToClaimsPrincipalLite(this ClaimsPrincipal principal) - { - var claims = principal.Claims.Select( - x => new ClaimLite - { - Type = x.Type, - Value = x.Value, - ValueType = x.ValueType == ClaimValueTypes.String ? null : x.ValueType - }).ToArray(); - - return new ClaimsPrincipalLite - { - AuthenticationType = principal.Identity!.AuthenticationType, - NameClaimType = principal.Identities.First().NameClaimType, - RoleClaimType = principal.Identities.First().RoleClaimType, - Claims = claims - }; - } /// /// Serializes and AuthenticationTicket to a string @@ -190,53 +157,6 @@ public class AuthenticationTicketLite /// public IDictionary Items { get; set; } = default!; } - - /// - /// Serialization friendly claim - /// - public class ClaimLite - { - /// - /// The type - /// - public string Type { get; init; } = default!; - - /// - /// The value - /// - public string Value { get; init; } = default!; - - /// - /// The value type - /// - public string? ValueType { get; init; } - } - - /// - /// Serialization friendly ClaimsPrincipal - /// - public class ClaimsPrincipalLite - { - /// - /// The authentication type - /// - public string? AuthenticationType { get; init; } - - /// - /// The name claim type - /// - public string? NameClaimType { get; init; } - - /// - /// The role claim type - /// - public string? RoleClaimType { get; init; } - - /// - /// The claims - /// - public ClaimLite[] Claims { get; init; } = default!; - } /// /// Envelope for serialized data diff --git a/src/Duende.Bff/SessionManagement/TicketStore/ServerSideTicketStore.cs b/src/Duende.Bff/SessionManagement/TicketStore/ServerSideTicketStore.cs index dfd3fadb..b03d236d 100644 --- a/src/Duende.Bff/SessionManagement/TicketStore/ServerSideTicketStore.cs +++ b/src/Duende.Bff/SessionManagement/TicketStore/ServerSideTicketStore.cs @@ -3,14 +3,12 @@ #nullable disable -using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.DataProtection.KeyManagement; using Microsoft.Extensions.Logging; namespace Duende.Bff; @@ -20,6 +18,12 @@ namespace Duende.Bff; /// public class ServerSideTicketStore : IServerTicketStore { + /// + /// The "purpose" string to use when protecting and unprotecting server side + /// tickets. + /// + public static string DataProtectorPurpose = "Duende.Bff.ServerSideTicketStore"; + private readonly IUserSessionStore _store; private readonly IDataProtector _protector; private readonly ILogger _logger; @@ -36,7 +40,7 @@ public ServerSideTicketStore( ILogger logger) { _store = store; - _protector = dataProtectionProvider.CreateProtector("Duende.Bff.ServerSideTicketStore"); + _protector = dataProtectionProvider.CreateProtector(DataProtectorPurpose); _logger = logger; } diff --git a/test/Duende.Bff.Tests/TestFramework/ApiResponse.cs b/test/Duende.Bff.Tests/TestFramework/ApiResponse.cs index d25434a1..a99c6ecf 100644 --- a/test/Duende.Bff.Tests/TestFramework/ApiResponse.cs +++ b/test/Duende.Bff.Tests/TestFramework/ApiResponse.cs @@ -5,7 +5,7 @@ namespace Duende.Bff.Tests.TestFramework { - public record ApiResponse(string Method, string Path, string Sub, string ClientId, IEnumerable Claims) + public record ApiResponse(string Method, string Path, string Sub, string ClientId, IEnumerable Claims) { public string Body { get; init; } From 68ba71129cff022bfb25ef659e22f3a619cc5bb3 Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Mon, 26 Aug 2024 15:33:14 -0500 Subject: [PATCH 2/7] Add unit test coverage of Blazor projects. --- Duende.Bff.sln | 33 ++- .../BffBlazorOptions.cs | 25 ++- .../BffClientAuthenticationStateProvider.cs | 120 +++-------- .../Duende.Bff.Blazor.Client.csproj | 5 + .../Internals/GetUserService.cs | 93 +++++++++ .../Internals/IGetUserService.cs | 22 ++ .../Internals/IPersistentUserService.cs | 19 ++ .../Internals/PersistentUserService.cs | 30 +++ .../ServiceCollectionExtensions.cs | 78 ++++++- .../PersistingAuthenticationStateProvider.cs | 1 - src/Duende.Bff.Blazor/ServerSideTokenStore.cs | 2 +- src/Duende.Bff.Shared/ClaimLite.cs | 8 +- src/Duende.Bff.Shared/ClaimsLiteExtensions.cs | 4 +- src/Duende.Bff.Shared/ClaimsPrincipalLite.cs | 10 +- .../AntiforgeryHandlerTests.cs | 30 +++ ...fClientAuthenticationStateProviderTests.cs | 146 +++++++++++++ .../Duende.Bff.Blazor.Client.UnitTests.csproj | 30 +++ .../GetUserServiceTests.cs | 161 +++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 191 ++++++++++++++++++ .../TestMocks.cs | 31 +++ .../Duende.Bff.Blazor.UnitTests.csproj | 29 +++ .../ServerSideTokenStoreTests.cs | 84 ++++++++ 22 files changed, 1029 insertions(+), 123 deletions(-) create mode 100644 src/Duende.Bff.Blazor.Client/Internals/GetUserService.cs create mode 100644 src/Duende.Bff.Blazor.Client/Internals/IGetUserService.cs create mode 100644 src/Duende.Bff.Blazor.Client/Internals/IPersistentUserService.cs create mode 100644 src/Duende.Bff.Blazor.Client/Internals/PersistentUserService.cs create mode 100644 test/Duende.Bff.Blazor.Client.UnitTests/AntiforgeryHandlerTests.cs create mode 100644 test/Duende.Bff.Blazor.Client.UnitTests/BffClientAuthenticationStateProviderTests.cs create mode 100644 test/Duende.Bff.Blazor.Client.UnitTests/Duende.Bff.Blazor.Client.UnitTests.csproj create mode 100644 test/Duende.Bff.Blazor.Client.UnitTests/GetUserServiceTests.cs create mode 100644 test/Duende.Bff.Blazor.Client.UnitTests/ServiceCollectionExtensionsTests.cs create mode 100644 test/Duende.Bff.Blazor.Client.UnitTests/TestMocks.cs create mode 100644 test/Duende.Bff.Blazor.UnitTests/Duende.Bff.Blazor.UnitTests.csproj create mode 100644 test/Duende.Bff.Blazor.UnitTests/ServerSideTokenStoreTests.cs diff --git a/Duende.Bff.sln b/Duende.Bff.sln index 63aced70..9aa9dc9f 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 5e73bf3d..5f3b8d64 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 60fa9bc8..9870aa9a 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 eca5b9df..8ac59466 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 00000000..b5629fc1 --- /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 00000000..752e32fd --- /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 00000000..bb9c373b --- /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 00000000..af2d98dd --- /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 17ab858f..f4f4498e 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,36 @@ public static IServiceCollection AddBffBlazorClient(this IServiceCollection serv services .AddAuthorizationCore() - .AddScoped() - .AddTransient() - .AddHttpClient("BffAuthenticationStateProvider", (sp, client) => + // Most services for wasm are singletons, because DI scope doesn't exist in wasm + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(TimeProvider.System) + // HttpMessageHandlers must be registered as transient + .AddTransient() + .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 +120,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 +138,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 +157,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 +176,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 f321c252..2957a0e9 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 099c9d86..b7562f22 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 ee7fd863..6d9a989a 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 d113d978..ba44f60d 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 ee880aa3..455f8116 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 00000000..5e4ed834 --- /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 00000000..3c5e4170 --- /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 00000000..5626771b --- /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 00000000..93b13339 --- /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 00000000..2d1b04e3 --- /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 00000000..4a02170e --- /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 00000000..2c104e3d --- /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 00000000..ec82a694 --- /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 From 8bfc6abd1fcff6beda6bc9e211d3e852c46bf7e1 Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Fri, 30 Aug 2024 13:29:20 -0500 Subject: [PATCH 3/7] Fix broken solution file --- Duende.Bff.sln | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/Duende.Bff.sln b/Duende.Bff.sln index 9aa9dc9f..28dba794 100644 --- a/Duende.Bff.sln +++ b/Duende.Bff.sln @@ -47,9 +47,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Blazor.Wasm.Bff", "samples\ 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}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "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}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Duende.Bff.Blazor.UnitTests", "test\Duende.Bff.Blazor.UnitTests\Duende.Bff.Blazor.UnitTests.csproj", "{2A04808A-A06C-4F10-87B9-2D12E065F729}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Duende.Bff.Shared", "src\Duende.Bff.Shared\Duende.Bff.Shared.csproj", "{EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -253,6 +255,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 + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x64.ActiveCfg = Debug|Any CPU + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x64.Build.0 = Debug|Any CPU + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x86.ActiveCfg = Debug|Any CPU + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x86.Build.0 = Debug|Any CPU + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|Any CPU.Build.0 = Release|Any CPU + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x64.ActiveCfg = Release|Any CPU + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x64.Build.0 = Release|Any CPU + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x86.ActiveCfg = Release|Any CPU + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x86.Build.0 = Release|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x64.ActiveCfg = Debug|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x64.Build.0 = Debug|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x86.ActiveCfg = Debug|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x86.Build.0 = Debug|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|Any CPU.Build.0 = Release|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|x64.ActiveCfg = Release|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|x64.Build.0 = Release|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|x86.ActiveCfg = Release|Any CPU + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.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 @@ -277,6 +303,18 @@ Global {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 + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Debug|x64.ActiveCfg = Debug|Any CPU + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Debug|x64.Build.0 = Debug|Any CPU + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Debug|x86.ActiveCfg = Debug|Any CPU + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Debug|x86.Build.0 = Debug|Any CPU + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Release|Any CPU.Build.0 = Release|Any CPU + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Release|x64.ActiveCfg = Release|Any CPU + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Release|x64.Build.0 = Release|Any CPU + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Release|x86.ActiveCfg = Release|Any CPU + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -298,8 +336,11 @@ 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} + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3} = {3C549079-A502-4B40-B051-5278915AE91B} + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4} = {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} + {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB} = {3C549079-A502-4B40-B051-5278915AE91B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3DAD5980-4688-4794-9CF0-6F3CB67194E7} From 94ffb87ab735950e35a3e63022ef40823637babd Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Fri, 30 Aug 2024 14:23:17 -0500 Subject: [PATCH 4/7] A second try at fixing the solution file --- Duende.Bff.sln | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Duende.Bff.sln b/Duende.Bff.sln index 28dba794..05397bce 100644 --- a/Duende.Bff.sln +++ b/Duende.Bff.sln @@ -53,6 +53,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Duende.Bff.Blazor.UnitTests EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Duende.Bff.Shared", "src\Duende.Bff.Shared\Duende.Bff.Shared.csproj", "{EDC31C09-611B-4B4A-870B-FE1BD9EF82AB}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Blazor", "Blazor", "{CBA3995A-7326-46AA-9153-12DDDC1C15CB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -336,11 +338,12 @@ 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} - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3} = {3C549079-A502-4B40-B051-5278915AE91B} - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4} = {3C549079-A502-4B40-B051-5278915AE91B} + {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3} = {CBA3995A-7326-46AA-9153-12DDDC1C15CB} + {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4} = {CBA3995A-7326-46AA-9153-12DDDC1C15CB} {001840D4-8B83-4A8C-AF2C-5429D4F9A370} = {B2A776DB-385B-4AD4-96A5-61746FD909C3} {2A04808A-A06C-4F10-87B9-2D12E065F729} = {B2A776DB-385B-4AD4-96A5-61746FD909C3} {EDC31C09-611B-4B4A-870B-FE1BD9EF82AB} = {3C549079-A502-4B40-B051-5278915AE91B} + {CBA3995A-7326-46AA-9153-12DDDC1C15CB} = {E14F66D1-EA3E-40C6-835A-91A4382D4646} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3DAD5980-4688-4794-9CF0-6F3CB67194E7} From 0ac7a01f91c775aa92ac62dd5913480263ae0294 Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Thu, 5 Sep 2024 09:29:16 -0500 Subject: [PATCH 5/7] Revalidate in server auth state provider --- .../PersistingAuthenticationStateProvider.cs | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs b/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs index 2957a0e9..3e7549dc 100644 --- a/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs +++ b/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs @@ -3,11 +3,14 @@ using System.Diagnostics; using System.Security.Claims; +using Duende.Bff.Blazor.Client; +using IdentityModel; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; // This is based on the PersistingServerAuthenticationStateProvider from ASP.NET // 8's templates. @@ -18,12 +21,14 @@ namespace Duende.Bff.Blazor; + // This is a server-side AuthenticationStateProvider that uses // PersistentComponentState to flow the authentication state to the client which // is then used to initialize the authentication state in the WASM application. -public sealed class BffServerAuthenticationStateProvider : ServerAuthenticationStateProvider, IDisposable +public sealed class BffServerAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider, IDisposable { private readonly IClaimsService _claimsService; + private readonly IUserSessionStore _sessionStore; private readonly PersistentComponentState _state; private readonly NavigationManager _navigation; private readonly ILogger _logger; @@ -32,16 +37,25 @@ public sealed class BffServerAuthenticationStateProvider : ServerAuthenticationS private Task? _authenticationStateTask; + protected override TimeSpan RevalidationInterval { get; } + public BffServerAuthenticationStateProvider( IClaimsService claimsService, + IUserSessionStore sessionStore, PersistentComponentState persistentComponentState, NavigationManager navigation, - ILogger logger) + IOptions options, + ILoggerFactory loggerFactory) + : base(loggerFactory) { _claimsService = claimsService; + _sessionStore = sessionStore; _state = persistentComponentState; _navigation = navigation; - _logger = logger; + _logger = loggerFactory.CreateLogger(); + + // TODO - Consider separate options for server and client + RevalidationInterval = TimeSpan.FromMilliseconds(options.Value.StateProviderPollingInterval); AuthenticationStateChanged += OnAuthenticationStateChanged; _subscription = _state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly); @@ -88,4 +102,17 @@ public void Dispose() _subscription.Dispose(); AuthenticationStateChanged -= OnAuthenticationStateChanged; } + + protected override async Task ValidateAuthenticationStateAsync(AuthenticationState authenticationState, CancellationToken cancellationToken) + { + var sid = authenticationState.User.FindFirstValue(JwtClaimTypes.SessionId); + var sub = authenticationState.User.FindFirstValue(JwtClaimTypes.Subject); + + var sessions = await _sessionStore.GetUserSessionsAsync(new UserSessionsFilter + { + SessionId = sid, + SubjectId = sub + }); + return sessions.Count != 0; + } } \ No newline at end of file From 85257fb94d106198374c1c0a6aab4c9b9b1a38bb Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Thu, 5 Sep 2024 09:29:40 -0500 Subject: [PATCH 6/7] Rename file to match class name --- ...onStateProvider.cs => BffServerAuthenticationStateProvider.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Duende.Bff.Blazor/{PersistingAuthenticationStateProvider.cs => BffServerAuthenticationStateProvider.cs} (100%) diff --git a/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs b/src/Duende.Bff.Blazor/BffServerAuthenticationStateProvider.cs similarity index 100% rename from src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs rename to src/Duende.Bff.Blazor/BffServerAuthenticationStateProvider.cs From cca5e5fbf8e0500492bea4a103f881ff28b3854d Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Thu, 5 Sep 2024 10:30:55 -0500 Subject: [PATCH 7/7] Handle ordering edge cases in auth state changes --- src/Duende.Bff.Blazor/BffBuilderExtensions.cs | 6 +++++ src/Duende.Bff.Blazor/ServerSideTokenStore.cs | 26 ++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/Duende.Bff.Blazor/BffBuilderExtensions.cs b/src/Duende.Bff.Blazor/BffBuilderExtensions.cs index 5e8de8ae..efd00306 100644 --- a/src/Duende.Bff.Blazor/BffBuilderExtensions.cs +++ b/src/Duende.Bff.Blazor/BffBuilderExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using Duende.AccessTokenManagement.OpenIdConnect; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -13,6 +14,11 @@ public static BffBuilder AddBlazorServer(this BffBuilder builder) { builder.Services.AddOpenIdConnectAccessTokenManagement() .AddBlazorServerAccessTokenManagement(); + + var removeThis = builder.Services.First(d => d.ImplementationType == typeof(ServerSideTokenStore)); + builder.Services.Remove(removeThis); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Duende.Bff.Blazor/ServerSideTokenStore.cs b/src/Duende.Bff.Blazor/ServerSideTokenStore.cs index b7562f22..adc0255b 100644 --- a/src/Duende.Bff.Blazor/ServerSideTokenStore.cs +++ b/src/Duende.Bff.Blazor/ServerSideTokenStore.cs @@ -4,6 +4,7 @@ using System.Security.Claims; using Duende.AccessTokenManagement.OpenIdConnect; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; @@ -16,22 +17,33 @@ public class ServerSideTokenStore( IStoreTokensInAuthenticationProperties tokensInAuthProperties, IUserSessionStore sessionStore, IDataProtectionProvider dataProtectionProvider, - ILogger logger) : IUserTokenStore + ILogger logger, + AuthenticationStateProvider authenticationStateProvider) : IUserTokenStore { private readonly IDataProtector protector = dataProtectionProvider.CreateProtector(ServerSideTicketStore.DataProtectorPurpose); + private readonly IHostEnvironmentAuthenticationStateProvider _authenticationStateProvider = authenticationStateProvider as IHostEnvironmentAuthenticationStateProvider + ?? throw new ArgumentException("AuthenticationStateProvider must implement IHostEnvironmentAuthenticationStateProvider"); + public async Task GetTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters? parameters = null) { logger.LogDebug("Retrieving token for user {user}", user.Identity?.Name); var session = await GetSession(user); + if (session == null) + { + var anonymous = new ClaimsPrincipal(new ClaimsIdentity()); + var loggedOutTask = Task.FromResult(new AuthenticationState(user: anonymous)); + _authenticationStateProvider.SetAuthenticationState(loggedOutTask); + return new UserToken(); + } var ticket = session.Deserialize(protector, logger) ?? throw new InvalidOperationException("Failed to deserialize authentication ticket from session"); return tokensInAuthProperties.GetUserToken(ticket.Properties, parameters); } - private async Task GetSession(ClaimsPrincipal user) + private async Task GetSession(ClaimsPrincipal user) { var sub = user.FindFirst("sub")?.Value ?? throw new InvalidOperationException("no sub claim"); var sid = user.FindFirst("sid")?.Value ?? throw new InvalidOperationException("no sid claim"); @@ -44,7 +56,10 @@ private async Task GetSession(ClaimsPrincipal user) SessionId = sid }); - if (sessions.Count == 0) throw new InvalidOperationException("No ticket found"); + if (sessions.Count == 0) + { + return null; + } if (sessions.Count > 1) throw new InvalidOperationException("Multiple tickets found"); return sessions.First(); @@ -67,6 +82,11 @@ public async Task ClearTokenAsync(ClaimsPrincipal user, UserTokenRequestParamete protected async Task UpdateTicket(ClaimsPrincipal user, Action updateAction) { var session = await GetSession(user); + if (session == null) + { + logger.LogDebug("Failed to find a session to update, bailing out"); + return; + } var ticket = session.Deserialize(protector, logger) ?? throw new InvalidOperationException("Failed to deserialize authentication ticket from session");