@guettonenrique/passwordless-auth 0.0.0-stage → 0.1.2

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 HouseVenture
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,388 @@
1
- # Temporary Holding Version
1
+ # @guettonenrique/passwordless-auth
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
+ Passwordless **email OTP** authentication in one package with three entry points:
4
+
5
+ - **`/functions`** — ready-to-register Firebase Cloud Function factories for
6
+ sending codes, verifying codes, and scheduled cleanup.
7
+ - **`/react-native`** — the mobile API layer plus `OtpInput` (auto-fill code
8
+ boxes) and `EmailOtpModal` (the full two-step dialog).
9
+ - **`/shared`** — the single source of truth for constants, types, validation,
10
+ and the email template. Pure TypeScript with no SDK imports.
11
+
12
+ The React Native entry point is optional. The backend functions can be used
13
+ without installing the package in the client, allowing the app to own the
14
+ email form, OTP input, modal, styling, resend timer, and error handling.
15
+
16
+ The security model is baked in and tested: SHA-256 hashed codes (never
17
+ plaintext), timing-safe comparison, single-use codes deleted inside the verify
18
+ transaction, a five-attempt brute-force cap, a 60-second resend cooldown,
19
+ 10-minute expiry, and client-side access to the OTP collection blocked by
20
+ rules.
21
+
22
+ > **v0.1** — first publish. API may still change; breaking changes allowed until 1.0.
23
+
24
+ ---
25
+
26
+ ## Package overview
27
+
28
+ ### Backend (`/functions`)
29
+
30
+ Three factory functions return ready-to-deploy Firebase Cloud Functions:
31
+
32
+ | Factory | What it handles |
33
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | `createRequestEmailOtp` | Email validation, 60 s resend cooldown, cryptographically secure 6-digit code generation, SHA-256 hashing (`email:code`), Firestore write, email dispatch (SendGrid default or custom sender), structured error responses |
35
+ | `createVerifyEmailOtp` | Code lookup, timing-safe hash comparison, expiry check, 5-attempt brute-force cap, single-use deletion inside a Firestore transaction, user provisioning (delegated to your hook), Firebase custom token minting |
36
+ | `createCleanupExpiredOtps` | Scheduled (every 15 min) drain-loop that deletes orphaned OTP docs (users who requested but never entered a code) |
37
+
38
+ ### Mobile (`/react-native`)
39
+
40
+ The mobile entry point provides the API layer and UI components:
41
+
42
+ | Export | What it does |
43
+ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
+ | `requestEmailOtp(email)` | Calls the backend, returns `{ status, message }` |
45
+ | `verifyEmailOtp(email, code)` | Calls the backend, returns the Firebase custom token |
46
+ | `signInWithEmailOtp(email, code)` | Verifies + `signInWithCustomToken` — one call, user is signed in |
47
+ | `<OtpInput />` | 6-slot auto-fill code input (paste, iOS `oneTimeCode` / Android `one-time-code`, haptics, auto-submit on completion) |
48
+ | `<EmailOtpModal />` | Full two-step dialog (email → code) with cooldown countdown, resend, error banner, double-submit guard. On success the modal closes and your existing `onAuthStateChanged` listener takes over |
49
+
50
+ ### Shared (`/shared`)
51
+
52
+ Constants, types, email template, and validation utilities. This entry point
53
+ is pure TypeScript with no SDK imports, keeping the backend and mobile
54
+ contracts in sync.
55
+
56
+ ### Security model
57
+
58
+ - Codes are SHA-256 hashed before storage — plaintext never touches Firestore
59
+ - Timing-safe comparison prevents hash-timing attacks
60
+ - Single-use: the doc is deleted inside the verify transaction (concurrent verifies → one winner)
61
+ - 5-attempt cap per code, then the doc is destroyed
62
+ - 60-second resend cooldown, 10-minute code expiry
63
+ - Client-side access to the OTP collection is blocked by Firestore rules (only admin SDK writes)
64
+ - App Check enforcement is a one-flag option (`enforceAppCheck: true`)
65
+
66
+ ---
67
+
68
+ ## Quick start
69
+
70
+ ### Backend
71
+
72
+ 1. **Install** the package (see below).
73
+ 2. **Register the three functions** — ~30 lines total:
74
+
75
+ ```typescript
76
+ // functions/src/index.ts (or per-function files)
77
+ import {
78
+ createRequestEmailOtp,
79
+ createVerifyEmailOtp,
80
+ createCleanupExpiredOtps,
81
+ } from "@guettonenrique/passwordless-auth/functions";
82
+
83
+ exports.requestEmailOtp = createRequestEmailOtp({
84
+ appName: "MyApp",
85
+ sendgrid: {
86
+ apiKey: defineSecret("SENDGRID_API_KEY"),
87
+ fromEmail,
88
+ fromName,
89
+ },
90
+ });
91
+
92
+ exports.verifyEmailOtp = createVerifyEmailOtp({
93
+ provision: async (email) => {
94
+ // Your user schema: resolve or create, return the uid.
95
+ const { user } = await resolveOrCreateUser(email, {
96
+ emailVerified: true,
97
+ });
98
+ return user.uid;
99
+ },
100
+ });
101
+
102
+ exports.cleanupExpiredOtps = createCleanupExpiredOtps();
103
+ ```
104
+
105
+ The **only required customization** is the `provision` hook — it owns your user schema (Firestore docs, subscribers, notification settings, etc.). Everything else is config or defaults.
106
+
107
+ 3. **Lock down the OTP collection** in `firestore.rules`:
108
+
109
+ ```javascript
110
+ match /auth_otps/{document=**} {
111
+ allow read, write: if false;
112
+ }
113
+ ```
114
+
115
+ 4. **Deploy**: `firebase deploy --only functions,firestore:rules`
116
+
117
+ ### Mobile
118
+
119
+ 1. **Install** the package + the mobile peers (see table below).
120
+ 2. **Add the modal** to your auth screen — ~10 lines:
121
+
122
+ ```tsx
123
+ import { EmailOtpModal } from '@guettonenrique/passwordless-auth/react-native';
124
+
125
+ const [otpVisible, setOtpVisible] = useState(false);
126
+
127
+ <TouchableOpacity onPress={() => setOtpVisible(true)}>
128
+ <Text>Continue with email code</Text>
129
+ </TouchableOpacity>
130
+
131
+ <EmailOtpModal visible={otpVisible} onClose={() => setOtpVisible(false)} />
132
+ ```
133
+
134
+ That's the entire integration. On success the modal closes and your existing `onAuthStateChanged` listener flips the app into the signed-in state — no navigation code needed.
135
+
136
+ 3. **Optional:** use `OtpInput` + the API functions directly if you want to build a custom dialog.
137
+
138
+ The package never touches your auth state, navigation, user schema, or email
139
+ infrastructure beyond what you explicitly configure.
140
+
141
+ ### Client-owned UI (without `/react-native`)
142
+
143
+ You can omit the package from the client entirely and call the deployed
144
+ Firebase callable functions with the app's existing Firebase dependencies.
145
+ The client flow is:
146
+
147
+ 1. Call `requestEmailOtp` with the normalized email.
148
+ 2. Collect the six-digit code in your own UI.
149
+ 3. Call `verifyEmailOtp` with the email and code.
150
+ 4. Read `customToken` from the response.
151
+ 5. Sign in with Firebase Auth using `signInWithCustomToken`.
152
+
153
+ With React Native Firebase, the direct implementation looks like this:
154
+
155
+ ```typescript
156
+ import { getAuth, signInWithCustomToken } from "@react-native-firebase/auth";
157
+ import { getFunctions, httpsCallable } from "@react-native-firebase/functions";
158
+
159
+ type VerifyOtpResponse = { customToken: string };
160
+
161
+ const requestEmailOtp = httpsCallable<
162
+ { email: string },
163
+ { status: string; message: string }
164
+ >(getFunctions(), "requestEmailOtp");
165
+ const verifyEmailOtp = httpsCallable<
166
+ { email: string; code: string },
167
+ VerifyOtpResponse
168
+ >(getFunctions(), "verifyEmailOtp");
169
+
170
+ await requestEmailOtp({ email: email.trim().toLowerCase() });
171
+
172
+ const result = await verifyEmailOtp({
173
+ email: email.trim().toLowerCase(),
174
+ code: code.trim(),
175
+ });
176
+
177
+ await signInWithCustomToken(getAuth(), result.data.customToken);
178
+ ```
179
+
180
+ The callable names and payloads must match the deployed backend functions.
181
+ The package's `/shared` entry can still be used separately for shared
182
+ constants and validation, but it is not required by the client.
183
+
184
+ ---
185
+
186
+ ## Installation
187
+
188
+ ```bash
189
+ npm install @guettonenrique/passwordless-auth
190
+ ```
191
+
192
+ Because `/functions` and `/react-native` target separate environments with
193
+ disjoint dependency trees, runtime SDKs are **not declared as top-level
194
+ `dependencies` or `peerDependencies`** (avoiding npm auto-installing the React
195
+ Native stack into a Cloud Functions project or vice versa). Install only what
196
+ your entry needs (exact tested versions live in this package's
197
+ `devDependencies`):
198
+
199
+ | Entry | Needs installed in the consuming project |
200
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
201
+ | `/functions` | `firebase-functions >=4`, `firebase-admin >=11`, `@sendgrid/mail >=8` (or pass a custom sender) |
202
+ | `/react-native` | `@react-native-firebase/app + auth + functions >=17`, `react >=18`, `react-native >=0.73`, `react-native-keyboard-controller >=1`, `lucide-react-native >=0.300`; optional: `expo-haptics` (haptic feedback) |
203
+
204
+ ---
205
+
206
+ ## Backend setup (Firebase Cloud Functions, TypeScript)
207
+
208
+ ### 1. Register the three functions
209
+
210
+ ```typescript
211
+ // functions/src/index.ts
212
+ import { defineSecret } from "firebase-functions/params";
213
+ import {
214
+ createRequestEmailOtp,
215
+ createVerifyEmailOtp,
216
+ createCleanupExpiredOtps,
217
+ } from "@guettonenrique/passwordless-auth/functions";
218
+ import { resolveOrCreateUser } from "./utils/yourUserProvisioning";
219
+
220
+ const sendgridKey = defineSecret("SENDGRID_API_KEY");
221
+
222
+ // The one project-specific hook: resolve or create the user, return the uid.
223
+ const provision = async (email: string): Promise<string> => {
224
+ const { user } = await resolveOrCreateUser(email, { emailVerified: true });
225
+ return user.uid;
226
+ };
227
+
228
+ exports.requestEmailOtp = createRequestEmailOtp({
229
+ appName: "MyApp",
230
+ // ⚠️ register the secret on the function so the runtime can resolve it:
231
+ // pass `secrets: [sendgridKey]` via onCall options if your deployment
232
+ // requires it — see "Secrets" below.
233
+ sendgrid: {
234
+ apiKey: sendgridKey, // a params Secret works directly
235
+ fromEmail: "no-reply@yourdomain.com",
236
+ fromName: "MyApp Support",
237
+ },
238
+ });
239
+
240
+ exports.verifyEmailOtp = createVerifyEmailOtp({ provision });
241
+
242
+ // Reaps expired docs (orphans from users who never entered the code).
243
+ exports.cleanupExpiredOtps = createCleanupExpiredOtps();
244
+ ```
245
+
246
+ `apiKey` accepts a `defineSecret(...)` object directly — the factory resolves
247
+ it per call, which is what makes Firebase secrets work.
248
+
249
+ **Secrets:** declare `SENDGRID_API_KEY` in your `firebase.json` (or use
250
+ `firebase functions:secrets:set SENDGRID_API_KEY`) and reference it with
251
+ `defineSecret`. The factory reads it through the params API at request time.
252
+
253
+ ### 2. Lock the OTP collection in Firestore rules
254
+
255
+ ```javascript
256
+ match /auth_otps/{document=**} {
257
+ allow read, write: if false; // only the Cloud Functions (admin SDK) may touch it
258
+ }
259
+ ```
260
+
261
+ ### 3. Deploy
262
+
263
+ ```bash
264
+ firebase deploy --only functions,firestore:rules
265
+ ```
266
+
267
+ ### Options (all optional, sane defaults)
268
+
269
+ | Option | Default | Notes |
270
+ | -------------------- | -------------------- | --------------------------------------------------------------------------- |
271
+ | `collection` | `'auth_otps'` | same value must be used on all three factories |
272
+ | `cooldownMs` | `60_000` | resend rate limit |
273
+ | `expiryMs` | `600_000` | code lifetime (10 min) |
274
+ | `maxAttempts` | `5` | brute-force cap per code |
275
+ | `enforceAppCheck` | `false` | flip to `true` in production (see below) |
276
+ | `sendEmail` | SendGrid sender | custom `(email: OtpEmail) => Promise<void>` to swap providers or log in dev |
277
+ | `schedule` (cleanup) | `'every 15 minutes'` | Cloud Scheduler syntax |
278
+
279
+ **App Check:** start with `enforceAppCheck: false` while wiring up, then enable
280
+ it in production once your app ships App Check (React Native: `@react-native-firebase/app-check`).
281
+
282
+ **Dev mode without SendGrid:**
283
+
284
+ ```typescript
285
+ exports.requestEmailOtp = createRequestEmailOtp({
286
+ appName: "MyApp",
287
+ sendEmail: async (email) => {
288
+ console.log(`🔑 [DEV OTP] ${email.to}: ${email.code}`);
289
+ },
290
+ });
291
+ ```
292
+
293
+ ### Error contract (surfaced verbatim to users)
294
+
295
+ | Code | Meaning |
296
+ | -------------------- | --------------------------------------------------------------------------------- |
297
+ | `invalid-argument` | Bad email, wrong code format, or wrong code (message includes remaining attempts) |
298
+ | `not-found` | No active code for this email |
299
+ | `deadline-exceeded` | Code expired |
300
+ | `resource-exhausted` | Cooldown active or attempts exhausted |
301
+ | `internal` | Email delivery failed (doc is auto-cleaned) |
302
+
303
+ ---
304
+
305
+ ## Mobile setup (React Native / Expo)
306
+
307
+ ### 1. Add to your auth screen
308
+
309
+ ```tsx
310
+ import { useState } from 'react';
311
+ import { EmailOtpModal } from '@guettonenrique/passwordless-auth/react-native';
312
+
313
+ const [otpVisible, setOtpVisible] = useState(false);
314
+
315
+ <TouchableOpacity onPress={() => setOtpVisible(true)}>
316
+ <Text>Continue with email code</Text>
317
+ </TouchableOpacity>
318
+
319
+ <EmailOtpModal
320
+ visible={otpVisible}
321
+ onClose={() => setOtpVisible(false)}
322
+ initialEmail={email} // optional pre-fill
323
+ />
324
+ ```
325
+
326
+ That's the whole integration. On success the modal closes and your existing
327
+ `onAuthStateChanged` listener flips the app into the signed-in state — no
328
+ navigation code needed.
329
+
330
+ ### 2. Or use the pieces yourself
331
+
332
+ ```typescript
333
+ import {
334
+ requestEmailOtp, // (email) → { status, message }
335
+ verifyEmailOtp, // (email, code) → customToken
336
+ signInWithEmailOtp, // (email, code) → signs in via signInWithCustomToken
337
+ OtpInput, // 6-slot auto-fill code input
338
+ } from "@guettonenrique/passwordless-auth/react-native";
339
+ ```
340
+
341
+ `OtpInput` uses one hidden native input (the only way paste + iOS `oneTimeCode`
342
+ / Android `one-time-code` auto-fill work reliably) and calls `onComplete` when
343
+ the last digit arrives — wire it to `signInWithEmailOtp` for auto-submit.
344
+
345
+ ### 3. Keep the UI in sync with the backend
346
+
347
+ If you override `cooldownMs`/`expiryMs` on the backend, pass the matching
348
+ `cooldownSeconds` prop to `EmailOtpModal` so the UI countdown mirrors the
349
+ server rate limit.
350
+
351
+ ---
352
+
353
+ ## Testing the package (contributors)
354
+
355
+ ```bash
356
+ npm install
357
+ npm run typecheck # 3 per-entry tsc passes
358
+ npm run lint # includes the cross-entry boundary rule
359
+ npm test # 58 tests: shared, functions (mocked Firestore/Auth), react-native
360
+ npm run build # tsup: ESM+CJS+DTS for each entry
361
+ ```
362
+
363
+ **Boundary rule:** `src/shared` may import nothing; `src/functions` may not
364
+ import `src/react-native` and vice versa. ESLint enforces it on import
365
+ specifiers; per-entry `tsc` configs keep the type graphs separate.
366
+
367
+ **Publishing** (manual for v0.x; CI-on-tag from 1.0):
368
+
369
+ The package is published publicly to **npmjs** (`publishConfig` sets
370
+ `"registry": "https://registry.npmjs.org"` and `"access": "public"`):
371
+
372
+ 1. Authenticate with npm:
373
+
374
+ ```bash
375
+ npm login
376
+ ```
377
+
378
+ 2. Publish (`prepublishOnly` runs `npm run build` automatically):
379
+
380
+ ```bash
381
+ npm publish
382
+ ```
383
+
384
+ 3. Verify:
385
+
386
+ ```bash
387
+ npm view @guettonenrique/passwordless-auth
388
+ ```