supertokens-node
Version:
NodeJS driver for SuperTokens core
689 lines (688 loc) • 35.2 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.default = getAPIImplementation;
const logger_1 = require("../../../logger");
const authUtils_1 = require("../../../authUtils");
const multifactorauth_1 = require("../../multifactorauth");
const utils_1 = require("../utils");
const error_1 = __importDefault(require("../../session/error"));
function getAPIImplementation(stInstance) {
return {
consumeCodePOST: async function (input) {
var _a, _b, _c;
const errorCodeMap = {
SIGN_UP_NOT_ALLOWED:
"Cannot sign in / up due to security reasons. Please try a different login method or contact support. (ERR_CODE_002)",
SIGN_IN_NOT_ALLOWED:
"Cannot sign in / up due to security reasons. Please try a different login method or contact support. (ERR_CODE_003)",
LINKING_TO_SESSION_USER_FAILED: {
// We should never get an email verification error here, since pwless automatically marks the user
// email as verified
RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR:
"Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_017)",
ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR:
"Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_018)",
SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR:
"Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_019)",
},
};
const deviceInfo = await input.options.recipeImplementation.listCodesByPreAuthSessionId({
tenantId: input.tenantId,
preAuthSessionId: input.preAuthSessionId,
userContext: input.userContext,
});
if (!deviceInfo) {
return {
status: "RESTART_FLOW_ERROR",
};
}
const recipeId = "passwordless";
const accountInfo =
deviceInfo.phoneNumber !== undefined
? {
phoneNumber: deviceInfo.phoneNumber,
}
: {
email: deviceInfo.email,
};
let checkCredentialsResponseProm;
let checkCredentials = async () => {
if (checkCredentialsResponseProm === undefined) {
checkCredentialsResponseProm = input.options.recipeImplementation.checkCode(
"deviceId" in input
? {
preAuthSessionId: input.preAuthSessionId,
deviceId: input.deviceId,
userInputCode: input.userInputCode,
tenantId: input.tenantId,
userContext: input.userContext,
}
: {
preAuthSessionId: input.preAuthSessionId,
linkCode: input.linkCode,
tenantId: input.tenantId,
userContext: input.userContext,
}
);
}
const checkCredentialsResponse = await checkCredentialsResponseProm;
return checkCredentialsResponse.status === "OK";
};
const authenticatingUser = await authUtils_1.AuthUtils.getAuthenticatingUserAndAddToCurrentTenantIfRequired(
{
stInstance,
accountInfo,
recipeId,
userContext: input.userContext,
session: input.session,
tenantId: input.tenantId,
checkCredentialsOnTenant: checkCredentials,
}
);
const emailVerificationInstance = stInstance.getRecipeInstance("emailverification");
// If we have a session and emailverification was initialized plus this code was sent to an email
// then we check if we can/should verify this email address for the session user.
// This helps in usecases like phone-password and emailverification-with-otp where we only want to allow linking
// and making a user primary if they are verified, but the verification process itself involves account linking.
// If a valid code was submitted, we can take that as the session (and the session user) having access to the email
// which means that we can verify their email address
if (
accountInfo.email !== undefined &&
input.session !== undefined &&
emailVerificationInstance !== undefined
) {
// We first load the session user, so we can check if verification is required
// We do this first, it is better for caching if we group the post calls together (verifyIng the code and the email address)
const sessionUser = await stInstance
.getRecipeInstanceOrThrow("accountlinking")
.recipeInterfaceImpl.getUser({ userId: input.session.getUserId(), userContext: input.userContext });
if (sessionUser === undefined) {
throw new error_1.default({
type: error_1.default.UNAUTHORISED,
message: "Session user not found",
});
}
const loginMethod = sessionUser.loginMethods.find(
(lm) => lm.recipeUserId.getAsString() === input.session.getRecipeUserId().getAsString()
);
if (loginMethod === undefined) {
throw new error_1.default({
type: error_1.default.UNAUTHORISED,
message: "Session user and session recipeUserId is inconsistent",
});
}
// If the code was sent as an email and the authenticating user has the same email address as unverified,
// we verify it using the emailverification recipe
if (loginMethod.hasSameEmailAs(accountInfo.email) && !loginMethod.verified) {
// We first check that the submitted code is actually valid
if (await checkCredentials()) {
const tokenResponse =
await emailVerificationInstance.recipeInterfaceImpl.createEmailVerificationToken({
tenantId: input.tenantId,
recipeUserId: loginMethod.recipeUserId,
email: accountInfo.email,
userContext: input.userContext,
});
if (tokenResponse.status === "OK") {
await emailVerificationInstance.recipeInterfaceImpl.verifyEmailUsingToken({
tenantId: input.tenantId,
token: tokenResponse.token,
attemptAccountLinking: false, // we pass false here cause
// we anyway do account linking in this API after this function is
// called.
userContext: input.userContext,
});
}
}
}
}
let factorId;
if (deviceInfo.email !== undefined) {
if ("userInputCode" in input) {
factorId = multifactorauth_1.FactorIds.OTP_EMAIL;
} else {
factorId = multifactorauth_1.FactorIds.LINK_EMAIL;
}
} else {
if ("userInputCode" in input) {
factorId = multifactorauth_1.FactorIds.OTP_PHONE;
} else {
factorId = multifactorauth_1.FactorIds.LINK_PHONE;
}
}
const isSignUp = authenticatingUser === undefined;
const preAuthChecks = await authUtils_1.AuthUtils.preAuthChecks({
stInstance,
authenticatingAccountInfo: {
recipeId: "passwordless",
email: deviceInfo.email,
phoneNumber: deviceInfo.phoneNumber,
},
factorIds: [factorId],
authenticatingUser:
authenticatingUser === null || authenticatingUser === void 0 ? void 0 : authenticatingUser.user,
isSignUp,
isVerified:
(_a =
authenticatingUser === null || authenticatingUser === void 0
? void 0
: authenticatingUser.loginMethod.verified) !== null && _a !== void 0
? _a
: true,
signInVerifiesLoginMethod: true,
skipSessionUserUpdateInCore: false,
tenantId: input.tenantId,
userContext: input.userContext,
session: input.session,
shouldTryLinkingWithSessionUser: input.shouldTryLinkingWithSessionUser,
});
if (preAuthChecks.status !== "OK") {
// On the frontend, this should show a UI of asking the user
// to login using a different method.
return authUtils_1.AuthUtils.getErrorStatusResponseWithReason(
preAuthChecks,
errorCodeMap,
"SIGN_IN_UP_NOT_ALLOWED"
);
}
if (checkCredentialsResponseProm !== undefined) {
// We need to cast this because otherwise TS thinks that this is never updated for some reason.
const checkCredentialsResponse = await checkCredentialsResponseProm;
if (checkCredentialsResponse.status !== "OK") {
// In these cases we return early otherwise consumeCode would increase the invalidAttemptCount again
return checkCredentialsResponse;
}
}
let response = await input.options.recipeImplementation.consumeCode(
"deviceId" in input
? {
preAuthSessionId: input.preAuthSessionId,
deviceId: input.deviceId,
userInputCode: input.userInputCode,
session: input.session,
shouldTryLinkingWithSessionUser: input.shouldTryLinkingWithSessionUser,
tenantId: input.tenantId,
userContext: input.userContext,
}
: {
preAuthSessionId: input.preAuthSessionId,
linkCode: input.linkCode,
session: input.session,
shouldTryLinkingWithSessionUser: input.shouldTryLinkingWithSessionUser,
tenantId: input.tenantId,
userContext: input.userContext,
}
);
if (
response.status === "RESTART_FLOW_ERROR" ||
response.status === "INCORRECT_USER_INPUT_CODE_ERROR" ||
response.status === "EXPIRED_USER_INPUT_CODE_ERROR"
) {
return response;
}
if (response.status !== "OK") {
return authUtils_1.AuthUtils.getErrorStatusResponseWithReason(
response,
errorCodeMap,
"SIGN_IN_UP_NOT_ALLOWED"
);
}
// Here we do these checks after sign in is done cause:
// - We first want to check if the credentials are correct first or not
// - The above recipe function marks the email as verified
// - Even though the above call to signInUp is state changing (it changes the email
// of the user), it's OK to do this check here cause the preAuthChecks already checks
// conditions related to account linking
const postAuthChecks = await authUtils_1.AuthUtils.postAuthChecks({
stInstance,
factorId,
isSignUp,
authenticatedUser: (_b = response.user) !== null && _b !== void 0 ? _b : authenticatingUser.user,
recipeUserId:
(_c = response.recipeUserId) !== null && _c !== void 0
? _c
: authenticatingUser.loginMethod.recipeUserId,
req: input.options.req,
res: input.options.res,
tenantId: input.tenantId,
userContext: input.userContext,
session: input.session,
});
if (postAuthChecks.status !== "OK") {
return authUtils_1.AuthUtils.getErrorStatusResponseWithReason(
postAuthChecks,
errorCodeMap,
"SIGN_IN_UP_NOT_ALLOWED"
);
}
return {
status: "OK",
createdNewRecipeUser: response.createdNewRecipeUser,
user: postAuthChecks.user,
session: postAuthChecks.session,
};
},
createCodePOST: async function (input) {
var _a;
const errorCodeMap = {
SIGN_UP_NOT_ALLOWED:
"Cannot sign in / up due to security reasons. Please try a different login method or contact support. (ERR_CODE_002)",
LINKING_TO_SESSION_USER_FAILED: {
SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR:
"Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_019)",
},
};
const accountInfo = {};
if ("email" in input) {
accountInfo.email = input.email;
}
if ("phoneNumber" in input) {
accountInfo.phoneNumber = input.phoneNumber;
}
// Here we use do not use the helper from AuthUtil to check if this is going to be a sign in or up, because:
// 1. At this point we have no way to check credentials
// 2. We do not want to associate the relevant recipe user with the current tenant (yet)
const userWithMatchingLoginMethod = await getPasswordlessUserByAccountInfo(
Object.assign(Object.assign({}, input), { accountInfo, stInstance })
);
let factorIds;
if (input.session !== undefined) {
if (accountInfo.email !== undefined) {
factorIds = [multifactorauth_1.FactorIds.OTP_EMAIL];
} else {
factorIds = [multifactorauth_1.FactorIds.OTP_PHONE];
}
} else {
factorIds = (0, utils_1.getEnabledPwlessFactors)(input.options.config);
if (accountInfo.email !== undefined) {
factorIds = factorIds.filter((factor) =>
[multifactorauth_1.FactorIds.OTP_EMAIL, multifactorauth_1.FactorIds.LINK_EMAIL].includes(factor)
);
} else {
factorIds = factorIds.filter((factor) =>
[multifactorauth_1.FactorIds.OTP_PHONE, multifactorauth_1.FactorIds.LINK_PHONE].includes(factor)
);
}
}
const preAuthChecks = await authUtils_1.AuthUtils.preAuthChecks({
stInstance,
authenticatingAccountInfo: Object.assign(Object.assign({}, accountInfo), { recipeId: "passwordless" }),
isSignUp: userWithMatchingLoginMethod === undefined,
authenticatingUser:
userWithMatchingLoginMethod === null || userWithMatchingLoginMethod === void 0
? void 0
: userWithMatchingLoginMethod.user,
isVerified:
(_a =
userWithMatchingLoginMethod === null || userWithMatchingLoginMethod === void 0
? void 0
: userWithMatchingLoginMethod.loginMethod.verified) !== null && _a !== void 0
? _a
: true,
signInVerifiesLoginMethod: true,
skipSessionUserUpdateInCore: true,
tenantId: input.tenantId,
factorIds,
userContext: input.userContext,
session: input.session,
shouldTryLinkingWithSessionUser: input.shouldTryLinkingWithSessionUser,
});
if (preAuthChecks.status !== "OK") {
// On the frontend, this should show a UI of asking the user
// to login using a different method.
return authUtils_1.AuthUtils.getErrorStatusResponseWithReason(
preAuthChecks,
errorCodeMap,
"SIGN_IN_UP_NOT_ALLOWED"
);
}
let response = await input.options.recipeImplementation.createCode(
"email" in input
? {
userContext: input.userContext,
email: input.email,
userInputCode:
input.options.config.getCustomUserInputCode === undefined
? undefined
: await input.options.config.getCustomUserInputCode(
input.tenantId,
input.userContext
),
session: input.session,
shouldTryLinkingWithSessionUser: input.shouldTryLinkingWithSessionUser,
tenantId: input.tenantId,
}
: {
userContext: input.userContext,
phoneNumber: input.phoneNumber,
userInputCode:
input.options.config.getCustomUserInputCode === undefined
? undefined
: await input.options.config.getCustomUserInputCode(
input.tenantId,
input.userContext
),
session: input.session,
shouldTryLinkingWithSessionUser: input.shouldTryLinkingWithSessionUser,
tenantId: input.tenantId,
}
);
if (response.status !== "OK") {
return authUtils_1.AuthUtils.getErrorStatusResponseWithReason(
response,
errorCodeMap,
"SIGN_IN_UP_NOT_ALLOWED"
);
}
// now we send the email / text message.
let magicLink = undefined;
let userInputCode = undefined;
let flowType = input.options.config.flowType;
if (preAuthChecks.validFactorIds.every((id) => id.startsWith("link"))) {
flowType = "MAGIC_LINK";
} else if (preAuthChecks.validFactorIds.every((id) => id.startsWith("otp"))) {
flowType = "USER_INPUT_CODE";
} else {
flowType = "USER_INPUT_CODE_AND_MAGIC_LINK";
}
if (flowType === "MAGIC_LINK" || flowType === "USER_INPUT_CODE_AND_MAGIC_LINK") {
magicLink =
input.options.appInfo
.getOrigin({
request: input.options.req,
userContext: input.userContext,
})
.getAsStringDangerous() +
input.options.appInfo.websiteBasePath.getAsStringDangerous() +
"/verify" +
"?preAuthSessionId=" +
response.preAuthSessionId +
"&tenantId=" +
input.tenantId +
"#" +
response.linkCode;
}
if (flowType === "USER_INPUT_CODE" || flowType === "USER_INPUT_CODE_AND_MAGIC_LINK") {
userInputCode = response.userInputCode;
}
// we don't do something special for serverless env here
// cause we want to wait for service's reply since it can show
// a UI error message for if sending an SMS / email failed or not.
if (
input.options.config.contactMethod === "PHONE" ||
(input.options.config.contactMethod === "EMAIL_OR_PHONE" && "phoneNumber" in input)
) {
(0, logger_1.logDebugMessage)(`Sending passwordless login SMS to ${input.phoneNumber}`);
await input.options.smsDelivery.ingredientInterfaceImpl.sendSms({
type: "PASSWORDLESS_LOGIN",
isFirstFactor: preAuthChecks.isFirstFactor,
codeLifetime: response.codeLifetime,
phoneNumber: input.phoneNumber,
preAuthSessionId: response.preAuthSessionId,
urlWithLinkCode: magicLink,
userInputCode,
tenantId: input.tenantId,
userContext: input.userContext,
});
} else {
(0, logger_1.logDebugMessage)(`Sending passwordless login email to ${input.email}`);
await input.options.emailDelivery.ingredientInterfaceImpl.sendEmail({
type: "PASSWORDLESS_LOGIN",
isFirstFactor: preAuthChecks.isFirstFactor,
email: input.email,
codeLifetime: response.codeLifetime,
preAuthSessionId: response.preAuthSessionId,
urlWithLinkCode: magicLink,
userInputCode,
tenantId: input.tenantId,
userContext: input.userContext,
});
}
return {
status: "OK",
deviceId: response.deviceId,
flowType: flowType,
preAuthSessionId: response.preAuthSessionId,
};
},
emailExistsGET: async function (input) {
const users = await stInstance
.getRecipeInstanceOrThrow("accountlinking")
.recipeInterfaceImpl.listUsersByAccountInfo({
tenantId: input.tenantId,
accountInfo: {
email: input.email,
},
doUnionOfAccountInfo: false,
userContext: input.userContext,
});
const userExists = users.some((u) =>
u.loginMethods.some((lm) => lm.recipeId === "passwordless" && lm.hasSameEmailAs(input.email))
);
return {
exists: userExists,
status: "OK",
};
},
phoneNumberExistsGET: async function (input) {
let users = await stInstance
.getRecipeInstanceOrThrow("accountlinking")
.recipeInterfaceImpl.listUsersByAccountInfo({
tenantId: input.tenantId,
accountInfo: {
phoneNumber: input.phoneNumber,
},
doUnionOfAccountInfo: false,
userContext: input.userContext,
});
return {
exists: users.length > 0,
status: "OK",
};
},
resendCodePOST: async function (input) {
let deviceInfo = await input.options.recipeImplementation.listCodesByDeviceId({
userContext: input.userContext,
deviceId: input.deviceId,
tenantId: input.tenantId,
});
if (deviceInfo === undefined) {
return {
status: "RESTART_FLOW_ERROR",
};
}
if (
(input.options.config.contactMethod === "PHONE" && deviceInfo.phoneNumber === undefined) ||
(input.options.config.contactMethod === "EMAIL" && deviceInfo.email === undefined)
) {
return {
status: "RESTART_FLOW_ERROR",
};
}
const userWithMatchingLoginMethod = await getPasswordlessUserByAccountInfo(
Object.assign(Object.assign({}, input), { accountInfo: deviceInfo, stInstance: stInstance })
);
const authTypeInfo = await authUtils_1.AuthUtils.checkAuthTypeAndLinkingStatus(
stInstance,
input.session,
input.shouldTryLinkingWithSessionUser,
{
recipeId: "passwordless",
email: deviceInfo.email,
phoneNumber: deviceInfo.phoneNumber,
},
userWithMatchingLoginMethod === null || userWithMatchingLoginMethod === void 0
? void 0
: userWithMatchingLoginMethod.user,
true,
input.userContext
);
if (authTypeInfo.status === "LINKING_TO_SESSION_USER_FAILED") {
// This can happen in the following edge-cases:
// 1. Either the session didn't exist during createCode or the app didn't want to link to the session user
// and now linking should happen (in consumeCode), but we can't make the session user primary.
// 2. The session user was a primary after createCode, but then before resend happens, it was unlinked and
// another primary user was created with the same account info
// Both of these should be rare enough that we can ask the FE to start over with createCode that does more
// checks than we need to right here.
return {
status: "RESTART_FLOW_ERROR",
};
}
let numberOfTriesToCreateNewCode = 0;
while (true) {
numberOfTriesToCreateNewCode++;
let response = await input.options.recipeImplementation.createNewCodeForDevice({
userContext: input.userContext,
deviceId: input.deviceId,
userInputCode:
input.options.config.getCustomUserInputCode === undefined
? undefined
: await input.options.config.getCustomUserInputCode(input.tenantId, input.userContext),
tenantId: input.tenantId,
});
if (response.status === "USER_INPUT_CODE_ALREADY_USED_ERROR") {
if (numberOfTriesToCreateNewCode >= 3) {
// we retry 3 times.
return {
status: "GENERAL_ERROR",
message: "Failed to generate a one time code. Please try again",
};
}
continue;
}
if (response.status === "OK") {
let magicLink = undefined;
let userInputCode = undefined;
// This mirrors how we construct factorIds in createCodePOST
let factorIds;
if (!authTypeInfo.isFirstFactor) {
if (deviceInfo.email !== undefined) {
factorIds = [multifactorauth_1.FactorIds.OTP_EMAIL];
} else {
factorIds = [multifactorauth_1.FactorIds.OTP_PHONE];
}
// We do not do further filtering here, since we know the exact factor id and the fact that it was created
// which means it was allowed and the user is allowed to re-send it.
// We will execute all check when the code is consumed anyway.
} else {
factorIds = (0, utils_1.getEnabledPwlessFactors)(input.options.config);
factorIds = await authUtils_1.AuthUtils.filterOutInvalidFirstFactorsOrThrowIfAllAreInvalid(
stInstance,
factorIds,
input.tenantId,
false,
input.userContext
);
}
// This is correct because in createCodePOST we only allow OTP_EMAIL
let flowType = input.options.config.flowType;
if (factorIds.every((id) => id.startsWith("link"))) {
flowType = "MAGIC_LINK";
} else if (factorIds.every((id) => id.startsWith("otp"))) {
flowType = "USER_INPUT_CODE";
} else {
flowType = "USER_INPUT_CODE_AND_MAGIC_LINK";
}
if (flowType === "MAGIC_LINK" || flowType === "USER_INPUT_CODE_AND_MAGIC_LINK") {
magicLink =
input.options.appInfo
.getOrigin({
request: input.options.req,
userContext: input.userContext,
})
.getAsStringDangerous() +
input.options.appInfo.websiteBasePath.getAsStringDangerous() +
"/verify" +
"?preAuthSessionId=" +
response.preAuthSessionId +
"&tenantId=" +
input.tenantId +
"#" +
response.linkCode;
}
if (flowType === "USER_INPUT_CODE" || flowType === "USER_INPUT_CODE_AND_MAGIC_LINK") {
userInputCode = response.userInputCode;
}
// we don't do something special for serverless env here
// cause we want to wait for service's reply since it can show
// a UI error message for if sending an SMS / email failed or not.
if (
input.options.config.contactMethod === "PHONE" ||
(input.options.config.contactMethod === "EMAIL_OR_PHONE" &&
deviceInfo.phoneNumber !== undefined)
) {
(0, logger_1.logDebugMessage)(`Sending passwordless login SMS to ${input.phoneNumber}`);
await input.options.smsDelivery.ingredientInterfaceImpl.sendSms({
type: "PASSWORDLESS_LOGIN",
isFirstFactor: authTypeInfo.isFirstFactor,
codeLifetime: response.codeLifetime,
phoneNumber: deviceInfo.phoneNumber,
preAuthSessionId: response.preAuthSessionId,
urlWithLinkCode: magicLink,
userInputCode,
tenantId: input.tenantId,
userContext: input.userContext,
});
} else {
(0, logger_1.logDebugMessage)(`Sending passwordless login email to ${deviceInfo.email}`);
await input.options.emailDelivery.ingredientInterfaceImpl.sendEmail({
type: "PASSWORDLESS_LOGIN",
isFirstFactor: authTypeInfo.isFirstFactor,
email: deviceInfo.email,
codeLifetime: response.codeLifetime,
preAuthSessionId: response.preAuthSessionId,
urlWithLinkCode: magicLink,
userInputCode,
tenantId: input.tenantId,
userContext: input.userContext,
});
}
}
return {
status: response.status,
};
}
},
};
}
async function getPasswordlessUserByAccountInfo(input) {
const existingUsers = await input.stInstance
.getRecipeInstanceOrThrow("accountlinking")
.recipeInterfaceImpl.listUsersByAccountInfo({
tenantId: input.tenantId,
accountInfo: input.accountInfo,
doUnionOfAccountInfo: false,
userContext: input.userContext,
});
(0, logger_1.logDebugMessage)(
`getPasswordlessUserByAccountInfo got ${existingUsers.length} from core resp ${JSON.stringify(
input.accountInfo
)}`
);
const usersWithMatchingLoginMethods = existingUsers
.map((user) => ({
user,
loginMethod: user.loginMethods.find(
(lm) =>
lm.recipeId === "passwordless" &&
(lm.hasSameEmailAs(input.accountInfo.email) ||
lm.hasSamePhoneNumberAs(input.accountInfo.phoneNumber))
),
}))
.filter(({ loginMethod }) => loginMethod !== undefined);
(0, logger_1.logDebugMessage)(
`getPasswordlessUserByAccountInfo ${usersWithMatchingLoginMethods.length} has matching login methods`
);
if (usersWithMatchingLoginMethods.length > 1) {
throw new Error(
"This should never happen: multiple users exist matching the accountInfo in passwordless createCode"
);
}
return usersWithMatchingLoginMethods[0];
}