Skip to content

Utilizing AWS Cognito for Authentication

Utilizing AWS Cognito for Authentication

AWS Cognito, one of the most popular services of the Amazon Web Services, is at the heart of many web and mobile applications, providing numerous useful user identity and data security features. It is designed to simplify the process of user authentication and authorization, and many developers decide to use it instead of developing their own solution.

"Never roll out your own authentication" is a common phrase you'll hear in the development community, and not without a reason. Building an authentication system from scratch can be time-consuming and error-prone, with a high risk of introducing security vulnerabilities. Existing solutions like AWS Cognito have been built by expert teams, extensively tested, and are constantly updated to fix bugs and meet evolving security standards.

Here at This Dot, we've used AWS Cognito together with Amplify in many of our projects, including Let's Chat With, an application that we recently open-sourced. In this blog post, we'll show you how we accomplished that, and how we used various Cognito configuration options to tailor the experience to our app.

Setting Up Cognito

Setting up Cognito is relatively straightforward, but requires several steps. In Let's Chat With, we set it up as follows:

  1. Sign in to the AWS Console, then open Cognito.
  2. Click the "Create user pool" to create a user pool. User Pools are essentially user directories that provide sign-up and sign-in options, including multi-factor authentication and user-profile functionality.
  3. In the first step, as a sign-in option, select "Email", and click "Next".
  4. Choose "Cognito defaults" as the password policy "No MFA" for multi-factor authentication. Leave everything else at the default, and click "Next".
  5. In the "Configure sign-up experience" step, leave everything at the default settings.
  6. In the "Configure message delivery" step, select "Send email with Cognito".
  7. In the "Integrate your app" step, just enter names for your user pool and app client. For example, the user pool might be named "YourAppUserPool_Dev", while the app client could be named "YourAppFrontend_Dev".
  8. In the last step, review your settings and create the user pool.

After the user pool is created, make note of its user pool ID:

as well as the client ID of the app client created under the user pool:

These two values will be passed to the configuration of the Cognito API.

Using the Cognito API

Let's Chat With is built on top of Amplify, AWS's collection of various services that make development of web and mobile apps easy. Cognito is one of the services that powers Amplify, and Amplify's SDK is offers some helper methods to interact with the Cognito API.

In an Angular application like Let's Chat With, the initial configuration of Cognito is typically done in the main.ts file as shown below:

// apps/admin/src/main.ts

Amplify.configure({
    Auth: {
      userPoolId: process.env.USER_POOL_ID,
      userPoolWebClientId: process.env.USER_POOL_WEB_CLIENT_ID,
    }
  }
);

How the user pool ID and user pool web client ID are injected depends on your deployment option. In our case, we used Amplify and defined the environment variables for injection into the built app using Webpack.

Once Cognito is configured, you can utilize its authentication methods from the Auth class in the @aws-amplify/auth package. For example, to sign in after the user has submitted the form containing the username and password, you can use the Auth.signIn(email, password) method as shown below:

// libs/core/src/lib/amplify/auth.service.ts

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  constructor(private transloco: TranslocoService) {}

  signInAdmin(email: string, password: string): Observable<CoreUser> {
    return from(Auth.signIn(email, password)).pipe(
      switchMap(() => {
        return this.isAdmin().pipe(
          switchMap((isAdmin) => {
            if (isAdmin) {
              return this.getCurrentUser();
            }
            throw new Error(this.transloco.translate('userAuth.errors.notAdmin'));
          })
        );
      })
    );
  }

  getCurrentUser(): Observable<CoreUser> {
    return from(Auth.currentUserInfo()).pipe(
      filter((user) => !!user),
      map((user) => this.cognitoToCoreUser(user))
    );
  }

  cognitoToCoreUser(cognitoUser: AmplifyCognitoUser): CoreUser {
    return {
      cognitoId: cognitoUser.username,
      emailVerified: cognitoUser.attributes.email_verified,
    };
  }  
}

The logged-in user object is then translated to an instance of CoreUser, which represents the internal representation of the logged-in user.

The AuthService class contains many other methods that act as a facade over the Amplify SDK methods. This service is used in authentication effects since Let's Chat With is based on NgRx and implements many core functionalities through NgRx effects:

@Injectable()
export class AuthEffects implements OnInitEffects {
  public signIn$ = createEffect(() =>
    this.actions$.pipe(
      ofType(SignInActions.userSignInAttempted),
      withLatestFrom(this.store.select(AuthSelectors.selectRedirectUrl)),
      exhaustMap(([{ email, password }, redirectUrl]) =>
        this.authService.signInAdmin(email, password).pipe(
          map((user) => AuthAPIActions.userLoginSuccess({ user })),
          tap(() => void this.router.navigateByUrl(redirectUrl || '/reports')),
          catchError((error) =>
            of(
              AuthAPIActions.userSignInFailed({
                errors: [error.message],
                email,
              })
            )
          )
        )
      )
    )
  );
}

The login component triggers a SignInActions.userSignInAttempted action, which is processed by the above effect. Depending on the outcome of the signInAdmin call in the AuthService class, the action is translated to either AuthAPIActions.userLoginSuccess or AuthAPIActions.userSignInFailed.

The remaining user flows are implemented similarly:

  • Clicking signup triggers the Auth.signUp method for user registration.
  • Signing out is done using Auth.signOut.

Reacting to Cognito Events

How can you implement additional logic when a signup occurs, such as saving the user to the database? While you can use an NgRx effect to call a backend service for that purpose, it requires additional effort and may introduce a security vulnerability since the endpoint needs to be open to the public Internet. In Let's Chat With, we used Cognito triggers to perform this logic within Cognito without the need for extra API endpoints. Cognito triggers are a powerful feature that allows developers to run AWS Lambda functions in response to specific actions in the authentication and authorization flow.

Triggers are configured in the "User pool properties" section of user pools in the AWS Console. We have a dedicated Lambda function that runs on post-authentication or post-confirmation events:

The Lambda function first checks if the user already exists. If not, it inserts a new user object associated with the Cognito user into a DynamoDB table. The Cognito user ID is read from the event.request.userAttributes.sub property.

async function handler(event, context) {
  const owner = event.request.userAttributes.sub;

  if (owner) {
    const user = await getUser({ owner });
    if (user == null) {
      await addUser({ owner, notificationConfig: DEFAULT_NOTIFICATION_CONFIG });
    }
    context.done(null, event);
  } else {
    context.done(null, event);
  }
}

async function getUser({ owner }) {
  const params = {
    ExpressionAttributeNames: { '#owner': 'owner' },
    ExpressionAttributeValues: { ':owner': owner },
    KeyConditionExpression: '#owner = :owner',
    IndexName: 'byOwner',
    TableName: process.env.USER_TABLE_NAME,
  };
  const { Items } = await documentClient().query(params).promise();
  return Items.length ? Items[0] : null;
}

async function addUser(user) {
  const { owner, notificationConfig } = user;
  const date = new Date();

  const params = {
    Item: {
      id: uuidv4(),
      __typename: 'User',
      owner: owner,
      notificationConfig: notificationConfig,
      createdAt: date.toISOString(),
      updatedAt: date.toISOString(),
      termsAccepted: false,
    },
    TableName: process.env.USER_TABLE_NAME,
  };
  await documentClient().put(params).promise();
}

Customizing Cognito Emails

Another Cognito trigger that we found useful for Let's Chat With is the "Custom message" trigger. This trigger allows you to customize the content of verification emails or messages for your app. When a user attempts to register or perform an action that requires a verification message, the trigger is activated, and your Lambda function is invoked.

Our Lambda function reads the verification code and the email from the event, and creates a custom-designed email message using the template() function. The template reads the HTML template embedded in the Lambda.

exports.handler = async (event, context) => {
  try {
    if (event.triggerSource === 'CustomMessage_SignUp') {
      const { codeParameter } = event.request;
      const { email } = event.request.userAttributes;
      const encodedEmail = encodeURIComponent(email);
      const link = `${process.env.REDIRECT_URL}email=${encodedEmail}&code=${codeParameter}`;
      const createdAt = new Date();
      const year = createdAt.getFullYear();

      event.response.emailSubject = 'Your verification code';
      event.response.emailMessage = template(email, codeParameter, link, year);
    }
    context.done(null, event);
    console.log(`Successfully sent custom message after signing up`);
  } catch (err) {
    context.done(null, event);
    console.error(
      `Error when sending custom message after signing up`,
      JSON.stringify(err, null, 2)
    );
  }
};

const template = (email, code, link, year) => `<html>
  <body style="background-color:#333; font-family: PT Sans,Trebuchet MS,sans-serif; ">
    ...
  </body>
</html>`;

Conclusion

Cognito has proven to be reliable and easy to use while developing Let's Chat With. By handling the intricacies of user authentication, it allowed us to focus on developing other features of the application. The next time you create a new app and user authentication becomes a pressing concern. Remember that you don't need to build it from scratch. Give Cognito (or a similar service) a try. Your future self, your users, and probably your sanity will thank you.

If you're interested in the source code for Let's Chat With, check out its GitHub repository. Contributions are always welcome!