supertokens-node
Version:
NodeJS driver for SuperTokens core
904 lines • 58.7 kB
JavaScript
"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