@narcisbodea/smstunnel-otp 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NARBOWEB SRL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,234 @@
1
- # Temporary Holding Version
1
+ # SMSTunnel OTP
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Send the sign-in and verification codes of **Supabase Auth, Auth0, Clerk, Better Auth and Ory** from **your own Android phone**, from **your own number**, with [SMSTunnel](https://smstunnel.io).
4
+
5
+ Your auth platform creates the code as usual. This package hands it to your SMSTunnel account, and your phone sends it from its own SIM, so people get the code from a number they know. There is no per-message bill from an SMS provider. Codes go through SMSTunnel's 2FA endpoint (`POST /api/v1/sms/send-2fa`), which has its own allowance, separate from your regular texts.
6
+
7
+ Your phone needs the SMSTunnel app from Google Play:
8
+
9
+ <a href="https://play.google.com/store/apps/details?id=io.smstunnel.app"><img src="https://smstunnel.io/google-play-badge.png" alt="Get it on Google Play" height="56"></a>
10
+
11
+ No runtime dependencies (Fetch and WebCrypto only): it runs in Node 18+, Deno (Supabase Edge Functions), Bun, Cloudflare Workers and Auth0 Actions.
12
+
13
+ ```bash
14
+ npm install @narcisbodea/smstunnel-otp
15
+ ```
16
+
17
+ ## Before you start
18
+
19
+ 1. Install the [SMSTunnel app from Google Play](https://play.google.com/store/apps/details?id=io.smstunnel.app) on your Android phone and sign in. Sign in at [smstunnel.io](https://smstunnel.io) with the same account: the phone appears under **Phones**.
20
+ 2. In your SMSTunnel dashboard, open **Apps** → **Add an app** and create its key (sending texts is all it needs). Keep the key in your platform's secrets, never in client code.
21
+ 3. Keep the default **6-digit** codes. SMSTunnel's code endpoint recognizes codes of 4, 6, 7 or 8 digits. If you change the code length (in Auth0 or Better Auth, for example), pick one of these.
22
+
23
+ If your phone is offline, the code waits and goes out as soon as the phone is back.
24
+
25
+ ## Supabase Auth: Send SMS hook
26
+
27
+ Supabase calls an HTTPS endpoint with the user's number and the code instead of its built-in SMS provider. This package gives you that endpoint, including the Standard Webhooks signature check.
28
+
29
+ `supabase/functions/send-sms/index.ts`:
30
+
31
+ ```ts
32
+ import { createSupabaseSendSmsHook } from 'npm:@narcisbodea/smstunnel-otp@^1.0.0';
33
+
34
+ Deno.serve(
35
+ createSupabaseSendSmsHook({
36
+ apiKey: Deno.env.get('SMSTUNNEL_API_KEY')!,
37
+ hookSecret: Deno.env.get('SEND_SMS_HOOK_SECRETS')!, // v1,whsec_...
38
+ template: 'Your Acme code is {code}', // optional
39
+ }),
40
+ );
41
+ ```
42
+
43
+ A ready copy, with a `deno.json` for the import, is in [`examples/supabase/functions/send-sms`](https://cdn.jsdelivr.net/npm/@narcisbodea/smstunnel-otp/examples/supabase/functions/send-sms/).
44
+
45
+ 1. Deploy it without JWT checks (the signature protects it): `supabase functions deploy send-sms --no-verify-jwt`
46
+ 2. Dashboard → **Authentication → Hooks** → **Add a new hook** → **Send SMS hook** → type **HTTPS**, URL `https://<project-ref>.supabase.co/functions/v1/send-sms` → **Generate secret** → **Create**.
47
+ 3. Store the secrets: `supabase secrets set SMSTUNNEL_API_KEY=... SEND_SMS_HOOK_SECRETS='v1,whsec_...'`
48
+ 4. Dashboard → **Authentication → Sign In / Providers → Phone**: enable it and save. The SMS provider fields aren't used while the hook is on. Keep phone auto-confirm off, or sign-ups never ask for a code.
49
+
50
+ What the hook does:
51
+
52
+ - It verifies the signature with your secret: `webhook-id`, `webhook-timestamp` and `webhook-signature`, HMAC-SHA256, constant-time comparison, 5-minute window. Several secrets separated by `|` are accepted while you rotate.
53
+ - It sends the code to `sms.phone` (Supabase sets it to the new number when a user changes theirs, and to the MFA factor's number), or else to `user.phone`. It adds the `+` that Supabase leaves out.
54
+ - It answers `200 {}` on success. Errors use Supabase's shape, `{"error":{"http_code":…,"message":"…"}}`. A rate limit or an unreachable server answers 429 or 503 with `retry-after`, which Supabase retries within its 5-second budget. The default timeout is 4 s.
55
+
56
+ For local development, add this to `supabase/config.toml`:
57
+
58
+ ```toml
59
+ [auth.hook.send_sms]
60
+ enabled = true
61
+ uri = "http://host.docker.internal:54321/functions/v1/send-sms"
62
+ secrets = "env(SEND_SMS_HOOK_SECRETS)"
63
+
64
+ [functions.send-sms]
65
+ verify_jwt = false
66
+ ```
67
+
68
+ ## Auth0: Actions
69
+
70
+ Auth0 can hand phone messages to an Action. Use the `custom-phone-provider` trigger, which Auth0 recommends (one provider for MFA, passwordless SMS and phone sign-up). The older `send-phone-message` trigger, for MFA only, is supported too.
71
+
72
+ ### Custom Phone Provider (recommended)
73
+
74
+ **Branding → Phone Provider → Custom**, delivery method **Text**. In the Action editor, add the dependency `@narcisbodea/smstunnel-otp` and the secret `SMSTUNNEL_API_KEY`, then paste:
75
+
76
+ ```js
77
+ const { createAuth0CustomPhoneProvider } = require('@narcisbodea/smstunnel-otp');
78
+
79
+ const sendWithSmsTunnel = createAuth0CustomPhoneProvider();
80
+
81
+ exports.onExecuteCustomPhoneProvider = async (event, api) => {
82
+ await sendWithSmsTunnel(event, api);
83
+ };
84
+ ```
85
+
86
+ Save, then use **Send Test Message**. For MFA, also turn on **Use Tenant-Level Messaging Provider** under **Security → Multi-factor Auth → Phone Message**.
87
+
88
+ - Codes (`otp_verify`, `otp_enroll`) go through the 2FA endpoint, using the text Auth0 rendered (your template and the user's language). Pass `{ template: 'Your Acme code is {code}' }` to write your own.
89
+ - Notices without a code (`blocked_account`, `change_password`, `password_breach`) are sent as regular texts. Pass `{ nonCodeMessages: 'skip' }` to ignore them.
90
+ - Failures are reported to Auth0 as documented. Rate limits and network errors call `api.notification.retry(reason)`, which Auth0 retries up to 5 times. A bad key or a bad number calls `api.notification.drop(reason)`.
91
+ - SMSTunnel sends text messages only. A voice delivery is dropped with a clear reason; set the delivery method to **Text**.
92
+
93
+ If you'd rather not add a dependency, [`examples/auth0/custom-phone-provider.no-dependency.js`](https://cdn.jsdelivr.net/npm/@narcisbodea/smstunnel-otp/examples/auth0/custom-phone-provider.no-dependency.js) does the same in about twenty lines.
94
+
95
+ ### Send Phone Message (MFA)
96
+
97
+ **Security → Multi-factor Auth → Phone Message**, delivery method **SMS**, provider **Custom**. Same dependency and secret, then:
98
+
99
+ ```js
100
+ const { createAuth0SendPhoneMessage } = require('@narcisbodea/smstunnel-otp');
101
+
102
+ const sendWithSmsTunnel = createAuth0SendPhoneMessage();
103
+
104
+ exports.onExecuteSendPhoneMessage = async (event, api) => {
105
+ await sendWithSmsTunnel(event, api);
106
+ };
107
+ ```
108
+
109
+ It throws when the code can't be sent, so Auth0 logs the failure; a `voice` message throws a clear error. Optional secrets for both triggers: `SMSTUNNEL_DEVICE_ID` (send from one phone of your account) and `SMSTUNNEL_SERVER_URL` (self-hosted SMSTunnel).
110
+
111
+ ## Clerk: `sms.created` webhook
112
+
113
+ When you turn **Delivered by Clerk** off for an SMS template, Clerk stops sending that text and sends you an `sms.created` webhook instead. This package gives you the endpoint, with the Svix (Standard Webhooks) signature check.
114
+
115
+ Next.js, `app/api/webhooks/clerk-sms/route.ts`:
116
+
117
+ ```ts
118
+ import { createClerkSmsWebhook } from '@narcisbodea/smstunnel-otp';
119
+
120
+ export const POST = createClerkSmsWebhook({
121
+ apiKey: process.env.SMSTUNNEL_API_KEY!,
122
+ signingSecret: process.env.CLERK_SMS_WEBHOOK_SIGNING_SECRET!, // whsec_...
123
+ });
124
+ ```
125
+
126
+ 1. Clerk Dashboard → **Webhooks** → **Add Endpoint** → your URL, subscribe to `sms.created` → copy the **Signing Secret**.
127
+ 2. **Customization → SMS** → open each template you want sent from your phone (Verification code, Reset password code…) → turn **Delivered by Clerk** off. The setting is per template.
128
+ 3. Keep the route public in your Clerk middleware: webhooks carry no session.
129
+
130
+ It sends `data.to_phone_number` the text Clerk rendered (`data.message`) through the 2FA endpoint, making sure `data.data.otp_code` is in it. Events with `delivered_by_clerk: true` and other event types get a `200` and nothing is sent. Templates without a code are sent as regular texts; pass `nonCodeMessages: 'skip'` to ignore them. A bad signature answers `400`.
131
+
132
+ Other frameworks: `handler.process({ method, headers, body })` returns `{ status, headers, body }`. It needs the raw body; see [`examples/clerk/express.mjs`](https://cdn.jsdelivr.net/npm/@narcisbodea/smstunnel-otp/examples/clerk/express.mjs).
133
+
134
+ ## Better Auth: `phoneNumber` plugin
135
+
136
+ ```ts
137
+ import { betterAuth } from 'better-auth';
138
+ import { phoneNumber } from 'better-auth/plugins';
139
+ import { smstunnelSendOTP } from '@narcisbodea/smstunnel-otp';
140
+
141
+ const apiKey = process.env.SMSTUNNEL_API_KEY!;
142
+
143
+ export const auth = betterAuth({
144
+ plugins: [
145
+ phoneNumber({
146
+ sendOTP: smstunnelSendOTP({ apiKey, template: 'Your Acme code is {code}' }),
147
+ sendPasswordResetOTP: smstunnelSendOTP({ apiKey, template: 'Your Acme password reset code is {code}' }),
148
+ }),
149
+ ],
150
+ });
151
+ ```
152
+
153
+ Better Auth runs `sendOTP` in the background when it can, so sign-in doesn't wait for the send. If people type local numbers, add `defaultCountryCode: '40'` (or your country), or validate numbers with the plugin's `phoneNumberValidator`.
154
+
155
+ ## Ory: Kratos and Ory Network
156
+
157
+ No code needed: Ory's courier HTTP channel calls SMSTunnel directly. Merge this into your identity config ([`examples/ory/kratos.courier.yml`](https://cdn.jsdelivr.net/npm/@narcisbodea/smstunnel-otp/examples/ory/kratos.courier.yml)):
158
+
159
+ ```yaml
160
+ courier:
161
+ channels:
162
+ - id: sms
163
+ type: http
164
+ request_config:
165
+ url: https://smstunnel.io/api/v1/sms/send-2fa
166
+ method: POST
167
+ body: base64://ZnVuY3Rpb24oY3R4KSB7CiAgdG86IGN0eC5yZWNpcGllbnQsCiAgbWVzc2FnZTogY3R4LmJvZHksCn0K
168
+ headers:
169
+ Content-Type: application/json
170
+ auth:
171
+ type: api_key
172
+ config:
173
+ name: X-API-Key
174
+ value: YOUR_SMSTUNNEL_API_KEY
175
+ in: header
176
+ ```
177
+
178
+ The `body` is this Jsonnet, base64-encoded ([`examples/ory/smstunnel-sms.jsonnet`](https://cdn.jsdelivr.net/npm/@narcisbodea/smstunnel-otp/examples/ory/smstunnel-sms.jsonnet); self-hosted Kratos can also use `file://…`):
179
+
180
+ ```jsonnet
181
+ function(ctx) {
182
+ to: ctx.recipient,
183
+ message: ctx.body,
184
+ }
185
+ ```
186
+
187
+ - **Ory Network:** `ory get identity-config --project <id> --workspace <id> --format yaml > identity-config.yaml`, add the block, then `ory update identity-config --project <id> --workspace <id> --file identity-config.yaml`. You can also enter it in the Console's SMS configuration.
188
+ - **Self-hosted Kratos:** add the block to `kratos.yml` and run the courier (`kratos serve --watch-courier` or `kratos courier watch`).
189
+ - Your identity schema needs a phone trait with `"via": "sms"` (for example `"credentials": { "code": { "identifier": true, "via": "sms" } }` for sign-in codes, `"verification": { "via": "sms" }` for verification). Ory has SMS templates for `login_code`, `verification_code` and `recovery_code`, and they include the code.
190
+
191
+ ## Any other platform
192
+
193
+ ```ts
194
+ import { sendCode, SmsTunnelOtpError } from '@narcisbodea/smstunnel-otp';
195
+
196
+ await sendCode({
197
+ apiKey: process.env.SMSTUNNEL_API_KEY!,
198
+ to: '+14155550123',
199
+ code: '123456',
200
+ template: 'Your Acme code is {code}', // or message: 'full text'; the code is added if it is missing
201
+ // deviceId, defaultCountryCode, serverUrl, timeoutMs (default 10000)
202
+ });
203
+ ```
204
+
205
+ `sendCode` always puts the code in the text, as a word of its own, and refuses texts over 320 characters before calling SMSTunnel. It sends one recipient per call.
206
+
207
+ ## Errors
208
+
209
+ Everything throws (or reports) a `SmsTunnelOtpError`:
210
+
211
+ | Field | Meaning |
212
+ | --- | --- |
213
+ | `status` | HTTP status from SMSTunnel; `0` for a timeout, a network error or a refused argument |
214
+ | `code` | SMSTunnel's own code when it sends one (for example `PERMISSION_REQUIRED`), else `UNAUTHORIZED` (401), `FORBIDDEN` (403), `BAD_REQUEST` (400), `RATE_LIMITED` (429), `SERVER_ERROR` (5xx), `TIMEOUT`, `NETWORK_ERROR`, `INVALID_ARGUMENT`, `MESSAGE_TOO_LONG`, `UNSUPPORTED_DELIVERY_METHOD` |
215
+ | `retryable` | `true` for 429, 5xx, timeouts and network errors |
216
+ | `message` | What went wrong, in words |
217
+
218
+ A webhook that fails the signature check throws `WebhookVerificationError` (inside the Supabase and Clerk handlers it becomes a 401 or 400 answer, and nothing is sent). `verifyWebhook()` and `signWebhook()` are exported if you need the check elsewhere.
219
+
220
+ ## Security notes
221
+
222
+ - Signatures are checked over the raw body before anything is parsed. Old or future timestamps (more than 5 minutes off) are refused, and malformed or short secrets fail at start-up.
223
+ - Each handler remembers recent webhook ids, so a retry of a code that already went out isn't sent twice. This memory is per instance.
224
+ - Nothing in this package logs codes or keys.
225
+
226
+ ## Tests
227
+
228
+ Unit tests use a mocked Fetch. Every adapter was also run end to end against a local SMSTunnel server, the Supabase example under Deno, and the Ory config with a real Kratos in Docker.
229
+
230
+ ## License
231
+
232
+ MIT © NARBOWEB SRL
233
+
234
+ Supabase, Auth0, Clerk, Better Auth and Ory are trademarks of their respective owners. SMSTunnel OTP is not affiliated with, endorsed by, or sponsored by Supabase, Auth0 (Okta), Clerk, Better Auth or Ory.