@draftlab/auth 0.4.1 → 0.5.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/dist/adapters/{node.js → node.mjs} +2 -4
- package/dist/{allow.js → allow.mjs} +1 -1
- package/dist/{client.d.ts → client.d.mts} +2 -2
- package/dist/{client.js → client.mjs} +55 -10
- package/dist/{core.d.ts → core.d.mts} +10 -10
- package/dist/{core.js → core.mjs} +72 -55
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +3 -0
- package/dist/{keys.d.ts → keys.d.mts} +1 -1
- package/dist/{keys.js → keys.mjs} +6 -8
- package/dist/{pkce.js → pkce.mjs} +5 -10
- package/dist/plugin/{builder.d.ts → builder.d.mts} +1 -1
- package/dist/plugin/{manager.d.ts → manager.d.mts} +2 -2
- package/dist/plugin/{manager.js → manager.mjs} +1 -1
- package/dist/plugin/{plugin.d.ts → plugin.d.mts} +1 -1
- package/dist/plugin/{types.d.ts → types.d.mts} +1 -1
- package/dist/provider/{code.d.ts → code.d.mts} +1 -1
- package/dist/provider/{code.js → code.mjs} +2 -3
- package/dist/provider/{discord.d.ts → discord.d.mts} +2 -2
- package/dist/provider/{discord.js → discord.mjs} +59 -1
- package/dist/provider/{facebook.d.ts → facebook.d.mts} +2 -2
- package/dist/provider/{facebook.js → facebook.mjs} +57 -1
- package/dist/provider/{github.d.ts → github.d.mts} +2 -2
- package/dist/provider/{github.js → github.mjs} +79 -1
- package/dist/provider/{google.d.ts → google.d.mts} +2 -2
- package/dist/provider/{google.js → google.mjs} +45 -1
- package/dist/provider/{linkedin.d.ts → linkedin.d.mts} +2 -2
- package/dist/provider/{linkedin.js → linkedin.mjs} +57 -1
- package/dist/provider/{magiclink.d.ts → magiclink.d.mts} +1 -1
- package/dist/provider/{magiclink.js → magiclink.mjs} +4 -6
- package/dist/provider/{microsoft.d.ts → microsoft.d.mts} +2 -2
- package/dist/provider/{microsoft.js → microsoft.mjs} +68 -1
- package/dist/provider/{oauth2.d.ts → oauth2.d.mts} +1 -1
- package/dist/provider/{oauth2.js → oauth2.mjs} +4 -4
- package/dist/provider/{passkey.d.ts → passkey.d.mts} +1 -1
- package/dist/provider/{passkey.js → passkey.mjs} +8 -13
- package/dist/provider/{password.d.ts → password.d.mts} +1 -1
- package/dist/provider/{password.js → password.mjs} +31 -44
- package/dist/provider/{provider.d.ts → provider.d.mts} +1 -1
- package/dist/provider/{totp.d.ts → totp.d.mts} +1 -1
- package/dist/provider/{totp.js → totp.mjs} +51 -14
- package/dist/{random.js → random.mjs} +1 -2
- package/dist/storage/{memory.d.ts → memory.d.mts} +1 -1
- package/dist/storage/{memory.js → memory.mjs} +3 -5
- package/dist/storage/{storage.d.ts → storage.d.mts} +27 -10
- package/dist/storage/storage.mjs +104 -0
- package/dist/storage/{turso.d.ts → turso.d.mts} +1 -1
- package/dist/storage/{turso.js → turso.mjs} +1 -1
- package/dist/storage/{unstorage.d.ts → unstorage.d.mts} +1 -1
- package/dist/storage/{unstorage.js → unstorage.mjs} +11 -4
- package/dist/{subject.d.ts → subject.d.mts} +1 -1
- package/dist/ui/{base.d.ts → base.d.mts} +1 -1
- package/dist/ui/{base.js → base.mjs} +1 -1
- package/dist/ui/{code.d.ts → code.d.mts} +1 -1
- package/dist/ui/{code.js → code.mjs} +3 -4
- package/dist/ui/{magiclink.d.ts → magiclink.d.mts} +1 -1
- package/dist/ui/{magiclink.js → magiclink.mjs} +3 -4
- package/dist/ui/{passkey.d.ts → passkey.d.mts} +1 -1
- package/dist/ui/{passkey.js → passkey.mjs} +2 -2
- package/dist/ui/{password.d.ts → password.d.mts} +1 -1
- package/dist/ui/{password.js → password.mjs} +3 -4
- package/dist/ui/{select.d.ts → select.d.mts} +1 -1
- package/dist/ui/{select.js → select.mjs} +2 -2
- package/dist/ui/{totp.d.ts → totp.d.mts} +1 -1
- package/dist/ui/{totp.js → totp.mjs} +2 -2
- package/dist/{util.js → util.mjs} +2 -5
- package/package.json +17 -16
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -3
- package/dist/storage/storage.js +0 -62
- /package/dist/adapters/{node.d.ts → node.d.mts} +0 -0
- /package/dist/{allow.d.ts → allow.d.mts} +0 -0
- /package/dist/{error.d.ts → error.d.mts} +0 -0
- /package/dist/{error.js → error.mjs} +0 -0
- /package/dist/{pkce.d.ts → pkce.d.mts} +0 -0
- /package/dist/plugin/{builder.js → builder.mjs} +0 -0
- /package/dist/plugin/{plugin.js → plugin.mjs} +0 -0
- /package/dist/plugin/{types.js → types.mjs} +0 -0
- /package/dist/provider/{provider.js → provider.mjs} +0 -0
- /package/dist/{random.d.ts → random.d.mts} +0 -0
- /package/dist/{subject.js → subject.mjs} +0 -0
- /package/dist/themes/{theme.d.ts → theme.d.mts} +0 -0
- /package/dist/themes/{theme.js → theme.mjs} +0 -0
- /package/dist/{types.d.ts → types.d.mts} +0 -0
- /package/dist/{types.js → types.mjs} +0 -0
- /package/dist/ui/{form.d.ts → form.d.mts} +0 -0
- /package/dist/ui/{form.js → form.mjs} +0 -0
- /package/dist/ui/{icon.d.ts → icon.d.mts} +0 -0
- /package/dist/ui/{icon.js → icon.mjs} +0 -0
- /package/dist/{util.d.ts → util.d.mts} +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { getRelativeUrl } from "../util.
|
|
2
|
-
import { UnknownStateError } from "../error.
|
|
3
|
-
import { generateUnbiasedDigits, timingSafeCompare } from "../random.
|
|
4
|
-
import { Storage } from "../storage/storage.
|
|
1
|
+
import { getRelativeUrl } from "../util.mjs";
|
|
2
|
+
import { UnknownStateError } from "../error.mjs";
|
|
3
|
+
import { generateUnbiasedDigits, timingSafeCompare } from "../random.mjs";
|
|
4
|
+
import { Storage } from "../storage/storage.mjs";
|
|
5
5
|
import * as jose from "jose";
|
|
6
6
|
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
7
7
|
import { TextEncoder } from "node:util";
|
|
@@ -123,12 +123,11 @@ const PasswordProvider = (config) => {
|
|
|
123
123
|
message: validationError
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
if (await Storage.get(ctx.storage, [
|
|
127
127
|
"email",
|
|
128
128
|
email,
|
|
129
129
|
"password"
|
|
130
|
-
]);
|
|
131
|
-
if (existingUser) return transition(provider, { type: "email_taken" });
|
|
130
|
+
])) return transition(provider, { type: "email_taken" });
|
|
132
131
|
const code = generateCode();
|
|
133
132
|
await config.sendCode(email, code);
|
|
134
133
|
return transition({
|
|
@@ -151,12 +150,11 @@ const PasswordProvider = (config) => {
|
|
|
151
150
|
if (action === "verify" && provider.type === "code") {
|
|
152
151
|
const code = formData.get("code")?.toString();
|
|
153
152
|
if (!(code && timingSafeCompare(code, provider.code))) return transition(provider, { type: "invalid_code" });
|
|
154
|
-
|
|
153
|
+
if (await Storage.get(ctx.storage, [
|
|
155
154
|
"email",
|
|
156
155
|
provider.email,
|
|
157
156
|
"password"
|
|
158
|
-
]);
|
|
159
|
-
if (existingUser) return transition({ type: "start" }, { type: "email_taken" });
|
|
157
|
+
])) return transition({ type: "start" }, { type: "email_taken" });
|
|
160
158
|
await Storage.set(ctx.storage, [
|
|
161
159
|
"email",
|
|
162
160
|
provider.email,
|
|
@@ -170,10 +168,9 @@ const PasswordProvider = (config) => {
|
|
|
170
168
|
* GET /change - Display password change form
|
|
171
169
|
*/
|
|
172
170
|
routes.get("/change", async (c) => {
|
|
173
|
-
const redirect = c.query("redirect_uri") || getRelativeUrl(c, "/authorize");
|
|
174
171
|
const state = {
|
|
175
172
|
type: "start",
|
|
176
|
-
redirect
|
|
173
|
+
redirect: c.query("redirect_uri") || getRelativeUrl(c, "/authorize")
|
|
177
174
|
};
|
|
178
175
|
await ctx.set(c, "provider", 3600 * 24, state);
|
|
179
176
|
return ctx.forward(c, await config.change(c.request, state));
|
|
@@ -215,12 +212,11 @@ const PasswordProvider = (config) => {
|
|
|
215
212
|
});
|
|
216
213
|
}
|
|
217
214
|
if (action === "update" && provider.type === "update") {
|
|
218
|
-
|
|
215
|
+
if (!await Storage.get(ctx.storage, [
|
|
219
216
|
"email",
|
|
220
217
|
provider.email,
|
|
221
218
|
"password"
|
|
222
|
-
]);
|
|
223
|
-
if (!existingPassword) return c.redirect(provider.redirect, 302);
|
|
219
|
+
])) return c.redirect(provider.redirect, 302);
|
|
224
220
|
const password = formData.get("password")?.toString();
|
|
225
221
|
const repeat = formData.get("repeat")?.toString();
|
|
226
222
|
if (!password) return transition(provider, { type: "invalid_password" });
|
|
@@ -274,8 +270,7 @@ const PBKDF2Hasher = (opts) => {
|
|
|
274
270
|
const iterations = opts?.iterations ?? 6e5;
|
|
275
271
|
return {
|
|
276
272
|
async hash(password) {
|
|
277
|
-
const
|
|
278
|
-
const passwordBytes = encoder.encode(password);
|
|
273
|
+
const passwordBytes = new TextEncoder().encode(password);
|
|
279
274
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
280
275
|
const keyMaterial = await crypto.subtle.importKey("raw", passwordBytes, "PBKDF2", false, ["deriveBits"]);
|
|
281
276
|
const hashBuffer = await crypto.subtle.deriveBits({
|
|
@@ -284,17 +279,14 @@ const PBKDF2Hasher = (opts) => {
|
|
|
284
279
|
salt,
|
|
285
280
|
iterations
|
|
286
281
|
}, keyMaterial, 256);
|
|
287
|
-
const hashBase64 = jose.base64url.encode(new Uint8Array(hashBuffer));
|
|
288
|
-
const saltBase64 = jose.base64url.encode(salt);
|
|
289
282
|
return {
|
|
290
|
-
hash:
|
|
291
|
-
salt:
|
|
283
|
+
hash: jose.base64url.encode(new Uint8Array(hashBuffer)),
|
|
284
|
+
salt: jose.base64url.encode(salt),
|
|
292
285
|
iterations
|
|
293
286
|
};
|
|
294
287
|
},
|
|
295
288
|
async verify(password, compare) {
|
|
296
|
-
const
|
|
297
|
-
const passwordBytes = encoder.encode(password);
|
|
289
|
+
const passwordBytes = new TextEncoder().encode(password);
|
|
298
290
|
const salt = jose.base64url.decode(compare.salt);
|
|
299
291
|
const keyMaterial = await crypto.subtle.importKey("raw", passwordBytes, "PBKDF2", false, ["deriveBits"]);
|
|
300
292
|
const hashBuffer = await crypto.subtle.deriveBits({
|
|
@@ -303,8 +295,7 @@ const PBKDF2Hasher = (opts) => {
|
|
|
303
295
|
salt,
|
|
304
296
|
iterations: compare.iterations
|
|
305
297
|
}, keyMaterial, 256);
|
|
306
|
-
|
|
307
|
-
return timingSafeCompare(hashBase64, compare.hash);
|
|
298
|
+
return timingSafeCompare(jose.base64url.encode(new Uint8Array(hashBuffer)), compare.hash);
|
|
308
299
|
}
|
|
309
300
|
};
|
|
310
301
|
};
|
|
@@ -324,21 +315,18 @@ const ScryptHasher = (opts) => {
|
|
|
324
315
|
async hash(password) {
|
|
325
316
|
const salt = randomBytes(16);
|
|
326
317
|
const keyLength = 32;
|
|
327
|
-
const derivedKey = await new Promise((resolve, reject) => {
|
|
328
|
-
scrypt(password, salt, keyLength, {
|
|
329
|
-
N,
|
|
330
|
-
r,
|
|
331
|
-
p
|
|
332
|
-
}, (err, derivedKey$1) => {
|
|
333
|
-
if (err) reject(err);
|
|
334
|
-
else resolve(derivedKey$1);
|
|
335
|
-
});
|
|
336
|
-
});
|
|
337
|
-
const hashBase64 = derivedKey.toString("base64");
|
|
338
|
-
const saltBase64 = salt.toString("base64");
|
|
339
318
|
return {
|
|
340
|
-
hash:
|
|
341
|
-
|
|
319
|
+
hash: (await new Promise((resolve, reject) => {
|
|
320
|
+
scrypt(password, salt, keyLength, {
|
|
321
|
+
N,
|
|
322
|
+
r,
|
|
323
|
+
p
|
|
324
|
+
}, (err, derivedKey) => {
|
|
325
|
+
if (err) reject(err);
|
|
326
|
+
else resolve(derivedKey);
|
|
327
|
+
});
|
|
328
|
+
})).toString("base64"),
|
|
329
|
+
salt: salt.toString("base64"),
|
|
342
330
|
N,
|
|
343
331
|
r,
|
|
344
332
|
p
|
|
@@ -347,17 +335,16 @@ const ScryptHasher = (opts) => {
|
|
|
347
335
|
async verify(password, compare) {
|
|
348
336
|
const salt = Buffer.from(compare.salt, "base64");
|
|
349
337
|
const keyLength = 32;
|
|
350
|
-
|
|
338
|
+
return timingSafeEqual(await new Promise((resolve, reject) => {
|
|
351
339
|
scrypt(password, salt, keyLength, {
|
|
352
340
|
N: compare.N,
|
|
353
341
|
r: compare.r,
|
|
354
342
|
p: compare.p
|
|
355
|
-
}, (err, derivedKey
|
|
343
|
+
}, (err, derivedKey) => {
|
|
356
344
|
if (err) reject(err);
|
|
357
|
-
else resolve(derivedKey
|
|
345
|
+
else resolve(derivedKey);
|
|
358
346
|
});
|
|
359
|
-
});
|
|
360
|
-
return timingSafeEqual(derivedKey, Buffer.from(compare.hash, "base64"));
|
|
347
|
+
}), Buffer.from(compare.hash, "base64"));
|
|
361
348
|
}
|
|
362
349
|
};
|
|
363
350
|
};
|
|
@@ -1,8 +1,50 @@
|
|
|
1
|
-
import { generateSecureToken } from "../random.
|
|
2
|
-
import { Storage } from "../storage/storage.
|
|
1
|
+
import { generateSecureToken } from "../random.mjs";
|
|
2
|
+
import { Storage } from "../storage/storage.mjs";
|
|
3
3
|
import { Secret, TOTP } from "otpauth";
|
|
4
4
|
|
|
5
5
|
//#region src/provider/totp.ts
|
|
6
|
+
/**
|
|
7
|
+
* Configures a provider that supports TOTP (Time-based One-Time Password) authentication.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { TOTPProvider } from "@draftlab/auth/provider/totp"
|
|
11
|
+
*
|
|
12
|
+
* export default issuer({
|
|
13
|
+
* providers: {
|
|
14
|
+
* totp: TOTPProvider({
|
|
15
|
+
* issuer: "My Application",
|
|
16
|
+
* setup: async (req, qrCode, secret, backupCodes) => {
|
|
17
|
+
* return new Response(renderSetupPage(qrCode, secret, backupCodes))
|
|
18
|
+
* },
|
|
19
|
+
* verify: async (req, error) => {
|
|
20
|
+
* return new Response(renderVerifyPage(error))
|
|
21
|
+
* },
|
|
22
|
+
* recovery: async (req, error) => {
|
|
23
|
+
* return new Response(renderRecoveryPage(error))
|
|
24
|
+
* }
|
|
25
|
+
* })
|
|
26
|
+
* },
|
|
27
|
+
* // ...
|
|
28
|
+
* })
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* TOTPProvider implements Time-based One-Time Password authentication.
|
|
32
|
+
* It provides secure TOTP token generation and verification with backup recovery codes.
|
|
33
|
+
*
|
|
34
|
+
* The provider requires configuration of:
|
|
35
|
+
* - Issuer name for authenticator apps
|
|
36
|
+
* - UI handlers for setup, verification, and recovery flows
|
|
37
|
+
* - Optional TOTP parameters (algorithm, digits, period)
|
|
38
|
+
*
|
|
39
|
+
* It automatically manages:
|
|
40
|
+
* - Secure secret generation
|
|
41
|
+
* - QR code URL generation for authenticator apps
|
|
42
|
+
* - Token validation with timing attack protection
|
|
43
|
+
* - Recovery codes generation and one-time usage
|
|
44
|
+
* - Storage of TOTP configuration and backup codes
|
|
45
|
+
*
|
|
46
|
+
* @packageDocumentation
|
|
47
|
+
*/
|
|
6
48
|
const totpKey = (userId) => [
|
|
7
49
|
"totp",
|
|
8
50
|
"user",
|
|
@@ -72,16 +114,14 @@ const TOTPProvider = (config) => {
|
|
|
72
114
|
const secret = new Secret({ size: 20 });
|
|
73
115
|
const label = config.generateLabel ? await config.generateLabel(email) : email;
|
|
74
116
|
const backupCodes = generateBackupCodes(backupCodesCount);
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const totpData$1 = {
|
|
117
|
+
const qrCodeUrl$1 = createTOTPInstance(secret.base32, label).toString();
|
|
118
|
+
await saveTOTPData(email, {
|
|
78
119
|
secret: secret.base32,
|
|
79
120
|
enabled: false,
|
|
80
121
|
backupCodes,
|
|
81
122
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
82
123
|
label
|
|
83
|
-
};
|
|
84
|
-
await saveTOTPData(email, totpData$1);
|
|
124
|
+
});
|
|
85
125
|
return ctx.forward(c, await config.register(c.request, qrCodeUrl$1, secret.base32, backupCodes, void 0, email));
|
|
86
126
|
}
|
|
87
127
|
const token = formData.get("token")?.toString();
|
|
@@ -89,11 +129,10 @@ const TOTPProvider = (config) => {
|
|
|
89
129
|
const totpData = await getTOTPData(email);
|
|
90
130
|
if (!totpData) return ctx.forward(c, await config.register(c.request, "", "", [], "TOTP setup session not found"));
|
|
91
131
|
const totp = createTOTPInstance(totpData.secret, totpData.label || email);
|
|
92
|
-
|
|
132
|
+
if (totp.validate({
|
|
93
133
|
token,
|
|
94
134
|
window
|
|
95
|
-
})
|
|
96
|
-
if (delta !== null) {
|
|
135
|
+
}) !== null) {
|
|
97
136
|
totpData.enabled = true;
|
|
98
137
|
await saveTOTPData(email, totpData);
|
|
99
138
|
return ctx.success(c, {
|
|
@@ -114,12 +153,10 @@ const TOTPProvider = (config) => {
|
|
|
114
153
|
if (!email || !token) return ctx.forward(c, await config.authorize(c.request, "Email and verification code are required"));
|
|
115
154
|
const totpData = await getTOTPData(email);
|
|
116
155
|
if (!totpData || !totpData.enabled) return ctx.forward(c, await config.authorize(c.request, "TOTP is not set up for this email"));
|
|
117
|
-
|
|
118
|
-
const delta = totp.validate({
|
|
156
|
+
if (createTOTPInstance(totpData.secret, totpData.label || email).validate({
|
|
119
157
|
token,
|
|
120
158
|
window
|
|
121
|
-
})
|
|
122
|
-
if (delta !== null) return ctx.success(c, {
|
|
159
|
+
}) !== null) return ctx.success(c, {
|
|
123
160
|
email,
|
|
124
161
|
method: "totp"
|
|
125
162
|
});
|
|
@@ -25,8 +25,7 @@ const generateSecureToken = (length = 32) => {
|
|
|
25
25
|
if (length <= 0 || !Number.isInteger(length)) throw new RangeError("Token length must be a positive integer");
|
|
26
26
|
const randomBytes$1 = new Uint8Array(length);
|
|
27
27
|
crypto.getRandomValues(randomBytes$1);
|
|
28
|
-
|
|
29
|
-
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
28
|
+
return btoa(String.fromCharCode.apply(null, Array.from(randomBytes$1))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
30
29
|
};
|
|
31
30
|
/**
|
|
32
31
|
* Generates a cryptographically secure string of random digits without modulo bias.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { joinKey, splitKey } from "./storage.
|
|
1
|
+
import { joinKey, splitKey } from "./storage.mjs";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { writeFile } from "node:fs/promises";
|
|
4
4
|
|
|
@@ -79,8 +79,7 @@ const MemoryStorage = (options) => {
|
|
|
79
79
|
};
|
|
80
80
|
return {
|
|
81
81
|
async get(key) {
|
|
82
|
-
const
|
|
83
|
-
const match = search(searchKey);
|
|
82
|
+
const match = search(joinKey(key));
|
|
84
83
|
if (!match.found) return;
|
|
85
84
|
const storeEntry = store[match.index];
|
|
86
85
|
if (!storeEntry) return;
|
|
@@ -104,8 +103,7 @@ const MemoryStorage = (options) => {
|
|
|
104
103
|
await save();
|
|
105
104
|
},
|
|
106
105
|
async remove(key) {
|
|
107
|
-
const
|
|
108
|
-
const match = search(searchKey);
|
|
106
|
+
const match = search(joinKey(key));
|
|
109
107
|
if (match.found) {
|
|
110
108
|
store.splice(match.index, 1);
|
|
111
109
|
await save();
|
|
@@ -42,50 +42,64 @@ interface StorageAdapter {
|
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* Joins an array of key segments into a single string using the separator.
|
|
45
|
+
* Segments are properly escaped to handle any input, including separators and escape characters.
|
|
45
46
|
*
|
|
46
47
|
* @param key - Array of key segments to join
|
|
47
48
|
* @returns Single string representing the full key path
|
|
48
49
|
*
|
|
49
50
|
* @example
|
|
50
51
|
* ```ts
|
|
51
|
-
* joinKey(['user', '
|
|
52
|
-
* // Returns: "user\
|
|
52
|
+
* joinKey(['user', 'data\x1fwith\x1fseparators'])
|
|
53
|
+
* // Returns: "user\x1fdata\\x1fwith\\x1fseparators"
|
|
53
54
|
* ```
|
|
54
55
|
*/
|
|
55
56
|
declare const joinKey: (key: string[]) => string;
|
|
56
57
|
/**
|
|
57
58
|
* Splits a joined key string back into its component segments.
|
|
59
|
+
* Handles escaped characters properly.
|
|
58
60
|
*
|
|
59
61
|
* @param key - Joined key string to split
|
|
60
62
|
* @returns Array of individual key segments
|
|
61
63
|
*
|
|
62
64
|
* @example
|
|
63
65
|
* ```ts
|
|
64
|
-
* splitKey("user\
|
|
65
|
-
* // Returns: ['user', '
|
|
66
|
+
* splitKey("user\x1fdata\\x1fwith\\x1fseparators")
|
|
67
|
+
* // Returns: ['user', 'data\x1fwith\x1fseparators']
|
|
66
68
|
* ```
|
|
67
69
|
*/
|
|
68
70
|
declare const splitKey: (key: string) => string[];
|
|
69
71
|
/**
|
|
70
72
|
* High-level storage operations with key encoding and type safety.
|
|
71
73
|
* Provides a convenient interface over storage adapters with additional features
|
|
72
|
-
* like TTL
|
|
74
|
+
* like TTL validation and secure key encoding to prevent collisions.
|
|
73
75
|
*/
|
|
74
76
|
declare const Storage: {
|
|
75
77
|
/**
|
|
76
|
-
* Encodes key segments by
|
|
77
|
-
* Ensures storage keys don't contain characters that could
|
|
78
|
+
* Encodes key segments by escaping special characters.
|
|
79
|
+
* Ensures storage keys don't contain unescaped separator characters that could cause collisions.
|
|
78
80
|
*
|
|
79
81
|
* @param key - Array of key segments to encode
|
|
80
|
-
* @returns Array of
|
|
82
|
+
* @returns Array of properly escaped key segments
|
|
83
|
+
*
|
|
84
|
+
* @throws {Error} If any segment is empty or whitespace-only
|
|
81
85
|
*
|
|
82
86
|
* @example
|
|
83
87
|
* ```ts
|
|
84
88
|
* Storage.encode(['user', 'data\x1fwith\x1fseparators'])
|
|
85
|
-
* // Returns: ['user', '
|
|
89
|
+
* // Returns: ['user', 'data\\x1fwith\\x1fseparators']
|
|
86
90
|
* ```
|
|
87
91
|
*/
|
|
88
92
|
readonly encode: (key: string[]) => string[];
|
|
93
|
+
/**
|
|
94
|
+
* Decodes key segments by unescaping special characters.
|
|
95
|
+
* Reverse operation of encode().
|
|
96
|
+
*
|
|
97
|
+
* @param key - Array of encoded key segments
|
|
98
|
+
* @returns Array of decoded key segments
|
|
99
|
+
*
|
|
100
|
+
* @internal
|
|
101
|
+
*/
|
|
102
|
+
readonly decode: (key: string[]) => string[];
|
|
89
103
|
/**
|
|
90
104
|
* Retrieves a typed value from storage.
|
|
91
105
|
*
|
|
@@ -110,6 +124,7 @@ declare const Storage: {
|
|
|
110
124
|
readonly get: <T = Record<string, unknown>>(adapter: StorageAdapter, key: string[]) => Promise<T | null>;
|
|
111
125
|
/**
|
|
112
126
|
* Stores a value with optional time-to-live in seconds.
|
|
127
|
+
* Validates that TTL is a positive integer to prevent edge cases like negative or overflow values.
|
|
113
128
|
*
|
|
114
129
|
* @param adapter - Storage adapter to use
|
|
115
130
|
* @param key - Array of key segments identifying where to store
|
|
@@ -117,12 +132,14 @@ declare const Storage: {
|
|
|
117
132
|
* @param ttlSeconds - Optional TTL in seconds for automatic expiration
|
|
118
133
|
* @returns Promise that resolves when storage is complete
|
|
119
134
|
*
|
|
135
|
+
* @throws {RangeError} If TTL is invalid (negative, non-integer, or exceeds maximum)
|
|
136
|
+
*
|
|
120
137
|
* @example
|
|
121
138
|
* ```ts
|
|
122
139
|
* // Store with 1 hour TTL
|
|
123
140
|
* await Storage.set(adapter, ['sessions', sessionId], sessionData, 3600)
|
|
124
141
|
*
|
|
125
|
-
* // Store permanently
|
|
142
|
+
* // Store permanently (no expiration)
|
|
126
143
|
* await Storage.set(adapter, ['users', userId], userData)
|
|
127
144
|
* ```
|
|
128
145
|
*/
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//#region src/storage/storage.ts
|
|
2
|
+
/**
|
|
3
|
+
* ASCII unit separator character used to join key segments.
|
|
4
|
+
* Using a control character ensures it won't conflict with user data.
|
|
5
|
+
*/
|
|
6
|
+
const SEPARATOR = String.fromCharCode(31);
|
|
7
|
+
/**
|
|
8
|
+
* Escape character used to escape SEPARATOR characters in key segments.
|
|
9
|
+
* Uses backslash as the escape character, which is then itself escaped when appearing.
|
|
10
|
+
*/
|
|
11
|
+
const ESCAPE = "\\";
|
|
12
|
+
/**
|
|
13
|
+
* Joins an array of key segments into a single string using the separator.
|
|
14
|
+
* Segments are properly escaped to handle any input, including separators and escape characters.
|
|
15
|
+
*
|
|
16
|
+
* @param key - Array of key segments to join
|
|
17
|
+
* @returns Single string representing the full key path
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* joinKey(['user', 'data\x1fwith\x1fseparators'])
|
|
22
|
+
* // Returns: "user\x1fdata\\x1fwith\\x1fseparators"
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
const joinKey = (key) => {
|
|
26
|
+
return key.join(SEPARATOR);
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Splits a joined key string back into its component segments.
|
|
30
|
+
* Handles escaped characters properly.
|
|
31
|
+
*
|
|
32
|
+
* @param key - Joined key string to split
|
|
33
|
+
* @returns Array of individual key segments
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* splitKey("user\x1fdata\\x1fwith\\x1fseparators")
|
|
38
|
+
* // Returns: ['user', 'data\x1fwith\x1fseparators']
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
const splitKey = (key) => {
|
|
42
|
+
return key.split(SEPARATOR);
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Encodes a single key segment by escaping special characters.
|
|
46
|
+
* Prevents collisions by properly escaping separator and escape characters.
|
|
47
|
+
*
|
|
48
|
+
* @param segment - The key segment to encode
|
|
49
|
+
* @returns Encoded segment with special characters escaped
|
|
50
|
+
* @throws {Error} If segment is empty or whitespace-only
|
|
51
|
+
*
|
|
52
|
+
* @internal
|
|
53
|
+
*/
|
|
54
|
+
const encodeSegment = (segment) => {
|
|
55
|
+
if (!segment || !segment.trim()) throw new Error(`Storage key segment cannot be empty or whitespace-only: "${segment}"`);
|
|
56
|
+
return segment.replaceAll(ESCAPE, ESCAPE + ESCAPE).replaceAll(SEPARATOR, ESCAPE + SEPARATOR);
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Decodes a key segment by unescaping special characters.
|
|
60
|
+
* Reverse of encodeSegment operation.
|
|
61
|
+
*
|
|
62
|
+
* @param segment - The encoded segment to decode
|
|
63
|
+
* @returns Decoded segment with special characters restored
|
|
64
|
+
*
|
|
65
|
+
* @internal
|
|
66
|
+
*/
|
|
67
|
+
const decodeSegment = (segment) => {
|
|
68
|
+
return segment.replaceAll(ESCAPE + SEPARATOR, SEPARATOR).replaceAll(ESCAPE + ESCAPE, ESCAPE);
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* High-level storage operations with key encoding and type safety.
|
|
72
|
+
* Provides a convenient interface over storage adapters with additional features
|
|
73
|
+
* like TTL validation and secure key encoding to prevent collisions.
|
|
74
|
+
*/
|
|
75
|
+
const Storage = {
|
|
76
|
+
encode: (key) => {
|
|
77
|
+
return key.map(encodeSegment);
|
|
78
|
+
},
|
|
79
|
+
decode: (key) => {
|
|
80
|
+
return key.map(decodeSegment);
|
|
81
|
+
},
|
|
82
|
+
get: (adapter, key) => {
|
|
83
|
+
return adapter.get(Storage.encode(key));
|
|
84
|
+
},
|
|
85
|
+
set: (adapter, key, value, ttlSeconds) => {
|
|
86
|
+
if (ttlSeconds !== void 0 && ttlSeconds !== null) {
|
|
87
|
+
if (!Number.isInteger(ttlSeconds)) throw new RangeError(`Storage TTL must be an integer in seconds, received ${typeof ttlSeconds}`);
|
|
88
|
+
if (ttlSeconds <= 0) throw new RangeError(`Storage TTL must be positive, received ${ttlSeconds}`);
|
|
89
|
+
const maxTtlSeconds = 3600 * 24 * 365 * 10;
|
|
90
|
+
if (ttlSeconds > maxTtlSeconds) throw new RangeError(`Storage TTL exceeds maximum (${maxTtlSeconds}s = 10 years), received ${ttlSeconds}s`);
|
|
91
|
+
}
|
|
92
|
+
const expiry = ttlSeconds ? new Date(Date.now() + ttlSeconds * 1e3) : void 0;
|
|
93
|
+
return adapter.set(Storage.encode(key), value, expiry);
|
|
94
|
+
},
|
|
95
|
+
remove: (adapter, key) => {
|
|
96
|
+
return adapter.remove(Storage.encode(key));
|
|
97
|
+
},
|
|
98
|
+
scan: (adapter, prefix) => {
|
|
99
|
+
return adapter.scan(Storage.encode(prefix));
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
export { Storage, joinKey, splitKey };
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { joinKey, splitKey } from "./storage.
|
|
1
|
+
import { joinKey, splitKey } from "./storage.mjs";
|
|
2
2
|
import { createStorage } from "unstorage";
|
|
3
3
|
|
|
4
4
|
//#region src/storage/unstorage.ts
|
|
5
5
|
/**
|
|
6
|
+
* Universal storage adapter for Draft Auth using Unstorage drivers.
|
|
7
|
+
* Provides seamless integration with any Unstorage-compatible backend including
|
|
8
|
+
* Redis, Cloudflare KV, Vercel KV, and more.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
6
13
|
* Creates a Draft Auth storage adapter using Unstorage drivers.
|
|
7
14
|
* Supports automatic expiration, error handling, and any Unstorage driver.
|
|
8
15
|
*
|
|
@@ -35,15 +42,15 @@ const UnStorage = ({ driver } = {}) => {
|
|
|
35
42
|
try {
|
|
36
43
|
const keyPath = joinKey(key);
|
|
37
44
|
const entry = await store.getItem(keyPath);
|
|
38
|
-
if (!entry) return
|
|
45
|
+
if (!entry) return;
|
|
39
46
|
if (entry.expiry && Date.now() >= entry.expiry) {
|
|
40
47
|
store.removeItem(keyPath).catch(() => {});
|
|
41
|
-
return
|
|
48
|
+
return;
|
|
42
49
|
}
|
|
43
50
|
return entry.value;
|
|
44
51
|
} catch (error) {
|
|
45
52
|
console.error("UnStorage get error:", error);
|
|
46
|
-
return
|
|
53
|
+
return;
|
|
47
54
|
}
|
|
48
55
|
},
|
|
49
56
|
async set(key, value, expiry) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Layout, renderToHTML } from "./base.
|
|
2
|
-
import { FormAlert } from "./form.
|
|
1
|
+
import { Layout, renderToHTML } from "./base.mjs";
|
|
2
|
+
import { FormAlert } from "./form.mjs";
|
|
3
3
|
import { jsx, jsxs } from "preact/jsx-runtime";
|
|
4
4
|
|
|
5
5
|
//#region src/ui/code.tsx
|
|
@@ -34,9 +34,8 @@ const getErrorMessage = (error, copy) => {
|
|
|
34
34
|
const getSuccessMessage = (state, copy) => {
|
|
35
35
|
if (state.type === "start" || !state.claims) return void 0;
|
|
36
36
|
const contact = state.claims.email || state.claims.phone || "";
|
|
37
|
-
const prefix = state.resend ? copy.code_resent : copy.code_sent;
|
|
38
37
|
return {
|
|
39
|
-
message: `${
|
|
38
|
+
message: `${state.resend ? copy.code_resent : copy.code_sent}${contact}`,
|
|
40
39
|
contact
|
|
41
40
|
};
|
|
42
41
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Layout, renderToHTML } from "./base.
|
|
2
|
-
import { FormAlert } from "./form.
|
|
1
|
+
import { Layout, renderToHTML } from "./base.mjs";
|
|
2
|
+
import { FormAlert } from "./form.mjs";
|
|
3
3
|
import { jsx, jsxs } from "preact/jsx-runtime";
|
|
4
4
|
|
|
5
5
|
//#region src/ui/magiclink.tsx
|
|
@@ -32,9 +32,8 @@ const getErrorMessage = (error, copy) => {
|
|
|
32
32
|
const getSuccessMessage = (state, copy, mode) => {
|
|
33
33
|
if (state.type === "start" || !state.claims) return void 0;
|
|
34
34
|
const contact = state.claims[mode] || "";
|
|
35
|
-
const prefix = state.resend ? copy.link_resent : copy.link_sent;
|
|
36
35
|
return {
|
|
37
|
-
message: `${
|
|
36
|
+
message: `${state.resend ? copy.link_resent : copy.link_sent}${contact}`,
|
|
38
37
|
contact
|
|
39
38
|
};
|
|
40
39
|
};
|