diff --git a/samples/BlazorServer/HostingExtensions.cs b/samples/BlazorServer/HostingExtensions.cs index 1fd4233..3bcb7f8 100644 --- a/samples/BlazorServer/HostingExtensions.cs +++ b/samples/BlazorServer/HostingExtensions.cs @@ -1,6 +1,5 @@ using BlazorServer.Plumbing; using BlazorServer.Services; -using Duende.AccessTokenManagement.OpenIdConnect; using Serilog; namespace BlazorServer; @@ -50,19 +49,16 @@ public static WebApplication ConfigureServices(this WebApplicationBuilder builde }); // adds access token management - builder.Services.AddOpenIdConnectAccessTokenManagement(); + builder.Services.AddOpenIdConnectAccessTokenManagement() + .AddBlazorServerAccessTokenManagement(); // register events to customize authentication handlers builder.Services.AddTransient(); builder.Services.AddTransient(); - // not allowed to programmatically use HttpContext in Blazor Server. - // that's why tokens cannot be managed in the login session - builder.Services.AddSingleton(); - // registers HTTP client that uses the managed user access token builder.Services.AddTransient(); - builder.Services.AddHttpClient(client => + builder.Services.AddUserAccessTokenHttpClient("demoApiClient", configureClient: client => { client.BaseAddress = new Uri("https://demo.duendesoftware.com/api/"); }); @@ -70,7 +66,7 @@ public static WebApplication ConfigureServices(this WebApplicationBuilder builde builder.Services.AddControllersWithViews(); builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor(); - + builder.Services.AddSingleton(); return builder.Build(); diff --git a/samples/BlazorServer/Plumbing/ServerSideTokenStore.cs b/samples/BlazorServer/Plumbing/ServerSideTokenStore.cs index f5e1f67..9272fe6 100644 --- a/samples/BlazorServer/Plumbing/ServerSideTokenStore.cs +++ b/samples/BlazorServer/Plumbing/ServerSideTokenStore.cs @@ -9,7 +9,7 @@ namespace BlazorServer.Plumbing; /// /// Simplified implementation of a server-side token store. -/// Probably want somehting more robust IRL +/// Probably want something more robust IRL /// public class ServerSideTokenStore : IUserTokenStore { diff --git a/samples/BlazorServer/Services/RemoteApiService.cs b/samples/BlazorServer/Services/RemoteApiService.cs index e524e34..1867035 100644 --- a/samples/BlazorServer/Services/RemoteApiService.cs +++ b/samples/BlazorServer/Services/RemoteApiService.cs @@ -2,45 +2,25 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. using System.Text.Json; -using Duende.AccessTokenManagement.OpenIdConnect; -using IdentityModel.Client; -using Microsoft.AspNetCore.Components.Authorization; namespace BlazorServer.Services; public class RemoteApiService { private readonly HttpClient _client; - private readonly AuthenticationStateProvider _authenticationStateProvider; - private readonly IUserTokenManagementService _tokenManagementService; public RemoteApiService( - HttpClient client, - AuthenticationStateProvider authenticationStateProvider, - IUserTokenManagementService tokenManagementService) + IHttpClientFactory factory) { - _client = client; - _authenticationStateProvider = authenticationStateProvider; - _tokenManagementService = tokenManagementService; + _client = factory.CreateClient("demoApiClient"); } private record Claim(string type, object value); public async Task GetData() { - var request = new HttpRequestMessage(HttpMethod.Get, "test"); - var response = await SendRequestAsync(request); - - var json = JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync()); + var response = await _client.GetStringAsync("test"); + var json = JsonSerializer.Deserialize>(response); return JsonSerializer.Serialize(json, new JsonSerializerOptions { WriteIndented = true }); } - - private async Task SendRequestAsync(HttpRequestMessage request) - { - var state = await _authenticationStateProvider.GetAuthenticationStateAsync(); - var token = await _tokenManagementService.GetAccessTokenAsync(state.User); - - request.SetToken(token.AccessTokenType!, token.AccessToken!); - return await _client.SendAsync(request); - } } \ No newline at end of file diff --git a/samples/BlazorServer/Shared/RedirectToLogin.razor b/samples/BlazorServer/Shared/RedirectToLogin.razor index f1df73c..c90043c 100644 --- a/samples/BlazorServer/Shared/RedirectToLogin.razor +++ b/samples/BlazorServer/Shared/RedirectToLogin.razor @@ -1,9 +1,12 @@ @inject NavigationManager Navigation @code { - protected override void OnInitialized() + // Using the async method prevents NavigationExceptions, even though this method is synchronous + #pragma warning disable CS1998 + protected override async Task OnInitializedAsync() { var returnUrl = Uri.EscapeDataString("/" + Navigation.ToBaseRelativePath(Navigation.Uri)); Navigation.NavigateTo($"account/login?returnUrl={returnUrl}", forceLoad: true); } + #pragma warning restore CS1998 } \ No newline at end of file diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/BlazorServerPrincipalAccessor.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/BlazorServerPrincipalAccessor.cs new file mode 100644 index 0000000..9e5314b --- /dev/null +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/BlazorServerPrincipalAccessor.cs @@ -0,0 +1,55 @@ +// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Duende.AccessTokenManagement.OpenIdConnect; + +/// +/// Accesses the current user from blazor server. +/// +/// +/// ctor +/// +public class BlazorServerUserAccessor( + // We use the CircuitServicesAccessor to resolve the + // AuthenticationStateProvider, rather than injecting it. Injecting the + // state provider directly doesn't work here, because this service might be + // called in a non-blazor DI scope. + CircuitServicesAccessor circuitServicesAccessor, + IHttpContextAccessor? httpContextAccessor, + ILogger logger) : IUserAccessor +{ + + /// + public async Task GetCurrentUserAsync() + { + var authStateProvider = circuitServicesAccessor.Services? + .GetService(); + // If we are in blazor server (streaming over a circuit), this provider will be non-null + if (authStateProvider != null) + { + var authState = await authStateProvider.GetAuthenticationStateAsync(); + return authState.User; + } + // Otherwise, we should be in an SSR scenario, and the httpContext should be available + else if(httpContextAccessor?.HttpContext != null) + { + return httpContextAccessor.HttpContext.User; + } + // If we are in neither blazor server or SSR, something weird is going on. + else + { + logger.LogWarning("Neither an authentication state provider or http context are available to obtain the current principal."); + return new ClaimsPrincipal(); + } + } + +} + + diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/CircuitServicesAccessor.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/CircuitServicesAccessor.cs new file mode 100644 index 0000000..e544488 --- /dev/null +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/CircuitServicesAccessor.cs @@ -0,0 +1,64 @@ +// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Server.Circuits; +using Microsoft.Extensions.DependencyInjection; + +namespace Duende.AccessTokenManagement.OpenIdConnect; + +// This code is from the blazor documentation: +// https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/dependency-injection?view=aspnetcore-8.0#access-server-side-blazor-services-from-a-different-di-scope + +/// +/// Provides access to scoped blazor services from non-blazor DI scopes, such as +/// scopes created using IHttpClientFactory. +/// +public class CircuitServicesAccessor +{ + static readonly AsyncLocal blazorServices = new(); + + internal IServiceProvider? Services + { + get => blazorServices.Value; + set => blazorServices.Value = value!; + } +} + +internal class ServicesAccessorCircuitHandler : CircuitHandler +{ + readonly IServiceProvider services; + readonly CircuitServicesAccessor circuitServicesAccessor; + + internal ServicesAccessorCircuitHandler(IServiceProvider services, + CircuitServicesAccessor servicesAccessor) + { + this.services = services; + this.circuitServicesAccessor = servicesAccessor; + } + + public override Func CreateInboundActivityHandler( + Func next) + { + return async context => + { + circuitServicesAccessor.Services = services; + await next(context); + circuitServicesAccessor.Services = null; + }; + } +} + +internal static class CircuitServicesServiceCollectionExtensions +{ + public static IServiceCollection AddCircuitServicesAccessor( + this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/HttpContextPrincipalAccessor.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/HttpContextPrincipalAccessor.cs new file mode 100644 index 0000000..f7189ac --- /dev/null +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/HttpContextPrincipalAccessor.cs @@ -0,0 +1,30 @@ +// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Duende.AccessTokenManagement.OpenIdConnect; + +/// +/// Accesses the current principal based on the HttpContext.User. +/// +public class HttpContextUserAccessor : IUserAccessor +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + /// + /// ctor + /// + public HttpContextUserAccessor(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + /// + public Task GetCurrentUserAsync() + { + return Task.FromResult(_httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal()); + } +} diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/IPrincipalAccessor.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/IPrincipalAccessor.cs new file mode 100644 index 0000000..6e171eb --- /dev/null +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/IPrincipalAccessor.cs @@ -0,0 +1,18 @@ +// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Duende.AccessTokenManagement.OpenIdConnect; + +/// +/// Service that retrieves the current principal. +/// +public interface IUserAccessor +{ + /// + /// Gets the current user. + /// + Task GetCurrentUserAsync(); +} diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectTokenManagementServiceCollectionExtensions.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectTokenManagementServiceCollectionExtensions.cs index a2adc9b..faf1013 100644 --- a/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectTokenManagementServiceCollectionExtensions.cs +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectTokenManagementServiceCollectionExtensions.cs @@ -5,7 +5,6 @@ using System.Net.Http; using Duende.AccessTokenManagement; using Duende.AccessTokenManagement.OpenIdConnect; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -35,13 +34,38 @@ public static IServiceCollection AddOpenIdConnectAccessTokenManagement(this ISer services.TryAddTransient(); services.TryAddTransient(); - // scoped since it will be caching per-request authentication results - services.TryAddScoped(); services.TryAddSingleton(); services.TryAddTransient(); services.ConfigureOptions(); + // By default, we assume that we are in a traditional web application + // where we can use the http context. The services below depend on http + // context, and we register different ones in blazor + + services.TryAddScoped(); + // scoped since it will be caching per-request authentication results + services.TryAddScoped(); + + return services; + } + + /// + /// Adds implementations of services that enable access token management in + /// Blazor Server. + /// + /// An IUserTokenStore implementation. Blazor + /// Server requires an IUserTokenStore because the default token store + /// relies on cookies, which are not present when streaming updates over a + /// blazor circuit. + public static IServiceCollection AddBlazorServerAccessTokenManagement(this IServiceCollection services) + where TTokenStore : class, IUserTokenStore + { + services.AddSingleton(); + services.AddScoped(); + services.AddCircuitServicesAccessor(); + services.AddHttpContextAccessor(); // For SSR + return services; } @@ -143,10 +167,12 @@ public static IHttpClientBuilder AddUserAccessTokenHandler( { var dpopService = provider.GetRequiredService(); var dpopNonceStore = provider.GetRequiredService(); - var contextAccessor = provider.GetRequiredService(); + var userTokenManagement = provider.GetRequiredService(); var logger = provider.GetRequiredService>(); + var principalAccessor = provider.GetRequiredService(); - return new OpenIdConnectUserAccessTokenHandler(dpopService, dpopNonceStore, contextAccessor, logger, parameters); + return new OpenIdConnectUserAccessTokenHandler( + dpopService, dpopNonceStore, principalAccessor, userTokenManagement, logger, parameters); }); } diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectUserAccessTokenHandler.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectUserAccessTokenHandler.cs index 56c19a0..491eeb2 100755 --- a/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectUserAccessTokenHandler.cs +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectUserAccessTokenHandler.cs @@ -1,8 +1,6 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using System.Threading; using System.Threading.Tasks; @@ -14,7 +12,8 @@ namespace Duende.AccessTokenManagement.OpenIdConnect; /// public class OpenIdConnectUserAccessTokenHandler : AccessTokenHandler { - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IUserAccessor _userAccessor; + private readonly IUserTokenManagementService _userTokenManagement; private readonly UserTokenRequestParameters _parameters; /// @@ -22,18 +21,21 @@ public class OpenIdConnectUserAccessTokenHandler : AccessTokenHandler /// /// /// - /// + /// + /// /// /// public OpenIdConnectUserAccessTokenHandler( IDPoPProofService dPoPProofService, IDPoPNonceStore dPoPNonceStore, - IHttpContextAccessor httpContextAccessor, + IUserAccessor userAccessor, + IUserTokenManagementService userTokenManagement, ILogger logger, UserTokenRequestParameters? parameters = null) : base(dPoPProofService, dPoPNonceStore, logger) { - _httpContextAccessor = httpContextAccessor; + _userAccessor = userAccessor; + _userTokenManagement = userTokenManagement; _parameters = parameters ?? new UserTokenRequestParameters(); } @@ -49,6 +51,8 @@ protected override async Task GetAccessTokenAsync(bool f ForceRenewal = forceRenewal, }; - return await _httpContextAccessor.HttpContext!.GetUserAccessTokenAsync(parameters).ConfigureAwait(false); + var user = await _userAccessor.GetCurrentUserAsync(); + + return await _userTokenManagement.GetAccessTokenAsync(user, parameters, cancellationToken).ConfigureAwait(false); } -} \ No newline at end of file +}