Skip to content
This repository has been archived by the owner on Jan 24, 2025. It is now read-only.

Commit

Permalink
Refactor and test the blazor wasm auth state provider
Browse files Browse the repository at this point in the history
  • Loading branch information
josephdecock committed Aug 27, 2024
1 parent 2bce28d commit 562c6c3
Show file tree
Hide file tree
Showing 11 changed files with 434 additions and 97 deletions.
Original file line number Diff line number Diff line change
@@ -1,57 +1,42 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.

using System.Net.Http.Json;
using System.Security.Claims;
using Duende.Bff.Blazor.Client.Internals;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Options;

namespace Duende.Bff.Blazor.Client;

public class BffClientAuthenticationStateProvider : AuthenticationStateProvider
{
private static readonly TimeSpan UserCacheRefreshInterval = TimeSpan.FromSeconds(60);

private readonly HttpClient _client;
public const string HttpClientName = "Duende.Bff.Blazor.Client:StateProvider";
private readonly IGetUserService _getUserService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<BffClientAuthenticationStateProvider> _logger;
private readonly BffBlazorOptions _options;

private DateTimeOffset _userLastCheck = DateTimeOffset.MinValue;
private ClaimsPrincipal _cachedUser = new(new ClaimsIdentity());
private readonly IPersistentUserService _persistentUserService;
private readonly ILogger<BffClientAuthenticationStateProvider> _logger;

/// <summary>
/// An <see cref="AuthenticationStateProvider"/> intended for use in Blazor
/// WASM. It polls the /bff/user endpoint to monitor session state.
/// </summary>
public BffClientAuthenticationStateProvider(
PersistentComponentState state,
IHttpClientFactory factory,
IGetUserService getUserService,
TimeProvider timeProvider,
IPersistentUserService persistentUserService,
IOptions<BffBlazorOptions> options,
ILogger<BffClientAuthenticationStateProvider> logger)
{

_client = factory.CreateClient(nameof(BffClientAuthenticationStateProvider));
_cachedUser = _persistentUserService.GetPersistedUser(state);
if (_cachedUser.Identity?.IsAuthenticated == true)
{
_userLastCheck = timeProvider.GetUtcNow();
}

_getUserService = getUserService;
_timeProvider = timeProvider;
_persistentUserService = persistentUserService;
_options = options.Value;
_logger = logger;
}

public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var user = await GetUser();
_getUserService.InitializeCache();
var user = await _getUserService.GetUserAsync();
var state = new AuthenticationState(user);

if (user.Identity is { IsAuthenticated: true })
Expand All @@ -61,13 +46,13 @@ public override async Task<AuthenticationState> GetAuthenticationStateAsync()

timer = _timeProvider.CreateTimer(async _ =>
{
var currentUser = await GetUser(false);
var currentUser = await _getUserService.GetUserAsync(false);
// Always notify that auth state has changed, because the user
// management claims (usually) change over time.
//
// Future TODO - Someday we may want an extensibility point. If the
// user management claims have been customized, then auth state
// wouldn't always change. In that case, we'd want to only fire
// might not always change. In that case, we'd want to only fire
// if the user actually had changed.
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(currentUser)));

Expand All @@ -85,59 +70,6 @@ public override async Task<AuthenticationState> GetAuthenticationStateAsync()
TimeSpan.FromMilliseconds(_options.StateProviderPollingDelay),
TimeSpan.FromMilliseconds(_options.StateProviderPollingInterval));
}

return state;
}

private async ValueTask<ClaimsPrincipal> GetUser(bool useCache = true)
{
var now = _timeProvider.GetUtcNow();
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<ClaimsPrincipal> FetchUser()
{
try
{
_logger.LogInformation("Fetching user information.");
var response = await _client.GetAsync("bff/user?slide=false");
response.EnsureSuccessStatusCode();
var claims = await response.Content.ReadFromJsonAsync<List<BffClientAuthenticationStateProvider.ClaimRecord>>();

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

5 changes: 5 additions & 0 deletions src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@
<ProjectReference Include="../Duende.Bff.Shared/Duende.Bff.Shared.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Duende.Bff.Blazor.Client.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>

</Project>
12 changes: 0 additions & 12 deletions src/Duende.Bff.Blazor.Client/IPersistentUserService.cs

This file was deleted.

93 changes: 93 additions & 0 deletions src/Duende.Bff.Blazor.Client/Internals/GetUserService.cs
Original file line number Diff line number Diff line change
@@ -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<GetUserService> _logger;

private DateTimeOffset _userLastCheck = DateTimeOffset.MinValue;
private ClaimsPrincipal _cachedUser = new(new ClaimsIdentity());

public GetUserService(
IHttpClientFactory clientFactory,
IPersistentUserService persistentUserService,
TimeProvider timeProvider,
IOptions<BffBlazorOptions> options,
ILogger<GetUserService> 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<ClaimsPrincipal> 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<ClaimsPrincipal> FetchUser()
{
try
{
_logger.LogInformation("Fetching user information.");
var claims = await _client.GetFromJsonAsync<List<ClaimRecord>>("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());
}
}
22 changes: 22 additions & 0 deletions src/Duende.Bff.Blazor.Client/Internals/IGetUserService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Internal service for retrieval of user info in the authentication state provider.
/// </summary>
public interface IGetUserService
{
/// <summary>
/// Gets the user.
/// </summary>
ValueTask<ClaimsPrincipal> GetUserAsync(bool useCache = true);

/// <summary>
/// Initializes the cache.
/// </summary>
void InitializeCache();
}
19 changes: 19 additions & 0 deletions src/Duende.Bff.Blazor.Client/Internals/IPersistentUserService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A service for interacting with the user persisted in PersistentComponentState in blazor.
/// </summary>
public interface IPersistentUserService
{
/// <summary>
/// Retrieves a ClaimsPrincipal from PersistentComponentState. If there is no persisted user, returns an anonymous
/// user.
/// </summary>
/// <returns></returns>
ClaimsPrincipal GetPersistedUser();
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;

namespace Duende.Bff.Blazor.Client;
namespace Duende.Bff.Blazor.Client.Internals;

public class PersistentUserService(ILogger<PersistentUserService> logger) : IPersistentUserService
/// <summary>
/// This class wraps our usage of the PersistentComponentState, mostly to facilitate testing.
/// </summary>
/// <param name="state"></param>
/// <param name="logger"></param>
internal class PersistentUserService(PersistentComponentState state, ILogger<PersistentUserService> logger) : IPersistentUserService
{
public ClaimsPrincipal GetPersistedUser(PersistentComponentState state)
/// <inheritdoc />
public ClaimsPrincipal GetPersistedUser()
{
if (!state.TryTakeFromJson<ClaimsPrincipalLite>(nameof(ClaimsPrincipalLite), out var lite) || lite is null)
{
Expand Down
4 changes: 3 additions & 1 deletion src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.

using Duende.Bff.Blazor.Client.Internals;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -25,10 +26,11 @@ public static IServiceCollection AddBffBlazorClient(this IServiceCollection serv
services
.AddAuthorizationCore()
.AddScoped<IPersistentUserService, PersistentUserService>()
.AddScoped<IGetUserService, GetUserService>()
.AddScoped<AuthenticationStateProvider, BffClientAuthenticationStateProvider>()
// TODO - Should this have a different lifetime?
.AddTransient<AntiforgeryHandler>()
.AddHttpClient(nameof(BffClientAuthenticationStateProvider), (sp, client) =>
.AddHttpClient(BffClientAuthenticationStateProvider.HttpClientName, (sp, client) =>
{
var baseAddress = GetStateProviderBaseAddress(sp);
client.BaseAddress = new Uri(baseAddress);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="Shouldly" Version="4.2.1" />
Expand Down
Loading

0 comments on commit 562c6c3

Please sign in to comment.