bpt-pack-two
Version:
Study Passwordless authentication on aws project
180 lines (179 loc) • 7.44 kB
JavaScript
import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";
import { randomInt } from "crypto";
import { CognitoJwtVerifier } from "aws-jwt-verify";
import { SimpleJwksCache } from "aws-jwt-verify/jwk";
import { logger, UserFacingError } from "./common.js";
let config = {
/** Should SMS OTP step-up sign-in be enabled? If set to false, clients cannot sign-in with SMS OTP step-up (an error is shown instead when they request a OTP sms) */
smsOtpStepUpEnabled: !!process.env.SMS_OTP_STEP_UP_ENABLED,
/** The length of the OTP */
secretCodeLength: process.env.OTP_LENGTH ? Number(process.env.OTP_LENGTH) : 6,
/** Amazon SNS origination number to use for sending SMS messages */
originationNumber: process.env.ORIGINATION_NUMBER || undefined,
/** Amazon SNS sender ID to use for sending SMS messages */
senderId: process.env.SENDER_ID || undefined,
/** The Amazon SNS region, override e.g. to set a region where you are out of the SES sandbox */
snsRegion: process.env.SNS_REGION || process.env.AWS_REGION,
/** Function to mask the phone nr that will be visible in the public challenge parameters */
phoneNrMasker: maskPhoneNumber,
/** Function to create the content of the OTP sms-es, override to e.g. use a custom sms template */
contentCreator: createSmsContent,
/** The function to verify JWTs with, override to e.g. verify custom claims */
jwtVerifier: verifyJwt,
};
function requireConfig(k) {
// eslint-disable-next-line security/detect-object-injection
const value = config[k];
if (value === undefined)
throw new Error(`Missing configuration for: ${k}`);
return value;
}
export function configure(update) {
config = { ...config, ...update };
return config;
}
export async function addChallengeToEvent(event) {
if (!config.smsOtpStepUpEnabled)
throw new UserFacingError("Step-up authentication with SMS OTP not supported");
logger.info("Adding SMS OTP Step up challenge to event ...");
const { phoneNumber, secretCode } = await createChallenge(event);
// This is sent back to the client app
event.response.publicChallengeParameters = {
phoneNumber: config.phoneNrMasker(phoneNumber),
};
// Add the secret login code to the private challenge parameters
// so it can be verified by the "Verify Auth Challenge Response" trigger
event.response.privateChallengeParameters = { secretCode, phoneNumber };
// Add the secret login code to the session so it is available
// in a next invocation of the "Create Auth Challenge" trigger
event.response.challengeMetadata = `SMS-OTP-STEPUP-CODE-${secretCode}`;
}
async function createChallenge(event) {
logger.info("Creating SMS OTP step-up challenge ...");
let phoneNumber = event.request.userAttributes.phone_number_verified
? event.request.userAttributes.phone_number
: undefined;
if (event.request.userNotFound) {
logger.info("User not found");
phoneNumber = `+${[...Buffer.from(event.userName)].join("").slice(0, 10)}`;
}
if (!phoneNumber) {
throw new UserFacingError("User has no (verified) phone number");
}
// If we already sent a secret code in this auth flow instance, re-use it.
// This allows the user to make a mistake when keying in the code and to then retry,
// rather then needing to send the user an all new code again.
const previousChallenge = event.request.session.slice(-1)[0];
const previousSecretCode = previousChallenge.challengeMetadata?.match(/SMS-OTP-STEPUP-CODE-(\d+)/)?.[1];
let secretCode;
if (!previousSecretCode) {
logger.info("SMS Code has not been sent yet, generating and sending one ...");
secretCode = [...new Array(requireConfig("secretCodeLength"))]
.map(() => randomInt(0, 9))
.join("");
const attributes = {};
if (config.senderId) {
attributes["AWS.SNS.SMS.SenderID"] = {
DataType: "String",
StringValue: config.senderId,
};
}
if (config.originationNumber) {
attributes["AWS.MM.SMS.OriginationNumber"] = {
DataType: "String",
StringValue: config.originationNumber,
};
}
if (!event.request.userNotFound) {
await new SNSClient({ region: config.snsRegion }).send(new PublishCommand({
Message: await config.contentCreator.call(undefined, {
secretCode,
event,
}),
PhoneNumber: phoneNumber,
MessageAttributes: {
"AWS.SNS.SMS.SMSType": {
StringValue: "Transactional",
DataType: "String",
},
...attributes,
},
}));
}
}
else {
logger.info("Will re-use prior OTP code (user made a typo?)");
secretCode = previousSecretCode;
}
return {
phoneNumber,
secretCode,
};
}
async function createSmsContent({ secretCode, }) {
return `Your verification code is: ${secretCode}`;
}
export async function addChallengeVerificationResultToEvent(event) {
logger.info("Verifying SMS OTP StepUp Challenge Response ...");
if (event.request.userNotFound) {
logger.info("User not found");
}
if (!config.smsOtpStepUpEnabled)
throw new UserFacingError("Step-up authentication with SMS OTP not supported");
if (event.request.privateChallengeParameters.challenge ===
"PROVIDE_AUTH_PARAMETERS")
return;
let parsedAnswer;
try {
parsedAnswer = JSON.parse(event.request.challengeAnswer);
assertIsAnswer(parsedAnswer);
}
catch (err) {
logger.error("Invalid challengeAnswer:", err);
event.response.answerCorrect = false;
return;
}
const secretCodeValid = !!event.request.privateChallengeParameters.secretCode &&
event.request.privateChallengeParameters.secretCode ===
parsedAnswer.secretCode;
const jwtValid = await config.jwtVerifier.call(undefined, {
userPoolId: event.userPoolId,
clientId: event.callerContext.clientId,
sub: event.request.userAttributes.sub,
jwt: parsedAnswer.jwt,
});
event.response.answerCorrect = secretCodeValid && jwtValid;
}
function assertIsAnswer(answer) {
if (!answer ||
typeof answer !== "object" ||
!("secretCode" in answer) ||
typeof answer.secretCode !== "string" ||
!("jwt" in answer) ||
typeof answer.jwt !== "string") {
throw new Error("Invalid answer");
}
}
const jwksCache = new SimpleJwksCache();
async function verifyJwt({ userPoolId, clientId, jwt, sub, }) {
return CognitoJwtVerifier.create({
userPoolId,
tokenUse: "access",
clientId,
customJwtCheck: ({ payload }) => {
if (payload.sub !== sub) {
throw new Error("Wrong sub");
}
},
}, { jwksCache })
.verify(jwt)
.then(() => true)
.catch((err) => {
logger.error(err);
return false;
});
}
function maskPhoneNumber(phoneNumber) {
const show = phoneNumber.length < 8 ? 2 : 4;
return `+${new Array(11 - show).fill("*").join("")}${phoneNumber.slice(-show)}`;
}