@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 +23 -0
- package/package.json +1 -1
- package/src/auth/index.ts +3 -0
- package/src/auth/phoneVerify.ts +86 -0
- package/src/auth/setup.ts +30 -3
- package/src/auth/users.ts +20 -2
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
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 {
|
|
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: {
|
|
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
|
-
|
|
48
|
-
|
|
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))
|