Post Cover

Implementing Cognito User Login and Signup in ASP.NET Core using AWS SDK

ASP.NET Core AWS Cognito Posted Apr 27, 2021

Cognito is a User Identity Management service provided by AWS in its Cloud suite. Cognito provides user identity and authorization management for applications, while applications can use the solutions in granting access to their functionalities. 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.

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(UserConfirmSignUpModel model);
        Task<UserSignUpResponse> CreateUserAsync(UserSignUpModel model);
        /* Signup Flow Ends */
        
        /* Change Password Flow */
        Task<BaseResponseModel> TryChangePasswordAsync(ChangePwdModel model);
        
        /* Forgot Password Flow Starts */
        Task<InitForgotPwdResponse> TryInitForgotPasswordAsync(InitForgotPwdModel model);
        Task<ResetPasswordResponse> TryResetPasswordWithConfirmationCodeAsync(ResetPasswordModel model);
        /* Forgot Password Flow Ends */
        
        /* Login Flow Starts */
        Task<AuthResponseModel> TryLoginAsync(UserLoginModel model);
        /* Login Flow Ends */
        
        Task<UserSignOutResponse> TryLogOutAsync(UserSignOutModel model);
        
        /* Update Profile Flow Starts */
        Task<UserProfileResponse> GetUserAsync(string userId);
        Task<UpdateProfileResponse> UpdateUserAttributesAsync(UpdateProfileModel model);
        /* Update Profile Flow Ends */
    }
}

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

public class BaseResponseModel
{
    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 - 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 an existing user with his/her Email address and Password combination aka Login Flow:

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<AuthResponseModel> TryLoginAsync(UserLoginModel model)
{
    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 authResponseModel = new AuthResponseModel();
        authResponseModel.EmailAddress = user.UserID;
        authResponseModel.UserId = user.Username;
        authResponseModel.Tokens = new TokenModel
        {
            IdToken = result.IdToken,
            AccessToken = result.AccessToken,
            ExpiresIn = result.ExpiresIn,
            RefreshToken = result.RefreshToken
        };
        
        authResponseModel.IsSuccess = true;
        return authResponseModel;
    }
    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 AuthResponseModel
        {
            IsSuccess = false,
            Message = "EmailAddress not found."
        };
    }
    catch (NotAuthorizedException)
    {
        return new AuthResponseModel
        {
            IsSuccess = false,
            Message = "Incorrect username or password"
        };
    }
}

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

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(UserSignUpModel model)
{
    // create a SignUpRequest
    var signUpRequest = new SignUpRequest
    {
        ClientId = _cloudConfig.AppClientId,
        Password = model.Password,
        Username = model.EmailAddress
    };

    // add all the attributes 
    // you want to add to the New User
    signUpRequest.UserAttributes.Add(new AttributeType
    {
        Name = "email",
        Value = model.EmailAddress
    });
    signUpRequest.UserAttributes.Add(new AttributeType
    {
        Value = model.GivenName,
        Name = "given_name"
    });
    signUpRequest.UserAttributes.Add(new AttributeType
    {
        Value = model.PhoneNumber,
        Name = "phone_number"
    });

    //if (model.ProfilePhoto != null)
    //{
    //    // upload the incoming profile photo to user's S3 folder
    //    // and get the s3 url
    //    // add the s3 url to the profile_photo attribute of the userCognito
    //    var picUrl = await _storage.AddItem(model.ProfilePhoto, "profile");

    //    signUpRequest.UserAttributes.Add(new AttributeType
    //    {
    //          Value = picUrl,
    //          Name = "picture"
    //    });
    //}

    try
    {
        // call SignUpAsync() method
        SignUpResponse response = await _provider.SignUpAsync(signUpRequest);

        var signUpResponse = new UserSignUpResponse
        {
            UserId = response.UserSub,
            EmailAddress = model.EmailAddress,
            Message = $"Confirmation Code sent to {response.CodeDeliveryDetails.Destination} via {response.CodeDeliveryDetails.DeliveryMedium.Value}",
            IsSuccess = true
        };

        return signUpResponse;
    }
    catch (UsernameExistsException)
    {
        return new UserSignUpResponse
        {
            IsSuccess = false,
            Message = "Emailaddress 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(UserConfirmSignUpModel model)
{
    ConfirmSignUpRequest request = new ConfirmSignUpRequest
    {
        ClientId = _cloudConfig.AppClientId,
        ConfirmationCode = model.ConfirmationCode,
        Username = model.EmailAddress
    };

    try
    {
        var response = await _provider.ConfirmSignUpAsync(request);
        return new UserSignUpResponse
        {
            EmailAddress = model.EmailAddress,
            UserId = model.UserId,
            Message = "User Confirmed",
            IsSuccess = true
        };
    }
    catch (CodeMismatchException)
    {
        return new UserSignUpResponse
        {
            IsSuccess = false,
            Message = "Invalid Confirmation Code",
            EmailAddress = model.EmailAddress
        };
    }
}

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.

PART 2: Implementing Cognito Forgot Password and Update Profile in ASP.NET Core using AWS SDK

Author-Image

Ram

I'm a full-stack developer and a software enthusiast who likes to play around with cloud and tech stack out of curiosity.

You can now show your support. 😊

We use cookies to provide you with a great user experience, analyze traffic and serve targeted promotions.   Learn More   Accept