UNPKG

supertokens-node

Version:
904 lines 58.7 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: as