supertokens-node
Version:
NodeJS driver for SuperTokens core
588 lines (587 loc) • 32.6 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];
}