This is going to hopefully be a fairly brief post to document how to secure an ASP.Net Core 8 Blazor Web Application using AWS Cognito User Pools - more to act as a personal log really of the steps taken.

Microsoft have already provided a large part of the work in the article Secure an ASP.NET Core Blazor Web App with OpenID Connect (OIDC) which provides a link to download a starter sample app. The sample app out of the box integrates with Microsoft as an OIDC provider. AWS Cognito is just ever so slightly different with regards to some settings, but on the whole, the sample application and article is a solid start point.

To get started, a sample AWS Cognito user pool is required. I won't document the process (loads of other articles abound that walk you through setting up an AWS Cognito User Pool using the AWS Console with lots of pictures to guide you) - to simplify things, you can grab a basic CloudFormation template to set up a testing User Pool here

Once you've run that template (or stepped through one of the dozens of pages that show you how to set up a User Pool) collect some of the following information from your User Pool App Client, namely the Client ID and the Client secret (you'll need to toggle the 'show client secret' toggle) as well as the User pool ID and the User Pool domain

The AppUserPool created using the Cloudformation template linked above

The AppUserPool App Client created using the Cloudformation template linked above

The majority of changes are made to the BlazorWebAppOidc.Program.cs file.

Replace

const string MS_OIDC_SCHEME = "MicrosoftOidc";

with

const string AWS_OIDC_SCHEME = "CognitoOidc";

and do a search & replace for MS_OIDC_SCHEME with AWS_OIDC_SCHEME. This isn't critical - its simply a key used but it helps keep the code clearer that you're working with Cognito and not Microsoft.

Next is to add a chunk of custom code to BlazorWebAppOidc.Program.cs - I don't think its super important where it goes, but the first lines are to refine the list of scopes expected/available, so the existing commented out line dealing with scopes seems a good place to start.

just after the commented out line

//oidcOptions.Scope.Add(OpenIdConnectScope.OpenIdProfile);

add the following block of code. The following code needs to have {User pool ID}, {Cognito domain} and {ClientId} replaced with the appropriate value from the User pool values recorded above.

        oidcOptions.Scope.Clear();
        // required OIDC scopes
        oidcOptions.Scope.Add("openid");
        oidcOptions.Scope.Add("profile");

        // default Cognito Scope
        oidcOptions.Scope.Add("email");
        // replace {User pool ID} below with the actual User pool ID from your User Pool
        oidcOptions.MetadataAddress = "https://cognito-idp.ap-southeast-2.amazonaws.com/{User pool ID}/.well-known/openid-configuration";


        oidcOptions.Events = new OpenIdConnectEvents
        {
            OnRedirectToIdentityProviderForSignOut = context =>
            {
                // replace {Cognito domain} with the actual domain value from the User Pool - e.g. https://my-test-app.auth.ap-southeast-2.amazoncognito.com if you use the attached Cloudformation template
                var uri = new Uri("{Cognito domain}/logout", UriKind.Absolute);

                // set the logout page that will be redirected to when logging out.
                // if this is changed to be a different page, be sure to add or change the LogoutUrls in the cloudformation template
                // or edit the Allowed sign-out URLs in the Hosted UI section of the App client
                var logoutUrl = $"{context.Request.Scheme}://{context.Request.Host}/";

                context.ProtocolMessage.IssuerAddress = uri.AbsoluteUri;
                context.ProtocolMessage.ResponseType = "code";

                // Replace {ClientId} with the Client Id from the App Client.  
                context.ProtocolMessage.SetParameter("client_id", "{ClientId}");
                context.ProtocolMessage.SetParameter("logout_uri", logoutUrl);
                context.ProtocolMessage.SetParameter("redirect_uri", logoutUrl);

                return Task.CompletedTask;
            }
        };

Next, uncomment the following lines - no changes required

        oidcOptions.CallbackPath = new PathString("/signin-oidc");
        oidcOptions.SignedOutCallbackPath = new PathString("/signout-callback-oidc");

Uncomment this line as well

oidcOptions.RemoteSignOutPath = new PathString("/signout-oidc");

Change

oidcOptions.Authority = "https://login.microsoftonline.com/{TENANT ID}/v2.0/";

to the following (replacing obviously with the actual domain value from the User Pool - e.g. https://my-test-app.auth.ap-southeast-2.amazoncognito.com if you use the attached Cloudformation template)

oidcOptions.Authority = "{Cognito domain}/oauth2/authorize";

Update the line

oidcOptions.ClientId = "{CLIENT ID}";

to replace or populate the Client Id from the App client

Then uncomment and update the line - noting the comment block about this being unwise to hard code this value in your source and instead fetch this value from a secure credential store such as Azure Vault or AWS Secrets Manager

//oidcOptions.ClientSecret = "{PREFER NOT SETTING THIS HERE}";

The last change for BlazorWebAppOidc.Program.cs is to comment out the following line for now - handling the refresh of the auth cookie is still an outstanding task taht I'll have to come back & update once I resolve some issues

builder.Services.ConfigureCookieOidcRefresh(CookieAuthenticationDefaults.AuthenticationScheme, AWS_OIDC_SCHEME);

Download the entire modified BlazorWebAppOidc.Program.cs file from here

Update the file BlazorWebAppOidc.LoginLogoutEndpointRouteBuilderExtensions.cs file to replace "MicrosoftOidc" (line 21) with "CognitoOidc" instead

Finally, the file BlazorWebAppOidc.Client.UserInfo.cs will require a small change if you don't modify the linked Cloudformation template (or if you set up your own User Pool & don't add 'name' as a Custom scope to the App client

The AppUserPool App Client scopes

Line 19 of BlazorWebAppOidc.Client.UserInfo.cs expect to receive a value provided for the Scope 'name'. Unless you configure the App client custom scopes to provide this, and add this as a scope in Program.cs, this line will throw an exception. One quick change is to instead substitute email for name and provide the email UserInfo.Name property instead, e.g.

    public const string UserIdClaimType = "sub";
    public const string NameClaimType = "name";
    
    // add a new email claim type
    public const string EmailClaimType = "email";

    public static UserInfo FromClaimsPrincipal(ClaimsPrincipal principal) =>
        new()
        {
            UserId = GetRequiredClaim(principal, UserIdClaimType),
            // replace the use of NameClaimType with the new EmailClaimType instead
            Name = GetRequiredClaim(principal, EmailClaimType),
        };