Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2e137cf
feat: create a passwordless recipe
samarajyastha Mar 16, 2026
4584f1d
feat: create passwordless recipe config
samarajyastha Mar 16, 2026
0ef6d36
feat: use passwordless recipe in supertokens auth
samarajyastha Mar 16, 2026
fd7af32
feat: update implementation usage of passwordless auth
samarajyastha Mar 16, 2026
1df4a19
feat: add twilio types in config
samarajyastha Mar 16, 2026
dc20614
feat: use twilio service for sms delivery
samarajyastha Mar 16, 2026
c2ad862
feat: implement overriding the sms text
samarajyastha Mar 16, 2026
9c3f100
feat: add consumeCode function for passwordless user creation and ove…
anveshdzangolab Mar 31, 2026
cdebc0e
feat: make twilio configuration optional and add error handling for m…
anveshdzangolab Mar 31, 2026
6fd9a63
feat: add fallbackEmailDomain option to UserConfig interface
anveshdzangolab Apr 1, 2026
087ec99
feat: implement consumeCodePOST function and integrate with passwordl…
anveshdzangolab Apr 1, 2026
b30fdfd
feat: add twilio configuration options to UserConfig interface
anveshdzangolab Apr 1, 2026
e006153
feat: add local development env support with custom OTP
anveshdzangolab Apr 1, 2026
9323b5a
refactor: update passwordless configuration structure and improve Twi…
anveshdzangolab Apr 2, 2026
5cc001a
refactor: make fallbackEmailDomain, smsMessage, and twilio optional i…
anveshdzangolab Apr 2, 2026
71f485c
Merge branch 'main' of github.com:prefabs-tech/fastify into refactor/…
anveshdzangolab May 12, 2026
c28c3a0
fix: lint errors
anveshdzangolab May 12, 2026
6275d4d
feat: add development mode bypass for SMS in passwordless config
anveshdzangolab May 12, 2026
362fa1c
chore: add comment to use supertokens otp genration logic
anveshdzangolab May 12, 2026
d0a0bbb
chore(user): bump version to 0.94.0-beta.2 for beta release
uddhab May 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/user/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -81,4 +81,4 @@
"engines": {
"node": ">=20"
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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<APIInterface> = {};

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<RecipeInterface> = {};

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;
2 changes: 2 additions & 0 deletions packages/user/src/supertokens/recipes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
19 changes: 19 additions & 0 deletions packages/user/src/supertokens/recipes/initPasswordlessRecipe.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading