@supa-media/convex 1.7.1 → 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.7.1",
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,10 +9,12 @@ export type {
8
9
  SupaAuthAdmission,
9
10
  SupaAuthConfig,
10
11
  SupaAuthMagicLinkConfig,
12
+ SupaAuthPhoneVerifyConfig,
11
13
  SupaAuthResendConfig,
12
14
  SupaAuthTestEmailConfig,
13
15
  SupaAuthTwilioConfig,
14
16
  SupaAuthUserCreated,
17
+ SupaAuthFindUserByEmail,
15
18
  } from "./setup";
16
19
  export {
17
20
  checkTwilioVerification,
@@ -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,10 +28,18 @@ 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 { userCallback, type SupaAuthUserCreated } from "./users";
31
+ import { createPhoneVerifySignIn, type SupaAuthPhoneVerifyConfig } from "./phoneVerify";
32
+ import { userCallback, type SupaAuthFindUserByEmail, type SupaAuthUserCreated } from "./users";
32
33
 
33
34
  export { NOT_ADMITTED_MESSAGE, type SupaAuthAdmission } from "./admission";
34
- export type { SupaAuthUserCreated } from "./users";
35
+ export type { SupaAuthFindUserByEmail, SupaAuthUserCreated } from "./users";
36
+
37
+ export {
38
+ createPhoneVerifySignIn,
39
+ phoneVerifyAuthorize,
40
+ PHONE_VERIFY_PROVIDER_ID,
41
+ type SupaAuthPhoneVerifyConfig,
42
+ } from "./phoneVerify";
35
43
 
36
44
  export {
37
45
  createTestEmailOtp,
@@ -135,6 +143,22 @@ export interface SupaAuthConfig {
135
143
  * scheduled action, not here.
136
144
  */
137
145
  onUserCreated?: SupaAuthUserCreated;
146
+ /**
147
+ * For an app that lets one person sign in with several emails: the user an
148
+ * address belongs to, or `null`. Asked when an email sign-in has no auth
149
+ * account yet, before the `users.email` match, so a sign-in through an
150
+ * attached address reaches its user rather than making a new one. Only
151
+ * return a user the address was confirmed for: whoever holds that mailbox
152
+ * signs in as them.
153
+ */
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;
138
162
  /** Resend email OTP configuration. */
139
163
  resend?: SupaAuthResendConfig;
140
164
  /** Twilio phone OTP configuration. */
@@ -421,10 +445,13 @@ export function createSupaAuth(config: SupaAuthConfig = {}) {
421
445
  : []),
422
446
  ...(methods.includes("email") ? createTestEmailOtps(config.testEmail) : []),
423
447
  ...(methods.includes("phone") ? [createPhoneOtp(config)] : []),
448
+ ...(config.phoneVerify !== undefined ? [createPhoneVerifySignIn(config.phoneVerify)] : []),
424
449
  ];
425
450
 
426
451
  return convexAuth({
427
452
  providers,
428
- callbacks: { createOrUpdateUser: userCallback(config.admission, config.onUserCreated) },
453
+ callbacks: {
454
+ createOrUpdateUser: userCallback(config.admission, config.onUserCreated, config.findUserByEmail),
455
+ },
429
456
  });
430
457
  }
package/src/auth/users.ts CHANGED
@@ -15,6 +15,12 @@ import type { convexAuth } from "@convex-dev/auth/server";
15
15
 
16
16
  import { assertMayCreateUser, type SupaAuthAdmission } from "./admission";
17
17
 
18
+ /** See `SupaAuthConfig.findUserByEmail`. */
19
+ export type SupaAuthFindUserByEmail = (
20
+ ctx: GenericMutationCtx<AnyDataModel>,
21
+ email: string,
22
+ ) => Promise<GenericId<"users"> | null>;
23
+
18
24
  /** See `SupaAuthConfig.onUserCreated`. */
19
25
  export type SupaAuthUserCreated = (
20
26
  ctx: GenericMutationCtx<AnyDataModel>,
@@ -28,6 +34,7 @@ type Handler = NonNullable<
28
34
  export function userCallback(
29
35
  admission: SupaAuthAdmission | undefined,
30
36
  onUserCreated?: SupaAuthUserCreated,
37
+ findUserByEmail?: SupaAuthFindUserByEmail,
31
38
  ): Handler {
32
39
  async function createOrUpdateUser(
33
40
  ctx: Parameters<Handler>[0],
@@ -44,8 +51,12 @@ export function userCallback(
44
51
  if (type === "email" || type === "verification") {
45
52
  updateData.emailVerificationTime = Date.now();
46
53
  }
47
- if (profile.phone) updateData.phone = profile.phone;
48
- if (profile.email) updateData.email = profile.email;
54
+ // Fill in a missing address, never replace one. A user may sign in
55
+ // through several auth accounts (an app that lets one person keep a
56
+ // work and a home email), and the address on the user row is the one
57
+ // they chose for mail, not whichever they signed in with last.
58
+ if (profile.phone && !existingUser.phone) updateData.phone = profile.phone;
59
+ if (profile.email && !existingUser.email) updateData.email = profile.email;
49
60
  if (profile.name) updateData.name = profile.name;
50
61
 
51
62
  if (Object.keys(updateData).length > 0) {
@@ -74,6 +85,13 @@ export function userCallback(
74
85
  // New auth account — try to link to existing user by email
75
86
  if (type === "email" && typeof profile.email === "string") {
76
87
  const email = profile.email;
88
+ // The app's own answer first: an address it has attached to a user as
89
+ // an extra sign-in email is that user, whatever the user row says.
90
+ const attached = findUserByEmail === undefined ? null : await findUserByEmail(ctx, email);
91
+ if (attached !== null) {
92
+ await ctx.db.patch(attached, { emailVerificationTime: Date.now() });
93
+ return attached;
94
+ }
77
95
  const existingUser = await ctx.db
78
96
  .query("users")
79
97
  .filter((q) => q.eq(q.field("email"), email))