DEV Community

Cover image for Building a Secure Serverless Angular App with AWS CDK, Cognito, Lambda, and API Gateway
Kevin Lactio Kemta
Kevin Lactio Kemta

Posted on

Building a Secure Serverless Angular App with AWS CDK, Cognito, Lambda, and API Gateway

Day 009 - 100DaysAWSIaCDevopsChallenge

Recently, on day 008 of my 100 Days of Code challenge, I created an infrastructure to deploy a REST API behind an API Gateway and an Angular app to consume the exposed APIs. Today, I am going to secure some of those APIs with AWS Cognito. To access these APIs, the user will need to provide an ACCESS or ID token in the header of every requests made.

To achieve this, we will follow the steps below:

  • Set Up AWS Cognito User Pool: Create an User Pool to handler users, and then create the App Client for authentication needs.
  • Create Lambda functions for authentication process: Develop the necessary Lambda functions to support the authentication process.
  • Create Api Gateway Authorizer: Set up an API Gateway authorizer of type Cognito.
  • Update the Rest Api Methods: Secure the methods that need protection by applying the Cognito authorizer.
  • Updated the Angular App with authentication proccess: Implement the authentication process within the Angular app.
  • Re-deploy the app as S3 static website
Prerequises
  • AWS Cloud Developement Kit (CDK)
  • Cognito, Secrets Manager, Lambda, API Gateway, IAM
  • Typescript, Angular, esbuild

Infrastructure Diagram

Image Diagram

Set Up AWS Cognito User Pool

I chose to create my own CDK construct to achieve this. In my construct, I first create the User Pool and then associate an App Client with it. Before these two resources are created, I will store the raw app client secret in an AWS Secrets Manager resource for added security, and I will retrieve it directly inside the Lambda by key.

Cogniton Construct
import { Construct } from 'constructs'
import { aws_cognito as cognito, aws_secretsmanager as sm, Duration, RemovalPolicy, StackProps } from 'aws-cdk-lib'

interface CognitoProps extends StackProps {
  verificationEmailUrl: string,
  fromEmail: string
}

export class Cognito extends Construct {
  private readonly _defaultAppClientId: string
  private readonly _secretValueArn: string
  private readonly _userPoolArn: string
  private readonly _userPoolId: string
  private readonly _userPool: cognito.IUserPool
  constructor(scope: Construct, id: string, props: CognitoProps) {
    super(scope, id)
    const stdRequired: cognito.StandardAttribute = {
      required: true
    }
    const userPool = new cognito.UserPool(this, 'CognitoUserPoolResource', {
      removalPolicy: RemovalPolicy.DESTROY,
      accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
      userPoolName: 'awschallenge-userpool-2',
      selfSignUpEnabled: true,
      userInvitation: {
        emailSubject: 'An account has been created for you',
        emailBody: 'Your Cognito account has been created by an admin. Here is your username: {username} and your temporary password: {####}'
      },
      signInCaseSensitive: false,
      signInAliases: {
        email: true,
        username: true
      },autoVerify: {
        email: true,
        phone: true
      },keepOriginal: {
        email: true
      },standardAttributes: {
        email: { ...stdRequired, mutable: true }
      },
      customAttributes: {
        'domain': new cognito.StringAttribute(),
        'created_at': new cognito.StringAttribute(),
        'last_updated_at': new cognito.StringAttribute(),
        'verified_at': new cognito.StringAttribute(),
        'first_name': new cognito.StringAttribute(),
        'last_name': new cognito.StringAttribute()
      },
      passwordPolicy: {
        tempPasswordValidity: Duration.days(14),
        minLength: 6,
        requireDigits: true,
        requireUppercase: true,
        requireLowercase: true,
        requireSymbols: true
      },
      email: cognito.UserPoolEmail.withCognito(props.fromEmail)
    })
    userPool.addTrigger(cognito.UserPoolOperation.CUSTOM_MESSAGE, this.customEmailMessageLambda())
    const userPoolClient = userPool.addClient('todo-app-client', {
      authFlows: {
        userPassword: true,
        adminUserPassword: true,
        custom: true,
        userSrp: true
      },
      disableOAuth: true,
      preventUserExistenceErrors: true,
      generateSecret: true,
      userPoolClientName: 'Todo-App-Client',
      accessTokenValidity: Duration.minutes(30),
      idTokenValidity: Duration.minutes(30),
      refreshTokenValidity: Duration.days(30)
    })
    const clientSecret = new sm.Secret(this, 'SecretManagerResource', {
      secretName: `${userPool.userPoolId}/${userPoolClient.userPoolClientId}`,
      generateSecretString: {
        secretStringTemplate: JSON.stringify({
          appClientSecret: userPoolClient.userPoolClientSecret.unsafeUnwrap()
        }),
        generateStringKey: `${userPoolClient.userPoolClientName}-AppSecret`
      }
    })
    this._defaultAppClientId = userPoolClient.userPoolClientId
    this._secretValueArn = clientSecret.secretArn
    this._userPoolArn = userPool.userPoolArn
    this._userPoolId = userPool.userPoolId
    this._userPool = userPool
  }
  get defaultAppClientId() {
    return this._defaultAppClientId
  }
  get secretValueArn() {
    return this._secretValueArn
  }
  get userPoolArn() {
    return this._userPoolArn
  }
  get userPoolId() {
    return this._userPoolId
  }
  get userPool() {
    return this._userPool
  }
  customEmailMessageLambda = () => {
    return new LambdaFunction(this, 'CustomVerificationMessage', {
        functionName: 'CustomMessageFunction',
        entryFunction: './apps/functions/auth/custom-confirm-message.function.ts',
        env: {
            CONFIRM_URL: this.props.verificationEmailUrl
        },
        logged: true
        }).resource
    }
}
Enter fullscreen mode Exit fullscreen mode
The Lambda construct
import * as cdk from 'constructs'
import { Construct } from 'constructs'
import { Aws, aws_apigateway, aws_iam as iam, aws_lambda as lambda, Duration, StackProps } from 'aws-cdk-lib'
import { NodejsFunction, SourceMapMode } from 'aws-cdk-lib/aws-lambda-nodejs'
import { generateResourceID } from './utils'
interface Props {
  entryFunction: string;
  functionName: string;
  env?: Record<string, string>;
  layersArns?: string[];
  externalDeps?: string[];
  logged?: boolean;
  memory?: number;
  role?: {
    name?: string,
    inlinePolicies?: Record<string, iam.PolicyDocument>
  };
}

export class LambdaFunction extends cdk.Construct {
  private readonly _functionArn: string
  private readonly _resource: NodejsFunction
  constructor(scope: Construct, id: string, private param: Props) {
    super(scope, id)
    const policies: Record<string, iam.PolicyDocument> = param.role?.inlinePolicies ?? {}
    if (param.logged) {
      policies['logging'] = new iam.PolicyDocument({
        assignSids: true,
        statements: [
          new iam.PolicyStatement({
            actions: [
              'logs:CreateLogGroup',
              'logs:PutLogEvents',
              'logs:CreateLogStream'
            ],
            effect: iam.Effect.ALLOW,
            resources: ['*']
          })
        ]
      })
    }
    const lamnbdaRole = this.createLambdaRole(
      param.role?.name ?? `Role${generateResourceID()}`,
      param,
      policies
    )

    const func = new NodejsFunction(this, `Lambda${generateResourceID()}Resource`, {
      entry: param.entryFunction,
      handler: 'index.handler',
      timeout: Duration.seconds(10),
      functionName: param.functionName,
      environment: param.env,
      runtime: lambda.Runtime.NODEJS_20_X,
      memorySize: param.memory ?? 128,
      role: lamnbdaRole,
      bundling: {
        externalModules: [
          ...(param.externalDeps || []),
          'utils'
        ],
        sourceMap: true,
        sourceMapMode: SourceMapMode.BOTH
      },
      layers: [
        ...((param.layersArns || [])
          .filter(value => !!value && value.length)
          .map(value => lambda.LayerVersion.fromLayerVersionArn(this, `LayerVersion${generateResourceID()}Resource`, value)))
      ]
    })

    this._functionArn = func.functionArn
    this._resource = func
  }

  get functionArn() {
    return this._functionArn
  }

  get resource() {
    return this._resource
  }

  grantApi = (restApi: aws_apigateway.RestApi) => {
    this._resource.addPermission(`TodoAppLambdaPermissionResource-${randomUUID()}`, {
      action: 'lambda:InvokeFunction',
      principal: new iam.ServicePrincipal('apigateway.amazonaws.com'),
      sourceAccount: restApi.env?.account,
      sourceArn: `arn:${Aws.PARTITION}:execute-api:${restApi.env?.region}:${restApi.env?.account}:${restApi.restApiId}/*/*/*`
    })
  }

  private createLambdaRole(roleName: string, props: StackProps, inlinePolicies?: Record<string, iam.PolicyDocument>): iam.Role {
    return new iam.Role(this, `${roleName}Resource`, {
      roleName: roleName,
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com', {
        region: props.env?.region
      }),
      path: '/',
      inlinePolicies: {
        ...inlinePolicies
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

The important things here to know is:

  • accountRecovery Specifies the method for users to recover their account if they lose their password. In our case, it's set to email only, so the recovery link will be sent to the user via email provided by him.
  • selfSignUpEnabled Give the habillity to the users (unknown user) to use the Sign-Up functionality to create their accounts.
  • userInvitation The message template (subject and body) sent to the user when their account is created by an admin. In the emailBody, {username} and {####} will be replaced by the user's username and the verification code, respectively.

  • signInAliases A list of methods by which a user can register or sign in to a user pool. It allows either username with aliases or sign-in with email, phone, or both. In our case, the user can sign in using either their email or username.

  • customAttributes Additional attributes for the user. Note that each attribute here will be represented in the Cognito schema as custom:<ATTR_NAME>, so there's no need to prefix these attributes with custom.

  • email The email settings for the User Pool. There are two options: Cognito or SES. Since we chose the Cognito option, we provide the no-reply address (sender name )

In the code above, we added a Lambda Trigger of type CUSTOM_MESSAGE to customize the email content for a more appealing appearance. This trigger activates when the verification code email is sent to the user after sign-up.

userPool.addTrigger(
    cognito.UserPoolOperation.CUSTOM_MESSAGE,
    this.customEmailMessageLambda()
)
Enter fullscreen mode Exit fullscreen mode

The rendering will be hardcoded in the provided Lambda and will look like this.

Image email body preview

You can find the entire source code for the function here custom-confirm-message.function.tsโ†—

Now let's talk about the app client. The important points to note are:

  • disableOAuth set to false to desable the OAuth interaction.
  • generateSecret set to true to generate the app client secret.
  • authFlows Specifies the set of OAuth authentication flows to enable on the client

Note that for security purposes, it is recommended to retrieve the secret at Lambda runtime logic to avoid exposing the secret in the CloudFormation template. For this, pass the secret ARN as an environment variable to the lambda function instead of passing the secret value directly.

As you can see, we also store the app client secret using AWS Secrets Manager with the following snippet of code

const clientSecret = new sm.Secret(this, 'SecretManagerResource', {
      secretName: `${userPool.userPoolId}/${userPoolClient.userPoolClientId}`,
      generateSecretString: {
        secretStringTemplate: JSON.stringify({
          appClientSecret: userPoolClient.userPoolClientSecret.unsafeUnwrap()
        }),
        generateStringKey: `${userPoolClient.userPoolClientName}-AppSecret`
      }
    })
Enter fullscreen mode Exit fullscreen mode

Create Lambda functions for authentication process

In this section, I will create three (03) Lambda functions for Sign-In, Sign-Up, and Confirm Email.

Lambda function for user Sign-Up

apps/functions/auth/register.function.ts

import { APIGatewayProxyEvent, Context, Handler } from 'aws-lambda'
import { LambdaResponse } from '../../src/infra/dto/lambda.response'
import {
  CognitoIdentityProviderClient,
  SignUpCommand,
  SignUpCommandOutput
} from '@aws-sdk/client-cognito-identity-provider'
import moment from 'moment'
import { computeSecretHash, DATETIME_FORMAT, responseError, responseOk, toPayload } from 'utils'
import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager'
import { SecretsManagerRepository } from '../../src/infra/storage/secrets/secrets-manager.repository'

const client = new CognitoIdentityProviderClient({})
const smClient = new SecretsManagerClient({
  region: process.env.REGION || 'us-east-1'
})
const CLIENT_ID = process.env.APP_CLIENT_ID
const SECRET_VALUE_ARN = process.env.SECRET_VALUE_ARN

export const handler: Handler = async (event: APIGatewayProxyEvent, context: Context) => {
  const payload = toPayload(event)
  let responseHandler: LambdaResponse
  if (!validatePayload(payload)) {
    responseHandler = responseError('some Input(s) are invalid', 403)
  } else {
    const attributes = [{
      Name: 'email',
      Value: payload.email
    }, {
      Name: 'custom:domain',
      Value: 'nivekaa.com'
    }, {
      Name: 'custom:first_name',
      Value: payload.firstName
    }, {
      Name: 'family_name',
      Value: payload.lastName
    }, {
      Name: 'given_name',
      Value: payload.firstName
    }, {
      Name: 'custom:last_name',
      Value: payload.lastName
    }, {
      Name: 'custom:created_at',
      Value: moment().format(DATETIME_FORMAT)
    }, {
      Name: 'custom:last_updated_at',
      Value: moment().format(DATETIME_FORMAT)
    }, {
      Name: 'name',
      Value: (!!payload.lastName || !!payload.firstName) ? `${payload.firstName} ${payload.lastName}`.trim() : null
    }]
    let messageError = null
    try {
      const clientSecret = await new SecretsManagerRepository(smClient)
        .getCognitoAppClientSecretValue(SECRET_VALUE_ARN!)
      const command = new SignUpCommand({
        ClientId: CLIENT_ID,
        Username: payload.username,
        Password: payload.password,
        SecretHash: computeSecretHash(CLIENT_ID, clientSecret, payload.username),
        UserAttributes: attributes,
        UserContextData: {
          IpAddress: event.requestContext?.identity?.sourceIp
        }
      })
      const response: SignUpCommandOutput = await client.send(command)
      if (response.$metadata.httpStatusCode !== 200) {
        messageError = `Something wrong: StatusCode=${response.$metadata.httpStatusCode}`
      }
    } catch (e) {
      console.error(e)
      messageError = e.message
    }
    if (messageError !== null) {
      return responseError(messageError)
    } else {
      return responseOk({}, 'User successfully created')
    }
  }
  return responseHandler
}
const validatePayload = (body: any): boolean => {
  const email = body.email
  const password = body.password
  const username = body.username
  const areNotNull = [email, password, username].every(value => !!value)
  const isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
  const isValidPassword = /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*\W)(?!.* ).{8,16}$/.test(password)
  return areNotNull && isEmailValid && isValidPassword
}
Enter fullscreen mode Exit fullscreen mode

Considering that the SECRET_VALUE_ARN and APP_CLIENT_ID were passed to the Lambda resource during the infrastructure construction, remember that in the previous section, I stored the user pool app client secret in Secrets Manager. Below is the code to retrieve it:

// app/src/infra/storage/secrets/secrets-manager.repository.ts
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'
export class SecretsManagerRepository {
  constructor(private client: SecretsManagerClient) {
  }
  getCognitoAppClientSecretValue = async (secretArn: string): Promise<string> => {
    const command = new GetSecretValueCommand({
      SecretId: secretArn
    })
    try {
      const response = await this.client.send(command)
      if (response.$metadata.httpStatusCode === 200) {
        const json = JSON.parse(response.SecretString!)
        return json.appClientSecret + ''
      }
    } catch (e) {
      console.error(e)
    }
    throw new Error('Technical error. Please Contact the admin or try again later')
  }
}
Enter fullscreen mode Exit fullscreen mode

And below is the content of function to compute the SecretHash parameter

import { createHmac } from 'node:crypto'
....
export const computeSecretHash = (clientId: string, clientSecret: string, username: string): string => {
  const hasher = createHmac('sha256', clientSecret + '')
  hasher.update(`${username}${clientId}`)
  return hasher.digest('base64')
}
Enter fullscreen mode Exit fullscreen mode

For more information about the client-secret hash value see aws docsโ†—

Lambda function for user Sign-In

apps/functions/auth/login.function.ts

import { APIGatewayProxyEvent, Context, Handler } from 'aws-lambda'
import { computeSecretHash, responseError, toPayload } from 'utils'
import {
  AuthFlowType,
  CognitoIdentityProviderClient,
  InitiateAuthCommand
} from '@aws-sdk/client-cognito-identity-provider'
import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager'
import { SecretsManagerRepository } from '../../src/infra/storage/secrets/secrets-manager.repository'
const client = new CognitoIdentityProviderClient({
  region: process.env.REGION || 'us-east-1'
})
const smClient = new SecretsManagerClient({
  region: process.env.REGION || 'us-east-1'
})
const CLIENT_ID = process.env.APP_CLIENT_ID
const SECRET_VALUE_ARN = process.env.SECRET_VALUE_ARN

export const handler: Handler = async (event: APIGatewayProxyEvent, context: Context) => {
  const payload = toPayload(event)
  if (!validatePayload(payload)) {
    return responseError('Invalid input(s).', 403)
  }
  try {
    const clientSecret = await new SecretsManagerRepository(smClient)
      .getCognitoAppClientSecretValue(SECRET_VALUE_ARN!)
    const command = new InitiateAuthCommand({
      ClientId: CLIENT_ID,
      AuthFlow: AuthFlowType.USER_PASSWORD_AUTH,
      AuthParameters: {
        SECRET_HASH: computeSecretHash(CLIENT_ID!, clientSecret, payload.username),
        USERNAME: payload.username,
        PASSWORD: payload.password
      },
      UserContextData: {
        IpAddress: event.requestContext?.identity?.sourceIp
      }
    })
    const response = await client.send(command)
    if (response.$metadata.httpStatusCode !== 200) {
      return responseError(`Something wrong!! Status=${response.$metadata.httpStatusCode}`)
    }
    return {
      isBase64Encoded: false,
      statusCode: 200,
      headers: {
        Authorization: `Bearer ${response.AuthenticationResult?.IdToken}`
      },
      body: JSON.stringify({
        message: 'user logged!!',
        idToken: response.AuthenticationResult?.IdToken,
        accessToken: response.AuthenticationResult?.AccessToken,
        tokenType: response.AuthenticationResult?.TokenType,
        expiredIn: response.AuthenticationResult?.ExpiresIn,
        refreshToken: response.AuthenticationResult?.RefreshToken
      })
    }
  } catch (e) {
    console.error(e)
    return responseError(e.message)
  }
}

const validatePayload = (payload: any): boolean => {
  return (payload.hasOwnProperty('username') && payload.hasOwnProperty('password'))
    && [payload.username, payload.password].every(value => !!value)
}
Enter fullscreen mode Exit fullscreen mode

This function initiates the authentication process by generating the Id and Access tokens that will be used by external systems to access API resources.

Lambda function for Confirm Email

This function will be called when the user clicks on theActivate Account button inside the verification email. We need the code (generated by Cognito during the sign-up process) and username for this process.

import { APIGatewayProxyEvent, Context, Handler } from 'aws-lambda'
import { CognitoIdentityProviderClient, ConfirmSignUpCommand } from '@aws-sdk/client-cognito-identity-provider'
import { computeSecretHash, responseError, responseOk, toPayload } from 'utils'
import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager'
import { SecretsManagerRepository } from '../../src/infra/storage/secrets/secrets-manager.repository'
const client = new CognitoIdentityProviderClient({})
const smClient = new SecretsManagerClient({
  region: process.env.REGION || 'us-east-1'
})
const CLIENT_ID = process.env.APP_CLIENT_ID
const SECRET_VALUE_ARN = process.env.SECRET_VALUE_ARN
const DOMAIN = process.env.DOMAIN

export const handler: Handler = async (event: APIGatewayProxyEvent, context: Context) => {
  const payload = toPayload(event)
  if (!validatePayload(payload)) {
    return responseError('Invalid input(s)', 403)
  }
  if (payload.hasOwnProperty('email') && payload.email && DOMAIN !== payload.email.split('@')[1]) {
    return responseError('The email is wrong.')
  } else {
    try {
      const clientSecret = await new SecretsManagerRepository(smClient)
        .getCognitoAppClientSecretValue(SECRET_VALUE_ARN!)
      const cmd = new ConfirmSignUpCommand({
        ConfirmationCode: payload.code + '',
        ClientId: CLIENT_ID,
        Username: payload.username,
        SecretHash: computeSecretHash(CLIENT_ID!, clientSecret, payload.username)
      })
      const response = await client.send(cmd)
      console.log(response)
      if (response.$metadata.httpStatusCode === 200) {
        return responseOk('Email confirmed')
      }
      return responseError('Something unexpected wrong!', response.$metadata.httpStatusCode)
    } catch (e) {
      console.error(e)
      return responseError(e.message)
    }
  }
}

const validatePayload = (payload: any): boolean => {
  return (payload.hasOwnProperty('username') && payload.hasOwnProperty('code'))
    && [payload.username, payload.code].every(value => !!value)
}
Enter fullscreen mode Exit fullscreen mode
Add methods for authentication process

Now that all the functions for the authentication process are ready, let's add methods for them to the API Gateway.

stacks/cdk-stack.ts

const cors: api.CorsOptions = {...}
const layerArn = ...
...

const rootResource = restApi.root.addResource('todo-app-api')
const authRoot = restApi.root.addResource('auth')
// Create the authentication endpoints
const loginResource = authRoot.addResource('login', {
    defaultCorsPreflightOptions: cors
})
const registerResource = authRoot.addResource('register', {
    defaultCorsPreflightOptions: cors
})
const confirmResource = authRoot.addResource('confirm-email', {
    defaultCorsPreflightOptions: cors
})
// Add authentication methods with each lambda integration
const cognitoInlinePolicy = {
    cognito: new iam.PolicyDocument({
    assignSids: true,
    statements: [
        new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: [
            'cognito-idp:ConfirmDevice',
            'cognito-idp:ChangePassword',
            'cognito-idp:ConfirmForgotPassword',
            'cognito-idp:ChangePassword',
            'cognito-idp:ConfirmSignUp',
            'cognito-idp:ForgotPassword',
            'cognito-idp:GetUser',
            'cognito-idp:GetUserAttributeVerificationCode',
            'cognito-idp:ListUsers',
            'cognito-idp:SignUp',
            'cognito-idp:UpdateUserAttributes'
        ],
        resources: [
            cognito.userPoolArn
        ]
        })
    ]
    }),
    secret: new iam.PolicyDocument({
    assignSids: true,
    statements: [
        new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['secretsmanager:GetSecretValue'],
        resources: [cognito.secretArn]
        })
    ]
    })
}

const loginFunction = new LambdaFunction(this, 'LoginFunction', {
    functionName: 'Auth-Cognito-Login-NodejsFunction',
    entryFunction: './apps/functions/auth/login.function.ts',
    env: {
        USER_POOL_ID: cognito.userPooId,
        SECRET_VALUE_ARN: cognito.secretArn,
        APP_CLIENT_ID: cognito.clientId,
        REGION: this.props.env?.region!
    },
    logged: true,
    role: { inlinePolicies: cognitoInlinePolicy },
    layersArns: [layerArn],
    externalDeps: Object.keys(dependencies)
})

const registerFunction = new LambdaFunction(this, 'RegistrationFunction', {
    functionName: 'Auth-Cognito-Register-NodejsFunction',
    entryFunction: './apps/functions/auth/register.function.ts',
    env: {
        USER_POOL_ID: cognito.userPooId,
        SECRET_VALUE_ARN: cognito.secretArn,
        APP_CLIENT_ID: cognito.clientId,
        REGION: this.props.env?.region!
    },
    role: {
        name: 'AuthRegistrationRole',
        inlinePolicies: cognitoInlinePolicy
    },
    layersArns: [layerArn],
    externalDeps: Object.keys(dependencies)
})

const confirmUserFunction = new LambdaFunction(this, 'ConfirmUserFunction', {
    functionName: 'Auth-Cognito-ConfirmUser-NodejsFunction',
    entryFunction: './apps/functions/auth/confirm-user.function.ts',
    env: {
        USER_POOL_ID: cognito.userPooId,
        SECRET_VALUE_ARN: cognito.secretArn,
        APP_CLIENT_ID: cognito.clientId,
        DOMAIN: this.props.cognito?.domain!,
        REGION: this.props.env?.region!
    },
    logged: true,
    role: {
        name: 'AuthConfirmRole',
        inlinePolicies: cognitoInlinePolicy
    },
    layersArns: [layerArn],
    externalDeps: Object.keys(dependencies)
})

// Allow each lambda to be invoked by API Gateway
Array.of(loginFunction, registerFunction, confirmUserFunction)
      .forEach(func => {
    func.resource.addPermission(`APIGW-Permission-${randomUUID()}`, {
        principal: new iam.ServicePrincipal('apigateway.amazonaws.com'),
        sourceArn: `arn:${Aws.PARTITION}:execute-api:${this.props.env?.region}:${this.props.env?.account}:${restApi.restApiId}/*/*/*`,
        sourceAccount: this.props.env?.account,
        scope: this,
        action: 'lambda:InvokeFunction'
    })
})

const loginMethod = auth.loginResource.addMethod(MediaType.POST, new api.LambdaIntegration(loginFunction.resource, {
    proxy: true
}), {
    authorizationType: api.AuthorizationType.NONE,
    methodResponses
})

const registerMethod = auth.registerResource.addMethod(MediaType.POST, new api.LambdaIntegration(registerFunction.resource, {
    proxy: true
}), {
    authorizationType: api.AuthorizationType.NONE,
    methodResponses
})

const confirmUserMethod = auth.confirmResource.addMethod(MediaType.POST, new api.LambdaIntegration(confirmUserFunction.resource, {
    proxy: true
}), {
    methodResponses,
    authorizationType: api.AuthorizationType.NONE
})

Enter fullscreen mode Exit fullscreen mode

โš ๏ธโš ๏ธ Pay attention to the Lambdas set up above. Since they call Cognito and Secrets Manager, it is necessary to grant them the appropriate permissions to perform their operations without causing issues. Additionally, youโ€™ll notice that API Gateway is granted permission to invoke all of these Lambda functions.

Create Api Gateway Authorizer

Before creating the Cognito Authorizer, it is imperative to instantiate our construct created in the User Pool section, because the authorizer needs its ARN.

const cognitoUserPool = new Cognito(this, 'CustomCognitoResource', {
    verificationEmailUrl: 'https://localhost:4200/auth/confirm-email',
    fromEmail: props.cognito?.verificationFromEmail!
})
Enter fullscreen mode Exit fullscreen mode

Now that the user pool is constructed, we can create the Cognito authorizer and attach it directly to the User Pool we previously created.

const cognitoAuthorizer = new api.CognitoUserPoolsAuthorizer(this, 'AuthorizerResource', {
    authorizerName: 'todo-app-authorizer',
    cognitoUserPools: [cognitoUserPool.userPool],
    identitySource: api.IdentitySource.header('authorization')
})
Enter fullscreen mode Exit fullscreen mode

api.IdentitySource.header('authorization') will return the string 'method.request.header.Authorization'

Update the Rest Api Methods

In my last article (Day 008โ†—), I had created insecure APIs. Now, let's update these resources by securing them with the Cognito authorizer that we previously created.

const getTodoListsMethod = getTodoListsResource.addMethod(MediaType.GET, new api.LambdaIntegration(lambdaFunction.resource, {
    connectionType: ConnectionType.INTERNET,
    proxy: true
}), {
    authorizationType: api.AuthorizationType.COGNITO,
    authorizer: cognitoAuthorizer,
    methodResponses
})

const createTodoListMethod = createTodoListResource.addMethod(MediaType.POST, new api.LambdaIntegration(lambdaFunction.resource, {
    proxy: true,
    connectionType: ConnectionType.INTERNET
}), {
    authorizer: cognitoAuthorizer,
    authorizationType: api.AuthorizationType.COGNITO,
    methodResponses
})
const updateTodoListMethod = updateTodoListResource.addMethod(MediaType.PUT, new api.LambdaIntegration(lambdaFunction.resource, {
    proxy: true,
    connectionType: api.ConnectionType.INTERNET
}), {
    authorizer: cognitoAuthorizer,
    authorizationType: api.AuthorizationType.COGNITO,
    methodResponses
})
const deleteTodoListMethod = deleteTodoListResource.addMethod(MediaType.DELETE, new api.LambdaIntegration(lambdaFunction.resource, {
    connectionType: api.ConnectionType.INTERNET,
    proxy: true
}), {
    authorizer: cognitoAuthorizer,
    authorizationType: api.AuthorizationType.COGNITO,
    methodResponses
})
Enter fullscreen mode Exit fullscreen mode

Now let's test one of these APIs to verify that the security integration is working correctly.

Image description

I received a 401 Unauthorized error, indicating that I need to provide either the Access or ID token.

Image description

I copied the idToken, included it in the header of my request, and tried again. This time, I received:

Image description

Updated the Angular App with authentication proccess

The app integration involves passing the IdToken generated during the sign-in process in the header of each request, formatted as Authorization: Bearer <ID_TOKEN>

export const authorizerInterceptor = (req: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> => {
  const tokenAuth = inject(MEMORY_TOKEN_REPOSITORY)
  const url = req.url
  if (!url.includes('/auth/') || url.endsWith('/auth/details')) {
    if (tokenAuth.hasToken()) {
      const reReq = req.clone({
        setHeaders: {
          Authorization: `Bearer ${tokenAuth.getToken()}`
        }
      })
      return next(reReq)
    }
  }
  return next(req)
}
Enter fullscreen mode Exit fullscreen mode

You can find the complete code for the Angular app hereโ†—

Deploy the entire infrastructure

git clone https://github.com/nivekalara237/100DaysTerraformAWSDevops.git

export STAGE_NAME=dev # needed by api gateway staging

cd 100DaysTerraformAWSDevops/day_009
cdk deploy --profile cdk-user --all
Enter fullscreen mode Exit fullscreen mode

Image description

Re-deploy app as S3 static website

To deploy the Angular app to the S3 bucket as a website, follow the instructions in my previous article ๐Ÿ‘‰๐Ÿฝ๐Ÿ‘‰๐Ÿฝ Deploying a REST API and Angular Frontend Using AWS CDK, S3, and API Gateway
โš ๏ธโš ๏ธ Make sure to update the API Gateway URL in the src/environment.ts file with the correct API Gateway endpoint before building and deploying the application.

export const environment = {
  production: true,
  apiUrl: 'https://{{API_GATEWAY_ID}}.execute-api.us-east-1.amazonaws.com/{{STAGE_NAME}}/'
}
Enter fullscreen mode Exit fullscreen mode

__

๐Ÿฅณโœจ
We have reached the end of the article.
Thank you so much ๐Ÿ™‚


Your can find the full source code on GitHub Repo

Top comments (0)