Using Amazon Cognito with Blazor Web Assembly in .NET 8


For talent acquisition and maintainability reasons, I’m moving off of React and on to Blazor Web Assembly for one of my Software as a Service (SaaS) projects. It’s taking off faster than I expected, but my plate is always full, and it’s faster for me to code natively in C# as much as possible in one project.  Reducing time on target and accelerating release schedules is the name of the game here.  I know it’s an ongoing debate.

Now, as far as identity management goes, one of my favorites has been Cognito on Amazon Web Services.  The price point and free tier is great for startups and new SaaS projects, and it’s fairly robust.

I’ve implemented it in APIs and Angular/React combinations, but for Blazor, it was new territory for me.  I will not go over creating resources and app integrations in Cognito in AWS.  My assumption is you can find that on your own.  This project assumes a hosted UI.

I created a new standalone Blazor web assembly project with .NET 8:

Screenshot

The first thing I do in the new project is add appsettings.json under wwwroot in the project.

 "Cognito": {
      "Authority": "",
      "ClientId" : "",
      "RedirectUri": "http://localhost:5124/authentication/login-callback", 
      "PostLogoutRedirectUri": "http://localhost:5124/authentication/logout-callback",
      "ResponseType": "code"
}

Authority will be generally be the regional Cognito URL/User Pool ID. ClientId will be from your app client integration with that user pool. Redirect URIs are from your application. Make sure you add those to your hosted UI callback URLs in Cognito.

Now, add a package reference for Microsoft.AspNetCore.Components.WebAssembly.Authentication.

You’ll need to include the Authentication library in your index.html file under wwwroot. Drop this right above the reference to blazor.webassembly.js.

<script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></script> 

We also want to add our Authorization and Authentication to our Razor equivalent of global usings, and that’s _Imports.razor.

@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization
@using blazorcognito
@using blazorcognito.Layout

We’ll now add OIDC authentication to our Program.cs. We add the aws.cognito.signin.user.admin scope because we’ll want to pass access tokens back to our API, possibly. I’m also using given_name and family_name in my Cognito user objects, so I am explicitly identifying the Name claim for our identity user in .NET. Additionally, I want to use Cognito groups to identify roles, so I explicitly define that array return as our RoleClaim.

There is also an issue with logout URLs. As of .NET 8, there’s seemingly no ability to add custom parameters to the logout URL. Cognito requires client_id, which the JS Authentication library does not provide out of the box. I add these additional provider parameters to take care of that. I worried it would not break login because it does add those to that URL, but only after the initial values, so they seem to be ignored by Cognito. This is an issue for some Auth0 implementations, too, I understand, so it’s something that we’ll stay tuned to, as it could change. It feels janky, but I can attest—it works.

builder.Services.AddOidcAuthentication(options => {
    options.ProviderOptions.DefaultScopes.Add("aws.cognito.signin.user.admin");
    options.UserOptions.NameClaim = "given_name";
    options.UserOptions.RoleClaim = "cognito:groups";
    builder.Configuration.Bind("Cognito", options.ProviderOptions);
    options.ProviderOptions.AdditionalProviderParameters.Add("client_id", builder.Configuration["Cognito:ClientId"]);
    options.ProviderOptions.AdditionalProviderParameters.Add("redirect_uri", $"{builder.HostEnvironment.BaseAddress}authentication/logout-callback");
    options.ProviderOptions.AdditionalProviderParameters.Add("response_type", "code");
});

If we want to consume our AuthenticationState downstream in our App, it’s best to wrap our Router and content in App.razor. Our default project looks like this:

<Router AppAssembly="@typeof(App).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        <FocusOnNavigate RouteData="@routeData" Selector="h1" />
    </Found>
    <NotFound>
        <PageTitle>Not found</PageTitle>
        <LayoutView Layout="@typeof(MainLayout)">
            <p role="alert">Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

We want to implement both CascadingAuthenticationState and our AuthorizeRouteView. We also have a custom Razor component we’ll introduce to handle redirection to login.

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if(context.User.Identity?.IsAuthenticated != true)
                    {
                        <RedirectToLogin />
                    } else {
                        <p role="alert">You are not authorized to access this resource.</p>
                    }
                </NotAuthorized>
            </AuthorizeRouteView>
            <FocusOnNavigate RouteData="@routeData" Selector="h1" />
        </Found>
        <NotFound>
            <PageTitle>Not found</PageTitle>
            <LayoutView Layout="@typeof(MainLayout)">
                <p role="alert">Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

We’ll now add our RedirectToLogin.razor file.

@inject NavigationManager Navigation
@code {
    protected override void OnInitialized()
    {
        Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}");
    }
}

Let’s add a LoginDisplay.razor file. It’s important to note that NavigaateToLogout is new, previous methods you’ve seen are now deprecated.

@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation

<AuthorizeView>
    <Authorized>
        Hello, @context.User.Identity?.Name!
        <button class="nav-link btn btn-link" @onclick="BeginSignOut">Log out</button>
    </Authorized>
    <NotAuthorized>
        <a href="authentication/login">Log in</a>
    </NotAuthorized>
</AuthorizeView>

@code {
    private void BeginSignOut(MouseEventArgs args)
    {
        Navigation.NavigateToLogout("authentication/logout");
    }
}

We also have to add an authentication page!

@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
<RemoteAuthenticatorView Action="@Action" />

@code{
    [Parameter] public string? Action { get; set; }
}

At this point, we have everything we need to cover down on for our standalone Blazor project to work against Cognito. We will now update our layout files to use our LoginDisplay and protect content. We’ll update MainLayout.razor first. We’re going to add our <LoginDisplay /> at line 9.

@inherits LayoutComponentBase
<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <LoginDisplay />
            <a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @Body
        </article>
    </main>
</div>

Now we’ll go to NavMenu.razor and protect the Weather page. At line 22 we’re going to add <AuthorizeView> to wrap the menu item. Also note AuthorizeView will take a Roles argument. If you use Cognito groups and use the RoleClaim login, you can automatically secure your view without a whole lot of typing. This view keeps your link from showing up unless a user is logged in.

When we run the project, we see that Weather is not visible.

But we still want to also protect the page itself. It’s a rather simple affair. We go to Weather.razor and drop in our using statement for Authentication and an @attribute [Authorize]. Just like the AuthorizeView above, we can also add roles to further protect using Role claims. @attribute [Authorize(Roles=”AdminUser”)] for instance.

@page "/weather"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@attribute [Authorize]
@inject HttpClient Http

<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.json");
    }

    public class WeatherForecast
    {
        public DateOnly Date { get; set; }

        public int TemperatureC { get; set; }

        public string? Summary { get; set; }

        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

Bringing It All Together

Let’s give the whole thing a whirl!

We click “Log in”, which will look at our status and redirect us, if needed, to our hosted Cognito UI.

This will redirect us back to our app with appropriate tokens and codes and whatnot. And we see that our Weather app appears, and that the app respects my NameClaim setting earlier.

We’ll validate that our Weather app is appropriately secure. I’ll hit it once logged in. I verify that when I try to navigate to it after logging out, it redirects me to the login page.

Passing Tokens to APIs

I also like my APIs to validate and require tokens in my Cognito integrations. In my case, I have secured and unsecured routes to my API. The ability to add HttpClients in Blazor standalone projects simplifies this a great deal.

You will have to add the Microsoft.Extensions.Http package to your project for this to work.

I add to my appsettings.json my BaseUrl. Then, I add an “authorized” HttpClient to my Program.cs file. I also to this client add a message handler which I then configure. I create two, of these, one without the handler for unauthorized calls. In this example, I’m also passing another header parameter, too, called “accountId”. This is just an example, but if you are working against routes, like from Azure Functions using x-functions-key, for instance, it’s a nice way to configure things.

builder.Services.AddHttpClient("AuthWebAPI", httpClient => {
    httpClient.BaseAddress = new Uri(builder.Configuration["BaseUrl"]);
    httpClient.DefaultRequestHeaders.Add("accountId", builder.Configuration["AccountId"]);
}).AddHttpMessageHandler(sp => sp.GetRequiredService<AuthorizationMessageHandler>()
    .ConfigureHandler(
        authorizedUrls: new [] { builder.Configuration["BaseUrl"] },
        scopes: new[] { "read", "write" }));

builder.Services.AddHttpClient("WebAPI", httpClient => {
    httpClient.BaseAddress = new Uri(builder.Configuration["BaseUrl"]);
    httpClient.DefaultRequestHeaders.Add("accountId", builder.Configuration["AccountId"]);
});

To use these clients, it’s easy enough in a page where you’re invoking API calls to do this.

var client = ClientFactory.CreateClient("AuthWebAPI");
        customType = await client.GetFromJsonAsync<CustomType[]>($"specificAPI");

Using Cognito Groups for Multiple Claims

I did gloss over one component of using more than one group in the cognito:groups array. It’s a little bit of a hassle, but we add a file at the root called MultipleRoleClaimsPrincipalFactory.cs.

using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;

public class MultipleRoleClaimsPrincipalFactory<TAccount> : AccountClaimsPrincipalFactory<TAccount> 
    where TAccount : RemoteUserAccount
{
    public MultipleRoleClaimsPrincipalFactory(IAccessTokenProviderAccessor accessor) 
        : base(accessor)
    {
    }
    public async override ValueTask<ClaimsPrincipal> CreateUserAsync(TAccount account, RemoteAuthenticationUserOptions options)
    {
        var user = await base.CreateUserAsync(account, options);
        var claimsIdentity = (ClaimsIdentity)user.Identity;
        if (account != null)
        {
            MapArrayClaimsToMultipleSeparateClaims(account, claimsIdentity);
        }
        return user;
    }
    private static void MapArrayClaimsToMultipleSeparateClaims(TAccount account, ClaimsIdentity claimsIdentity)
    {
        foreach (var prop in account.AdditionalProperties)
        {
            var key = prop.Key;
            var value = prop.Value;
            if (value != null &&
                (value is JsonElement element && element.ValueKind == JsonValueKind.Array))
            {
                claimsIdentity.RemoveClaim(claimsIdentity.FindFirst(prop.Key));
                var claims = element.EnumerateArray()
                    .Select(x => new Claim(prop.Key, x.ToString()));
                claimsIdentity.AddClaims(claims);
            }
        }
    }
}

We update our .AddOidcAuthentication in Program.cs accordingly.

builder.Services.AddOidcAuthentication(options => {
    ...
}).AddAccountClaimsPrincipalFactory<MultipleRoleClaimsPrincipalFactory<RemoteUserAccount>>();

It works fairly well for me thus far. This is still my first Blazor to AWS Cognito implementation, but as often as I use both, it’s something I’m sure I’ll continue to refine.

You can view this entire example project here: https://github.com/parkerfly38/blazorcognito

Brian

Writer, President of Bangor's Congregation Beth Israel, soldier, programmer, father, musician, Heeb, living in the woods of Maine with three ladies and a dog.

You may also like

LEAVE A COMMENT

About Brian

Brian Kresge

Brian Kresge

Writer, President of Bangor's Congregation Beth Israel, soon-to-be-retired-soldier, programmer, father, musician, Heeb, living in the woods of Maine with three ladies and a dog. Brian is also a rabbinical student with the Pluralistic Rabbinical Seminary.

About Leah

Leah Kresge

Leah Kresge

Director of Education for Congregation Beth Israel in Bangor, Maine, runs joint religious school with our sister congregation, special educator and former school board member, mother to Avi and Nezzie.

follow me

Flickr

Flickr Feed
Flickr Feed
Flickr Feed
Flickr Feed
Flickr Feed
Flickr Feed