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..05397bce 100644 --- a/Duende.Bff.sln +++ b/Duende.Bff.sln @@ -39,12 +39,22 @@ 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 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("{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("{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 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Blazor", "Blazor", "{CBA3995A-7326-46AA-9153-12DDDC1C15CB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -223,6 +233,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 + {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 {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 @@ -247,6 +281,42 @@ Global {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 + {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 + {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 @@ -266,9 +336,14 @@ 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} + {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} 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..5f3b8d64 --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs @@ -0,0 +1,41 @@ +// 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 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. + /// + 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..9870aa9a --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs @@ -0,0 +1,77 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.Bff.Blazor.Client.Internals; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Options; + +namespace Duende.Bff.Blazor.Client; + +public class BffClientAuthenticationStateProvider : AuthenticationStateProvider +{ + public const string HttpClientName = "Duende.Bff.Blazor.Client:StateProvider"; + + private readonly IGetUserService _getUserService; + private readonly TimeProvider _timeProvider; + private readonly BffBlazorOptions _options; + private readonly ILogger _logger; + + /// + /// An intended for use in Blazor + /// WASM. It polls the /bff/user endpoint to monitor session state. + /// + public BffClientAuthenticationStateProvider( + IGetUserService getUserService, + TimeProvider timeProvider, + IOptions options, + ILogger logger) + { + _getUserService = getUserService; + _timeProvider = timeProvider; + _options = options.Value; + _logger = logger; + } + + public override async Task GetAuthenticationStateAsync() + { + _getUserService.InitializeCache(); + var user = await _getUserService.GetUserAsync(); + var state = new AuthenticationState(user); + + if (user.Identity is { IsAuthenticated: true }) + { + _logger.LogInformation("starting background check.."); + ITimer? timer = null; + + async void TimerCallback(object? _) + { + 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 + // 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))); + + if (currentUser!.Identity!.IsAuthenticated == false) + { + _logger.LogInformation("user logged out"); + + if (timer != null) + { + await timer.DisposeAsync(); + } + } + } + + timer = _timeProvider.CreateTimer(TimerCallback, + null, + TimeSpan.FromMilliseconds(_options.StateProviderPollingDelay), + TimeSpan.FromMilliseconds(_options.StateProviderPollingInterval)); + } + return state; + } +} 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..8ac59466 --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000..f4f4498e --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,198 @@ +// 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; +using Microsoft.Extensions.Options; + +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) + { + if (configureAction != null) + { + services.Configure(configureAction); + } + + services + .AddAuthorizationCore() + // 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 = 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>(); + 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); + } + + /// + /// 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) + { + return services.AddHttpClient(clientName, SetBaseAddress(configureClient)) + .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) + { + return services.AddHttpClient(clientName, SetBaseAddress(configureClient)) + .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 + { + return services.AddHttpClient(SetBaseAddress(configureClient)) + .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 + { + 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..efd00306 --- /dev/null +++ b/src/Duende.Bff.Blazor/BffBuilderExtensions.cs @@ -0,0 +1,27 @@ +// 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; + +namespace Duende.Bff.Blazor; + +public static class BffBuilderExtensions +{ + 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(); + + return builder; + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor/BffServerAuthenticationStateProvider.cs b/src/Duende.Bff.Blazor/BffServerAuthenticationStateProvider.cs new file mode 100644 index 00000000..3e7549dc --- /dev/null +++ b/src/Duende.Bff.Blazor/BffServerAuthenticationStateProvider.cs @@ -0,0 +1,118 @@ +// 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 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. + +// 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 : RevalidatingServerAuthenticationStateProvider, IDisposable +{ + private readonly IClaimsService _claimsService; + private readonly IUserSessionStore _sessionStore; + private readonly PersistentComponentState _state; + private readonly NavigationManager _navigation; + private readonly ILogger _logger; + + private readonly PersistingComponentStateSubscription _subscription; + + private Task? _authenticationStateTask; + + protected override TimeSpan RevalidationInterval { get; } + + public BffServerAuthenticationStateProvider( + IClaimsService claimsService, + IUserSessionStore sessionStore, + PersistentComponentState persistentComponentState, + NavigationManager navigation, + IOptions options, + ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _claimsService = claimsService; + _sessionStore = sessionStore; + _state = persistentComponentState; + _navigation = navigation; + _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); + } + + 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; + } + + 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 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/ServerSideTokenStore.cs b/src/Duende.Bff.Blazor/ServerSideTokenStore.cs new file mode 100644 index 00000000..adc0255b --- /dev/null +++ b/src/Duende.Bff.Blazor/ServerSideTokenStore.cs @@ -0,0 +1,99 @@ +// 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.Components.Authorization; +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, + 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) + { + 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) + { + return null; + } + 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); + 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"); + + 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..6d9a989a --- /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..ba44f60d --- /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..455f8116 --- /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.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 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; }