Published on

AWS Secrets Manager Typescript Lambda for Password Auto Rotation

I wanted to explore AWS Secrets Manager’s Auto Password Rotation Service. Soon, I discovered that it already provides a Lambda function that automatically rotates passwords, but it was written in Python. Since I’m more familiar with Node TypeScript, I decided to convert the code from Python to Node TypeScript.

First here is to Create Secret Manager that holds Database Credentials. I am assuming you already know how to create a new Secret and you already have created it. Incase you do not know, here is my article link that can help you with creating a new Database Credential Secret in AWS Secrets Manager

https://medium.com/@lakshitshah.nmims/create-a-new-aws-secret-manager-for-database-credentials-7a0eb14773a1

The Secret is created successfully, marking the completion of the first milestone of the process.

For the next milestone, we need to create a Lambda function that will handle the password update in AWS Secrets Manager and the database. I have attached the Lambda code at the end of the page, as I wanted to provide a brief explanation first.

The Lambda will be invoked according to the time interval we set. Secrets Manager will automatically invoke the Lambda four times in a row. It consists of four events, each with separate functionality. Each event contributes to the password rotation process.

  1. Create Secret:

    This event is meant to create a Pending State Secret. A Pending State Secret means it won’t be visible on the AWS console, but it still exists, much like a backend task preparing to be revealed to the world. The next step is to generate a new random password. The password can be generated using a custom algorithm or Secrets Manager’s getRandomPassword method. After generating the password, update the secret with the new password and set it to pending state.

  2. Set Secret:

    This is the main event where we fetch the pending state secrets, recover the password from it, and execute a query in the database to alter the user’s password.

  3. Test Secret:

    This event will simply fetch the pending state secret and use it to establish a database connection by executing any query.

  4. Finish Secret

    This event will promote pending state secrets to the current state. After this event, one can see the updated password on the AWS Secrets Manager console.

Here is the final code.

			import {
				SecretsManagerClient,
				GetSecretValueCommand,
				DescribeSecretCommand,
				PutSecretValueCommand,
				DescribeSecretCommandOutput,
				UpdateSecretVersionStageCommand,
				GetRandomPasswordCommand,
			} from '@aws-sdk/client-secrets-manager';
			import { Client as PGClient } from 'pg';

			const client = new SecretsManagerClient({
				region: 'us-east-1',
			});

			interface IDBPasswordRotation {
				SecretId: string;
				ClientRequestToken: string;
				Step: 'createSecret' | 'setSecret' | 'testSecret' | 'finishSecret';
			}

			interface SecretValue {
				host: string;
				username: string;
				password: string;
				port?: number;
				dbname?: string;
				ssl?: boolean | string;
				engine?: string;
			}

			async function getRandomPassword() {
				const command = new GetRandomPasswordCommand({
					IncludeSpace: false,
					PasswordLength: 12,
					ExcludeUppercase: true,
					ExcludePunctuation: true,
					ExcludeCharacters: ':/@"'\',
				});
				const response = await client.send(command);
				return response.RandomPassword;
			}

			async function getPostgresConnection(secret: SecretValue) {
				try {
					const pgClient = new PGClient({
						host: secret.host,
						user: secret.username,
						password: secret.password,
						database: secret.dbname,
						port: secret.port,
						application_name: 'Password_Updates',
					});
					await pgClient.connect();
					return pgClient;
				} catch (error) {
					console.log('Error while connecting to Postgres');
					console.log(error);
					return null;
				}
			}

			async function getSecretValue(event: IDBPasswordRotation, stage: 'AWSCURRENT' | 'AWSPENDING' | 'AWSCURRENT', token?: string): Promise {
				const secretCommand = new GetSecretValueCommand({
					SecretId: event.SecretId,
					VersionStage: stage,
					VersionId: token,
				});
				const response = await client.send(secretCommand);
				return JSON.parse(response.SecretString!);
			}

			async function createSecretFunction(event: IDBPasswordRotation, credentials: SecretValue) {
				const { ClientRequestToken, SecretId } = event;
				try {
					const getCommand = new GetSecretValueCommand({ SecretId, VersionStage: 'AWSPENDING' });
					const pendingResponse = await client.send(getCommand);
				} catch (error) {
					const copy = JSON.parse(JSON.stringify(credentials));
					copy.password = await getRandomPassword();
					const putCommand = new PutSecretValueCommand({
						SecretId,
						ClientRequestToken,
						SecretString: JSON.stringify(copy),
						VersionStages: ['AWSPENDING'],
					});
					await client.send(putCommand);
				}
			}

			async function setSecretFunction(event: IDBPasswordRotation, currentCred: SecretValue) {
				const { ClientRequestToken, SecretId } = event;
				let previosCred: SecretValue | null = null;
				try {
					// it might throw error
					const getPreviusSecretCommand = new GetSecretValueCommand({ SecretId, VersionStage: 'AWSPREVIOUS' });
					const previosSecretResponse = await client.send(getPreviusSecretCommand);
					previosCred = JSON.parse(previosSecretResponse.SecretString!);
				} catch (error) {
					console.log('Error while fetch AWSPREVIOUS value from Secrets, it is expected');
					console.log(error);
				}

				const pendingCred = await getSecretValue(event, 'AWSPENDING', ClientRequestToken);
				const dbConnectionPending = await getPostgresConnection(pendingCred);
				// if the connection is established successfully from pending then its fine
				if (dbConnectionPending) {
					await dbConnectionPending.end();
					console.log('setSecret: AWSPENDING secret is already set as password in PostgreSQL DB');
					return;
				}
				if (currentCred.username !== pendingCred.username) {
					throw new Error(`Attempting to modify user ${ pendingCred.username } other than current user ${ currentCred.username }`);
				}
				if (currentCred.host !== pendingCred.host) {
					throw new Error(`Attempting to modify host ${ pendingCred.host } other than current user ${ currentCred.host } `);
				}

				let dbConnection = await getPostgresConnection(currentCred);
				if (!dbConnection && previosCred) {
					dbConnection = await getPostgresConnection(previosCred);
					if (previosCred.username !== pendingCred.username) {
						throw new Error(`Attempting to modify user ${ pendingCred.username } other than previous valid user ${ previosCred.username } `);
					}
					if (previosCred.host !== pendingCred.host) {
						throw new Error(`Attempting to modify host ${ pendingCred.host } other than previous valid user ${ previosCred.host } `);
					}
				}

				if (!dbConnection) {
					throw new Error('Unable to log into database with previous, current, or pending');
				}

				const response = await dbConnection.query(`ALTER USER ${ pendingCred.username } WITH PASSWORD '${pendingCred.password}'`);
				await dbConnection.end();
			}

			async function testSecretFunction(event: IDBPasswordRotation) {
				const pendingCreds = await getSecretValue(event, 'AWSPENDING', event.ClientRequestToken);
				const pendingConn = await getPostgresConnection(pendingCreds);
				if (pendingConn) {
					// fire any query here and check the response
					const loanData = await pendingConn.query('SELECT * from user LIMIT 1');
					console.log('testSecret: Successfully signed into PostgreSQL DB with AWSPENDING');
					await pendingConn.end();
				} else {
					throw new Error('Unable to log into database with pending secret');
				}
			}

			async function finishSecretFunction(event: IDBPasswordRotation, metadata: DescribeSecretCommandOutput) {
				try {
					let current_version: string | null = null;
					// eslint-disable-next-line no-restricted-syntax
					for (const version of Object.keys(metadata.VersionIdsToStages!)) {
						if (metadata.VersionIdsToStages![version].includes('AWSCURRENT')) {
							if (version === event.ClientRequestToken) {
								console.log(`finishSecret: Version ${ version } already marked as AWSCURRENT`);
								return;
							}
							current_version = version;
							break;
						}
					}
					if (current_version) {
						const updateStageCommand = new UpdateSecretVersionStageCommand({
							SecretId: event.SecretId,
							VersionStage: 'AWSCURRENT',
							MoveToVersionId: event.ClientRequestToken,
							RemoveFromVersionId: current_version,
						});
						const updateStageResponse = await client.send(updateStageCommand);
						console.log('Update Stage Response : ', JSON.stringify(updateStageResponse));
					}
				} catch (error) {
					console.log('Error in Finish Secret');
					console.log(error);
					throw error;
				}
			}

			export const handler = async (event: IDBPasswordRotation) => {
				try {
					const {
						SecretId,
						ClientRequestToken,
						Step,
					} = event;
					const command = new DescribeSecretCommand({ SecretId });
					const metadata = await client.send(command);
					if (!metadata.RotationEnabled) {
						throw new Error(`Secret Rotation is not enabled for Secret ${ metadata.Name }`);
					}
					const allVersions = metadata.VersionIdsToStages;
					if (!Object.keys(allVersions!).includes(ClientRequestToken)) {
						throw new Error('Secret has no such version available');
					}

					if (allVersions![ClientRequestToken].includes('AWSCURRENT')) {
						throw new Error('Secret is already set to current');
					}
					const currentCreds = await getSecretValue(event, 'AWSCURRENT', undefined);
					switch (Step) {
						case 'createSecret':
							await createSecretFunction(event, currentCreds);
							break;
						case 'setSecret':
							await setSecretFunction(event, currentCreds);
							break;
						case 'testSecret':
							await testSecretFunction(event);
							break;
						case 'finishSecret':
							await finishSecretFunction(event, metadata);
							break;
						default:
							throw new Error('Invalid Step found for RDS DB Password Rotation Lambda');
					}
				} catch (error) {
					console.log('Error in Lambda Function');
					console.log(error);
				}
			};
		

The most challenging part of the process is now complete. All that’s left is to attach the newly created Lambda function to our Secret Manager. Here’s how to do it

  1. Go to the AWS Management Console, navigate to Secrets Manager, and choose the secret you want to attach the rotation to.
  2. Click on the “Rotation” tab.
  3. AWS Secrets Manager Rotation Page
  4. The Rotation status will be Disabled. Click on the “Edit Rotation” button at the top right in the rotation section.
  5. AWS Secrets Manager Configure Auto Rotation Page
  6. Turn on the Automatic Rotation Switch.
  7. Fill out the form for the Rotation Schedule. In this case, we have set it for every 12 hours. i.e password will be updated twice a day.
  8. In the Rotation function section, select the radio button for “Use a rotation function from your account” and choose the newly created Lambda function. Initially, the newly created Lambda may not be visible, so you’ll need to turn off the switch for “Hide Lambda functions Secrets Manager didn’t create.”
  9. The Rotation Strategy here will be Single User. Hit Save, and everything is now in place.