@labyrinthe/otp-widget 0.0.0-stage → 1.2.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/CHANGELOG.md +80 -0
- package/LICENSE +21 -0
- package/README.fr.md +584 -0
- package/README.md +574 -2
- package/dist/index.cjs +887 -0
- package/dist/index.d.cts +215 -0
- package/dist/index.d.ts +215 -0
- package/dist/index.js +875 -0
- package/dist/labyrinthe-otp.min.js +70 -0
- package/dist/react.cjs +81 -0
- package/dist/react.d.cts +43 -0
- package/dist/react.d.ts +43 -0
- package/dist/react.js +66 -0
- package/dist/server.cjs +613 -0
- package/dist/server.d.cts +264 -0
- package/dist/server.d.ts +264 -0
- package/dist/server.js +610 -0
- package/package.json +131 -4
package/dist/server.js
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
// src/phone.ts
|
|
2
|
+
var DEFAULT_COUNTRY_CODE = "243";
|
|
3
|
+
function normalizePhone(raw, defaultCountryCode = DEFAULT_COUNTRY_CODE) {
|
|
4
|
+
if (!isCountryCode(defaultCountryCode)) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
const input = raw.trim();
|
|
8
|
+
if (input === "") {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const explicitInternational = input.startsWith("+") || input.startsWith("00");
|
|
12
|
+
let digits = input.replace(/\D/g, "");
|
|
13
|
+
if (explicitInternational) {
|
|
14
|
+
digits = input.startsWith("00") ? digits.slice(2) : digits;
|
|
15
|
+
} else if (digits.startsWith(defaultCountryCode) && digits.length > 10) ; else if (digits.length >= 11 && !digits.startsWith("0")) ; else {
|
|
16
|
+
digits = defaultCountryCode + digits.replace(/^0+/, "");
|
|
17
|
+
}
|
|
18
|
+
return digits.length >= 8 && digits.length <= 15 ? digits : null;
|
|
19
|
+
}
|
|
20
|
+
function isCountryCode(value) {
|
|
21
|
+
return /^[1-9]\d{0,2}$/.test(value);
|
|
22
|
+
}
|
|
23
|
+
function maskPhone(phone) {
|
|
24
|
+
const digits = phone.replace(/\D/g, "");
|
|
25
|
+
if (digits.length <= 6) {
|
|
26
|
+
return "\u2022".repeat(digits.length);
|
|
27
|
+
}
|
|
28
|
+
return `+${digits.slice(0, 3)} ${digits.slice(3, 5)}${"\u2022".repeat(digits.length - 7)}${digits.slice(-2)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/server/session.ts
|
|
32
|
+
function memoryStore() {
|
|
33
|
+
const entries = /* @__PURE__ */ new Map();
|
|
34
|
+
return {
|
|
35
|
+
get size() {
|
|
36
|
+
return entries.size;
|
|
37
|
+
},
|
|
38
|
+
get(id) {
|
|
39
|
+
const entry = entries.get(id);
|
|
40
|
+
if (entry === void 0 || entry.until < Date.now()) {
|
|
41
|
+
entries.delete(id);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return { ...entry.state };
|
|
45
|
+
},
|
|
46
|
+
set(id, state, ttlSeconds) {
|
|
47
|
+
entries.set(id, { state: { ...state }, until: Date.now() + ttlSeconds * 1e3 });
|
|
48
|
+
},
|
|
49
|
+
delete(id) {
|
|
50
|
+
entries.delete(id);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
var encoder = new TextEncoder();
|
|
55
|
+
var decoder = new TextDecoder();
|
|
56
|
+
var TOKEN_PREFIX = "v1.";
|
|
57
|
+
var IV_LENGTH = 12;
|
|
58
|
+
var cryptoPromise;
|
|
59
|
+
function getCrypto() {
|
|
60
|
+
const g = globalThis;
|
|
61
|
+
if (g.crypto?.subtle !== void 0) {
|
|
62
|
+
return Promise.resolve(g.crypto);
|
|
63
|
+
}
|
|
64
|
+
cryptoPromise ??= (async () => {
|
|
65
|
+
const specifier = ["node", "crypto"].join(":");
|
|
66
|
+
const mod = await import(
|
|
67
|
+
/* webpackIgnore: true */
|
|
68
|
+
/* @vite-ignore */
|
|
69
|
+
specifier
|
|
70
|
+
);
|
|
71
|
+
if (mod.webcrypto?.subtle === void 0) {
|
|
72
|
+
throw new Error("Web Crypto is not available in this runtime.");
|
|
73
|
+
}
|
|
74
|
+
return mod.webcrypto;
|
|
75
|
+
})();
|
|
76
|
+
return cryptoPromise;
|
|
77
|
+
}
|
|
78
|
+
var Sealer = class {
|
|
79
|
+
#key;
|
|
80
|
+
#macKey;
|
|
81
|
+
constructor(secret) {
|
|
82
|
+
const material = (async () => {
|
|
83
|
+
const crypto = await getCrypto();
|
|
84
|
+
return crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]);
|
|
85
|
+
})();
|
|
86
|
+
const derive = async (info, algorithm, usages) => {
|
|
87
|
+
const crypto = await getCrypto();
|
|
88
|
+
return crypto.subtle.deriveKey(
|
|
89
|
+
{ name: "HKDF", hash: "SHA-256", salt: encoder.encode("labyrinthe-otp-widget"), info: encoder.encode(info) },
|
|
90
|
+
await material,
|
|
91
|
+
algorithm,
|
|
92
|
+
false,
|
|
93
|
+
usages
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
this.#key = derive("session-cookie-v1", { name: "AES-GCM", length: 256 }, ["encrypt", "decrypt"]);
|
|
97
|
+
this.#macKey = derive("idempotency-key-v1", { name: "HMAC", hash: "SHA-256", length: 256 }, ["sign"]);
|
|
98
|
+
}
|
|
99
|
+
/** HMAC-SHA-256 de `value` en base64url (43 caractères) : identifiant stable, non devinable sans le secret. */
|
|
100
|
+
async tag(value) {
|
|
101
|
+
const crypto = await getCrypto();
|
|
102
|
+
return toBase64Url(new Uint8Array(await crypto.subtle.sign("HMAC", await this.#macKey, encoder.encode(value))));
|
|
103
|
+
}
|
|
104
|
+
/** `context` (nom du cookie) est authentifié avec le contenu : un jeton ne sert que là où il a été émis. */
|
|
105
|
+
async seal(value, context) {
|
|
106
|
+
const crypto = await getCrypto();
|
|
107
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
108
|
+
const cipher = await crypto.subtle.encrypt(
|
|
109
|
+
{ name: "AES-GCM", iv, additionalData: encoder.encode(context) },
|
|
110
|
+
await this.#key,
|
|
111
|
+
encoder.encode(JSON.stringify(value))
|
|
112
|
+
);
|
|
113
|
+
const bytes = new Uint8Array(IV_LENGTH + cipher.byteLength);
|
|
114
|
+
bytes.set(iv, 0);
|
|
115
|
+
bytes.set(new Uint8Array(cipher), IV_LENGTH);
|
|
116
|
+
return TOKEN_PREFIX + toBase64Url(bytes);
|
|
117
|
+
}
|
|
118
|
+
/** null si le jeton est absent, altéré, émis avec un autre secret ou illisible. */
|
|
119
|
+
async open(token, context) {
|
|
120
|
+
if (typeof token !== "string" || !token.startsWith(TOKEN_PREFIX) || token.length > 4096) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const bytes = fromBase64Url(token.slice(TOKEN_PREFIX.length));
|
|
125
|
+
if (bytes.length <= IV_LENGTH) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const crypto = await getCrypto();
|
|
129
|
+
const plain = await crypto.subtle.decrypt(
|
|
130
|
+
{ name: "AES-GCM", iv: bytes.slice(0, IV_LENGTH), additionalData: encoder.encode(context) },
|
|
131
|
+
await this.#key,
|
|
132
|
+
bytes.slice(IV_LENGTH)
|
|
133
|
+
);
|
|
134
|
+
return JSON.parse(decoder.decode(plain));
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
async function randomId() {
|
|
141
|
+
const crypto = await getCrypto();
|
|
142
|
+
return toBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
143
|
+
}
|
|
144
|
+
function isSessionState(value) {
|
|
145
|
+
if (typeof value !== "object" || value === null) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const v = value;
|
|
149
|
+
return typeof v["otpId"] === "string" && typeof v["phone"] === "string" && typeof v["expiresAt"] === "number" && (v["attemptsLeft"] === null || typeof v["attemptsLeft"] === "number");
|
|
150
|
+
}
|
|
151
|
+
function toBase64Url(bytes) {
|
|
152
|
+
let binary = "";
|
|
153
|
+
for (const byte of bytes) {
|
|
154
|
+
binary += String.fromCharCode(byte);
|
|
155
|
+
}
|
|
156
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
157
|
+
}
|
|
158
|
+
function fromBase64Url(value) {
|
|
159
|
+
if (!/^[A-Za-z0-9_-]*$/.test(value)) {
|
|
160
|
+
throw new Error("invalid base64url");
|
|
161
|
+
}
|
|
162
|
+
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
|
|
163
|
+
const bytes = new Uint8Array(binary.length);
|
|
164
|
+
for (let i = 0; i < binary.length; i++) {
|
|
165
|
+
bytes[i] = binary.charCodeAt(i);
|
|
166
|
+
}
|
|
167
|
+
return bytes;
|
|
168
|
+
}
|
|
169
|
+
function readCookie(header, name) {
|
|
170
|
+
if (header === null) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
for (const part of header.split(";")) {
|
|
174
|
+
const index = part.indexOf("=");
|
|
175
|
+
if (index > 0 && part.slice(0, index).trim() === name) {
|
|
176
|
+
return part.slice(index + 1).trim();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
function serializeCookie(name, value, maxAgeSeconds, options) {
|
|
182
|
+
let cookie = `${name}=${value}; Max-Age=${String(Math.max(0, Math.floor(maxAgeSeconds)))}; Path=${options.path}; HttpOnly; SameSite=${options.sameSite}`;
|
|
183
|
+
if (options.domain !== void 0) {
|
|
184
|
+
cookie += `; Domain=${options.domain}`;
|
|
185
|
+
}
|
|
186
|
+
if (options.secure) {
|
|
187
|
+
cookie += "; Secure";
|
|
188
|
+
}
|
|
189
|
+
return cookie;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/server/index.ts
|
|
193
|
+
var MAX_BODY_BYTES = 4096;
|
|
194
|
+
var MAX_CODE_LENGTH = 20;
|
|
195
|
+
var DEFAULT_RESEND_AFTER = 60;
|
|
196
|
+
var COOKIE_GRACE_SECONDS = 300;
|
|
197
|
+
var HttpError = class extends Error {
|
|
198
|
+
constructor(status, code, message, retryAfterSeconds, maintenance2) {
|
|
199
|
+
super(message);
|
|
200
|
+
this.status = status;
|
|
201
|
+
this.code = code;
|
|
202
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
203
|
+
this.maintenance = maintenance2;
|
|
204
|
+
}
|
|
205
|
+
status;
|
|
206
|
+
code;
|
|
207
|
+
retryAfterSeconds;
|
|
208
|
+
maintenance;
|
|
209
|
+
};
|
|
210
|
+
function createOtpHandlers(options) {
|
|
211
|
+
const secret = options.secret ?? readEnv("LABYRINTHE_OTP_SECRET");
|
|
212
|
+
if (secret === void 0 || secret.length < 32) {
|
|
213
|
+
throw new Error("createOtpHandlers: `secret` (or LABYRINTHE_OTP_SECRET) must be at least 32 characters.");
|
|
214
|
+
}
|
|
215
|
+
if (typeof options.onVerified !== "function") {
|
|
216
|
+
throw new Error("createOtpHandlers: `onVerified` is required (it is where the verified phone number is trusted).");
|
|
217
|
+
}
|
|
218
|
+
if (options.exposeTestCode === true && readEnv("NODE_ENV") === "production") {
|
|
219
|
+
throw new Error('createOtpHandlers: `exposeTestCode` is for development only and is refused when NODE_ENV is "production".');
|
|
220
|
+
}
|
|
221
|
+
const defaultCountryCode = options.defaultCountryCode ?? DEFAULT_COUNTRY_CODE;
|
|
222
|
+
if (!isCountryCode(defaultCountryCode)) {
|
|
223
|
+
throw new Error('createOtpHandlers: `defaultCountryCode` must be 1 to 3 digits without "+".');
|
|
224
|
+
}
|
|
225
|
+
if (options.template !== void 0 && !options.template.includes("{code}")) {
|
|
226
|
+
throw new Error("createOtpHandlers: `template` must contain the {code} token.");
|
|
227
|
+
}
|
|
228
|
+
if (options.webOtpDomain !== void 0 && !WEB_OTP_DOMAIN.test(options.webOtpDomain)) {
|
|
229
|
+
throw new Error('createOtpHandlers: `webOtpDomain` must be a host name such as "app.example.com" (no scheme, port or path).');
|
|
230
|
+
}
|
|
231
|
+
const sealer = new Sealer(secret);
|
|
232
|
+
const cookieName = options.cookie?.name ?? "labyrinthe_otp";
|
|
233
|
+
let client;
|
|
234
|
+
const getClient = () => client ??= typeof options.client === "function" ? options.client() : options.client;
|
|
235
|
+
const cookieAttributes = (request) => ({
|
|
236
|
+
path: options.cookie?.path ?? "/",
|
|
237
|
+
domain: options.cookie?.domain,
|
|
238
|
+
sameSite: options.cookie?.sameSite ?? "Strict",
|
|
239
|
+
secure: options.cookie?.secure ?? (isHttps(request) || readEnv("NODE_ENV") === "production")
|
|
240
|
+
});
|
|
241
|
+
async function loadState(request) {
|
|
242
|
+
const payload = await sealer.open(readCookie(request.header("cookie"), cookieName), cookieName);
|
|
243
|
+
if (payload === null || typeof payload !== "object") {
|
|
244
|
+
return { state: null, sid: null };
|
|
245
|
+
}
|
|
246
|
+
if (options.store !== void 0) {
|
|
247
|
+
if (typeof payload.sid !== "string") {
|
|
248
|
+
return { state: null, sid: null };
|
|
249
|
+
}
|
|
250
|
+
const stored = await options.store.get(payload.sid);
|
|
251
|
+
return { state: isSessionState(stored) ? stored : null, sid: payload.sid };
|
|
252
|
+
}
|
|
253
|
+
return { state: isSessionState(payload.s) ? payload.s : null, sid: null };
|
|
254
|
+
}
|
|
255
|
+
async function saveState(request, result, state, sid) {
|
|
256
|
+
const ttl = Math.max(1, Math.ceil((state.expiresAt - Date.now()) / 1e3) + COOKIE_GRACE_SECONDS);
|
|
257
|
+
let payload;
|
|
258
|
+
if (options.store !== void 0) {
|
|
259
|
+
const id = sid ?? await randomId();
|
|
260
|
+
await options.store.set(id, state, ttl);
|
|
261
|
+
payload = { sid: id };
|
|
262
|
+
} else {
|
|
263
|
+
payload = { s: state };
|
|
264
|
+
}
|
|
265
|
+
result.cookies.push(serializeCookie(cookieName, await sealer.seal(payload, cookieName), ttl, cookieAttributes(request)));
|
|
266
|
+
}
|
|
267
|
+
async function clearState(request, result, sid) {
|
|
268
|
+
if (sid !== null && options.store !== void 0) {
|
|
269
|
+
await options.store.delete(sid);
|
|
270
|
+
}
|
|
271
|
+
result.cookies.push(serializeCookie(cookieName, "", 0, cookieAttributes(request)));
|
|
272
|
+
}
|
|
273
|
+
async function readJson(request) {
|
|
274
|
+
if (request.method !== "POST") {
|
|
275
|
+
throw new HttpError(405, "invalid_request", "Use POST.");
|
|
276
|
+
}
|
|
277
|
+
if (!originAllowed(request, options.allowedOrigins)) {
|
|
278
|
+
throw new HttpError(403, "forbidden", "Origin not allowed.");
|
|
279
|
+
}
|
|
280
|
+
if (!(request.header("content-type") ?? "").toLowerCase().includes("application/json")) {
|
|
281
|
+
throw new HttpError(415, "invalid_request", "Content-Type must be application/json.");
|
|
282
|
+
}
|
|
283
|
+
const text = await request.text();
|
|
284
|
+
if (text.length > MAX_BODY_BYTES) {
|
|
285
|
+
throw new HttpError(413, "invalid_request", "Request body too large.");
|
|
286
|
+
}
|
|
287
|
+
let body;
|
|
288
|
+
try {
|
|
289
|
+
body = JSON.parse(text);
|
|
290
|
+
} catch {
|
|
291
|
+
throw new HttpError(400, "invalid_request", "Invalid JSON.");
|
|
292
|
+
}
|
|
293
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
294
|
+
throw new HttpError(400, "invalid_request", "Expected a JSON object.");
|
|
295
|
+
}
|
|
296
|
+
return body;
|
|
297
|
+
}
|
|
298
|
+
async function send(request, result) {
|
|
299
|
+
const body = await readJson(request);
|
|
300
|
+
const phone = typeof body["phone"] === "string" ? normalizePhone(body["phone"], defaultCountryCode) : null;
|
|
301
|
+
if (phone === null) {
|
|
302
|
+
throw new HttpError(422, "invalid_phone", "Invalid phone number.");
|
|
303
|
+
}
|
|
304
|
+
if (options.beforeSend !== void 0 && await options.beforeSend({ phone, request: request.source }) === false) {
|
|
305
|
+
throw new HttpError(403, "forbidden", "Refused.");
|
|
306
|
+
}
|
|
307
|
+
const locale = options.locale ?? (body["locale"] === "fr" || body["locale"] === "en" ? body["locale"] : void 0);
|
|
308
|
+
const nonce = body["send_id"];
|
|
309
|
+
if (nonce !== void 0 && (typeof nonce !== "string" || !SEND_ID.test(nonce))) {
|
|
310
|
+
throw new HttpError(400, "invalid_request", "Invalid send_id.");
|
|
311
|
+
}
|
|
312
|
+
const idempotencyKey = nonce === void 0 ? void 0 : `otp-widget-${await sealer.tag(`send:${nonce}`)}`;
|
|
313
|
+
let sent;
|
|
314
|
+
try {
|
|
315
|
+
sent = await getClient().otp.send({
|
|
316
|
+
// « + » explicite : le numéro est déjà international, l'indicatif par défaut ne doit pas s'y ajouter.
|
|
317
|
+
phone: `+${phone}`,
|
|
318
|
+
...options.template === void 0 ? {} : { template: options.template },
|
|
319
|
+
...locale === void 0 ? {} : { locale },
|
|
320
|
+
...options.webOtpDomain === void 0 ? {} : { webOtpDomain: options.webOtpDomain },
|
|
321
|
+
...options.stripAccents === void 0 ? {} : { stripAccents: options.stripAccents },
|
|
322
|
+
...idempotencyKey === void 0 ? {} : { idempotencyKey }
|
|
323
|
+
});
|
|
324
|
+
} catch (error) {
|
|
325
|
+
throw upstreamError(error, "send", options.onError);
|
|
326
|
+
}
|
|
327
|
+
const previous = await loadState(request);
|
|
328
|
+
const state = {
|
|
329
|
+
otpId: sent.otpId,
|
|
330
|
+
phone,
|
|
331
|
+
expiresAt: sent.expiresAt.getTime(),
|
|
332
|
+
attemptsLeft: sent.maxAttempts
|
|
333
|
+
};
|
|
334
|
+
if (previous.sid !== null && options.store !== void 0) {
|
|
335
|
+
await options.store.delete(previous.sid);
|
|
336
|
+
}
|
|
337
|
+
await saveState(request, result, state, null);
|
|
338
|
+
const codeLength = options.codeLength ?? positiveInt(sent.codeLength);
|
|
339
|
+
const codeType = options.codeType ?? (sent.codeType === "numeric" || sent.codeType === "alphanumeric" ? sent.codeType : void 0);
|
|
340
|
+
const resendAfter = options.resendAfterSeconds ?? nonNegativeInt(sent.resendAfterSeconds) ?? DEFAULT_RESEND_AFTER;
|
|
341
|
+
const response = {
|
|
342
|
+
sent: true,
|
|
343
|
+
phone_masked: maskPhone(phone),
|
|
344
|
+
expires_at: sent.expiresAt.toISOString(),
|
|
345
|
+
max_attempts: sent.maxAttempts,
|
|
346
|
+
resend_after_seconds: resendAfter,
|
|
347
|
+
...codeLength === void 0 ? {} : { code_length: codeLength },
|
|
348
|
+
...codeType === void 0 ? {} : { code_type: codeType }
|
|
349
|
+
};
|
|
350
|
+
const testCode = sent.testCode;
|
|
351
|
+
if (options.exposeTestCode === true && typeof testCode === "string" && testCode !== "") {
|
|
352
|
+
response.test_code = testCode;
|
|
353
|
+
}
|
|
354
|
+
result.body = response;
|
|
355
|
+
}
|
|
356
|
+
async function verify(request, result) {
|
|
357
|
+
const body = await readJson(request);
|
|
358
|
+
const code = typeof body["code"] === "string" ? body["code"].trim() : "";
|
|
359
|
+
if (code === "" || code.length > MAX_CODE_LENGTH) {
|
|
360
|
+
throw new HttpError(422, "invalid_request", `The code must be 1 to ${String(MAX_CODE_LENGTH)} characters.`);
|
|
361
|
+
}
|
|
362
|
+
const { state, sid } = await loadState(request);
|
|
363
|
+
if (state === null) {
|
|
364
|
+
await clearState(request, result, sid);
|
|
365
|
+
throw new HttpError(400, "no_pending_verification", "No pending verification: send a new code.");
|
|
366
|
+
}
|
|
367
|
+
if (state.expiresAt <= Date.now()) {
|
|
368
|
+
await clearState(request, result, sid);
|
|
369
|
+
result.body = { verified: false, reason: "expired", attempts_left: null };
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
let checked;
|
|
373
|
+
try {
|
|
374
|
+
checked = await getClient().otp.verify({ otpId: state.otpId, code });
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (errorCode(error) === "NOT_FOUND") {
|
|
377
|
+
await clearState(request, result, sid);
|
|
378
|
+
throw new HttpError(400, "no_pending_verification", "No pending verification: send a new code.");
|
|
379
|
+
}
|
|
380
|
+
throw upstreamError(error, "verify", options.onError);
|
|
381
|
+
}
|
|
382
|
+
if (checked.verified) {
|
|
383
|
+
await clearState(request, result, sid);
|
|
384
|
+
const data = await options.onVerified({
|
|
385
|
+
phone: state.phone,
|
|
386
|
+
request: request.source,
|
|
387
|
+
responseHeaders: result.headers,
|
|
388
|
+
setCookie: (cookie) => result.cookies.push(cookie)
|
|
389
|
+
});
|
|
390
|
+
result.body = data === void 0 ? { verified: true } : { verified: true, data };
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const reason = checked.reason === "expired" || checked.reason === "already_used" || checked.reason === "max_attempts_reached" ? checked.reason : "invalid_code";
|
|
394
|
+
if (reason === "invalid_code") {
|
|
395
|
+
const fromApi = nonNegativeInt(checked.attemptsRemaining);
|
|
396
|
+
const attemptsLeft = fromApi ?? (state.attemptsLeft === null ? null : Math.max(0, state.attemptsLeft - 1));
|
|
397
|
+
await saveState(request, result, { ...state, attemptsLeft }, sid);
|
|
398
|
+
result.body = { verified: false, reason, attempts_left: attemptsLeft };
|
|
399
|
+
} else {
|
|
400
|
+
await clearState(request, result, sid);
|
|
401
|
+
result.body = { verified: false, reason, attempts_left: reason === "max_attempts_reached" ? 0 : null };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async function run(action, request) {
|
|
405
|
+
const result = { status: 200, body: { verified: false, reason: "invalid_code", attempts_left: null }, headers: new Headers(), cookies: [] };
|
|
406
|
+
try {
|
|
407
|
+
await (action === "send" ? send : verify)(request, result);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
const httpError = error instanceof HttpError ? error : (options.onError?.(error, { action }), new HttpError(500, "server_error", "Unexpected error."));
|
|
410
|
+
result.status = httpError.status;
|
|
411
|
+
result.body = {
|
|
412
|
+
error: {
|
|
413
|
+
code: httpError.code,
|
|
414
|
+
message: httpError.message,
|
|
415
|
+
...httpError.retryAfterSeconds === void 0 ? {} : { retry_after_seconds: httpError.retryAfterSeconds },
|
|
416
|
+
...httpError.maintenance?.adminMessage === void 0 ? {} : { admin_message: httpError.maintenance.adminMessage },
|
|
417
|
+
...httpError.maintenance?.until === void 0 ? {} : { until: httpError.maintenance.until }
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
if (httpError.retryAfterSeconds !== void 0) {
|
|
421
|
+
result.headers.set("Retry-After", String(httpError.retryAfterSeconds));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
result.headers.set("Content-Type", "application/json");
|
|
425
|
+
result.headers.set("Cache-Control", "no-store");
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
const fetchHandler = (action) => async (request) => {
|
|
429
|
+
const result = await run(action, fromFetch(request));
|
|
430
|
+
const headers = new Headers(result.headers);
|
|
431
|
+
for (const cookie of result.cookies) {
|
|
432
|
+
headers.append("Set-Cookie", cookie);
|
|
433
|
+
}
|
|
434
|
+
return new Response(JSON.stringify(result.body), { status: result.status, headers });
|
|
435
|
+
};
|
|
436
|
+
const expressHandler = (action) => async (req, res, next) => {
|
|
437
|
+
try {
|
|
438
|
+
const result = await run(action, fromExpress(req));
|
|
439
|
+
res.statusCode = result.status;
|
|
440
|
+
result.headers.forEach((value, name) => {
|
|
441
|
+
res.setHeader(name, value);
|
|
442
|
+
});
|
|
443
|
+
if (result.cookies.length > 0) {
|
|
444
|
+
res.setHeader("Set-Cookie", result.cookies);
|
|
445
|
+
}
|
|
446
|
+
res.end(JSON.stringify(result.body));
|
|
447
|
+
} catch (error) {
|
|
448
|
+
next(error);
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
return {
|
|
452
|
+
send: fetchHandler("send"),
|
|
453
|
+
verify: fetchHandler("verify"),
|
|
454
|
+
express: { send: expressHandler("send"), verify: expressHandler("verify") }
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function errorCode(error) {
|
|
458
|
+
const code = error?.code;
|
|
459
|
+
return typeof code === "string" ? code : null;
|
|
460
|
+
}
|
|
461
|
+
function upstreamError(error, action, onError) {
|
|
462
|
+
const code = errorCode(error);
|
|
463
|
+
switch (code) {
|
|
464
|
+
case "INVALID_ARGUMENT":
|
|
465
|
+
case "INVALID_PHONE":
|
|
466
|
+
case "RECIPIENT_NOT_SENDABLE":
|
|
467
|
+
return new HttpError(422, "invalid_phone", "This phone number cannot receive a code.");
|
|
468
|
+
case "OTP_RESEND_TOO_SOON":
|
|
469
|
+
return new HttpError(429, "resend_too_soon", "A code was sent recently.", retryAfter(error) ?? 30);
|
|
470
|
+
case "OTP_RATE_LIMITED":
|
|
471
|
+
case "TOO_MANY_REQUESTS":
|
|
472
|
+
return new HttpError(429, "rate_limited", "Too many requests.", retryAfter(error));
|
|
473
|
+
case "IDEMPOTENCY_REQUEST_IN_PROGRESS":
|
|
474
|
+
return new HttpError(429, "rate_limited", "A send is still in progress.", retryAfter(error) ?? 5);
|
|
475
|
+
case "IDEMPOTENCY_KEY_UNRESOLVED":
|
|
476
|
+
onError?.(error, { action });
|
|
477
|
+
return new HttpError(502, "send_failed", "The code may not have been sent.");
|
|
478
|
+
case "FEATURE_DISABLED":
|
|
479
|
+
case "OTP_SERVICE_DISABLED":
|
|
480
|
+
onError?.(error, { action });
|
|
481
|
+
return new HttpError(
|
|
482
|
+
503,
|
|
483
|
+
"service_unavailable",
|
|
484
|
+
"The verification service is temporarily unavailable.",
|
|
485
|
+
featureRetryAfter(error),
|
|
486
|
+
maintenance(error)
|
|
487
|
+
);
|
|
488
|
+
case "ACCOUNT_SUSPENDED":
|
|
489
|
+
onError?.(error, { action });
|
|
490
|
+
return new HttpError(503, "account_suspended", "The verification service is unavailable.");
|
|
491
|
+
default:
|
|
492
|
+
onError?.(error, { action });
|
|
493
|
+
return new HttpError(503, "service_unavailable", "The verification service is unavailable.");
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
var WEB_OTP_DOMAIN = /^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
|
|
497
|
+
var SEND_ID = /^[A-Za-z0-9_-]{16,64}$/;
|
|
498
|
+
function positiveInt(value) {
|
|
499
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : void 0;
|
|
500
|
+
}
|
|
501
|
+
function nonNegativeInt(value) {
|
|
502
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
|
|
503
|
+
}
|
|
504
|
+
function retryAfter(error) {
|
|
505
|
+
const ms = error?.retryAfterMs;
|
|
506
|
+
if (typeof ms === "number" && ms >= 0) {
|
|
507
|
+
return Math.ceil(ms / 1e3);
|
|
508
|
+
}
|
|
509
|
+
const seconds = error?.details?.retry_after_seconds;
|
|
510
|
+
return typeof seconds === "number" && seconds >= 0 ? Math.ceil(seconds) : void 0;
|
|
511
|
+
}
|
|
512
|
+
function featureRetryAfter(error) {
|
|
513
|
+
const ms = error?.retryAfterMs;
|
|
514
|
+
if (typeof ms === "number" && ms >= 0) {
|
|
515
|
+
return Math.max(1, Math.ceil(ms / 1e3));
|
|
516
|
+
}
|
|
517
|
+
const header = error?.headers?.["retry-after"];
|
|
518
|
+
return typeof header === "string" && /^\d+$/.test(header.trim()) ? Math.max(1, Number(header.trim())) : void 0;
|
|
519
|
+
}
|
|
520
|
+
function maintenance(error) {
|
|
521
|
+
const details = error?.details ?? {};
|
|
522
|
+
const message = typeof details["message"] === "string" ? details["message"].trim().slice(0, 500) : "";
|
|
523
|
+
const until = details["until"];
|
|
524
|
+
return {
|
|
525
|
+
...message === "" ? {} : { adminMessage: message },
|
|
526
|
+
...typeof until === "string" && !Number.isNaN(Date.parse(until)) ? { until } : {}
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
function originAllowed(request, allowed) {
|
|
530
|
+
const origin = request.header("origin");
|
|
531
|
+
if (allowed !== void 0) {
|
|
532
|
+
return origin === null ? request.header("sec-fetch-site") !== "cross-site" : allowed.includes(origin);
|
|
533
|
+
}
|
|
534
|
+
if (request.header("sec-fetch-site") === "cross-site") {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
if (origin === null) {
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
const host = request.header("x-forwarded-host") ?? request.header("host") ?? safeHost(request.url);
|
|
541
|
+
try {
|
|
542
|
+
return new URL(origin).host === host;
|
|
543
|
+
} catch {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function isHttps(request) {
|
|
548
|
+
const forwarded = request.header("x-forwarded-proto");
|
|
549
|
+
if (forwarded !== null) {
|
|
550
|
+
return forwarded.split(",")[0]?.trim() === "https";
|
|
551
|
+
}
|
|
552
|
+
return request.url.startsWith("https:");
|
|
553
|
+
}
|
|
554
|
+
function safeHost(url) {
|
|
555
|
+
try {
|
|
556
|
+
return new URL(url).host;
|
|
557
|
+
} catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function fromFetch(request) {
|
|
562
|
+
return {
|
|
563
|
+
method: request.method.toUpperCase(),
|
|
564
|
+
url: request.url,
|
|
565
|
+
header: (name) => request.headers.get(name),
|
|
566
|
+
text: () => request.text(),
|
|
567
|
+
source: request
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function fromExpress(req) {
|
|
571
|
+
const header = (name) => {
|
|
572
|
+
const value = req.headers[name.toLowerCase()];
|
|
573
|
+
return Array.isArray(value) ? value.join(name.toLowerCase() === "cookie" ? "; " : ", ") : value ?? null;
|
|
574
|
+
};
|
|
575
|
+
return {
|
|
576
|
+
method: (req.method ?? "POST").toUpperCase(),
|
|
577
|
+
url: `${req.protocol ?? "http"}://${header("host") ?? "localhost"}${req.originalUrl ?? req.url ?? "/"}`,
|
|
578
|
+
header,
|
|
579
|
+
text: async () => {
|
|
580
|
+
if (typeof req.body === "string") {
|
|
581
|
+
return req.body;
|
|
582
|
+
}
|
|
583
|
+
if (req.body instanceof Uint8Array) {
|
|
584
|
+
return new TextDecoder().decode(req.body);
|
|
585
|
+
}
|
|
586
|
+
if (req.body !== void 0) {
|
|
587
|
+
return JSON.stringify(req.body);
|
|
588
|
+
}
|
|
589
|
+
const decoder2 = new TextDecoder();
|
|
590
|
+
let text = "";
|
|
591
|
+
for await (const chunk of req) {
|
|
592
|
+
text += typeof chunk === "string" ? chunk : decoder2.decode(chunk, { stream: true });
|
|
593
|
+
if (text.length > MAX_BODY_BYTES) {
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return text + decoder2.decode();
|
|
598
|
+
},
|
|
599
|
+
source: req
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
function readEnv(name) {
|
|
603
|
+
try {
|
|
604
|
+
return globalThis.process?.env?.[name];
|
|
605
|
+
} catch {
|
|
606
|
+
return void 0;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export { createOtpHandlers, memoryStore };
|