diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/ClientAppCoordinator.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/ClientAppCoordinator.cs index f638850a27..8425ae9932 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/ClientAppCoordinator.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/ClientAppCoordinator.cs @@ -86,13 +86,14 @@ private async void AuthenticationStateChanged(Task task) try { var user = (await task).User; - TelemetryContext.UserId = user.IsAuthenticated() ? user.GetUserId() : null; - TelemetryContext.UserSessionId = user.IsAuthenticated() ? user.GetSessionId() : null; + var isAuthenticated = user.IsAuthenticated(); + TelemetryContext.UserId = isAuthenticated ? user.GetUserId() : null; + TelemetryContext.UserSessionId = isAuthenticated ? user.GetSessionId() : null; var data = TelemetryContext.ToDictionary(); //#if (appInsights == true) - if (user.IsAuthenticated()) + if (isAuthenticated) { _ = appInsights.SetAuthenticatedUserContext(user.GetUserId().ToString()); } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/AuthenticationManager.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/AuthenticationManager.cs index 47d03c6bac..897886353e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/AuthenticationManager.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/AuthenticationManager.cs @@ -1,11 +1,15 @@ -using System.Text; -using Boilerplate.Shared.Dtos.Identity; +using Boilerplate.Shared.Dtos.Identity; using Boilerplate.Shared.Controllers.Identity; namespace Boilerplate.Client.Core.Services; public partial class AuthenticationManager : AuthenticationStateProvider { + /// + /// To prevent multiple simultaneous refresh token requests. + /// + private readonly SemaphoreSlim semaphore = new(1, maxCount: 1); + [AutoInject] private Cookie cookie = default!; [AutoInject] private IJSRuntime jsRuntime = default!; [AutoInject] private IStorageService storageService = default!; @@ -14,7 +18,6 @@ public partial class AuthenticationManager : AuthenticationStateProvider [AutoInject] private IPrerenderStateService prerenderStateService; [AutoInject] private IExceptionHandler exceptionHandler = default!; [AutoInject] private IIdentityController identityController = default!; - [AutoInject] private JsonSerializerOptions jsonSerializerOptions = default!; /// /// Sign in and return whether the user requires two-factor authentication. @@ -81,40 +84,45 @@ public override async Task GetAuthenticationStateAsync() if (string.IsNullOrEmpty(access_token) && inPrerenderSession is false) { - string? refresh_token = await storageService.GetItem("refresh_token"); - - if (string.IsNullOrEmpty(refresh_token) is false) + try { - // We refresh the access_token to ensure a seamless user experience, preventing unnecessary 'NotAuthorized' page redirects and improving overall UX. - // This method is triggered after 401 and 403 server responses in AuthDelegationHandler, - // as well as when accessing pages without the required permissions in NotAuthorizedPage, ensuring that any recent claims granted to the user are promptly reflected. - - try + await semaphore.WaitAsync(); + access_token = await tokenProvider.GetAccessToken(); + if (string.IsNullOrEmpty(access_token)) // Check again after acquiring the lock. { - var refreshTokenResponse = await identityController.Refresh(new() { RefreshToken = refresh_token }, CancellationToken.None); - await StoreTokens(refreshTokenResponse!); - access_token = refreshTokenResponse!.AccessToken; - } - catch (UnauthorizedException) // refresh_token is either invalid or expired. - { - await storageService.RemoveItem("refresh_token"); + string? refresh_token = await storageService.GetItem("refresh_token"); + + if (string.IsNullOrEmpty(refresh_token) is false) + { + // We refresh the access_token to ensure a seamless user experience, preventing unnecessary 'NotAuthorized' page redirects and improving overall UX. + // This method is triggered after 401 and 403 server responses in AuthDelegationHandler, + // as well as when accessing pages without the required permissions in NotAuthorizedPage, ensuring that any recent claims granted to the user are promptly reflected. + + try + { + var refreshTokenResponse = await identityController.Refresh(new() { RefreshToken = refresh_token }, CancellationToken.None); + await StoreTokens(refreshTokenResponse!); + access_token = refreshTokenResponse!.AccessToken; + } + catch (UnauthorizedException) // refresh_token is either invalid or expired. + { + await storageService.RemoveItem("refresh_token"); + } + } } } + finally + { + semaphore.Release(); + } } - if (string.IsNullOrEmpty(access_token)) - { - return NotSignedIn(); - } - - var identity = new ClaimsIdentity(claims: ParseTokenClaims(access_token), authenticationType: "Bearer", nameType: "name", roleType: "role"); - - return new AuthenticationState(new ClaimsPrincipal(identity)); + return new AuthenticationState(tokenProvider.ParseAccessToken(access_token, validateExpiry: false /* For better UX in order to minimize Routes.razor's Authorizing loading duration. */)); } catch (Exception exp) { exceptionHandler.Handle(exp); // Do not throw exceptions in GetAuthenticationStateAsync. This will fault CascadingAuthenticationState's state unless NotifyAuthenticationStateChanged is called again. - return NotSignedIn(); + return new AuthenticationState(tokenProvider.Anonymous()); } } @@ -141,67 +149,4 @@ await cookie.Set(new() }); } } - - private static AuthenticationState NotSignedIn() - { - return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); - } - - private IEnumerable ParseTokenClaims(string access_token) - { - var parsedClaims = ParseJwt(access_token); - - var claims = new List(); - foreach (var keyValue in parsedClaims) - { - if (keyValue.Value.ValueKind == JsonValueKind.Array) - { - foreach (var element in keyValue.Value.EnumerateArray()) - { - claims.Add(new Claim(keyValue.Key, element.ToString() ?? string.Empty)); - } - } - else - { - claims.Add(new Claim(keyValue.Key, keyValue.Value.ToString() ?? string.Empty)); - } - } - - return claims; - } - - private Dictionary ParseJwt(string access_token) - { - // Split the token to get the payload - string base64UrlPayload = access_token.Split('.')[1]; - - // Convert the payload from Base64Url format to Base64 - string base64Payload = ConvertBase64UrlToBase64(base64UrlPayload); - - // Decode the Base64 string to get a JSON string - string jsonPayload = Encoding.UTF8.GetString(Convert.FromBase64String(base64Payload)); - - // Deserialize the JSON string to a dictionary - var claims = JsonSerializer.Deserialize(jsonPayload, jsonSerializerOptions.GetTypeInfo>())!; - - return claims; - } - - private static string ConvertBase64UrlToBase64(string base64Url) - { - base64Url = base64Url.Replace('-', '+').Replace('_', '/'); - - // Adjust base64Url string length for padding - switch (base64Url.Length % 4) - { - case 2: - base64Url += "=="; - break; - case 3: - base64Url += "="; - break; - } - - return base64Url; - } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/Contracts/IAuthTokenProvider.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/Contracts/IAuthTokenProvider.cs index 41f5099a76..e6d48dab95 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/Contracts/IAuthTokenProvider.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/Contracts/IAuthTokenProvider.cs @@ -1,6 +1,92 @@ -namespace Boilerplate.Client.Core.Services.Contracts; +using System.Text; + +namespace Boilerplate.Client.Core.Services.Contracts; public interface IAuthTokenProvider { Task GetAccessToken(); + + public ClaimsPrincipal Anonymous() => new(new ClaimsIdentity()); + + public ClaimsPrincipal ParseAccessToken(string? access_token, bool validateExpiry) + { + if (string.IsNullOrEmpty(access_token) is true) + return Anonymous(); + + var claims = ReadClaims(access_token, validateExpiry); + + if (claims is null) + return Anonymous(); + + var identity = new ClaimsIdentity(claims: claims, authenticationType: "Bearer", nameType: "name", roleType: "role"); + + var claimPrinciple = new ClaimsPrincipal(identity); + + return claimPrinciple; + } + + private IEnumerable? ReadClaims(string access_token, bool validateExpiry) + { + var parsedClaims = DeserializeAccessToken(access_token); + + if (validateExpiry && long.TryParse(parsedClaims["exp"].ToString(), out var expSeconds)) + { + var expirationDate = DateTimeOffset.FromUnixTimeSeconds(expSeconds); + if (expirationDate <= DateTimeOffset.UtcNow) + return null; + } + + var claims = new List(); + foreach (var keyValue in parsedClaims) + { + if (keyValue.Value.ValueKind == JsonValueKind.Array) + { + foreach (var element in keyValue.Value.EnumerateArray()) + { + claims.Add(new Claim(keyValue.Key, element.ToString() ?? string.Empty)); + } + } + else + { + claims.Add(new Claim(keyValue.Key, keyValue.Value.ToString() ?? string.Empty)); + } + } + + return claims; + } + + private Dictionary DeserializeAccessToken(string access_token) + { + // Split the token to get the payload + string base64UrlPayload = access_token.Split('.')[1]; + + // Convert the payload from Base64Url format to Base64 + string base64Payload = ConvertBase64UrlToBase64(base64UrlPayload); + + // Decode the Base64 string to get a JSON string + string jsonPayload = Encoding.UTF8.GetString(Convert.FromBase64String(base64Payload)); + + // Deserialize the JSON string to a dictionary + var claims = JsonSerializer.Deserialize(jsonPayload, AppJsonContext.Default.Options.GetTypeInfo>())!; + + return claims; + } + + private string ConvertBase64UrlToBase64(string base64Url) + { + base64Url = base64Url.Replace('-', '+').Replace('_', '/'); + + // Adjust base64Url string length for padding + switch (base64Url.Length % 4) + { + case 2: + base64Url += "=="; + break; + case 3: + base64Url += "="; + break; + } + + return base64Url; + } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/HttpMessageHandlers/AuthDelegatingHandler.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/HttpMessageHandlers/AuthDelegatingHandler.cs index ba19185e0e..f3a41ba702 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/HttpMessageHandlers/AuthDelegatingHandler.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/HttpMessageHandlers/AuthDelegatingHandler.cs @@ -1,27 +1,33 @@ using System.Net.Http.Headers; +using Boilerplate.Shared.Controllers.Identity; namespace Boilerplate.Client.Core.Services.HttpMessageHandlers; public partial class AuthDelegatingHandler(IAuthTokenProvider tokenProvider, IJSRuntime jsRuntime, - IServiceProvider serviceProvider, - IStorageService storageService, + IServiceProvider serviceProvider, + IStorageService storageService, HttpMessageHandler handler) : DelegatingHandler(handler) { protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if (request.Headers.Authorization is null) + var isRefreshTokenRequest = request.RequestUri?.LocalPath?.Contains(IIdentityController.RefreshUri, StringComparison.InvariantCultureIgnoreCase) is true; + + try { - var access_token = await tokenProvider.GetAccessToken(); - if (access_token is not null) + if (request.Headers.Authorization is null && isRefreshTokenRequest is false) { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", access_token); + var access_token = await tokenProvider.GetAccessToken(); + if (access_token is not null) + { + if (tokenProvider.ParseAccessToken(access_token, validateExpiry: true).IsAuthenticated() is false) + throw new UnauthorizedException(); + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", access_token); + } } - } - try - { return await base.SendAsync(request, cancellationToken); } catch (KnownException _) when (_ is ForbiddenException or UnauthorizedException) @@ -29,10 +35,10 @@ protected override async Task SendAsync(HttpRequestMessage // Let's update the access token by refreshing it when a refresh token is available. // Following this procedure, the newly acquired access token may now include the necessary roles or claims. - if (AppPlatform.IsBlazorHybrid is false && jsRuntime.IsInitialized() is false) + if (AppPlatform.IsBlazorHybrid is false && jsRuntime.IsInitialized() is false) throw; // We don't have access to refresh_token during pre-rendering. - if (request.RequestUri?.LocalPath?.Contains("api/Identity/Refresh", StringComparison.InvariantCultureIgnoreCase) is true) + if (isRefreshTokenRequest) throw; // To prevent refresh token loop var refresh_token = await storageService.GetItem("refresh_token"); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs index 9790aa32f8..653e263da7 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs @@ -24,6 +24,7 @@ public interface IIdentityController : IAppController [HttpPost] Task ResetPassword(ResetPasswordRequestDto request, CancellationToken cancellationToken); + public const string RefreshUri = "api/Identity/Refresh"; [HttpPost] Task Refresh(RefreshRequestDto request, CancellationToken cancellationToken) => default!;