@supa-media/convex 1.6.0 → 1.7.1
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/package.json +1 -1
- package/src/auth/index.ts +6 -0
- package/src/auth/setup.ts +6 -31
- package/src/auth/twilioVerify.ts +108 -0
package/package.json
CHANGED
package/src/auth/index.ts
CHANGED
|
@@ -13,6 +13,12 @@ export type {
|
|
|
13
13
|
SupaAuthTwilioConfig,
|
|
14
14
|
SupaAuthUserCreated,
|
|
15
15
|
} from "./setup";
|
|
16
|
+
export {
|
|
17
|
+
checkTwilioVerification,
|
|
18
|
+
sendTwilioVerification,
|
|
19
|
+
twilioVerifyKeys,
|
|
20
|
+
} from "./twilioVerify";
|
|
21
|
+
export type { TwilioCheckResult, TwilioSendResult, TwilioVerifyKeys } from "./twilioVerify";
|
|
16
22
|
export {
|
|
17
23
|
requireAuth,
|
|
18
24
|
requireAuthId,
|
package/src/auth/setup.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { sendTwilioVerification } from "./twilioVerify";
|
|
25
26
|
import { convexAuth } from "@convex-dev/auth/server";
|
|
26
27
|
import { Email } from "@convex-dev/auth/providers/Email";
|
|
27
28
|
import { Phone } from "@convex-dev/auth/providers/Phone";
|
|
@@ -388,39 +389,13 @@ function createPhoneOtp(config: SupaAuthConfig) {
|
|
|
388
389
|
return;
|
|
389
390
|
}
|
|
390
391
|
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
method: "POST",
|
|
395
|
-
headers: {
|
|
396
|
-
Authorization: `Basic ${btoa(`${accountSid}:${authToken}`)}`,
|
|
397
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
398
|
-
},
|
|
399
|
-
body: new URLSearchParams({
|
|
400
|
-
To: phone,
|
|
401
|
-
Channel: "sms",
|
|
402
|
-
}),
|
|
403
|
-
},
|
|
392
|
+
const sent = await sendTwilioVerification(
|
|
393
|
+
{ accountSid, authToken, serviceSid: verifyServiceSid },
|
|
394
|
+
phone,
|
|
404
395
|
);
|
|
405
|
-
|
|
406
|
-
if (!response.ok) {
|
|
407
|
-
const errorText = await response.text();
|
|
408
|
-
let errorData: { code?: number; message?: string };
|
|
409
|
-
try {
|
|
410
|
-
errorData = JSON.parse(errorText);
|
|
411
|
-
} catch {
|
|
412
|
-
errorData = { message: errorText };
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
console.error("Twilio Verify send error:", {
|
|
416
|
-
status: response.status,
|
|
417
|
-
errorCode: errorData?.code,
|
|
418
|
-
errorMessage: errorData?.message,
|
|
419
|
-
phone,
|
|
420
|
-
});
|
|
421
|
-
|
|
396
|
+
if (!sent.ok) {
|
|
422
397
|
throw new Error(
|
|
423
|
-
|
|
398
|
+
sent.reason === "invalid_phone"
|
|
424
399
|
? "Invalid phone number. Please check and try again."
|
|
425
400
|
: "Failed to send verification code. Please try again.",
|
|
426
401
|
);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twilio Verify, on its own: send a code to a phone, then check one.
|
|
3
|
+
*
|
|
4
|
+
* `createSupaAuth`'s phone provider uses Twilio Verify to *sign in* with a
|
|
5
|
+
* phone. An app that signs in with email can still want to know a person holds
|
|
6
|
+
* a phone: proof of a real person, or one identity across several sign-in
|
|
7
|
+
* emails. These two calls are that, without a sign-in provider: Twilio keeps
|
|
8
|
+
* the code, so the app stores no secret and only records the result.
|
|
9
|
+
*
|
|
10
|
+
* The keys are the provider's: `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`,
|
|
11
|
+
* `TWILIO_VERIFY_SERVICE_SID`. An API key works in place of the auth token:
|
|
12
|
+
* `TWILIO_API_KEY_SID` with `TWILIO_API_KEY_SECRET`, which Twilio recommends
|
|
13
|
+
* because it can be revoked on its own. With no usable pair, `twilioVerifyKeys`
|
|
14
|
+
* answers `null`, and the app decides what that means. It should never mean
|
|
15
|
+
* "verified".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface TwilioVerifyKeys {
|
|
19
|
+
accountSid: string;
|
|
20
|
+
/** The account's auth token, or the API key's secret when `apiKeySid` is set. */
|
|
21
|
+
authToken: string;
|
|
22
|
+
serviceSid: string;
|
|
23
|
+
/** Set when signing requests with an API key rather than the auth token. */
|
|
24
|
+
apiKeySid?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TwilioSendResult =
|
|
28
|
+
| { ok: true }
|
|
29
|
+
| { ok: false; reason: "invalid_phone" | "too_many" | "failed" };
|
|
30
|
+
|
|
31
|
+
/** `wrong` covers an expired or unknown code too: Twilio answers both with 404. */
|
|
32
|
+
export type TwilioCheckResult = "approved" | "wrong" | "too_many" | "failed";
|
|
33
|
+
|
|
34
|
+
type Fetch = typeof fetch;
|
|
35
|
+
|
|
36
|
+
/** The keys from the environment, or `null` when any is unset. */
|
|
37
|
+
export function twilioVerifyKeys(
|
|
38
|
+
env: Record<string, string | undefined> = process.env,
|
|
39
|
+
): TwilioVerifyKeys | null {
|
|
40
|
+
const accountSid = env.TWILIO_ACCOUNT_SID?.trim();
|
|
41
|
+
const authToken = env.TWILIO_AUTH_TOKEN?.trim();
|
|
42
|
+
const serviceSid = env.TWILIO_VERIFY_SERVICE_SID?.trim();
|
|
43
|
+
if (!accountSid || !serviceSid) return null;
|
|
44
|
+
if (authToken) return { accountSid, authToken, serviceSid };
|
|
45
|
+
const apiKeySid = env.TWILIO_API_KEY_SID?.trim();
|
|
46
|
+
const apiKeySecret = env.TWILIO_API_KEY_SECRET?.trim();
|
|
47
|
+
if (!apiKeySid || !apiKeySecret) return null;
|
|
48
|
+
return { accountSid, authToken: apiKeySecret, serviceSid, apiKeySid };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function request(keys: TwilioVerifyKeys, path: string, body: Record<string, string>, fetchImpl: Fetch) {
|
|
52
|
+
return fetchImpl(`https://verify.twilio.com/v2/Services/${keys.serviceSid}/${path}`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
Authorization: `Basic ${btoa(`${keys.apiKeySid ?? keys.accountSid}:${keys.authToken}`)}`,
|
|
56
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
57
|
+
},
|
|
58
|
+
body: new URLSearchParams(body),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function errorOf(response: Response): Promise<{ code?: number; message?: string }> {
|
|
63
|
+
const text = await response.text();
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(text) as { code?: number; message?: string };
|
|
66
|
+
} catch {
|
|
67
|
+
return { message: text };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Twilio's "Max send attempts reached" and "Max check attempts reached". */
|
|
72
|
+
const TOO_MANY = new Set([60203, 60202]);
|
|
73
|
+
|
|
74
|
+
/** Text a code to `phone` (E.164). */
|
|
75
|
+
export async function sendTwilioVerification(
|
|
76
|
+
keys: TwilioVerifyKeys,
|
|
77
|
+
phone: string,
|
|
78
|
+
fetchImpl: Fetch = fetch,
|
|
79
|
+
): Promise<TwilioSendResult> {
|
|
80
|
+
const response = await request(keys, "Verifications", { To: phone, Channel: "sms" }, fetchImpl);
|
|
81
|
+
if (response.ok) return { ok: true };
|
|
82
|
+
const error = await errorOf(response);
|
|
83
|
+
// Logged without the number: a phone number is personal data.
|
|
84
|
+
console.error("Twilio Verify send failed", { status: response.status, code: error.code });
|
|
85
|
+
if (error.code !== undefined && TOO_MANY.has(error.code)) return { ok: false, reason: "too_many" };
|
|
86
|
+
if (error.code === 60200 || /invalid.*phone/i.test(error.message ?? "")) {
|
|
87
|
+
return { ok: false, reason: "invalid_phone" };
|
|
88
|
+
}
|
|
89
|
+
return { ok: false, reason: "failed" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Check a code typed for `phone`. Only `approved` means the person holds it. */
|
|
93
|
+
export async function checkTwilioVerification(
|
|
94
|
+
keys: TwilioVerifyKeys,
|
|
95
|
+
phone: string,
|
|
96
|
+
code: string,
|
|
97
|
+
fetchImpl: Fetch = fetch,
|
|
98
|
+
): Promise<TwilioCheckResult> {
|
|
99
|
+
const response = await request(keys, "VerificationCheck", { To: phone, Code: code }, fetchImpl);
|
|
100
|
+
if (response.status === 404) return "wrong";
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
const error = await errorOf(response);
|
|
103
|
+
console.error("Twilio Verify check failed", { status: response.status, code: error.code });
|
|
104
|
+
return error.code !== undefined && TOO_MANY.has(error.code) ? "too_many" : "failed";
|
|
105
|
+
}
|
|
106
|
+
const body = (await response.json()) as { status?: string; valid?: boolean };
|
|
107
|
+
return body.status === "approved" && body.valid !== false ? "approved" : "wrong";
|
|
108
|
+
}
|