UNPKG

supertokens-node

Version:
760 lines 54.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.AuthUtils = void 0; const utils_1 = require("./recipe/multifactorauth/utils"); const utils_2 = require("./recipe/multitenancy/utils"); const error_1 = __importDefault(require("./recipe/session/error")); const _1 = require("."); const logger_1 = require("./logger"); const emailverification_1 = require("./recipe/emailverification"); const error_2 = __importDefault(require("./error")); const utils_3 = require("./recipe/accountlinking/utils"); exports.AuthUtils = { /** * This helper function can be used to map error statuses (w/ an optional reason) to error responses with human readable reasons. * This maps to a response in the format of `{ status: "3rd param", reason: "human readable string from second param" }` * * The errorCodeMap is expected to be something like: * ``` * { * EMAIL_VERIFICATION_REQUIRED: "This is returned as reason if the resp(1st param) has the status code EMAIL_VERIFICATION_REQUIRED and an undefined reason", * STATUS: { * REASON: "This is returned as reason if the resp(1st param) has STATUS in the status prop and REASON in the reason prop" * } * } * ``` */ getErrorStatusResponseWithReason(resp, errorCodeMap, errorStatus) { const reasons = errorCodeMap[resp.status]; if (reasons !== undefined) { if (typeof reasons === "string") { return { status: errorStatus, reason: reasons, }; } else if (typeof reasons === "object" && resp.reason !== undefined) { if (reasons[resp.reason]) { return { status: errorStatus, reason: reasons[resp.reason], }; } } } (0, logger_1.logDebugMessage)(`unmapped error status ${resp.status} (${resp.reason})`); throw new Error("Should never come here: unmapped error status " + resp.status); }, /** * Runs all checks we need to do before trying to authenticate a user: * - if this is a first factor auth or not * - if the session user is required to be primary (and tries to make it primary if necessary) * - if any of the factorids are valid (as first or secondary factors), taking into account mfa factor setup rules * - if sign up is allowed (if isSignUp === true) * * It returns the following statuses: * - OK: the auth flow can proceed * - SIGN_UP_NOT_ALLOWED: if isSignUpAllowed returned false. This is mostly because of conflicting users with the same account info * - LINKING_TO_SESSION_USER_FAILED (SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR): * if the session user should become primary but we couldn't make it primary because of a conflicting primary user. */ preAuthChecks: async function ({ stInstance, authenticatingAccountInfo, tenantId, isSignUp, isVerified, signInVerifiesLoginMethod, authenticatingUser, factorIds, skipSessionUserUpdateInCore, session, shouldTryLinkingWithSessionUser, userContext, }) { let validFactorIds; // This would be an implementation error on our part, we only check it because TS doesn't if (factorIds.length === 0) { throw new Error("This should never happen: empty factorIds array passed to preSignInChecks"); } (0, logger_1.logDebugMessage)("preAuthChecks checking auth types"); // First we check if the app intends to link the inputUser or not, // to decide if this is a first factor auth or not and if it'll link to the session user // We also load the session user here if it is available. const authTypeInfo = await exports.AuthUtils.checkAuthTypeAndLinkingStatus(stInstance, session, shouldTryLinkingWithSessionUser, authenticatingAccountInfo, authenticatingUser, skipSessionUserUpdateInCore, userContext); if (authTypeInfo.status !== "OK") { (0, logger_1.logDebugMessage)(`preAuthChecks returning ${authTypeInfo.status} from checkAuthType results`); return authTypeInfo; } // If the app will not link these accounts after auth, we consider this to be a first factor auth if (authTypeInfo.isFirstFactor) { (0, logger_1.logDebugMessage)("preAuthChecks getting valid first factors"); // We check if any factors here are valid first factors. // In all apis besides passwordless createCode, we know exactly which factor we are signing into, so in those cases, // this is basically just checking if the single factor is allowed or not. // For createCode, we filter whatever is allowed, if any of them are allowed, createCode can happen. // The filtered list can be used to select email templates. As an example: // If the flowType for passwordless is USER_INPUT_CODE_AND_MAGIC_LINK and firstFactors for the tenant we only have otp-email // then we do not want to include a link in the email. const validFirstFactors = await exports.AuthUtils.filterOutInvalidFirstFactorsOrThrowIfAllAreInvalid(stInstance, factorIds, tenantId, session !== undefined, userContext); validFactorIds = validFirstFactors; } else { (0, logger_1.logDebugMessage)("preAuthChecks getting valid secondary factors"); // In this case the app will try to link the session user and the authenticating user after auth, // so we need to check if this is allowed by the MFA recipe (if initialized). validFactorIds = await filterOutInvalidSecondFactorsOrThrowIfAllAreInvalid(stInstance, factorIds, authTypeInfo.inputUserAlreadyLinkedToSessionUser, authTypeInfo.sessionUser, session, userContext); } if (!isSignUp && authenticatingUser === undefined) { throw new Error("This should never happen: preAuthChecks called with isSignUp: false, authenticatingUser: undefined"); } // If this is a sign up we check that the sign up is allowed if (isSignUp) { // We need this check in case the session user has verified an email address and now tries to add a password for it. let verifiedInSessionUser = !authTypeInfo.isFirstFactor && authTypeInfo.sessionUser.loginMethods.some((lm) => lm.verified && (lm.hasSameEmailAs(authenticatingAccountInfo.email) || lm.hasSamePhoneNumberAs(authenticatingAccountInfo.phoneNumber))); (0, logger_1.logDebugMessage)("preAuthChecks checking if the user is allowed to sign up"); if (!(await stInstance.getRecipeInstanceOrThrow("accountlinking").isSignUpAllowed({ newUser: authenticatingAccountInfo, isVerified: isVerified || signInVerifiesLoginMethod || verifiedInSessionUser, tenantId, session, userContext, }))) { return { status: "SIGN_UP_NOT_ALLOWED" }; } } else if (authenticatingUser !== undefined) { // for sign ins, this is checked after the credentials have been verified (0, logger_1.logDebugMessage)("preAuthChecks checking if the user is allowed to sign in"); if (!(await stInstance.getRecipeInstanceOrThrow("accountlinking").isSignInAllowed({ user: authenticatingUser, accountInfo: authenticatingAccountInfo, signInVerifiesLoginMethod, tenantId, session, userContext, }))) { return { status: "SIGN_IN_NOT_ALLOWED" }; } } (0, logger_1.logDebugMessage)("preAuthChecks returning OK"); // If nothing failed, we return OK return { status: "OK", validFactorIds, isFirstFactor: authTypeInfo.isFirstFactor, }; }, /** * Runs the linking process and all check we need to before creating a session + creates the new session if necessary: * - runs the linking process which will: try to link to the session user, or link by account info or try to make the authenticated user primary * - checks if sign in is allowed (if isSignUp === false) * - creates a session if necessary * - marks the factor as completed if necessary * * It returns the following statuses: * - OK: the auth flow went as expected * - LINKING_TO_SESSION_USER_FAILED(EMAIL_VERIFICATION_REQUIRED): if we couldn't link to the session user because linking requires email verification * - LINKING_TO_SESSION_USER_FAILED(RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR): * if we couldn't link to the session user because the authenticated user has been linked to another primary user concurrently * - LINKING_TO_SESSION_USER_FAILED(ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR): * if we couldn't link to the session user because of a conflicting primary user that has the same account info as authenticatedUser * - LINKING_TO_SESSION_USER_FAILED (SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR): * if the session user should be primary but we couldn't make it primary because of a conflicting primary user. */ postAuthChecks: async function ({ stInstance, authenticatedUser, recipeUserId, isSignUp, factorId, session, req, res, tenantId, userContext, }) { (0, logger_1.logDebugMessage)(`postAuthChecks called ${session !== undefined ? "with" : "without"} a session to ${isSignUp ? "sign in" : "sign up"} with ${factorId}`); const mfaInstance = stInstance.getRecipeInstance("multifactorauth"); let respSession = session; if (session !== undefined) { const authenticatedUserLinkedToSessionUser = authenticatedUser.loginMethods.some((lm) => lm.recipeUserId.getAsString() === session.getRecipeUserId(userContext).getAsString()); if (authenticatedUserLinkedToSessionUser) { (0, logger_1.logDebugMessage)(`postAuthChecks session and input user got linked`); if (mfaInstance !== undefined) { (0, logger_1.logDebugMessage)(`postAuthChecks marking factor as completed`); // if the authenticating user is linked to the current session user (it means that the factor got set up or completed), // we mark it as completed in the session. await mfaInstance.recipeInterfaceImpl.markFactorAsCompleteInSession({ session: respSession, factorId, userContext, }); } } else { // If the new user wasn't linked to the current one, we overwrite the session // Note: we could also get here if MFA is enabled, but the app didn't want to link the user to the session user. respSession = await stInstance.getRecipeInstanceOrThrow("session").createNewSession({ req, res, tenantId, recipeUserId, accessTokenPayload: {}, sessionDataInDatabase: {}, userContext, }); if (mfaInstance !== undefined) { await mfaInstance.recipeInterfaceImpl.markFactorAsCompleteInSession({ session: respSession, factorId, userContext, }); } } } else { (0, logger_1.logDebugMessage)(`postAuthChecks creating session for first factor sign in/up`); // If there is no input session, we do not need to do anything other checks and create a new session respSession = await stInstance.getRecipeInstanceOrThrow("session").createNewSession({ req, res, tenantId, recipeUserId, accessTokenPayload: {}, sessionDataInDatabase: {}, userContext, }); // Here we can always mark the factor as completed, since we just created the session if (mfaInstance !== undefined) { await mfaInstance.recipeInterfaceImpl.markFactorAsCompleteInSession({ session: respSession, factorId, userContext, }); } } return { status: "OK", session: respSession, user: authenticatedUser }; }, /** * This function tries to find the authenticating user (we use this information to see if the current auth is sign in or up) * if a session was passed and the authenticating user was not found on the current tenant, it checks if the session user * has a matching login method on other tenants. If it does and the credentials check out on the other tenant, it associates * the recipe user for the login method (matching account info, recipeId and credentials) with the current tenant. * * While this initially complicates the auth logic, we want to avoid creating a new recipe user if a tenant association will do, * because it'll make managing MFA factors (i.e.: secondary passwords) a lot easier for the app, and, * most importantly, this way all secondary factors are app-wide instead of mixing app-wide (totp) and tenant-wide (password) factors. */ getAuthenticatingUserAndAddToCurrentTenantIfRequired: async ({ stInstance, recipeId, accountInfo, checkCredentialsOnTenant, tenantId, session, userContext, }) => { let i = 0; while (i++ < 300) { (0, logger_1.logDebugMessage)(`getAuthenticatingUserAndAddToCurrentTenantIfRequired called with ${JSON.stringify(accountInfo)}`); const existingUsers = await stInstance .getRecipeInstanceOrThrow("accountlinking") .recipeInterfaceImpl.listUsersByAccountInfo({ tenantId, accountInfo, doUnionOfAccountInfo: true, userContext: userContext, }); (0, logger_1.logDebugMessage)(`getAuthenticatingUserAndAddToCurrentTenantIfRequired got ${existingUsers.length} users from the core resp`); const usersWithMatchingLoginMethods = existingUsers .map((user) => ({ user, loginMethod: user.loginMethods.find((lm) => lm.recipeId === recipeId && ((accountInfo.email !== undefined && lm.hasSameEmailAs(accountInfo.email)) || lm.hasSamePhoneNumberAs(accountInfo.phoneNumber) || lm.hasSameThirdPartyInfoAs(accountInfo.thirdParty) || lm.hasSameWebauthnInfoAs(accountInfo.webauthn))), })) .filter(({ loginMethod }) => loginMethod !== undefined); (0, logger_1.logDebugMessage)(`getAuthenticatingUserAndAddToCurrentTenantIfRequired got ${usersWithMatchingLoginMethods.length} users with matching login methods`); if (usersWithMatchingLoginMethods.length > 1) { throw new Error("You have found a bug. Please report it on https://github.com/supertokens/supertokens-node/issues"); } let authenticatingUser = usersWithMatchingLoginMethods[0]; if (authenticatingUser === undefined && session !== undefined) { (0, logger_1.logDebugMessage)(`getAuthenticatingUserAndAddToCurrentTenantIfRequired checking session user`); const sessionUser = await stInstance .getRecipeInstanceOrThrow("accountlinking") .recipeInterfaceImpl.getUser({ userId: session.getUserId(userContext), userContext }); if (sessionUser === undefined) { throw new error_1.default({ type: error_1.default.UNAUTHORISED, message: "Session user not found", }); } // Since the session user is not primary, they only have a single login method // and we know that that login method is associated with the current tenant. // This means that the user has no loginMethods we need to check (that only belong to other tenantIds) if (!sessionUser.isPrimaryUser) { (0, logger_1.logDebugMessage)(`getAuthenticatingUserAndAddToCurrentTenantIfRequired session user is non-primary so returning early without checking other tenants`); return undefined; } const matchingLoginMethodsFromSessionUser = sessionUser.loginMethods.filter((lm) => lm.recipeId === recipeId && (lm.hasSameEmailAs(accountInfo.email) || lm.hasSamePhoneNumberAs(accountInfo.phoneNumber) || lm.hasSameThirdPartyInfoAs(accountInfo.thirdParty))); (0, logger_1.logDebugMessage)(`getAuthenticatingUserAndAddToCurrentTenantIfRequired session has ${matchingLoginMethodsFromSessionUser.length} matching login methods`); if (matchingLoginMethodsFromSessionUser.some((lm) => lm.tenantIds.includes(tenantId))) { (0, logger_1.logDebugMessage)(`getAuthenticatingUserAndAddToCurrentTenantIfRequired session has ${matchingLoginMethodsFromSessionUser.length} matching login methods`); // This can happen in a race condition where a user was created and linked with the session user // between listing the existing users and loading the session user // We can return early, this only means that someone did the same sharing this function was aiming to do // concurrently. return { user: sessionUser, loginMethod: matchingLoginMethodsFromSessionUser.find((lm) => lm.tenantIds.includes(tenantId)), }; } let goToRetry = false; for (const lm of matchingLoginMethodsFromSessionUser) { (0, logger_1.logDebugMessage)(`getAuthenticatingUserAndAddToCurrentTenantIfRequired session checking credentials on ${lm.tenantIds[0]}`); if (await checkCredentialsOnTenant(lm.tenantIds[0])) { (0, logger_1.logDebugMessage)(`getAuthenticatingUserAndAddToCurrentTenantIfRequired associating user from ${lm.tenantIds[0]} with current tenant`); const associateRes = await stInstance .getRecipeInstanceOrThrow("multitenancy") .recipeInterfaceImpl.associateUserToTenant({ tenantId, recipeUserId: lm.recipeUserId, userContext, }); (0, logger_1.logDebugMessage)(`getAuthenticatingUserAndAddToCurrentTenantIfRequired associating returned ${associateRes.status}`); if (associateRes.status === "OK") { // We know that this is what happens lm.tenantIds.push(tenantId); return { user: sessionUser, loginMethod: lm }; } if (associateRes.status === "UNKNOWN_USER_ID_ERROR" || // This means that the recipe user was deleted // All below conditions mean that both the account list and the session user we loaded is outdated associateRes.status === "EMAIL_ALREADY_EXISTS_ERROR" || associateRes.status === "PHONE_NUMBER_ALREADY_EXISTS_ERROR" || associateRes.status === "THIRD_PARTY_USER_ALREADY_EXISTS_ERROR") { // In these cases we retry, because we know some info we are using is outdated // while some of these cases we could handle locally, it's cleaner to restart the process. goToRetry = true; break; } if (associateRes.status === "ASSOCIATION_NOT_ALLOWED_ERROR") { // Since we were trying to share the recipe user linked to a primary user already associated with the tenant, // this can only happen if the session user was disassociated from the tenant of the session, // plus another user was created holding the account info we are trying to share with the tenant. // Which basically means that the session is no longer valid. throw new error_1.default({ type: error_1.default.UNAUTHORISED, message: "Session user not associated with the session tenant", }); } } } if (goToRetry) { (0, logger_1.logDebugMessage)(`getAuthenticatingUserAndAddToCurrentTenantIfRequired retrying`); continue; } } return authenticatingUser; } throw new Error("This should never happen: ran out of retries for getAuthenticatingUserAndAddToCurrentTenantIfRequired"); }, /** * This function checks if the current authentication attempt should be considered a first factor or not. * To do this it'll also need to (if a session was passed): * - load the session user (and possibly make it primary) * - check the linking status of the input and session user * - call and check the results of shouldDoAutomaticAccountLinking * So in the non-first factor case it also returns the results of those checks/operations. * * It returns the following statuses: * - OK: if everything went well * - LINKING_TO_SESSION_USER_FAILED (SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR): * if the session user should be primary but we couldn't make it primary because of a conflicting primary user. */ checkAuthTypeAndLinkingStatus: async function (stInstance, session, shouldTryLinkingWithSessionUser, accountInfo, inputUser, skipSessionUserUpdateInCore, userContext) { (0, logger_1.logDebugMessage)(`checkAuthTypeAndLinkingStatus called`); let sessionUser = undefined; if (session === undefined) { if (shouldTryLinkingWithSessionUser === true) { throw new error_1.default({ type: error_1.default.UNAUTHORISED, message: "Session not found but shouldTryLinkingWithSessionUser is true", }); } (0, logger_1.logDebugMessage)(`checkAuthTypeAndLinkingStatus returning first factor because there is no session`); // If there is no active session we have nothing to link to - so this has to be a first factor sign in return { status: "OK", isFirstFactor: true }; } else { if (shouldTryLinkingWithSessionUser === false) { (0, logger_1.logDebugMessage)(`checkAuthTypeAndLinkingStatus returning first factor because shouldTryLinkingWithSessionUser is false`); // In our normal flows this should never happen - but some user overrides might do this. // Anyway, since shouldTryLinkingWithSessionUser explicitly set to false, it's safe to consider this a firstFactor return { status: "OK", isFirstFactor: true }; } if (!(0, utils_3.recipeInitDefinedShouldDoAutomaticAccountLinking)(stInstance.getRecipeInstanceOrThrow("accountlinking").config)) { if (shouldTryLinkingWithSessionUser === true) { throw new Error("Please initialise the account linking recipe and define shouldDoAutomaticAccountLinking to enable MFA"); } else { // This is the legacy case where shouldTryLinkingWithSessionUser is undefined if (stInstance.getRecipeInstance("multifactorauth") !== undefined) { throw new Error("Please initialise the account linking recipe and define shouldDoAutomaticAccountLinking to enable MFA"); } else { (0, logger_1.logDebugMessage)(`checkAuthTypeAndLinkingStatus (legacy behaviour) returning first factor because MFA is not initialised and shouldDoAutomaticAccountLinking is not defined`); return { status: "OK", isFirstFactor: true }; } } } // If we get here: // - session is defined // - shouldTryLinkingWithSessionUser is true or undefined // - shouldDoAutomaticAccountLinking is defined // - MFA may or may not be initialized // If the input and the session user are the same if (inputUser !== undefined && inputUser.id === session.getUserId()) { (0, logger_1.logDebugMessage)(`checkAuthTypeAndLinkingStatus returning secondary factor, session and input user are the same`); // Then this is basically a user logging in with an already linked secondary account // Which is basically a factor completion in MFA terms. // Since the sessionUser and the inputUser are the same in this case, we can just return early return { status: "OK", isFirstFactor: false, inputUserAlreadyLinkedToSessionUser: true, sessionUser: inputUser, }; } (0, logger_1.logDebugMessage)(`checkAuthTypeAndLinkingStatus loading session user, ${inputUser === null || inputUser === void 0 ? void 0 : inputUser.id} === ${session.getUserId()}`); // We have to load the session user in order to get the account linking info const sessionUserResult = await exports.AuthUtils.tryAndMakeSessionUserIntoAPrimaryUser(stInstance, session, skipSessionUserUpdateInCore, userContext); if (sessionUserResult.status === "SHOULD_AUTOMATICALLY_LINK_FALSE") { if (shouldTryLinkingWithSessionUser === true) { // tryAndMakeSessionUserIntoAPrimaryUser throws if it is an email verification iss throw new _1.Error({ message: "shouldDoAutomaticAccountLinking returned false when making the session user primary but shouldTryLinkingWithSessionUser is true", type: "BAD_INPUT_ERROR", }); } return { status: "OK", isFirstFactor: true, }; } else if (sessionUserResult.status === "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR") { return { status: "LINKING_TO_SESSION_USER_FAILED", reason: "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR", }; } sessionUser = sessionUserResult.sessionUser; // We check if the app intends to link these two accounts // Note: in some cases if the accountInfo already belongs to a primary user const shouldLink = await stInstance .getRecipeInstanceOrThrow("accountlinking") .config.shouldDoAutomaticAccountLinking(accountInfo, sessionUser, session, session.getTenantId(), userContext); (0, logger_1.logDebugMessage)(`checkAuthTypeAndLinkingStatus session user <-> input user shouldDoAutomaticAccountLinking returned ${JSON.stringify(shouldLink)}`); if (shouldLink.shouldAutomaticallyLink === false) { if (shouldTryLinkingWithSessionUser === true) { throw new _1.Error({ message: "shouldDoAutomaticAccountLinking returned false when making the session user primary but shouldTryLinkingWithSessionUser is true", type: "BAD_INPUT_ERROR", }); } return { status: "OK", isFirstFactor: true }; } else { return { status: "OK", isFirstFactor: false, inputUserAlreadyLinkedToSessionUser: false, sessionUser, linkingToSessionUserRequiresVerification: shouldLink.shouldRequireVerification, }; } } }, /** * This function checks the auth type (first factor or not), links by account info for first factor auths otherwise * it tries to link the input user to the session user * * It returns the following statuses: * - OK: the linking went as expected * - LINKING_TO_SESSION_USER_FAILED(EMAIL_VERIFICATION_REQUIRED): if we couldn't link to the session user because linking requires email verification * - LINKING_TO_SESSION_USER_FAILED(RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR): * if we couldn't link to the session user because the authenticated user has been linked to another primary user concurrently * - LINKING_TO_SESSION_USER_FAILED(ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR): * if we couldn't link to the session user because of a conflicting primary user that has the same account info as authenticatedUser * - LINKING_TO_SESSION_USER_FAILED (SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR): * if the session user should be primary but we couldn't make it primary because of a conflicting primary user. */ linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo: async function ({ stInstance, tenantId, inputUser, recipeUserId, session, shouldTryLinkingWithSessionUser, userContext, }) { (0, logger_1.logDebugMessage)("linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo called"); const retry = () => { (0, logger_1.logDebugMessage)("linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo retrying...."); return exports.AuthUtils.linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo({ stInstance, tenantId, inputUser: inputUser, session, shouldTryLinkingWithSessionUser, recipeUserId, userContext, }); }; // If we got here, we have a session and a primary session user // We can not assume the inputUser is non-primary, since we'll only check that seeing if the app wants to link to the session user or not. const authLoginMethod = inputUser.loginMethods.find((lm) => lm.recipeUserId.getAsString() === recipeUserId.getAsString()); if (authLoginMethod === undefined) { throw new Error("This should never happen: the recipeUserId and user is inconsistent in createPrimaryUserIdOrLinkByAccountInfo params"); } const authTypeRes = await exports.AuthUtils.checkAuthTypeAndLinkingStatus(stInstance, session, shouldTryLinkingWithSessionUser, authLoginMethod, inputUser, false, userContext); if (authTypeRes.status !== "OK") { return authTypeRes; } if (authTypeRes.isFirstFactor) { if (!(0, utils_3.recipeInitDefinedShouldDoAutomaticAccountLinking)(stInstance.getRecipeInstanceOrThrow("accountlinking").config)) { (0, logger_1.logDebugMessage)("linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo skipping link by account info because this is a first factor auth and the app hasn't defined shouldDoAutomaticAccountLinking"); return { status: "OK", user: inputUser }; } (0, logger_1.logDebugMessage)("linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo trying to link by account info because this is a first factor auth"); // We try and list all users that can be linked to the input user based on the account info // later we can use these when trying to link or when checking if linking to the session user is possible. const linkRes = await stInstance .getRecipeInstanceOrThrow("accountlinking") .tryLinkingByAccountInfoOrCreatePrimaryUser({ inputUser: inputUser, session, tenantId, userContext, }); if (linkRes.status === "OK") { return { status: "OK", user: linkRes.user }; } if (linkRes.status === "NO_LINK") { return { status: "OK", user: inputUser }; } return retry(); } if (authTypeRes.inputUserAlreadyLinkedToSessionUser) { return { status: "OK", user: authTypeRes.sessionUser, }; } (0, logger_1.logDebugMessage)("linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo trying to link by session info"); const sessionLinkingRes = await exports.AuthUtils.tryLinkingBySession({ stInstance, sessionUser: authTypeRes.sessionUser, authenticatedUser: inputUser, authLoginMethod, linkingToSessionUserRequiresVerification: authTypeRes.linkingToSessionUserRequiresVerification, userContext, }); if (sessionLinkingRes.status === "LINKING_TO_SESSION_USER_FAILED") { if (sessionLinkingRes.reason === "INPUT_USER_IS_NOT_A_PRIMARY_USER") { // This means that although we made the session user primary above, some race condition undid that (e.g.: calling unlink concurrently with this func) // We can retry in this case, since we start by trying to make it into a primary user and throwing if we can't return retry(); } else { return sessionLinkingRes; } } else { // If we get here the status is OK, so we can just return it return sessionLinkingRes; } }, /** * This function loads the session user and tries to make it primary. * It returns: * - OK: if the session user was a primary user or we made it into one or it can/should become one but `skipSessionUserUpdateInCore` is set to true * - SHOULD_AUTOMATICALLY_LINK_FALSE: if shouldDoAutomaticAccountLinking returned `{ shouldAutomaticallyLink: false }` * - ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: * If we tried to make it into a primary user but it didn't succeed because of a conflicting primary user * * It throws INVALID_CLAIM_ERROR if shouldDoAutomaticAccountLinking returned `{ shouldAutomaticallyLink: false }` but the email verification status was wrong */ tryAndMakeSessionUserIntoAPrimaryUser: async function (stInstance, session, skipSessionUserUpdateInCore, userContext) { (0, logger_1.logDebugMessage)(`tryAndMakeSessionUserIntoAPrimaryUser called`); const sessionUser = await stInstance .getRecipeInstanceOrThrow("accountlinking") .recipeInterfaceImpl.getUser({ userId: session.getUserId(), userContext }); if (sessionUser === undefined) { throw new error_1.default({ type: error_1.default.UNAUTHORISED, message: "Session user not found", }); } if (sessionUser.isPrimaryUser) { (0, logger_1.logDebugMessage)(`tryAndMakeSessionUserIntoAPrimaryUser session user already primary`); // if the session user was already primary we can just return it return { status: "OK", sessionUser }; } else { // if the session user is not primary we try and make it one (0, logger_1.logDebugMessage)(`tryAndMakeSessionUserIntoAPrimaryUser not primary user yet`); // We could check here if the session user can even become a primary user, but that'd only mean one extra core call // without any added benefits, since the core already checks all pre-conditions // We do this check here instead of using the shouldBecomePrimaryUser util, because // here we handle the shouldRequireVerification case differently const shouldDoAccountLinking = await stInstance .getRecipeInstanceOrThrow("accountlinking") .config.shouldDoAutomaticAccountLinking(sessionUser.loginMethods[0], undefined, session, session.getTenantId(userContext), userContext); (0, logger_1.logDebugMessage)(`tryAndMakeSessionUserIntoAPrimaryUser shouldDoAccountLinking: ${JSON.stringify(shouldDoAccountLinking)}`); if (shouldDoAccountLinking.shouldAutomaticallyLink) { if (skipSessionUserUpdateInCore) { return { status: "OK", sessionUser: sessionUser }; } if (shouldDoAccountLinking.shouldRequireVerification && !sessionUser.loginMethods[0].verified) { // We force-update the claim value if it is not set or different from what we just fetched from the DB if ((await session.getClaimValue(emailverification_1.EmailVerificationClaim, userContext)) !== false) { (0, logger_1.logDebugMessage)(`tryAndMakeSessionUserIntoAPrimaryUser updating emailverification status in session`); // This will let the frontend know if the value has been updated in the background await session.setClaimValue(emailverification_1.EmailVerificationClaim, false, userContext); } (0, logger_1.logDebugMessage)(`tryAndMakeSessionUserIntoAPrimaryUser throwing validation error`); // Then run the validation expecting it to fail. We run assertClaims instead of throwing the error locally // to make sure the error shape in the response will match what we'd return normally await session.assertClaims([emailverification_1.EmailVerificationClaim.validators.isVerified()], userContext); throw new Error("This should never happen: email verification claim validator passed after setting value to false"); } const createPrimaryUserRes = await stInstance .getRecipeInstanceOrThrow("accountlinking") .recipeInterfaceImpl.createPrimaryUser({ recipeUserId: sessionUser.loginMethods[0].recipeUserId, userContext, }); (0, logger_1.logDebugMessage)(`tryAndMakeSessionUserIntoAPrimaryUser createPrimaryUser returned ${createPrimaryUserRes.status}`); if (createPrimaryUserRes.status === "RECIPE_USER_ID_ALREADY_LINKED_WITH_PRIMARY_USER_ID_ERROR") { // This means that the session user got primary since we loaded the session user info above // but this status means that the user id has also changed, so the session should be invalid throw new error_1.default({ type: error_1.default.UNAUTHORISED, message: "Session user not found", }); } else if (createPrimaryUserRes.status === "OK") { return { status: "OK", sessionUser: createPrimaryUserRes.user }; } else { // All other statuses signify that we can't make the session user primary // Which means we can't continue return createPrimaryUserRes; } } else { // This means that the app doesn't want to make the session user primary return { status: "SHOULD_AUTOMATICALLY_LINK_FALSE" }; } } }, /** * This function tries linking by session, and doesn't attempt to make the authenticated user a primary or link it by account info * * It returns the following statuses: * - OK: the linking went as expected * - LINKING_TO_SESSION_USER_FAILED(EMAIL_VERIFICATION_REQUIRED): if we couldn't link to the session user because linking requires email verification * - LINKING_TO_SESSION_USER_FAILED(RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR): * if we couldn't link to the session user because the authenticated user has been linked to another primary user concurrently * - LINKING_TO_SESSION_USER_FAILED(ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR): * if we couldn't link to the session user because of a conflicting primary user that has the same account info as authenticatedUser * - LINKING_TO_SESSION_USER_FAILED (INPUT_USER_IS_NOT_A_PRIMARY_USER): * if the session user is not primary. This can be resolved by making it primary and retrying the call. */ tryLinkingBySession: async function ({ stInstance, linkingToSessionUserRequiresVerification, authLoginMethod, authenticatedUser, sessionUser, userContext, }) { (0, logger_1.logDebugMessage)("tryLinkingBySession called"); // If the input user has another user (and it's not the session user) it could be linked to based on account info then we can't link it to the session user. // However, we do not need to check this as the linkAccounts check will fail anyway and we do not want the extra core call in case it succeeds // If the session user has already verified the current email address/phone number and wants to add another account with it // then we don't want to ask them to verify it again. // This is different from linking based on account info, but the presence of a session shows that the user has access to both accounts, // and intends to link these two accounts. const sessionUserHasVerifiedAccountInfo = sessionUser.loginMethods.some((lm) => (lm.hasSameEmailAs(authLoginMethod.email) || lm.hasSamePhoneNumberAs(authLoginMethod.phoneNumber)) && lm.verified); const canLinkBasedOnVerification = !linkingToSessionUserRequiresVerification || authLoginMethod.verified || sessionUserHasVerifiedAccountInfo; if (!canLinkBasedOnVerification) { return { status: "LINKING_TO_SESSION_USER_FAILED", reason: "EMAIL_VERIFICATION_REQUIRED" }; } // If we get here, it means that the session and the input user can be linked, so we try it. // Note that this function will not call shouldDoAutomaticAccountLinking and check the verification status before linking // it'll mark the freshly linked recipe user as verified if the email address was verified in the session user. let linkAccountsResult = await stInstance .getRecipeInstanceOrThrow("accountlinking") .recipeInterfaceImpl.linkAccounts({ recipeUserId: authenticatedUser.loginMethods[0].recipeUserId, primaryUserId: sessionUser.id, userContext, }); if (linkAccountsResult.status === "OK") { (0, logger_1.logDebugMessage)("tryLinkingBySession successfully linked input user to session user"); return { status: "OK", user: linkAccountsResult.user }; } else if (linkAccountsResult.status === "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR") { // this can happen because of a race condition wherein the recipe user ID get's linked to // some other primary user whilst the linking is going on. (0, logger_1.logDebugMessage)("tryLinkingBySession linking to session user failed because of a race condition - input user linked to another user"); return { status: "LINKING_TO_SESSION_USER_FAILED", reason: linkAccountsResult.status }; } else if (linkAccountsResult.status === "INPUT_USER_IS_NOT_A_PRIMARY_USER") { (0, logger_1.logDebugMessage)("tryLinkingBySession linking to session user failed because of a race condition - INPUT_USER_IS_NOT_A_PRIMARY_USER, should retry"); // This can be possible during a race condition wherein the primary user we created above // is somehow no more a primary user. This can happen if the unlink function was called in parallel // on that user. We can just retry, as that will try and make it a primary user again. return { status: "LINKING_TO_SESSION_USER_FAILED", reason: linkAccountsResult.status }; } else { (0, logger_1.logDebugMessage)("tryLinkingBySession linking to session user failed because of a race condition - input user has another primary user it can be linked to"); // Status can only be "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 because 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, // or if another primary user was created with the same email as the input user while this function is running return { status: "LINKING_TO_SESSION_USER_FAILED", reason: linkAccountsResult.status }; } }, filterOutInvalidFirstFactorsOrThrowIfAllAreInvalid: async function (stInstance, factorIds, tenantId, hasSession, userContext) { let validFactorIds = []; for (const id of factorIds) { // This util takes the tenant config into account (if it exists), then the MFA (static) config if it was initialized and set. let validRes = await (0, utils_2.isValidFirstFactor)(stInstance, tenantId, id, userContext); if (validRes.status === "TENANT_NOT_FOUND_ERROR") { if (hasSession) { throw new error_1.default({ type: error_1.default.UNAUTHORISED, message: "Tenant not found", }); } else { throw new Error("Tenant not found error."); } } else if (validRes.status === "OK") { validFactorIds.push(id); } } if (validFactorIds.length === 0) { if (!hasSession) { throw new error_1.default({ type: error_1.default.UNAUTHORISED, message: "A valid session is required to authenticate with secondary factors", }); } else { throw new error_2.default({ type: error_2.default.BAD_INPUT_ERROR, message: "First factor sign in/up called for a non-first factor with an active session. This might indicate that you are trying to use this as a secondary factor, but disabled account linking.", }); } } return validFactorIds; }, loadSessionInAuthAPIIfNeeded: async function (stInstance, req, res, shouldTryLinkingWithSessionUser, userContext) { if (shouldTryLinkingWithSessionUser !== false) { (0, logger_1.logDebugMessage)("loadSessionInAuthAPIIfNeeded: loading session because shouldTryLinkingWithSessionUser is not set to false so we may want to link later"); return await stInstance.getRecipeInstanceOrThrow("session").getSession({ req, res, options: { // This is optional only if shouldTryLinkingWithSessionUser is undefined // in the (old) 3.0 FDI, this flag didn't exist and we linking was based on the session presence and shouldDoAutomaticAccountLinking sessionRequired: shouldTryLinkingWithSessionUser === true, overrideGlobalClaimValidators: () => [], }, userContext, }); } (0, logger_1.logDebugMessage)("loadSessionInAuthAPIIfNeeded: skipping session loading because we are not linking and we would overwrite it anyway"); return undefined; }, }; async function filterOutInvalidSecondFactorsOrThrowIfAllAreInvalid(stInstance, factorIds, inputUserAlreadyLinkedToSessionUser, sessionUser, session, userContext) { if (session === undefined) { throw new Error("This should never happen: filterOutInvalidSecondFactorsOrThrowIfAllAreInvalid called without a session"); } if (sessionUser === undefined) { throw new Error("This should never happen: filterOutInvalidSecondFactorsOrThrowIfAllAreInvalid called without a sessionUser"); } (