diff --git a/packages/user/package.json b/packages/user/package.json index 73efc146b..438ad60ae 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-user", - "version": "0.93.5", + "version": "0.94.0-beta.1", "description": "Fastify user plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/user#readme", "repository": { diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts new file mode 100644 index 000000000..7cb3e0b54 --- /dev/null +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts @@ -0,0 +1,124 @@ +import { CustomError } from "@prefabs.tech/fastify-error-handler"; +import { formatDate } from "@prefabs.tech/fastify-slonik"; +import { deleteUser, getRequestFromUserContext } from "supertokens-node"; +import UserRoles from "supertokens-node/recipe/userroles"; + +import { User, UserCreateInput } from "src/types"; + +import { ROLE_USER } from "../../../../constants"; +import getUserService from "../../../../lib/getUserService"; +import areRolesExist from "../../../utils/areRolesExist"; + +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/passwordless/types"; + +const consumeCode = ( + originalImplementation: RecipeInterface, + fastify: FastifyInstance, +): RecipeInterface["consumeCode"] => { + return async (input) => { + const roles = (input.userContext.roles || [ + fastify.config.user.role || ROLE_USER, + ]) as string[]; + + if (!(await areRolesExist(roles))) { + throw new CustomError( + `At least one role from ${roles.join(", ")} does not exist.`, + "SIGNUP_FAILED_ERROR", + ); + } + + const originalResponse = await originalImplementation.consumeCode(input); + + if (originalResponse.status !== "OK") { + return originalResponse; + } + + const request = getRequestFromUserContext(input.userContext)?.original as + | FastifyRequest + | undefined; + + const userService = getUserService( + request?.config || fastify.config, + request?.slonik || fastify.slonik, + request?.dbSchema, + ); + + const phoneNumber = originalResponse.user.phoneNumber; + + const emailDomain = + fastify.config.user.fallbackEmailDomain || + fastify.config.appName.toLowerCase().replaceAll(/\s+/g, "") + ".com"; + + const email = phoneNumber + ? `${phoneNumber}@${emailDomain}` + : originalResponse.user.email; + + if (!email || !phoneNumber) { + await deleteUser(originalResponse.user.id); + + throw new Error("Passwordless user missing phoneNumber or email"); + } + + let user: User | null | undefined; + + if (originalResponse.createdNewUser) { + try { + user = await userService.create({ + id: originalResponse.user.id, + email, + phoneNumber, + } as UserCreateInput); + + if (!user) { + throw new Error("User not found"); + } + } catch (error) { + await deleteUser(originalResponse.user.id); + + throw error; + } + + user.roles = roles; + + originalResponse.user = { + ...originalResponse.user, + ...user, + }; + + for (const role of roles) { + const rolesResponse = await UserRoles.addRoleToUser( + originalResponse.user.id, + role, + ); + + if (rolesResponse.status !== "OK") { + fastify.log.error(rolesResponse.status); + } + } + } else { + await userService + .update(originalResponse.user.id, { + lastLoginAt: formatDate(new Date(Date.now())), + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .catch((error: any) => { + fastify.log.error( + `Unable to update lastLoginAt for userId ${originalResponse.user.id}`, + ); + fastify.log.error(error); + }); + } + + return { + ...originalResponse, + user: { + ...originalResponse.user, + email, + phoneNumber, + }, + }; + }; +}; + +export default consumeCode; diff --git a/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts b/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts new file mode 100644 index 000000000..220794f44 --- /dev/null +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts @@ -0,0 +1,23 @@ +import { ROLE_USER } from "../../../../constants"; + +import type { FastifyInstance } from "fastify"; +import type { APIInterface } from "supertokens-node/recipe/passwordless/types"; + +const consumeCodePOST = ( + originalImplementation: APIInterface, + fastify: FastifyInstance, +): APIInterface["consumeCodePOST"] => { + return async (input) => { + input.userContext.roles = input.userContext.roles || [ + fastify.config.user.role || ROLE_USER, + ]; + + if (originalImplementation.consumeCodePOST === undefined) { + throw new Error("Should never come here"); + } + + return originalImplementation.consumeCodePOST(input); + }; +}; + +export default consumeCodePOST; diff --git a/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts new file mode 100644 index 000000000..055471b87 --- /dev/null +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -0,0 +1,163 @@ +import { FastifyInstance } from "fastify"; +import { TwilioService } from "supertokens-node/recipe/passwordless/smsdelivery"; + +import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe"; + +import consumeCode from "./passwordless/consumeCode"; +import consumeCodePOST from "./passwordless/consumeCodePost"; + +import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; +import type { + APIInterface, + RecipeInterface, + TypeInput as PasswordlessRecipeConfig, +} from "supertokens-node/recipe/passwordless/types"; + +const getPasswordlessRecipeConfig = ( + fastify: FastifyInstance, +): PasswordlessRecipeConfig => { + const { config } = fastify; + const isDevelopment = process.env.NODE_ENV === "development"; + const defaultTestOtp = process.env.DEFAULT_TEST_OTP || "123456"; + + let passwordless: PasswordlessRecipe = {}; + + if (typeof config.user.supertokens.recipes?.passwordless === "object") { + passwordless = config.user.supertokens.recipes.passwordless; + } + + let twilioSettings: TwilioServiceConfig | undefined; + + if (!isDevelopment) { + if (!config.user.twilio) { + throw new Error( + "Twilio config is missing for passwordless recipe. Please add twilio config to your app config.", + ); + } + + if ( + !("messagingServiceSid" in config.user.twilio) && + !("from" in config.user.twilio) + ) { + throw new Error( + "Twilio config requires either messagingServiceSid or from", + ); + } + + twilioSettings = + "messagingServiceSid" in config.user.twilio + ? { + opts: config.user.twilio.opts, + accountSid: config.user.twilio.accountSid, + authToken: config.user.twilio.authToken, + messagingServiceSid: config.user.twilio.messagingServiceSid, + } + : { + opts: config.user.twilio.opts, + accountSid: config.user.twilio.accountSid, + authToken: config.user.twilio.authToken, + from: config.user.twilio.from, + }; + } + + return { + contactMethod: passwordless?.contactMethod || "PHONE", + flowType: passwordless?.flowType || "USER_INPUT_CODE", + ...(isDevelopment + ? { + getCustomUserInputCode: () => defaultTestOtp, + } + : {}), + override: { + apis: (originalImplementation) => { + const apiInterface: Partial = {}; + + if (passwordless.override?.apis) { + const apis = passwordless.override.apis; + + let api: keyof APIInterface; + + for (api in apis) { + const apiWrapper = apis[api]; + + if (apiWrapper) { + apiInterface[api] = apiWrapper( + originalImplementation, + fastify, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any; + } + } + } + + return { + ...originalImplementation, + consumeCodePOST: consumeCodePOST(originalImplementation, fastify), + ...apiInterface, + }; + }, + functions: (originalImplementation) => { + const recipeInterface: Partial = {}; + + if (passwordless.override?.functions) { + const recipes = passwordless.override.functions; + + let recipe: keyof RecipeInterface; + + for (recipe in recipes) { + const recipeWrapper = recipes[recipe]; + + if (recipeWrapper) { + recipeInterface[recipe] = recipeWrapper( + originalImplementation, + fastify, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any; + } + } + } + + return { + ...originalImplementation, + consumeCode: consumeCode(originalImplementation, fastify), + ...recipeInterface, + }; + }, + }, + ...(isDevelopment + ? { + createAndSendCustomTextMessage: async () => { + fastify.log.info( + `Skipping passwordless SMS delivery in development environment. Use default OTP [${defaultTestOtp}] for testing.`, + ); + }, + } + : { + smsDelivery: { + service: new TwilioService({ + twilioSettings: twilioSettings as TwilioServiceConfig, + override: (originalImplementation) => { + return { + ...originalImplementation, + getContent: async (input) => { + const message = + config.user.twilio?.message || + "Your verification code is:"; + + return { + body: `${message} ${input.userInputCode}.`, + toPhoneNumber: input.phoneNumber, + }; + }, + sendRawSms: async (input) => { + await originalImplementation.sendRawSms(input); + }, + }; + }, + }), + }, + }), + }; +}; + +export default getPasswordlessRecipeConfig; diff --git a/packages/user/src/supertokens/recipes/index.ts b/packages/user/src/supertokens/recipes/index.ts index 46d702268..ffe3de880 100644 --- a/packages/user/src/supertokens/recipes/index.ts +++ b/packages/user/src/supertokens/recipes/index.ts @@ -1,4 +1,5 @@ import initEmailVerificationRecipe from "./initEmailVerificationRecipe"; +import initPasswordlessRecipe from "./initPasswordlessRecipe"; import initSessionRecipe from "./initSessionRecipe"; import initThirdPartyEmailPassword from "./initThirdPartyEmailPasswordRecipe"; import initUserRolesRecipe from "./initUserRolesRecipe"; @@ -8,6 +9,7 @@ import type { RecipeListFunction } from "supertokens-node/types"; const getRecipeList = (fastify: FastifyInstance): RecipeListFunction[] => { const recipeList = [ + initPasswordlessRecipe(fastify), initSessionRecipe(fastify), initThirdPartyEmailPassword(fastify), initUserRolesRecipe(fastify), diff --git a/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts b/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts new file mode 100644 index 000000000..f65c109d6 --- /dev/null +++ b/packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts @@ -0,0 +1,19 @@ +import { FastifyInstance } from "fastify"; +import Passwordless from "supertokens-node/recipe/passwordless"; + +import getPasswordlessRecipeConfig from "./config/passwordlessRecipeConfig"; + +import type { SupertokensRecipes } from "../types"; + +const init = (fastify: FastifyInstance) => { + const passwordless: SupertokensRecipes["passwordless"] = + fastify.config.user.supertokens.recipes?.passwordless; + + if (typeof passwordless === "function") { + return Passwordless.init(passwordless(fastify)); + } + + return Passwordless.init(getPasswordlessRecipeConfig(fastify)); +}; + +export default init; diff --git a/packages/user/src/supertokens/types/index.ts b/packages/user/src/supertokens/types/index.ts index b22ad8302..caa988057 100644 --- a/packages/user/src/supertokens/types/index.ts +++ b/packages/user/src/supertokens/types/index.ts @@ -6,10 +6,12 @@ import { } from "supertokens-node/recipe/thirdpartyemailpassword"; import type { EmailVerificationRecipe } from "./emailVerificationRecipe"; +import type { PasswordlessRecipe } from "./passwordlessRecipe"; import type { SessionRecipe } from "./sessionRecipe"; import type { ThirdPartyEmailPasswordRecipe } from "./thirdPartyEmailPasswordRecipe"; import type { FastifyInstance } from "fastify"; import type { TypeInput as EmailVerificationRecipeConfig } from "supertokens-node/recipe/emailverification/types"; +import type { TypeInput as PasswordlessRecipeConfig } from "supertokens-node/recipe/passwordless/types"; import type { TypeInput as SessionRecipeConfig } from "supertokens-node/recipe/session/types"; import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpassword"; import type { TypeInput as ThirdPartyEmailPasswordRecipeConfig } from "supertokens-node/recipe/thirdpartyemailpassword/types"; @@ -19,6 +21,9 @@ interface SupertokensRecipes { emailVerification?: | EmailVerificationRecipe | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig); + passwordless?: + | PasswordlessRecipe + | ((fastify: FastifyInstance) => PasswordlessRecipeConfig); session?: SessionRecipe | ((fastify: FastifyInstance) => SessionRecipeConfig); userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; thirdPartyEmailPassword?: diff --git a/packages/user/src/supertokens/types/passwordlessRecipe.ts b/packages/user/src/supertokens/types/passwordlessRecipe.ts new file mode 100644 index 000000000..13c202106 --- /dev/null +++ b/packages/user/src/supertokens/types/passwordlessRecipe.ts @@ -0,0 +1,31 @@ +import { FastifyInstance } from "fastify"; + +import type { + APIInterface, + RecipeInterface, +} from "supertokens-node/recipe/passwordless/types"; + +type APIInterfaceWrapper = { + [key in keyof APIInterface]?: ( + originalImplementation: APIInterface, + fastify: FastifyInstance, + ) => APIInterface[key]; +}; + +type RecipeInterfaceWrapper = { + [key in keyof RecipeInterface]?: ( + originalImplementation: RecipeInterface, + fastify: FastifyInstance, + ) => RecipeInterface[key]; +}; + +interface PasswordlessRecipe { + contactMethod?: "EMAIL" | "PHONE" | "EMAIL_OR_PHONE"; + flowType?: "USER_INPUT_CODE"; + override?: { + apis?: APIInterfaceWrapper; + functions?: RecipeInterfaceWrapper; + }; +} + +export type { APIInterfaceWrapper, RecipeInterfaceWrapper, PasswordlessRecipe }; diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index d98cd658f..bb961478c 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -23,6 +23,7 @@ interface UserConfig { resetPassword?: EmailOptions; resetPasswordNotification?: EmailOptions; }; + fallbackEmailDomain?: string; features?: { profileValidation?: { /** @@ -122,6 +123,21 @@ interface UserConfig { name?: string; }; }; + twilio?: + | { + accountSid: string; + authToken: string; + from: string; + message?: string; + opts?: Record; + } + | { + accountSid: string; + authToken: string; + message?: string; + messagingServiceSid: string; + opts?: Record; + }; } export type { EmailOptions, UserConfig };