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; }