UNPKG

supertokens-node

Version:
717 lines (716 loc) 49.8 kB
"use strict"; /* Copyright (c) 2023, 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 utils_1 = require("./utils"); const supertokens_js_override_1 = __importDefault(require("supertokens-js-override")); const recipeImplementation_1 = __importDefault(require("./recipeImplementation")); const error_1 = __importDefault(require("../../error")); const processState_1 = require("../../processState"); const logger_1 = require("../../logger"); const utils_2 = require("../../utils"); const plugins_1 = require("../../plugins"); class Recipe extends recipeModule_1.default { constructor(stInstance, recipeId, appInfo, config, _recipes, _ingredients) { super(stInstance, recipeId, appInfo); this.getPrimaryUserThatCanBeLinkedToRecipeUserId = async ({ tenantId, user, userContext, }) => { // first we check if this user itself is a primary user or not. If it is, we return that. if (user.isPrimaryUser) { return user; } // then, we try and find a primary user based on the email / phone number / third party ID / credentialId. let users = await this.recipeInterfaceImpl.listUsersByAccountInfo({ tenantId, accountInfo: Object.assign(Object.assign({}, user.loginMethods[0]), { // we don't need to list by (webauthn) credentialId because we are looking for // a user to link to the current recipe user, but any search using the credentialId // of the current user "will identify the same user" which is the current one. webauthn: undefined }), doUnionOfAccountInfo: true, userContext, }); (0, logger_1.logDebugMessage)(`getPrimaryUserThatCanBeLinkedToRecipeUserId found ${users.length} matching users`); let pUsers = users.filter((u) => u.isPrimaryUser); (0, logger_1.logDebugMessage)(`getPrimaryUserThatCanBeLinkedToRecipeUserId found ${pUsers.length} matching primary users`); if (pUsers.length > 1) { // this means that the new user has account info such that it's // spread across multiple primary user IDs. In this case, even // if we return one of them, it won't be able to be linked anyway // cause if we did, it would mean 2 primary users would have the // same account info. So we return undefined /** * this being said, with the current set of auth recipes, it should * never come here - cause: * ----> If the recipeuserid is a passwordless user, then it can have either a phone * email or both. If it has just one of them, then anyway 2 primary users can't * exist with the same phone number / email. If it has both, then the only way * that it can have multiple primary users returned is if there is another passwordless * primary user with the same phone number - which is not possible, cause phone * numbers are unique across passwordless users. * * ----> If the input is a third party user, then it has third party info and an email. Now there can be able to primary user with the same email, but * there can't be another thirdparty user with the same third party info (since that is unique). * Nor can there an email password primary user with the same email along with another * thirdparty primary user with the same email (since emails can't be the same across primary users). * * ----> If the input is an email password user, then it has an email. There can't be multiple primary users with the same email anyway. */ throw new Error("You found a bug. Please report it on github.com/supertokens/supertokens-node"); } return pUsers.length === 0 ? undefined : pUsers[0]; }; this.getOldestUserThatCanBeLinkedToRecipeUser = async ({ tenantId, user, userContext, }) => { // first we check if this user itself is a primary user or not. If it is, we return that since it cannot be linked to anything else if (user.isPrimaryUser) { return user; } // then, we try and find matching users based on the email / phone number / third party ID. let users = await this.recipeInterfaceImpl.listUsersByAccountInfo({ tenantId, accountInfo: Object.assign(Object.assign({}, user.loginMethods[0]), { // we don't need to list by (webauthn) credentialId because we are looking for // a user to link to the current recipe user, but any search using the credentialId // of the current user "will identify the same user" which is the current one. webauthn: undefined }), doUnionOfAccountInfo: true, userContext, }); (0, logger_1.logDebugMessage)(`getOldestUserThatCanBeLinkedToRecipeUser found ${users.length} matching users`); // finally select the oldest one const oldestUser = users.length === 0 ? undefined : users.reduce((min, curr) => (min.timeJoined < curr.timeJoined ? min : curr)); return oldestUser; }; this.isSignInAllowed = async ({ user, accountInfo, tenantId, session, signInVerifiesLoginMethod, userContext, }) => { processState_1.ProcessState.getInstance().addState(processState_1.PROCESS_STATE.IS_SIGN_IN_ALLOWED_CALLED); if (user.isPrimaryUser || user.loginMethods[0].verified || signInVerifiesLoginMethod) { return true; } return this.isSignInUpAllowedHelper({ accountInfo, isVerified: user.loginMethods[0].verified, session, tenantId, isSignIn: true, user, userContext, }); }; this.isSignUpAllowed = async ({ newUser, isVerified, session, tenantId, userContext, }) => { processState_1.ProcessState.getInstance().addState(processState_1.PROCESS_STATE.IS_SIGN_UP_ALLOWED_CALLED); if (newUser.email !== undefined && newUser.phoneNumber !== undefined) { // we do this check cause below when we call listUsersByAccountInfo, // we only pass in one of email or phone number throw new Error("Please pass one of email or phone number, not both"); } return this.isSignInUpAllowedHelper({ accountInfo: newUser, isVerified, session, tenantId, userContext, user: undefined, isSignIn: false, }); }; this.isSignInUpAllowedHelper = async ({ accountInfo, isVerified, session, tenantId, isSignIn, user, userContext, }) => { processState_1.ProcessState.getInstance().addState(processState_1.PROCESS_STATE.IS_SIGN_IN_UP_ALLOWED_HELPER_CALLED); // since this is a recipe level user, we have to do the following checks // before allowing sign in. We do these checks cause sign in also attempts // account linking: // - If there is no primary user for this user's account info, then // we check if any recipe user exist with the same info and it's not verified. If we // find one, we disallow signing in cause when this user becomes a primary user, // it may cause that other recipe user to be linked to this and if that recipe user // is owned by an attacker, it will lead to an account take over case // - If there exists another primary user, and if this user is not verified, we will // disallow cause if after sign in, this user sends an email verification email // to the email, then the primary user may click on it by mistake and get their account // taken over. // - If there exists another primary user, and that user's email is not verified, // then we disallow sign in cause that primary user may be owned by an attacker // and after this email is verified, it will link to that account causing account // takeover. // we find other accounts based on the email / phone number. // we do not pass in third party info, or both email or phone // cause we want to guarantee that the output array contains just one // primary user. let users = await this.recipeInterfaceImpl.listUsersByAccountInfo({ tenantId, accountInfo: Object.assign(Object.assign({}, accountInfo), { // we don't need to list by (webauthn) credentialId because we are looking for // a user to link to the current recipe user, but any search using the credentialId // of the current user "will identify the same user" which is the current one. webauthn: undefined }), doUnionOfAccountInfo: true, userContext, }); if (users.length === 0) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning true because no user with given account info"); // this is a brand new email / phone number, so we allow sign up. return true; } if (isSignIn && user === undefined) { throw new Error("This should never happen: isSignInUpAllowedHelper called with isSignIn: true, user: undefined"); } if (users.length === 1 && isSignIn && user !== undefined && users[0].id === user.id) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning true because this is sign in and there is only a single user with the given account info"); return true; } // now we check if there exists some primary user with the same email / phone number // such that that info is not verified for that account. In this case, we do not allow // sign up cause we cannot link this new account to that primary account yet (since // the email / phone is unverified - this is to prevent an attack where an attacker // might have access to the unverified account's primary user and we do not want to // link this account to that one), and we can't make this a primary user either (since // then there would be two primary users with the same email / phone number - which is // not allowed..) const primaryUsers = users.filter((u) => u.isPrimaryUser); if (primaryUsers.length === 0) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper no primary user exists"); // since there is no primary user, it means that this user, if signed up, will end up // being the primary user. In this case, we check if any of the non primary user's // are in an unverified state having the same account info, and if they are, then we // disallow this sign up, cause if the user becomes the primary user, and then the other // account which is unverified sends an email verification email, the legit user might // click on the link and that account (which was unverified and could have been controlled // by an attacker), will end up getting linked to this account. let shouldDoAccountLinking = await this.config.shouldDoAutomaticAccountLinking(accountInfo, undefined, session, tenantId, userContext); if (!shouldDoAccountLinking.shouldAutomaticallyLink) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning true because account linking is disabled"); return true; } if (!shouldDoAccountLinking.shouldRequireVerification) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning true because dec does not require email verification"); // the dev says they do not care about verification before account linking // so we are OK with the risk mentioned above. return true; } let shouldAllow = true; for (let i = 0; i < users.length; i++) { let currUser = users[i]; // all these are not primary users, so we can use // loginMethods[0] to get the account info. if (session !== undefined && currUser.id === session.getUserId(userContext)) { // We do not consider the current session user to be conflicting // This can be useful in cases where the current sign in will mark the session user as verified continue; } let thisIterationIsVerified = false; if (accountInfo.email !== undefined) { if (currUser.loginMethods[0].hasSameEmailAs(accountInfo.email) && currUser.loginMethods[0].verified) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper found same email for another user and verified"); thisIterationIsVerified = true; } } if (accountInfo.phoneNumber !== undefined) { if (currUser.loginMethods[0].hasSamePhoneNumberAs(accountInfo.phoneNumber) && currUser.loginMethods[0].verified) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper found same phone number for another user and verified"); thisIterationIsVerified = true; } } if (!thisIterationIsVerified) { // even if one of the users is not verified, we do not allow sign up (see why above). // Sure, this allows attackers to create email password accounts with an email // to block actual users from signing up, but that's ok, since those // users will just see an email already exists error and then will try another // login method. They can also still just go through the password reset flow // and then gain access to their email password account (which can then be verified). (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning false cause one of the other recipe level users is not verified"); shouldAllow = false; break; } } processState_1.ProcessState.getInstance().addState(processState_1.PROCESS_STATE.IS_SIGN_IN_UP_ALLOWED_NO_PRIMARY_USER_EXISTS); (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning " + shouldAllow); return shouldAllow; } else { if (primaryUsers.length > 1) { throw new Error("You have found a bug. Please report to https://github.com/supertokens/supertokens-node/issues"); } let primaryUser = primaryUsers[0]; (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper primary user found"); let shouldDoAccountLinking = await this.config.shouldDoAutomaticAccountLinking(accountInfo, primaryUser, session, tenantId, userContext); if (!shouldDoAccountLinking.shouldAutomaticallyLink) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning true because account linking is disabled"); return true; } if (!shouldDoAccountLinking.shouldRequireVerification) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning true because dec does not require email verification"); // the dev says they do not care about verification before account linking // so we can link this new user to the primary user post recipe user creation // even if that user's email / phone number is not verified. return true; } if (!isVerified) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning false because new user's email is not verified, and primary user with the same email was found."); // this will exist early with a false here cause it means that // if we come here, the newUser will be linked to the primary user post email // verification. Whilst this seems OK, there is a risk that the actual user might // click on the email verification link thinking that they it's for their existing // (legit) account, and then the attacker (who signed up with email password maybe) // will have access to the account - cause email verification will cause account linking. // We do this AFTER calling shouldDoAutomaticAccountLinking cause // in case email verification is not required, then linking should not be // an issue anyway. return false; } // We do not consider the current session user to be conflicting // This can be useful in cases where the current sign in will mark the session user as verified if (session !== undefined && primaryUser.id === session.getUserId()) { return true; } // we check for even if one is verified as opposed to all being unverified cause // even if one is verified, we know that the email / phone number is owned by the // primary account holder. // we only check this for primary user and not other users in the users array cause // they are all recipe users. The reason why we ignore them is cause, in normal // situations, they should not exist cause: // - if primary user was created first, then the recipe user creation would not // be allowed via unverified means of login method (like email password). // - if recipe user was created first, and is unverified, then the primary user // sign up should not be possible either. for (let i = 0; i < primaryUser.loginMethods.length; i++) { let lM = primaryUser.loginMethods[i]; if (lM.email !== undefined) { if (lM.hasSameEmailAs(accountInfo.email) && lM.verified) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning true cause found same email for primary user and verified"); return true; } } if (lM.phoneNumber !== undefined) { if (lM.hasSamePhoneNumberAs(accountInfo.phoneNumber) && lM.verified) { (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning true cause found same phone number for primary user and verified"); return true; } } } (0, logger_1.logDebugMessage)("isSignInUpAllowedHelper returning false cause primary user does not have the same email or phone number that is verified" //"isSignInUpAllowedHelper returning false because new user's email is not verified, and primary user with the same email was found." ); return false; } }; this.isEmailChangeAllowed = async (input) => { /** * The purpose of this function is to check that if a recipe user ID's email * can be changed or not. There are two conditions for when it can't be changed: * - If the recipe user is a primary user, then we need to check that the new email * doesn't belong to any other primary user. If it does, we disallow the change * since multiple primary user's can't have the same account info. * * - If the recipe user is NOT a primary user, and if isVerified is false, then * we check if there exists a primary user with the same email, and if it does * we disallow the email change cause if this email is changed, and an email * verification email is sent, then the primary user may end up clicking * on the link by mistake, causing account linking to happen which can result * in account take over if this recipe user is malicious. */ let inputUser = input.user; for (const tenantId of inputUser.tenantIds) { let existingUsersWithNewEmail = await this.recipeInterfaceImpl.listUsersByAccountInfo({ tenantId, accountInfo: { email: input.newEmail, }, doUnionOfAccountInfo: false, userContext: input.userContext, }); let otherUsersWithNewEmail = existingUsersWithNewEmail.filter((u) => u.id !== inputUser.id); let otherPrimaryUserForNewEmail = otherUsersWithNewEmail.filter((u) => u.isPrimaryUser); if (otherPrimaryUserForNewEmail.length > 1) { throw new Error("You found a bug. Please report it on github.com/supertokens/supertokens-node"); } if (inputUser.isPrimaryUser) { // this is condition one from the above comment. if (otherPrimaryUserForNewEmail.length !== 0) { (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: returning false cause email change will lead to two primary users having same email on ${tenantId}`); return { allowed: false, reason: "PRIMARY_USER_CONFLICT" }; } if (input.isVerified) { (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: can change on ${tenantId} cause input user is primary, new email is verified and doesn't belong to any other primary user`); continue; } if (inputUser.loginMethods.some((lm) => lm.hasSameEmailAs(input.newEmail) && lm.verified)) { (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: can change on ${tenantId} cause input user is primary, new email is verified in another login method and doesn't belong to any other primary user`); continue; } if (otherUsersWithNewEmail.length === 0) { (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: can change on ${tenantId} cause input user is primary and the new email doesn't belong to any other user (primary or non-primary)`); continue; } const shouldDoAccountLinking = await this.config.shouldDoAutomaticAccountLinking(otherUsersWithNewEmail[0].loginMethods[0], inputUser, input.session, tenantId, input.userContext); if (!shouldDoAccountLinking.shouldAutomaticallyLink) { (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: can change on ${tenantId} cause linking is disabled`); continue; } if (!shouldDoAccountLinking.shouldRequireVerification) { (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: can change on ${tenantId} cause linking is doesn't require email verification`); continue; } (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: returning false because the user hasn't verified the new email address and there exists another user with it on ${tenantId} and linking requires verification`); return { allowed: false, reason: "ACCOUNT_TAKEOVER_RISK" }; } else { if (input.isVerified) { (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: can change on ${tenantId} cause input user is not a primary and new email is verified`); continue; } if (inputUser.loginMethods[0].hasSameEmailAs(input.newEmail)) { (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: can change on ${tenantId} cause input user is not a primary and new email is same as the older one`); continue; } if (otherPrimaryUserForNewEmail.length === 1) { let shouldDoAccountLinking = await this.config.shouldDoAutomaticAccountLinking(inputUser.loginMethods[0], otherPrimaryUserForNewEmail[0], input.session, tenantId, input.userContext); if (!shouldDoAccountLinking.shouldAutomaticallyLink) { (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: can change on ${tenantId} cause input user is not a primary there exists a primary user exists with the new email, but the dev does not have account linking enabled.`); continue; } if (!shouldDoAccountLinking.shouldRequireVerification) { (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: can change on ${tenantId} cause input user is not a primary there exists a primary user exists with the new email, but the dev does not require email verification.`); continue; } (0, logger_1.logDebugMessage)("isEmailChangeAllowed: returning false cause input user is not a primary there exists a primary user exists with the new email."); return { allowed: false, reason: "ACCOUNT_TAKEOVER_RISK" }; } (0, logger_1.logDebugMessage)(`isEmailChangeAllowed: can change on ${tenantId} cause input user is not a primary no primary user exists with the new email`); continue; } } (0, logger_1.logDebugMessage)("isEmailChangeAllowed: returning true cause email change can happen on all tenants the user is part of"); return { allowed: true }; }; this.verifyEmailForRecipeUserIfLinkedAccountsAreVerified = async (input) => { let emailVerificationInstance = this.stInstance.getRecipeInstance("emailverification"); if (emailVerificationInstance === undefined) { // if email verification recipe is not initialized, we do a no-op return; } // This is just a helper function cause it's called in many places // like during sign up, sign in and post linking accounts. // This is not exposed to the developer as it's called in the relevant // recipe functions. // We do not do this in the core cause email verification is a different // recipe. // Finally, we only mark the email of this recipe user as verified and not // the other recipe users in the primary user (if this user's email is verified), // cause when those other users sign in, this function will be called for them anyway if (input.user.isPrimaryUser) { let recipeUserEmail = undefined; let isAlreadyVerified = false; input.user.loginMethods.forEach((lm) => { if (lm.recipeUserId.getAsString() === input.recipeUserId.getAsString()) { recipeUserEmail = lm.email; isAlreadyVerified = lm.verified; } }); if (recipeUserEmail !== undefined) { if (isAlreadyVerified) { return; } let shouldVerifyEmail = false; input.user.loginMethods.forEach((lm) => { if (lm.hasSameEmailAs(recipeUserEmail) && lm.verified) { shouldVerifyEmail = true; } }); if (shouldVerifyEmail) { let resp = await emailVerificationInstance.recipeInterfaceImpl.createEmailVerificationToken({ // While the token we create here is tenant specific, the verification status is not // So we can use any tenantId the user is associated with here as long as we use the // same in the verifyEmailUsingToken call tenantId: input.user.tenantIds[0], recipeUserId: input.recipeUserId, email: recipeUserEmail, userContext: input.userContext, }); if (resp.status === "OK") { // we purposely pass in false below cause we don't want account // linking to happen await emailVerificationInstance.recipeInterfaceImpl.verifyEmailUsingToken({ // See comment about tenantId in the createEmailVerificationToken params tenantId: input.user.tenantIds[0], token: resp.token, attemptAccountLinking: false, userContext: input.userContext, }); } } } } }; this.config = (0, utils_1.validateAndNormaliseUserInput)(appInfo, config); { let builder = new supertokens_js_override_1.default((0, recipeImplementation_1.default)(this.querier, this.config, this)); this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); } } static init(config) { return (stInstance, appInfo, _isInServerlessEnv, plugins) => { if (Recipe.instance === undefined) { Recipe.instance = new Recipe(stInstance, Recipe.RECIPE_ID, appInfo, (0, plugins_1.applyPlugins)(Recipe.RECIPE_ID, config, plugins !== null && plugins !== void 0 ? plugins : []), {}, { emailDelivery: undefined, }); return Recipe.instance; } else { throw new Error("AccountLinking recipe has already been initialised. Please check your code for bugs."); } }; } // we auto init the account linking recipe here cause we always require this // to be initialized even if the user has not initialized it. // The side effect of this is that if there are any APIs or errors specific to this recipe, // those won't be handled by the supertokens middleware and error handler (cause this recipe // is not in the recipeList). static getInstanceOrThrowError() { if (Recipe.instance !== undefined) { return Recipe.instance; } throw new Error("Initialisation not done. Did you forget to call the AccountLinking.init function?"); } getAPIsHandled() { // APIs won't be added to the supertokens middleware cause we are auto initializing // it in getInstance function return []; } handleAPIRequest(_id, _tenantId, _req, _response, _path, _method) { throw new Error("Should never come here"); } handleError(error, _request, _response) { // Errors won't come here cause we are auto initializing // it in getInstance function throw error; } getAllCORSHeaders() { return []; } isErrorFromThisRecipe(err) { return error_1.default.isErrorFromSuperTokens(err) && err.fromRecipe === Recipe.RECIPE_ID; } static reset() { if (!(0, utils_2.isTestEnv)()) { throw new Error("calling testing function in non testing env"); } Recipe.instance = undefined; } async shouldBecomePrimaryUser(user, tenantId, session, userContext) { const shouldDoAccountLinking = await this.config.shouldDoAutomaticAccountLinking(user.loginMethods[0], undefined, session, tenantId, userContext); if (!shouldDoAccountLinking.shouldAutomaticallyLink) { (0, logger_1.logDebugMessage)("shouldBecomePrimaryUser returning false because shouldAutomaticallyLink is false"); return false; } if (shouldDoAccountLinking.shouldRequireVerification && !user.loginMethods[0].verified) { (0, logger_1.logDebugMessage)("shouldBecomePrimaryUser returning false because shouldRequireVerification is true but the login method is not verified"); return false; } (0, logger_1.logDebugMessage)("shouldBecomePrimaryUser returning true"); return true; } async tryLinkingByAccountInfoOrCreatePrimaryUser({ inputUser, session, tenantId, userContext, }) { let tries = 0; while (tries++ < 100) { const primaryUserThatCanBeLinkedToTheInputUser = await this.getPrimaryUserThatCanBeLinkedToRecipeUserId({ user: inputUser, tenantId, userContext, }); if (primaryUserThatCanBeLinkedToTheInputUser !== undefined) { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser: got primary user we can try linking"); // we check if the inputUser and primaryUserThatCanBeLinkedToTheInputUser are linked based on recipeIds because the inputUser obj could be outdated if (!primaryUserThatCanBeLinkedToTheInputUser.loginMethods.some((lm) => lm.recipeUserId.getAsString() === inputUser.loginMethods[0].recipeUserId.getAsString())) { // If we got a primary user that can be linked to the input user and they are is not linked, we try to link them. // The input user in this case cannot be linked to anything else, otherwise multiple primary users would have the same email // we can use the 0 index cause targetUser is not a primary user. let shouldDoAccountLinking = await this.config.shouldDoAutomaticAccountLinking(inputUser.loginMethods[0], primaryUserThatCanBeLinkedToTheInputUser, session, tenantId, userContext); // We already checked if factor setup is allowed by this point, but maybe we should check again? if (!shouldDoAccountLinking.shouldAutomaticallyLink) { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser: not linking because shouldAutomaticallyLink is false"); return { status: "NO_LINK" }; } const accountInfoVerifiedInPrimUser = primaryUserThatCanBeLinkedToTheInputUser.loginMethods.some((lm) => (inputUser.loginMethods[0].email !== undefined && lm.hasSameEmailAs(inputUser.loginMethods[0].email)) || (inputUser.loginMethods[0].phoneNumber !== undefined && lm.hasSamePhoneNumberAs(inputUser.loginMethods[0].phoneNumber) && lm.verified)); if (shouldDoAccountLinking.shouldRequireVerification && (!inputUser.loginMethods[0].verified || !accountInfoVerifiedInPrimUser)) { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser: not linking because shouldRequireVerification is true but the login method is not verified in the new or the primary user"); return { status: "NO_LINK" }; } (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser linking"); let linkAccountsResult = await this.recipeInterfaceImpl.linkAccounts({ recipeUserId: inputUser.loginMethods[0].recipeUserId, primaryUserId: primaryUserThatCanBeLinkedToTheInputUser.id, userContext, }); if (linkAccountsResult.status === "OK") { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser successfully linked"); return { status: "OK", user: linkAccountsResult.user }; } else if (linkAccountsResult.status === "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR") { // this can happen cause of a race condition wherein the recipe user ID get's linked to // some other primary user whilst this function is running. // We can return this directly, because: // 1. a retry would result in the same // 2. the tryLinkingByAccountInfoOrCreatePrimaryUser doesn't specify where it should be linked // and we can't linked it to anything here after it became primary/linked to another primary user (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser already linked to another user"); return { status: "OK", user: linkAccountsResult.user, }; } else if (linkAccountsResult.status === "INPUT_USER_IS_NOT_A_PRIMARY_USER") { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser linking failed because of a race condition"); // this can be possible during a race condition wherein the primary user // that we fetched somehow is no more a primary user. This can happen if // the unlink function was called in parallel on that user. So we can just retry continue; } else { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser linking failed because of a race condition"); // status is "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" // it can come here if the recipe user ID can't be linked to the primary user ID cause // the email / phone number is associated with some other primary user ID. // This can happen due to a race condition in which the email has changed // from one primary user to another during this function call. // In this case we can retry, since the next time we fetch the primary user we can link to // it'll point to the correct user. continue; } } // If they are already linked, this is a no-op return { status: "OK", user: inputUser }; } // If there is no primary user we could link to, but we there is another account we can link this to // then we try and link it (respecting shouldDoAutomaticAccountLinking) const oldestUserThatCanBeLinkedToTheInputUser = await this.getOldestUserThatCanBeLinkedToRecipeUser({ user: inputUser, tenantId, userContext, }); if (oldestUserThatCanBeLinkedToTheInputUser !== undefined && oldestUserThatCanBeLinkedToTheInputUser.id !== inputUser.id) { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser: got an older user we can try linking"); // We know that the older user isn't primary, otherwise we'd hit the branch above. let shouldMakeOlderUserPrimary = await this.shouldBecomePrimaryUser(oldestUserThatCanBeLinkedToTheInputUser, tenantId, session, userContext); // if the app doesn't want to make the older user primary, we can't link to it // so we fall back to trying the newer user primary (and not linking) if (shouldMakeOlderUserPrimary) { const createPrimaryUserResult = await this.recipeInterfaceImpl.createPrimaryUser({ recipeUserId: oldestUserThatCanBeLinkedToTheInputUser.loginMethods[0].recipeUserId, userContext, }); if (createPrimaryUserResult.status === "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" || createPrimaryUserResult.status === "RECIPE_USER_ID_ALREADY_LINKED_WITH_PRIMARY_USER_ID_ERROR") { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser: retrying because createPrimaryUser returned " + createPrimaryUserResult.status); continue; } // If we got a primary user that can be linked to the input user and they are is not linked, we try to link them. // The input user in this case cannot be linked to anything else, otherwise multiple primary users would have the same email // we can use the 0 index cause targetUser is not a primary user. let shouldDoAccountLinking = await this.config.shouldDoAutomaticAccountLinking(inputUser.loginMethods[0], createPrimaryUserResult.user, session, tenantId, userContext); // We already checked if factor setup is allowed by this point, but maybe we should check again? if (!shouldDoAccountLinking.shouldAutomaticallyLink) { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser: not linking because shouldAutomaticallyLink is false"); return { status: "NO_LINK" }; } if (shouldDoAccountLinking.shouldRequireVerification && !inputUser.loginMethods[0].verified) { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser: not linking because shouldRequireVerification is true but the login method is not verified"); return { status: "NO_LINK" }; } (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser linking"); let linkAccountsResult = await this.recipeInterfaceImpl.linkAccounts({ recipeUserId: inputUser.loginMethods[0].recipeUserId, primaryUserId: createPrimaryUserResult.user.id, userContext, }); if (linkAccountsResult.status === "OK") { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser successfully linked"); return { status: "OK", user: linkAccountsResult.user }; } else if (linkAccountsResult.status === "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR") { // this can happen cause of a race condition wherein the recipe user ID get's linked to // some other primary user whilst this function is running. // We can return this directly, because: // 1. a retry would result in the same // 2. the tryLinkingByAccountInfoOrCreatePrimaryUser doesn't specify where it should be linked // and we can't linked it to anything here after it became primary/linked to another primary user (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser already linked to another user"); return { status: "OK", user: linkAccountsResult.user, }; } else if (linkAccountsResult.status === "INPUT_USER_IS_NOT_A_PRIMARY_USER") { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser linking failed because of a race condition"); // this can be possible during a race condition wherein the primary user // that we fetched somehow is no more a primary user. This can happen if // the unlink function was called in parallel on that user. So we can just retry continue; } else { (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser linking failed because of a race condition"); // status is "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" // it can come here if the recipe user ID can't be linked to the primary user ID cause // the email / phone number is associated with some other primary user ID. // This can happen due to a race condition in which the email has changed // from one primary user to another during this function call. // In this case we can retry, since the next time we fetch the primary user we can link to // it'll point to the correct user. continue; } } } (0, logger_1.logDebugMessage)("tryLinkingByAccountInfoOrCreatePrimaryUser: trying to make the current user primary"); // In this case we have no other account we can link to, so we check if the current user should become a primary user if (await this.shouldBecomePrimaryUser(inputUser, tenantId, session, userContext)) { let createPrimaryUserResult = await this.recipeInterfaceImpl.createPrimaryUser({ recipeUserId: inputUser.loginMethods[0].recipeUserId, userContext, }); // If the status is "OK", we can return it directly (it contains the updated user) // if the status is "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" or "RECIPE_USER_ID_ALREADY_LINKED_WITH_PRIMARY_USER_ID_ERROR" // meaning that the recipe user ID is already linked to another primary user id (race condition), or that some other // primary user ID exists with the same email / phone number (again, race condition). // In this case we call a retry in createPrimaryUserIdOrLinkByAccountInfo if (createPrimaryUserResult.status === "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" || createPrimaryUserResult.status === "RECIPE_USER_ID_ALREADY_LINKED_WITH_PRIMARY_USER_ID_ERROR") { continue; } return createPrimaryUserResult; } else { // If not we return it unchanged return { status: "OK", user: inputUser }; } } throw new Error("This should never happen: ran out of retries for tryLinkingByAccountInfoOrCreatePrimaryUser"); } } Recipe.instance = undefined; Recipe.RECIPE_ID = "accountlinking"; exports.default = Recipe;