The lion's den: NestJS and authentication with AWS Cognito

Cover for The lion's den: NestJS and authentication with AWS Cognito

I’ve heard of many cases where developers (specifically, those with Node.js experience) have reported that NestJS is very much a stable and solid solution ready for enterprise-level production. But then, what if we want to add AWS Cognito to the mix? Let’s cover the lion’s share of the steps you’ll need to take to make it all work.

In a previous article, we demonstrated how to streamline authentication with React and AWS Cognito.

That can be tricky, but doable. However, our frontend team wanted to stretch their paws and show that we’re also down for full stack projects. This article has its origins in our real experiences. And luckily, for those developers who want to complete the full-stack circle, integrating AWS Cognito Authentication into a NestJS TypeScript application is quite straightforward by comparison.

If you’ve got to integrate AWS Cognito with NestJS, following this journey will be of particular value to you.

NestJS will give us a solid foundation for implementing AWS Cognito, and we’ll show how NestJS applications can be further fortified against unauthorized access, enhancing security (and maintaining user trust).

Schedule call

Irina Nazarova CEO at Evil Martians

Schedule call

By the end of the road, you’ll have a secure authentication system configured and ready, and a full stack application that’s ready to roll.

Kickstarting NestJS Integration with AWS Cognito

We begin by defining some essential environment variables:

COGNITO_CLIENT_ID=exampleClientIdABC123
COGNITO_USER_POOL_ID=us-fake-1_abCDeFgHi
COGNITO_ADMINS_GROUP_NAME=demoAdmins
COGNITO_MAIN_REGION=us-fake-1

Let’s linger for a moment on two of them:

  • COGNITO_CLIENT_ID (exampleClientIdABC123): This is the application client’s Cognito identifier; it’s used to make authenticated requests to Cognito.
  • COGNITO_USER_POOL_ID (us-fake-1_abCDeFgHi): This identifier represents the user pool in Cognito, a user directory that facilitates sign-ups and sign-ins.

Next, consider the administrative group and the AWS region where the Cognito instance will reside:

  • COGNITO_ADMINS_GROUP_NAME (demoAdmins): This defines a user group within the Cognito user pool, and is typically used to separate different types of users (such as administrators).
  • COGNITO_MAIN_REGION (us-fake-1): AWS services are regional, so this indicates the region where a Cognito user pool is hosted.

NestJS: strongly-typed configuration

Schema validation is critical for directly avoiding the use of raw values from process.env. Further, it also guarantees the presence of all the necessary configuration settings.

We start by defining our ApplicationConfig interface, which will represent the structure of our application’s configuration (including both production and testing credentials for AWS Cognito):

import * as process from 'process';

export interface ApplicationConfig {
  cognitoClientID: string;
  cognitoUserPoolID: string;
  cognitoAdminsGroupName: string;
  cognitoMainRegion: string;
}

Next, we gather our configuration from environment variables, bridging our .env settings with our application logic:

export const appConfig = (): ApplicationConfig => {
  const config = {
    cognitoClientID: process.env.COGNITO_CLIENT_ID,
    cognitoUserPoolID: process.env.COGNITO_USER_POOL_ID,
    cognitoAdminsGroupName: process.env.COGNITO_ADMINS_GROUP_NAME,
    cognitoMainRegion: process.env.COGNITO_MAIN_REGION,
  };
}

To ensure our application runs with the correct and complete configuration, we’re using data validation via Joi; let’s make sure all the necessary environment variables are present:

import * as Joi from 'joi';

const validationSchema = Joi.object<ApplicationConfig>({
  cognitoClientID: Joi.string().required(),
  cognitoUserPoolID: Joi.string().required(),
  cognitoAdminsGroupName: Joi.string().required(),
  cognitoMainRegion: Joi.string().required(),
});

const { error, value } = validationSchema.validate(config, { abortEarly: true });

if (error) {
  throw new Error(
    `[Application Config]: Environments validation failed. Please check .env file.
    Error message: ${error.message}`,
  );
}

Using @nestjs-cognito for authentication and authorization

@nestjs-cognito is a comprehensive NestJS library designed for seamless integration with AWS Cognito. Translation: you get secure authentication and authorization in your NestJS applications with minimal effort, easily connected AWS Cognito features like user management, authentication, and security.

Configuring AppModule

Typically, NestJS applications are structured around modules. These modules encapsulate related functionality, and AppModule is the root module. Specifically, it orchestrates the application’s components, including controllers, services, and other modules.

Within the AppModule, we’ll register the CognitoAuthModule asynchronously, taking advantage of the existing configuration module:

import { CognitoAuthModule } from '@nestjs-cognito/auth';
import { CognitoConfig } from './config/cognito.config';

@Module({
  imports: [
    CognitoAuthModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useClass: CognitoConfig,
    }),
  ],
  providers: [CognitoConfig],
})
export class AppModule {}

This code dynamically configures the CognitoAuthModule using a custom CognitoConfig. It specifies that the ConfigModule is a dependency, and the ConfigService will be injected into the fetch configuration details. The useClass method then leverages CognitoConfig for specific Cognito settings.

Moving on to the bit of code below, dependency injection provides the ConfigService with our application’s configuration context, enabling access to the necessary Cognito parameters. The foundation of our Cognito integration is the CognitoConfig service, as it implements CognitoModuleOptionsFactory from the @nestjs-cognito/core package. Additionally, via ConfigService, the CognitoConfig service accesses application-specific settings defined in ApplicationConfig.

// cognito.config.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
  CognitoModuleOptions,
  CognitoModuleOptionsFactory,
} from '@nestjs-cognito/core';
import { ApplicationConfig } from './app.config';

@Injectable()
export class CognitoConfig implements CognitoModuleOptionsFactory {
  constructor(
    private readonly configService: ConfigService<ApplicationConfig>,
  ) {}
}

Next up from there, the core functionality of CognitoConfig is encapsulated in the createCognitoModuleOptions method. It meticulously assembles the needed configuration so that Cognito can work within our application.

By extracting environment-specific values (like accessKeyId, clientId, and userPoolId), we can customize Cognito’s setup to our application’s needs, so we get both the desired functionality and security.

And then, with all necessary details in place, the service finalizes the Cognito module options, focusing on the identity provider credentials and JWT verification settings:

createCognitoModuleOptions(): CognitoModuleOptions {
  return {
    jwtVerifier: {
      clientId: this.configService.get('cognitoClientID'),
      tokenUse: 'id',
      userPoolId: this.configService.get('cognitoUserPoolID'),
    },
  };
}

Understanding the JWT Verifier

An ID token in OAuth 2.0 and OpenID Connect is a JSON Web Token (JWT) that contains claims about the authentication of an end-user by the authorization server.

tokenUse: 'id' is specified to tell the system that ID tokens are being used for user authentication and identity verification.

On the other hand, tokenUse: 'access', denotes access tokens. Access tokens are used to authorize API requests on behalf of a user; they do not contain information about the user’s identity but rather specify permissions granted.

Choosing tokenUse: 'id' over tokenUse: 'access' is essential for scenarios where an application needs to know the identity of the user, for example, to display user-specific data or make decisions based on the user’s role or attributes. Access tokens, being more about granting access to resources rather than conveying user identity information, are less suitable for these purposes.

This concludes configuration of the Cognito module. From here, we’ll see how to use it in an application!

Securing routes with authentication and authorization

To protect your routes against unauthorized access, let’s start with the @Authentication decorator from @nestjs-cognito/auth library. Simply tag it above your controller class and your route is secure. (For a more granular approach, @UseGuards(AuthenticationGuard) allows us to protect specific endpoints.)

import { Authentication } from "@nestjs-cognito/auth";
import { Controller } from "@nestjs/common";

@Controller("secure-route")
@Authentication()
export class SecureController {}

While authentication confirms identity, authorization defines what an authenticated user can do. Here, the @Authorization decorator comes into play, allowing us to specify the user groups which can access your controller or route:

@Controller("admin-area")
@Authorization({
  allowedGroups: [appConfig().cognitoAdminsGroupName],
  requiredGroups: [appConfig().cognitoAdminsGroupName],
})
export class AdminController {}

Beyond that, understanding who is making a request is crucial: the @CognitoUser decorator injects user details directly into your route handlers, offering a straightforward way to access user information without boilerplate code:

@Get()
findUserData(@CognitoUser("email") userEmail: string) {
  console.log(`User email: ${userEmail}`);
}

Testing controllers

For testing purposes we’ll simplify the process of integration using login/password methods as if from one real user. This approach allows for easier testing and debugging, and it gives us a more straightforward authentication mechanism for development cycles (without compromising the integrity of our production security posture):

COGNITO_TEST_CLIENT_ID=demoTestClientIdXYZ789
COGNITO_TEST_USER_PASSWORD=Pass!word123
COGNITO_TEST_USER_EMAIL=demo@example.com
COGNITO_TEST_MAIN_REGION=us-mock-2
COGNITO_TEST_USER_POOL_ID=us-mock-2_jKlMnOpQr

Let’s take a look at these items a little more closely:

  • COGNITO_TEST_CLIENT_ID (demoTestClientIdXYZ789): similar to COGNITO_CLIENT_ID, but for the test environment
  • COGNITO_TEST_USER_POOL_ID (us-mock-2_jKlMnOpQr): represents the user pool used for testing
  • COGNITO_TEST_USER_EMAIL (demo@example.com) and COGNITO_TEST_USER_PASSWORD (Pass!word123): these are credentials for a test user account
  • COGNITO_TEST_MAIN_REGION (us-mock-2): Tte AWS region for the test Cognito user pool

We utilize AWS Cognito for user authentication to make sure we have secure access in place.

This step involves setting up the CognitoTestingModule, which mocks the authentication process for testing purposes:

import { CognitoTestingModule } from '@nestjs-cognito/testing';

We begin by mocking the authentication process to simulate user login via AWS Cognito:

describe('AppController (e2e)', () => {
  let app: INestApplication;
  let config: ConfigService<ApplicationConfig>;
  let token: string;
  let apiKeysRepository: ApiKeysRepository;

  beforeAll(async () => {
    const mockCognitoClient = {
      initiateAuth: async () => {
        return request(app.getHttpServer()).post('/cognito-testing-login').send({
          clientId: config.get('cognitoTestClientId'),
          password: config.get('cognitoTestUserPassword'),
          username: config.get('cognitoTestUserEmail'),
        });
      },
    };
  });
})

With our mock authentication client ready, let’s move on and configure the testing environment. This involves setting up CognitoTestingModule with the specific configurations for our AWS Cognito instance. This includes the regions and client IDs, and makes sure our tests communicate with AWS Cognito as they would in a live environment:

const moduleRef = await Test.createTestingModule({
  imports: [
    CognitoTestingModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (
        configService: ConfigService<ApplicationConfig>,
      ) => {
        return {
          identityProvider: {
            region: configService.get('cognitoTestMainRegion'),
          },
          jwtVerifier: {
            clientId: configService.get('cognitoTestClientId'),
            tokenUse: 'id',
            userPoolId: configService.get(
              'cognitoTestUserPoolId',
            ),
          },
        };
      },
    }),
    AppModule,
  ],
}).compile();

With our environment configured, it’s time to initiate our NestJS application within the testing framework. This boots up our application with the mock Cognito client so we can test our system under simulated authentication scenarios:

app = moduleRef.createNestApplication();
config = moduleRef.get<ConfigService>(ConfigService);

await app.init();

const authenticationResult = await mockCognitoClient.initiateAuth();
token = authenticationResult?.body.IdToken;

The first scenario tests the process for authenticated users, and by sending a POST request with a valid authentication token, we can simulate an authorized user’s attempt to post some data:

it('/POST create with token should return data', () => {
  return request(app.getHttpServer())
    .post('/controller-route')
    .set({ Authorization: `Bearer ${token}` })
    .expect(201)
});

This test verifies that whenever an authenticated request is made, our system actually processes it.

Then, conversely, let’s also simulate an unauthenticated user’s attempt to post some data by leaving out the request’s authentication token. This test ensures our system can effectively defend against unauthorized access:

it('/POST create without token should return 403', () => {
  return request(app.getHttpServer())
    .post('/controller-route')
    .expect(403);
});

Security first!

Finally, as we wrap up our testing procedures, let’s go ahead and be sure we’ll have a clean, stable testing environment for future tests; that means making sure our application is properly shut down:

afterAll(async () => {
  await app.close();
});

**

That concludes our tale of bringing NestJS and AWS Cognito together. Hopefully this sheds some light on the nuances of authentication and authorization when working with them! Consider this beast tamed.

Schedule call

Irina Nazarova CEO at Evil Martians

Jump start product development, ship new integrations and periphery, or accelerate delivery with our JS/TS experts.