Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
b0f0f5b
Merge branch 'main' of github.com:prefabs-tech/fastify into feat/pass…
anveshdzangolab Jul 27, 2026
0646845
refactor(user): replace passwordless recipe with generic recipe factory
anveshdzangolab Jul 28, 2026
bd5a3e7
feat(passwordless): extract passwordless login into standalone package
anveshdzangolab Jul 28, 2026
7985217
feat(user): add phone_number column to users table
anveshdzangolab Jul 28, 2026
1392b15
feat(user): add SuperTokens recipe registry and user phoneNumber field
anveshdzangolab Jul 28, 2026
db235b5
docs: update documentation and remove unsued dependency
anveshdzangolab Jul 28, 2026
0db79a9
chore: remove supertokens test
anveshdzangolab Jul 28, 2026
3305090
feat(passwordless): add migration to add phone_number to users table
anveshdzangolab Aug 3, 2026
d3273bc
fix(passwordless): defer migration to onReady hook
anveshdzangolab Aug 3, 2026
9624a41
feat(passwordless): augment fastify-user User with phoneNumber
anveshdzangolab Aug 3, 2026
75f9a81
docs: update docs
anveshdzangolab Aug 3, 2026
541a13f
feat: remove phone number from user type and schema
anveshdzangolab Aug 3, 2026
ccd463e
feat(passwordless): expose phoneNumber on GraphQL User type
anveshdzangolab Aug 3, 2026
cfb6620
feat: rename package name to phone-auth
anveshdzangolab Aug 3, 2026
facf461
chore: remove extraction plan docs
anveshdzangolab Aug 3, 2026
c8476ea
Merge branch 'main' of github.com:prefabs-tech/fastify into feat/pass…
anveshdzangolab Aug 4, 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ A set of fastify libraries
- @prefabs.tech/fastify-config (https://www.npmjs.com/package/@prefabs.tech/fastify-config)
- @prefabs.tech/fastify-graphql (https://www.npmjs.com/package/@prefabs.tech/fastify-graphql)
- @prefabs.tech/fastify-mailer (https://www.npmjs.com/package/@prefabs.tech/fastify-mailer)
- @prefabs.tech/fastify-phone-auth (https://www.npmjs.com/package/@prefabs.tech/fastify-phone-auth)
- @prefabs.tech/fastify-s3 (https://www.npmjs.com/package/@prefabs.tech/fastify-s3)
- @prefabs.tech/fastify-slonik (https://www.npmjs.com/package/@prefabs.tech/fastify-slonik)
- @prefabs.tech/fastify-user (https://www.npmjs.com/package/@prefabs.tech/fastify-user)
Expand Down
4 changes: 4 additions & 0 deletions packages/phone-auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**/*.log*
/coverage
/dist
/node_modules
75 changes: 75 additions & 0 deletions packages/phone-auth/FEATURES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<!-- Structured feature inventory — used by automated test generation. Developer docs: see GUIDE.md -->

# @prefabs.tech/fastify-phone-auth — Features

## Plugin Lifecycle

1. **Enable/disable via config flag** — when `config.phoneAuth.enabled === false`, no recipe factory is contributed and the SuperTokens passwordless endpoints are not served. The check is `=== false`; `undefined` means enabled.

2. **Automatic recipe registration** — on registration (when enabled), the plugin pushes `initPasswordlessRecipe` into the SuperTokens recipe registry via `addSupertokensRecipe` from `@prefabs.tech/fastify-user`. No consumer wiring beyond registering the plugin is required.

3. **Registration order guard** — `addSupertokensRecipe` throws when the Fastify instance already carries the `supertokensInitialized` decorator, i.e. when this plugin is registered *after* `@prefabs.tech/fastify-user`. SuperTokens allows exactly one global `init()`, so a late registration could not contribute a recipe; failing loudly beats silently dropping passwordless login.

4. **Phone number migration** — on `onReady` the plugin runs an idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS phone_number VARCHAR ( 20 )` against `config.user.tables?.users?.name` (default `users`). It runs on `onReady` rather than at registration because the users table is created while `@prefabs.tech/fastify-user` registers, and this plugin must be registered before that one. No migration runs when `enabled === false`.

5. **`User` type augmentation** — the package augments the `User` interface from `@prefabs.tech/fastify-user` with `phoneNumber?: string`, which also flows into `UserCreateInput`. `@prefabs.tech/fastify-user` does not carry the field in its REST response schema, so it is not serialized on the users REST routes; GraphQL exposure is handled at runtime, below.

6. **Runtime GraphQL `User` extension** — in the same `onReady` hook, the plugin calls `fastify.graphql.extendSchema("extend type User { phoneNumber: String }")`, so the field appears on the GraphQL `User` type with no consumer wiring. It is guarded by `fastify.graphql?.schema?.getType("User")`: apps without GraphQL enabled, or that never merged `userSchema`, are skipped rather than failed. No resolver is required — the default field resolver reads the camelized `phoneNumber` off the row.

7. **No routes of its own** — this package registers no controllers. The passwordless endpoints are served by the SuperTokens Fastify plugin that `@prefabs.tech/fastify-user` registers.

## Recipe Configuration

8. **Default contact method and flow type** — `contactMethod` defaults to `"PHONE"` and `flowType` to `"USER_INPUT_CODE"`, both overridable through `config.phoneAuth`. `flowType` is typed to `"USER_INPUT_CODE"` only; magic-link flows are not supported.

9. **Full recipe escape hatch** — when `config.phoneAuth.recipe` is a function, it is called with the Fastify instance and its return value is passed straight to `Passwordless.init`, bypassing the generated config entirely.

10. **Boot-time config validation** — `getPasswordlessRecipeConfig` throws when `config.phoneAuth` is absent, when `enableDevMode` is true without a `devModeOtp`, and (outside dev mode) when the Twilio credentials are missing or incomplete. All three run inside `supertokens.init()`, so they fail at boot.

11. **API override wrappers** — each entry in `config.phoneAuth.override.apis` is invoked with `(originalImplementation, fastify)` and spread over the built-in API overrides, so a consumer wrapper wins.

12. **Function override wrappers** — same mechanism for `config.phoneAuth.override.functions` over the built-in `consumeCode` override.

## Twilio Verify Integration

13. **Placeholder user input code** — `getCustomUserInputCode` returns `TWILIO_VERIFY_PLACEHOLDER_CODE` (`"000000"`) for regular numbers, so SuperTokens stores a code while Twilio Verify owns the real OTP.

14. **Dev mode OTP** — when `config.phoneAuth.enableDevMode` is true, `getCustomUserInputCode` returns `devModeOtp` for every number.

15. **Per-number SMS bypass** — outside dev mode, numbers listed in `config.phoneAuth.bypassSmsFor` also get `devModeOtp` and no SMS is sent.

16. **SMS delivery through Twilio Verify** — outside dev mode, `smsDelivery.override.sendSms` calls `verify.v2.services(verifyServiceSid).verifications.create({ channel: "sms", to })`. Send failures are logged and rethrown.

17. **Dev mode skips SMS delivery entirely** — in dev mode the recipe supplies `createAndSendCustomTextMessage` (a log line) instead of `smsDelivery`.

18. **Phone number capture on create** — the `createCodePOST` override copies `input.phoneNumber` onto `input.userContext` so downstream hooks can read it.

19. **OTP verification on consume** — the `consumeCodePOST` override looks the device up by `preAuthSessionId`, then calls `verify.v2.services(verifyServiceSid).verificationChecks.create({ code, to })`. On `approved` it replays the original `consumeCodePOST` with the placeholder code; otherwise it returns `INCORRECT_USER_INPUT_CODE_ERROR`.

20. **Graceful degradation to RESTART_FLOW_ERROR** — a missing device/phone number, unusable Twilio credentials, or a thrown Twilio Verify call all return `{ status: "RESTART_FLOW_ERROR" }` after logging.

21. **Dev mode and bypassed numbers skip Twilio on consume** — they go straight to the original `consumeCodePOST`, which validates against `devModeOtp`.

22. **Magic link flows pass through untouched** — when `input` carries no `userInputCode`, `consumeCodePOST` delegates to the original implementation without contacting Twilio.

23. **Synthetic email enrichment** — successful consume responses get `email` filled in as `<phoneNumber>@<fallbackEmailDomain>` when SuperTokens has none.

## Local User Creation

24. **Role existence check before signup** — `functions.consumeCode` verifies every role in `userContext.roles` (default `[config.user.role ?? ROLE_USER]`) exists, throwing a `SIGNUP_FAILED_ERROR` `CustomError` otherwise.

25. **Local user row on first sign-in** — when SuperTokens reports `createdNewUser`, a row is created through `getUserService` with the id, phone number, and synthetic email. The email domain falls back to the app name lowercased with whitespace stripped plus `.com`.

26. **Rollback on failed insert** — if the local insert throws, the SuperTokens user is deleted via `deleteUser` before the error is rethrown, so the two stores cannot drift.

27. **Missing phone number aborts signup** — when neither a phone number nor an email is available the SuperTokens user is deleted and an error is thrown.

28. **Role assignment** — each role is assigned with `UserRoles.addRoleToUser`; a non-`OK` status is logged rather than thrown.

29. **`lastLoginAt` refresh on returning users** — when no new user was created, `lastLoginAt` is updated; a failure is logged and swallowed so sign-in still succeeds.

30. **Multi-tenant request context** — the user service is built from the request recovered via `getRequestFromUserContext`, so `request.config`, `request.slonik` and `request.dbSchema` win over the Fastify-level ones when present.

## Known Limitations

31. **`bypassSmsFor` does not apply on resend** — `resendCodePOST` is not overridden and `userContext.phoneNumber` is only set by `createCodePOST`, so `getCustomUserInputCode` cannot match a bypassed number on the resend path.
235 changes: 235 additions & 0 deletions packages/phone-auth/GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
# @prefabs.tech/fastify-phone-auth — Developer Guide

## Installation

### For package consumers

```bash
npm install @prefabs.tech/fastify-phone-auth
```

```bash
pnpm add @prefabs.tech/fastify-phone-auth
```

Peer dependencies are listed in [README.md](./README.md#requirements).

### For monorepo development

```bash
pnpm install
pnpm --filter @prefabs.tech/fastify-phone-auth test
pnpm --filter @prefabs.tech/fastify-phone-auth build
```

## Registration order — read this first

SuperTokens permits exactly one global `supertokens.init()`. `@prefabs.tech/fastify-user` performs it synchronously while it is being registered, building its recipe list at that moment. Recipe plugins therefore contribute their recipe through a registry that `@prefabs.tech/fastify-user` drains at init time, which means **this plugin must be registered before it**.

```typescript
await fastify.register(configPlugin, { config });
await fastify.register(slonikPlugin);
await fastify.register(phoneAuthPlugin); // pushes the recipe factory
await fastify.register(userPlugin); // supertokens.init() drains the registry
```

Get the order wrong and registration fails loudly rather than silently dropping passwordless login:

```
Error: SuperTokens is already initialised. Register SuperTokens recipe plugins
before @prefabs.tech/fastify-user.
```

The registry itself is `addSupertokensRecipe`, exported from `@prefabs.tech/fastify-user`. It is generic — any package can use it to contribute a SuperTokens recipe.

## Setup

```typescript
import type { ApiConfig } from "@prefabs.tech/fastify-config";

import configPlugin from "@prefabs.tech/fastify-config";
import phoneAuthPlugin from "@prefabs.tech/fastify-phone-auth";
import slonikPlugin from "@prefabs.tech/fastify-slonik";
import userPlugin from "@prefabs.tech/fastify-user";
import Fastify from "fastify";

const config: ApiConfig = {
// ...the rest of your app config
phoneAuth: {
fallbackEmailDomain: "example.com",
twilio: {
accountSid: process.env.TWILIO_ACCOUNT_SID as string,
authToken: process.env.TWILIO_AUTH_TOKEN as string,
verifyServiceSid: process.env.TWILIO_VERIFY_SERVICE_SID as string,
},
},
};

const fastify = Fastify();

await fastify.register(configPlugin, { config });
await fastify.register(slonikPlugin);
await fastify.register(phoneAuthPlugin);
await fastify.register(userPlugin);
```

All subsequent examples assume this setup.

---

## Base Libraries

### `supertokens-node` — Passwordless recipe (MODIFIED passthrough)

This plugin does not expose routes of its own. It configures SuperTokens' Passwordless recipe, and the SuperTokens Fastify plugin registered by `@prefabs.tech/fastify-user` serves the resulting endpoints (`POST <apiBasePath>/signinup/code`, `POST <apiBasePath>/signinup/code/consume`, `POST <apiBasePath>/signinup/code/resend`). See the [SuperTokens Passwordless docs](https://supertokens.com/docs/passwordless/introduction) for the endpoint contracts.

Our delta over the stock recipe:

- `contactMethod` is constrained to `"EMAIL" | "EMAIL_OR_PHONE" | "PHONE"` and defaults to `"PHONE"`.
- `flowType` is constrained to `"USER_INPUT_CODE"` — magic-link and link-or-code flows are deliberately not supported.
- `getCustomUserInputCode` returns a placeholder rather than a real OTP (see below).
- `apis.consumeCodePOST`, `apis.createCodePOST` and `functions.consumeCode` are overridden. `resendCodePOST` and `functions.createCode` are not.
- `smsDelivery.sendSms` is replaced with a Twilio Verify call, or with a log line in dev mode.

### `twilio` — Verify API (PARTIAL passthrough)

Only the Verify v2 service is used: `verifications.create` to send an OTP and `verificationChecks.create` to check one. Messaging/SMS APIs are not used, which is why `TwilioConfig` omits `from` and `messagingServiceSid` and requires `verifyServiceSid` instead.

---

## How the Twilio Verify bridge works

SuperTokens insists on owning a user input code; Twilio Verify insists on owning the OTP. The two are reconciled like this:

1. Sign in/up hits `createCodePOST`. The override records the phone number on `userContext`, then the SMS-delivery override asks Twilio Verify to send an OTP.
2. SuperTokens still stores a code of its own, so `getCustomUserInputCode` hands it the constant `TWILIO_VERIFY_PLACEHOLDER_CODE` (`"000000"`) instead of the real OTP.
3. The user submits the OTP they received. `consumeCodePOST` looks the device up by `preAuthSessionId` to recover the phone number, then checks the submitted code against Twilio Verify. If Twilio approves, the original `consumeCodePOST` is replayed with the placeholder so SuperTokens can complete its own flow.
4. `functions.consumeCode` then creates the matching row in your `users` table.

## User creation

On first successful sign-in, `functions.consumeCode`:

- Verifies every role in `userContext.roles` (default `[config.user.role ?? "USER"]`) exists, throwing a `SIGNUP_FAILED_ERROR` `CustomError` otherwise.
- Creates the local user with the phone number and a synthetic email of `<phoneNumber>@<fallbackEmailDomain>`, falling back to `<appName lowercased, spaces stripped>.com` when `fallbackEmailDomain` is unset. SuperTokens requires an email; passwordless phone users do not supply one.
- Assigns the roles via `UserRoles.addRoleToUser`.
- Deletes the SuperTokens user again if the local insert fails, so the two stores cannot drift.

On subsequent sign-ins it only updates `lastLoginAt`.

## Migration

This package owns the `phone_number` column. On `onReady` it runs an idempotent

```sql
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number VARCHAR ( 20 );
```

against `config.user.tables?.users?.name` (default `users`). It runs on `onReady`, not at registration time, because the table is created while `@prefabs.tech/fastify-user` registers — and this plugin has to be registered *before* that one.

It also augments the `User` interface from `@prefabs.tech/fastify-user` with `phoneNumber?: string`, so the field is typed wherever `User`, `UserCreateInput`, or `request.user` is used in an app that registers this plugin.

## GraphQL

`@prefabs.tech/fastify-user` does not carry `phoneNumber` in its `User` SDL, so this plugin adds it at runtime. In the same `onReady` hook as the migration it calls:

```typescript
fastify.graphql.extendSchema(`
extend type User {
phoneNumber: String
}
`);
```

No consumer wiring is required — merge `userSchema` as you normally would and the field appears on the `User` type.

Details:

- **No resolver is needed.** The default field resolver reads `phoneNumber` off the row, which the slonik interceptor camelizes from `phone_number`; the user service selects `users.*`, so the value is already there.
- **It is skipped, not failed, when there is nothing to extend.** The hook checks `fastify.graphql?.schema?.getType("User")` first, so an app with `config.graphql.enabled = false` — or one that never merged `userSchema` — boots normally. Without that guard `extendSchema` throws `Cannot extend type "User" because it is not defined.`
- **Registration order does not matter.** The call happens on `onReady`, by which point mercurius has been registered regardless of whether this plugin was registered before or after `@prefabs.tech/fastify-graphql`.
- The REST response schema is separate and unaffected — `phoneNumber` is not serialized on the users REST routes.

## Configuration reference

`config.phoneAuth`:

| Key | Type | Default | Notes |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Only `false` disables; `undefined` means enabled. |
| `contactMethod` | `"EMAIL" \| "EMAIL_OR_PHONE" \| "PHONE"` | `"PHONE"` | |
| `flowType` | `"USER_INPUT_CODE"` | `"USER_INPUT_CODE"` | |
| `fallbackEmailDomain` | `string` | app name + `.com` | Domain of the synthetic email. |
| `enableDevMode` | `boolean` | `false` | Skips Twilio for every number. |
| `devModeOtp` | `string` | — | Required when `enableDevMode` is `true`. |
| `bypassSmsFor` | `string[]` | `[]` | Phone numbers that skip Twilio and accept `devModeOtp`. |
| `twilio` | `TwilioConfig` | — | Required unless `enableDevMode` is `true`. |
| `override` | `{ apis?, functions? }` | — | Per-API/per-function wrappers, applied after the built-in overrides. |
| `recipe` | `(fastify) => TypeInput` | — | Full escape hatch: replaces the generated recipe config entirely. |

`TwilioConfig` is SuperTokens' `TwilioServiceConfig` without `from` and `messagingServiceSid`, plus a required `verifyServiceSid`.

### Disabling the plugin

```typescript
phoneAuth: {
enabled: false;
}
```

No recipe is contributed and the SuperTokens passwordless endpoints are not served.

### Development without Twilio

```typescript
phoneAuth: {
devModeOtp: "123456",
enableDevMode: true,
fallbackEmailDomain: "example.com",
}
```

Every number accepts `123456` and no SMS is sent. To keep Twilio live for real users but bypass it for a handful of test numbers, leave `enableDevMode` off and use `bypassSmsFor` together with `devModeOtp`.

## Overriding behaviour

Wrappers receive the original implementation and the Fastify instance, and are applied **after** the built-in overrides — so replacing `consumeCodePOST` or `consumeCode` removes the Twilio Verify integration or the local user creation respectively.

```typescript
phoneAuth: {
override: {
apis: {
consumeCodePOST: (originalImplementation, fastify) => async (input) => {
fastify.log.info("consuming a passwordless code");

return originalImplementation.consumeCodePOST!(input);
},
},
},
}
```

For total control, bypass the generated config entirely:

```typescript
phoneAuth: {
recipe: (fastify) => ({
contactMethod: "PHONE",
flowType: "USER_INPUT_CODE",
}),
}
```

## Validation and failure modes

`getPasswordlessRecipeConfig` runs during `supertokens.init()`, so configuration mistakes fail at boot rather than on the first sign-in attempt:

- No `config.phoneAuth` at all → `Phone auth config is missing.`
- `enableDevMode: true` without `devModeOtp` → `phoneAuth.devModeOtp is required when phoneAuth.enableDevMode is true`
- Not in dev mode and `twilio` missing or incomplete → `Twilio config is missing for phone auth.` / `accountSid and ... authToken are required`

At request time, a Twilio Verify failure is logged and returned as `RESTART_FLOW_ERROR`; a rejected code returns `INCORRECT_USER_INPUT_CODE_ERROR`.

## Known limitation

`userContext.phoneNumber` is only set by the `createCodePOST` override, so it is unset on the **resend** path (`resendCodePOST` is not overridden). The `bypassSmsFor` check inside `getCustomUserInputCode` therefore cannot match on a resend.
Loading