@ursalock/server 0.2.0 → 0.3.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/index.js +389 -107
- package/package.json +3 -1
- package/dist/chunk-BYTMLM3Q.js +0 -1048
- package/dist/server.js +0 -45
package/dist/index.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
import { Hono as Hono5 } from "hono";
|
|
3
3
|
import { cors } from "hono/cors";
|
|
4
4
|
import { logger } from "hono/logger";
|
|
5
|
+
import { bodyLimit } from "hono/body-limit";
|
|
6
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
5
7
|
import { ZodError } from "zod";
|
|
6
8
|
|
|
7
9
|
// src/api/auth/router.ts
|
|
8
|
-
import { createHash as createHash2, randomBytes } from "crypto";
|
|
9
10
|
import { Hono as Hono3 } from "hono";
|
|
10
11
|
import { zValidator as zValidator3 } from "@hono/zod-validator";
|
|
12
|
+
import bcrypt from "bcryptjs";
|
|
11
13
|
|
|
12
14
|
// src/db/client.ts
|
|
13
15
|
import Database from "better-sqlite3";
|
|
@@ -20,7 +22,7 @@ var envSchema = {
|
|
|
20
22
|
PORT: numeric.default("3456"),
|
|
21
23
|
/** SQLite database file path */
|
|
22
24
|
DATABASE_PATH: z.string().default("./data/vault.db"),
|
|
23
|
-
/** JWT secret for signing tokens (32+
|
|
25
|
+
/** JWT secret for signing tokens (32+ chars required). Comma-separated for rotation. */
|
|
24
26
|
JWT_SECRET: z.string().min(32),
|
|
25
27
|
/** JWT token expiry in seconds (default: 7 days) */
|
|
26
28
|
JWT_EXPIRY: numeric.default("604800"),
|
|
@@ -31,11 +33,41 @@ var envSchema = {
|
|
|
31
33
|
* e.g., "https://app1.example.com,https://app2.example.com"
|
|
32
34
|
* The RP_ID is derived from the hostname of the validated origin
|
|
33
35
|
*/
|
|
34
|
-
RP_ORIGINS: z.string().default("http://localhost:5173")
|
|
36
|
+
RP_ORIGINS: z.string().default("http://localhost:5173").refine(
|
|
37
|
+
(val) => {
|
|
38
|
+
const origins = val.split(",").map((s) => s.trim()).filter(Boolean);
|
|
39
|
+
return origins.every((o) => {
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(o);
|
|
42
|
+
return url.protocol === "https:" || url.protocol === "http:";
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
{ message: "RP_ORIGINS must be valid URLs (http or https)" }
|
|
49
|
+
).refine(
|
|
50
|
+
(val) => {
|
|
51
|
+
if (process.env["NODE_ENV"] !== "production") return true;
|
|
52
|
+
const origins = val.split(",").map((s) => s.trim()).filter(Boolean);
|
|
53
|
+
return origins.every((o) => new URL(o).protocol === "https:");
|
|
54
|
+
},
|
|
55
|
+
{ message: "RP_ORIGINS must use HTTPS in production" }
|
|
56
|
+
)
|
|
35
57
|
};
|
|
36
|
-
var
|
|
58
|
+
var TEST_JWT_SECRET = "ursalock-test-secret-DO-NOT-USE-IN-PRODUCTION-x9k2m";
|
|
59
|
+
var isTestEnv = process.env["NODE_ENV"] === "test";
|
|
37
60
|
var env = (() => {
|
|
38
|
-
if (
|
|
61
|
+
if (isTestEnv && !process.env["JWT_SECRET"]) {
|
|
62
|
+
process.env["JWT_SECRET"] = TEST_JWT_SECRET;
|
|
63
|
+
}
|
|
64
|
+
const jwtSecret = process.env["JWT_SECRET"];
|
|
65
|
+
if (!jwtSecret || jwtSecret.length < 32) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`JWT_SECRET must be at least 32 characters (got ${jwtSecret?.length ?? 0}). Set a strong secret in your environment.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (isTestEnv) {
|
|
39
71
|
return Object.fromEntries(
|
|
40
72
|
Object.entries(envSchema).map(([key, schema]) => {
|
|
41
73
|
const result = schema.safeParse(process.env[key]);
|
|
@@ -51,6 +83,9 @@ ${errors2}`);
|
|
|
51
83
|
}
|
|
52
84
|
return parsed.data;
|
|
53
85
|
})();
|
|
86
|
+
function getAllowedOrigins() {
|
|
87
|
+
return env.RP_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean);
|
|
88
|
+
}
|
|
54
89
|
|
|
55
90
|
// src/db/schema.ts
|
|
56
91
|
var CREATE_TABLES_SQL = `
|
|
@@ -123,6 +158,7 @@ function getDb() {
|
|
|
123
158
|
_db = new Database(env.DATABASE_PATH);
|
|
124
159
|
_db.pragma("journal_mode = WAL");
|
|
125
160
|
_db.pragma("foreign_keys = ON");
|
|
161
|
+
_db.pragma("secure_delete = ON");
|
|
126
162
|
_db.exec(CREATE_TABLES_SQL);
|
|
127
163
|
runMigrations(_db);
|
|
128
164
|
}
|
|
@@ -148,14 +184,31 @@ function closeDb() {
|
|
|
148
184
|
_db = null;
|
|
149
185
|
}
|
|
150
186
|
}
|
|
187
|
+
var USER_COLUMNS = `id, uid, email, password_hash as passwordHash,
|
|
188
|
+
opaque_id as opaqueId, display_name as displayName,
|
|
189
|
+
created_at as createdAt, updated_at as updatedAt`;
|
|
190
|
+
var USER_JOIN_COLUMNS = `u.id as "user.id", u.uid as "user.uid", u.email as "user.email",
|
|
191
|
+
u.password_hash as "user.passwordHash", u.opaque_id as "user.opaqueId",
|
|
192
|
+
u.display_name as "user.displayName", u.created_at as "user.createdAt",
|
|
193
|
+
u.updated_at as "user.updatedAt"`;
|
|
194
|
+
function userFromRow(row) {
|
|
195
|
+
return {
|
|
196
|
+
id: row["user.id"],
|
|
197
|
+
uid: row["user.uid"],
|
|
198
|
+
email: row["user.email"] ?? null,
|
|
199
|
+
passwordHash: row["user.passwordHash"] ?? null,
|
|
200
|
+
opaqueId: row["user.opaqueId"] ?? null,
|
|
201
|
+
displayName: row["user.displayName"] ?? null,
|
|
202
|
+
createdAt: row["user.createdAt"],
|
|
203
|
+
updatedAt: row["user.updatedAt"]
|
|
204
|
+
};
|
|
205
|
+
}
|
|
151
206
|
function createUser(input) {
|
|
152
207
|
const db = getDb();
|
|
153
208
|
const stmt = db.prepare(`
|
|
154
209
|
INSERT INTO users (email, password_hash, opaque_id, display_name)
|
|
155
210
|
VALUES (?, ?, ?, ?)
|
|
156
|
-
RETURNING
|
|
157
|
-
opaque_id as opaqueId, display_name as displayName,
|
|
158
|
-
created_at as createdAt, updated_at as updatedAt
|
|
211
|
+
RETURNING ${USER_COLUMNS}
|
|
159
212
|
`);
|
|
160
213
|
return stmt.get(
|
|
161
214
|
input.email ?? null,
|
|
@@ -166,10 +219,9 @@ function createUser(input) {
|
|
|
166
219
|
}
|
|
167
220
|
function getUserByEmail(email) {
|
|
168
221
|
const db = getDb();
|
|
222
|
+
email = email.toLowerCase().trim();
|
|
169
223
|
const stmt = db.prepare(`
|
|
170
|
-
SELECT
|
|
171
|
-
opaque_id as opaqueId, display_name as displayName,
|
|
172
|
-
created_at as createdAt, updated_at as updatedAt
|
|
224
|
+
SELECT ${USER_COLUMNS}
|
|
173
225
|
FROM users WHERE email = ?
|
|
174
226
|
`);
|
|
175
227
|
return stmt.get(email);
|
|
@@ -177,9 +229,7 @@ function getUserByEmail(email) {
|
|
|
177
229
|
function getUserByOpaqueId(opaqueId) {
|
|
178
230
|
const db = getDb();
|
|
179
231
|
const stmt = db.prepare(`
|
|
180
|
-
SELECT
|
|
181
|
-
opaque_id as opaqueId, display_name as displayName,
|
|
182
|
-
created_at as createdAt, updated_at as updatedAt
|
|
232
|
+
SELECT ${USER_COLUMNS}
|
|
183
233
|
FROM users WHERE opaque_id = ?
|
|
184
234
|
`);
|
|
185
235
|
return stmt.get(opaqueId);
|
|
@@ -209,7 +259,7 @@ function getPasskeyByCredentialId(credentialId) {
|
|
|
209
259
|
SELECT
|
|
210
260
|
p.id, p.user_id as userId, p.credential_id as credentialId, p.public_key as publicKey,
|
|
211
261
|
p.counter, p.device_type as deviceType, p.backed_up as backedUp, p.transports, p.created_at as createdAt,
|
|
212
|
-
|
|
262
|
+
${USER_JOIN_COLUMNS}
|
|
213
263
|
FROM passkeys p
|
|
214
264
|
JOIN users u ON p.user_id = u.id
|
|
215
265
|
WHERE p.credential_id = ?
|
|
@@ -226,16 +276,7 @@ function getPasskeyByCredentialId(credentialId) {
|
|
|
226
276
|
backedUp: Boolean(row["backedUp"]),
|
|
227
277
|
transports: row["transports"],
|
|
228
278
|
createdAt: row["createdAt"],
|
|
229
|
-
user:
|
|
230
|
-
id: row["user.id"],
|
|
231
|
-
uid: row["user.uid"],
|
|
232
|
-
email: row["user.email"],
|
|
233
|
-
passwordHash: null,
|
|
234
|
-
opaqueId: null,
|
|
235
|
-
displayName: null,
|
|
236
|
-
createdAt: 0,
|
|
237
|
-
updatedAt: 0
|
|
238
|
-
}
|
|
279
|
+
user: userFromRow(row)
|
|
239
280
|
};
|
|
240
281
|
}
|
|
241
282
|
function getPasskeysByUserId(userId) {
|
|
@@ -252,8 +293,24 @@ function updatePasskeyCounter(credentialId, counter) {
|
|
|
252
293
|
const stmt = db.prepare(`UPDATE passkeys SET counter = ? WHERE credential_id = ?`);
|
|
253
294
|
stmt.run(counter, credentialId);
|
|
254
295
|
}
|
|
296
|
+
var MAX_SESSIONS = 10;
|
|
255
297
|
function createSession(input) {
|
|
256
298
|
const db = getDb();
|
|
299
|
+
const countStmt = db.prepare(
|
|
300
|
+
`SELECT COUNT(*) as cnt FROM sessions WHERE user_id = ? AND expires_at > unixepoch()`
|
|
301
|
+
);
|
|
302
|
+
const { cnt } = countStmt.get(input.userId);
|
|
303
|
+
if (cnt >= MAX_SESSIONS) {
|
|
304
|
+
const deleteOldest = db.prepare(`
|
|
305
|
+
DELETE FROM sessions WHERE id IN (
|
|
306
|
+
SELECT id FROM sessions
|
|
307
|
+
WHERE user_id = ? AND expires_at > unixepoch()
|
|
308
|
+
ORDER BY created_at ASC
|
|
309
|
+
LIMIT ?
|
|
310
|
+
)
|
|
311
|
+
`);
|
|
312
|
+
deleteOldest.run(input.userId, cnt - MAX_SESSIONS + 1);
|
|
313
|
+
}
|
|
257
314
|
const stmt = db.prepare(`
|
|
258
315
|
INSERT INTO sessions (user_id, token_hash, expires_at)
|
|
259
316
|
VALUES (?, ?, ?)
|
|
@@ -266,7 +323,7 @@ function getSessionByTokenHash(tokenHash) {
|
|
|
266
323
|
const stmt = db.prepare(`
|
|
267
324
|
SELECT
|
|
268
325
|
s.id, s.user_id as userId, s.token_hash as tokenHash, s.expires_at as expiresAt, s.created_at as createdAt,
|
|
269
|
-
|
|
326
|
+
${USER_JOIN_COLUMNS}
|
|
270
327
|
FROM sessions s
|
|
271
328
|
JOIN users u ON s.user_id = u.id
|
|
272
329
|
WHERE s.token_hash = ? AND s.expires_at > unixepoch()
|
|
@@ -279,16 +336,7 @@ function getSessionByTokenHash(tokenHash) {
|
|
|
279
336
|
tokenHash: row["tokenHash"],
|
|
280
337
|
expiresAt: row["expiresAt"],
|
|
281
338
|
createdAt: row["createdAt"],
|
|
282
|
-
user:
|
|
283
|
-
id: row["user.id"],
|
|
284
|
-
uid: row["user.uid"],
|
|
285
|
-
email: row["user.email"],
|
|
286
|
-
passwordHash: null,
|
|
287
|
-
opaqueId: null,
|
|
288
|
-
displayName: null,
|
|
289
|
-
createdAt: 0,
|
|
290
|
-
updatedAt: 0
|
|
291
|
-
}
|
|
339
|
+
user: userFromRow(row)
|
|
292
340
|
};
|
|
293
341
|
}
|
|
294
342
|
function deleteSession(tokenHash) {
|
|
@@ -332,12 +380,20 @@ function getVaultsByUserId(userId) {
|
|
|
332
380
|
}
|
|
333
381
|
function updateVault(uid, userId, input) {
|
|
334
382
|
const db = getDb();
|
|
383
|
+
if (input.version != null) {
|
|
384
|
+
const stmt2 = db.prepare(`
|
|
385
|
+
UPDATE vaults SET data = ?, salt = ?, version = ? + 1, updated_at = unixepoch()
|
|
386
|
+
WHERE uid = ? AND user_id = ? AND version = ?
|
|
387
|
+
RETURNING id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
|
|
388
|
+
`);
|
|
389
|
+
return stmt2.get(input.data, input.salt, input.version, uid, userId, input.version);
|
|
390
|
+
}
|
|
335
391
|
const stmt = db.prepare(`
|
|
336
|
-
UPDATE vaults SET data = ?, salt = ?, version =
|
|
392
|
+
UPDATE vaults SET data = ?, salt = ?, version = version + 1, updated_at = unixepoch()
|
|
337
393
|
WHERE uid = ? AND user_id = ?
|
|
338
394
|
RETURNING id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
|
|
339
395
|
`);
|
|
340
|
-
return stmt.get(input.data, input.salt,
|
|
396
|
+
return stmt.get(input.data, input.salt, uid, userId);
|
|
341
397
|
}
|
|
342
398
|
function deleteVault(uid, userId) {
|
|
343
399
|
const db = getDb();
|
|
@@ -348,24 +404,43 @@ function deleteVault(uid, userId) {
|
|
|
348
404
|
// src/features/auth/jwt.ts
|
|
349
405
|
import { createHash } from "crypto";
|
|
350
406
|
import { SignJWT, jwtVerify } from "jose";
|
|
351
|
-
function
|
|
352
|
-
|
|
407
|
+
function getSecretKeys() {
|
|
408
|
+
if (!env.JWT_SECRET) {
|
|
409
|
+
throw new Error("JWT_SECRET is not set. Refusing to sign/verify tokens with an empty secret.");
|
|
410
|
+
}
|
|
411
|
+
const secrets = env.JWT_SECRET.split(",").map((s) => s.trim()).filter(Boolean);
|
|
412
|
+
if (secrets.length === 0) {
|
|
413
|
+
throw new Error("JWT_SECRET is empty after parsing. Refusing to sign/verify tokens.");
|
|
414
|
+
}
|
|
415
|
+
return secrets.map((s) => new TextEncoder().encode(s));
|
|
416
|
+
}
|
|
417
|
+
function getCurrentSecretKey() {
|
|
418
|
+
return getSecretKeys()[0];
|
|
353
419
|
}
|
|
354
420
|
async function createToken(payload) {
|
|
355
421
|
const jti = crypto.randomUUID();
|
|
356
422
|
const jwt = new SignJWT({ email: payload.email }).setSubject(payload.userId).setJti(jti).setIssuedAt().setExpirationTime(`${env.JWT_EXPIRY}s`).setProtectedHeader({ alg: "HS256" });
|
|
357
|
-
return jwt.sign(
|
|
423
|
+
return jwt.sign(getCurrentSecretKey());
|
|
358
424
|
}
|
|
359
425
|
async function verifyToken(token) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
426
|
+
const keys = getSecretKeys();
|
|
427
|
+
for (let i = 0; i < keys.length; i++) {
|
|
428
|
+
try {
|
|
429
|
+
const { payload } = await jwtVerify(token, keys[i]);
|
|
430
|
+
if (!payload.sub) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
if (i > 0) {
|
|
434
|
+
console.warn(
|
|
435
|
+
`[auth/jwt] Token for sub=${payload.sub} validated with rotated secret (index=${i}). The token was signed with a previous JWT_SECRET. It will stop working once that secret is removed.`
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return payload;
|
|
439
|
+
} catch {
|
|
440
|
+
continue;
|
|
364
441
|
}
|
|
365
|
-
return payload;
|
|
366
|
-
} catch {
|
|
367
|
-
return null;
|
|
368
442
|
}
|
|
443
|
+
return null;
|
|
369
444
|
}
|
|
370
445
|
function hashToken(token) {
|
|
371
446
|
return createHash("sha256").update(token).digest("hex");
|
|
@@ -381,12 +456,14 @@ var ErrorCode = z2.enum([
|
|
|
381
456
|
"unauthorized",
|
|
382
457
|
"invalid_credentials",
|
|
383
458
|
"email_already_exists",
|
|
459
|
+
"opaque_id_exists",
|
|
384
460
|
"passkey_not_found",
|
|
385
461
|
"session_expired",
|
|
386
462
|
"invalid_origin",
|
|
387
463
|
// Vault errors
|
|
388
464
|
"vault_not_found",
|
|
389
465
|
"vault_already_exists",
|
|
466
|
+
"vault_conflict",
|
|
390
467
|
"invalid_vault_data",
|
|
391
468
|
// Validation errors
|
|
392
469
|
"validation_error",
|
|
@@ -407,6 +484,7 @@ var errors = {
|
|
|
407
484
|
unauthorized: { code: "unauthorized", message: "Unauthorized" },
|
|
408
485
|
invalid_credentials: { code: "invalid_credentials", message: "Invalid email or password" },
|
|
409
486
|
email_already_exists: { code: "email_already_exists", message: "Email already registered" },
|
|
487
|
+
opaque_id_exists: { code: "opaque_id_exists", message: "Opaque ID already registered" },
|
|
410
488
|
passkey_not_found: { code: "passkey_not_found", message: "Passkey not found" },
|
|
411
489
|
session_expired: { code: "session_expired", message: "Session expired" },
|
|
412
490
|
invalid_origin: { code: "invalid_origin", message: "Origin not allowed" },
|
|
@@ -416,6 +494,7 @@ var errors = {
|
|
|
416
494
|
code: "vault_already_exists",
|
|
417
495
|
message: `Vault "${name}" already exists`
|
|
418
496
|
}),
|
|
497
|
+
vault_conflict: { code: "vault_conflict", message: "Version conflict - vault has been modified. Please refresh and retry." },
|
|
419
498
|
invalid_vault_data: { code: "invalid_vault_data", message: "Invalid vault data" },
|
|
420
499
|
// Validation errors
|
|
421
500
|
validation_error: (details) => ({
|
|
@@ -529,19 +608,18 @@ var PasskeyLoginOptionsRequest = z3.object({
|
|
|
529
608
|
var PasskeyLoginVerifyRequest = z3.object({
|
|
530
609
|
credential: z3.record(z3.unknown())
|
|
531
610
|
});
|
|
532
|
-
var
|
|
533
|
-
|
|
534
|
-
|
|
611
|
+
var MAX_DATA_SIZE = 5 * 1024 * 1024;
|
|
612
|
+
var MAX_SALT_LENGTH = 64;
|
|
613
|
+
var BASE64_RE = /^[A-Za-z0-9+/\-_=]*$/;
|
|
614
|
+
var VAULT_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
535
615
|
var CreateVaultRequest = z3.object({
|
|
536
|
-
name: z3.string().min(1).max(255),
|
|
537
|
-
data: z3.string(),
|
|
538
|
-
|
|
539
|
-
salt: z3.string()
|
|
540
|
-
// Salt (base64)
|
|
616
|
+
name: z3.string().min(1).max(255).regex(VAULT_NAME_RE, "Name must be alphanumeric (hyphens and underscores allowed)"),
|
|
617
|
+
data: z3.string().max(MAX_DATA_SIZE, `Data must not exceed ${MAX_DATA_SIZE} bytes`).regex(BASE64_RE, "Data must be valid base64"),
|
|
618
|
+
salt: z3.string().max(MAX_SALT_LENGTH, `Salt must not exceed ${MAX_SALT_LENGTH} characters`).regex(BASE64_RE, "Salt must be valid base64")
|
|
541
619
|
});
|
|
542
620
|
var UpdateVaultRequest = z3.object({
|
|
543
|
-
data: z3.string(),
|
|
544
|
-
salt: z3.string(),
|
|
621
|
+
data: z3.string().max(MAX_DATA_SIZE, `Data must not exceed ${MAX_DATA_SIZE} bytes`).regex(BASE64_RE, "Data must be valid base64"),
|
|
622
|
+
salt: z3.string().max(MAX_SALT_LENGTH, `Salt must not exceed ${MAX_SALT_LENGTH} characters`).regex(BASE64_RE, "Salt must be valid base64"),
|
|
545
623
|
version: z3.number().optional()
|
|
546
624
|
});
|
|
547
625
|
var VaultResponse = z3.object({
|
|
@@ -569,7 +647,7 @@ import {
|
|
|
569
647
|
|
|
570
648
|
// src/features/auth/origin.ts
|
|
571
649
|
var allowedOrigins = null;
|
|
572
|
-
function
|
|
650
|
+
function getAllowedOrigins2() {
|
|
573
651
|
if (!allowedOrigins) {
|
|
574
652
|
allowedOrigins = env.RP_ORIGINS.split(",").map((o) => o.trim()).filter(Boolean);
|
|
575
653
|
}
|
|
@@ -579,7 +657,7 @@ function validateOrigin(origin) {
|
|
|
579
657
|
if (!origin) {
|
|
580
658
|
throw new ApiException(errors.invalid_origin, 403);
|
|
581
659
|
}
|
|
582
|
-
const allowed =
|
|
660
|
+
const allowed = getAllowedOrigins2();
|
|
583
661
|
if (!allowed.includes(origin)) {
|
|
584
662
|
console.warn(`[auth] Rejected origin: ${origin}. Allowed: ${allowed.join(", ")}`);
|
|
585
663
|
throw new ApiException(errors.invalid_origin, 403);
|
|
@@ -593,15 +671,54 @@ function getRpConfigFromRequest(c) {
|
|
|
593
671
|
return validateOrigin(origin);
|
|
594
672
|
}
|
|
595
673
|
|
|
674
|
+
// src/features/auth/audit-log.ts
|
|
675
|
+
function logAuthEvent(event) {
|
|
676
|
+
console.log(JSON.stringify({ ...event, _tag: "auth_audit" }));
|
|
677
|
+
}
|
|
678
|
+
function extractRequestMeta(c) {
|
|
679
|
+
return {
|
|
680
|
+
ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown",
|
|
681
|
+
userAgent: c.req.header("user-agent") ?? "unknown"
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
596
685
|
// src/api/auth/passkey.ts
|
|
686
|
+
var MAX_CHALLENGES = 1e3;
|
|
597
687
|
var challengeStore = /* @__PURE__ */ new Map();
|
|
598
|
-
|
|
688
|
+
function pruneExpiredChallenges() {
|
|
599
689
|
const now = Date.now();
|
|
600
690
|
for (const [key, value] of challengeStore) {
|
|
601
691
|
if (value.expiresAt < now) {
|
|
602
692
|
challengeStore.delete(key);
|
|
603
693
|
}
|
|
604
694
|
}
|
|
695
|
+
}
|
|
696
|
+
function evictOldestChallenges() {
|
|
697
|
+
if (challengeStore.size <= MAX_CHALLENGES) return;
|
|
698
|
+
const excess = challengeStore.size - MAX_CHALLENGES;
|
|
699
|
+
let deleted = 0;
|
|
700
|
+
for (const key of challengeStore.keys()) {
|
|
701
|
+
if (deleted >= excess) break;
|
|
702
|
+
challengeStore.delete(key);
|
|
703
|
+
deleted++;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function setChallenge(key, entry) {
|
|
707
|
+
pruneExpiredChallenges();
|
|
708
|
+
challengeStore.set(key, entry);
|
|
709
|
+
evictOldestChallenges();
|
|
710
|
+
}
|
|
711
|
+
function getChallenge(key) {
|
|
712
|
+
const entry = challengeStore.get(key);
|
|
713
|
+
if (!entry) return void 0;
|
|
714
|
+
if (entry.expiresAt < Date.now()) {
|
|
715
|
+
challengeStore.delete(key);
|
|
716
|
+
return void 0;
|
|
717
|
+
}
|
|
718
|
+
return entry;
|
|
719
|
+
}
|
|
720
|
+
setInterval(() => {
|
|
721
|
+
pruneExpiredChallenges();
|
|
605
722
|
}, 6e4);
|
|
606
723
|
var PasskeyRegisterOptionsRequest2 = z4.object({
|
|
607
724
|
email: z4.string().email().optional()
|
|
@@ -625,7 +742,8 @@ var passkeyRouter = new Hono().post(
|
|
|
625
742
|
"/register/options",
|
|
626
743
|
zValidator("json", PasskeyRegisterOptionsRequest2),
|
|
627
744
|
async (c) => {
|
|
628
|
-
const { email } = c.req.valid("json");
|
|
745
|
+
const { email: rawEmail } = c.req.valid("json");
|
|
746
|
+
const email = rawEmail ? rawEmail.toLowerCase().trim() : void 0;
|
|
629
747
|
if (email) {
|
|
630
748
|
const existing = getUserByEmail(email);
|
|
631
749
|
if (existing) {
|
|
@@ -647,7 +765,7 @@ var passkeyRouter = new Hono().post(
|
|
|
647
765
|
},
|
|
648
766
|
timeout: 6e4
|
|
649
767
|
});
|
|
650
|
-
|
|
768
|
+
setChallenge(options.challenge, {
|
|
651
769
|
challenge: options.challenge,
|
|
652
770
|
email: email ?? void 0,
|
|
653
771
|
expiresAt: Date.now() + 12e4
|
|
@@ -659,13 +777,14 @@ var passkeyRouter = new Hono().post(
|
|
|
659
777
|
"/register/verify",
|
|
660
778
|
zValidator("json", PasskeyRegisterVerifyRequest2),
|
|
661
779
|
async (c) => {
|
|
662
|
-
const { email, credential } = c.req.valid("json");
|
|
780
|
+
const { email: rawEmail, credential } = c.req.valid("json");
|
|
781
|
+
const email = rawEmail ? rawEmail.toLowerCase().trim() : void 0;
|
|
663
782
|
const response = credential;
|
|
664
783
|
const clientDataJSON = JSON.parse(
|
|
665
784
|
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf-8")
|
|
666
785
|
);
|
|
667
786
|
const challenge = clientDataJSON.challenge;
|
|
668
|
-
const stored =
|
|
787
|
+
const stored = getChallenge(challenge);
|
|
669
788
|
if (!stored) {
|
|
670
789
|
throw new ApiException(errors.invalid_credentials, 401);
|
|
671
790
|
}
|
|
@@ -713,7 +832,13 @@ var passkeyRouter = new Hono().post(
|
|
|
713
832
|
});
|
|
714
833
|
} catch (error) {
|
|
715
834
|
if (error instanceof ApiException) throw error;
|
|
716
|
-
|
|
835
|
+
logAuthEvent({
|
|
836
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
837
|
+
level: "warn",
|
|
838
|
+
event: "passkey_register_fail",
|
|
839
|
+
...extractRequestMeta(c),
|
|
840
|
+
details: { reason: String(error) }
|
|
841
|
+
});
|
|
717
842
|
throw new ApiException(errors.invalid_credentials, 401);
|
|
718
843
|
}
|
|
719
844
|
}
|
|
@@ -721,7 +846,8 @@ var passkeyRouter = new Hono().post(
|
|
|
721
846
|
"/login/options",
|
|
722
847
|
zValidator("json", PasskeyLoginOptionsRequest2),
|
|
723
848
|
async (c) => {
|
|
724
|
-
const { email } = c.req.valid("json");
|
|
849
|
+
const { email: rawEmail } = c.req.valid("json");
|
|
850
|
+
const email = rawEmail ? rawEmail.toLowerCase().trim() : void 0;
|
|
725
851
|
let allowCredentials;
|
|
726
852
|
if (email) {
|
|
727
853
|
const user = getUserByEmail(email);
|
|
@@ -741,7 +867,7 @@ var passkeyRouter = new Hono().post(
|
|
|
741
867
|
timeout: 6e4
|
|
742
868
|
});
|
|
743
869
|
const challengeKey = `auth:${options.challenge}`;
|
|
744
|
-
|
|
870
|
+
setChallenge(challengeKey, {
|
|
745
871
|
challenge: options.challenge,
|
|
746
872
|
expiresAt: Date.now() + 12e4
|
|
747
873
|
});
|
|
@@ -757,16 +883,16 @@ var passkeyRouter = new Hono().post(
|
|
|
757
883
|
if (!passkey) {
|
|
758
884
|
throw new ApiException(errors.passkey_not_found, 401);
|
|
759
885
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
if (!storedChallenge) {
|
|
886
|
+
const clientDataJSON = JSON.parse(
|
|
887
|
+
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf-8")
|
|
888
|
+
);
|
|
889
|
+
const challengeFromResponse = clientDataJSON.challenge;
|
|
890
|
+
const challengeKey = `auth:${challengeFromResponse}`;
|
|
891
|
+
const storedChallengeData = getChallenge(challengeKey);
|
|
892
|
+
if (!storedChallengeData) {
|
|
768
893
|
throw new ApiException(errors.invalid_credentials, 401);
|
|
769
894
|
}
|
|
895
|
+
const storedChallenge = storedChallengeData.challenge;
|
|
770
896
|
const { rpId, rpOrigin } = getRpConfigFromRequest(c);
|
|
771
897
|
try {
|
|
772
898
|
const verification = await verifyAuthenticationResponse({
|
|
@@ -784,7 +910,20 @@ var passkeyRouter = new Hono().post(
|
|
|
784
910
|
if (!verification.verified) {
|
|
785
911
|
throw new ApiException(errors.invalid_credentials, 401);
|
|
786
912
|
}
|
|
787
|
-
|
|
913
|
+
const newCounter = verification.authenticationInfo.newCounter;
|
|
914
|
+
if (newCounter > 0 && newCounter <= passkey.counter) {
|
|
915
|
+
console.warn(
|
|
916
|
+
`[SECURITY] Possible cloned authenticator detected! credentialId=${passkey.credentialId}, storedCounter=${passkey.counter}, newCounter=${newCounter}, userId=${passkey.user.uid}`
|
|
917
|
+
);
|
|
918
|
+
throw new ApiException(
|
|
919
|
+
{
|
|
920
|
+
code: "invalid_credentials",
|
|
921
|
+
message: "Authentication rejected: authenticator counter regression detected (possible cloned authenticator)"
|
|
922
|
+
},
|
|
923
|
+
401
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
updatePasskeyCounter(passkey.credentialId, newCounter);
|
|
788
927
|
const token = await createToken({
|
|
789
928
|
userId: passkey.user.uid,
|
|
790
929
|
email: passkey.user.email ?? void 0
|
|
@@ -792,7 +931,7 @@ var passkeyRouter = new Hono().post(
|
|
|
792
931
|
const tokenHash = hashToken(token);
|
|
793
932
|
const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
|
|
794
933
|
createSession({ userId: passkey.user.id, tokenHash, expiresAt });
|
|
795
|
-
challengeStore.delete(
|
|
934
|
+
challengeStore.delete(challengeKey);
|
|
796
935
|
return c.json({
|
|
797
936
|
user: {
|
|
798
937
|
id: passkey.user.uid,
|
|
@@ -803,7 +942,13 @@ var passkeyRouter = new Hono().post(
|
|
|
803
942
|
});
|
|
804
943
|
} catch (error) {
|
|
805
944
|
if (error instanceof ApiException) throw error;
|
|
806
|
-
|
|
945
|
+
logAuthEvent({
|
|
946
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
947
|
+
level: "warn",
|
|
948
|
+
event: "passkey_login_fail",
|
|
949
|
+
...extractRequestMeta(c),
|
|
950
|
+
details: { credentialId: response.id, reason: String(error) }
|
|
951
|
+
});
|
|
807
952
|
throw new ApiException(errors.invalid_credentials, 401);
|
|
808
953
|
}
|
|
809
954
|
}
|
|
@@ -811,7 +956,8 @@ var passkeyRouter = new Hono().post(
|
|
|
811
956
|
"/check",
|
|
812
957
|
zValidator("json", PasskeyCheckRequest),
|
|
813
958
|
(c) => {
|
|
814
|
-
const { email } = c.req.valid("json");
|
|
959
|
+
const { email: rawEmail } = c.req.valid("json");
|
|
960
|
+
const email = rawEmail.toLowerCase().trim();
|
|
815
961
|
const user = getUserByEmail(email);
|
|
816
962
|
if (!user) {
|
|
817
963
|
return c.json({ hasPasskey: false });
|
|
@@ -843,10 +989,18 @@ var zkcRouter = new Hono2().post(
|
|
|
843
989
|
"/register",
|
|
844
990
|
zValidator2("json", ZkcRegisterRequest),
|
|
845
991
|
async (c) => {
|
|
992
|
+
getRpConfigFromRequest(c);
|
|
846
993
|
const { opaqueId, displayName } = c.req.valid("json");
|
|
847
994
|
const existing = getUserByOpaqueId(opaqueId);
|
|
848
995
|
if (existing) {
|
|
849
|
-
|
|
996
|
+
logAuthEvent({
|
|
997
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
998
|
+
level: "warn",
|
|
999
|
+
event: "zkc_register_fail",
|
|
1000
|
+
...extractRequestMeta(c),
|
|
1001
|
+
details: { reason: "opaque_id_exists" }
|
|
1002
|
+
});
|
|
1003
|
+
throw new ApiException(errors.opaque_id_exists, 409);
|
|
850
1004
|
}
|
|
851
1005
|
const user = createUser({
|
|
852
1006
|
opaqueId,
|
|
@@ -868,9 +1022,17 @@ var zkcRouter = new Hono2().post(
|
|
|
868
1022
|
"/authenticate",
|
|
869
1023
|
zValidator2("json", ZkcAuthenticateRequest),
|
|
870
1024
|
async (c) => {
|
|
1025
|
+
getRpConfigFromRequest(c);
|
|
871
1026
|
const { opaqueId } = c.req.valid("json");
|
|
872
1027
|
const user = getUserByOpaqueId(opaqueId);
|
|
873
1028
|
if (!user) {
|
|
1029
|
+
logAuthEvent({
|
|
1030
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1031
|
+
level: "warn",
|
|
1032
|
+
event: "zkc_auth_fail",
|
|
1033
|
+
...extractRequestMeta(c),
|
|
1034
|
+
details: { reason: "opaque_id_not_found" }
|
|
1035
|
+
});
|
|
874
1036
|
throw new ApiException(errors.passkey_not_found, 401);
|
|
875
1037
|
}
|
|
876
1038
|
const token = await createToken({ userId: user.uid });
|
|
@@ -896,24 +1058,17 @@ var zkcRouter = new Hono2().post(
|
|
|
896
1058
|
);
|
|
897
1059
|
|
|
898
1060
|
// src/api/auth/router.ts
|
|
899
|
-
function hashPassword(password, salt) {
|
|
900
|
-
const crypto2 = createHash2("sha256");
|
|
901
|
-
return crypto2.update(password + salt).digest("hex");
|
|
902
|
-
}
|
|
903
|
-
function generateSalt() {
|
|
904
|
-
return randomBytes(16).toString("hex");
|
|
905
|
-
}
|
|
906
1061
|
var authRouter = new Hono3().post(
|
|
907
1062
|
"/email/register",
|
|
908
1063
|
zValidator3("json", EmailRegisterRequest),
|
|
909
1064
|
async (c) => {
|
|
910
|
-
const { email, password } = c.req.valid("json");
|
|
1065
|
+
const { email: rawEmail, password } = c.req.valid("json");
|
|
1066
|
+
const email = rawEmail.toLowerCase().trim();
|
|
911
1067
|
const existing = getUserByEmail(email);
|
|
912
1068
|
if (existing) {
|
|
913
1069
|
throw new ApiException(errors.email_already_exists, 409);
|
|
914
1070
|
}
|
|
915
|
-
const
|
|
916
|
-
const passwordHash = hashPassword(password, salt) + ":" + salt;
|
|
1071
|
+
const passwordHash = await bcrypt.hash(password, 12);
|
|
917
1072
|
const user = createUser({ email, passwordHash });
|
|
918
1073
|
const token = await createToken({ userId: user.uid, email });
|
|
919
1074
|
const tokenHash = hashToken(token);
|
|
@@ -932,17 +1087,14 @@ var authRouter = new Hono3().post(
|
|
|
932
1087
|
"/email/login",
|
|
933
1088
|
zValidator3("json", EmailLoginRequest),
|
|
934
1089
|
async (c) => {
|
|
935
|
-
const { email, password } = c.req.valid("json");
|
|
1090
|
+
const { email: rawEmail, password } = c.req.valid("json");
|
|
1091
|
+
const email = rawEmail.toLowerCase().trim();
|
|
936
1092
|
const user = getUserByEmail(email);
|
|
937
1093
|
if (!user || !user.passwordHash) {
|
|
938
1094
|
throw new ApiException(errors.invalid_credentials, 401);
|
|
939
1095
|
}
|
|
940
|
-
const
|
|
941
|
-
if (!
|
|
942
|
-
throw new ApiException(errors.invalid_credentials, 401);
|
|
943
|
-
}
|
|
944
|
-
const inputHash = hashPassword(password, salt);
|
|
945
|
-
if (inputHash !== storedHash) {
|
|
1096
|
+
const valid = await bcrypt.compare(password, user.passwordHash);
|
|
1097
|
+
if (!valid) {
|
|
946
1098
|
throw new ApiException(errors.invalid_credentials, 401);
|
|
947
1099
|
}
|
|
948
1100
|
const token = await createToken({ userId: user.uid, email: user.email ?? void 0 });
|
|
@@ -967,8 +1119,7 @@ var authRouter = new Hono3().post(
|
|
|
967
1119
|
user: {
|
|
968
1120
|
id: session.user.uid,
|
|
969
1121
|
email: session.user.email,
|
|
970
|
-
createdAt:
|
|
971
|
-
// Not needed for /me
|
|
1122
|
+
createdAt: session.user.createdAt
|
|
972
1123
|
}
|
|
973
1124
|
});
|
|
974
1125
|
}
|
|
@@ -1075,6 +1226,12 @@ var VaultService = class {
|
|
|
1075
1226
|
updateVault(uid, userId, data) {
|
|
1076
1227
|
const vault = this.vaultRepo.update(uid, userId, data);
|
|
1077
1228
|
if (!vault) {
|
|
1229
|
+
if (data.version != null) {
|
|
1230
|
+
const existing = this.vaultRepo.findByUid(uid, userId);
|
|
1231
|
+
if (existing) {
|
|
1232
|
+
throw new ApiException(errors.vault_conflict, 409);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1078
1235
|
throw new ApiException(errors.vault_not_found, 404);
|
|
1079
1236
|
}
|
|
1080
1237
|
return toVaultResponse(vault);
|
|
@@ -1167,6 +1324,109 @@ var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
|
|
|
1167
1324
|
}
|
|
1168
1325
|
);
|
|
1169
1326
|
|
|
1327
|
+
// src/features/auth/rate-limit.ts
|
|
1328
|
+
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
1329
|
+
var DEFAULT_CONFIG = { max: 100, windowMs: 6e4 };
|
|
1330
|
+
var CLEANUP_INTERVAL_MS = 6e4;
|
|
1331
|
+
function createStore(windowMs) {
|
|
1332
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
1333
|
+
const timer = setInterval(() => {
|
|
1334
|
+
const cutoff = Date.now() - windowMs;
|
|
1335
|
+
for (const [ip, entry] of buckets) {
|
|
1336
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
1337
|
+
if (entry.timestamps.length === 0) {
|
|
1338
|
+
buckets.delete(ip);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}, CLEANUP_INTERVAL_MS);
|
|
1342
|
+
if (timer.unref) timer.unref();
|
|
1343
|
+
return {
|
|
1344
|
+
/**
|
|
1345
|
+
* Record a hit and return the current count within the window.
|
|
1346
|
+
*/
|
|
1347
|
+
hit(ip) {
|
|
1348
|
+
const now = Date.now();
|
|
1349
|
+
const cutoff = now - windowMs;
|
|
1350
|
+
let entry = buckets.get(ip);
|
|
1351
|
+
if (!entry) {
|
|
1352
|
+
entry = { timestamps: [] };
|
|
1353
|
+
buckets.set(ip, entry);
|
|
1354
|
+
}
|
|
1355
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
1356
|
+
entry.timestamps.push(now);
|
|
1357
|
+
return entry.timestamps.length;
|
|
1358
|
+
},
|
|
1359
|
+
/**
|
|
1360
|
+
* Return the oldest timestamp still in the window for Retry-After calculation.
|
|
1361
|
+
*/
|
|
1362
|
+
oldestInWindow(ip) {
|
|
1363
|
+
return buckets.get(ip)?.timestamps[0];
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
function rateLimit(config = {}) {
|
|
1368
|
+
const { max, windowMs } = { ...DEFAULT_CONFIG, ...config };
|
|
1369
|
+
const store = createStore(windowMs);
|
|
1370
|
+
return createMiddleware2(async (c, next) => {
|
|
1371
|
+
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
|
|
1372
|
+
const count = store.hit(ip);
|
|
1373
|
+
const remaining = Math.max(0, max - count);
|
|
1374
|
+
c.header("X-RateLimit-Limit", String(max));
|
|
1375
|
+
c.header("X-RateLimit-Remaining", String(remaining));
|
|
1376
|
+
if (count > max) {
|
|
1377
|
+
const oldest = store.oldestInWindow(ip);
|
|
1378
|
+
const retryAfterSec = oldest ? Math.ceil((oldest + windowMs - Date.now()) / 1e3) : Math.ceil(windowMs / 1e3);
|
|
1379
|
+
c.header("Retry-After", String(Math.max(1, retryAfterSec)));
|
|
1380
|
+
throw new ApiException(
|
|
1381
|
+
{ code: "invalid_request", message: "Too many requests, please try again later" },
|
|
1382
|
+
429
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
await next();
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/features/auth/csrf.ts
|
|
1390
|
+
import { createMiddleware as createMiddleware3 } from "hono/factory";
|
|
1391
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
1392
|
+
var CSRF_COOKIE_NAME = "__csrf";
|
|
1393
|
+
var CSRF_HEADER_NAME = "x-csrf-token";
|
|
1394
|
+
var TOKEN_BYTES = 32;
|
|
1395
|
+
var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
1396
|
+
function csrfCookieOptions() {
|
|
1397
|
+
return {
|
|
1398
|
+
path: "/",
|
|
1399
|
+
httpOnly: false,
|
|
1400
|
+
// Client JS must read this value
|
|
1401
|
+
sameSite: "Strict",
|
|
1402
|
+
secure: env.NODE_ENV === "production"
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
function generateToken() {
|
|
1406
|
+
const bytes = new Uint8Array(TOKEN_BYTES);
|
|
1407
|
+
crypto.getRandomValues(bytes);
|
|
1408
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1409
|
+
}
|
|
1410
|
+
var csrfProtection = createMiddleware3(async (c, next) => {
|
|
1411
|
+
if (SAFE_METHODS.has(c.req.method)) {
|
|
1412
|
+
const existing = getCookie(c, CSRF_COOKIE_NAME);
|
|
1413
|
+
if (!existing) {
|
|
1414
|
+
setCookie(c, CSRF_COOKIE_NAME, generateToken(), csrfCookieOptions());
|
|
1415
|
+
}
|
|
1416
|
+
return next();
|
|
1417
|
+
}
|
|
1418
|
+
const cookieToken = getCookie(c, CSRF_COOKIE_NAME);
|
|
1419
|
+
const headerToken = c.req.header(CSRF_HEADER_NAME);
|
|
1420
|
+
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
|
1421
|
+
throw new ApiException(
|
|
1422
|
+
{ code: "invalid_request", message: "Invalid or missing CSRF token" },
|
|
1423
|
+
403
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
setCookie(c, CSRF_COOKIE_NAME, generateToken(), csrfCookieOptions());
|
|
1427
|
+
await next();
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1170
1430
|
// src/app.ts
|
|
1171
1431
|
var errorHandler = (error, c) => {
|
|
1172
1432
|
const requestId = c.req.header("x-request-id") ?? crypto.randomUUID();
|
|
@@ -1198,24 +1458,47 @@ var errorHandler = (error, c) => {
|
|
|
1198
1458
|
};
|
|
1199
1459
|
function createApp() {
|
|
1200
1460
|
const app = new Hono5();
|
|
1201
|
-
app.use("*",
|
|
1461
|
+
app.use("*", bodyLimit({ maxSize: 11 * 1024 * 1024 }));
|
|
1462
|
+
app.use(
|
|
1463
|
+
"*",
|
|
1464
|
+
secureHeaders({
|
|
1465
|
+
strictTransportSecurity: "max-age=63072000; includeSubDomains; preload",
|
|
1466
|
+
contentSecurityPolicy: {
|
|
1467
|
+
defaultSrc: ["'self'"],
|
|
1468
|
+
scriptSrc: ["'self'"],
|
|
1469
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
1470
|
+
connectSrc: ["'self'"],
|
|
1471
|
+
frameAncestors: ["'none'"]
|
|
1472
|
+
},
|
|
1473
|
+
xFrameOptions: "DENY",
|
|
1474
|
+
xContentTypeOptions: "nosniff",
|
|
1475
|
+
referrerPolicy: "strict-origin-when-cross-origin"
|
|
1476
|
+
})
|
|
1477
|
+
);
|
|
1478
|
+
if (env.NODE_ENV !== "production") {
|
|
1479
|
+
app.use("*", logger());
|
|
1480
|
+
}
|
|
1481
|
+
const allowedOrigins2 = new Set(getAllowedOrigins());
|
|
1202
1482
|
app.use(
|
|
1203
1483
|
"*",
|
|
1204
1484
|
cors({
|
|
1205
|
-
origin: "
|
|
1206
|
-
// Configure for production
|
|
1485
|
+
origin: (origin) => allowedOrigins2.has(origin) ? origin : "",
|
|
1207
1486
|
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
1208
|
-
allowHeaders: ["Content-Type", "Authorization"],
|
|
1209
|
-
exposeHeaders: ["X-Request-Id"]
|
|
1487
|
+
allowHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
|
|
1488
|
+
exposeHeaders: ["X-Request-Id", "X-RateLimit-Limit", "X-RateLimit-Remaining", "Retry-After"]
|
|
1210
1489
|
})
|
|
1211
1490
|
);
|
|
1491
|
+
app.use("*", rateLimit({ max: 100, windowMs: 6e4 }));
|
|
1492
|
+
app.use("*", csrfProtection);
|
|
1212
1493
|
app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
|
|
1494
|
+
app.use("/auth/*", rateLimit({ max: 10, windowMs: 6e4 }));
|
|
1213
1495
|
app.route("/auth", authRouter);
|
|
1214
1496
|
app.route("/vault", vaultRouter);
|
|
1215
1497
|
app.onError(errorHandler);
|
|
1216
1498
|
app.notFound((c) => {
|
|
1499
|
+
const requestId = c.req.header("x-request-id") ?? crypto.randomUUID();
|
|
1217
1500
|
return c.json(
|
|
1218
|
-
{ error: { code: "not_found", message: "Endpoint not found" } },
|
|
1501
|
+
{ error: { code: "not_found", message: "Endpoint not found" }, requestId },
|
|
1219
1502
|
404
|
|
1220
1503
|
);
|
|
1221
1504
|
});
|
|
@@ -1228,7 +1511,6 @@ export {
|
|
|
1228
1511
|
EmailLoginRequest,
|
|
1229
1512
|
EmailRegisterRequest,
|
|
1230
1513
|
MeResponse,
|
|
1231
|
-
PasskeyCheckResponse,
|
|
1232
1514
|
PasskeyLoginOptionsRequest,
|
|
1233
1515
|
PasskeyLoginVerifyRequest,
|
|
1234
1516
|
PasskeyRegisterOptionsRequest,
|