Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/short-peaches-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"cognito-functions": major
---

Precompile Cognito email templates at build time instead of rendering MJML at runtime. This removes the runtime dependency on `mjml`, switches template minification to `html-minifier-next`, and centralizes the default locale used when a localized template is not available.
2 changes: 2 additions & 0 deletions apps/cognito-functions/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ dist
# TernJS port file
.tern-port

src/templates/generated/

# Stores VSCode versions used for testing VSCode extensions
.vscode-test

Expand Down
109 changes: 109 additions & 0 deletions apps/cognito-functions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# cognito-functions

AWS Lambda handlers used by the DevPortal Cognito User Pool.

This package contains the Cognito trigger functions for sign-up, custom authentication, OTP verification, and post-confirmation emails. It is bundled as a single Lambda artifact and deployed with different handlers wired to different Cognito triggers.

## What this app does

The package exports these handlers from `src/main.ts`:

- `customMessageHandler`: builds Cognito email messages for sign-up confirmation, resend code, forgot password, and email change confirmation.
- `postConfirmationHandler`: sends the welcome email after successful sign-up confirmation.
- `defineAuthChallengeHandler`: defines the custom auth flow sequence after SRP and password verification.
- `createAuthChallengeHandler`: generates the OTP, emails it to the user through SES, and stores the expected verification code in the private challenge parameters.
- `verifyAuthChallengeHandler`: validates the OTP provided by the user.
- `createPreSignUpHandler`: blocks sign-up attempts from email domains that are not explicitly allowed.

## Runtime configuration

The handlers read their configuration from environment variables.

| Variable | Required by | Purpose |
| --- | --- | --- |
| `DOMAIN` | `customMessageHandler`, `postConfirmationHandler`, `createAuthChallengeHandler` | Public DevPortal domain used to build links and image URLs in emails. |
| `FROM_EMAIL_ADDRESS` | `postConfirmationHandler`, `createAuthChallengeHandler` | Sender address used by SES for OTP and welcome emails. |
| `SIGNUP_ALLOWED_EMAIL_DOMAINS` | `createPreSignUpHandler` | JSON array of allowed email domains, for example `["example.com"]`. |

Notes:

- `customMessageHandler` returns HTML and subject back to Cognito. It does not send email directly.
- `postConfirmationHandler` and `createAuthChallengeHandler` send email with AWS SES.
- Locale selection uses `custom:preferred_language` and falls back to `it`.

## Email templates

Email HTML is not rendered from MJML at runtime.

Instead, the package uses a build-time pipeline:

1. MJML source templates live in `src/templates/template-sources.ts`.
2. `npm run generate:templates` compiles MJML to HTML and minifies it.
3. The generated output is written to `src/templates/generated/precompiled-email-templates.ts`.
4. Runtime code loads the precompiled HTML and replaces placeholders with escaped values such as OTP, links, first name, and domain.

This design keeps `mjml` and `html-minifier-next` out of the Lambda runtime dependency graph while preserving the current email authoring model.

If you change email copy or layout, regenerate the templates before building or testing. The package scripts already do this automatically.

## Scripts

Run commands from the workspace root with `-w cognito-functions`, or from this directory directly.

| Command | Purpose |
| --- | --- |
| `npm run generate:templates` | Compile MJML source templates into the generated HTML module. |
| `npm run clean` | Remove `dist`, `out`, and generated templates. |
| `npm run compile` | Generate templates, then run TypeScript compilation. |
| `npm run build` | Generate templates, bundle the Lambda with esbuild, and zip the artifact in `out/cognito-functions.zip`. |
| `npm run test` | Generate templates, compile TypeScript, and run the Jest suite. |
| `npm run lint` | Run ESLint on `src`. |

## Local development

Typical workflow:

1. Update handler or template code.
2. Run `npm run test -w cognito-functions`.
3. Run `npm run build -w cognito-functions`.
4. Use `out/cognito-functions.zip` as the deployment artifact.

If you are only changing email templates, `npm run generate:templates -w cognito-functions` is enough to validate the precompilation step.

## Auth flow summary

The custom authentication flow is:

1. Cognito performs `SRP_A`.
2. `defineAuthChallengeHandler` requests `PASSWORD_VERIFIER`.
3. After successful password verification, `defineAuthChallengeHandler` requests `CUSTOM_CHALLENGE`.
4. `createAuthChallengeHandler` generates a 6-digit OTP and sends it through SES.
5. `verifyAuthChallengeHandler` compares the user answer with the expected OTP.
6. `defineAuthChallengeHandler` issues tokens only when the custom challenge succeeds.

`OTP_DURATION_MINUTES` is currently set to `15` in `src/create-auth-challenge-handler.ts`.

## Testing

The package uses Jest with `ts-jest`. Existing tests cover:

- Custom message email generation
- OTP email creation and subject localization
- Post-confirmation email sending
- Define and verify auth challenge behavior

Run:

```sh
npm test -w cognito-functions
```

## Deployment artifact

The build produces:

```sh
apps/cognito-functions/out/cognito-functions.zip
```

Infrastructure code can then point multiple Cognito triggers at different exported handlers inside the same bundle.
Comment on lines +101 to +109
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there a dedicated workflow?

21 changes: 1 addition & 20 deletions apps/cognito-functions/esbuild.config.mjs
Original file line number Diff line number Diff line change
@@ -1,28 +1,9 @@
/* eslint-disable functional/no-expression-statements */
import * as esbuild from 'esbuild';

/**
* mjml-core has html-minifier as dependency.
* html-minifier has uglify-js as dependency.
* The bundle generated had issues on uglify-js dependency (no module found).
* This is a workaround that returns an empty module for uglify-js node file.
*
* To solve this problem, we took inspiration from https://github.com/mjmlio/mjml/issues/2132#issuecomment-1004713444
*
*/
const emptyUglifyPlugin = {
name: 'empty mjml uglify plugin',
setup(build) {
build.onLoad({ filter: /uglify-js\/tools\/node.js$/ }, () => ({
contents: '{}',
loader: 'js',
}));
},
};

await esbuild.build({
entryPoints: ['src/main.ts'],
bundle: true,
outfile: 'out/main.js',
platform: 'node',
plugins: [emptyUglifyPlugin]
});
3 changes: 3 additions & 0 deletions apps/cognito-functions/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const compat = new FlatCompat({
});

export default defineConfig([
{
ignores: ['src/templates/generated/**'],
},
{
extends: compat.extends('custom'),
},
Expand Down
15 changes: 8 additions & 7 deletions apps/cognito-functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
"version": "1.1.2",
"private": true,
"scripts": {
"clean": "rimraf dist/ out/",
"precompile": "npm run clean",
"clean": "rimraf dist/ out/ src/templates/generated/",
"generate:templates": "ts-node --project tsconfig.scripts.json --transpile-only scripts/generate-email-templates.ts",
"precompile": "npm run clean && npm run generate:templates",
"compile": "tsc",
"prebuild": "npm run generate:templates",
"build": "node esbuild.config.mjs",
"postbuild": "cd out && zip -r cognito-functions.zip .",
"lint": "eslint src",
Expand All @@ -15,14 +17,15 @@
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.34.0",
"@types/aws-lambda": "^8.10.152",
"@types/html-minifier": "^4.0.5",
"@types/jest": "^29.0.0",
"@types/mjml": "^4.7.4",
"@types/node": "^22.18.28",
"esbuild": "^0.25.9",
"eslint": "^9.34.0",
"eslint-config-custom": "*",
"html-minifier-next": "^5.1.7",
"jest": "^29.7.0",
"jest-mock-extended": "^3.0.7",
"mjml": "^4.15.3",
"rimraf": "^6.1.2",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
Expand All @@ -31,8 +34,6 @@
"dependencies": {
"@aws-sdk/client-ses": "^3.441.0",
"fp-ts": "^2.16.11",
"html-minifier": "^4.0.0",
"io-ts": "^2.2.22",
"mjml": "^4.15.3"
"io-ts": "^2.2.22"
}
}
97 changes: 97 additions & 0 deletions apps/cognito-functions/scripts/generate-email-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/* eslint-disable functional/immutable-data */
/* eslint-disable functional/no-loop-statements */
/* eslint-disable functional/no-expression-statements */
/* eslint-disable functional/no-throw-statements */
import fs from 'node:fs';
import path from 'node:path';

import mjml2html from 'mjml';
import { minify } from 'html-minifier-next';

import { SUPPORTED_LOCALES } from '../src/i18n/locales';
import { EMAIL_TEMPLATE_BUILDERS } from '../src/templates/template-sources';

const defaultMinificationOptions = {
caseSensitive: true,
collapseWhitespace: true,
minifyCSS: true,
removeEmptyAttributes: true,
} as const;

const outputFilePath = path.join(
__dirname,
'..',
'src',
'templates',
'generated',
'precompiled-email-templates.ts'
);

type BuildTemplate = (locale: string) => string;

const compileTemplate = async (
buildTemplate: BuildTemplate,
locale: string
): Promise<string> => {
const { errors, html } = mjml2html(buildTemplate(locale), {
validationLevel: 'soft',
});

if (html.length === 0) {
throw new Error(
`Unable to compile ${
buildTemplate.name || 'email template'
} for locale ${locale}`
);
}

if (errors.length > 0) {
console.warn(
`MJML validation warnings for ${
buildTemplate.name || 'email template'
} (${locale}): ${errors.map(({ message }) => message).join(', ')}`
);
}

return minify(html, defaultMinificationOptions);
};

const buildTemplates = async (): Promise<
Record<string, Record<string, string>>
> => {
const precompiledTemplates: Record<string, Record<string, string>> = {};

for (const [templateId, buildTemplate] of Object.entries(
EMAIL_TEMPLATE_BUILDERS
) as ReadonlyArray<readonly [string, BuildTemplate]>) {
precompiledTemplates[templateId] = {};

for (const locale of SUPPORTED_LOCALES) {
precompiledTemplates[templateId][locale] = await compileTemplate(
buildTemplate,
locale
);
}
}

return precompiledTemplates;
};

const writeTemplatesFile = async (): Promise<void> => {
const precompiledTemplates = await buildTemplates();
const outputContents = `/* This file is generated by scripts/generate-email-templates.ts. */
export const PRECOMPILED_EMAIL_TEMPLATES = ${JSON.stringify(
precompiledTemplates,
null,
2
)} as const;
`;

fs.mkdirSync(path.dirname(outputFilePath), { recursive: true });
fs.writeFileSync(outputFilePath, outputContents);
};

writeTemplatesFile().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
18 changes: 10 additions & 8 deletions apps/cognito-functions/src/create-auth-challenge-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as E from 'fp-ts/Either';
import { makeOtpMessageEmail } from './templates/otp-message';
import { SUPPORTED_LOCALES } from './i18n/locales';
import { EMAIL_TRANSLATIONS } from './templates/translations';
import { DEFAULT_LOCALE } from './i18n/locales';

export const generateVerificationCode = (): string =>
Array.from({ length: 6 }, () => crypto.randomInt(0, 9)).join('');
Expand Down Expand Up @@ -65,28 +66,29 @@ export const makeHandler =
event.request.userAttributes['custom:preferred_language'];
const locale = SUPPORTED_LOCALES.includes(localeAttribute)
? localeAttribute
: 'it'; // Defaults to 'it'
: DEFAULT_LOCALE;

// only called once after SRP_A and PASSWORD_VERIFIER challenges. Hence
// session.length == 2
if (session.length === 2) {
const { email } = event.request.userAttributes;
const verificationCode = env.generateVerificationCode();
const emailBody = makeOtpMessageEmail(
verificationCode,
env.config.domain,
OTP_DURATION_MINUTES,
locale
);
const subjectTemplate =
EMAIL_TRANSLATIONS.otp[locale as keyof typeof EMAIL_TRANSLATIONS.otp]
?.subject || EMAIL_TRANSLATIONS.otp.it.subject;
?.subject || EMAIL_TRANSLATIONS.otp[DEFAULT_LOCALE].subject;
const subject = subjectTemplate.replace('{{code}}', verificationCode);
const sendEmailCommand = new SendEmailCommand(
makeSesEmailParameters(
email,
env.config.fromEmailAddress,
subject,
makeOtpMessageEmail(
verificationCode,
env.config.domain,
OTP_DURATION_MINUTES,
locale
)
emailBody
)
);

Expand Down
9 changes: 6 additions & 3 deletions apps/cognito-functions/src/custom-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { EMAIL_TRANSLATIONS } from './templates/translations';

import { sanitize } from './utils/sanitize';
import { SUPPORTED_LOCALES } from './i18n/locales';
import { DEFAULT_LOCALE } from './i18n/locales';

export const CustomMessageEnv = t.type({
domain: t.string,
Expand All @@ -24,7 +25,7 @@ export const makeHandler =
event.request.userAttributes['custom:preferred_language'];
const locale = SUPPORTED_LOCALES.includes(localeAttribute)
? localeAttribute
: 'it'; // Defaults to 'it'
: DEFAULT_LOCALE;

if (
eventTrigger === 'CustomMessage_SignUp' ||
Expand Down Expand Up @@ -65,7 +66,8 @@ export const makeHandler =
const emailSubject =
EMAIL_TRANSLATIONS.confirmationForgotPassword[
locale as keyof typeof EMAIL_TRANSLATIONS.confirmationForgotPassword
]?.subject || EMAIL_TRANSLATIONS.confirmationForgotPassword.it.subject;
]?.subject ||
EMAIL_TRANSLATIONS.confirmationForgotPassword[DEFAULT_LOCALE].subject;
const response = { ...event.response, emailMessage, emailSubject };
return { ...event, response };
} else if (eventTrigger === 'CustomMessage_UpdateUserAttribute') {
Expand All @@ -86,7 +88,8 @@ export const makeHandler =
EMAIL_TRANSLATIONS.confirmationUpdateEmailAddress[
locale as keyof typeof EMAIL_TRANSLATIONS.confirmationUpdateEmailAddress
]?.subject ||
EMAIL_TRANSLATIONS.confirmationUpdateEmailAddress.it.subject;
EMAIL_TRANSLATIONS.confirmationUpdateEmailAddress[DEFAULT_LOCALE]
.subject;
const response = { ...event.response, emailMessage, emailSubject };
return { ...event, response };
} else {
Expand Down
2 changes: 2 additions & 0 deletions apps/cognito-functions/src/i18n/locales.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const DEFAULT_LOCALE = 'it';

export const SUPPORTED_LOCALES: readonly string[] = ['it', 'en'];
Loading
Loading