How to AWS Cognito Login Signup in ASP.NET Core

In this article, let’s look at how we can design and build such an API that encapsulates all of User Identity Management functionalities such as Login..

Introduction

AWS Cognito provides OAuth2 auth flows such as Authorization Code where the application can redirect to AWS Cognito hosted login screens where the user credentials are validated against Cognito data store and is redirected to the configured landing pages on the application side with an idToken which represents user authorization.

While it is the most recommended approach for applications, some designs prefer having a layer of their API that would communicate with Cognito for authorization, as a matter of decoupling Cognito with the Client (so as to have flexibility or better control).

In this article, let’s look at how we can design and build such an API that encapsulates all of User Identity Management functionalities such as Login, Signup, Password Reset, Update profile and so on, while internally communicating with Cognito for respective flows.

Keep in mind that to run such an API, we need the API to be deployed in a resource that has all the IAM permissions for Cognito access.

Features we’ll implement & Getting started

We shall look at designing our APIs which provide the below features:

  1. Login an existing user with his/her Email address and Password combination
  2. Signup a new user with his/her Email address, Name, Phone and Password
  3. Update a logged in user’s profile information
  4. Update a logged in user’s password
  5. Reset a user’s forgotten password based on Email address

To get started, let’s create a new AspNetCore project that’d host all of these functionalities.

dotnet new mvc --name CognitoUserManager

I’m creating an MVC application instead of a WebAPI, so that I can also add a layer of UI for testing the functionalities. In the project, I create a Repository class called UserRepository which implements an interface IUserRepository that defines all the functionalities as contracts. This Repository is injected into our API layers or UI layer by means of the inbuilt Dependency Injection.

namespace CognitoUserManager.Contracts.Repositories
{
    public interface IUserRepository
    {
        /* Signup Flow Starts */
        Task<UserSignUpResponse> ConfirmUserSignUpAsync(UserConfirmSignUpRequest request);
        Task<UserSignUpResponse> CreateUserAsync(UserSignUpRequest request);
        /* Signup Flow Ends */
        
        /* Change Password Flow */
        Task<BaseResponseRequest> TryChangePasswordAsync(ChangePwdRequest request);
        
        /* Forgot Password Flow Starts */
        Task<InitForgotPwdResponse> TryInitForgotPasswordAsync(InitForgotPwdRequest request);
        Task<ResetPasswordResponse> TryResetPasswordWithConfirmationCodeAsync(ResetPasswordRequest request);
        /* Forgot Password Flow Ends */
        
        /* Login Flow Starts */
        Task<AuthResponseRequest> TryLoginAsync(UserLoginRequest request);
        /* Login Flow Ends */
        
        Task<UserSignOutResponse> TryLogOutAsync(UserSignOutRequest request);
        
        /* Update Profile Flow Starts */
        Task<UserProfileResponse> GetUserAsync(string userId);
        Task<UpdateProfileResponse> UpdateUserAttributesAsync(UpdateProfileRequest request);
        /* Update Profile Flow Ends */
    }
}

I’ve created individual request and response DTO classes for handling respective data. All the Response classes extend from a BaseResponse class which contains the below attributes.

public class BaseResponse
{
    public bool IsSuccess { get; set; }
    public string Message { get; set; }
}

Before jumping into the implementation, we also need to know what information from Cognito to be configured and used in the API.

Prerequisites for Accessing AWS Cognito Resources

To access Cognito, we’d require a UserPool where all the users are stored and an AppClient which is created over this UserPool. We’d also require the Region in which this UserPool resides.

In case if we’re not deploying our application inside an AWS environment we’d also require the credentials of a user who has all the required permissions for accessing the Cognito user pool.

This we call the AccessKey and the SecretKey. We shall store and access all these information inside our appsettings.json (or preferably as Environmental Variables if redeployments are not an option).

"AppConfig": {
    "Region": "us-west-2",
    "UserPoolId": "us-west-2_aBcDeFgHiJ",
    "AppClientId": "Ab12Cd34Ef56Gh78ij90",
    "AccessKeyId": "AKIA1234567890",
    "AccessSecretKey": "abcdEfghalfheqncoryedofhuehhrh"
}

I’ve configured this configuration section into IOptions so as to access it as an object inside my Repository class.

services.Configure<AppConfig>(Configuration.GetSection("AppConfig"));
services.AddScoped<IUserRepository, UserRepository>();

We’d also require to install the below packages which contain the necessary libraries for communicating with Cognito and working with UserPools based on the requests.

<PackageReference Include="Amazon.Extensions.CognitoAuthentication" Version="2.0.3" />
<PackageReference Include="AWSSDK.CognitoIdentityProvider" Version="3.5.1.17" />

Implementing UserRepository for Cognito Flows

Let’s begin by implementing the UserRepository which implements the IUserRepository interface and encapsulates all the user flows. To communicate with cognito we use an instance of the AmazonCognitoIdentityProviderClient which we configure by passing all the cognito details we collected before.

_cloudConfig = appConfigOptions.Value;
_provider = new AmazonCognitoIdentityProviderClient(
                _cloudConfig.AccessKeyId, 
                _cloudConfig.AccessSecretKey, 
                RegionEndpoint.GetBySystemName(_cloudConfig.Region));

Next we create an instance of CognitoUserPool by passing the AmazonCognitoIdentityProviderClient instance we created before along with few other parameters.

_userPool = new CognitoUserPool(
        _cloudConfig.UserPoolId, 
        _cloudConfig.AppClientId, 
        _provider);

Now that we’re done with our initial setups, let’s jump into action – implementing these user flows one by one using AWS .NET SDK for Cognito.

  1. Login an existing user with his/her Email address and Password combination
  2. Signup a new user with his/her Email address, Name, Phone and Password
  3. Update a logged in user’s profile information
  4. Update a logged in user’s password
  5. Reset a user’s forgotten password based on Email address

Login Flow – an existing user with Email address and Password

In this flow, the client passes an Email address and Password to the API which needs to validate this combination against a UserPool. We use a Resource Owner Password Grant (ROPG) type of flow in this, which is called as a Secure Remote Password (SRP) Authentication. We implement this as below:

CognitoUser user = new CognitoUser(emailAddress, _cloudConfig.AppClientId, _userPool, _provider);
InitiateSrpAuthRequest authRequest = new InitiateSrpAuthRequest()
{
    Password = password
};

AuthFlowResponse authResponse = await user.StartWithSrpAuthAsync(authRequest);

We create an instance of CognitoUser by passing the emailAddress and the instances of CognitoUserPool and AmazonCognitoIdentityProviderClient we created before. Then we call the StartWithSrpAuthAsync() method that takes an instance of InitiateSrpAuthRequest where we pass the password.

This call validates the passed on credentials for any user inside the passed on user pool and returns an AuthFlowResponse.

This response contains an AuthenticationResult, which is an instance of AuthenticationResultType containing the tokens representing the authenticated user. The tokens generated are based on the scope configurations provided while configuring the cognito.

If the user is not authenticated, the method throws a NotAuthorizedException which we can assume that the password combination is wrong.

public async Task<AuthResponseRequest> TryLoginAsync(UserLoginRequest request)
{
    try
    {
        CognitoUser user = new CognitoUser(
                emailAddress, 
                _cloudConfig.AppClientId, 
                _userPool, 
                _provider);
        
        InitiateSrpAuthRequest authRequest = new InitiateSrpAuthRequest()
        {
            Password = password
        };

        AuthFlowResponse authResponse = await user.StartWithSrpAuthAsync(authRequest);
        var result = authResponse.AuthenticationResult;

        var authResponseRequest = new AuthResponseRequest();
        authResponseRequest.EmailAddress = user.UserID;
        authResponseRequest.UserId = user.Username;
        authResponseRequest.Tokens = new TokenRequest
        {
            IdToken = result.IdToken,
            AccessToken = result.AccessToken,
            ExpiresIn = result.ExpiresIn,
            RefreshToken = result.RefreshToken
        };
        
        authResponseRequest.IsSuccess = true;
        return authResponseRequest;
    }
    catch (UserNotConfirmedException)
    {
        // Occurs if the User has signed up 
        // but has not confirmed his EmailAddress
        // In this block we try sending 
        // the Confirmation Code again and ask user to confirm
    }
    catch (UserNotFoundException)
    {
        // Occurs if the provided emailAddress 
        // doesn't exist in the UserPool
        return new AuthResponseRequest
        {
            IsSuccess = false,
            Message = "EmailAddress not found."
        };
    }
    catch (NotAuthorizedException)
    {
        return new AuthResponseRequest
        {
            IsSuccess = false,
            Message = "Incorrect username or password"
        };
    }
}

Signup Flow – a new user with his/her Email address, Name, Phone and Password

To let a new user signup, we need to follow a two step process:

  1. Create a new User in Cognito – this leaves the user in a NotConfirmed state
  2. Confirm the User by passing the Confirmation Code that is sent to the user’s primary source (EmailAddress in our case).

Creating a user is a straight forward process, where we pass the EmailAddress, Password and other information such as Name, PhoneNumber and so on as OpenID attributes to the AmazonCognitoIdentityProviderClient instance.

public async Task<UserSignUpResponse> CreateUserAsync(CreateUserRequest request, CancellationToken cancellationToken)
    {
        _logger.LogInformation("Create user {0}.", request.Email);

        var validator = new CreateUserRequestValidator(_repository, _validatorLocalizer);
        await validator.ValidateAndThrowAsync(request, cancellationToken: cancellationToken);

        var agency = await _agencyRepository.GetByIdAsync(request.AgencyId, cancellationToken);

        _ = agency ?? throw new NotFoundException(string.Format(_localizer["agency.notfound"], request.AgencyId));

        if (agency.Name.Equals("Tonkin", StringComparison.InvariantCultureIgnoreCase))
            request.UserType = Domain.Common.UserType.Tonkin;

        var user = new ApplicationUser(
        request.AgencyId,
        request.UserType,
        request.FirstName,
        request.LastName,
        request.Email);
        await _repository.AddAsync(user, cancellationToken);

        var signUpRequest = new SignUpRequest
        {
            ClientId = _myApiCredentials.AppClientId,
            Password = "Password@1122",
            Username = request.Email,
            SecretHash = CognitoHashCalculator.GetSecretHash(request.Email, _myApiCredentials.AppClientId, _myApiCredentials.AppClientSecret),
        };

        signUpRequest.UserAttributes.AddRange(new[]
        {
            new AttributeType { Name = "email", Value = request.Email },
            new AttributeType { Value = request.FirstName, Name = "given_name" },
            new AttributeType { Value = $"{request.FirstName} {request.LastName}", Name = "family_name" }
        });

        try
        {
            var response = await _provider.SignUpAsync(signUpRequest, cancellationToken);

            return new UserSignUpResponse
            {
                UserId = user.Id,
                UserSub = response.UserSub,
                Email = request.Email,
                IsSuccess = true,
                Message = $"Confirmation Code sent to {response.CodeDeliveryDetails.Destination} via {response.CodeDeliveryDetails.DeliveryMedium.Value}",
            };
        }
        catch (UsernameExistsException)
        {
            return new UserSignUpResponse
            {
                IsSuccess = false,
                Message = "Email Already Exists"
            };
        }
    }

The SignUpResponse returned by the Cognito CreateUserAsync() method contains the details about where the Confirmation Code has been sent to. The Destination property of the CodeDeliveryDetails contains a masked EmailAddress (or PhoneNumber) to where the code is sent. The next step is to post this Confirmation Code sent by the user to Cognito to as to “Confirm” the user.

 public async Task<UserSignUpResponse> ConfirmUserSignUpAsync(UserConfirmSignUpRequest request, CancellationToken cancellationToken)
    {
        var confirmRequest = new ConfirmSignUpRequest
        {
            ClientId = _myApiCredentials.AppClientId,
            ConfirmationCode = request.Code,
            Username = request.Email,
            SecretHash = CognitoHashCalculator.GetSecretHash(request.Email, _myApiCredentials.AppClientId, _myApiCredentials.AppClientSecret),
        };

        try
        {
            _ = await _provider.ConfirmSignUpAsync(confirmRequest, cancellationToken);
            return new UserSignUpResponse
            {
                Email = request.Email,
                UserId = request.UserId,
                Message = "User Confirmed",
                IsSuccess = true
            };
        }
        catch (CodeMismatchException)
        {
            return new UserSignUpResponse
            {
                IsSuccess = false,
                Message = "Invalid Confirmation Code",
                Email = request.Email
            };
        }
    }

Here we post the confirmation code for the respective EmailAddress related to the AppClientId and call the ConfirmSignUpAsync() method on the client.

A successful confirmation means no Exception and we can safely ask the user to login. If the confirmation code is wrong, the method throws a CodeMismatchException.

if Amazon.CognitoIdentityProvider.Model.NotAuthorizedException throw. i.e. ‘Client abcded8uek12di4ftcj0cv7btt is configured for secret but secret was not received. Use the below CognitoHashCalculator method in request payload.

public static class CognitoHashCalculator
{
    public static string GetSecretHash(string username, string appClientId, string appSecretKey)
    {
        var dataString = username + appClientId;

        var data = Encoding.UTF8.GetBytes(dataString);
        var key = Encoding.UTF8.GetBytes(appSecretKey);

        return Convert.ToBase64String(HmacSHA256(data, key));
    }

    public static byte[] HmacSHA256(byte[] data, byte[] key)
    {
        using var shaAlgorithm = new System.Security.Cryptography.HMACSHA256(key);
        var result = shaAlgorithm.ComputeHash(data);
        return result;
    }
}

Leave a Comment

Your email address will not be published. Required fields are marked *