From d37bd36f33af79bb7aa3ae5940feece62469b4a7 Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Mon, 19 Aug 2024 19:58:35 -0500 Subject: [PATCH] Added Duende.Bff.Blazor --- .gitignore | 3 + Directory.Build.targets | 20 ++- Duende.Bff.sln | 60 +++---- build/Program.cs | 2 + .../AntiforgeryHandler.cs | 14 ++ .../BffBlazorOptions.cs | 34 ++++ .../BffClientAuthenticationStateProvider.cs | 147 ++++++++++++++++++ .../Duende.Bff.Blazor.Client.csproj | 19 +++ .../ServiceCollectionExtensions.cs | 128 +++++++++++++++ src/Duende.Bff.Blazor/BffBuilderExtensions.cs | 21 +++ .../CaptureManagementClaimsCookieEvents.cs | 41 +++++ .../Duende.Bff.Blazor.csproj | 14 ++ .../PersistingAuthenticationStateProvider.cs | 92 +++++++++++ src/Duende.Bff.Blazor/ServerSideTokenStore.cs | 79 ++++++++++ src/Duende.Bff.Shared/ClaimLite.cs | 25 +++ src/Duende.Bff.Shared/ClaimsLiteExtensions.cs | 44 ++++++ src/Duende.Bff.Shared/ClaimsPrincipalLite.cs | 30 ++++ .../Duende.Bff.Shared.csproj | 10 ++ src/Duende.Bff.Shared/README.md | 9 ++ .../BffServiceCollectionExtensions.cs | 3 + src/Duende.Bff/Duende.Bff.csproj | 2 + .../Logout/DefaultLogoutService.cs | 29 +++- .../User/DefaultClaimsService.cs | 68 ++++++++ .../User/DefaultUserService.cs | 83 ++++------ .../EndpointServices/User/IClaimsService.cs | 33 ++++ .../AuthenticationTicketExtensions.cs | 84 +--------- .../TicketStore/ServerSideTicketStore.cs | 10 +- .../TestFramework/ApiResponse.cs | 2 +- 28 files changed, 931 insertions(+), 175 deletions(-) create mode 100644 src/Duende.Bff.Blazor.Client/AntiforgeryHandler.cs create mode 100644 src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs create mode 100644 src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs create mode 100644 src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj create mode 100644 src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs create mode 100644 src/Duende.Bff.Blazor/BffBuilderExtensions.cs create mode 100644 src/Duende.Bff.Blazor/CaptureManagementClaimsCookieEvents.cs create mode 100644 src/Duende.Bff.Blazor/Duende.Bff.Blazor.csproj create mode 100644 src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs create mode 100644 src/Duende.Bff.Blazor/ServerSideTokenStore.cs create mode 100644 src/Duende.Bff.Shared/ClaimLite.cs create mode 100644 src/Duende.Bff.Shared/ClaimsLiteExtensions.cs create mode 100644 src/Duende.Bff.Shared/ClaimsPrincipalLite.cs create mode 100644 src/Duende.Bff.Shared/Duende.Bff.Shared.csproj create mode 100644 src/Duende.Bff.Shared/README.md create mode 100644 src/Duende.Bff/EndpointServices/User/DefaultClaimsService.cs create mode 100644 src/Duende.Bff/EndpointServices/User/IClaimsService.cs diff --git a/.gitignore b/.gitignore index c40c1def..869b90fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# MacOs +.DS_Store + # Rider .idea diff --git a/Directory.Build.targets b/Directory.Build.targets index 0687475f..f6ea513e 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,9 +1,10 @@ 8.0.0 - 8.0.0 + 8.0.8 + 7.1.2 2.1.0 - 7.0.4 + 7.0.6 @@ -13,15 +14,26 @@ - + + + + + + + + + + + - + + diff --git a/Duende.Bff.sln b/Duende.Bff.sln index 68db5315..63aced70 100644 --- a/Duende.Bff.sln +++ b/Duende.Bff.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.9.34414.90 @@ -39,7 +39,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JS8.DPoP", "samples\JS8.DPo EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JS8.EF", "samples\JS8.EF\JS8.EF.csproj", "{CBB98134-92F5-487D-8CA3-84C19FF46775}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Blazor.Wasm", "Blazor.Wasm", "{7E6EA8BA-EE8B-450E-AE89-C4604C0DD326}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Duende.Bff.Blazor", "src\Duende.Bff.Blazor\Duende.Bff.Blazor.csproj", "{E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Duende.Bff.Blazor.Client", "src\Duende.Bff.Blazor.Client\Duende.Bff.Blazor.Client.csproj", "{DDB9C401-6B1F-4727-A4CB-932034FBF94E}" +EndProject EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Blazor.Wasm.Bff", "samples\Blazor.Wasm\Blazor.Wasm.Bff\Blazor.Wasm.Bff.csproj", "{BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}" EndProject @@ -223,30 +226,30 @@ Global {CBB98134-92F5-487D-8CA3-84C19FF46775}.Release|x64.Build.0 = Release|Any CPU {CBB98134-92F5-487D-8CA3-84C19FF46775}.Release|x86.ActiveCfg = Release|Any CPU {CBB98134-92F5-487D-8CA3-84C19FF46775}.Release|x86.Build.0 = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x64.ActiveCfg = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x64.Build.0 = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x86.ActiveCfg = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Debug|x86.Build.0 = Debug|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|Any CPU.Build.0 = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x64.ActiveCfg = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x64.Build.0 = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x86.ActiveCfg = Release|Any CPU - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}.Release|x86.Build.0 = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x64.ActiveCfg = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x64.Build.0 = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x86.ActiveCfg = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Debug|x86.Build.0 = Debug|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|Any CPU.Build.0 = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|x64.ActiveCfg = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|x64.Build.0 = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|x86.ActiveCfg = Release|Any CPU - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}.Release|x86.Build.0 = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|x64.ActiveCfg = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|x64.Build.0 = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|x86.ActiveCfg = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Debug|x86.Build.0 = Debug|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|Any CPU.Build.0 = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|x64.ActiveCfg = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|x64.Build.0 = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|x86.ActiveCfg = Release|Any CPU + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84}.Release|x86.Build.0 = Release|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|x64.ActiveCfg = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|x64.Build.0 = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|x86.ActiveCfg = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Debug|x86.Build.0 = Debug|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|Any CPU.Build.0 = Release|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|x64.ActiveCfg = Release|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|x64.Build.0 = Release|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|x86.ActiveCfg = Release|Any CPU + {DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -266,9 +269,8 @@ Global {B37CA136-3F20-4D8A-9677-E3A9C9D893EF} = {E14F66D1-EA3E-40C6-835A-91A4382D4646} {D8757F0F-254E-495F-961F-0192F8C97E3F} = {E14F66D1-EA3E-40C6-835A-91A4382D4646} {CBB98134-92F5-487D-8CA3-84C19FF46775} = {E14F66D1-EA3E-40C6-835A-91A4382D4646} - {7E6EA8BA-EE8B-450E-AE89-C4604C0DD326} = {E14F66D1-EA3E-40C6-835A-91A4382D4646} - {BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3} = {7E6EA8BA-EE8B-450E-AE89-C4604C0DD326} - {4E69FCF6-AE76-4F6D-98B8-969E9D244AE4} = {7E6EA8BA-EE8B-450E-AE89-C4604C0DD326} + {E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84} = {3C549079-A502-4B40-B051-5278915AE91B} + {DDB9C401-6B1F-4727-A4CB-932034FBF94E} = {3C549079-A502-4B40-B051-5278915AE91B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3DAD5980-4688-4794-9CF0-6F3CB67194E7} diff --git a/build/Program.cs b/build/Program.cs index 8fa20985..45d975c2 100644 --- a/build/Program.cs +++ b/build/Program.cs @@ -59,6 +59,8 @@ internal static async Task Main(string[] args) Run("dotnet", $"pack ./src/Duende.Bff/Duende.Bff.csproj -c Release -o {Directory.CreateDirectory(packOutput).FullName} --no-build --nologo"); Run("dotnet", $"pack ./src/Duende.Bff.EntityFramework/Duende.Bff.EntityFramework.csproj -c Release -o {Directory.CreateDirectory(packOutput).FullName} --no-build --nologo"); Run("dotnet", $"pack ./src/Duende.Bff.Yarp/Duende.Bff.Yarp.csproj -c Release -o {Directory.CreateDirectory(packOutput).FullName} --no-build --nologo"); + Run("dotnet", $"pack ./src/Duende.Bff.Blazor/Duende.Bff.Blazor.csproj -c Release -o {Directory.CreateDirectory(packOutput).FullName} --no-build --nologo"); + Run("dotnet", $"pack ./src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj -c Release -o {Directory.CreateDirectory(packOutput).FullName} --no-build --nologo"); }); Target(Targets.SignPackage, DependsOn(Targets.Pack, Targets.RestoreTools), () => diff --git a/src/Duende.Bff.Blazor.Client/AntiforgeryHandler.cs b/src/Duende.Bff.Blazor.Client/AntiforgeryHandler.cs new file mode 100644 index 00000000..d29ab014 --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/AntiforgeryHandler.cs @@ -0,0 +1,14 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.Bff.Blazor.Client; + +public class AntiforgeryHandler : DelegatingHandler +{ + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + request.Headers.Add("X-CSRF", "1"); + return base.SendAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs b/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs new file mode 100644 index 00000000..5e73bf3d --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs @@ -0,0 +1,34 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.Bff.Blazor.Client; + +/// +/// Options for Blazor BFF +/// +public class BffBlazorOptions +{ + /// + /// The base path to use for remote APIs. + /// + public string RemoteApiPath { get; set; } = "remote-apis/"; + + /// + /// The base address to use for remote APIs. If unset (the default), the + /// blazor hosting environment's base address is used. + /// + public string? RemoteApiBaseAddress { get; set; } = null; + + /// + /// The delay, in milliseconds, before the AuthenticationStateProvider + /// will start polling the /bff/user endpoint. Defaults to 1000 ms. + /// + public int StateProviderPollingDelay { get; set; } = 1000; + + /// + /// The delay, in milliseconds, between polling requests by the + /// AuthenticationStateProvider to the /bff/user endpoint. Defaults to + /// 5000 ms. + /// + public int StateProviderPollingInterval { get; set; } = 5000; +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs b/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs new file mode 100644 index 00000000..60fa9bc8 --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs @@ -0,0 +1,147 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net.Http.Json; +using System.Security.Claims; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Options; + +namespace Duende.Bff.Blazor.Client; + +public class BffClientAuthenticationStateProvider : AuthenticationStateProvider +{ + private static readonly TimeSpan UserCacheRefreshInterval = TimeSpan.FromSeconds(60); + + private readonly HttpClient _client; + private readonly ILogger _logger; + private readonly BffBlazorOptions _options; + + private DateTimeOffset _userLastCheck = DateTimeOffset.MinValue; + private ClaimsPrincipal _cachedUser = new(new ClaimsIdentity()); + + /// + /// An intended for use in + /// Blazor WASM. It polls the /bff/user endpoint to monitor session + /// state. + /// + public BffClientAuthenticationStateProvider( + PersistentComponentState state, + IHttpClientFactory factory, + IOptions options, + ILogger logger) + { + _client = factory.CreateClient("BffAuthenticationStateProvider"); + _logger = logger; + _cachedUser = GetPersistedUser(state); + if (_cachedUser.Identity?.IsAuthenticated == true) + { + _userLastCheck = DateTimeOffset.Now; + } + + _options = options.Value; + } + + public override async Task GetAuthenticationStateAsync() + { + var user = await GetUser(); + var state = new AuthenticationState(user); + + // Periodically + if (user.Identity is { IsAuthenticated: true }) + { + _logger.LogInformation("starting background check.."); + Timer? timer = null; + + timer = new Timer(async _ => + { + var currentUser = await GetUser(false); + // Always notify that auth state has changed, because the user + // management claims (usually) change over time. + // + // Future TODO - Someday we may want an extensibility point. If the + // user management claims have been customized, then auth state + // wouldn't always change. In that case, we'd want to only fire + // if the user actually had changed. + NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(currentUser))); + + if (currentUser!.Identity!.IsAuthenticated == false) + { + _logger.LogInformation("user logged out"); + + if (timer != null) + { + await timer.DisposeAsync(); + } + } + }, null, _options.StateProviderPollingDelay, _options.StateProviderPollingInterval); + } + + return state; + } + + private async ValueTask GetUser(bool useCache = true) + { + var now = DateTimeOffset.Now; + if (useCache && now < _userLastCheck + UserCacheRefreshInterval) + { + _logger.LogDebug("Taking user from cache"); + return _cachedUser; + } + + _logger.LogDebug("Fetching user"); + _cachedUser = await FetchUser(); + _userLastCheck = now; + + return _cachedUser; + } + + // TODO - Consider using ClaimLite instead here + record ClaimRecord(string Type, object Value); + + private async Task FetchUser() + { + try + { + _logger.LogInformation("Fetching user information."); + var response = await _client.GetAsync("bff/user?slide=false"); + response.EnsureSuccessStatusCode(); + var claims = await response.Content.ReadFromJsonAsync>(); + + var identity = new ClaimsIdentity( + nameof(BffClientAuthenticationStateProvider), + "name", + "role"); + + if (claims != null) + { + foreach (var claim in claims) + { + identity.AddClaim(new Claim(claim.Type, claim.Value.ToString() ?? "no value")); + } + } + + return new ClaimsPrincipal(identity); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Fetching user failed."); + } + + return new ClaimsPrincipal(new ClaimsIdentity()); + } + + private ClaimsPrincipal GetPersistedUser(PersistentComponentState state) + { + if (!state.TryTakeFromJson(nameof(ClaimsPrincipalLite), out var lite) || lite is null) + { + _logger.LogDebug("Failed to load persisted user."); + return new ClaimsPrincipal(new ClaimsIdentity()); + } + + _logger.LogDebug("Persisted user loaded."); + + return lite.ToClaimsPrincipal(); + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj b/src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj new file mode 100644 index 00000000..eca5b9df --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs b/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..17ab858f --- /dev/null +++ b/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,128 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Duende.Bff.Blazor.Client; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddBffBlazorClient(this IServiceCollection services, + Action? configureAction = null) + { + if (configureAction != null) + { + services.Configure(configureAction); + } + + services + .AddAuthorizationCore() + .AddScoped() + .AddTransient() + .AddHttpClient("BffAuthenticationStateProvider", (sp, client) => + { + var baseAddress = GetBaseAddress(sp); + client.BaseAddress = new Uri(baseAddress); + }).AddHttpMessageHandler(); + + return services; + } + + private static string GetBaseAddress(IServiceProvider sp) + { + var opt = sp.GetRequiredService>(); + if (opt.Value.RemoteApiBaseAddress != null) + { + return opt.Value.RemoteApiBaseAddress; + } + else + { + var hostEnv = sp.GetRequiredService(); + return hostEnv.BaseAddress; + } + } + + private static string GetRemoteApiPath(IServiceProvider sp) + { + var opt = sp.GetRequiredService>(); + return opt.Value.RemoteApiPath; + } + + private static Action SetBaseAddress( + Action? configureClient) + { + return (sp, client) => + { + SetBaseAddress(sp, client); + configureClient?.Invoke(sp, client); + }; + } + + private static Action SetBaseAddress( + Action? configureClient) + { + return (sp, client) => + { + SetBaseAddress(sp, client); + configureClient?.Invoke(client); + }; + } + + private static void SetBaseAddress(IServiceProvider sp, HttpClient client) + { + var baseAddress = GetBaseAddress(sp); + if (!baseAddress.EndsWith("/")) + { + baseAddress += "/"; + } + + var remoteApiPath = GetRemoteApiPath(sp); + if (!string.IsNullOrEmpty(remoteApiPath)) + { + if (remoteApiPath.StartsWith("/")) + { + remoteApiPath = remoteApiPath.Substring(1); + } + + if (!remoteApiPath.EndsWith("/")) + { + remoteApiPath += "/"; + } + } + + client.BaseAddress = new Uri(new Uri(baseAddress), remoteApiPath); + } + + public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, string clientName, + Action configureClient) + { + return services.AddHttpClient(clientName, SetBaseAddress(configureClient)) + .AddHttpMessageHandler(); + } + + public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, string clientName, + Action? configureClient = null) + { + return services.AddHttpClient(clientName, SetBaseAddress(configureClient)) + .AddHttpMessageHandler(); + } + + public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, + Action configureClient) + where T : class + { + return services.AddHttpClient(SetBaseAddress(configureClient)) + .AddHttpMessageHandler(); + } + + public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, + Action? configureClient = null) + where T : class + { + return services.AddHttpClient(SetBaseAddress(configureClient)) + .AddHttpMessageHandler(); + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor/BffBuilderExtensions.cs b/src/Duende.Bff.Blazor/BffBuilderExtensions.cs new file mode 100644 index 00000000..5e8de8ae --- /dev/null +++ b/src/Duende.Bff.Blazor/BffBuilderExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; + +namespace Duende.Bff.Blazor; + +public static class BffBuilderExtensions +{ + public static BffBuilder AddBlazorServer(this BffBuilder builder) + { + builder.Services.AddOpenIdConnectAccessTokenManagement() + .AddBlazorServerAccessTokenManagement(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + return builder; + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor/CaptureManagementClaimsCookieEvents.cs b/src/Duende.Bff.Blazor/CaptureManagementClaimsCookieEvents.cs new file mode 100644 index 00000000..1790f9c8 --- /dev/null +++ b/src/Duende.Bff.Blazor/CaptureManagementClaimsCookieEvents.cs @@ -0,0 +1,41 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Duende.Bff.Blazor; + +/// +/// This subclass invokes the BFF to retrieve management claims and add them to the +/// session. This is useful in interactive render modes where components are +/// initialled rendered server side. +/// +public class CaptureManagementClaimsCookieEvents : CookieAuthenticationEvents +{ + private readonly IClaimsService _claimsService; + + public CaptureManagementClaimsCookieEvents(IClaimsService claimsService) + { + _claimsService = claimsService; + } + + public override async Task ValidatePrincipal(CookieValidatePrincipalContext context) + { + var managementClaims = await _claimsService.GetManagementClaimsAsync( + context.Request.PathBase, + context.Principal, context.Properties); + + if (context.Principal?.Identity is ClaimsIdentity id) + { + foreach (var claim in managementClaims) + { + if (context.Principal.Claims.Any(c => c.Type == claim.type) != true) + { + id.AddClaim(new Claim(claim.type, claim.value?.ToString() ?? string.Empty)); + } + } + } + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor/Duende.Bff.Blazor.csproj b/src/Duende.Bff.Blazor/Duende.Bff.Blazor.csproj new file mode 100644 index 00000000..dbe67c50 --- /dev/null +++ b/src/Duende.Bff.Blazor/Duende.Bff.Blazor.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs b/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs new file mode 100644 index 00000000..f321c252 --- /dev/null +++ b/src/Duende.Bff.Blazor/PersistingAuthenticationStateProvider.cs @@ -0,0 +1,92 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Diagnostics; +using System.Security.Claims; +using Duende.Bff.Blazor.Client; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.Logging; + +// This is based on the PersistingServerAuthenticationStateProvider from ASP.NET +// 8's templates. + +// Future TODO - In .NET 9, the types added by the template are getting moved +// into ASP.NET itself, so we could potentially extend those instead of copying +// the template. + +namespace Duende.Bff.Blazor; + +// This is a server-side AuthenticationStateProvider that uses +// PersistentComponentState to flow the authentication state to the client which +// is then used to initialize the authentication state in the WASM application. +public sealed class BffServerAuthenticationStateProvider : ServerAuthenticationStateProvider, IDisposable +{ + private readonly IClaimsService _claimsService; + private readonly PersistentComponentState _state; + private readonly NavigationManager _navigation; + private readonly ILogger _logger; + + private readonly PersistingComponentStateSubscription _subscription; + + private Task? _authenticationStateTask; + + public BffServerAuthenticationStateProvider( + IClaimsService claimsService, + PersistentComponentState persistentComponentState, + NavigationManager navigation, + ILogger logger) + { + _claimsService = claimsService; + _state = persistentComponentState; + _navigation = navigation; + _logger = logger; + + AuthenticationStateChanged += OnAuthenticationStateChanged; + _subscription = _state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly); + } + + private void OnAuthenticationStateChanged(Task task) + { + _authenticationStateTask = task; + } + + private async Task OnPersistingAsync() + { + if (_authenticationStateTask is null) + { + throw new UnreachableException($"Authentication state not set in {nameof(OnPersistingAsync)}()."); + } + + var authenticationState = await _authenticationStateTask; + + var claims = authenticationState.User.Claims + .Select(c => new ClaimLite + { + Type = c.Type, + Value = c.Value?.ToString() ?? string.Empty, + ValueType = c.ValueType == ClaimValueTypes.String ? null : c.ValueType + }).ToArray(); + + var principal = new ClaimsPrincipalLite + { + AuthenticationType = authenticationState.User.Identity!.AuthenticationType, + NameClaimType = authenticationState.User.Identities.First().NameClaimType, + RoleClaimType = authenticationState.User.Identities.First().RoleClaimType, + Claims = claims + }; + + _logger.LogDebug("Persisting Authentication State"); + + _state.PersistAsJson(nameof(ClaimsPrincipalLite), principal); + } + + + public void Dispose() + { + _subscription.Dispose(); + AuthenticationStateChanged -= OnAuthenticationStateChanged; + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Blazor/ServerSideTokenStore.cs b/src/Duende.Bff.Blazor/ServerSideTokenStore.cs new file mode 100644 index 00000000..099c9d86 --- /dev/null +++ b/src/Duende.Bff.Blazor/ServerSideTokenStore.cs @@ -0,0 +1,79 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Claims; +using Duende.AccessTokenManagement.OpenIdConnect; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; + +namespace Duende.Bff.Blazor; + +/// +/// A token store that retrieves tokens from server side sessions. +/// +public class ServerSideTokenStore( + IStoreTokensInAuthenticationProperties tokensInAuthProperties, + IUserSessionStore sessionStore, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) : IUserTokenStore +{ + private readonly IDataProtector protector = + dataProtectionProvider.CreateProtector(ServerSideTicketStore.DataProtectorPurpose); + + public async Task GetTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters? parameters = null) + { + logger.LogDebug("Retrieving token for user {user}", user.Identity?.Name); + var session = await GetSession(user); + var ticket = session.Deserialize(protector, logger) ?? + throw new InvalidOperationException("Failed to deserialize authentication ticket from session"); + + return tokensInAuthProperties.GetUserToken(ticket.Properties, parameters); + } + + private async Task GetSession(ClaimsPrincipal user) + { + var sub = user.FindFirst("sub")?.Value ?? throw new InvalidOperationException("no sub claim"); + var sid = user.FindFirst("sid")?.Value ?? throw new InvalidOperationException("no sid claim"); + + logger.LogDebug("Retrieving session {sid} for sub {sub}", sid, sub); + + var sessions = await sessionStore.GetUserSessionsAsync(new UserSessionsFilter + { + SubjectId = sub, + SessionId = sid + }); + + if (sessions.Count == 0) throw new InvalidOperationException("No ticket found"); + if (sessions.Count > 1) throw new InvalidOperationException("Multiple tickets found"); + + return sessions.First(); + } + + public async Task StoreTokenAsync(ClaimsPrincipal user, UserToken token, + UserTokenRequestParameters? parameters = null) + { + logger.LogDebug("Storing token for user {user}", user.Identity?.Name); + await UpdateTicket(user, + ticket => { tokensInAuthProperties.SetUserToken(token, ticket.Properties, parameters); }); + } + + public async Task ClearTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters? parameters = null) + { + logger.LogDebug("Removing token for user {user}", user.Identity?.Name); + await UpdateTicket(user, ticket => { tokensInAuthProperties.RemoveUserToken(ticket.Properties, parameters); }); + } + + protected async Task UpdateTicket(ClaimsPrincipal user, Action updateAction) + { + var session = await GetSession(user); + var ticket = session.Deserialize(protector, logger) ?? + throw new InvalidOperationException("Failed to deserialize authentication ticket from session"); + + updateAction(ticket); + + session.Ticket = ticket.Serialize(protector); + + await sessionStore.UpdateUserSessionAsync(session.Key, session); + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Shared/ClaimLite.cs b/src/Duende.Bff.Shared/ClaimLite.cs new file mode 100644 index 00000000..ee7fd863 --- /dev/null +++ b/src/Duende.Bff.Shared/ClaimLite.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.Bff; + +/// +/// Serialization friendly claim +/// +public class ClaimLite +{ + /// + /// The type + /// + public string Type { get; init; } = default!; + + /// + /// The value + /// + public string Value { get; init; } = default!; + + /// + /// The value type + /// + public string? ValueType { get; init; } +} \ No newline at end of file diff --git a/src/Duende.Bff.Shared/ClaimsLiteExtensions.cs b/src/Duende.Bff.Shared/ClaimsLiteExtensions.cs new file mode 100644 index 00000000..d113d978 --- /dev/null +++ b/src/Duende.Bff.Shared/ClaimsLiteExtensions.cs @@ -0,0 +1,44 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Claims; + +namespace Duende.Bff; + +public static class ClaimsLiteExtensions +{ + /// + /// Converts a ClaimsPrincipalLite to ClaimsPrincipal + /// + public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsPrincipalLite principal) + { + var claims = principal.Claims.Select(x => new Claim(x.Type, x.Value, x.ValueType ?? ClaimValueTypes.String)) + .ToArray(); + var id = new ClaimsIdentity(claims, principal.AuthenticationType, principal.NameClaimType, + principal.RoleClaimType); + + return new ClaimsPrincipal(id); + } + + /// + /// Converts a ClaimsPrincipal to ClaimsPrincipalLite + /// + public static ClaimsPrincipalLite ToClaimsPrincipalLite(this ClaimsPrincipal principal) + { + var claims = principal.Claims.Select( + x => new ClaimLite + { + Type = x.Type, + Value = x.Value, + ValueType = x.ValueType == ClaimValueTypes.String ? null : x.ValueType + }).ToArray(); + + return new ClaimsPrincipalLite + { + AuthenticationType = principal.Identity!.AuthenticationType, + NameClaimType = principal.Identities.First().NameClaimType, + RoleClaimType = principal.Identities.First().RoleClaimType, + Claims = claims + }; + } +} \ No newline at end of file diff --git a/src/Duende.Bff.Shared/ClaimsPrincipalLite.cs b/src/Duende.Bff.Shared/ClaimsPrincipalLite.cs new file mode 100644 index 00000000..ee880aa3 --- /dev/null +++ b/src/Duende.Bff.Shared/ClaimsPrincipalLite.cs @@ -0,0 +1,30 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.Bff; + +/// +/// Serialization friendly ClaimsPrincipal +/// +public class ClaimsPrincipalLite +{ + /// + /// The authentication type + /// + public string? AuthenticationType { get; init; } + + /// + /// The name claim type + /// + public string? NameClaimType { get; init; } + + /// + /// The role claim type + /// + public string? RoleClaimType { get; init; } + + /// + /// The claims + /// + public ClaimLite[] Claims { get; init; } = default!; +} \ No newline at end of file diff --git a/src/Duende.Bff.Shared/Duende.Bff.Shared.csproj b/src/Duende.Bff.Shared/Duende.Bff.Shared.csproj new file mode 100644 index 00000000..d76ee03e --- /dev/null +++ b/src/Duende.Bff.Shared/Duende.Bff.Shared.csproj @@ -0,0 +1,10 @@ + + + + net8.0 + enable + enable + Duende.Bff + + + diff --git a/src/Duende.Bff.Shared/README.md b/src/Duende.Bff.Shared/README.md new file mode 100644 index 00000000..fe785791 --- /dev/null +++ b/src/Duende.Bff.Shared/README.md @@ -0,0 +1,9 @@ +This project contains code that needs to be shared across Duende.Bff and +Duende.Bff.Blazor.Client. We can't depend on Duende.Bff in +Duende.Bff.Blazor.Client because the Duende.Bff has a framework reference to +aspnetcore and Duende.Bff.Blazor.Client is intended to be consumed in blazor +wasm applications. + +We can't depend on the Duende.Bff.Blazor.Client from Duende.Bff, because that +would bring all the blazor client work into the main package - we want that to +be opt in. \ No newline at end of file diff --git a/src/Duende.Bff/Configuration/BffServiceCollectionExtensions.cs b/src/Duende.Bff/Configuration/BffServiceCollectionExtensions.cs index 27656177..ff49ed43 100644 --- a/src/Duende.Bff/Configuration/BffServiceCollectionExtensions.cs +++ b/src/Duende.Bff/Configuration/BffServiceCollectionExtensions.cs @@ -47,6 +47,9 @@ public static BffBuilder AddBff(this IServiceCollection services, Action(); services.AddTransient(); + // Claims for user endpoint + services.AddTransient(); + // session management services.TryAddTransient(); diff --git a/src/Duende.Bff/Duende.Bff.csproj b/src/Duende.Bff/Duende.Bff.csproj index c909fa89..24816306 100644 --- a/src/Duende.Bff/Duende.Bff.csproj +++ b/src/Duende.Bff/Duende.Bff.csproj @@ -14,5 +14,7 @@ + + \ No newline at end of file diff --git a/src/Duende.Bff/EndpointServices/Logout/DefaultLogoutService.cs b/src/Duende.Bff/EndpointServices/Logout/DefaultLogoutService.cs index 6e0d3bfd..49d9ace7 100644 --- a/src/Duende.Bff/EndpointServices/Logout/DefaultLogoutService.cs +++ b/src/Duende.Bff/EndpointServices/Logout/DefaultLogoutService.cs @@ -1,6 +1,7 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using Duende.AccessTokenManagement.OpenIdConnect; using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -31,7 +32,12 @@ public class DefaultLogoutService : ILogoutService /// The return URL validator /// protected readonly IReturnUrlValidator ReturnUrlValidator; - + + /// + /// Service to interact with the token endpoint. + /// + protected readonly IUserTokenEndpointService TokenEndpoint; + /// /// The logger /// @@ -40,18 +46,16 @@ public class DefaultLogoutService : ILogoutService /// /// Ctor /// - /// - /// - /// - /// public DefaultLogoutService(IOptions options, IAuthenticationSchemeProvider authenticationAuthenticationSchemeProviderProvider, IReturnUrlValidator returnUrlValidator, + IUserTokenEndpointService tokenEndpoint, ILogger logger) { Options = options.Value; AuthenticationSchemeProvider = authenticationAuthenticationSchemeProviderProvider; ReturnUrlValidator = returnUrlValidator; + TokenEndpoint = tokenEndpoint; Logger = logger; } @@ -88,6 +92,21 @@ public virtual async Task ProcessRequestAsync(HttpContext context) } } + if (Options.RevokeRefreshTokenOnLogout && result.Ticket != null) + { + var refreshToken = result.Ticket.Properties.GetTokenValue("refresh_token"); + if (!String.IsNullOrWhiteSpace(refreshToken)) + { + await TokenEndpoint.RevokeRefreshTokenAsync(new UserToken { RefreshToken = refreshToken }, new UserTokenRequestParameters()); + + Logger.LogDebug("Refresh token revoked for sub {sub} and sid {sid}", result.Ticket.GetSubjectId(), result.Ticket.GetSessionId()); + } + else + { + Logger.LogTrace("Refresh token not found for sub {sub} and sid {sid}", result.Ticket.GetSubjectId(), result.Ticket.GetSessionId()); + } + } + // get rid of local cookie first var signInScheme = await AuthenticationSchemeProvider.GetDefaultSignInSchemeAsync(); await context.SignOutAsync(signInScheme?.Name); diff --git a/src/Duende.Bff/EndpointServices/User/DefaultClaimsService.cs b/src/Duende.Bff/EndpointServices/User/DefaultClaimsService.cs new file mode 100644 index 00000000..6b8b302f --- /dev/null +++ b/src/Duende.Bff/EndpointServices/User/DefaultClaimsService.cs @@ -0,0 +1,68 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using IdentityModel; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace Duende.Bff; + +/// +public class DefaultClaimsService : IClaimsService +{ + private readonly BffOptions Options; + + /// + /// Ctor. + /// + /// + public DefaultClaimsService(IOptions options) + { + Options = options.Value; + } + + /// + public Task> GetManagementClaimsAsync(PathString pathBase, ClaimsPrincipal? principal, AuthenticationProperties? properties) + { + var claims = new List(); + + var sessionId = principal?.FindFirst(JwtClaimTypes.SessionId)?.Value; + if (!String.IsNullOrWhiteSpace(sessionId)) + { + claims.Add(new ClaimRecord( + Constants.ClaimTypes.LogoutUrl, + pathBase + Options.LogoutPath.Value + $"?sid={UrlEncoder.Default.Encode(sessionId)}")); + } + + if (properties != null) + { + if (properties.ExpiresUtc.HasValue) + { + var expiresInSeconds = + properties.ExpiresUtc.Value.Subtract(DateTimeOffset.UtcNow).TotalSeconds; + claims.Add(new ClaimRecord( + Constants.ClaimTypes.SessionExpiresIn, + Math.Round(expiresInSeconds))); + } + + if (properties.Items.TryGetValue(OpenIdConnectSessionProperties.SessionState, out var sessionState) && sessionState is not null) + { + claims.Add(new ClaimRecord(Constants.ClaimTypes.SessionState, sessionState)); + } + } + + return Task.FromResult>(claims); + } + + /// + public Task> GetUserClaimsAsync(ClaimsPrincipal? principal, AuthenticationProperties? properties) => + Task.FromResult(principal?.Claims.Select(x => new ClaimRecord(x.Type, x.Value)) ?? Enumerable.Empty()); +} diff --git a/src/Duende.Bff/EndpointServices/User/DefaultUserService.cs b/src/Duende.Bff/EndpointServices/User/DefaultUserService.cs index 105dbc32..8c6ceadf 100644 --- a/src/Duende.Bff/EndpointServices/User/DefaultUserService.cs +++ b/src/Duende.Bff/EndpointServices/User/DefaultUserService.cs @@ -1,16 +1,12 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. -using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using System; using System.Collections.Generic; using System.Linq; using System.Text; -using System.Text.Encodings.Web; using System.Text.Json; using System.Threading.Tasks; using Duende.Bff.Logging; @@ -23,6 +19,11 @@ namespace Duende.Bff; /// public class DefaultUserService : IUserService { + /// + /// The claims service + /// + protected readonly IClaimsService Claims; + /// /// The options /// @@ -36,10 +37,12 @@ public class DefaultUserService : IUserService /// /// Ctor /// + /// /// /// - public DefaultUserService(IOptions options, ILoggerFactory loggerFactory) + public DefaultUserService(IClaimsService claims, IOptions options, ILoggerFactory loggerFactory) { + Claims = claims; Options = options.Value; Logger = loggerFactory.CreateLogger(LogCategories.ManagementEndpoints); } @@ -70,9 +73,18 @@ public virtual async Task ProcessRequestAsync(HttpContext context) } else { - var claims = new List(); - claims.AddRange(GetUserClaims(result)); - claims.AddRange(GetManagementClaims(context, result)); + // In blazor, it is sometimes necessary to copy management claims + // into the session. So, we don't want duplicate mgmt claims. + // Instead, they should overwrite the existing mgmt claims (in case + // they changed when the session slid, etc) + var claims = (await GetUserClaimsAsync(result)).ToList(); + var mgmtClaims = await GetManagementClaimsAsync(context, result); + + foreach (var claim in mgmtClaims) + { + claims.RemoveAll(c => c.type == claim.type); + claims.Add(claim); + } var json = JsonSerializer.Serialize(claims); @@ -89,10 +101,8 @@ public virtual async Task ProcessRequestAsync(HttpContext context) /// /// /// - protected virtual IEnumerable GetUserClaims(AuthenticateResult authenticateResult) - { - return authenticateResult.Principal?.Claims.Select(x => new ClaimRecord(x.Type, x.Value)) ?? Enumerable.Empty(); - } + protected virtual Task> GetUserClaimsAsync(AuthenticateResult authenticateResult) => + Claims.GetUserClaimsAsync(authenticateResult.Principal, authenticateResult.Properties); /// /// Collect management claims @@ -100,44 +110,15 @@ protected virtual IEnumerable GetUserClaims(AuthenticateResult auth /// /// /// - protected virtual IEnumerable GetManagementClaims(HttpContext context, AuthenticateResult authenticateResult) + protected virtual Task> GetManagementClaimsAsync(HttpContext context, AuthenticateResult authenticateResult) { - var claims = new List(); - - var pathBase = context.Request.PathBase; - - var sessionId = authenticateResult.Principal?.FindFirst(JwtClaimTypes.SessionId)?.Value; - if (!String.IsNullOrWhiteSpace(sessionId)) - { - claims.Add(new ClaimRecord( - Constants.ClaimTypes.LogoutUrl, - pathBase + Options.LogoutPath.Value + $"?sid={UrlEncoder.Default.Encode(sessionId)}")); - } - - if (authenticateResult.Properties != null) - { - if (authenticateResult.Properties.ExpiresUtc.HasValue) - { - var expiresInSeconds = - authenticateResult.Properties.ExpiresUtc.Value.Subtract(DateTimeOffset.UtcNow).TotalSeconds; - claims.Add(new ClaimRecord( - Constants.ClaimTypes.SessionExpiresIn, - Math.Round(expiresInSeconds))); - } - - if (authenticateResult.Properties.Items.TryGetValue(OpenIdConnectSessionProperties.SessionState, out var sessionState) && sessionState is not null) - { - claims.Add(new ClaimRecord(Constants.ClaimTypes.SessionState, sessionState)); - } - } - - return claims; + return Claims.GetManagementClaimsAsync(context.Request.PathBase, authenticateResult.Principal, authenticateResult.Properties); } - - /// - /// Serialization-friendly claim - /// - /// - /// - protected record ClaimRecord(string type, object value); -} \ No newline at end of file +} + +/// +/// Serialization-friendly claim +/// +/// +/// +public record ClaimRecord(string type, object value); \ No newline at end of file diff --git a/src/Duende.Bff/EndpointServices/User/IClaimsService.cs b/src/Duende.Bff/EndpointServices/User/IClaimsService.cs new file mode 100644 index 00000000..bb08a8ef --- /dev/null +++ b/src/Duende.Bff/EndpointServices/User/IClaimsService.cs @@ -0,0 +1,33 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Security.Claims; + +namespace Duende.Bff; + +/// +/// Interface for a service that retrieves user and management claims. +/// +public interface IClaimsService +{ + /// + /// Gets claims associated with the user's session. + /// + /// + /// + /// + Task> GetUserClaimsAsync(ClaimsPrincipal? principal, AuthenticationProperties? properties); + + /// + /// Gets claims that facilitate session and token management. + /// + /// + /// + /// + /// + Task> GetManagementClaimsAsync(PathString pathBase, ClaimsPrincipal? principal, AuthenticationProperties? properties); +} diff --git a/src/Duende.Bff/Extensions/AuthenticationTicketExtensions.cs b/src/Duende.Bff/Extensions/AuthenticationTicketExtensions.cs index 658ec962..dee932ac 100644 --- a/src/Duende.Bff/Extensions/AuthenticationTicketExtensions.cs +++ b/src/Duende.Bff/Extensions/AuthenticationTicketExtensions.cs @@ -43,7 +43,7 @@ public static string GetSubjectId(this AuthenticationTicket ticket) { return ticket.Principal.FindFirst(JwtClaimTypes.SessionId)?.Value; } - + /// /// Extracts the issuance time /// @@ -51,7 +51,7 @@ public static DateTime GetIssued(this AuthenticationTicket ticket) { return ticket.Properties.IssuedUtc?.UtcDateTime ?? DateTime.UtcNow; } - + /// /// Extracts the expiration time /// @@ -59,39 +59,6 @@ public static DateTime GetIssued(this AuthenticationTicket ticket) { return ticket.Properties.ExpiresUtc?.UtcDateTime; } - - /// - /// Converts a ClaimsPrincipalLite to ClaimsPrincipal - /// - private static ClaimsPrincipal ToClaimsPrincipal(this ClaimsPrincipalLite principal) - { - var claims = principal.Claims.Select(x => new Claim(x.Type, x.Value, x.ValueType ?? ClaimValueTypes.String)).ToArray(); - var id = new ClaimsIdentity(claims, principal.AuthenticationType, principal.NameClaimType, principal.RoleClaimType); - - return new ClaimsPrincipal(id); - } - - /// - /// Converts a ClaimsPrincipal to ClaimsPrincipalLite - /// - private static ClaimsPrincipalLite ToClaimsPrincipalLite(this ClaimsPrincipal principal) - { - var claims = principal.Claims.Select( - x => new ClaimLite - { - Type = x.Type, - Value = x.Value, - ValueType = x.ValueType == ClaimValueTypes.String ? null : x.ValueType - }).ToArray(); - - return new ClaimsPrincipalLite - { - AuthenticationType = principal.Identity!.AuthenticationType, - NameClaimType = principal.Identities.First().NameClaimType, - RoleClaimType = principal.Identities.First().RoleClaimType, - Claims = claims - }; - } /// /// Serializes and AuthenticationTicket to a string @@ -190,53 +157,6 @@ public class AuthenticationTicketLite /// public IDictionary Items { get; set; } = default!; } - - /// - /// Serialization friendly claim - /// - public class ClaimLite - { - /// - /// The type - /// - public string Type { get; init; } = default!; - - /// - /// The value - /// - public string Value { get; init; } = default!; - - /// - /// The value type - /// - public string? ValueType { get; init; } - } - - /// - /// Serialization friendly ClaimsPrincipal - /// - public class ClaimsPrincipalLite - { - /// - /// The authentication type - /// - public string? AuthenticationType { get; init; } - - /// - /// The name claim type - /// - public string? NameClaimType { get; init; } - - /// - /// The role claim type - /// - public string? RoleClaimType { get; init; } - - /// - /// The claims - /// - public ClaimLite[] Claims { get; init; } = default!; - } /// /// Envelope for serialized data diff --git a/src/Duende.Bff/SessionManagement/TicketStore/ServerSideTicketStore.cs b/src/Duende.Bff/SessionManagement/TicketStore/ServerSideTicketStore.cs index dfd3fadb..b03d236d 100644 --- a/src/Duende.Bff/SessionManagement/TicketStore/ServerSideTicketStore.cs +++ b/src/Duende.Bff/SessionManagement/TicketStore/ServerSideTicketStore.cs @@ -3,14 +3,12 @@ #nullable disable -using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.DataProtection.KeyManagement; using Microsoft.Extensions.Logging; namespace Duende.Bff; @@ -20,6 +18,12 @@ namespace Duende.Bff; /// public class ServerSideTicketStore : IServerTicketStore { + /// + /// The "purpose" string to use when protecting and unprotecting server side + /// tickets. + /// + public static string DataProtectorPurpose = "Duende.Bff.ServerSideTicketStore"; + private readonly IUserSessionStore _store; private readonly IDataProtector _protector; private readonly ILogger _logger; @@ -36,7 +40,7 @@ public ServerSideTicketStore( ILogger logger) { _store = store; - _protector = dataProtectionProvider.CreateProtector("Duende.Bff.ServerSideTicketStore"); + _protector = dataProtectionProvider.CreateProtector(DataProtectorPurpose); _logger = logger; } diff --git a/test/Duende.Bff.Tests/TestFramework/ApiResponse.cs b/test/Duende.Bff.Tests/TestFramework/ApiResponse.cs index d25434a1..a99c6ecf 100644 --- a/test/Duende.Bff.Tests/TestFramework/ApiResponse.cs +++ b/test/Duende.Bff.Tests/TestFramework/ApiResponse.cs @@ -5,7 +5,7 @@ namespace Duende.Bff.Tests.TestFramework { - public record ApiResponse(string Method, string Path, string Sub, string ClientId, IEnumerable Claims) + public record ApiResponse(string Method, string Path, string Sub, string ClientId, IEnumerable Claims) { public string Body { get; init; }