supertokens-node
Version:
NodeJS driver for SuperTokens core
548 lines (547 loc) • 31.9 kB
JavaScript
"use strict";
/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const recipeModule_1 = __importDefault(require("../../recipeModule"));
const error_1 = __importDefault(require("./error"));
const utils_1 = require("./utils");
const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath"));
const recipeImplementation_1 = __importDefault(require("./recipeImplementation"));
const implementation_1 = __importDefault(require("./api/implementation"));
const supertokens_js_override_1 = __importDefault(require("supertokens-js-override"));
const consumeCode_1 = __importDefault(require("./api/consumeCode"));
const createCode_1 = __importDefault(require("./api/createCode"));
const emailExists_1 = __importDefault(require("./api/emailExists"));
const phoneNumberExists_1 = __importDefault(require("./api/phoneNumberExists"));
const resendCode_1 = __importDefault(require("./api/resendCode"));
const constants_1 = require("./constants");
const emaildelivery_1 = __importDefault(require("../../ingredients/emaildelivery"));
const smsdelivery_1 = __importDefault(require("../../ingredients/smsdelivery"));
const postSuperTokensInitCallbacks_1 = require("../../postSuperTokensInitCallbacks");
const utils_2 = require("../thirdparty/utils");
const multifactorauth_1 = require("../multifactorauth");
const utils_3 = require("../../utils");
const plugins_1 = require("../../plugins");
class Recipe extends recipeModule_1.default {
constructor(stInstance, recipeId, appInfo, isInServerlessEnv, config, ingredients) {
super(stInstance, recipeId, appInfo);
// abstract instance functions below...............
this.getAPIsHandled = () => {
return [
{
id: constants_1.CONSUME_CODE_API,
disabled: this.apiImpl.consumeCodePOST === undefined,
method: "post",
pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.CONSUME_CODE_API),
},
{
id: constants_1.CREATE_CODE_API,
disabled: this.apiImpl.createCodePOST === undefined,
method: "post",
pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.CREATE_CODE_API),
},
{
id: constants_1.DOES_EMAIL_EXIST_API,
disabled: this.apiImpl.emailExistsGET === undefined,
method: "get",
pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.DOES_EMAIL_EXIST_API),
},
{
id: constants_1.DOES_EMAIL_EXIST_API_OLD,
disabled: this.apiImpl.emailExistsGET === undefined,
method: "get",
pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.DOES_EMAIL_EXIST_API_OLD),
},
{
id: constants_1.DOES_PHONE_NUMBER_EXIST_API,
disabled: this.apiImpl.phoneNumberExistsGET === undefined,
method: "get",
pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.DOES_PHONE_NUMBER_EXIST_API),
},
{
id: constants_1.DOES_PHONE_NUMBER_EXIST_API_OLD,
disabled: this.apiImpl.phoneNumberExistsGET === undefined,
method: "get",
pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.DOES_PHONE_NUMBER_EXIST_API_OLD),
},
{
id: constants_1.RESEND_CODE_API,
disabled: this.apiImpl.resendCodePOST === undefined,
method: "post",
pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.RESEND_CODE_API),
},
];
};
this.handleAPIRequest = async (id, tenantId, req, res, _, __, userContext) => {
const options = {
config: this.config,
recipeId: this.getRecipeId(),
isInServerlessEnv: this.isInServerlessEnv,
recipeImplementation: this.recipeInterfaceImpl,
req,
res,
emailDelivery: this.emailDelivery,
smsDelivery: this.smsDelivery,
appInfo: this.getAppInfo(),
};
if (id === constants_1.CONSUME_CODE_API) {
return await (0, consumeCode_1.default)(this.stInstance, this.apiImpl, tenantId, options, userContext);
}
else if (id === constants_1.CREATE_CODE_API) {
return await (0, createCode_1.default)(this.stInstance, this.apiImpl, tenantId, options, userContext);
}
else if (id === constants_1.DOES_EMAIL_EXIST_API || id === constants_1.DOES_EMAIL_EXIST_API_OLD) {
return await (0, emailExists_1.default)(this.apiImpl, tenantId, options, userContext);
}
else if (id === constants_1.DOES_PHONE_NUMBER_EXIST_API || id === constants_1.DOES_PHONE_NUMBER_EXIST_API_OLD) {
return await (0, phoneNumberExists_1.default)(this.apiImpl, tenantId, options, userContext);
}
else {
return await (0, resendCode_1.default)(this.stInstance, this.apiImpl, tenantId, options, userContext);
}
};
this.handleError = async (err, _, __) => {
throw err;
};
this.getAllCORSHeaders = () => {
return [];
};
this.isErrorFromThisRecipe = (err) => {
return error_1.default.isErrorFromSuperTokens(err) && err.fromRecipe === Recipe.RECIPE_ID;
};
// helper functions below...
this.createMagicLink = async (input) => {
let userInputCode = this.config.getCustomUserInputCode !== undefined
? await this.config.getCustomUserInputCode(input.tenantId, input.userContext)
: undefined;
const codeInfo = await this.recipeInterfaceImpl.createCode("email" in input
? {
email: input.email,
userInputCode,
session: input.session,
shouldTryLinkingWithSessionUser: !!input.session,
tenantId: input.tenantId,
userContext: input.userContext,
}
: {
phoneNumber: input.phoneNumber,
userInputCode,
session: input.session,
shouldTryLinkingWithSessionUser: !!input.session,
tenantId: input.tenantId,
userContext: input.userContext,
});
if (codeInfo.status !== "OK") {
throw new Error("Failed to create user. Please retry");
}
const appInfo = this.getAppInfo();
let magicLink = appInfo
.getOrigin({
request: input.request,
userContext: input.userContext,
})
.getAsStringDangerous() +
appInfo.websiteBasePath.getAsStringDangerous() +
"/verify" +
"?preAuthSessionId=" +
codeInfo.preAuthSessionId +
"&tenantId=" +
input.tenantId +
"#" +
codeInfo.linkCode;
return magicLink;
};
this.signInUp = async (input) => {
let codeInfo = await this.recipeInterfaceImpl.createCode("email" in input
? {
email: input.email,
tenantId: input.tenantId,
session: input.session,
shouldTryLinkingWithSessionUser: !!input.session,
userContext: input.userContext,
}
: {
phoneNumber: input.phoneNumber,
tenantId: input.tenantId,
session: input.session,
shouldTryLinkingWithSessionUser: !!input.session,
userContext: input.userContext,
});
if (codeInfo.status !== "OK") {
throw new Error("Failed to create user. Please retry");
}
let consumeCodeResponse = await this.recipeInterfaceImpl.consumeCode(this.config.flowType === "MAGIC_LINK"
? {
preAuthSessionId: codeInfo.preAuthSessionId,
linkCode: codeInfo.linkCode,
session: input.session,
shouldTryLinkingWithSessionUser: !!input.session,
tenantId: input.tenantId,
userContext: input.userContext,
}
: {
preAuthSessionId: codeInfo.preAuthSessionId,
deviceId: codeInfo.deviceId,
userInputCode: codeInfo.userInputCode,
session: input.session,
shouldTryLinkingWithSessionUser: !!input.session,
tenantId: input.tenantId,
userContext: input.userContext,
});
if (consumeCodeResponse.status === "OK") {
return {
status: "OK",
createdNewRecipeUser: consumeCodeResponse.createdNewRecipeUser,
recipeUserId: consumeCodeResponse.recipeUserId,
user: consumeCodeResponse.user,
};
}
else {
throw new Error("Failed to create user. Please retry");
}
};
this.isInServerlessEnv = isInServerlessEnv;
this.config = (0, utils_1.validateAndNormaliseUserInput)(this, appInfo, config);
{
let builder = new supertokens_js_override_1.default((0, recipeImplementation_1.default)(this.stInstance, this.querier));
this.recipeInterfaceImpl = builder.override(this.config.override.functions).build();
}
{
let builder = new supertokens_js_override_1.default((0, implementation_1.default)(this.stInstance));
this.apiImpl = builder.override(this.config.override.apis).build();
}
/**
* emailDelivery will always needs to be declared after isInServerlessEnv
* and recipeInterfaceImpl values are set
*/
this.emailDelivery =
ingredients.emailDelivery === undefined
? new emaildelivery_1.default(this.config.getEmailDeliveryConfig())
: ingredients.emailDelivery;
this.smsDelivery =
ingredients.smsDelivery === undefined
? new smsdelivery_1.default(this.config.getSmsDeliveryConfig())
: ingredients.smsDelivery;
let allFactors = (0, utils_1.getEnabledPwlessFactors)(this.config);
postSuperTokensInitCallbacks_1.PostSuperTokensInitCallbacks.addPostInitCallback(() => {
const mfaInstance = stInstance.getRecipeInstance("multifactorauth");
if (mfaInstance !== undefined) {
mfaInstance.addFuncToGetAllAvailableSecondaryFactorIdsFromOtherRecipes(() => {
return allFactors;
});
mfaInstance.addFuncToGetFactorsSetupForUserFromOtherRecipes(async (user) => {
// We deliberately do not check for matching tenantId because
// even if the user is logging into a tenant does not have
// passwordless loginMethod, the frontend will call the
// same consumeCode API as if there was a passwordless user.
// the only diff is that a new recipe user will be associated with the session tenant
function isFactorSetupForUser(user, factorId) {
for (const loginMethod of user.loginMethods) {
if (loginMethod.recipeId !== Recipe.RECIPE_ID) {
continue;
}
// Notice that we also check for if the email is fake or not,
// cause if it is fake, then we should not consider it as setup
// so that the frontend asks the user to enter an email,
// or uses the email of another login method.
if (loginMethod.email !== undefined && !(0, utils_2.isFakeEmail)(loginMethod.email)) {
if (factorId === multifactorauth_1.FactorIds.OTP_EMAIL || factorId === multifactorauth_1.FactorIds.LINK_EMAIL) {
return true;
}
}
if (loginMethod.phoneNumber !== undefined) {
if (factorId === multifactorauth_1.FactorIds.OTP_PHONE || factorId === multifactorauth_1.FactorIds.LINK_PHONE) {
return true;
}
}
}
return false;
}
return allFactors.filter((id) => isFactorSetupForUser(user, id));
});
mfaInstance.addFuncToGetEmailsForFactorFromOtherRecipes((user, sessionRecipeUserId) => {
// This function is called in the MFA info endpoint API.
// Based on https://github.com/supertokens/supertokens-node/pull/741#discussion_r1432749346
// preparing some reusable variables for the logic below...
const sessionLoginMethod = user.loginMethods.find((lM) => {
return lM.recipeUserId.getAsString() === sessionRecipeUserId.getAsString();
});
if (sessionLoginMethod === undefined) {
// this can happen maybe cause this login method
// was unlinked from the user or deleted entirely...
return {
status: "UNKNOWN_SESSION_RECIPE_USER_ID",
};
}
const orderedLoginMethodsByTimeJoinedOldestFirst = user.loginMethods.sort((a, b) => {
return a.timeJoined - b.timeJoined;
});
// MAIN LOGIC FOR THE FUNCTION STARTS HERE
let nonFakeEmailsThatPasswordlessLoginMethodOrderedByTimeJoined = [];
for (let i = 0; i < orderedLoginMethodsByTimeJoinedOldestFirst.length; i++) {
// in the if statement below, we also check for if the email
// is fake or not cause if it is fake, then we consider that
// that login method is not setup for passwordless, and instead
// we want to ask the user to enter their email, or to use
// another login method that has no fake email.
if (orderedLoginMethodsByTimeJoinedOldestFirst[i].recipeId === Recipe.RECIPE_ID) {
if (orderedLoginMethodsByTimeJoinedOldestFirst[i].email !== undefined &&
!(0, utils_2.isFakeEmail)(orderedLoginMethodsByTimeJoinedOldestFirst[i].email)) {
// loginmethods for passwordless are guaranteed to have unique emails
// across all the loginmethods for a user.
nonFakeEmailsThatPasswordlessLoginMethodOrderedByTimeJoined.push(orderedLoginMethodsByTimeJoinedOldestFirst[i].email);
}
}
}
if (nonFakeEmailsThatPasswordlessLoginMethodOrderedByTimeJoined.length === 0) {
// this means that this factor is not setup for email based factors.
// However, we still check if there is an email for this user
// from other loginMethods, and return those. The frontend
// will then call the consumeCode API eventually.
// first we check if the session loginMethod has an email
// and return that. Cause if it does, then the UX will be good
// in that the user will set a password for the the email
// they used to login into the current session.
// when constructing the emails array, we prioritize
// the session user's email cause it's a better UX
// for setting or asking for the OTP for the same email
// that the user used to login.
let emailsResult = [];
if (sessionLoginMethod.email !== undefined && !(0, utils_2.isFakeEmail)(sessionLoginMethod.email)) {
emailsResult = [sessionLoginMethod.email];
}
for (let i = 0; i < orderedLoginMethodsByTimeJoinedOldestFirst.length; i++) {
if (orderedLoginMethodsByTimeJoinedOldestFirst[i].email !== undefined &&
!(0, utils_2.isFakeEmail)(orderedLoginMethodsByTimeJoinedOldestFirst[i].email)) {
// we have the if check below cause different loginMethods
// across different recipes can have the same email.
if (!emailsResult.includes(orderedLoginMethodsByTimeJoinedOldestFirst[i].email)) {
emailsResult.push(orderedLoginMethodsByTimeJoinedOldestFirst[i].email);
}
}
}
let factorIdToEmailsMap = {};
if (allFactors.includes(multifactorauth_1.FactorIds.OTP_EMAIL)) {
factorIdToEmailsMap[multifactorauth_1.FactorIds.OTP_EMAIL] = emailsResult;
}
if (allFactors.includes(multifactorauth_1.FactorIds.LINK_EMAIL)) {
factorIdToEmailsMap[multifactorauth_1.FactorIds.LINK_EMAIL] = emailsResult;
}
return {
status: "OK",
factorIdToEmailsMap,
};
}
else if (nonFakeEmailsThatPasswordlessLoginMethodOrderedByTimeJoined.length === 1) {
// we return just this email and not others cause we want to
// not create more loginMethods with passwordless for the user
// object.
let factorIdToEmailsMap = {};
if (allFactors.includes(multifactorauth_1.FactorIds.OTP_EMAIL)) {
factorIdToEmailsMap[multifactorauth_1.FactorIds.OTP_EMAIL] =
nonFakeEmailsThatPasswordlessLoginMethodOrderedByTimeJoined;
}
if (allFactors.includes(multifactorauth_1.FactorIds.LINK_EMAIL)) {
factorIdToEmailsMap[multifactorauth_1.FactorIds.LINK_EMAIL] =
nonFakeEmailsThatPasswordlessLoginMethodOrderedByTimeJoined;
}
return {
status: "OK",
factorIdToEmailsMap,
};
}
// Finally, we return all emails that have passwordless login
// method for this user, but keep the session's email first
// if the session's email is in the list of
// nonFakeEmailsThatPasswordlessLoginMethodOrderedByTimeJoined (for better UX)
let emailsResult = [];
if (sessionLoginMethod.email !== undefined &&
nonFakeEmailsThatPasswordlessLoginMethodOrderedByTimeJoined.includes(sessionLoginMethod.email)) {
emailsResult = [sessionLoginMethod.email];
}
for (let i = 0; i < nonFakeEmailsThatPasswordlessLoginMethodOrderedByTimeJoined.length; i++) {
if (!emailsResult.includes(nonFakeEmailsThatPasswordlessLoginMethodOrderedByTimeJoined[i])) {
emailsResult.push(nonFakeEmailsThatPasswordlessLoginMethodOrderedByTimeJoined[i]);
}
}
let factorIdToEmailsMap = {};
if (allFactors.includes(multifactorauth_1.FactorIds.OTP_EMAIL)) {
factorIdToEmailsMap[multifactorauth_1.FactorIds.OTP_EMAIL] = emailsResult;
}
if (allFactors.includes(multifactorauth_1.FactorIds.LINK_EMAIL)) {
factorIdToEmailsMap[multifactorauth_1.FactorIds.LINK_EMAIL] = emailsResult;
}
return {
status: "OK",
factorIdToEmailsMap,
};
});
mfaInstance.addFuncToGetPhoneNumbersForFactorsFromOtherRecipes((user, sessionRecipeUserId) => {
// This function is called in the MFA info endpoint API.
// Based on https://github.com/supertokens/supertokens-node/pull/741#discussion_r1432749346
// preparing some reusable variables for the logic below...
const sessionLoginMethod = user.loginMethods.find((lM) => {
return lM.recipeUserId.getAsString() === sessionRecipeUserId.getAsString();
});
if (sessionLoginMethod === undefined) {
// this can happen maybe cause this login method
// was unlinked from the user or deleted entirely...
return {
status: "UNKNOWN_SESSION_RECIPE_USER_ID",
};
}
const orderedLoginMethodsByTimeJoinedOldestFirst = user.loginMethods.sort((a, b) => {
return a.timeJoined - b.timeJoined;
});
// MAIN LOGIC FOR THE FUNCTION STARTS HERE
let phoneNumbersThatPasswordlessLoginMethodOrderedByTimeJoined = [];
for (let i = 0; i < orderedLoginMethodsByTimeJoinedOldestFirst.length; i++) {
// in the if statement below, we also check for if the email
// is fake or not cause if it is fake, then we consider that
// that login method is not setup for passwordless, and instead
// we want to ask the user to enter their email, or to use
// another login method that has no fake email.
if (orderedLoginMethodsByTimeJoinedOldestFirst[i].recipeId === Recipe.RECIPE_ID) {
if (orderedLoginMethodsByTimeJoinedOldestFirst[i].phoneNumber !== undefined) {
// loginmethods for passwordless are guaranteed to have unique phone numbers
// across all the loginmethods for a user.
phoneNumbersThatPasswordlessLoginMethodOrderedByTimeJoined.push(orderedLoginMethodsByTimeJoinedOldestFirst[i].phoneNumber);
}
}
}
if (phoneNumbersThatPasswordlessLoginMethodOrderedByTimeJoined.length === 0) {
// this means that this factor is not setup for phone based factors.
// However, we still check if there is a phone for this user
// from other loginMethods, and return those. The frontend
// will then call the consumeCode API eventually.
// first we check if the session loginMethod has a phone number
// and return that. Cause if it does, then the UX will be good
// in that the user will set a password for the the email
// they used to login into the current session.
// when constructing the phone numbers array, we prioritize
// the session user's phone number cause it's a better UX
// for setting or asking for the OTP for the same phone number
// that the user used to login.
let phonesResult = [];
if (sessionLoginMethod.phoneNumber !== undefined) {
phonesResult = [sessionLoginMethod.phoneNumber];
}
for (let i = 0; i < orderedLoginMethodsByTimeJoinedOldestFirst.length; i++) {
if (orderedLoginMethodsByTimeJoinedOldestFirst[i].phoneNumber !== undefined) {
// we have the if check below cause different loginMethods
// across different recipes can have the same phone number.
if (!phonesResult.includes(orderedLoginMethodsByTimeJoinedOldestFirst[i].phoneNumber)) {
phonesResult.push(orderedLoginMethodsByTimeJoinedOldestFirst[i].phoneNumber);
}
}
}
let factorIdToPhoneNumberMap = {};
if (allFactors.includes(multifactorauth_1.FactorIds.OTP_PHONE)) {
factorIdToPhoneNumberMap[multifactorauth_1.FactorIds.OTP_PHONE] = phonesResult;
}
if (allFactors.includes(multifactorauth_1.FactorIds.LINK_PHONE)) {
factorIdToPhoneNumberMap[multifactorauth_1.FactorIds.LINK_PHONE] = phonesResult;
}
return {
status: "OK",
factorIdToPhoneNumberMap,
};
}
else if (phoneNumbersThatPasswordlessLoginMethodOrderedByTimeJoined.length === 1) {
// we return just this phone number and not others cause we want to
// not create more loginMethods with passwordless for the user
// object.
let factorIdToPhoneNumberMap = {};
if (allFactors.includes(multifactorauth_1.FactorIds.OTP_PHONE)) {
factorIdToPhoneNumberMap[multifactorauth_1.FactorIds.OTP_PHONE] =
phoneNumbersThatPasswordlessLoginMethodOrderedByTimeJoined;
}
if (allFactors.includes(multifactorauth_1.FactorIds.LINK_PHONE)) {
factorIdToPhoneNumberMap[multifactorauth_1.FactorIds.LINK_PHONE] =
phoneNumbersThatPasswordlessLoginMethodOrderedByTimeJoined;
}
return {
status: "OK",
factorIdToPhoneNumberMap,
};
}
// Finally, we return all phones that have passwordless login
// method for this user, but keep the session's phone first
// if the session's phone is in the list of
// phoneNumbersThatPasswordlessLoginMethodOrderedByTimeJoined (for better UX)
let phonesResult = [];
if (sessionLoginMethod.phoneNumber !== undefined &&
phoneNumbersThatPasswordlessLoginMethodOrderedByTimeJoined.includes(sessionLoginMethod.phoneNumber)) {
phonesResult = [sessionLoginMethod.phoneNumber];
}
for (let i = 0; i < phoneNumbersThatPasswordlessLoginMethodOrderedByTimeJoined.length; i++) {
if (!phonesResult.includes(phoneNumbersThatPasswordlessLoginMethodOrderedByTimeJoined[i])) {
phonesResult.push(phoneNumbersThatPasswordlessLoginMethodOrderedByTimeJoined[i]);
}
}
let factorIdToPhoneNumberMap = {};
if (allFactors.includes(multifactorauth_1.FactorIds.OTP_PHONE)) {
factorIdToPhoneNumberMap[multifactorauth_1.FactorIds.OTP_PHONE] = phonesResult;
}
if (allFactors.includes(multifactorauth_1.FactorIds.LINK_PHONE)) {
factorIdToPhoneNumberMap[multifactorauth_1.FactorIds.LINK_PHONE] = phonesResult;
}
return {
status: "OK",
factorIdToPhoneNumberMap,
};
});
}
const mtRecipe = stInstance.getRecipeInstance("multitenancy");
if (mtRecipe !== undefined) {
for (const factorId of allFactors) {
mtRecipe.allAvailableFirstFactors.push(factorId);
}
}
});
}
static getInstanceOrThrowError() {
if (Recipe.instance !== undefined) {
return Recipe.instance;
}
throw new Error("Initialisation not done. Did you forget to call the Passwordless.init function?");
}
static init(config) {
return (stInstance, appInfo, isInServerlessEnv, plugins) => {
if (Recipe.instance === undefined) {
Recipe.instance = new Recipe(stInstance, Recipe.RECIPE_ID, appInfo, isInServerlessEnv, (0, plugins_1.applyPlugins)(Recipe.RECIPE_ID, config, plugins !== null && plugins !== void 0 ? plugins : []), {
emailDelivery: undefined,
smsDelivery: undefined,
});
return Recipe.instance;
}
else {
throw new Error("Passwordless recipe has already been initialised. Please check your code for bugs.");
}
};
}
static reset() {
if (!(0, utils_3.isTestEnv)()) {
throw new Error("calling testing function in non testing env");
}
Recipe.instance = undefined;
}
}
Recipe.instance = undefined;
Recipe.RECIPE_ID = "passwordless";
exports.default = Recipe;