diff --git a/packages/user/package.json b/packages/user/package.json index 665a85c39..06e028c65 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-user", - "version": "0.94.0", + "version": "0.94.0-beta.2", "description": "Fastify user plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/user#readme", "repository": { @@ -81,4 +81,4 @@ "engines": { "node": ">=20" } -} \ No newline at end of file +} 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..38d0bfbe3 --- /dev/null +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCode.ts @@ -0,0 +1,123 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/passwordless/types"; + +import { CustomError } from "@prefabs.tech/fastify-error-handler"; +import { formatDate } from "@prefabs.tech/fastify-slonik"; +import { User, UserCreateInput } from "src/types"; +import { deleteUser, getRequestFromUserContext } from "supertokens-node"; +import UserRoles from "supertokens-node/recipe/userroles"; + +import { ROLE_USER } from "../../../../constants"; +import getUserService from "../../../../lib/getUserService"; +import areRolesExist from "../../../utils/areRolesExist"; + +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.passwordLessConfig.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: null | undefined | User; + + if (originalResponse.createdNewUser) { + try { + user = await userService.create({ + email, + id: originalResponse.user.id, + 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..881674555 --- /dev/null +++ b/packages/user/src/supertokens/recipes/config/passwordless/consumeCodePost.ts @@ -0,0 +1,23 @@ +import type { FastifyInstance } from "fastify"; +import type { APIInterface } from "supertokens-node/recipe/passwordless/types"; + +import { ROLE_USER } from "../../../../constants"; + +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..dcf8d721b --- /dev/null +++ b/packages/user/src/supertokens/recipes/config/passwordlessRecipeConfig.ts @@ -0,0 +1,176 @@ +import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; +import type { + APIInterface, + TypeInput as PasswordlessRecipeConfig, + RecipeInterface, +} from "supertokens-node/recipe/passwordless/types"; + +import { FastifyInstance } from "fastify"; +import { PasswordlessRecipe } from "src/supertokens/types/passwordlessRecipe"; +import { TwilioService } from "supertokens-node/recipe/passwordless/smsdelivery"; + +import consumeCode from "./passwordless/consumeCode"; +import consumeCodePOST from "./passwordless/consumeCodePost"; + +const getPasswordlessRecipeConfig = ( + fastify: FastifyInstance, +): PasswordlessRecipeConfig => { + const { config } = fastify; + const isDevelopment = config.user.passwordLessConfig.enableDevMode; + const developmentModeOtp = config.user.passwordLessConfig.devModeOtp; + + const isDevelopmentNumber = (phoneNumber: string) => { + const developmentModeNumbers = + config.user.passwordLessConfig.bypassSmsFor || []; + + return developmentModeNumbers.includes(phoneNumber); + }; + + let passwordless: PasswordlessRecipe = {}; + + if (typeof config.user.supertokens.recipes?.passwordless === "object") { + passwordless = config.user.supertokens.recipes.passwordless; + } + + const twilioSettings: TwilioServiceConfig | undefined = isDevelopment + ? undefined + : config.user.passwordLessConfig.twilio; + + if (!isDevelopment && !twilioSettings) { + throw new Error( + "Twilio config is missing for passwordless recipe. Please add twilio config to your app config.", + ); + } + + if ( + !isDevelopment && + twilioSettings && + !("from" in twilioSettings) && + !("messagingServiceSid" in twilioSettings) + ) { + throw new Error( + "Twilio config requires either 'from' or 'messagingServiceSid'.", + ); + } + + return { + contactMethod: passwordless?.contactMethod || "PHONE", + flowType: passwordless?.flowType || "USER_INPUT_CODE", + getCustomUserInputCode: async (userContext) => { + const phoneNumber = userContext?.phoneNumber as string | undefined; + + if (isDevelopment || (phoneNumber && isDevelopmentNumber(phoneNumber))) { + return developmentModeOtp; + } + + // TODO [AJ 20260512] Check how supertokens generates OTP by default and use that logic here + return Math.floor(100_000 + Math.random() * 900_000).toString(); + }, + 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), + createCodePOST: async (input) => { + if ("phoneNumber" in input) { + input.userContext.phoneNumber = input.phoneNumber; + } + + return originalImplementation.createCodePOST!(input); + }, + ...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 [${developmentModeOtp}] for testing.`, + ); + }, + } + : { + smsDelivery: { + service: new TwilioService({ + override: (originalImplementation) => { + return { + ...originalImplementation, + getContent: async (input) => { + const message = + config.user.passwordLessConfig.smsMessage || + "Your verification code is:"; + + return { + body: `${message} ${input.userInputCode}.`, + toPhoneNumber: input.phoneNumber, + }; + }, + sendRawSms: async (input) => { + if (isDevelopmentNumber(input.toPhoneNumber)) { + fastify.log.info( + `Skipping SMS for test number ${input.toPhoneNumber}. SMS body: [${input.body}]`, + ); + + return; + } + + await originalImplementation.sendRawSms(input); + }, + }; + }, + twilioSettings: twilioSettings as TwilioServiceConfig, + }), + }, + }), + }; +}; + +export default getPasswordlessRecipeConfig; diff --git a/packages/user/src/supertokens/recipes/index.ts b/packages/user/src/supertokens/recipes/index.ts index 0c589bc40..6daf9f76b 100644 --- a/packages/user/src/supertokens/recipes/index.ts +++ b/packages/user/src/supertokens/recipes/index.ts @@ -2,12 +2,14 @@ import type { FastifyInstance } from "fastify"; import type { RecipeListFunction } from "supertokens-node/types"; import initEmailVerificationRecipe from "./initEmailVerificationRecipe"; +import initPasswordlessRecipe from "./initPasswordlessRecipe"; import initSessionRecipe from "./initSessionRecipe"; import initThirdPartyEmailPassword from "./initThirdPartyEmailPasswordRecipe"; import initUserRolesRecipe from "./initUserRolesRecipe"; 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..77229a706 --- /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 type { SupertokensRecipes } from "../types"; + +import getPasswordlessRecipeConfig from "./config/passwordlessRecipeConfig"; + +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 a7df5c2ee..4492e8c2a 100644 --- a/packages/user/src/supertokens/types/index.ts +++ b/packages/user/src/supertokens/types/index.ts @@ -1,5 +1,6 @@ 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"; @@ -13,6 +14,7 @@ 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"; @@ -36,6 +38,9 @@ interface SupertokensRecipes { emailVerification?: | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig) | EmailVerificationRecipe; + passwordless?: + | ((fastify: FastifyInstance) => PasswordlessRecipeConfig) + | PasswordlessRecipe; session?: ((fastify: FastifyInstance) => SessionRecipeConfig) | SessionRecipe; thirdPartyEmailPassword?: | ((fastify: FastifyInstance) => ThirdPartyEmailPasswordRecipeConfig) @@ -43,6 +48,25 @@ interface SupertokensRecipes { userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; } +interface SupertokensRecipes { + emailVerification?: + | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig) + | EmailVerificationRecipe; + session?: ((fastify: FastifyInstance) => SessionRecipeConfig) | SessionRecipe; + thirdPartyEmailPassword?: + | ((fastify: FastifyInstance) => ThirdPartyEmailPasswordRecipeConfig) + | ThirdPartyEmailPasswordRecipe; + userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; +} + +interface SupertokensThirdPartyProvider { + apple?: Parameters[0][]; + custom?: TypeProvider[]; + facebook?: Parameters[0]; + github?: Parameters[0]; + google?: Parameters[0]; +} + interface SupertokensThirdPartyProvider { apple?: Parameters[0][]; custom?: TypeProvider[]; diff --git a/packages/user/src/supertokens/types/passwordlessRecipe.ts b/packages/user/src/supertokens/types/passwordlessRecipe.ts new file mode 100644 index 000000000..a2e2b2f9a --- /dev/null +++ b/packages/user/src/supertokens/types/passwordlessRecipe.ts @@ -0,0 +1,31 @@ +import type { + APIInterface, + RecipeInterface, +} from "supertokens-node/recipe/passwordless/types"; + +import { FastifyInstance } from "fastify"; + +type APIInterfaceWrapper = { + [key in keyof APIInterface]?: ( + originalImplementation: APIInterface, + fastify: FastifyInstance, + ) => APIInterface[key]; +}; + +interface PasswordlessRecipe { + contactMethod?: "EMAIL" | "EMAIL_OR_PHONE" | "PHONE"; + flowType?: "USER_INPUT_CODE"; + override?: { + apis?: APIInterfaceWrapper; + functions?: RecipeInterfaceWrapper; + }; +} + +type RecipeInterfaceWrapper = { + [key in keyof RecipeInterface]?: ( + originalImplementation: RecipeInterface, + fastify: FastifyInstance, + ) => RecipeInterface[key]; +}; + +export type { APIInterfaceWrapper, PasswordlessRecipe, RecipeInterfaceWrapper }; diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index 1b965494f..f69a1ff5d 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -1,4 +1,5 @@ import type { FastifyRequest } from "fastify"; +import type { TwilioServiceConfig } from "supertokens-node/lib/build/ingredients/smsdelivery/services/twilio"; import type { SupertokensConfig } from "../supertokens"; import type { Invitation } from "./invitation"; @@ -15,6 +16,7 @@ interface EmailOptions { subject?: string; templateName?: string; } + interface UserConfig { email?: IsEmailOptions; emailOverrides?: { @@ -88,6 +90,14 @@ interface UserConfig { ) => Promise; }; password?: StrongPasswordOptions; + passwordLessConfig: { + bypassSmsFor?: string[]; + devModeOtp: string; + enableDevMode: boolean; + fallbackEmailDomain?: string; + smsMessage?: string; + twilio?: TwilioServiceConfig; + }; permissions?: string[]; photoMaxSizeInMB?: number; role?: string;