Search code examples
c#asp.netauthenticationblazormaui

.NET 7 Blazor MAUI - Require Authentication with Azure User Logins


I am developing a Blazor MAUI application, and I am trying to require users in my organization to login with their Microsoft work account in order to access the application. I have not been able to find much documentation about authentication within Blazor MAUI, and have been struggling to get a solution working.

Currently, I have been following Microsoft's docs about Blazor Hybrid authentication: ASP.NET Core Blazor Hybrid authentication and authorization. I have added the following class ExternalAuthStateProvider.cs:

using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Authorization;

public class ExternalAuthStateProvider : AuthenticationStateProvider
{
    private readonly Task<AuthenticationState> authenticationState;

    public ExternalAuthStateProvider(AuthenticatedUser user) => 
        authenticationState = Task.FromResult(new AuthenticationState(user.Principal));

    public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
        authenticationState;
}

public class AuthenticatedUser
{
    public ClaimsPrincipal Principal { get; set; } = new();
}

And have replaced the return builder.Build(); in MauiProgram.cs with:

builder.Services.AddAuthorizationCore();
builder.Services.TryAddScoped<AuthenticationStateProvider, ExternalAuthStateProvider>();
builder.Services.AddSingleton<AuthenticatedUser>();
var host = builder.Build();

var authenticatedUser = host.Services.GetRequiredService<AuthenticatedUser>();

/*
Provide OpenID/MSAL code to authenticate the user. See your identity provider's 
documentation for details.

The user is represented by a new ClaimsPrincipal based on a new ClaimsIdentity.
*/
var user = new ClaimsPrincipal(new ClaimsIdentity());

authenticatedUser.Principal = user;

return host;

From here, I am not sure what code to add in MauiProgram.cs to connect to my Azure app registration. I have followed multiple guides for configuring my Azure app registration, but have not seen any C# code similar to the code above, so I am confused on how to implement the connection here.

~ Aside from this method, the only other documentation I have found for Blazor MAUI is this video: MSAL Auth in MAUI Blazor. However, I'm wondering what other options there are for social logins without being forced to use Azure AD B2C? I am working on a handful of other projects where I do not have access to Azure AD B2C, and do not want to be forced to use the service.


Solution

  • I also found Microsoft docs to be lacking on the subject when it comes to integrating MSAL with Blazor Hybrid. I spent a few days digging through various resources online to come up with something that worked.

    Here's a wrapper that includes the MSAL.NET code to authenticate the user. You'll have to create IAuthenticationService which I left out.

    public class AuthenticationService : IAuthenticationService
    {
        // I recommend storing this in appsettings.json and grabbing it from IConfiguration instead
        private readonly IPublicClientApplication authenticationClient;
        private readonly string[] _scopes = new[] { "User.Read" };
        private readonly string _tenantId = "[TENANT ID HERE]";
        private readonly string _clientId = "[APP ID HERE]";
    
        public AuthenticationService()
        {
            authenticationClient = PublicClientApplicationBuilder.Create(_clientId)
                .WithAuthority(AzureCloudInstance.AzurePublic, _tenantId) // Only allow accounts in the tenant to authenticate
                .WithRedirectUri($"msal{_clientId}://auth")
                .Build();
        }
    
        public async Task<AuthenticationResult?> AcquireTokenSilentAsync()
        {
            var accounts = await authenticationClient.GetAccountsAsync();
    
            AuthenticationResult? result;
            try
            {
                result = await authenticationClient.AcquireTokenSilent(_scopes, accounts.FirstOrDefault())
                    .ExecuteAsync();
            }
            catch (MsalUiRequiredException)
            {
                // Acquiring silently failed; need to acquire the token interactively
                result = await AcquireTokenInteractiveAsync();
            }
    
            return result;
        }
    
        public async Task<AuthenticationResult?> AcquireTokenInteractiveAsync()
        {
            if (authenticationClient == null)
                return null;
    
            AuthenticationResult result;
            try
            {
                result = await authenticationClient.AcquireTokenInteractive(_scopes)
                    .WithTenantId(_tenantId)
                    .ExecuteAsync()
                    .ConfigureAwait(false);
    
                return result;
            }
            catch (MsalClientException)
            {
                return null;
            }
        }
    }
    

    Now your state provider can inject AuthenticationService to get the token.

    There are a few weird quirks in here for ClaimsPrincipal that I could only get working with a hack so the user context would actually recognize the change in authentication state.... definitely some room for improvement.

    public class ExternalAuthStateProvider : AuthenticationStateProvider
    {
        private readonly IAuthenticationService _authenticationService;
        private ClaimsPrincipal _currentUser;
    
        public ExternalAuthStateProvider(IAuthenticationService authenticationService)
        {
            _currentUser = new ClaimsPrincipal(new ClaimsIdentity());
            _authenticationService = authenticationService;
        }
    
        public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
            Task.FromResult(new AuthenticationState(_currentUser));
    
        public Task LogInAsync()
        {
            var loginTask = LogInAsyncCore();
            // Use BlazorWebView to update the auth state
            NotifyAuthenticationStateChanged(loginTask);
    
            return loginTask;
    
            async Task<AuthenticationState> LogInAsyncCore()
            {
                _currentUser = await LoginWithExternalProviderAsync();
    
                return new AuthenticationState(_currentUser);
            }
        }
    
        private async Task<ClaimsPrincipal> LoginWithExternalProviderAsync()
        {
            var authResult = await _authenticationService.AcquireTokenInteractiveAsync();
    
            // Authentication failed, return the current logged out user state
            if (authResult == null) return _currentUser;
    
            List<Claim> claims;
            ClaimsPrincipal authenticatedUser;
    
            // For some reason AAD sets "name" as the claim type for the user name and not ClaimsType.Name...
            // This is a workaround since context.User.Identity only recognizes ClaimsType.Name
            var name = authResult.ClaimsPrincipal.FindFirst(c => c.Type == "name")?.Value;
            if (name != null)
            {
                claims = claims = authResult.ClaimsPrincipal.Claims.ToList();
                claims.Add(new Claim(ClaimTypes.Name, name));
                authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(claims));
            }
            else
            {
                // Another interesting quirk, we MUST recreate the ClaimsPrincipal instead of using authResult.ClaimsPrincipal directly
                // according to https://learn.microsoft.com/en-us/aspnet/core/blazor/hybrid/security/?view=aspnetcore-6.0&pivots=maui
                authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(authResult.ClaimsPrincipal.Claims));
            }
    
            // Here's the access token
            var token = authResult.AccessToken;
    
            return await Task.FromResult(authenticatedUser);
        }
    
        public void Logout()
        {
            _currentUser = new ClaimsPrincipal(new ClaimsIdentity());
            NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_currentUser)));
        }
    }
    

    Finally, we can add these services to MauiProgram.cs:

    builder.Services.AddAuthorizationCore();
    builder.Services.AddScoped<AuthenticationStateProvider, ExternalAuthStateProvider>();
    builder.Services.AddSingleton<IAuthenticationService>();
    

    As for integrating the authentication service with Blazor, see the answer from javiercn in this, as well as my post here.

    Hopefully this leaves you with a good start if you choose to pick up where you left off with your project.

    Resources: