@supa-media/convex 1.8.0 → 1.9.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/README.md CHANGED
@@ -339,6 +339,29 @@ not own. That is a nuisance, not a hole: the library's own generator produces
339
339
  attacker gets is the ability to invalidate somebody's pending code — which
340
340
  `signIn("email", { email })` could always do too.
341
341
 
342
+ ### Phone sign-in through Twilio Verify (`phoneVerify`, opt-in)
343
+
344
+ For people who already have an account. The app texts the code itself with
345
+ `sendTwilioVerification`, after deciding who may be texted, and the client
346
+ signs in with `signIn(PHONE_VERIFY_PROVIDER_ID, { phone, code })`:
347
+
348
+ ```ts
349
+ createSupaAuth({
350
+ methods: ["email"],
351
+ phoneVerify: {
352
+ findUserByPhone: (ctx, phone) => ctx.runQuery(internal.phones.holder, { phone }),
353
+ mayCheck: (ctx, phone) => ctx.runMutation(internal.phones.spendCheck, { phone }),
354
+ },
355
+ });
356
+ ```
357
+
358
+ Twilio holds the code, so nothing secret is stored. Only `approved` signs
359
+ anybody in, and a phone `findUserByPhone` does not know is refused: the
360
+ provider never creates an account, so it never reaches `admission`. A
361
+ credentials provider is not rate-limited by `@convex-dev/auth`; the limits are
362
+ Twilio's (five checks per code, ten minutes) and `mayCheck`, which fails closed.
363
+ This is separate from the bridge-based `"phone"` method.
364
+
342
365
  ### Auth helpers
343
366
 
344
367
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supa-media/convex",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Backend package for the Supa framework — OTP auth, schema helpers, and backend utilities for Convex",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/auth/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  createSupaAuth,
3
3
  MAGIC_LINK_PROVIDER_ID,
4
+ PHONE_VERIFY_PROVIDER_ID,
4
5
  NOT_ADMITTED_MESSAGE,
5
6
  TEST_EMAIL_PROVIDER_ID,
6
7
  } from "./setup";
@@ -8,6 +9,7 @@ export type {
8
9
  SupaAuthAdmission,
9
10
  SupaAuthConfig,
10
11
  SupaAuthMagicLinkConfig,
12
+ SupaAuthPhoneVerifyConfig,
11
13
  SupaAuthResendConfig,
12
14
  SupaAuthTestEmailConfig,
13
15
  SupaAuthTwilioConfig,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Sign in with a phone whose code Twilio Verify holds.
3
+ *
4
+ * An app texts the code itself (`sendTwilioVerification`), deciding first who
5
+ * may be texted at all, then the client calls
6
+ * `signIn(PHONE_VERIFY_PROVIDER_ID, { phone, code })`. This provider asks
7
+ * Twilio whether that code is approved for that phone, and only then asks the
8
+ * app which user holds the phone. Nothing secret is stored: Twilio keeps the
9
+ * code, and the app keeps only which phone is whose.
10
+ *
11
+ * ## It signs people in, it never makes them
12
+ *
13
+ * `findUserByPhone` answering `null` refuses the sign-in. A new person comes
14
+ * in another way (an email code, an invitation link) and adds the phone once
15
+ * signed in. That keeps the provider out of `createOrUpdateUser`, and out of
16
+ * `admission`, entirely: there is no row it could create.
17
+ *
18
+ * ## Guessing a code
19
+ *
20
+ * `@convex-dev/auth` does not rate-limit a credentials provider, so the limits
21
+ * are Twilio's — five checks per texted code, and a code lives ten minutes —
22
+ * and the app's own `mayCheck`, asked before every check. It fails closed: a
23
+ * hook that throws refuses. Only `approved` from Twilio signs anybody in.
24
+ */
25
+
26
+ import { ConvexCredentials } from "@convex-dev/auth/providers/ConvexCredentials";
27
+ import type { AnyDataModel, GenericActionCtx } from "convex/server";
28
+ import type { GenericId } from "convex/values";
29
+
30
+ import { checkTwilioVerification, twilioVerifyKeys, type TwilioCheckResult } from "./twilioVerify";
31
+
32
+ /** Provider id the client signs in with. */
33
+ export const PHONE_VERIFY_PROVIDER_ID = "phone-verify";
34
+
35
+ /** E.164: a plus, a non-zero country digit, 7 to 15 digits in all. */
36
+ const E164 = /^\+[1-9]\d{6,14}$/;
37
+
38
+ export interface SupaAuthPhoneVerifyConfig {
39
+ /**
40
+ * The user who holds `phone` (E.164), or `null`. Only return a user the
41
+ * phone was confirmed for: whoever holds that phone signs in as them.
42
+ */
43
+ findUserByPhone: (ctx: GenericActionCtx<AnyDataModel>, phone: string) => Promise<GenericId<"users"> | null>;
44
+ /** Spend one check for `phone`; `false` refuses before Twilio is asked. */
45
+ mayCheck?: (ctx: GenericActionCtx<AnyDataModel>, phone: string) => Promise<boolean>;
46
+ /** Overridable for tests. */
47
+ check?: (phone: string, code: string) => Promise<TwilioCheckResult>;
48
+ }
49
+
50
+ async function twilioCheck(phone: string, code: string): Promise<TwilioCheckResult> {
51
+ const keys = twilioVerifyKeys();
52
+ if (keys === null) return "failed";
53
+ return await checkTwilioVerification(keys, phone, code);
54
+ }
55
+
56
+ /** The `authorize` step, on its own so its boundary is testable without a deployment. */
57
+ export function phoneVerifyAuthorize(config: SupaAuthPhoneVerifyConfig) {
58
+ const check = config.check ?? twilioCheck;
59
+ return async (
60
+ credentials: Partial<Record<string, unknown>>,
61
+ ctx: GenericActionCtx<AnyDataModel>,
62
+ ): Promise<{ userId: GenericId<"users"> } | null> => {
63
+ const phone = typeof credentials.phone === "string" ? credentials.phone.trim() : "";
64
+ const code = typeof credentials.code === "string" ? credentials.code.replace(/\s/g, "") : "";
65
+ if (!E164.test(phone) || !/^\d{4,10}$/.test(code)) return null;
66
+ if (config.mayCheck !== undefined) {
67
+ let ok = false;
68
+ try {
69
+ ok = (await config.mayCheck(ctx, phone)) === true;
70
+ } catch (error) {
71
+ console.error("[supa-auth] phone check limit failed, refusing:", error);
72
+ }
73
+ if (!ok) return null;
74
+ }
75
+ if ((await check(phone, code)) !== "approved") return null;
76
+ const userId = await config.findUserByPhone(ctx, phone);
77
+ return userId === null ? null : { userId };
78
+ };
79
+ }
80
+
81
+ export function createPhoneVerifySignIn(config: SupaAuthPhoneVerifyConfig) {
82
+ return ConvexCredentials({
83
+ id: PHONE_VERIFY_PROVIDER_ID,
84
+ authorize: phoneVerifyAuthorize(config) as never,
85
+ });
86
+ }
package/src/auth/setup.ts CHANGED
@@ -28,11 +28,19 @@ import { Email } from "@convex-dev/auth/providers/Email";
28
28
  import { Phone } from "@convex-dev/auth/providers/Phone";
29
29
  import { assertMayReceiveEmailCode, type SupaAuthAdmission } from "./admission";
30
30
  import { createTestEmailOtps, type SupaAuthTestEmailConfig } from "./testEmail";
31
+ import { createPhoneVerifySignIn, type SupaAuthPhoneVerifyConfig } from "./phoneVerify";
31
32
  import { userCallback, type SupaAuthFindUserByEmail, type SupaAuthUserCreated } from "./users";
32
33
 
33
34
  export { NOT_ADMITTED_MESSAGE, type SupaAuthAdmission } from "./admission";
34
35
  export type { SupaAuthFindUserByEmail, SupaAuthUserCreated } from "./users";
35
36
 
37
+ export {
38
+ createPhoneVerifySignIn,
39
+ phoneVerifyAuthorize,
40
+ PHONE_VERIFY_PROVIDER_ID,
41
+ type SupaAuthPhoneVerifyConfig,
42
+ } from "./phoneVerify";
43
+
36
44
  export {
37
45
  createTestEmailOtp,
38
46
  createTestEmailOtps,
@@ -144,6 +152,13 @@ export interface SupaAuthConfig {
144
152
  * signs in as them.
145
153
  */
146
154
  findUserByEmail?: SupaAuthFindUserByEmail;
155
+ /**
156
+ * Sign in with a phone through Twilio Verify, for people who already have
157
+ * an account: the app texts the code, this checks it. Independent of
158
+ * `methods`, which governs the older bridge-based phone provider. See
159
+ * `phoneVerify.ts`.
160
+ */
161
+ phoneVerify?: SupaAuthPhoneVerifyConfig;
147
162
  /** Resend email OTP configuration. */
148
163
  resend?: SupaAuthResendConfig;
149
164
  /** Twilio phone OTP configuration. */
@@ -430,6 +445,7 @@ export function createSupaAuth(config: SupaAuthConfig = {}) {
430
445
  : []),
431
446
  ...(methods.includes("email") ? createTestEmailOtps(config.testEmail) : []),
432
447
  ...(methods.includes("phone") ? [createPhoneOtp(config)] : []),
448
+ ...(config.phoneVerify !== undefined ? [createPhoneVerifySignIn(config.phoneVerify)] : []),
433
449
  ];
434
450
 
435
451
  return convexAuth({