@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 +23 -0
- package/package.json +1 -1
- package/src/auth/index.ts +2 -0
- package/src/auth/phoneVerify.ts +86 -0
- package/src/auth/setup.ts +16 -0
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,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({
|