Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
f2dce4d
Merge branch 'main' of github.com:prefabs-tech/fastify into refactor/…
anveshdzangolab Jul 2, 2026
02eff4c
feat: add twilio dependency for SMS functionality
anveshdzangolab Jul 2, 2026
013b84f
feat: update UserConfig with passwordless login and Twilio configuration
anveshdzangolab Jul 2, 2026
34b814d
feat: integrate Twilio Verify for passwordless authentication and upd…
anveshdzangolab Jul 2, 2026
c4a5536
feat: conditionally add passwordless recipe to recipe list
anveshdzangolab Jul 2, 2026
33fb268
feat: remove smsMessage from UserConfig
anveshdzangolab Jul 2, 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
1 change: 1 addition & 0 deletions packages/user/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"dependencies": {
"humps": "2.0.1",
"twilio": "6.0.0",
"validator": "13.15.35"
},
"devDependencies": {
Expand Down
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 phone number 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,136 @@
import type { FastifyInstance } from "fastify";
import type { APIInterface } from "supertokens-node/recipe/passwordless/types";

import Passwordless from "supertokens-node/recipe/passwordless";
import twilio from "twilio";

import { ROLE_USER } from "../../../../constants";

export const TWILIO_VERIFY_PLACEHOLDER_CODE = "000000";

const enrichResult = (
result: Awaited<ReturnType<NonNullable<APIInterface["consumeCodePOST"]>>>,
phoneNumber: string,
fallbackEmailDomain: string,
) => {
if (result.status !== "OK") {
return result;
}

return {
...result,
user: {
...result.user,
email: result.user.email ?? `${phoneNumber}@${fallbackEmailDomain}`,
},
};
};

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");
}

// Only handle user input code flows, not magic link flows
if (!("userInputCode" in input) || input.userInputCode === undefined) {
return originalImplementation.consumeCodePOST(input);
}

const { config } = fastify;

if (!config.user.passwordLessConfig) {
throw new Error("Passwordless recipe config is missing");
}

const isDevelopment = config.user.passwordLessConfig.enableDevMode;

// Look up the device to retrieve the associated phone number
const deviceContext = await Passwordless.listCodesByPreAuthSessionId({
preAuthSessionId: input.preAuthSessionId,
});

if (!deviceContext || !deviceContext.phoneNumber) {
return { status: "RESTART_FLOW_ERROR" };
}

const { phoneNumber } = deviceContext;
const bypassNumbers = config.user.passwordLessConfig.bypassSmsFor ?? [];

const fallbackEmailDomain =
config.user.passwordLessConfig.fallbackEmailDomain ?? "";

// In dev mode or for bypassed numbers, skip Twilio Verify and let
// SuperTokens verify the code directly (uses devModeOtp)
if (isDevelopment || bypassNumbers.includes(phoneNumber)) {
return enrichResult(
await originalImplementation.consumeCodePOST(input),
phoneNumber,
fallbackEmailDomain,
);
}

const verifyServiceSid =
config.user.passwordLessConfig?.twilio?.verifyServiceSid;

if (!verifyServiceSid) {
fastify.log.error("TWILIO_VERIFY_SERVICE_SID is not configured");
return { status: "RESTART_FLOW_ERROR" };
}

const twilioConfig = config.user.passwordLessConfig.twilio;

if (!twilioConfig) {
fastify.log.error("Twilio config is missing for passwordless recipe");
return { status: "RESTART_FLOW_ERROR" };
}

if (!twilioConfig.accountSid || !twilioConfig.authToken) {
fastify.log.error(
"TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are required for passwordless verification",
);
return { status: "RESTART_FLOW_ERROR" };
}

const twilioClient = twilio(
twilioConfig.accountSid,
twilioConfig.authToken,
);

try {
const check = await twilioClient.verify.v2
.services(verifyServiceSid)
.verificationChecks.create({
code: input.userInputCode,
to: phoneNumber,
});

return check.status === "approved"
? enrichResult(
await originalImplementation.consumeCodePOST({
...input,
userInputCode: TWILIO_VERIFY_PLACEHOLDER_CODE,
}),
phoneNumber,
fallbackEmailDomain,
)
: {
failedCodeInputAttemptCount: 1,
maximumCodeInputAttempts: 5,
status: "INCORRECT_USER_INPUT_CODE_ERROR",
};
} catch (error) {
fastify.log.error(error, "Twilio Verify verification check failed");
return { status: "RESTART_FLOW_ERROR" };
}
};
};

export default consumeCodePOST;
Loading
Loading