In this article, I would like to introduce about Amazon CDK and how to write AWS infrastructure-as-code by TypeScript. Let's go step by step.
What is AWS CDK (Cloud Development Kit)?
The AWS Cloud Development Kit is an open-source software development framework developed by Amazon Web Services for defining and provisioning cloud infrastructure resources using familiar programming languages.
Setup
Step 1: Open command/terminal or PowerShell window.
Step 2: Verify node.js is installed or not. (If not: please access into link to download and then setup.)
npm --version
Step 3: Ensure AWS CLI is installed.
aws --version
Step 4: Install AWS CDK CLI
npm install -g aws-cdk
Step 5: Check AWS CDK is installed or not.
cdk
Step 6: Create a CDK project with project name is agapifa
mkdir agapifa
cd agapifa
Step 7: Create package.json file
{
"name": "agapifa",
"version": "1.0.0",
"description": "",
"scripts": {
"build": "npx tsc",
"start": "npm run build -w",
"cdk": "cdk",
"diff": "npx aws-cdk diff",
"deploy": "npx aws-cdk deploy",
"destroy": "npx aws-cdk destroy",
"lint": "eslint .",
"lint:fix": "eslint --fix",
"format": "prettier --write './**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.0.0",
"@types/node": "^18.13.0",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"aws-cdk": "^2.73.0",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
"eslint-config-standard-with-typescript": "^34.0.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^4.2.1",
"prettier": "^2.8.4",
"ts-node": "^10.9.1",
"typescript": "^4.9.5"
},
"dependencies": {
"aws-cdk-lib": "^2.73.0",
"constructs": "^10.1.306",
"eslint-plugin-n": "^15.6.1",
"eslint-plugin-promise": "^6.1.1",
"source-map-support": "^0.5.21"
}
}
Step 8: Create tsconfig.json
{
"compilerOptions": {
"noImplicitAny": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": "./",
"rootDir": "./",
"outDir": "./dist",
"strictPropertyInitialization": false,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "**/node_modules/*", "cdk.out"]
}
Step 9: Create cdk.json file
{
"app": "npx ts-node --prefer-ts-exts lib/index.ts",
"context": {
"@aws-cdk/core:newStyleStackSynthesis": "true",
"@aws-cdk/core:bootstrapQualifier": "hnb659fds"
}
}
Step 10: Copy code below into lib/index.ts file
#!/usr/bin/env node
import { App } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import apiStack from './stacks/api-stack';
class AgapifaApp extends Construct {
constructor(scope: App, id: string) {
super(scope, id);
new AuthStack(this, `${id}-auth-stack`, {
stackName: `${id}-auth-stack`,
});
}
}
const app = new App();
new AgapifaApp(app, 'Agapifa');
Step 11: Create lib/stacks/auth-stack.ts file
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { CognitoResource } from '../resources';
export class AuthStack extends Stack {
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
this.createCognito(this, id);
}
private createCognito(stack: Stack, id: string) {
new CognitoResource(stack, `${id}-cognito`, {})
.setupUserPool()
.setupAppClient()
.setupDomain()
.build();
}
}
Step 12: Setup Cognito User Pool like code below into lib/resources/cognito/index.ts file
import { Duration, Stack, StackProps, aws_cognito as cognito, aws_iam as iam } from 'aws-cdk-lib';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import path from 'path';
import { BaseResource } from '../base';
export class CognitoResource extends BaseResource {
private _cognitoUserPool: cognito.UserPool;
constructor(scope: Stack, id: string, props: StackProps) {
super(scope, id, props);
}
setupUserPool(name?: string) {
this._cognitoUserPool = new cognito.UserPool(
this._scope,
`${this._scopeId}-${name ?? 'user-pool'}`,
{
userPoolName: `${this._scopeId}-${name ?? 'user-pool'}`,
passwordPolicy: {
minLength: 8,
tempPasswordValidity: Duration.days(7),
requireDigits: false,
requireLowercase: false,
requireSymbols: false,
requireUppercase: false,
},
mfa: cognito.Mfa.REQUIRED,
mfaSecondFactor: {
otp: true,
sms: true,
},
accountRecovery: cognito.AccountRecovery.PHONE_AND_EMAIL,
autoVerify: {
phone: false,
email: true,
},
selfSignUpEnabled: true,
standardAttributes: {
phoneNumber: {
required: true,
},
},
signInAliases: {
preferredUsername: true,
email: true,
phone: true,
username: true,
},
signInCaseSensitive: false,
snsRegion: process.env.AWS_REGION,
lambdaTriggers: {
createAuthChallenge: this.createNodeJsFn(
'createAuthChallengeFn',
'create-auth-challenge',
new iam.Policy(this._scope, `${this._scopeId}-create-auth-challenge-sns-policy`, {
statements: [
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['SNS:Publish'],
resources: ['*'],
}),
],
}),
),
defineAuthChallenge: this.createNodeJsFn(
'defineAuthChallengeFn',
'define-auth-challenge',
),
preSignUp: this.createNodeJsFn('preSignUpFn', 'pre-sign-up'),
verifyAuthChallengeResponse: this.createNodeJsFn(
'verifyAuthChallengeResponseFn',
'verify-auth-challenge-response',
),
},
},
);
return this;
}
setupAppClient() {
this._cognitoUserPool.addClient(`${this._scopeId}-user-pool-app-client`, {
userPoolClientName: `${this._scopeId}-user-pool-app-client`,
authFlows: {
custom: true,
userPassword: true,
userSrp: true,
adminUserPassword: false,
},
refreshTokenValidity: Duration.days(
parseInt(process.env.REFRESH_TOKEN_DURATION_DAYS || '365', 10),
),
idTokenValidity: Duration.days(parseInt(process.env.ID_TOKEN_DURATION_DAYS || '1', 10)),
accessTokenValidity: Duration.days(
parseInt(process.env.ACCESS_TOKEN_DURATION_DAYS || '1', 10),
),
enableTokenRevocation: true,
preventUserExistenceErrors: true,
});
return this;
}
setupDomain() {
if (!process.env.COGNITO_DOMAIN_PREFIX) {
return this;
}
this._cognitoUserPool.addDomain(`${this._scope}-user-pool-domain`, {
cognitoDomain: {
domainPrefix: process.env.COGNITO_DOMAIN_PREFIX,
},
});
return this;
}
build() {
return this._cognitoUserPool;
}
private createNodeJsFn(name: string, id: string, role?: iam.Policy) {
const fn = new NodejsFunction(this._scope, name, {
functionName: `${this._scopeId}-${id}`,
runtime: Runtime.NODEJS_14_X,
entry: path.join(__dirname, `lambda-function/${id}/index.ts`),
});
if (role) fn.role?.attachInlinePolicy(role);
return fn;
}
}
Step 13: Create lambda functions
- create-auth-challenge/index.ts
import { CreateAuthChallengeTriggerEvent } from 'aws-lambda';
import AWS from 'aws-sdk';
function sendSMS(phone: string, message: string) {
const params: AWS.SNS.PublishInput = {
Message: message,
PhoneNumber: phone,
};
return new AWS.SNS({ apiVersion: '2010-03-31' }).publish(params).promise();
}
export const handler = async (event: CreateAuthChallengeTriggerEvent) => {
try {
const evtReq = event.request;
const evtReqSession = evtReq.session;
const phoneNumber = event.request.userAttributes.phone_number;
const otp = this.generateOtp();
if (!evtReqSession || evtReqSession.length === 0) {
const message = `OTP to login to WebsiteX is ${otp}`;
await sendSMS(phoneNumber, message);
event.response.privateChallengeParameters = {
answer: otp,
};
event.response.challengeMetadata = 'CUSTOM_CHALLENGE';
}
return event;
} catch (error) {
Promise.reject(error);
}
};
- define-auth-challenge/index.ts
import { DefineAuthChallengeTriggerEvent } from 'aws-lambda';
export const handler = async (event: DefineAuthChallengeTriggerEvent) => {
const evtReq = event.request;
const evtReqSession = evtReq.session;
// User is not registered
if (evtReq.userNotFound) {
event.response.issueTokens = false;
event.response.failAuthentication = true;
throw new Error('User does not exist', {
cause: evtReq,
});
}
// wrong OTP even After 3 sessions
if (evtReqSession.length >= 3 && evtReqSession.slice(-1)[0].challengeResult === false) {
event.response.issueTokens = false;
event.response.failAuthentication = true;
throw new Error('Invalid OTP');
}
// Correct OTP!
else if (evtReqSession.length > 0 && evtReqSession.slice(-1)[0].challengeResult === true) {
event.response.issueTokens = true;
event.response.failAuthentication = false;
}
// not yet received correct OTP
else {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = 'CUSTOM_CHALLENGE';
}
return event;
};
- pre-sign-up/index.ts
export const handler = (event, _, callback) => {
// Confirm the user
event.response.autoConfirmUser = true;
// Set the email as verified if it is in the request
if (event.request.userAttributes.hasOwnProperty('email')) {
event.response.autoVerifyEmail = true;
}
// Set the phone number as verified if it is in the request
if (event.request.userAttributes.hasOwnProperty('phone_number')) {
event.response.autoVerifyPhone = true;
}
// Return to Amazon Cognito
callback(null, event);
};
- verify-auth-challenge-response/index.ts
import { VerifyAuthChallengeResponseTriggerEvent } from 'aws-lambda';
export const handler = async (event: VerifyAuthChallengeResponseTriggerEvent) => {
if (event.request.privateChallengeParameters.answer === event.request.challengeAnswer) {
event.response.answerCorrect = true;
} else {
event.response.answerCorrect = false;
}
return event;
};
Step 14: Build AWS CDK
yarn build
Step 15: Deploy AWS CDK
cdk deploy --profile agapifa
Step 16: Check resources on AWS Console
- CloudFormation
- Lambda functions
- Cognito
Step 17: If you want to remove this stack. You could run command below
cdk destroy --profile agapifa
Summary
In this tutorial, you learned how to install the AWS CDK, set up and initialize an AWS CDK project, assemble it into a CloudFormation template, and deploy to AWS Cloud.
Good luck to your practice !!!