@syncello/auth 3.6.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -0
- package/dist/{chunk-OZ26T3ZA.js → chunk-W35EDOHW.js} +21 -3
- package/dist/chunk-W35EDOHW.js.map +1 -0
- package/dist/cli/index.js +27 -18
- package/dist/index.cjs +1055 -885
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +185 -41
- package/dist/index.d.ts +185 -41
- package/dist/index.js +1053 -911
- package/dist/index.js.map +1 -1
- package/dist/schema/index.cjs +20 -2
- package/dist/schema/index.cjs.map +1 -1
- package/dist/schema/index.d.cts +62 -2
- package/dist/schema/index.d.ts +62 -2
- package/dist/schema/index.js +1 -1
- package/package.json +2 -1
- package/dist/chunk-OZ26T3ZA.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -9,11 +9,11 @@ var __export = (target, all) => {
|
|
|
9
9
|
for (var name in all)
|
|
10
10
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
11
|
};
|
|
12
|
-
var __copyProps = (to, from, except,
|
|
12
|
+
var __copyProps = (to, from, except, desc2) => {
|
|
13
13
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
14
|
for (let key of __getOwnPropNames(from))
|
|
15
15
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc2 = __getOwnPropDesc(from, key)) || desc2.enumerable });
|
|
17
17
|
}
|
|
18
18
|
return to;
|
|
19
19
|
};
|
|
@@ -35,6 +35,8 @@ __export(src_exports, {
|
|
|
35
35
|
CloudflareEmailAdapter: () => CloudflareEmailAdapter,
|
|
36
36
|
EmailService: () => EmailService,
|
|
37
37
|
MAX_CHALLENGE_ATTEMPTS: () => MAX_CHALLENGE_ATTEMPTS,
|
|
38
|
+
PASSWORDLESS_MAX_ATTEMPTS: () => PASSWORDLESS_MAX_ATTEMPTS,
|
|
39
|
+
PASSWORDLESS_TTL_MS: () => PASSWORDLESS_TTL_MS,
|
|
38
40
|
ProblemTypes: () => ProblemTypes,
|
|
39
41
|
ResendAdapter: () => ResendAdapter,
|
|
40
42
|
SCHEMA_VERSION: () => SCHEMA_VERSION,
|
|
@@ -46,6 +48,7 @@ __export(src_exports, {
|
|
|
46
48
|
cancelEmailChange: () => cancelEmailChange,
|
|
47
49
|
checkAccountLocked: () => checkAccountLocked,
|
|
48
50
|
checkBreachedPassword: () => checkBreachedPassword,
|
|
51
|
+
cleanupExpiredPasswordlessChallenges: () => cleanupExpiredPasswordlessChallenges,
|
|
49
52
|
clearAccountLockout: () => clearAccountLockout,
|
|
50
53
|
clearChallengeCookie: () => clearChallengeCookie,
|
|
51
54
|
clearSessionCookie: () => clearSessionCookie,
|
|
@@ -56,6 +59,7 @@ __export(src_exports, {
|
|
|
56
59
|
createChallengeToken: () => createChallengeToken,
|
|
57
60
|
createDeviceToken: () => createDeviceToken,
|
|
58
61
|
createEmailAdapter: () => createEmailAdapter,
|
|
62
|
+
createPasswordlessChallenge: () => createPasswordlessChallenge,
|
|
59
63
|
createProblemDetails: () => createProblemDetails,
|
|
60
64
|
createSession: () => createSession,
|
|
61
65
|
csrf: () => csrf,
|
|
@@ -72,6 +76,7 @@ __export(src_exports, {
|
|
|
72
76
|
generate2faEnabledEmail: () => generate2faEnabledEmail,
|
|
73
77
|
generateApiKey: () => generateApiKey,
|
|
74
78
|
generateBackupCodes: () => generateBackupCodes,
|
|
79
|
+
generateCode: () => generateCode,
|
|
75
80
|
generateCodeChallenge: () => generateCodeChallenge,
|
|
76
81
|
generateCodeVerifier: () => generateCodeVerifier,
|
|
77
82
|
generateEmailChangeConfirmation: () => generateEmailChangeConfirmation,
|
|
@@ -110,14 +115,17 @@ __export(src_exports, {
|
|
|
110
115
|
optionalAuth: () => optionalAuth,
|
|
111
116
|
parseDeviceName: () => parseDeviceName,
|
|
112
117
|
parseDeviceType: () => parseDeviceType,
|
|
118
|
+
passwordlessEmail: () => passwordlessEmail,
|
|
113
119
|
problemJson: () => problemJson,
|
|
114
120
|
problems: () => problems,
|
|
115
121
|
rateLimit: () => rateLimit,
|
|
122
|
+
recentPasswordlessFailures: () => recentPasswordlessFailures,
|
|
116
123
|
refreshSession: () => refreshSession,
|
|
117
124
|
requestEmailChange: () => requestEmailChange,
|
|
118
125
|
requireAuth: () => requireAuth,
|
|
119
126
|
requireVerifiedEmail: () => requireVerifiedEmail,
|
|
120
127
|
retrieveChallengeToken: () => retrieveChallengeToken,
|
|
128
|
+
sessionRefreshDue: () => sessionRefreshDue,
|
|
121
129
|
setChallengeCookie: () => setChallengeCookie,
|
|
122
130
|
setSessionCookie: () => setSessionCookie,
|
|
123
131
|
setTrustedDeviceCookie: () => setTrustedDeviceCookie,
|
|
@@ -135,6 +143,8 @@ __export(src_exports, {
|
|
|
135
143
|
verifyCodeChallenge: () => verifyCodeChallenge,
|
|
136
144
|
verifyPassword: () => verifyPassword,
|
|
137
145
|
verifyPasswordWithRotation: () => verifyPasswordWithRotation,
|
|
146
|
+
verifyPasswordlessCode: () => verifyPasswordlessCode,
|
|
147
|
+
verifyPasswordlessLink: () => verifyPasswordlessLink,
|
|
138
148
|
verifyTotpCode: () => verifyTotpCode,
|
|
139
149
|
verifyTurnstileToken: () => verifyTurnstileToken,
|
|
140
150
|
verifyWebhookSignature: () => verifyWebhookSignature
|
|
@@ -142,7 +152,7 @@ __export(src_exports, {
|
|
|
142
152
|
module.exports = __toCommonJS(src_exports);
|
|
143
153
|
|
|
144
154
|
// src/schema/definitions.ts
|
|
145
|
-
var SCHEMA_VERSION = "2026.
|
|
155
|
+
var SCHEMA_VERSION = "2026.10.1";
|
|
146
156
|
var enums = {
|
|
147
157
|
identifierType: {
|
|
148
158
|
name: "identifier_type_enum",
|
|
@@ -380,6 +390,23 @@ var tables = {
|
|
|
380
390
|
{ name: "oauth_accounts_user_provider_idx", columns: ["userId", "provider"], unique: true },
|
|
381
391
|
{ name: "oauth_accounts_provider_user_idx", columns: ["provider", "providerUserId"], unique: true }
|
|
382
392
|
]
|
|
393
|
+
},
|
|
394
|
+
passwordlessChallenges: {
|
|
395
|
+
columns: {
|
|
396
|
+
id: { type: "uuid", primaryKey: true, default: "gen_random_uuid()" },
|
|
397
|
+
email: { type: "varchar", length: 255, notNull: true },
|
|
398
|
+
// The id of whatever account signs in, so no foreign key; null for a sign-up.
|
|
399
|
+
subjectId: { type: "uuid" },
|
|
400
|
+
linkTokenHash: { type: "varchar", length: 128, notNull: true, unique: true },
|
|
401
|
+
codeHash: { type: "varchar", length: 128, notNull: true },
|
|
402
|
+
browserHash: { type: "varchar", length: 128, notNull: true },
|
|
403
|
+
attempts: { type: "integer", notNull: true, default: 0 },
|
|
404
|
+
payload: { type: "jsonb" },
|
|
405
|
+
expiresAt: { type: "bigint", mode: "number", notNull: true },
|
|
406
|
+
consumedAt: { type: "bigint", mode: "number" },
|
|
407
|
+
createdAt: { type: "bigint", mode: "number", notNull: true }
|
|
408
|
+
},
|
|
409
|
+
indexes: [{ name: "idx_passwordless_email_created", columns: ["email", "createdAt"] }]
|
|
383
410
|
}
|
|
384
411
|
};
|
|
385
412
|
var tableNames = {
|
|
@@ -394,7 +421,8 @@ var tableNames = {
|
|
|
394
421
|
emailEvents: "email_events",
|
|
395
422
|
failedLoginAttempts: "failed_login_attempts",
|
|
396
423
|
securityAuditLog: "security_audit_log",
|
|
397
|
-
oauthAccounts: "oauth_accounts"
|
|
424
|
+
oauthAccounts: "oauth_accounts",
|
|
425
|
+
passwordlessChallenges: "passwordless_challenges"
|
|
398
426
|
};
|
|
399
427
|
|
|
400
428
|
// src/core/password.ts
|
|
@@ -647,9 +675,14 @@ var AUTH_DEFAULTS = {
|
|
|
647
675
|
// Absolute cap: a session dies this long after creation regardless of
|
|
648
676
|
// sliding-window activity (limits the value of a stolen session token).
|
|
649
677
|
SESSION_MAX_LIFETIME_DAYS: 30,
|
|
678
|
+
// The sliding window moves at most this often, so a busy session is not a write per request.
|
|
679
|
+
SESSION_REFRESH_INTERVAL_MINUTES: 15,
|
|
650
680
|
LOCKOUT_DURATION_MINUTES: 30,
|
|
651
681
|
LOCKOUT_MAX_ATTEMPTS: 5,
|
|
652
682
|
PASSWORD_RESET_TTL_MINUTES: 15,
|
|
683
|
+
EMAIL_VERIFICATION_TTL_HOURS: 24,
|
|
684
|
+
EMAIL_CHANGE_TTL_HOURS: 24,
|
|
685
|
+
TWO_FACTOR_EMAIL_CODE_TTL_MINUTES: 5,
|
|
653
686
|
TWO_FACTOR_CHALLENGE_TTL_SECONDS: 300,
|
|
654
687
|
// 5 minutes
|
|
655
688
|
APP_NAME: "App"
|
|
@@ -727,7 +760,7 @@ async function validateSession(db, tables2, sessionId, currentFingerprint, sessi
|
|
|
727
760
|
note: "SSR requests have different User-Agent, causing expected mismatch"
|
|
728
761
|
});
|
|
729
762
|
}
|
|
730
|
-
await refreshSession(db, { sessions }, sessionId);
|
|
763
|
+
if (sessionRefreshDue(row.sessionLastActiveAt)) await refreshSession(db, { sessions }, sessionId);
|
|
731
764
|
return {
|
|
732
765
|
user: {
|
|
733
766
|
id: row.userId,
|
|
@@ -746,6 +779,10 @@ async function validateSession(db, tables2, sessionId, currentFingerprint, sessi
|
|
|
746
779
|
}
|
|
747
780
|
};
|
|
748
781
|
}
|
|
782
|
+
function sessionRefreshDue(lastActiveAt, now = Date.now()) {
|
|
783
|
+
const interval = AUTH_DEFAULTS.SESSION_REFRESH_INTERVAL_MINUTES * 60 * 1e3;
|
|
784
|
+
return lastActiveAt === null || now - lastActiveAt >= interval;
|
|
785
|
+
}
|
|
749
786
|
async function refreshSession(db, tables2, sessionId, sessionTtlMs = AUTH_DEFAULTS.SESSION_TTL_DAYS * 24 * 60 * 60 * 1e3) {
|
|
750
787
|
const { sessions } = tables2;
|
|
751
788
|
const newExpiration = Date.now() + sessionTtlMs;
|
|
@@ -913,16 +950,16 @@ function createDeviceToken() {
|
|
|
913
950
|
};
|
|
914
951
|
}
|
|
915
952
|
async function validateTrustedDevice(db, userTrustedDevicesTable, userId, tokenHash) {
|
|
916
|
-
const { eq:
|
|
953
|
+
const { eq: eq34, and: and15, gt: gt7 } = await import("drizzle-orm");
|
|
917
954
|
const [device] = await db.select().from(userTrustedDevicesTable).where(
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
955
|
+
and15(
|
|
956
|
+
eq34(userTrustedDevicesTable.userId, userId),
|
|
957
|
+
eq34(userTrustedDevicesTable.tokenHash, tokenHash),
|
|
958
|
+
gt7(userTrustedDevicesTable.expiresAt, Date.now())
|
|
922
959
|
)
|
|
923
960
|
).limit(1);
|
|
924
961
|
if (device) {
|
|
925
|
-
await db.update(userTrustedDevicesTable).set({ lastUsedAt: Date.now() }).where(
|
|
962
|
+
await db.update(userTrustedDevicesTable).set({ lastUsedAt: Date.now() }).where(eq34(userTrustedDevicesTable.id, device.id));
|
|
926
963
|
return true;
|
|
927
964
|
}
|
|
928
965
|
return false;
|
|
@@ -1135,7 +1172,7 @@ async function verifyTurnstileToken(token, secretKey, remoteIp, environment) {
|
|
|
1135
1172
|
|
|
1136
1173
|
// src/core/email-change.ts
|
|
1137
1174
|
var import_drizzle_orm3 = require("drizzle-orm");
|
|
1138
|
-
var TOKEN_EXPIRY_MS =
|
|
1175
|
+
var TOKEN_EXPIRY_MS = AUTH_DEFAULTS.EMAIL_CHANGE_TTL_HOURS * 60 * 60 * 1e3;
|
|
1139
1176
|
async function requestEmailChange(params) {
|
|
1140
1177
|
const { userId, password, newEmail, pepper, db, tables: tables2 } = params;
|
|
1141
1178
|
const { users, emailChangeTokens } = tables2;
|
|
@@ -1335,582 +1372,48 @@ function base64UrlEncode(data) {
|
|
|
1335
1372
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1336
1373
|
}
|
|
1337
1374
|
|
|
1338
|
-
// src/
|
|
1339
|
-
var import_factory = require("hono/factory");
|
|
1375
|
+
// src/core/passwordless.ts
|
|
1340
1376
|
var import_drizzle_orm4 = require("drizzle-orm");
|
|
1341
1377
|
|
|
1342
|
-
// src/
|
|
1343
|
-
function
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
invitations
|
|
1351
|
-
});
|
|
1352
|
-
return {
|
|
1353
|
-
db,
|
|
1354
|
-
schema,
|
|
1355
|
-
getContext
|
|
1356
|
-
};
|
|
1357
|
-
}
|
|
1358
|
-
function createAuthMiddleware(auth2) {
|
|
1359
|
-
return async (c, next) => {
|
|
1360
|
-
const ctx = auth2.getContext(c);
|
|
1361
|
-
c.set("authContext", ctx);
|
|
1362
|
-
c.set("db", auth2.db);
|
|
1363
|
-
await next();
|
|
1364
|
-
};
|
|
1365
|
-
}
|
|
1366
|
-
function getAuthContext(c) {
|
|
1367
|
-
const ctx = c.get("authContext");
|
|
1368
|
-
if (!ctx) {
|
|
1369
|
-
throw new Error(
|
|
1370
|
-
"AuthContext not found. Did you apply createAuthMiddleware before the auth routes?"
|
|
1371
|
-
);
|
|
1378
|
+
// src/lib/email/security.ts
|
|
1379
|
+
function getAllowedDomains(appUrl) {
|
|
1380
|
+
const base = ["localhost", "127.0.0.1"];
|
|
1381
|
+
try {
|
|
1382
|
+
const url = new URL(appUrl);
|
|
1383
|
+
return [...base, url.hostname];
|
|
1384
|
+
} catch {
|
|
1385
|
+
return base;
|
|
1372
1386
|
}
|
|
1373
|
-
return ctx;
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
// src/lib/problem-json.ts
|
|
1377
|
-
var ProblemTypes = {
|
|
1378
|
-
// 4xx Client Errors
|
|
1379
|
-
BAD_REQUEST: "/errors/bad-request",
|
|
1380
|
-
UNAUTHORIZED: "/errors/unauthorized",
|
|
1381
|
-
FORBIDDEN: "/errors/forbidden",
|
|
1382
|
-
NOT_FOUND: "/errors/not-found",
|
|
1383
|
-
CONFLICT: "/errors/conflict",
|
|
1384
|
-
GONE: "/errors/gone",
|
|
1385
|
-
VALIDATION_ERROR: "/errors/validation-error",
|
|
1386
|
-
RATE_LIMIT_EXCEEDED: "/errors/rate-limit-exceeded",
|
|
1387
|
-
// 5xx Server Errors
|
|
1388
|
-
INTERNAL_ERROR: "/errors/internal-error",
|
|
1389
|
-
SERVICE_UNAVAILABLE: "/errors/service-unavailable"
|
|
1390
|
-
};
|
|
1391
|
-
function generateTraceId() {
|
|
1392
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1393
|
-
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1394
1387
|
}
|
|
1395
|
-
function
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
if (options?.detail) {
|
|
1402
|
-
problem.detail = options.detail;
|
|
1403
|
-
}
|
|
1404
|
-
if (options?.instance) {
|
|
1405
|
-
problem.instance = options.instance;
|
|
1388
|
+
function sanitizeEmailUrl(url, appUrl = "") {
|
|
1389
|
+
let parsed;
|
|
1390
|
+
try {
|
|
1391
|
+
parsed = new URL(url);
|
|
1392
|
+
} catch {
|
|
1393
|
+
throw new Error("Invalid URL format");
|
|
1406
1394
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
problem.requestId = options.requestId;
|
|
1395
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1396
|
+
throw new Error(`Disallowed protocol: ${parsed.protocol}`);
|
|
1410
1397
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1398
|
+
const allowedDomains = getAllowedDomains(appUrl);
|
|
1399
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
1400
|
+
const isAllowed = allowedDomains.some((domain) => {
|
|
1401
|
+
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
1402
|
+
});
|
|
1403
|
+
if (!isAllowed) {
|
|
1404
|
+
throw new Error(`Disallowed domain: ${hostname}`);
|
|
1413
1405
|
}
|
|
1414
|
-
return
|
|
1406
|
+
return parsed.toString();
|
|
1415
1407
|
}
|
|
1416
|
-
function
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
return c.json(problem, problem.status, {
|
|
1426
|
-
"Content-Type": "application/problem+json"
|
|
1427
|
-
});
|
|
1428
|
-
}
|
|
1429
|
-
var problems = {
|
|
1430
|
-
/**
|
|
1431
|
-
* 400 Bad Request - Generic client error
|
|
1432
|
-
*/
|
|
1433
|
-
badRequest(c, detail) {
|
|
1434
|
-
return problemJson(
|
|
1435
|
-
c,
|
|
1436
|
-
createProblemDetails(ProblemTypes.BAD_REQUEST, "Bad Request", 400, {
|
|
1437
|
-
detail: detail || "The request could not be understood by the server",
|
|
1438
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1439
|
-
requestId: c.get("requestId"),
|
|
1440
|
-
traceId: c.get("traceId")
|
|
1441
|
-
})
|
|
1442
|
-
);
|
|
1443
|
-
},
|
|
1444
|
-
/**
|
|
1445
|
-
* 400 Bad Request - Validation errors with field-specific details
|
|
1446
|
-
*/
|
|
1447
|
-
validationError(c, detail, errors) {
|
|
1448
|
-
return problemJson(
|
|
1449
|
-
c,
|
|
1450
|
-
createProblemDetails(ProblemTypes.VALIDATION_ERROR, "Validation Error", 400, {
|
|
1451
|
-
detail,
|
|
1452
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1453
|
-
requestId: c.get("requestId"),
|
|
1454
|
-
traceId: c.get("traceId"),
|
|
1455
|
-
extensions: errors ? { errors } : void 0
|
|
1456
|
-
})
|
|
1457
|
-
);
|
|
1458
|
-
},
|
|
1459
|
-
/**
|
|
1460
|
-
* 401 Unauthorized - Authentication required
|
|
1461
|
-
*/
|
|
1462
|
-
unauthorized(c, detail) {
|
|
1463
|
-
return problemJson(
|
|
1464
|
-
c,
|
|
1465
|
-
createProblemDetails(ProblemTypes.UNAUTHORIZED, "Unauthorized", 401, {
|
|
1466
|
-
detail: detail || "Authentication is required to access this resource",
|
|
1467
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1468
|
-
requestId: c.get("requestId"),
|
|
1469
|
-
traceId: c.get("traceId")
|
|
1470
|
-
})
|
|
1471
|
-
);
|
|
1472
|
-
},
|
|
1473
|
-
/**
|
|
1474
|
-
* 403 Forbidden - Insufficient permissions
|
|
1475
|
-
*/
|
|
1476
|
-
forbidden(c, detail) {
|
|
1477
|
-
return problemJson(
|
|
1478
|
-
c,
|
|
1479
|
-
createProblemDetails(ProblemTypes.FORBIDDEN, "Forbidden", 403, {
|
|
1480
|
-
detail: detail || "You do not have permission to access this resource",
|
|
1481
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1482
|
-
requestId: c.get("requestId"),
|
|
1483
|
-
traceId: c.get("traceId")
|
|
1484
|
-
})
|
|
1485
|
-
);
|
|
1486
|
-
},
|
|
1487
|
-
/**
|
|
1488
|
-
* 404 Not Found
|
|
1489
|
-
*/
|
|
1490
|
-
notFound(c, detail) {
|
|
1491
|
-
return problemJson(
|
|
1492
|
-
c,
|
|
1493
|
-
createProblemDetails(ProblemTypes.NOT_FOUND, "Not Found", 404, {
|
|
1494
|
-
detail: detail || "The requested resource was not found",
|
|
1495
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1496
|
-
requestId: c.get("requestId"),
|
|
1497
|
-
traceId: c.get("traceId")
|
|
1498
|
-
})
|
|
1499
|
-
);
|
|
1500
|
-
},
|
|
1501
|
-
/**
|
|
1502
|
-
* 409 Conflict - Resource conflict (e.g., duplicate email)
|
|
1503
|
-
*/
|
|
1504
|
-
conflict(c, detail) {
|
|
1505
|
-
return problemJson(
|
|
1506
|
-
c,
|
|
1507
|
-
createProblemDetails(ProblemTypes.CONFLICT, "Conflict", 409, {
|
|
1508
|
-
detail,
|
|
1509
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1510
|
-
requestId: c.get("requestId"),
|
|
1511
|
-
traceId: c.get("traceId")
|
|
1512
|
-
})
|
|
1513
|
-
);
|
|
1514
|
-
},
|
|
1515
|
-
/**
|
|
1516
|
-
* 410 Gone - Resource is no longer available
|
|
1517
|
-
*/
|
|
1518
|
-
gone(c, detail) {
|
|
1519
|
-
return problemJson(
|
|
1520
|
-
c,
|
|
1521
|
-
createProblemDetails(ProblemTypes.GONE, "Gone", 410, {
|
|
1522
|
-
detail: detail || "The requested resource is no longer available",
|
|
1523
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1524
|
-
requestId: c.get("requestId"),
|
|
1525
|
-
traceId: c.get("traceId")
|
|
1526
|
-
})
|
|
1527
|
-
);
|
|
1528
|
-
},
|
|
1529
|
-
/**
|
|
1530
|
-
* 429 Too Many Requests - Rate limit exceeded
|
|
1531
|
-
*/
|
|
1532
|
-
rateLimitExceeded(c, retryAfter) {
|
|
1533
|
-
const extensions = retryAfter ? { retryAfter } : void 0;
|
|
1534
|
-
return problemJson(
|
|
1535
|
-
c,
|
|
1536
|
-
createProblemDetails(ProblemTypes.RATE_LIMIT_EXCEEDED, "Rate Limit Exceeded", 429, {
|
|
1537
|
-
detail: retryAfter ? `Too many requests. Please retry after ${retryAfter} seconds` : "Too many requests. Please slow down",
|
|
1538
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1539
|
-
requestId: c.get("requestId"),
|
|
1540
|
-
traceId: c.get("traceId"),
|
|
1541
|
-
extensions
|
|
1542
|
-
})
|
|
1543
|
-
);
|
|
1544
|
-
},
|
|
1545
|
-
/**
|
|
1546
|
-
* 500 Internal Server Error - Generic server error
|
|
1547
|
-
* SECURITY: Never include error details or stack traces
|
|
1548
|
-
*/
|
|
1549
|
-
internalError(c, error) {
|
|
1550
|
-
const traceId = c.get("traceId") || generateTraceId();
|
|
1551
|
-
if (error) {
|
|
1552
|
-
logError(error, {
|
|
1553
|
-
context: "internal_error",
|
|
1554
|
-
path: c.req.path,
|
|
1555
|
-
method: c.req.method,
|
|
1556
|
-
traceId,
|
|
1557
|
-
requestId: c.get("requestId")
|
|
1558
|
-
});
|
|
1559
|
-
}
|
|
1560
|
-
return problemJson(
|
|
1561
|
-
c,
|
|
1562
|
-
createProblemDetails(ProblemTypes.INTERNAL_ERROR, "Internal Server Error", 500, {
|
|
1563
|
-
detail: "An unexpected error occurred. Please try again later",
|
|
1564
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1565
|
-
traceId,
|
|
1566
|
-
requestId: c.get("requestId")
|
|
1567
|
-
})
|
|
1568
|
-
);
|
|
1569
|
-
},
|
|
1570
|
-
/**
|
|
1571
|
-
* 503 Service Unavailable - Service is temporarily unavailable
|
|
1572
|
-
*/
|
|
1573
|
-
serviceUnavailable(c, detail) {
|
|
1574
|
-
return problemJson(
|
|
1575
|
-
c,
|
|
1576
|
-
createProblemDetails(ProblemTypes.SERVICE_UNAVAILABLE, "Service Unavailable", 503, {
|
|
1577
|
-
detail: detail || "The service is temporarily unavailable. Please try again later",
|
|
1578
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1579
|
-
requestId: c.get("requestId"),
|
|
1580
|
-
traceId: c.get("traceId")
|
|
1581
|
-
})
|
|
1582
|
-
);
|
|
1583
|
-
}
|
|
1584
|
-
};
|
|
1585
|
-
|
|
1586
|
-
// src/middleware/auth.ts
|
|
1587
|
-
var SESSION_MAX_LIFETIME_MS = AUTH_DEFAULTS.SESSION_MAX_LIFETIME_DAYS * 24 * 60 * 60 * 1e3;
|
|
1588
|
-
async function revokeIfPastMaxLifetime(db, sessions, sessionId, session) {
|
|
1589
|
-
if (Date.now() - session.createdAt <= SESSION_MAX_LIFETIME_MS) return false;
|
|
1590
|
-
logger_default.info("Session exceeded absolute lifetime - revoked", {
|
|
1591
|
-
type: "security",
|
|
1592
|
-
event: "session_max_lifetime_exceeded",
|
|
1593
|
-
severity: "low",
|
|
1594
|
-
sessionId: sessionId.slice(0, 8),
|
|
1595
|
-
userId: session.userId
|
|
1596
|
-
});
|
|
1597
|
-
await deleteSession(db, { sessions }, sessionId);
|
|
1598
|
-
return true;
|
|
1599
|
-
}
|
|
1600
|
-
async function getSessionFromCookie(db, sessions, cookieHeader, request, env) {
|
|
1601
|
-
const cookieName = getSessionCookieName(env);
|
|
1602
|
-
const cookiePattern = new RegExp(`${cookieName}=([^;]+)`);
|
|
1603
|
-
const sessionIdMatch = cookieHeader.match(cookiePattern);
|
|
1604
|
-
if (!sessionIdMatch) return null;
|
|
1605
|
-
const sessionId = sessionIdMatch[1];
|
|
1606
|
-
const foundSessions = await db.select({
|
|
1607
|
-
userId: sessions.userId,
|
|
1608
|
-
expiresAt: sessions.expiresAt,
|
|
1609
|
-
createdAt: sessions.createdAt,
|
|
1610
|
-
fingerprint: sessions.fingerprint
|
|
1611
|
-
}).from(sessions).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(sessions.id, sessionId), (0, import_drizzle_orm4.gt)(sessions.expiresAt, Date.now()))).limit(1);
|
|
1612
|
-
if (foundSessions.length === 0) return null;
|
|
1613
|
-
const session = foundSessions[0];
|
|
1614
|
-
if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
|
|
1615
|
-
await refreshSession(db, { sessions }, sessionId);
|
|
1616
|
-
const userAgent = request.headers.get("user-agent") || "";
|
|
1617
|
-
const cfWorker = request.headers.get("cf-worker") || "";
|
|
1618
|
-
const clientIp = request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || "";
|
|
1619
|
-
const isLocalSSR = userAgent.toLowerCase().includes("node");
|
|
1620
|
-
const isSvelteKitSSR = userAgent.toLowerCase().includes("sveltekit");
|
|
1621
|
-
const isCloudflareSSR = !userAgent && cfWorker.includes(".pages.dev");
|
|
1622
|
-
const isSSR = isLocalSSR || isSvelteKitSSR || isCloudflareSSR;
|
|
1623
|
-
if (isSSR) {
|
|
1624
|
-
logger_default.info("SSR request detected, using IP validation", {
|
|
1625
|
-
type: "security",
|
|
1626
|
-
event: "ssr_session_validation",
|
|
1627
|
-
sessionId: sessionId.slice(0, 8),
|
|
1628
|
-
userId: session.userId,
|
|
1629
|
-
clientIp,
|
|
1630
|
-
cfWorker,
|
|
1631
|
-
isLocalSSR,
|
|
1632
|
-
isSvelteKitSSR,
|
|
1633
|
-
isCloudflareSSR
|
|
1634
|
-
});
|
|
1635
|
-
} else {
|
|
1636
|
-
if (session.fingerprint) {
|
|
1637
|
-
const currentFingerprint = await generateFingerprint(request);
|
|
1638
|
-
if (session.fingerprint !== currentFingerprint) {
|
|
1639
|
-
logger_default.info("Session fingerprint changed (not enforced)", {
|
|
1640
|
-
type: "security",
|
|
1641
|
-
event: "fingerprint_changed",
|
|
1642
|
-
sessionId: sessionId.slice(0, 8),
|
|
1643
|
-
userId: session.userId,
|
|
1644
|
-
clientIp
|
|
1645
|
-
});
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
return {
|
|
1650
|
-
userId: session.userId,
|
|
1651
|
-
expiresAt: session.expiresAt
|
|
1652
|
-
};
|
|
1653
|
-
}
|
|
1654
|
-
async function getSessionFromBearerToken(db, sessions, authHeader) {
|
|
1655
|
-
if (!authHeader.startsWith("Bearer ")) return null;
|
|
1656
|
-
const sessionId = authHeader.slice(7);
|
|
1657
|
-
const foundSessions = await db.select({
|
|
1658
|
-
userId: sessions.userId,
|
|
1659
|
-
expiresAt: sessions.expiresAt,
|
|
1660
|
-
createdAt: sessions.createdAt
|
|
1661
|
-
}).from(sessions).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(sessions.id, sessionId), (0, import_drizzle_orm4.gt)(sessions.expiresAt, Date.now()))).limit(1);
|
|
1662
|
-
if (foundSessions.length === 0) return null;
|
|
1663
|
-
const session = foundSessions[0];
|
|
1664
|
-
if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
|
|
1665
|
-
await refreshSession(db, { sessions }, sessionId);
|
|
1666
|
-
return {
|
|
1667
|
-
userId: session.userId,
|
|
1668
|
-
expiresAt: session.expiresAt
|
|
1669
|
-
};
|
|
1670
|
-
}
|
|
1671
|
-
var requireAuth = (0, import_factory.createMiddleware)(
|
|
1672
|
-
async (c, next) => {
|
|
1673
|
-
const { db, schema } = getAuthContext(c);
|
|
1674
|
-
const authHeader = c.req.header("Authorization");
|
|
1675
|
-
if (authHeader) {
|
|
1676
|
-
const session2 = await getSessionFromBearerToken(db, schema.sessions, authHeader);
|
|
1677
|
-
if (session2) {
|
|
1678
|
-
c.set("userId", session2.userId);
|
|
1679
|
-
await next();
|
|
1680
|
-
return;
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
const cookieHeader = c.req.header("cookie");
|
|
1684
|
-
if (!cookieHeader) {
|
|
1685
|
-
return problems.unauthorized(c, "Please log in to continue");
|
|
1686
|
-
}
|
|
1687
|
-
const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
|
|
1688
|
-
if (!session) {
|
|
1689
|
-
c.header("Set-Cookie", clearSessionCookie(c.env));
|
|
1690
|
-
return problems.unauthorized(c, "Your session has expired. Please log in again.");
|
|
1691
|
-
}
|
|
1692
|
-
c.set("userId", session.userId);
|
|
1693
|
-
await next();
|
|
1694
|
-
}
|
|
1695
|
-
);
|
|
1696
|
-
var optionalAuth = (0, import_factory.createMiddleware)(
|
|
1697
|
-
async (c, next) => {
|
|
1698
|
-
const { db, schema } = getAuthContext(c);
|
|
1699
|
-
const authHeader = c.req.header("Authorization");
|
|
1700
|
-
if (authHeader) {
|
|
1701
|
-
const session = await getSessionFromBearerToken(db, schema.sessions, authHeader);
|
|
1702
|
-
if (session) {
|
|
1703
|
-
c.set("userId", session.userId);
|
|
1704
|
-
await next();
|
|
1705
|
-
return;
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
const cookieHeader = c.req.header("cookie");
|
|
1709
|
-
if (cookieHeader) {
|
|
1710
|
-
const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
|
|
1711
|
-
if (session) {
|
|
1712
|
-
c.set("userId", session.userId);
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
await next();
|
|
1716
|
-
}
|
|
1717
|
-
);
|
|
1718
|
-
|
|
1719
|
-
// src/middleware/csrf.ts
|
|
1720
|
-
var import_factory3 = require("hono/factory");
|
|
1721
|
-
var csrf = (0, import_factory3.createMiddleware)(async (c, next) => {
|
|
1722
|
-
const method = c.req.method;
|
|
1723
|
-
const path = c.req.path;
|
|
1724
|
-
const isWebhook = path.startsWith("/v1/webhooks/");
|
|
1725
|
-
if (isWebhook) {
|
|
1726
|
-
await next();
|
|
1727
|
-
return;
|
|
1728
|
-
}
|
|
1729
|
-
const isMcpEndpoint = path === "/v1/mcp";
|
|
1730
|
-
if (isMcpEndpoint) {
|
|
1731
|
-
await next();
|
|
1732
|
-
return;
|
|
1733
|
-
}
|
|
1734
|
-
const isOAuthMcpEndpoint = path.startsWith("/v1/oauth/");
|
|
1735
|
-
if (isOAuthMcpEndpoint) {
|
|
1736
|
-
await next();
|
|
1737
|
-
return;
|
|
1738
|
-
}
|
|
1739
|
-
const isToolsApi = path.startsWith("/v1/tools/") && !path.startsWith("/v1/tools/catalog");
|
|
1740
|
-
if (isToolsApi) {
|
|
1741
|
-
await next();
|
|
1742
|
-
return;
|
|
1743
|
-
}
|
|
1744
|
-
const isAdminApi = path.startsWith("/v1/admin/");
|
|
1745
|
-
if (isAdminApi) {
|
|
1746
|
-
await next();
|
|
1747
|
-
return;
|
|
1748
|
-
}
|
|
1749
|
-
const isMobileAuthEndpoint = path === "/v1/auth/login" || path === "/v1/auth/signup" || path === "/v1/auth/refresh";
|
|
1750
|
-
const authHeader = c.req.header("Authorization");
|
|
1751
|
-
const hasBearerToken = authHeader?.startsWith("Bearer ");
|
|
1752
|
-
const hasNoOrigin = !c.req.header("origin") && !c.req.header("referer");
|
|
1753
|
-
if (isMobileAuthEndpoint && hasNoOrigin || hasBearerToken) {
|
|
1754
|
-
await next();
|
|
1755
|
-
return;
|
|
1756
|
-
}
|
|
1757
|
-
const isInternalSync = path.startsWith("/v1/internal/sync/");
|
|
1758
|
-
if (isInternalSync) {
|
|
1759
|
-
await next();
|
|
1760
|
-
return;
|
|
1761
|
-
}
|
|
1762
|
-
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
|
1763
|
-
const appUrl = c.env.APP_URL || "http://localhost:5173";
|
|
1764
|
-
const allowedOrigins = getAllowedOrigins(appUrl);
|
|
1765
|
-
const request = c.req.raw;
|
|
1766
|
-
const isTestEndpoint = c.req.path.startsWith("/test");
|
|
1767
|
-
if (isTestEndpoint) {
|
|
1768
|
-
const origin = request.headers.get("origin");
|
|
1769
|
-
const referer = request.headers.get("referer");
|
|
1770
|
-
logger_default.debug("CSRF validation for test endpoint", {
|
|
1771
|
-
path: c.req.path,
|
|
1772
|
-
method,
|
|
1773
|
-
origin,
|
|
1774
|
-
referer,
|
|
1775
|
-
appUrl,
|
|
1776
|
-
allowedOrigins
|
|
1777
|
-
});
|
|
1778
|
-
}
|
|
1779
|
-
const pagesPattern = c.env.CORS_PAGES_PATTERN;
|
|
1780
|
-
if (!validateOrigin(request, allowedOrigins, pagesPattern)) {
|
|
1781
|
-
return problems.forbidden(c, "Request blocked for security reasons. Please try again.");
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
await next();
|
|
1785
|
-
});
|
|
1786
|
-
|
|
1787
|
-
// src/middleware/rateLimit.ts
|
|
1788
|
-
var import_factory4 = require("hono/factory");
|
|
1789
|
-
|
|
1790
|
-
// src/lib/rate-limit-kv.ts
|
|
1791
|
-
async function checkRateLimitKV(kv, key, maxAttempts, windowSeconds) {
|
|
1792
|
-
try {
|
|
1793
|
-
const rateLimitKey = `rate_limit:${key}`;
|
|
1794
|
-
const currentAttemptsStr = await kv.get(rateLimitKey);
|
|
1795
|
-
const currentAttempts = currentAttemptsStr ? parseInt(currentAttemptsStr, 10) : 0;
|
|
1796
|
-
if (currentAttempts >= maxAttempts) {
|
|
1797
|
-
return {
|
|
1798
|
-
allowed: false,
|
|
1799
|
-
remaining: 0
|
|
1800
|
-
};
|
|
1801
|
-
}
|
|
1802
|
-
const newAttempts = currentAttempts + 1;
|
|
1803
|
-
await kv.put(rateLimitKey, newAttempts.toString(), {
|
|
1804
|
-
expirationTtl: windowSeconds
|
|
1805
|
-
});
|
|
1806
|
-
return {
|
|
1807
|
-
allowed: true,
|
|
1808
|
-
remaining: maxAttempts - newAttempts
|
|
1809
|
-
};
|
|
1810
|
-
} catch (error) {
|
|
1811
|
-
logger_default.error("Rate limit KV error, failing open", {
|
|
1812
|
-
key,
|
|
1813
|
-
error: error instanceof Error ? error.message : "Unknown error"
|
|
1814
|
-
});
|
|
1815
|
-
return {
|
|
1816
|
-
allowed: true,
|
|
1817
|
-
remaining: maxAttempts
|
|
1818
|
-
};
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
// src/middleware/rateLimit.ts
|
|
1823
|
-
var rateLimit = (config) => (0, import_factory4.createMiddleware)(async (c, next) => {
|
|
1824
|
-
const kv = c.env.RATE_LIMIT_KV;
|
|
1825
|
-
const identifier = await config.identifier(c);
|
|
1826
|
-
const key = `${config.action}:${identifier}`;
|
|
1827
|
-
const windowSeconds = Math.ceil(config.windowMs / 1e3);
|
|
1828
|
-
const { allowed, remaining } = await checkRateLimitKV(
|
|
1829
|
-
kv,
|
|
1830
|
-
key,
|
|
1831
|
-
config.maxAttempts,
|
|
1832
|
-
windowSeconds
|
|
1833
|
-
);
|
|
1834
|
-
const resetTimestamp = Math.ceil(Date.now() / 1e3) + windowSeconds;
|
|
1835
|
-
c.header("X-RateLimit-Limit", config.maxAttempts.toString());
|
|
1836
|
-
c.header("X-RateLimit-Remaining", Math.max(0, remaining).toString());
|
|
1837
|
-
c.header("X-RateLimit-Reset", resetTimestamp.toString());
|
|
1838
|
-
if (!allowed) {
|
|
1839
|
-
c.header("Retry-After", windowSeconds.toString());
|
|
1840
|
-
return problems.rateLimitExceeded(c, windowSeconds);
|
|
1841
|
-
}
|
|
1842
|
-
await next();
|
|
1843
|
-
});
|
|
1844
|
-
|
|
1845
|
-
// src/middleware/require-verified-email.ts
|
|
1846
|
-
var import_factory5 = require("hono/factory");
|
|
1847
|
-
var import_drizzle_orm5 = require("drizzle-orm");
|
|
1848
|
-
var requireVerifiedEmail = (0, import_factory5.createMiddleware)(
|
|
1849
|
-
async (c, next) => {
|
|
1850
|
-
const userId = c.get("userId");
|
|
1851
|
-
if (!userId) {
|
|
1852
|
-
return problems.unauthorized(c, "Not authenticated");
|
|
1853
|
-
}
|
|
1854
|
-
const { db, schema } = getAuthContext(c);
|
|
1855
|
-
const database = db;
|
|
1856
|
-
const users = schema.users;
|
|
1857
|
-
const [user] = await database.select({ emailVerified: users.emailVerified }).from(users).where((0, import_drizzle_orm5.eq)(users.id, userId)).limit(1);
|
|
1858
|
-
if (!user || !user.emailVerified) {
|
|
1859
|
-
return problems.forbidden(c, "Please verify your email address to continue");
|
|
1860
|
-
}
|
|
1861
|
-
await next();
|
|
1862
|
-
}
|
|
1863
|
-
);
|
|
1864
|
-
|
|
1865
|
-
// src/routes/index.ts
|
|
1866
|
-
var import_zod_openapi42 = require("@hono/zod-openapi");
|
|
1867
|
-
|
|
1868
|
-
// src/routes/signup.ts
|
|
1869
|
-
var import_zod_openapi2 = require("@hono/zod-openapi");
|
|
1870
|
-
var import_drizzle_orm9 = require("drizzle-orm");
|
|
1871
|
-
|
|
1872
|
-
// src/lib/email/email-service.ts
|
|
1873
|
-
var import_drizzle_orm6 = require("drizzle-orm");
|
|
1874
|
-
|
|
1875
|
-
// src/lib/email/security.ts
|
|
1876
|
-
function getAllowedDomains(appUrl) {
|
|
1877
|
-
const base = ["localhost", "127.0.0.1"];
|
|
1878
|
-
try {
|
|
1879
|
-
const url = new URL(appUrl);
|
|
1880
|
-
return [...base, url.hostname];
|
|
1881
|
-
} catch {
|
|
1882
|
-
return base;
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
function sanitizeEmailUrl(url, appUrl = "") {
|
|
1886
|
-
let parsed;
|
|
1887
|
-
try {
|
|
1888
|
-
parsed = new URL(url);
|
|
1889
|
-
} catch {
|
|
1890
|
-
throw new Error("Invalid URL format");
|
|
1891
|
-
}
|
|
1892
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1893
|
-
throw new Error(`Disallowed protocol: ${parsed.protocol}`);
|
|
1894
|
-
}
|
|
1895
|
-
const allowedDomains = getAllowedDomains(appUrl);
|
|
1896
|
-
const hostname = parsed.hostname.toLowerCase();
|
|
1897
|
-
const isAllowed = allowedDomains.some((domain) => {
|
|
1898
|
-
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
1899
|
-
});
|
|
1900
|
-
if (!isAllowed) {
|
|
1901
|
-
throw new Error(`Disallowed domain: ${hostname}`);
|
|
1902
|
-
}
|
|
1903
|
-
return parsed.toString();
|
|
1904
|
-
}
|
|
1905
|
-
function escapeHtml(text) {
|
|
1906
|
-
const htmlEscapeMap = {
|
|
1907
|
-
"&": "&",
|
|
1908
|
-
"<": "<",
|
|
1909
|
-
">": ">",
|
|
1910
|
-
'"': """,
|
|
1911
|
-
"'": "'"
|
|
1912
|
-
};
|
|
1913
|
-
return text.replace(/[&<>"']/g, (char) => htmlEscapeMap[char]);
|
|
1408
|
+
function escapeHtml(text) {
|
|
1409
|
+
const htmlEscapeMap = {
|
|
1410
|
+
"&": "&",
|
|
1411
|
+
"<": "<",
|
|
1412
|
+
">": ">",
|
|
1413
|
+
'"': """,
|
|
1414
|
+
"'": "'"
|
|
1415
|
+
};
|
|
1416
|
+
return text.replace(/[&<>"']/g, (char) => htmlEscapeMap[char]);
|
|
1914
1417
|
}
|
|
1915
1418
|
|
|
1916
1419
|
// src/core/email-template.ts
|
|
@@ -1924,19 +1427,20 @@ function generateEmailTemplate(options) {
|
|
|
1924
1427
|
securityWarning,
|
|
1925
1428
|
footerNote,
|
|
1926
1429
|
appUrl,
|
|
1927
|
-
marketingUrl
|
|
1928
|
-
// Default to appUrl if no marketing site
|
|
1430
|
+
marketingUrl,
|
|
1929
1431
|
appName = "App",
|
|
1930
1432
|
buttonBgColor = "#00fe9a",
|
|
1931
1433
|
buttonTextColor = "#000004",
|
|
1932
1434
|
buttonFontWeight = 500,
|
|
1933
1435
|
buttonRadius = "6px",
|
|
1934
1436
|
logoUrl,
|
|
1935
|
-
footerColor = "#1e1b4b"
|
|
1437
|
+
footerColor = "#1e1b4b",
|
|
1438
|
+
headingColor = "#1e1b4b"
|
|
1936
1439
|
} = options;
|
|
1440
|
+
const safeAppName = escapeHtml(appName);
|
|
1937
1441
|
const safeButtonUrl = buttonUrl ? sanitizeEmailUrl(buttonUrl, appUrl) : "";
|
|
1938
1442
|
const safeAppUrl = appUrl ? sanitizeEmailUrl(appUrl, appUrl) : "";
|
|
1939
|
-
const safeMarketingUrl = marketingUrl ? sanitizeEmailUrl(marketingUrl,
|
|
1443
|
+
const safeMarketingUrl = marketingUrl ? sanitizeEmailUrl(marketingUrl, marketingUrl).replace(/\/$/, "") : "";
|
|
1940
1444
|
const safeLogoUrl = logoUrl ? sanitizeEmailUrl(logoUrl, appUrl) : `${safeAppUrl.replace(/\/$/, "")}/email-header-image.png`;
|
|
1941
1445
|
return `
|
|
1942
1446
|
<!DOCTYPE html>
|
|
@@ -2077,7 +1581,7 @@ function generateEmailTemplate(options) {
|
|
|
2077
1581
|
<td class="pad" style="padding-left:40px;padding-right:40px;width:100%;">
|
|
2078
1582
|
<div class="alignment" align="center">
|
|
2079
1583
|
<div class="fullWidth" style="max-width: 50%;">
|
|
2080
|
-
<img src="${safeLogoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${
|
|
1584
|
+
<img src="${safeLogoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${safeAppName}" title="${safeAppName}" height="auto">
|
|
2081
1585
|
</div>
|
|
2082
1586
|
</div>
|
|
2083
1587
|
</td>
|
|
@@ -2089,7 +1593,7 @@ function generateEmailTemplate(options) {
|
|
|
2089
1593
|
<table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
|
|
2090
1594
|
<tr>
|
|
2091
1595
|
<td class="pad" style="padding-top:30px;padding-left:40px;padding-right:40px;">
|
|
2092
|
-
<div style="color
|
|
1596
|
+
<div style="color:${headingColor};font-family:'Montserrat',Arial,sans-serif;font-size:22px;font-weight:600;line-height:1.4;text-align:center;mso-line-height-alt:31px;">
|
|
2093
1597
|
<p style="margin: 0; word-break: break-word;">${welcomeMessage}</p>
|
|
2094
1598
|
</div>
|
|
2095
1599
|
</td>
|
|
@@ -2102,7 +1606,7 @@ function generateEmailTemplate(options) {
|
|
|
2102
1606
|
<table class="paragraph_block block-4" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
|
|
2103
1607
|
<tr>
|
|
2104
1608
|
<td class="pad" style="padding-bottom:10px;padding-left:40px;padding-right:40px;padding-top:10px;">
|
|
2105
|
-
<div style="color
|
|
1609
|
+
<div style="color:${headingColor};font-family:'Montserrat',Arial,sans-serif;font-size:30px;font-weight:700;line-height:1.2;text-align:center;mso-line-height-alt:36px;">
|
|
2106
1610
|
<p style="margin: 0; word-break: break-word;">${heading}</p>
|
|
2107
1611
|
</div>
|
|
2108
1612
|
</td>
|
|
@@ -2178,104 +1682,778 @@ function generateEmailTemplate(options) {
|
|
|
2178
1682
|
<!-- Footer Note -->
|
|
2179
1683
|
<table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
|
|
2180
1684
|
<tr>
|
|
2181
|
-
<td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
|
|
2182
|
-
<div style="color:#737373;font-family:'Montserrat',Arial,sans-serif;font-size:13px;line-height:1.5;text-align:center;mso-line-height-alt:20px;">
|
|
2183
|
-
<p style="margin: 0; word-break: break-word;">${footerNote}</p>
|
|
1685
|
+
<td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
|
|
1686
|
+
<div style="color:#737373;font-family:'Montserrat',Arial,sans-serif;font-size:13px;line-height:1.5;text-align:center;mso-line-height-alt:20px;">
|
|
1687
|
+
<p style="margin: 0; word-break: break-word;">${footerNote}</p>
|
|
1688
|
+
</div>
|
|
1689
|
+
</td>
|
|
1690
|
+
</tr>
|
|
1691
|
+
</table>
|
|
1692
|
+
|
|
1693
|
+
</td>
|
|
1694
|
+
</tr>
|
|
1695
|
+
</tbody>
|
|
1696
|
+
</table>
|
|
1697
|
+
</td>
|
|
1698
|
+
</tr>
|
|
1699
|
+
</tbody>
|
|
1700
|
+
</table>
|
|
1701
|
+
|
|
1702
|
+
<!-- Spacer Row -->
|
|
1703
|
+
<table class="row row-2" align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
|
|
1704
|
+
<tbody>
|
|
1705
|
+
<tr>
|
|
1706
|
+
<td>
|
|
1707
|
+
<table class="row-content stack" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f8f9fa; color: #000000; max-width: 640px; width: 100%; margin: 0 auto;">
|
|
1708
|
+
<tbody>
|
|
1709
|
+
<tr>
|
|
1710
|
+
<td class="column column-1" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; font-weight: 400; text-align: left; padding-bottom: 5px; vertical-align: top;">
|
|
1711
|
+
<table class="empty_block block-1" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
|
|
1712
|
+
<tr>
|
|
1713
|
+
<td class="pad">
|
|
1714
|
+
<div></div>
|
|
1715
|
+
</td>
|
|
1716
|
+
</tr>
|
|
1717
|
+
</table>
|
|
1718
|
+
</td>
|
|
1719
|
+
</tr>
|
|
1720
|
+
</tbody>
|
|
1721
|
+
</table>
|
|
1722
|
+
</td>
|
|
1723
|
+
</tr>
|
|
1724
|
+
</tbody>
|
|
1725
|
+
</table>
|
|
1726
|
+
|
|
1727
|
+
<!-- Footer Row -->
|
|
1728
|
+
<table class="row row-3" align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
|
|
1729
|
+
<tbody>
|
|
1730
|
+
<tr>
|
|
1731
|
+
<td>
|
|
1732
|
+
<table class="row-content stack" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: ${footerColor}; color: #000000; max-width: 640px; width: 100%; margin: 0 auto;">
|
|
1733
|
+
<tbody>
|
|
1734
|
+
<tr>
|
|
1735
|
+
<td class="column column-1" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; font-weight: 400; text-align: left; vertical-align: top; padding: 20px;">
|
|
1736
|
+
|
|
1737
|
+
${safeMarketingUrl ? `
|
|
1738
|
+
<!-- Policy Links -->
|
|
1739
|
+
<table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
|
|
1740
|
+
<tr>
|
|
1741
|
+
<td class="pad" style="padding-bottom:20px;padding-left:40px;padding-right:40px;padding-top:20px;">
|
|
1742
|
+
<div style="color:#ffffff;font-family:'Montserrat',Arial,sans-serif;font-size:13px;line-height:1.5;text-align:center;mso-line-height-alt:20px;">
|
|
1743
|
+
<p style="margin: 0; word-break: break-word;">
|
|
1744
|
+
<a href="${safeMarketingUrl}/privacy" style="color: #ffffff; text-decoration: underline;">Privacy Policy</a> | <a href="${safeMarketingUrl}/contact" style="color: #ffffff; text-decoration: underline;">Get in Touch</a>
|
|
1745
|
+
</p>
|
|
1746
|
+
</div>
|
|
1747
|
+
</td>
|
|
1748
|
+
</tr>
|
|
1749
|
+
</table>
|
|
1750
|
+
` : ""}
|
|
1751
|
+
|
|
1752
|
+
<!-- Copyright & Disclaimer -->
|
|
1753
|
+
<table class="paragraph_block block-1" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
|
|
1754
|
+
<tr>
|
|
1755
|
+
<td class="pad" style="padding-bottom:15px;padding-left:40px;padding-right:40px;">
|
|
1756
|
+
<div style="color:#a3a3a3;font-family:'Montserrat',Arial,sans-serif;font-size:12px;line-height:1.5;text-align:center;mso-line-height-alt:18px;">
|
|
1757
|
+
<p style="margin: 0 0 12px 0; word-break: break-word;">\xA9 ${(/* @__PURE__ */ new Date()).getFullYear()} ${safeAppName}. All rights reserved.</p>
|
|
1758
|
+
<p style="margin: 0; font-size: 11px; line-height: 1.5; word-break: break-word;">
|
|
1759
|
+
Please do not reply to this email as this address does not accept incoming messages. This service email contains essential information relating to your ${safeAppName} account.
|
|
1760
|
+
</p>
|
|
2184
1761
|
</div>
|
|
2185
1762
|
</td>
|
|
2186
1763
|
</tr>
|
|
2187
1764
|
</table>
|
|
2188
1765
|
|
|
2189
|
-
</td>
|
|
2190
|
-
</tr>
|
|
2191
|
-
</tbody>
|
|
2192
|
-
</table>
|
|
2193
|
-
</td>
|
|
2194
|
-
</tr>
|
|
2195
|
-
</tbody>
|
|
2196
|
-
</table>
|
|
1766
|
+
</td>
|
|
1767
|
+
</tr>
|
|
1768
|
+
</tbody>
|
|
1769
|
+
</table>
|
|
1770
|
+
</td>
|
|
1771
|
+
</tr>
|
|
1772
|
+
</tbody>
|
|
1773
|
+
</table>
|
|
1774
|
+
|
|
1775
|
+
</td>
|
|
1776
|
+
</tr>
|
|
1777
|
+
</tbody>
|
|
1778
|
+
</table>
|
|
1779
|
+
</body>
|
|
1780
|
+
</html>
|
|
1781
|
+
`;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// src/core/passwordless.ts
|
|
1785
|
+
var PASSWORDLESS_TTL_MS = 10 * 60 * 1e3;
|
|
1786
|
+
var PASSWORDLESS_MAX_ATTEMPTS = 5;
|
|
1787
|
+
var CODE_SPACE = 1e6;
|
|
1788
|
+
var CODE_LIMIT = Math.floor(4294967296 / CODE_SPACE) * CODE_SPACE;
|
|
1789
|
+
var CLEANUP_GRACE_MS = 24 * 60 * 60 * 1e3;
|
|
1790
|
+
var normalizeEmail = (email) => email.trim().toLowerCase();
|
|
1791
|
+
function generateCode() {
|
|
1792
|
+
const buffer = new Uint32Array(1);
|
|
1793
|
+
for (; ; ) {
|
|
1794
|
+
crypto.getRandomValues(buffer);
|
|
1795
|
+
if (buffer[0] < CODE_LIMIT) return String(buffer[0] % CODE_SPACE).padStart(6, "0");
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
var codeHash = (challengeId, code) => hashToken(`${challengeId}:${code}`);
|
|
1799
|
+
function constantTimeEqual(a, b) {
|
|
1800
|
+
if (a.length !== b.length) return false;
|
|
1801
|
+
let diff = 0;
|
|
1802
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1803
|
+
return diff === 0;
|
|
1804
|
+
}
|
|
1805
|
+
async function consumeOpen(db, t, email, now) {
|
|
1806
|
+
await db.update(t).set({ consumedAt: now }).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(t.email, email), (0, import_drizzle_orm4.isNull)(t.consumedAt)));
|
|
1807
|
+
}
|
|
1808
|
+
async function createPasswordlessChallenge(db, tables2, input) {
|
|
1809
|
+
const t = tables2.passwordlessChallenges;
|
|
1810
|
+
const email = normalizeEmail(input.email);
|
|
1811
|
+
const now = Date.now();
|
|
1812
|
+
const challengeId = crypto.randomUUID();
|
|
1813
|
+
const linkToken = generateSecureToken(32);
|
|
1814
|
+
const browserSecret = generateSecureToken(32);
|
|
1815
|
+
const code = generateCode();
|
|
1816
|
+
await consumeOpen(db, t, email, now);
|
|
1817
|
+
await db.insert(t).values({
|
|
1818
|
+
id: challengeId,
|
|
1819
|
+
email,
|
|
1820
|
+
subjectId: input.subjectId ?? null,
|
|
1821
|
+
linkTokenHash: await hashToken(linkToken),
|
|
1822
|
+
codeHash: await codeHash(challengeId, code),
|
|
1823
|
+
browserHash: await hashToken(browserSecret),
|
|
1824
|
+
attempts: 0,
|
|
1825
|
+
payload: input.payload ?? null,
|
|
1826
|
+
expiresAt: now + (input.ttlMs ?? PASSWORDLESS_TTL_MS),
|
|
1827
|
+
createdAt: now
|
|
1828
|
+
});
|
|
1829
|
+
return { challengeId, linkToken, code, browserSecret };
|
|
1830
|
+
}
|
|
1831
|
+
async function verifyPasswordlessLink(db, tables2, input) {
|
|
1832
|
+
const t = tables2.passwordlessChallenges;
|
|
1833
|
+
const now = Date.now();
|
|
1834
|
+
const hash = await hashToken(input.linkToken);
|
|
1835
|
+
const [row] = await db.update(t).set({ consumedAt: now }).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(t.linkTokenHash, hash), (0, import_drizzle_orm4.isNull)(t.consumedAt), (0, import_drizzle_orm4.gt)(t.expiresAt, now))).returning();
|
|
1836
|
+
if (!row) {
|
|
1837
|
+
const [stale] = await db.select({ expiresAt: t.expiresAt, consumedAt: t.consumedAt }).from(t).where((0, import_drizzle_orm4.eq)(t.linkTokenHash, hash)).limit(1);
|
|
1838
|
+
const expired = stale && stale.consumedAt === null && stale.expiresAt <= now;
|
|
1839
|
+
return { ok: false, reason: expired ? "expired" : "invalid" };
|
|
1840
|
+
}
|
|
1841
|
+
await consumeOpen(db, t, row.email, now);
|
|
1842
|
+
return { ok: true, email: row.email, subjectId: row.subjectId, payload: row.payload };
|
|
1843
|
+
}
|
|
1844
|
+
async function verifyPasswordlessCode(db, tables2, input) {
|
|
1845
|
+
const t = tables2.passwordlessChallenges;
|
|
1846
|
+
const max = input.maxAttempts ?? PASSWORDLESS_MAX_ATTEMPTS;
|
|
1847
|
+
const email = normalizeEmail(input.email);
|
|
1848
|
+
const now = Date.now();
|
|
1849
|
+
const [row] = await db.select().from(t).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(t.email, email), (0, import_drizzle_orm4.isNull)(t.consumedAt))).orderBy((0, import_drizzle_orm4.desc)(t.createdAt)).limit(1);
|
|
1850
|
+
if (!row) return { ok: false, reason: "invalid" };
|
|
1851
|
+
if (!constantTimeEqual(row.browserHash, await hashToken(input.browserSecret))) {
|
|
1852
|
+
return { ok: false, reason: "invalid" };
|
|
1853
|
+
}
|
|
1854
|
+
if (row.expiresAt <= now) return { ok: false, reason: "expired" };
|
|
1855
|
+
if (row.attempts >= max) return { ok: false, reason: "locked" };
|
|
1856
|
+
if (!constantTimeEqual(row.codeHash, await codeHash(row.id, input.code.trim()))) {
|
|
1857
|
+
const [counted] = await db.update(t).set({ attempts: import_drizzle_orm4.sql`${t.attempts} + 1` }).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(t.id, row.id), (0, import_drizzle_orm4.lt)(t.attempts, max))).returning({ attempts: t.attempts });
|
|
1858
|
+
return { ok: false, reason: counted && counted.attempts < max ? "invalid" : "locked" };
|
|
1859
|
+
}
|
|
1860
|
+
const [won] = await db.update(t).set({ consumedAt: now }).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(t.id, row.id), (0, import_drizzle_orm4.isNull)(t.consumedAt), (0, import_drizzle_orm4.lt)(t.attempts, max))).returning();
|
|
1861
|
+
if (!won) return { ok: false, reason: "invalid" };
|
|
1862
|
+
await consumeOpen(db, t, email, now);
|
|
1863
|
+
return { ok: true, email, subjectId: won.subjectId, payload: won.payload };
|
|
1864
|
+
}
|
|
1865
|
+
async function recentPasswordlessFailures(db, tables2, input) {
|
|
1866
|
+
const t = tables2.passwordlessChallenges;
|
|
1867
|
+
const [row] = await db.select({ total: import_drizzle_orm4.sql`coalesce(sum(${t.attempts}), 0)::int` }).from(t).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(t.email, normalizeEmail(input.email)), (0, import_drizzle_orm4.gt)(t.createdAt, Date.now() - input.windowMs)));
|
|
1868
|
+
return Number(row?.total ?? 0);
|
|
1869
|
+
}
|
|
1870
|
+
async function cleanupExpiredPasswordlessChallenges(db, tables2) {
|
|
1871
|
+
const t = tables2.passwordlessChallenges;
|
|
1872
|
+
await db.delete(t).where((0, import_drizzle_orm4.lt)(t.expiresAt, Date.now() - CLEANUP_GRACE_MS));
|
|
1873
|
+
}
|
|
1874
|
+
function passwordlessEmail(input) {
|
|
1875
|
+
const { appName, appUrl, linkUrl, code } = input;
|
|
1876
|
+
const minutes = Math.max(1, Math.round((input.ttlMs ?? PASSWORDLESS_TTL_MS) / 6e4));
|
|
1877
|
+
const lifetime2 = `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
1878
|
+
const safeLink = sanitizeEmailUrl(linkUrl, appUrl);
|
|
1879
|
+
const html = generateEmailTemplate({
|
|
1880
|
+
heading: `Sign in to ${escapeHtml(appName)}`,
|
|
1881
|
+
body: `<p>Use this code, or the button below, to sign in:</p>
|
|
1882
|
+
<p style="font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 24px 0; font-family: monospace;">${escapeHtml(code)}</p>
|
|
1883
|
+
<p>The code and the link expire in ${lifetime2}.</p>`,
|
|
1884
|
+
buttonText: "Sign in",
|
|
1885
|
+
buttonUrl: safeLink,
|
|
1886
|
+
footerNote: "If you didn't ask to sign in, you can ignore this email.",
|
|
1887
|
+
appUrl,
|
|
1888
|
+
appName
|
|
1889
|
+
});
|
|
1890
|
+
return {
|
|
1891
|
+
subject: `${code} is your sign-in code`,
|
|
1892
|
+
html,
|
|
1893
|
+
text: `Your ${appName} sign-in code is ${code}.
|
|
1894
|
+
|
|
1895
|
+
Or sign in with this link:
|
|
1896
|
+
${safeLink}
|
|
1897
|
+
|
|
1898
|
+
The code and the link expire in ${lifetime2}.
|
|
1899
|
+
|
|
1900
|
+
If you didn't ask to sign in, you can ignore this email.`
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// src/middleware/auth.ts
|
|
1905
|
+
var import_factory = require("hono/factory");
|
|
1906
|
+
var import_drizzle_orm5 = require("drizzle-orm");
|
|
1907
|
+
|
|
1908
|
+
// src/factory.ts
|
|
1909
|
+
function createAuth(config) {
|
|
1910
|
+
const { db, schema, getEnv, emailBranding, invitations, signup = "open" } = config;
|
|
1911
|
+
if (signup === "invitation" && !invitations) {
|
|
1912
|
+
throw new Error("createAuth: signup: 'invitation' requires the invitations hooks");
|
|
1913
|
+
}
|
|
1914
|
+
const getContext = (c) => ({
|
|
1915
|
+
db,
|
|
1916
|
+
schema,
|
|
1917
|
+
env: getEnv(c),
|
|
1918
|
+
emailBranding,
|
|
1919
|
+
invitations,
|
|
1920
|
+
signup
|
|
1921
|
+
});
|
|
1922
|
+
return {
|
|
1923
|
+
db,
|
|
1924
|
+
schema,
|
|
1925
|
+
getContext
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
function createAuthMiddleware(auth2) {
|
|
1929
|
+
return async (c, next) => {
|
|
1930
|
+
const ctx = auth2.getContext(c);
|
|
1931
|
+
c.set("authContext", ctx);
|
|
1932
|
+
c.set("db", auth2.db);
|
|
1933
|
+
await next();
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
function getAuthContext(c) {
|
|
1937
|
+
const ctx = c.get("authContext");
|
|
1938
|
+
if (!ctx) {
|
|
1939
|
+
throw new Error(
|
|
1940
|
+
"AuthContext not found. Did you apply createAuthMiddleware before the auth routes?"
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
return ctx;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// src/lib/problem-json.ts
|
|
1947
|
+
var ProblemTypes = {
|
|
1948
|
+
// 4xx Client Errors
|
|
1949
|
+
BAD_REQUEST: "/errors/bad-request",
|
|
1950
|
+
UNAUTHORIZED: "/errors/unauthorized",
|
|
1951
|
+
FORBIDDEN: "/errors/forbidden",
|
|
1952
|
+
NOT_FOUND: "/errors/not-found",
|
|
1953
|
+
CONFLICT: "/errors/conflict",
|
|
1954
|
+
GONE: "/errors/gone",
|
|
1955
|
+
VALIDATION_ERROR: "/errors/validation-error",
|
|
1956
|
+
RATE_LIMIT_EXCEEDED: "/errors/rate-limit-exceeded",
|
|
1957
|
+
// 5xx Server Errors
|
|
1958
|
+
INTERNAL_ERROR: "/errors/internal-error",
|
|
1959
|
+
SERVICE_UNAVAILABLE: "/errors/service-unavailable"
|
|
1960
|
+
};
|
|
1961
|
+
function generateTraceId() {
|
|
1962
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1963
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1964
|
+
}
|
|
1965
|
+
function createProblemDetails(type, title, status, options) {
|
|
1966
|
+
const problem = {
|
|
1967
|
+
type,
|
|
1968
|
+
title,
|
|
1969
|
+
status
|
|
1970
|
+
};
|
|
1971
|
+
if (options?.detail) {
|
|
1972
|
+
problem.detail = options.detail;
|
|
1973
|
+
}
|
|
1974
|
+
if (options?.instance) {
|
|
1975
|
+
problem.instance = options.instance;
|
|
1976
|
+
}
|
|
1977
|
+
problem.traceId = options?.traceId || generateTraceId();
|
|
1978
|
+
if (options?.requestId) {
|
|
1979
|
+
problem.requestId = options.requestId;
|
|
1980
|
+
}
|
|
1981
|
+
if (options?.extensions) {
|
|
1982
|
+
Object.assign(problem, options.extensions);
|
|
1983
|
+
}
|
|
1984
|
+
return problem;
|
|
1985
|
+
}
|
|
1986
|
+
function problemJson(c, problem) {
|
|
1987
|
+
logger_default.warn("API error response", {
|
|
1988
|
+
type: problem.type,
|
|
1989
|
+
status: problem.status,
|
|
1990
|
+
traceId: problem.traceId,
|
|
1991
|
+
requestId: problem.requestId,
|
|
1992
|
+
path: c.req.path,
|
|
1993
|
+
method: c.req.method
|
|
1994
|
+
});
|
|
1995
|
+
return c.json(problem, problem.status, {
|
|
1996
|
+
"Content-Type": "application/problem+json"
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
var problems = {
|
|
2000
|
+
/**
|
|
2001
|
+
* 400 Bad Request - Generic client error
|
|
2002
|
+
*/
|
|
2003
|
+
badRequest(c, detail) {
|
|
2004
|
+
return problemJson(
|
|
2005
|
+
c,
|
|
2006
|
+
createProblemDetails(ProblemTypes.BAD_REQUEST, "Bad Request", 400, {
|
|
2007
|
+
detail: detail || "The request could not be understood by the server",
|
|
2008
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
2009
|
+
requestId: c.get("requestId"),
|
|
2010
|
+
traceId: c.get("traceId")
|
|
2011
|
+
})
|
|
2012
|
+
);
|
|
2013
|
+
},
|
|
2014
|
+
/**
|
|
2015
|
+
* 400 Bad Request - Validation errors with field-specific details
|
|
2016
|
+
*/
|
|
2017
|
+
validationError(c, detail, errors) {
|
|
2018
|
+
return problemJson(
|
|
2019
|
+
c,
|
|
2020
|
+
createProblemDetails(ProblemTypes.VALIDATION_ERROR, "Validation Error", 400, {
|
|
2021
|
+
detail,
|
|
2022
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
2023
|
+
requestId: c.get("requestId"),
|
|
2024
|
+
traceId: c.get("traceId"),
|
|
2025
|
+
extensions: errors ? { errors } : void 0
|
|
2026
|
+
})
|
|
2027
|
+
);
|
|
2028
|
+
},
|
|
2029
|
+
/**
|
|
2030
|
+
* 401 Unauthorized - Authentication required
|
|
2031
|
+
*/
|
|
2032
|
+
unauthorized(c, detail) {
|
|
2033
|
+
return problemJson(
|
|
2034
|
+
c,
|
|
2035
|
+
createProblemDetails(ProblemTypes.UNAUTHORIZED, "Unauthorized", 401, {
|
|
2036
|
+
detail: detail || "Authentication is required to access this resource",
|
|
2037
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
2038
|
+
requestId: c.get("requestId"),
|
|
2039
|
+
traceId: c.get("traceId")
|
|
2040
|
+
})
|
|
2041
|
+
);
|
|
2042
|
+
},
|
|
2043
|
+
/**
|
|
2044
|
+
* 403 Forbidden - Insufficient permissions
|
|
2045
|
+
*/
|
|
2046
|
+
forbidden(c, detail) {
|
|
2047
|
+
return problemJson(
|
|
2048
|
+
c,
|
|
2049
|
+
createProblemDetails(ProblemTypes.FORBIDDEN, "Forbidden", 403, {
|
|
2050
|
+
detail: detail || "You do not have permission to access this resource",
|
|
2051
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
2052
|
+
requestId: c.get("requestId"),
|
|
2053
|
+
traceId: c.get("traceId")
|
|
2054
|
+
})
|
|
2055
|
+
);
|
|
2056
|
+
},
|
|
2057
|
+
/**
|
|
2058
|
+
* 404 Not Found
|
|
2059
|
+
*/
|
|
2060
|
+
notFound(c, detail) {
|
|
2061
|
+
return problemJson(
|
|
2062
|
+
c,
|
|
2063
|
+
createProblemDetails(ProblemTypes.NOT_FOUND, "Not Found", 404, {
|
|
2064
|
+
detail: detail || "The requested resource was not found",
|
|
2065
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
2066
|
+
requestId: c.get("requestId"),
|
|
2067
|
+
traceId: c.get("traceId")
|
|
2068
|
+
})
|
|
2069
|
+
);
|
|
2070
|
+
},
|
|
2071
|
+
/**
|
|
2072
|
+
* 409 Conflict - Resource conflict (e.g., duplicate email)
|
|
2073
|
+
*/
|
|
2074
|
+
conflict(c, detail) {
|
|
2075
|
+
return problemJson(
|
|
2076
|
+
c,
|
|
2077
|
+
createProblemDetails(ProblemTypes.CONFLICT, "Conflict", 409, {
|
|
2078
|
+
detail,
|
|
2079
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
2080
|
+
requestId: c.get("requestId"),
|
|
2081
|
+
traceId: c.get("traceId")
|
|
2082
|
+
})
|
|
2083
|
+
);
|
|
2084
|
+
},
|
|
2085
|
+
/**
|
|
2086
|
+
* 410 Gone - Resource is no longer available
|
|
2087
|
+
*/
|
|
2088
|
+
gone(c, detail) {
|
|
2089
|
+
return problemJson(
|
|
2090
|
+
c,
|
|
2091
|
+
createProblemDetails(ProblemTypes.GONE, "Gone", 410, {
|
|
2092
|
+
detail: detail || "The requested resource is no longer available",
|
|
2093
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
2094
|
+
requestId: c.get("requestId"),
|
|
2095
|
+
traceId: c.get("traceId")
|
|
2096
|
+
})
|
|
2097
|
+
);
|
|
2098
|
+
},
|
|
2099
|
+
/**
|
|
2100
|
+
* 429 Too Many Requests - Rate limit exceeded
|
|
2101
|
+
*/
|
|
2102
|
+
rateLimitExceeded(c, retryAfter) {
|
|
2103
|
+
const extensions = retryAfter ? { retryAfter } : void 0;
|
|
2104
|
+
return problemJson(
|
|
2105
|
+
c,
|
|
2106
|
+
createProblemDetails(ProblemTypes.RATE_LIMIT_EXCEEDED, "Rate Limit Exceeded", 429, {
|
|
2107
|
+
detail: retryAfter ? `Too many requests. Please retry after ${retryAfter} seconds` : "Too many requests. Please slow down",
|
|
2108
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
2109
|
+
requestId: c.get("requestId"),
|
|
2110
|
+
traceId: c.get("traceId"),
|
|
2111
|
+
extensions
|
|
2112
|
+
})
|
|
2113
|
+
);
|
|
2114
|
+
},
|
|
2115
|
+
/**
|
|
2116
|
+
* 500 Internal Server Error - Generic server error
|
|
2117
|
+
* SECURITY: Never include error details or stack traces
|
|
2118
|
+
*/
|
|
2119
|
+
internalError(c, error) {
|
|
2120
|
+
const traceId = c.get("traceId") || generateTraceId();
|
|
2121
|
+
if (error) {
|
|
2122
|
+
logError(error, {
|
|
2123
|
+
context: "internal_error",
|
|
2124
|
+
path: c.req.path,
|
|
2125
|
+
method: c.req.method,
|
|
2126
|
+
traceId,
|
|
2127
|
+
requestId: c.get("requestId")
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
return problemJson(
|
|
2131
|
+
c,
|
|
2132
|
+
createProblemDetails(ProblemTypes.INTERNAL_ERROR, "Internal Server Error", 500, {
|
|
2133
|
+
detail: "An unexpected error occurred. Please try again later",
|
|
2134
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
2135
|
+
traceId,
|
|
2136
|
+
requestId: c.get("requestId")
|
|
2137
|
+
})
|
|
2138
|
+
);
|
|
2139
|
+
},
|
|
2140
|
+
/**
|
|
2141
|
+
* 503 Service Unavailable - Service is temporarily unavailable
|
|
2142
|
+
*/
|
|
2143
|
+
serviceUnavailable(c, detail) {
|
|
2144
|
+
return problemJson(
|
|
2145
|
+
c,
|
|
2146
|
+
createProblemDetails(ProblemTypes.SERVICE_UNAVAILABLE, "Service Unavailable", 503, {
|
|
2147
|
+
detail: detail || "The service is temporarily unavailable. Please try again later",
|
|
2148
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
2149
|
+
requestId: c.get("requestId"),
|
|
2150
|
+
traceId: c.get("traceId")
|
|
2151
|
+
})
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
};
|
|
2155
|
+
|
|
2156
|
+
// src/middleware/auth.ts
|
|
2157
|
+
var SESSION_MAX_LIFETIME_MS = AUTH_DEFAULTS.SESSION_MAX_LIFETIME_DAYS * 24 * 60 * 60 * 1e3;
|
|
2158
|
+
async function revokeIfPastMaxLifetime(db, sessions, sessionId, session) {
|
|
2159
|
+
if (Date.now() - session.createdAt <= SESSION_MAX_LIFETIME_MS) return false;
|
|
2160
|
+
logger_default.info("Session exceeded absolute lifetime - revoked", {
|
|
2161
|
+
type: "security",
|
|
2162
|
+
event: "session_max_lifetime_exceeded",
|
|
2163
|
+
severity: "low",
|
|
2164
|
+
sessionId: sessionId.slice(0, 8),
|
|
2165
|
+
userId: session.userId
|
|
2166
|
+
});
|
|
2167
|
+
await deleteSession(db, { sessions }, sessionId);
|
|
2168
|
+
return true;
|
|
2169
|
+
}
|
|
2170
|
+
async function getSessionFromCookie(db, sessions, cookieHeader, request, env) {
|
|
2171
|
+
const cookieName = getSessionCookieName(env);
|
|
2172
|
+
const cookiePattern = new RegExp(`${cookieName}=([^;]+)`);
|
|
2173
|
+
const sessionIdMatch = cookieHeader.match(cookiePattern);
|
|
2174
|
+
if (!sessionIdMatch) return null;
|
|
2175
|
+
const sessionId = sessionIdMatch[1];
|
|
2176
|
+
const foundSessions = await db.select({
|
|
2177
|
+
userId: sessions.userId,
|
|
2178
|
+
expiresAt: sessions.expiresAt,
|
|
2179
|
+
createdAt: sessions.createdAt,
|
|
2180
|
+
lastActiveAt: sessions.lastActiveAt,
|
|
2181
|
+
fingerprint: sessions.fingerprint
|
|
2182
|
+
}).from(sessions).where((0, import_drizzle_orm5.and)((0, import_drizzle_orm5.eq)(sessions.id, sessionId), (0, import_drizzle_orm5.gt)(sessions.expiresAt, Date.now()))).limit(1);
|
|
2183
|
+
if (foundSessions.length === 0) return null;
|
|
2184
|
+
const session = foundSessions[0];
|
|
2185
|
+
if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
|
|
2186
|
+
if (sessionRefreshDue(session.lastActiveAt)) await refreshSession(db, { sessions }, sessionId);
|
|
2187
|
+
const userAgent = request.headers.get("user-agent") || "";
|
|
2188
|
+
const cfWorker = request.headers.get("cf-worker") || "";
|
|
2189
|
+
const clientIp = request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || "";
|
|
2190
|
+
const isLocalSSR = userAgent.toLowerCase().includes("node");
|
|
2191
|
+
const isSvelteKitSSR = userAgent.toLowerCase().includes("sveltekit");
|
|
2192
|
+
const isCloudflareSSR = !userAgent && cfWorker.includes(".pages.dev");
|
|
2193
|
+
const isSSR = isLocalSSR || isSvelteKitSSR || isCloudflareSSR;
|
|
2194
|
+
if (isSSR) {
|
|
2195
|
+
logger_default.info("SSR request detected, using IP validation", {
|
|
2196
|
+
type: "security",
|
|
2197
|
+
event: "ssr_session_validation",
|
|
2198
|
+
sessionId: sessionId.slice(0, 8),
|
|
2199
|
+
userId: session.userId,
|
|
2200
|
+
clientIp,
|
|
2201
|
+
cfWorker,
|
|
2202
|
+
isLocalSSR,
|
|
2203
|
+
isSvelteKitSSR,
|
|
2204
|
+
isCloudflareSSR
|
|
2205
|
+
});
|
|
2206
|
+
} else {
|
|
2207
|
+
if (session.fingerprint) {
|
|
2208
|
+
const currentFingerprint = await generateFingerprint(request);
|
|
2209
|
+
if (session.fingerprint !== currentFingerprint) {
|
|
2210
|
+
logger_default.info("Session fingerprint changed (not enforced)", {
|
|
2211
|
+
type: "security",
|
|
2212
|
+
event: "fingerprint_changed",
|
|
2213
|
+
sessionId: sessionId.slice(0, 8),
|
|
2214
|
+
userId: session.userId,
|
|
2215
|
+
clientIp
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
return {
|
|
2221
|
+
userId: session.userId,
|
|
2222
|
+
expiresAt: session.expiresAt
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
async function getSessionFromBearerToken(db, sessions, authHeader) {
|
|
2226
|
+
if (!authHeader.startsWith("Bearer ")) return null;
|
|
2227
|
+
const sessionId = authHeader.slice(7);
|
|
2228
|
+
const foundSessions = await db.select({
|
|
2229
|
+
userId: sessions.userId,
|
|
2230
|
+
expiresAt: sessions.expiresAt,
|
|
2231
|
+
createdAt: sessions.createdAt,
|
|
2232
|
+
lastActiveAt: sessions.lastActiveAt
|
|
2233
|
+
}).from(sessions).where((0, import_drizzle_orm5.and)((0, import_drizzle_orm5.eq)(sessions.id, sessionId), (0, import_drizzle_orm5.gt)(sessions.expiresAt, Date.now()))).limit(1);
|
|
2234
|
+
if (foundSessions.length === 0) return null;
|
|
2235
|
+
const session = foundSessions[0];
|
|
2236
|
+
if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
|
|
2237
|
+
if (sessionRefreshDue(session.lastActiveAt)) await refreshSession(db, { sessions }, sessionId);
|
|
2238
|
+
return {
|
|
2239
|
+
userId: session.userId,
|
|
2240
|
+
expiresAt: session.expiresAt
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
var requireAuth = (0, import_factory.createMiddleware)(
|
|
2244
|
+
async (c, next) => {
|
|
2245
|
+
const { db, schema } = getAuthContext(c);
|
|
2246
|
+
const authHeader = c.req.header("Authorization");
|
|
2247
|
+
if (authHeader) {
|
|
2248
|
+
const session2 = await getSessionFromBearerToken(db, schema.sessions, authHeader);
|
|
2249
|
+
if (session2) {
|
|
2250
|
+
c.set("userId", session2.userId);
|
|
2251
|
+
await next();
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
const cookieHeader = c.req.header("cookie");
|
|
2256
|
+
if (!cookieHeader) {
|
|
2257
|
+
return problems.unauthorized(c, "Please log in to continue");
|
|
2258
|
+
}
|
|
2259
|
+
const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
|
|
2260
|
+
if (!session) {
|
|
2261
|
+
c.header("Set-Cookie", clearSessionCookie(c.env));
|
|
2262
|
+
return problems.unauthorized(c, "Your session has expired. Please log in again.");
|
|
2263
|
+
}
|
|
2264
|
+
c.set("userId", session.userId);
|
|
2265
|
+
await next();
|
|
2266
|
+
}
|
|
2267
|
+
);
|
|
2268
|
+
var optionalAuth = (0, import_factory.createMiddleware)(
|
|
2269
|
+
async (c, next) => {
|
|
2270
|
+
const { db, schema } = getAuthContext(c);
|
|
2271
|
+
const authHeader = c.req.header("Authorization");
|
|
2272
|
+
if (authHeader) {
|
|
2273
|
+
const session = await getSessionFromBearerToken(db, schema.sessions, authHeader);
|
|
2274
|
+
if (session) {
|
|
2275
|
+
c.set("userId", session.userId);
|
|
2276
|
+
await next();
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
const cookieHeader = c.req.header("cookie");
|
|
2281
|
+
if (cookieHeader) {
|
|
2282
|
+
const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
|
|
2283
|
+
if (session) {
|
|
2284
|
+
c.set("userId", session.userId);
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
await next();
|
|
2288
|
+
}
|
|
2289
|
+
);
|
|
2197
2290
|
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2291
|
+
// src/middleware/csrf.ts
|
|
2292
|
+
var import_factory3 = require("hono/factory");
|
|
2293
|
+
var csrf = (0, import_factory3.createMiddleware)(async (c, next) => {
|
|
2294
|
+
const method = c.req.method;
|
|
2295
|
+
const path = c.req.path;
|
|
2296
|
+
const isWebhook = path.startsWith("/v1/webhooks/");
|
|
2297
|
+
if (isWebhook) {
|
|
2298
|
+
await next();
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
const isMcpEndpoint = path === "/v1/mcp";
|
|
2302
|
+
if (isMcpEndpoint) {
|
|
2303
|
+
await next();
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
const isOAuthMcpEndpoint = path.startsWith("/v1/oauth/");
|
|
2307
|
+
if (isOAuthMcpEndpoint) {
|
|
2308
|
+
await next();
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
const isToolsApi = path.startsWith("/v1/tools/") && !path.startsWith("/v1/tools/catalog");
|
|
2312
|
+
if (isToolsApi) {
|
|
2313
|
+
await next();
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
const isAdminApi = path.startsWith("/v1/admin/");
|
|
2317
|
+
if (isAdminApi) {
|
|
2318
|
+
await next();
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
const isMobileAuthEndpoint = path === "/v1/auth/login" || path === "/v1/auth/signup" || path === "/v1/auth/refresh";
|
|
2322
|
+
const authHeader = c.req.header("Authorization");
|
|
2323
|
+
const hasBearerToken = authHeader?.startsWith("Bearer ");
|
|
2324
|
+
const hasNoOrigin = !c.req.header("origin") && !c.req.header("referer");
|
|
2325
|
+
if (isMobileAuthEndpoint && hasNoOrigin || hasBearerToken) {
|
|
2326
|
+
await next();
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
const isInternalSync = path.startsWith("/v1/internal/sync/");
|
|
2330
|
+
if (isInternalSync) {
|
|
2331
|
+
await next();
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
|
2335
|
+
const appUrl = c.env.APP_URL || "http://localhost:5173";
|
|
2336
|
+
const allowedOrigins = getAllowedOrigins(appUrl);
|
|
2337
|
+
const request = c.req.raw;
|
|
2338
|
+
const isTestEndpoint = c.req.path.startsWith("/test");
|
|
2339
|
+
if (isTestEndpoint) {
|
|
2340
|
+
const origin = request.headers.get("origin");
|
|
2341
|
+
const referer = request.headers.get("referer");
|
|
2342
|
+
logger_default.debug("CSRF validation for test endpoint", {
|
|
2343
|
+
path: c.req.path,
|
|
2344
|
+
method,
|
|
2345
|
+
origin,
|
|
2346
|
+
referer,
|
|
2347
|
+
appUrl,
|
|
2348
|
+
allowedOrigins
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
const pagesPattern = c.env.CORS_PAGES_PATTERN;
|
|
2352
|
+
if (!validateOrigin(request, allowedOrigins, pagesPattern)) {
|
|
2353
|
+
return problems.forbidden(c, "Request blocked for security reasons. Please try again.");
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
await next();
|
|
2357
|
+
});
|
|
2222
2358
|
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
<tbody>
|
|
2226
|
-
<tr>
|
|
2227
|
-
<td>
|
|
2228
|
-
<table class="row-content stack" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: ${footerColor}; color: #000000; max-width: 640px; width: 100%; margin: 0 auto;">
|
|
2229
|
-
<tbody>
|
|
2230
|
-
<tr>
|
|
2231
|
-
<td class="column column-1" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; font-weight: 400; text-align: left; vertical-align: top; padding: 20px;">
|
|
2359
|
+
// src/middleware/rateLimit.ts
|
|
2360
|
+
var import_factory4 = require("hono/factory");
|
|
2232
2361
|
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2362
|
+
// src/lib/rate-limit-kv.ts
|
|
2363
|
+
async function checkRateLimitKV(kv, key, maxAttempts, windowSeconds) {
|
|
2364
|
+
try {
|
|
2365
|
+
const rateLimitKey = `rate_limit:${key}`;
|
|
2366
|
+
const currentAttemptsStr = await kv.get(rateLimitKey);
|
|
2367
|
+
const currentAttempts = currentAttemptsStr ? parseInt(currentAttemptsStr, 10) : 0;
|
|
2368
|
+
if (currentAttempts >= maxAttempts) {
|
|
2369
|
+
return {
|
|
2370
|
+
allowed: false,
|
|
2371
|
+
remaining: 0
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
const newAttempts = currentAttempts + 1;
|
|
2375
|
+
await kv.put(rateLimitKey, newAttempts.toString(), {
|
|
2376
|
+
expirationTtl: windowSeconds
|
|
2377
|
+
});
|
|
2378
|
+
return {
|
|
2379
|
+
allowed: true,
|
|
2380
|
+
remaining: maxAttempts - newAttempts
|
|
2381
|
+
};
|
|
2382
|
+
} catch (error) {
|
|
2383
|
+
logger_default.error("Rate limit KV error, failing open", {
|
|
2384
|
+
key,
|
|
2385
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
2386
|
+
});
|
|
2387
|
+
return {
|
|
2388
|
+
allowed: true,
|
|
2389
|
+
remaining: maxAttempts
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2245
2393
|
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2394
|
+
// src/middleware/rateLimit.ts
|
|
2395
|
+
var rateLimit = (config) => (0, import_factory4.createMiddleware)(async (c, next) => {
|
|
2396
|
+
const kv = c.env.RATE_LIMIT_KV;
|
|
2397
|
+
const identifier = await config.identifier(c);
|
|
2398
|
+
const key = `${config.action}:${identifier}`;
|
|
2399
|
+
const windowSeconds = Math.ceil(config.windowMs / 1e3);
|
|
2400
|
+
const { allowed, remaining } = await checkRateLimitKV(
|
|
2401
|
+
kv,
|
|
2402
|
+
key,
|
|
2403
|
+
config.maxAttempts,
|
|
2404
|
+
windowSeconds
|
|
2405
|
+
);
|
|
2406
|
+
const resetTimestamp = Math.ceil(Date.now() / 1e3) + windowSeconds;
|
|
2407
|
+
c.header("X-RateLimit-Limit", config.maxAttempts.toString());
|
|
2408
|
+
c.header("X-RateLimit-Remaining", Math.max(0, remaining).toString());
|
|
2409
|
+
c.header("X-RateLimit-Reset", resetTimestamp.toString());
|
|
2410
|
+
if (!allowed) {
|
|
2411
|
+
c.header("Retry-After", windowSeconds.toString());
|
|
2412
|
+
return problems.rateLimitExceeded(c, windowSeconds);
|
|
2413
|
+
}
|
|
2414
|
+
await next();
|
|
2415
|
+
});
|
|
2259
2416
|
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2417
|
+
// src/middleware/require-verified-email.ts
|
|
2418
|
+
var import_factory5 = require("hono/factory");
|
|
2419
|
+
var import_drizzle_orm6 = require("drizzle-orm");
|
|
2420
|
+
var requireVerifiedEmail = (0, import_factory5.createMiddleware)(
|
|
2421
|
+
async (c, next) => {
|
|
2422
|
+
const userId = c.get("userId");
|
|
2423
|
+
if (!userId) {
|
|
2424
|
+
return problems.unauthorized(c, "Not authenticated");
|
|
2425
|
+
}
|
|
2426
|
+
const { db, schema } = getAuthContext(c);
|
|
2427
|
+
const database = db;
|
|
2428
|
+
const users = schema.users;
|
|
2429
|
+
const [user] = await database.select({ emailVerified: users.emailVerified }).from(users).where((0, import_drizzle_orm6.eq)(users.id, userId)).limit(1);
|
|
2430
|
+
if (!user || !user.emailVerified) {
|
|
2431
|
+
return problems.forbidden(c, "Please verify your email address to continue");
|
|
2432
|
+
}
|
|
2433
|
+
await next();
|
|
2434
|
+
}
|
|
2435
|
+
);
|
|
2268
2436
|
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2437
|
+
// src/routes/index.ts
|
|
2438
|
+
var import_zod_openapi42 = require("@hono/zod-openapi");
|
|
2439
|
+
|
|
2440
|
+
// src/routes/signup.ts
|
|
2441
|
+
var import_zod_openapi2 = require("@hono/zod-openapi");
|
|
2442
|
+
var import_drizzle_orm10 = require("drizzle-orm");
|
|
2443
|
+
|
|
2444
|
+
// src/lib/email/email-service.ts
|
|
2445
|
+
var import_drizzle_orm7 = require("drizzle-orm");
|
|
2277
2446
|
|
|
2278
2447
|
// src/lib/email/templates.ts
|
|
2448
|
+
function lifetime(minutes) {
|
|
2449
|
+
if (minutes % 60 !== 0) return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
2450
|
+
const hours = minutes / 60;
|
|
2451
|
+
return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
2452
|
+
}
|
|
2453
|
+
var VERIFICATION_LIFETIME = lifetime(AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60);
|
|
2454
|
+
var PASSWORD_RESET_LIFETIME = lifetime(AUTH_DEFAULTS.PASSWORD_RESET_TTL_MINUTES);
|
|
2455
|
+
var EMAIL_CHANGE_LIFETIME = lifetime(AUTH_DEFAULTS.EMAIL_CHANGE_TTL_HOURS * 60);
|
|
2456
|
+
var TWO_FACTOR_CODE_LIFETIME = lifetime(AUTH_DEFAULTS.TWO_FACTOR_EMAIL_CODE_TTL_MINUTES);
|
|
2279
2457
|
function extractAppUrl(url) {
|
|
2280
2458
|
try {
|
|
2281
2459
|
const parsed = new URL(url);
|
|
@@ -2291,12 +2469,13 @@ function generateVerificationEmail(data, appName = "Your App", configuredAppUrl
|
|
|
2291
2469
|
const safeFirstName = firstName ? escapeHtml(firstName) : null;
|
|
2292
2470
|
const html = generateEmailTemplate({
|
|
2293
2471
|
heading: "",
|
|
2294
|
-
body: `Please verify that your email address is <strong>${escapeHtml(email)}</strong>, and that you entered it when signing up for ${escapeHtml(appName)}.`,
|
|
2472
|
+
body: `Please verify that your email address is <strong>${escapeHtml(email)}</strong>, and that you entered it when signing up for ${escapeHtml(appName)}. This link expires in ${VERIFICATION_LIFETIME}.`,
|
|
2295
2473
|
buttonText: "Verify Email",
|
|
2296
2474
|
buttonUrl: safeUrl,
|
|
2297
2475
|
welcomeMessage: safeFirstName ? `Hi ${safeFirstName}, Welcome to ${escapeHtml(appName)}!` : void 0,
|
|
2298
2476
|
footerNote: "",
|
|
2299
2477
|
appUrl,
|
|
2478
|
+
appName,
|
|
2300
2479
|
...branding
|
|
2301
2480
|
});
|
|
2302
2481
|
return {
|
|
@@ -2309,7 +2488,7 @@ Thank you for signing up for ${appName}! Please verify your email address by cli
|
|
|
2309
2488
|
|
|
2310
2489
|
${safeUrl}
|
|
2311
2490
|
|
|
2312
|
-
This link expires in
|
|
2491
|
+
This link expires in ${VERIFICATION_LIFETIME}.
|
|
2313
2492
|
|
|
2314
2493
|
If you didn't create an account with ${appName}, you can safely ignore this email.`,
|
|
2315
2494
|
tags: {
|
|
@@ -2327,11 +2506,12 @@ function generatePasswordResetEmail(data, appName = "Your App", configuredAppUrl
|
|
|
2327
2506
|
buttonText: "Reset Password",
|
|
2328
2507
|
buttonUrl: safeUrl,
|
|
2329
2508
|
securityWarning: `
|
|
2330
|
-
<strong>Link expires in
|
|
2509
|
+
<strong>Link expires in ${PASSWORD_RESET_LIFETIME}.</strong><br>
|
|
2331
2510
|
For security, this link can only be used once.
|
|
2332
2511
|
`,
|
|
2333
2512
|
footerNote: "If you didn't request this change, no action is needed. Your password will remain unchanged.",
|
|
2334
2513
|
appUrl,
|
|
2514
|
+
appName,
|
|
2335
2515
|
...branding
|
|
2336
2516
|
});
|
|
2337
2517
|
return {
|
|
@@ -2344,7 +2524,7 @@ We received a request to reset your password. Click the link below to create a n
|
|
|
2344
2524
|
|
|
2345
2525
|
${safeUrl}
|
|
2346
2526
|
|
|
2347
|
-
This link
|
|
2527
|
+
This link expires in ${PASSWORD_RESET_LIFETIME}.
|
|
2348
2528
|
|
|
2349
2529
|
If you didn't request a password reset, you can safely ignore this email.`,
|
|
2350
2530
|
tags: {
|
|
@@ -2362,11 +2542,12 @@ function generateEmailChangeConfirmation(data, appName = "Your App", configuredA
|
|
|
2362
2542
|
buttonText: "Confirm Email Change",
|
|
2363
2543
|
buttonUrl: safeUrl,
|
|
2364
2544
|
securityWarning: `
|
|
2365
|
-
<strong>This link expires in
|
|
2545
|
+
<strong>This link expires in ${EMAIL_CHANGE_LIFETIME}.</strong><br>
|
|
2366
2546
|
For security, this link can only be used once.
|
|
2367
2547
|
`,
|
|
2368
2548
|
footerNote: "If you didn't request this change, you can safely ignore this email.",
|
|
2369
2549
|
appUrl,
|
|
2550
|
+
appName,
|
|
2370
2551
|
...branding
|
|
2371
2552
|
});
|
|
2372
2553
|
return {
|
|
@@ -2381,7 +2562,7 @@ Click the link below to confirm this change:
|
|
|
2381
2562
|
|
|
2382
2563
|
${safeUrl}
|
|
2383
2564
|
|
|
2384
|
-
This link expires in
|
|
2565
|
+
This link expires in ${EMAIL_CHANGE_LIFETIME}.
|
|
2385
2566
|
|
|
2386
2567
|
If you didn't request this change, you can safely ignore this email.`,
|
|
2387
2568
|
tags: {
|
|
@@ -2405,6 +2586,7 @@ function generateEmailChangeNotification(data, appName = "Your App", configuredA
|
|
|
2405
2586
|
`,
|
|
2406
2587
|
footerNote: "If you didn't request this change, click the button above to cancel it.",
|
|
2407
2588
|
appUrl,
|
|
2589
|
+
appName,
|
|
2408
2590
|
...branding
|
|
2409
2591
|
});
|
|
2410
2592
|
return {
|
|
@@ -2434,13 +2616,14 @@ function generate2faCodeEmail(data, appName = "Your App", appUrl = "", branding)
|
|
|
2434
2616
|
<p>Hi ${safeFirstName},</p>
|
|
2435
2617
|
<p>Your verification code is:</p>
|
|
2436
2618
|
<p style="font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 24px 0; font-family: monospace;">${escapeHtml(code)}</p>
|
|
2437
|
-
<p>This code expires in
|
|
2619
|
+
<p>This code expires in ${TWO_FACTOR_CODE_LIFETIME}.</p>
|
|
2438
2620
|
<p>If you didn't request this code, you can safely ignore this email.</p>
|
|
2439
2621
|
`,
|
|
2440
2622
|
buttonText: "",
|
|
2441
2623
|
buttonUrl: "",
|
|
2442
2624
|
footerNote: "",
|
|
2443
2625
|
appUrl,
|
|
2626
|
+
appName,
|
|
2444
2627
|
...branding
|
|
2445
2628
|
});
|
|
2446
2629
|
return {
|
|
@@ -2453,7 +2636,7 @@ Your verification code is:
|
|
|
2453
2636
|
|
|
2454
2637
|
${code}
|
|
2455
2638
|
|
|
2456
|
-
This code expires in
|
|
2639
|
+
This code expires in ${TWO_FACTOR_CODE_LIFETIME}.
|
|
2457
2640
|
|
|
2458
2641
|
If you didn't request this code, you can safely ignore this email.
|
|
2459
2642
|
|
|
@@ -2476,6 +2659,7 @@ function generate2faEnabledEmail(data, appName = "Your App", appUrl = "", brandi
|
|
|
2476
2659
|
buttonUrl: "",
|
|
2477
2660
|
footerNote: "",
|
|
2478
2661
|
appUrl,
|
|
2662
|
+
appName,
|
|
2479
2663
|
...branding
|
|
2480
2664
|
});
|
|
2481
2665
|
return {
|
|
@@ -2506,6 +2690,7 @@ function generate2faDisabledEmail(data, appName = "Your App", appUrl = "", brand
|
|
|
2506
2690
|
buttonUrl: appUrl ? `${appUrl}/forgot-password` : "",
|
|
2507
2691
|
footerNote: "",
|
|
2508
2692
|
appUrl,
|
|
2693
|
+
appName,
|
|
2509
2694
|
...branding
|
|
2510
2695
|
});
|
|
2511
2696
|
return {
|
|
@@ -2722,7 +2907,7 @@ var EmailService = class {
|
|
|
2722
2907
|
const [user] = await db.select({
|
|
2723
2908
|
emailBounced: usersTable.emailBounced,
|
|
2724
2909
|
emailComplained: usersTable.emailComplained
|
|
2725
|
-
}).from(usersTable).where((0,
|
|
2910
|
+
}).from(usersTable).where((0, import_drizzle_orm7.eq)(usersTable.email, emailAddress.toLowerCase())).limit(1);
|
|
2726
2911
|
if (!user) {
|
|
2727
2912
|
return { blocked: false };
|
|
2728
2913
|
}
|
|
@@ -3171,7 +3356,7 @@ var EmailService = class {
|
|
|
3171
3356
|
var import_resend2 = require("resend");
|
|
3172
3357
|
|
|
3173
3358
|
// src/lib/email/webhook-handler.ts
|
|
3174
|
-
var
|
|
3359
|
+
var import_drizzle_orm8 = require("drizzle-orm");
|
|
3175
3360
|
async function handleWebhookEvent(payload, db, tables2) {
|
|
3176
3361
|
const { type, data } = payload;
|
|
3177
3362
|
const emailAddress = data.to[0];
|
|
@@ -3230,7 +3415,7 @@ async function handleBounce(emailAddress, emailId, bounce, db, usersTable) {
|
|
|
3230
3415
|
await db.update(usersTable).set({
|
|
3231
3416
|
emailBounced: true,
|
|
3232
3417
|
emailBouncedAt: /* @__PURE__ */ new Date()
|
|
3233
|
-
}).where((0,
|
|
3418
|
+
}).where((0, import_drizzle_orm8.eq)(usersTable.email, emailAddress.toLowerCase()));
|
|
3234
3419
|
logError(new Error("Email bounced"), {
|
|
3235
3420
|
context: "email_webhook",
|
|
3236
3421
|
emailAddress,
|
|
@@ -3246,7 +3431,7 @@ async function handleComplaint(emailAddress, emailId, db, usersTable) {
|
|
|
3246
3431
|
await db.update(usersTable).set({
|
|
3247
3432
|
emailComplained: true,
|
|
3248
3433
|
emailComplainedAt: /* @__PURE__ */ new Date()
|
|
3249
|
-
}).where((0,
|
|
3434
|
+
}).where((0, import_drizzle_orm8.eq)(usersTable.email, emailAddress.toLowerCase()));
|
|
3250
3435
|
logError(new Error("Email spam complaint received"), {
|
|
3251
3436
|
context: "email_webhook",
|
|
3252
3437
|
emailAddress,
|
|
@@ -3268,15 +3453,15 @@ async function handleFailure(emailAddress, emailId, failed) {
|
|
|
3268
3453
|
}
|
|
3269
3454
|
|
|
3270
3455
|
// src/lib/email/monitoring.ts
|
|
3271
|
-
var
|
|
3456
|
+
var import_drizzle_orm9 = require("drizzle-orm");
|
|
3272
3457
|
async function calculateBounceRate(db, emailEventsTable, hoursAgo = 24) {
|
|
3273
3458
|
const cutoff = new Date(Date.now() - hoursAgo * 60 * 60 * 1e3);
|
|
3274
3459
|
const [stats] = await db.select({
|
|
3275
|
-
totalSent:
|
|
3276
|
-
totalBounced:
|
|
3460
|
+
totalSent: import_drizzle_orm9.sql`count(*) filter (where event_type = 'email.sent')`.as("total_sent"),
|
|
3461
|
+
totalBounced: import_drizzle_orm9.sql`count(*) filter (where event_type = 'email.bounced')`.as(
|
|
3277
3462
|
"total_bounced"
|
|
3278
3463
|
)
|
|
3279
|
-
}).from(emailEventsTable).where(
|
|
3464
|
+
}).from(emailEventsTable).where(import_drizzle_orm9.sql`created_at > ${cutoff}`);
|
|
3280
3465
|
if (!stats || stats.totalSent === 0) {
|
|
3281
3466
|
return 0;
|
|
3282
3467
|
}
|
|
@@ -3295,11 +3480,11 @@ async function calculateBounceRate(db, emailEventsTable, hoursAgo = 24) {
|
|
|
3295
3480
|
async function calculateComplaintRate(db, emailEventsTable, hoursAgo = 24) {
|
|
3296
3481
|
const cutoff = new Date(Date.now() - hoursAgo * 60 * 60 * 1e3);
|
|
3297
3482
|
const [stats] = await db.select({
|
|
3298
|
-
totalSent:
|
|
3299
|
-
totalComplained:
|
|
3483
|
+
totalSent: import_drizzle_orm9.sql`count(*) filter (where event_type = 'email.sent')`.as("total_sent"),
|
|
3484
|
+
totalComplained: import_drizzle_orm9.sql`count(*) filter (where event_type = 'email.complained')`.as(
|
|
3300
3485
|
"total_complained"
|
|
3301
3486
|
)
|
|
3302
|
-
}).from(emailEventsTable).where(
|
|
3487
|
+
}).from(emailEventsTable).where(import_drizzle_orm9.sql`created_at > ${cutoff}`);
|
|
3303
3488
|
if (!stats || stats.totalSent === 0) {
|
|
3304
3489
|
return 0;
|
|
3305
3490
|
}
|
|
@@ -3316,16 +3501,34 @@ async function calculateComplaintRate(db, emailEventsTable, hoursAgo = 24) {
|
|
|
3316
3501
|
return complaintRate;
|
|
3317
3502
|
}
|
|
3318
3503
|
|
|
3504
|
+
// src/core/invitations.ts
|
|
3505
|
+
async function verifyInvitation(invitations, args) {
|
|
3506
|
+
if (!invitations || !args.token) return null;
|
|
3507
|
+
const email = args.email.toLowerCase();
|
|
3508
|
+
try {
|
|
3509
|
+
const match = await invitations.verify({ token: args.token, email, db: args.db, env: args.env });
|
|
3510
|
+
return match && match.email.toLowerCase() === email ? args.token : null;
|
|
3511
|
+
} catch (err) {
|
|
3512
|
+
logError(err, { context: "invitation_verify_failed", email });
|
|
3513
|
+
return null;
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
async function acceptInvitation(invitations, args) {
|
|
3517
|
+
if (!invitations?.onAccepted) return;
|
|
3518
|
+
try {
|
|
3519
|
+
await invitations.onAccepted({ token: args.token, user: args.user, db: args.db, env: args.env });
|
|
3520
|
+
} catch (err) {
|
|
3521
|
+
logError(err, { context: args.context, userId: args.user.id });
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3319
3525
|
// src/routes/schemas.ts
|
|
3320
3526
|
var import_zod_openapi = require("@hono/zod-openapi");
|
|
3321
3527
|
var signupRequestSchema = import_zod_openapi.z.object({
|
|
3322
3528
|
email: import_zod_openapi.z.string().email().openapi({ example: "user@example.com" }),
|
|
3323
|
-
|
|
3324
|
-
password: import_zod_openapi.z.string().min(8).optional().openapi({ example: "SecurePass123!" }),
|
|
3529
|
+
password: import_zod_openapi.z.string().min(8).openapi({ example: "SecurePass123!" }),
|
|
3325
3530
|
name: import_zod_openapi.z.string().min(1).optional().openapi({ example: "John Doe" }),
|
|
3326
|
-
turnstileToken: import_zod_openapi.z.string().
|
|
3327
|
-
// OAuth signup completion token (from /signup?oauth_token=...)
|
|
3328
|
-
oauthToken: import_zod_openapi.z.string().optional().openapi({ example: "a1b2c3d4-..." }),
|
|
3531
|
+
turnstileToken: import_zod_openapi.z.string().openapi({ example: "XXXX.DUMMY.TOKEN.XXXX" }),
|
|
3329
3532
|
// App invitation token (from /signup?token=...). When it validates for this
|
|
3330
3533
|
// email, the account is created already-verified with no verification email.
|
|
3331
3534
|
invitationToken: import_zod_openapi.z.string().optional().openapi({ example: "iIB8tDJJ..." })
|
|
@@ -3451,76 +3654,22 @@ var signupRoute = (0, import_zod_openapi2.createRoute)({
|
|
|
3451
3654
|
description: "Invalid input",
|
|
3452
3655
|
content: { "application/json": { schema: errorResponseSchema } }
|
|
3453
3656
|
},
|
|
3657
|
+
403: {
|
|
3658
|
+
description: "Sign-up is by invitation only and no valid invitation was given",
|
|
3659
|
+
content: { "application/json": { schema: errorResponseSchema } }
|
|
3660
|
+
},
|
|
3454
3661
|
409: {
|
|
3455
3662
|
description: "Email already registered",
|
|
3456
3663
|
content: { "application/json": { schema: errorResponseSchema } }
|
|
3457
3664
|
}
|
|
3458
3665
|
}
|
|
3459
3666
|
});
|
|
3667
|
+
var INVITATION_ONLY = "Sign-up is by invitation only";
|
|
3668
|
+
var INVITATION_INVALID = "This invitation is invalid, expired, or for a different email address";
|
|
3460
3669
|
var signupHandler = async (c) => {
|
|
3461
|
-
const { email, password, name, turnstileToken,
|
|
3462
|
-
const { db: database, schema, emailBranding, invitations } = getAuthContext(c);
|
|
3670
|
+
const { email, password, name, turnstileToken, invitationToken } = c.req.valid("json");
|
|
3671
|
+
const { db: database, schema, env: authEnv, emailBranding, invitations, signup } = getAuthContext(c);
|
|
3463
3672
|
const env = c.env;
|
|
3464
|
-
if (oauthToken) {
|
|
3465
|
-
const stored = await env.OAUTH_STATES.get(`oauth:signup:${oauthToken}`);
|
|
3466
|
-
if (!stored) {
|
|
3467
|
-
return problems.badRequest(c, "Invalid or expired OAuth token");
|
|
3468
|
-
}
|
|
3469
|
-
const {
|
|
3470
|
-
email: tokenEmail,
|
|
3471
|
-
provider,
|
|
3472
|
-
providerUserId
|
|
3473
|
-
} = JSON.parse(stored);
|
|
3474
|
-
if (email.toLowerCase() !== tokenEmail.toLowerCase()) {
|
|
3475
|
-
return problems.badRequest(c, "Email does not match OAuth token");
|
|
3476
|
-
}
|
|
3477
|
-
await env.OAUTH_STATES.delete(`oauth:signup:${oauthToken}`);
|
|
3478
|
-
const existingUser2 = await database.query.users.findFirst({
|
|
3479
|
-
where: (0, import_drizzle_orm9.eq)(schema.users.email, tokenEmail.toLowerCase())
|
|
3480
|
-
});
|
|
3481
|
-
if (existingUser2) {
|
|
3482
|
-
return problems.conflict(c, "Email already registered");
|
|
3483
|
-
}
|
|
3484
|
-
let hashedPassword2 = null;
|
|
3485
|
-
if (password) {
|
|
3486
|
-
const passwordValidation2 = await validatePasswordWithBreachCheck(password);
|
|
3487
|
-
if (!passwordValidation2.valid) {
|
|
3488
|
-
return problems.badRequest(c, passwordValidation2.error || "Invalid password");
|
|
3489
|
-
}
|
|
3490
|
-
hashedPassword2 = await hashPassword(password, env.PASSWORD_PEPPER_V1);
|
|
3491
|
-
}
|
|
3492
|
-
const [newUser2] = await database.insert(schema.users).values({
|
|
3493
|
-
email: tokenEmail.toLowerCase(),
|
|
3494
|
-
name: name || null,
|
|
3495
|
-
hashedPassword: hashedPassword2,
|
|
3496
|
-
pepperKid: hashedPassword2 ? "v1" : null,
|
|
3497
|
-
emailVerified: true,
|
|
3498
|
-
// Provider verified the email
|
|
3499
|
-
activatedAt: /* @__PURE__ */ new Date()
|
|
3500
|
-
}).returning();
|
|
3501
|
-
await database.insert(schema.oauthAccounts).values({ userId: newUser2.id, provider, providerUserId, email: tokenEmail.toLowerCase() }).onConflictDoNothing();
|
|
3502
|
-
const fingerprint2 = await generateFingerprint(c.req.raw);
|
|
3503
|
-
const ipAddress2 = getClientIp(c.req.raw);
|
|
3504
|
-
const sessionId2 = await createSession(database, { sessions: schema.sessions }, newUser2.id, fingerprint2, ipAddress2);
|
|
3505
|
-
logSecurityEvent("user_signup_oauth", "low", {
|
|
3506
|
-
userId: newUser2.id,
|
|
3507
|
-
email: newUser2.email,
|
|
3508
|
-
provider
|
|
3509
|
-
});
|
|
3510
|
-
c.header("Set-Cookie", setSessionCookie(sessionId2, env), { append: true });
|
|
3511
|
-
return c.json({
|
|
3512
|
-
user: {
|
|
3513
|
-
id: newUser2.id,
|
|
3514
|
-
email: newUser2.email,
|
|
3515
|
-
name: newUser2.name,
|
|
3516
|
-
emailVerified: true
|
|
3517
|
-
},
|
|
3518
|
-
redirect: "/"
|
|
3519
|
-
});
|
|
3520
|
-
}
|
|
3521
|
-
if (!password || !turnstileToken) {
|
|
3522
|
-
return problems.badRequest(c, "Password and captcha are required");
|
|
3523
|
-
}
|
|
3524
3673
|
const turnstileValid = await verifyTurnstileToken(
|
|
3525
3674
|
turnstileToken,
|
|
3526
3675
|
env.TURNSTILE_SECRET_KEY,
|
|
@@ -3530,8 +3679,22 @@ var signupHandler = async (c) => {
|
|
|
3530
3679
|
if (!turnstileValid) {
|
|
3531
3680
|
return problems.badRequest(c, "Invalid captcha");
|
|
3532
3681
|
}
|
|
3682
|
+
const invited = await verifyInvitation(invitations, {
|
|
3683
|
+
token: invitationToken,
|
|
3684
|
+
email,
|
|
3685
|
+
db: database,
|
|
3686
|
+
env: authEnv
|
|
3687
|
+
});
|
|
3688
|
+
if (invitationToken && invitations && !invited && signup !== "invitation") {
|
|
3689
|
+
return problems.badRequest(c, INVITATION_INVALID);
|
|
3690
|
+
}
|
|
3691
|
+
if (signup === "invitation" && !invited) {
|
|
3692
|
+
logSecurityEvent("signup_refused_no_invitation", "medium", { email: email.toLowerCase() });
|
|
3693
|
+
return problems.forbidden(c, invitationToken ? INVITATION_INVALID : INVITATION_ONLY);
|
|
3694
|
+
}
|
|
3695
|
+
const invitedVerified = invited !== null;
|
|
3533
3696
|
const existingUser = await database.query.users.findFirst({
|
|
3534
|
-
where: (0,
|
|
3697
|
+
where: (0, import_drizzle_orm10.eq)(schema.users.email, email.toLowerCase())
|
|
3535
3698
|
});
|
|
3536
3699
|
if (existingUser) {
|
|
3537
3700
|
return problems.conflict(c, "Email already registered");
|
|
@@ -3541,22 +3704,6 @@ var signupHandler = async (c) => {
|
|
|
3541
3704
|
return problems.badRequest(c, passwordValidation.error || "Invalid password");
|
|
3542
3705
|
}
|
|
3543
3706
|
const hashedPassword = await hashPassword(password, env.PASSWORD_PEPPER_V1);
|
|
3544
|
-
let invitedVerified = false;
|
|
3545
|
-
if (invitationToken && invitations) {
|
|
3546
|
-
const match = await invitations.verify({
|
|
3547
|
-
token: invitationToken,
|
|
3548
|
-
email: email.toLowerCase(),
|
|
3549
|
-
db: database,
|
|
3550
|
-
env
|
|
3551
|
-
});
|
|
3552
|
-
if (!match || match.email.toLowerCase() !== email.toLowerCase()) {
|
|
3553
|
-
return problems.badRequest(
|
|
3554
|
-
c,
|
|
3555
|
-
"This invitation is invalid, expired, or for a different email address"
|
|
3556
|
-
);
|
|
3557
|
-
}
|
|
3558
|
-
invitedVerified = true;
|
|
3559
|
-
}
|
|
3560
3707
|
const [newUser] = await database.insert(schema.users).values({
|
|
3561
3708
|
email: email.toLowerCase(),
|
|
3562
3709
|
name: name || null,
|
|
@@ -3568,7 +3715,7 @@ var signupHandler = async (c) => {
|
|
|
3568
3715
|
if (!invitedVerified) {
|
|
3569
3716
|
const emailToken = generateSecureToken(32);
|
|
3570
3717
|
const tokenHash = await hashToken(emailToken);
|
|
3571
|
-
const expiresAt = Date.now() +
|
|
3718
|
+
const expiresAt = Date.now() + AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60 * 60 * 1e3;
|
|
3572
3719
|
await database.insert(schema.emailVerificationTokens).values({
|
|
3573
3720
|
tokenHash,
|
|
3574
3721
|
userId: newUser.id,
|
|
@@ -3589,17 +3736,14 @@ var signupHandler = async (c) => {
|
|
|
3589
3736
|
schema.users
|
|
3590
3737
|
);
|
|
3591
3738
|
}
|
|
3592
|
-
if (
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
} catch (err) {
|
|
3601
|
-
logError(err, { context: "signup_invitation_onAccepted_failed", userId: newUser.id });
|
|
3602
|
-
}
|
|
3739
|
+
if (invited) {
|
|
3740
|
+
await acceptInvitation(invitations, {
|
|
3741
|
+
token: invited,
|
|
3742
|
+
user: { id: newUser.id, email: newUser.email, name: newUser.name },
|
|
3743
|
+
db: database,
|
|
3744
|
+
env: authEnv,
|
|
3745
|
+
context: "signup_invitation_onAccepted_failed"
|
|
3746
|
+
});
|
|
3603
3747
|
}
|
|
3604
3748
|
const fingerprint = await generateFingerprint(c.req.raw);
|
|
3605
3749
|
const ipAddress = getClientIp(c.req.raw);
|
|
@@ -3632,25 +3776,25 @@ var signupMiddleware = [
|
|
|
3632
3776
|
|
|
3633
3777
|
// src/routes/login.ts
|
|
3634
3778
|
var import_zod_openapi3 = require("@hono/zod-openapi");
|
|
3635
|
-
var
|
|
3779
|
+
var import_drizzle_orm12 = require("drizzle-orm");
|
|
3636
3780
|
var import_cookie2 = require("hono/cookie");
|
|
3637
3781
|
|
|
3638
3782
|
// src/routes/2fa/helpers.ts
|
|
3639
|
-
var
|
|
3783
|
+
var import_drizzle_orm11 = require("drizzle-orm");
|
|
3640
3784
|
async function getUserEnabled2faMethods(database, userId, user2faMethodsTable) {
|
|
3641
3785
|
return database.select({
|
|
3642
3786
|
method: user2faMethodsTable.method,
|
|
3643
3787
|
isPrimary: user2faMethodsTable.isPrimary,
|
|
3644
3788
|
totpSecret: user2faMethodsTable.totpSecret,
|
|
3645
3789
|
lastTotpCounter: user2faMethodsTable.lastTotpCounter
|
|
3646
|
-
}).from(user2faMethodsTable).where((0,
|
|
3790
|
+
}).from(user2faMethodsTable).where((0, import_drizzle_orm11.eq)(user2faMethodsTable.userId, userId));
|
|
3647
3791
|
}
|
|
3648
3792
|
function generateEmailOtp() {
|
|
3649
3793
|
const bytes = crypto.getRandomValues(new Uint8Array(4));
|
|
3650
3794
|
const num = (bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]) >>> 0;
|
|
3651
3795
|
return String(num % 1e6).padStart(6, "0");
|
|
3652
3796
|
}
|
|
3653
|
-
var EMAIL_CODE_TTL_SECONDS =
|
|
3797
|
+
var EMAIL_CODE_TTL_SECONDS = AUTH_DEFAULTS.TWO_FACTOR_EMAIL_CODE_TTL_MINUTES * 60;
|
|
3654
3798
|
async function sendEmailChallengeCode(env, database, usersTable, user, emailBranding) {
|
|
3655
3799
|
const code = generateEmailOtp();
|
|
3656
3800
|
await env.OAUTH_STATES.put(`email_2fa_challenge:${user.id}`, await hashToken(code), {
|
|
@@ -3677,7 +3821,7 @@ async function verifyAny2faCode(database, env, userId, code, method, tables2) {
|
|
|
3677
3821
|
const secret = await decryptTotpSecret(totpMethod.totpSecret, env.TOTP_ENCRYPTION_KEY);
|
|
3678
3822
|
const result = verifyTotpCode(secret, code, totpMethod.lastTotpCounter);
|
|
3679
3823
|
if (result.valid) {
|
|
3680
|
-
await database.update(user2faMethodsTable).set({ lastTotpCounter: result.counter }).where((0,
|
|
3824
|
+
await database.update(user2faMethodsTable).set({ lastTotpCounter: result.counter }).where((0, import_drizzle_orm11.and)((0, import_drizzle_orm11.eq)(user2faMethodsTable.userId, userId), (0, import_drizzle_orm11.eq)(user2faMethodsTable.method, "totp")));
|
|
3681
3825
|
return { valid: true };
|
|
3682
3826
|
}
|
|
3683
3827
|
return { valid: false };
|
|
@@ -3689,18 +3833,18 @@ async function verifyAny2faCode(database, env, userId, code, method, tables2) {
|
|
|
3689
3833
|
const kvKey = `email_2fa_challenge:${userId}`;
|
|
3690
3834
|
const storedHash = await env.OAUTH_STATES.get(kvKey);
|
|
3691
3835
|
if (!storedHash) return { valid: false };
|
|
3692
|
-
const
|
|
3693
|
-
if (
|
|
3836
|
+
const codeHash2 = await hashToken(code);
|
|
3837
|
+
if (codeHash2 === storedHash) {
|
|
3694
3838
|
await env.OAUTH_STATES.delete(kvKey);
|
|
3695
3839
|
return { valid: true };
|
|
3696
3840
|
}
|
|
3697
3841
|
return { valid: false };
|
|
3698
3842
|
}
|
|
3699
3843
|
if (method === "backup") {
|
|
3700
|
-
const backupCodes = await database.select({ id: userBackupCodesTable.id, codeHash: userBackupCodesTable.codeHash }).from(userBackupCodesTable).where((0,
|
|
3844
|
+
const backupCodes = await database.select({ id: userBackupCodesTable.id, codeHash: userBackupCodesTable.codeHash }).from(userBackupCodesTable).where((0, import_drizzle_orm11.and)((0, import_drizzle_orm11.eq)(userBackupCodesTable.userId, userId), (0, import_drizzle_orm11.isNull)(userBackupCodesTable.usedAt)));
|
|
3701
3845
|
for (const bc of backupCodes) {
|
|
3702
3846
|
if (await verifyBackupCode(code, bc.codeHash)) {
|
|
3703
|
-
await database.update(userBackupCodesTable).set({ usedAt: Date.now() }).where((0,
|
|
3847
|
+
await database.update(userBackupCodesTable).set({ usedAt: Date.now() }).where((0, import_drizzle_orm11.eq)(userBackupCodesTable.id, bc.id));
|
|
3704
3848
|
logSecurityEvent("2fa_backup_code_used", "medium", { userId, codeId: bc.id });
|
|
3705
3849
|
return { valid: true };
|
|
3706
3850
|
}
|
|
@@ -3756,7 +3900,7 @@ var loginHandler = async (c) => {
|
|
|
3756
3900
|
return problems.badRequest(c, "Invalid captcha");
|
|
3757
3901
|
}
|
|
3758
3902
|
const user = await database.query.users.findFirst({
|
|
3759
|
-
where: (0,
|
|
3903
|
+
where: (0, import_drizzle_orm12.eq)(schema.users.email, email.toLowerCase())
|
|
3760
3904
|
});
|
|
3761
3905
|
if (!user) {
|
|
3762
3906
|
return problems.unauthorized(c, "Invalid email or password");
|
|
@@ -3865,7 +4009,7 @@ var loginMiddleware = [
|
|
|
3865
4009
|
|
|
3866
4010
|
// src/routes/logout.ts
|
|
3867
4011
|
var import_zod_openapi4 = require("@hono/zod-openapi");
|
|
3868
|
-
var
|
|
4012
|
+
var import_drizzle_orm13 = require("drizzle-orm");
|
|
3869
4013
|
var logoutRoute = (0, import_zod_openapi4.createRoute)({
|
|
3870
4014
|
method: "post",
|
|
3871
4015
|
path: "/logout",
|
|
@@ -3888,7 +4032,7 @@ var logoutHandler = async (c) => {
|
|
|
3888
4032
|
const sessionIdMatch = cookieHeader.match(cookiePattern);
|
|
3889
4033
|
if (sessionIdMatch) {
|
|
3890
4034
|
const sessionId = sessionIdMatch[1];
|
|
3891
|
-
await database.delete(schema.sessions).where((0,
|
|
4035
|
+
await database.delete(schema.sessions).where((0, import_drizzle_orm13.eq)(schema.sessions.id, sessionId));
|
|
3892
4036
|
}
|
|
3893
4037
|
}
|
|
3894
4038
|
c.header("Set-Cookie", clearSessionCookie(c.env), { append: true });
|
|
@@ -3897,7 +4041,7 @@ var logoutHandler = async (c) => {
|
|
|
3897
4041
|
|
|
3898
4042
|
// src/routes/me.ts
|
|
3899
4043
|
var import_zod_openapi5 = require("@hono/zod-openapi");
|
|
3900
|
-
var
|
|
4044
|
+
var import_drizzle_orm14 = require("drizzle-orm");
|
|
3901
4045
|
var meRoute = (0, import_zod_openapi5.createRoute)({
|
|
3902
4046
|
method: "get",
|
|
3903
4047
|
path: "/me",
|
|
@@ -3925,7 +4069,7 @@ var meHandler = async (c) => {
|
|
|
3925
4069
|
if (!userId) return problems.unauthorized(c, "Not authenticated");
|
|
3926
4070
|
const { db: database, schema } = getAuthContext(c);
|
|
3927
4071
|
const user = await database.query.users.findFirst({
|
|
3928
|
-
where: (0,
|
|
4072
|
+
where: (0, import_drizzle_orm14.eq)(schema.users.id, userId)
|
|
3929
4073
|
});
|
|
3930
4074
|
if (!user) {
|
|
3931
4075
|
return problems.notFound(c, "User not found");
|
|
@@ -3951,7 +4095,7 @@ var meMiddleware = [requireAuth];
|
|
|
3951
4095
|
|
|
3952
4096
|
// src/routes/verify-email.ts
|
|
3953
4097
|
var import_zod_openapi6 = require("@hono/zod-openapi");
|
|
3954
|
-
var
|
|
4098
|
+
var import_drizzle_orm15 = require("drizzle-orm");
|
|
3955
4099
|
var verifyEmailRoute = (0, import_zod_openapi6.createRoute)({
|
|
3956
4100
|
method: "post",
|
|
3957
4101
|
path: "/verify-email",
|
|
@@ -3980,17 +4124,17 @@ var verifyEmailHandler = async (c) => {
|
|
|
3980
4124
|
const { db: database, schema } = getAuthContext(c);
|
|
3981
4125
|
const tokenHash = await hashToken(token);
|
|
3982
4126
|
const verificationToken = await database.query.emailVerificationTokens.findFirst({
|
|
3983
|
-
where: (0,
|
|
4127
|
+
where: (0, import_drizzle_orm15.eq)(schema.emailVerificationTokens.tokenHash, tokenHash)
|
|
3984
4128
|
});
|
|
3985
4129
|
if (!verificationToken) {
|
|
3986
4130
|
return problems.badRequest(c, "Invalid or expired verification token");
|
|
3987
4131
|
}
|
|
3988
4132
|
if (verificationToken.expiresAt < Date.now()) {
|
|
3989
|
-
await database.delete(schema.emailVerificationTokens).where((0,
|
|
4133
|
+
await database.delete(schema.emailVerificationTokens).where((0, import_drizzle_orm15.eq)(schema.emailVerificationTokens.tokenHash, tokenHash));
|
|
3990
4134
|
return problems.badRequest(c, "Verification token has expired");
|
|
3991
4135
|
}
|
|
3992
|
-
await database.update(schema.users).set({ emailVerified: true }).where((0,
|
|
3993
|
-
await database.delete(schema.emailVerificationTokens).where((0,
|
|
4136
|
+
await database.update(schema.users).set({ emailVerified: true }).where((0, import_drizzle_orm15.eq)(schema.users.id, verificationToken.userId));
|
|
4137
|
+
await database.delete(schema.emailVerificationTokens).where((0, import_drizzle_orm15.eq)(schema.emailVerificationTokens.tokenHash, tokenHash));
|
|
3994
4138
|
logSecurityEvent("email_verified", "low", { userId: verificationToken.userId });
|
|
3995
4139
|
return c.json({ success: true, redirect: "/" });
|
|
3996
4140
|
};
|
|
@@ -3999,18 +4143,18 @@ var verifyEmailHandler = async (c) => {
|
|
|
3999
4143
|
var import_zod_openapi7 = require("@hono/zod-openapi");
|
|
4000
4144
|
|
|
4001
4145
|
// src/core/password-reset.ts
|
|
4002
|
-
var
|
|
4146
|
+
var import_drizzle_orm16 = require("drizzle-orm");
|
|
4003
4147
|
var TOKEN_EXPIRATION_MS = AUTH_DEFAULTS.PASSWORD_RESET_TTL_MINUTES * 60 * 1e3;
|
|
4004
4148
|
async function createPasswordResetToken(db, tables2, email) {
|
|
4005
4149
|
const { users, passwordResetTokens } = tables2;
|
|
4006
|
-
const [user] = await db.select({ id: users.id, emailVerified: users.emailVerified }).from(users).where((0,
|
|
4150
|
+
const [user] = await db.select({ id: users.id, emailVerified: users.emailVerified }).from(users).where((0, import_drizzle_orm16.eq)(users.email, email.toLowerCase())).limit(1);
|
|
4007
4151
|
if (!user) {
|
|
4008
4152
|
return null;
|
|
4009
4153
|
}
|
|
4010
4154
|
if (!user.emailVerified) {
|
|
4011
4155
|
return null;
|
|
4012
4156
|
}
|
|
4013
|
-
await db.delete(passwordResetTokens).where((0,
|
|
4157
|
+
await db.delete(passwordResetTokens).where((0, import_drizzle_orm16.eq)(passwordResetTokens.userId, user.id));
|
|
4014
4158
|
const token = generateSecureToken(64);
|
|
4015
4159
|
const tokenHash = await hashToken(token);
|
|
4016
4160
|
const expiresAt = Date.now() + TOKEN_EXPIRATION_MS;
|
|
@@ -4032,10 +4176,10 @@ async function validateResetToken(db, tables2, token) {
|
|
|
4032
4176
|
used: passwordResetTokens.used,
|
|
4033
4177
|
expiresAt: passwordResetTokens.expiresAt
|
|
4034
4178
|
}).from(passwordResetTokens).where(
|
|
4035
|
-
(0,
|
|
4036
|
-
(0,
|
|
4037
|
-
(0,
|
|
4038
|
-
(0,
|
|
4179
|
+
(0, import_drizzle_orm16.and)(
|
|
4180
|
+
(0, import_drizzle_orm16.eq)(passwordResetTokens.tokenHash, tokenHash),
|
|
4181
|
+
(0, import_drizzle_orm16.eq)(passwordResetTokens.used, false),
|
|
4182
|
+
(0, import_drizzle_orm16.gt)(passwordResetTokens.expiresAt, Date.now())
|
|
4039
4183
|
)
|
|
4040
4184
|
).limit(1);
|
|
4041
4185
|
if (!resetToken) {
|
|
@@ -4056,11 +4200,11 @@ async function resetPassword(db, tables2, token, newPassword, pepper, pepperKid
|
|
|
4056
4200
|
await db.update(users).set({
|
|
4057
4201
|
hashedPassword,
|
|
4058
4202
|
pepperKid,
|
|
4059
|
-
sessionVersion:
|
|
4060
|
-
}).where((0,
|
|
4203
|
+
sessionVersion: import_drizzle_orm16.sql`${users.sessionVersion} + 1`
|
|
4204
|
+
}).where((0, import_drizzle_orm16.eq)(users.id, userId));
|
|
4061
4205
|
const tokenHash = await hashToken(token);
|
|
4062
|
-
await db.update(passwordResetTokens).set({ used: true }).where((0,
|
|
4063
|
-
await db.delete(sessions).where((0,
|
|
4206
|
+
await db.update(passwordResetTokens).set({ used: true }).where((0, import_drizzle_orm16.eq)(passwordResetTokens.tokenHash, tokenHash));
|
|
4207
|
+
await db.delete(sessions).where((0, import_drizzle_orm16.eq)(sessions.userId, userId));
|
|
4064
4208
|
logger_default.info("Password reset successful", { userId });
|
|
4065
4209
|
return { success: true };
|
|
4066
4210
|
}
|
|
@@ -4208,14 +4352,14 @@ var resetPasswordMiddleware = [
|
|
|
4208
4352
|
var import_zod_openapi9 = require("@hono/zod-openapi");
|
|
4209
4353
|
|
|
4210
4354
|
// src/core/change-password.ts
|
|
4211
|
-
var
|
|
4355
|
+
var import_drizzle_orm17 = require("drizzle-orm");
|
|
4212
4356
|
async function changePassword(params) {
|
|
4213
4357
|
const { userId, currentPassword, newPassword, currentSessionId, pepper, db, tables: tables2 } = params;
|
|
4214
4358
|
const { users, sessions } = tables2;
|
|
4215
4359
|
const [user] = await db.select({
|
|
4216
4360
|
id: users.id,
|
|
4217
4361
|
hashedPassword: users.hashedPassword
|
|
4218
|
-
}).from(users).where((0,
|
|
4362
|
+
}).from(users).where((0, import_drizzle_orm17.eq)(users.id, userId)).limit(1);
|
|
4219
4363
|
if (!user || !user.hashedPassword) {
|
|
4220
4364
|
return { success: false, error: "User not found" };
|
|
4221
4365
|
}
|
|
@@ -4231,8 +4375,8 @@ async function changePassword(params) {
|
|
|
4231
4375
|
await db.update(users).set({
|
|
4232
4376
|
hashedPassword: newHashedPassword,
|
|
4233
4377
|
updatedAt: /* @__PURE__ */ new Date()
|
|
4234
|
-
}).where((0,
|
|
4235
|
-
await db.delete(sessions).where((0,
|
|
4378
|
+
}).where((0, import_drizzle_orm17.eq)(users.id, userId));
|
|
4379
|
+
await db.delete(sessions).where((0, import_drizzle_orm17.and)((0, import_drizzle_orm17.eq)(sessions.userId, userId), (0, import_drizzle_orm17.ne)(sessions.id, currentSessionId)));
|
|
4236
4380
|
logSecurityEvent("password_changed", "low", {
|
|
4237
4381
|
userId
|
|
4238
4382
|
});
|
|
@@ -4505,7 +4649,7 @@ var cancelEmailChangeHandler = async (c) => {
|
|
|
4505
4649
|
// src/routes/delete-account.ts
|
|
4506
4650
|
var import_zod_openapi17 = require("@hono/zod-openapi");
|
|
4507
4651
|
var import_zod_openapi18 = require("@hono/zod-openapi");
|
|
4508
|
-
var
|
|
4652
|
+
var import_drizzle_orm18 = require("drizzle-orm");
|
|
4509
4653
|
var deleteAccountRequestSchema = import_zod_openapi18.z.object({
|
|
4510
4654
|
password: import_zod_openapi18.z.string().min(1).openapi({ example: "CurrentPass123!" })
|
|
4511
4655
|
}).openapi("DeleteAccountRequest");
|
|
@@ -4553,7 +4697,7 @@ var deleteAccountHandler = async (c) => {
|
|
|
4553
4697
|
const [user] = await database.select({
|
|
4554
4698
|
id: schema.users.id,
|
|
4555
4699
|
hashedPassword: schema.users.hashedPassword
|
|
4556
|
-
}).from(schema.users).where((0,
|
|
4700
|
+
}).from(schema.users).where((0, import_drizzle_orm18.eq)(schema.users.id, userId)).limit(1);
|
|
4557
4701
|
if (!user || !user.hashedPassword) {
|
|
4558
4702
|
return problems.unauthorized(c, "Invalid credentials");
|
|
4559
4703
|
}
|
|
@@ -4568,8 +4712,8 @@ var deleteAccountHandler = async (c) => {
|
|
|
4568
4712
|
}
|
|
4569
4713
|
const now = /* @__PURE__ */ new Date();
|
|
4570
4714
|
const purgeAt = new Date(now.getTime() + PURGE_WINDOW_DAYS * 24 * 60 * 60 * 1e3);
|
|
4571
|
-
await database.update(schema.users).set({ deletedAt: now, scheduledPurgeAt: purgeAt, updatedAt: now }).where((0,
|
|
4572
|
-
await database.delete(schema.sessions).where((0,
|
|
4715
|
+
await database.update(schema.users).set({ deletedAt: now, scheduledPurgeAt: purgeAt, updatedAt: now }).where((0, import_drizzle_orm18.eq)(schema.users.id, userId));
|
|
4716
|
+
await database.delete(schema.sessions).where((0, import_drizzle_orm18.eq)(schema.sessions.userId, userId));
|
|
4573
4717
|
logSecurityEvent("account_deletion_requested", "high", { userId });
|
|
4574
4718
|
return c.json(
|
|
4575
4719
|
{
|
|
@@ -4590,7 +4734,7 @@ var deleteAccountMiddleware = [requireAuth];
|
|
|
4590
4734
|
// src/routes/refresh.ts
|
|
4591
4735
|
var import_zod_openapi19 = require("@hono/zod-openapi");
|
|
4592
4736
|
var import_zod_openapi20 = require("@hono/zod-openapi");
|
|
4593
|
-
var
|
|
4737
|
+
var import_drizzle_orm19 = require("drizzle-orm");
|
|
4594
4738
|
var refreshRequestSchema = import_zod_openapi20.z.object({
|
|
4595
4739
|
refreshToken: import_zod_openapi20.z.string().min(1).openapi({ example: "abc123..." })
|
|
4596
4740
|
}).openapi("RefreshRequest");
|
|
@@ -4630,7 +4774,7 @@ var refreshHandler = async (c) => {
|
|
|
4630
4774
|
expiresAt: schema.sessions.expiresAt,
|
|
4631
4775
|
fingerprint: schema.sessions.fingerprint,
|
|
4632
4776
|
ipAddress: schema.sessions.ipAddress
|
|
4633
|
-
}).from(schema.sessions).where((0,
|
|
4777
|
+
}).from(schema.sessions).where((0, import_drizzle_orm19.and)((0, import_drizzle_orm19.eq)(schema.sessions.id, refreshToken), (0, import_drizzle_orm19.gt)(schema.sessions.expiresAt, Date.now()))).limit(1);
|
|
4634
4778
|
if (!session) {
|
|
4635
4779
|
return problems.unauthorized(c, "Invalid or expired refresh token");
|
|
4636
4780
|
}
|
|
@@ -4653,7 +4797,7 @@ var refreshMiddleware = [];
|
|
|
4653
4797
|
// src/routes/resend-verification.ts
|
|
4654
4798
|
var import_zod_openapi21 = require("@hono/zod-openapi");
|
|
4655
4799
|
var import_zod_openapi22 = require("@hono/zod-openapi");
|
|
4656
|
-
var
|
|
4800
|
+
var import_drizzle_orm20 = require("drizzle-orm");
|
|
4657
4801
|
var resendVerificationResponseSchema = import_zod_openapi22.z.object({
|
|
4658
4802
|
success: import_zod_openapi22.z.boolean().openapi({ example: true }),
|
|
4659
4803
|
message: import_zod_openapi22.z.string().openapi({ example: "Verification email sent" })
|
|
@@ -4698,17 +4842,17 @@ var resendVerificationHandler = async (c) => {
|
|
|
4698
4842
|
email: schema.users.email,
|
|
4699
4843
|
name: schema.users.name,
|
|
4700
4844
|
emailVerified: schema.users.emailVerified
|
|
4701
|
-
}).from(schema.users).where((0,
|
|
4845
|
+
}).from(schema.users).where((0, import_drizzle_orm20.eq)(schema.users.id, userId)).limit(1);
|
|
4702
4846
|
if (!user) {
|
|
4703
4847
|
return problems.unauthorized(c, "User not found");
|
|
4704
4848
|
}
|
|
4705
4849
|
if (user.emailVerified) {
|
|
4706
4850
|
return problems.badRequest(c, "Email is already verified");
|
|
4707
4851
|
}
|
|
4708
|
-
await db.delete(schema.emailVerificationTokens).where((0,
|
|
4852
|
+
await db.delete(schema.emailVerificationTokens).where((0, import_drizzle_orm20.eq)(schema.emailVerificationTokens.userId, userId));
|
|
4709
4853
|
const emailToken = generateSecureToken(32);
|
|
4710
4854
|
const tokenHash = await hashToken(emailToken);
|
|
4711
|
-
const expiresAt = Date.now() +
|
|
4855
|
+
const expiresAt = Date.now() + AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60 * 60 * 1e3;
|
|
4712
4856
|
await db.insert(schema.emailVerificationTokens).values({
|
|
4713
4857
|
tokenHash,
|
|
4714
4858
|
userId: user.id,
|
|
@@ -4750,7 +4894,7 @@ var import_zod_openapi36 = require("@hono/zod-openapi");
|
|
|
4750
4894
|
|
|
4751
4895
|
// src/routes/2fa/status.ts
|
|
4752
4896
|
var import_zod_openapi24 = require("@hono/zod-openapi");
|
|
4753
|
-
var
|
|
4897
|
+
var import_drizzle_orm21 = require("drizzle-orm");
|
|
4754
4898
|
|
|
4755
4899
|
// src/routes/2fa/schemas.ts
|
|
4756
4900
|
var import_zod_openapi23 = require("@hono/zod-openapi");
|
|
@@ -4911,7 +5055,7 @@ var statusHandler = async (c) => {
|
|
|
4911
5055
|
if (!userId) return problems.unauthorized(c);
|
|
4912
5056
|
const { db, schema } = getAuthContext(c);
|
|
4913
5057
|
const methods = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4914
|
-
const backupCodes = await db.select({ id: schema.userBackupCodes.id }).from(schema.userBackupCodes).where((0,
|
|
5058
|
+
const backupCodes = await db.select({ id: schema.userBackupCodes.id }).from(schema.userBackupCodes).where((0, import_drizzle_orm21.and)((0, import_drizzle_orm21.eq)(schema.userBackupCodes.userId, userId), (0, import_drizzle_orm21.isNull)(schema.userBackupCodes.usedAt)));
|
|
4915
5059
|
c.header("Cache-Control", "private, max-age=60");
|
|
4916
5060
|
c.header("Vary", "Cookie");
|
|
4917
5061
|
return c.json({
|
|
@@ -4923,7 +5067,7 @@ var statusHandler = async (c) => {
|
|
|
4923
5067
|
|
|
4924
5068
|
// src/routes/2fa/totp-setup.ts
|
|
4925
5069
|
var import_zod_openapi25 = require("@hono/zod-openapi");
|
|
4926
|
-
var
|
|
5070
|
+
var import_drizzle_orm22 = require("drizzle-orm");
|
|
4927
5071
|
var totpSetupRoute = (0, import_zod_openapi25.createRoute)({
|
|
4928
5072
|
method: "post",
|
|
4929
5073
|
path: "/totp/setup",
|
|
@@ -4953,11 +5097,11 @@ var totpSetupHandler = async (c) => {
|
|
|
4953
5097
|
const userId = c.get("userId");
|
|
4954
5098
|
if (!userId) return problems.unauthorized(c);
|
|
4955
5099
|
const { db, schema, env } = getAuthContext(c);
|
|
4956
|
-
const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where((0,
|
|
5100
|
+
const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where((0, import_drizzle_orm22.and)((0, import_drizzle_orm22.eq)(schema.user2faMethods.userId, userId), (0, import_drizzle_orm22.eq)(schema.user2faMethods.method, "totp"))).limit(1);
|
|
4957
5101
|
if (existing.length > 0) {
|
|
4958
5102
|
return problems.badRequest(c, "TOTP already enabled");
|
|
4959
5103
|
}
|
|
4960
|
-
const [user] = await db.select({ email: schema.users.email }).from(schema.users).where((0,
|
|
5104
|
+
const [user] = await db.select({ email: schema.users.email }).from(schema.users).where((0, import_drizzle_orm22.eq)(schema.users.id, userId)).limit(1);
|
|
4961
5105
|
if (!user) return problems.notFound(c, "User not found");
|
|
4962
5106
|
const secret = generateTotpSecret();
|
|
4963
5107
|
const qrCodeUri = generateQrCodeUri(secret, user.email);
|
|
@@ -4968,7 +5112,7 @@ var totpSetupHandler = async (c) => {
|
|
|
4968
5112
|
|
|
4969
5113
|
// src/routes/2fa/totp-verify.ts
|
|
4970
5114
|
var import_zod_openapi26 = require("@hono/zod-openapi");
|
|
4971
|
-
var
|
|
5115
|
+
var import_drizzle_orm23 = require("drizzle-orm");
|
|
4972
5116
|
var totpVerifyRoute = (0, import_zod_openapi26.createRoute)({
|
|
4973
5117
|
method: "post",
|
|
4974
5118
|
path: "/totp/verify",
|
|
@@ -5014,7 +5158,7 @@ var totpVerifyHandler = async (c) => {
|
|
|
5014
5158
|
logSecurityEvent("2fa_totp_setup_failed", "medium", { userId });
|
|
5015
5159
|
return problems.badRequest(c, "Invalid code");
|
|
5016
5160
|
}
|
|
5017
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0,
|
|
5161
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0, import_drizzle_orm23.eq)(schema.users.id, userId)).limit(1);
|
|
5018
5162
|
const encryptionKey = env.TOTP_ENCRYPTION_KEY;
|
|
5019
5163
|
if (!encryptionKey) {
|
|
5020
5164
|
logger_default.error("TOTP_ENCRYPTION_KEY not configured");
|
|
@@ -5062,7 +5206,7 @@ var totpVerifyHandler = async (c) => {
|
|
|
5062
5206
|
|
|
5063
5207
|
// src/routes/2fa/totp-disable.ts
|
|
5064
5208
|
var import_zod_openapi27 = require("@hono/zod-openapi");
|
|
5065
|
-
var
|
|
5209
|
+
var import_drizzle_orm24 = require("drizzle-orm");
|
|
5066
5210
|
var totpDisableRoute = (0, import_zod_openapi27.createRoute)({
|
|
5067
5211
|
method: "post",
|
|
5068
5212
|
path: "/totp/disable",
|
|
@@ -5105,13 +5249,13 @@ var totpDisableHandler = async (c) => {
|
|
|
5105
5249
|
logSecurityEvent("2fa_disable_failed", "medium", { userId, method: "totp" });
|
|
5106
5250
|
return problems.badRequest(c, "Invalid verification code");
|
|
5107
5251
|
}
|
|
5108
|
-
await db.delete(schema.user2faMethods).where((0,
|
|
5252
|
+
await db.delete(schema.user2faMethods).where((0, import_drizzle_orm24.and)((0, import_drizzle_orm24.eq)(schema.user2faMethods.userId, userId), (0, import_drizzle_orm24.eq)(schema.user2faMethods.method, "totp")));
|
|
5109
5253
|
const remaining = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
5110
5254
|
if (remaining.length === 0) {
|
|
5111
|
-
await db.delete(schema.userBackupCodes).where((0,
|
|
5112
|
-
await db.delete(schema.userTrustedDevices).where((0,
|
|
5255
|
+
await db.delete(schema.userBackupCodes).where((0, import_drizzle_orm24.eq)(schema.userBackupCodes.userId, userId));
|
|
5256
|
+
await db.delete(schema.userTrustedDevices).where((0, import_drizzle_orm24.eq)(schema.userTrustedDevices.userId, userId));
|
|
5113
5257
|
await invalidateAllUserSessions(db, { sessions: schema.sessions, users: schema.users }, userId);
|
|
5114
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0,
|
|
5258
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0, import_drizzle_orm24.eq)(schema.users.id, userId)).limit(1);
|
|
5115
5259
|
if (user) {
|
|
5116
5260
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
5117
5261
|
const firstName = user.name?.split(" ")[0] || null;
|
|
@@ -5130,7 +5274,7 @@ var totpDisableHandler = async (c) => {
|
|
|
5130
5274
|
|
|
5131
5275
|
// src/routes/2fa/email-setup.ts
|
|
5132
5276
|
var import_zod_openapi28 = require("@hono/zod-openapi");
|
|
5133
|
-
var
|
|
5277
|
+
var import_drizzle_orm25 = require("drizzle-orm");
|
|
5134
5278
|
var emailSetupRoute = (0, import_zod_openapi28.createRoute)({
|
|
5135
5279
|
method: "post",
|
|
5136
5280
|
path: "/email/setup",
|
|
@@ -5160,15 +5304,15 @@ var emailSetupHandler = async (c) => {
|
|
|
5160
5304
|
const userId = c.get("userId");
|
|
5161
5305
|
if (!userId) return problems.unauthorized(c);
|
|
5162
5306
|
const { db, schema, env, emailBranding } = getAuthContext(c);
|
|
5163
|
-
const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where((0,
|
|
5307
|
+
const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where((0, import_drizzle_orm25.and)((0, import_drizzle_orm25.eq)(schema.user2faMethods.userId, userId), (0, import_drizzle_orm25.eq)(schema.user2faMethods.method, "email"))).limit(1);
|
|
5164
5308
|
if (existing.length > 0) {
|
|
5165
5309
|
return problems.badRequest(c, "Email 2FA already enabled");
|
|
5166
5310
|
}
|
|
5167
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0,
|
|
5311
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0, import_drizzle_orm25.eq)(schema.users.id, userId)).limit(1);
|
|
5168
5312
|
if (!user) return problems.notFound(c, "User not found");
|
|
5169
5313
|
const code = generateEmailOtp();
|
|
5170
|
-
const
|
|
5171
|
-
await env.OAUTH_STATES.put(`email_2fa_setup:${userId}`,
|
|
5314
|
+
const codeHash2 = await hashToken(code);
|
|
5315
|
+
await env.OAUTH_STATES.put(`email_2fa_setup:${userId}`, codeHash2, { expirationTtl: 300 });
|
|
5172
5316
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
5173
5317
|
const firstName = user.name?.split(" ")[0] || null;
|
|
5174
5318
|
await emailService.send2faCodeEmail({ email: user.email, firstName, code }, db, schema.users);
|
|
@@ -5178,7 +5322,7 @@ var emailSetupHandler = async (c) => {
|
|
|
5178
5322
|
|
|
5179
5323
|
// src/routes/2fa/email-verify.ts
|
|
5180
5324
|
var import_zod_openapi29 = require("@hono/zod-openapi");
|
|
5181
|
-
var
|
|
5325
|
+
var import_drizzle_orm26 = require("drizzle-orm");
|
|
5182
5326
|
var emailVerifyRoute = (0, import_zod_openapi29.createRoute)({
|
|
5183
5327
|
method: "post",
|
|
5184
5328
|
path: "/email/verify",
|
|
@@ -5215,12 +5359,12 @@ var emailVerifyHandler = async (c) => {
|
|
|
5215
5359
|
if (!storedHash) {
|
|
5216
5360
|
return problems.badRequest(c, "Code expired. Please request a new one.");
|
|
5217
5361
|
}
|
|
5218
|
-
const
|
|
5219
|
-
if (
|
|
5362
|
+
const codeHash2 = await hashToken(code);
|
|
5363
|
+
if (codeHash2 !== storedHash) {
|
|
5220
5364
|
logSecurityEvent("2fa_email_setup_failed", "medium", { userId });
|
|
5221
5365
|
return problems.badRequest(c, "Invalid code");
|
|
5222
5366
|
}
|
|
5223
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0,
|
|
5367
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0, import_drizzle_orm26.eq)(schema.users.id, userId)).limit(1);
|
|
5224
5368
|
const now = Date.now();
|
|
5225
5369
|
const existingMethods = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
5226
5370
|
const isFirstMethod = existingMethods.length === 0;
|
|
@@ -5262,7 +5406,7 @@ var emailVerifyHandler = async (c) => {
|
|
|
5262
5406
|
|
|
5263
5407
|
// src/routes/2fa/email-send-code.ts
|
|
5264
5408
|
var import_zod_openapi30 = require("@hono/zod-openapi");
|
|
5265
|
-
var
|
|
5409
|
+
var import_drizzle_orm27 = require("drizzle-orm");
|
|
5266
5410
|
var emailSendCodeRoute = (0, import_zod_openapi30.createRoute)({
|
|
5267
5411
|
method: "post",
|
|
5268
5412
|
path: "/email/send-code",
|
|
@@ -5292,15 +5436,15 @@ var emailSendCodeHandler = async (c) => {
|
|
|
5292
5436
|
const userId = c.get("userId");
|
|
5293
5437
|
if (!userId) return problems.unauthorized(c);
|
|
5294
5438
|
const { db, schema, env, emailBranding } = getAuthContext(c);
|
|
5295
|
-
const [emailMethod] = await db.select().from(schema.user2faMethods).where((0,
|
|
5439
|
+
const [emailMethod] = await db.select().from(schema.user2faMethods).where((0, import_drizzle_orm27.and)((0, import_drizzle_orm27.eq)(schema.user2faMethods.userId, userId), (0, import_drizzle_orm27.eq)(schema.user2faMethods.method, "email"))).limit(1);
|
|
5296
5440
|
if (!emailMethod) {
|
|
5297
5441
|
return problems.badRequest(c, "Email 2FA not enabled");
|
|
5298
5442
|
}
|
|
5299
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0,
|
|
5443
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0, import_drizzle_orm27.eq)(schema.users.id, userId)).limit(1);
|
|
5300
5444
|
if (!user) return problems.notFound(c, "User not found");
|
|
5301
5445
|
const code = generateEmailOtp();
|
|
5302
|
-
const
|
|
5303
|
-
await env.OAUTH_STATES.put(`email_2fa_challenge:${userId}`,
|
|
5446
|
+
const codeHash2 = await hashToken(code);
|
|
5447
|
+
await env.OAUTH_STATES.put(`email_2fa_challenge:${userId}`, codeHash2, {
|
|
5304
5448
|
expirationTtl: 300
|
|
5305
5449
|
});
|
|
5306
5450
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
@@ -5322,7 +5466,7 @@ var emailSendCodeHandler = async (c) => {
|
|
|
5322
5466
|
|
|
5323
5467
|
// src/routes/2fa/email-disable.ts
|
|
5324
5468
|
var import_zod_openapi31 = require("@hono/zod-openapi");
|
|
5325
|
-
var
|
|
5469
|
+
var import_drizzle_orm28 = require("drizzle-orm");
|
|
5326
5470
|
var emailDisableRoute = (0, import_zod_openapi31.createRoute)({
|
|
5327
5471
|
method: "post",
|
|
5328
5472
|
path: "/email/disable",
|
|
@@ -5365,13 +5509,13 @@ var emailDisableHandler = async (c) => {
|
|
|
5365
5509
|
logSecurityEvent("2fa_disable_failed", "medium", { userId, method: "email" });
|
|
5366
5510
|
return problems.badRequest(c, "Invalid verification code");
|
|
5367
5511
|
}
|
|
5368
|
-
await db.delete(schema.user2faMethods).where((0,
|
|
5512
|
+
await db.delete(schema.user2faMethods).where((0, import_drizzle_orm28.and)((0, import_drizzle_orm28.eq)(schema.user2faMethods.userId, userId), (0, import_drizzle_orm28.eq)(schema.user2faMethods.method, "email")));
|
|
5369
5513
|
const remaining = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
5370
5514
|
if (remaining.length === 0) {
|
|
5371
|
-
await db.delete(schema.userBackupCodes).where((0,
|
|
5372
|
-
await db.delete(schema.userTrustedDevices).where((0,
|
|
5515
|
+
await db.delete(schema.userBackupCodes).where((0, import_drizzle_orm28.eq)(schema.userBackupCodes.userId, userId));
|
|
5516
|
+
await db.delete(schema.userTrustedDevices).where((0, import_drizzle_orm28.eq)(schema.userTrustedDevices.userId, userId));
|
|
5373
5517
|
await invalidateAllUserSessions(db, { sessions: schema.sessions, users: schema.users }, userId);
|
|
5374
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0,
|
|
5518
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0, import_drizzle_orm28.eq)(schema.users.id, userId)).limit(1);
|
|
5375
5519
|
if (user) {
|
|
5376
5520
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
5377
5521
|
const firstName = user.name?.split(" ")[0] || null;
|
|
@@ -5390,7 +5534,7 @@ var emailDisableHandler = async (c) => {
|
|
|
5390
5534
|
|
|
5391
5535
|
// src/routes/2fa/trusted-devices.ts
|
|
5392
5536
|
var import_zod_openapi32 = require("@hono/zod-openapi");
|
|
5393
|
-
var
|
|
5537
|
+
var import_drizzle_orm29 = require("drizzle-orm");
|
|
5394
5538
|
var trustedDevicesGetRoute = (0, import_zod_openapi32.createRoute)({
|
|
5395
5539
|
method: "get",
|
|
5396
5540
|
path: "/trusted-devices",
|
|
@@ -5429,7 +5573,7 @@ var trustedDevicesGetHandler = async (c) => {
|
|
|
5429
5573
|
ipAddress: schema.userTrustedDevices.ipAddress,
|
|
5430
5574
|
lastUsedAt: schema.userTrustedDevices.lastUsedAt,
|
|
5431
5575
|
createdAt: schema.userTrustedDevices.createdAt
|
|
5432
|
-
}).from(schema.userTrustedDevices).where((0,
|
|
5576
|
+
}).from(schema.userTrustedDevices).where((0, import_drizzle_orm29.and)((0, import_drizzle_orm29.eq)(schema.userTrustedDevices.userId, userId), (0, import_drizzle_orm29.gt)(schema.userTrustedDevices.expiresAt, now)));
|
|
5433
5577
|
c.header("Cache-Control", "private, max-age=60");
|
|
5434
5578
|
c.header("Vary", "Cookie");
|
|
5435
5579
|
return c.json({ devices });
|
|
@@ -5465,7 +5609,7 @@ var trustedDevicesDeleteHandler = async (c) => {
|
|
|
5465
5609
|
if (!userId) return problems.unauthorized(c);
|
|
5466
5610
|
const deviceId = c.req.param("id");
|
|
5467
5611
|
const { db, schema } = getAuthContext(c);
|
|
5468
|
-
const result = await db.delete(schema.userTrustedDevices).where((0,
|
|
5612
|
+
const result = await db.delete(schema.userTrustedDevices).where((0, import_drizzle_orm29.and)((0, import_drizzle_orm29.eq)(schema.userTrustedDevices.id, deviceId), (0, import_drizzle_orm29.eq)(schema.userTrustedDevices.userId, userId))).returning({ id: schema.userTrustedDevices.id });
|
|
5469
5613
|
if (result.length === 0) {
|
|
5470
5614
|
return problems.notFound(c, "Device not found");
|
|
5471
5615
|
}
|
|
@@ -5475,7 +5619,7 @@ var trustedDevicesDeleteHandler = async (c) => {
|
|
|
5475
5619
|
|
|
5476
5620
|
// src/routes/2fa/backup-codes.ts
|
|
5477
5621
|
var import_zod_openapi33 = require("@hono/zod-openapi");
|
|
5478
|
-
var
|
|
5622
|
+
var import_drizzle_orm30 = require("drizzle-orm");
|
|
5479
5623
|
var backupCodesRegenerateRoute = (0, import_zod_openapi33.createRoute)({
|
|
5480
5624
|
method: "post",
|
|
5481
5625
|
path: "/backup-codes/regenerate",
|
|
@@ -5516,7 +5660,7 @@ var backupCodesRegenerateHandler = async (c) => {
|
|
|
5516
5660
|
logSecurityEvent("2fa_backup_regenerate_failed", "medium", { userId });
|
|
5517
5661
|
return problems.badRequest(c, "Invalid verification code");
|
|
5518
5662
|
}
|
|
5519
|
-
await db.delete(schema.userBackupCodes).where((0,
|
|
5663
|
+
await db.delete(schema.userBackupCodes).where((0, import_drizzle_orm30.eq)(schema.userBackupCodes.userId, userId));
|
|
5520
5664
|
const codes = generateBackupCodes();
|
|
5521
5665
|
const now = Date.now();
|
|
5522
5666
|
const hashedCodes = await Promise.all(
|
|
@@ -5533,7 +5677,7 @@ var backupCodesRegenerateHandler = async (c) => {
|
|
|
5533
5677
|
|
|
5534
5678
|
// src/routes/2fa/challenge.ts
|
|
5535
5679
|
var import_zod_openapi34 = require("@hono/zod-openapi");
|
|
5536
|
-
var
|
|
5680
|
+
var import_drizzle_orm31 = require("drizzle-orm");
|
|
5537
5681
|
var import_cookie3 = require("hono/cookie");
|
|
5538
5682
|
var challengeRoute = (0, import_zod_openapi34.createRoute)({
|
|
5539
5683
|
method: "post",
|
|
@@ -5605,7 +5749,7 @@ var challengeHandler = async (c) => {
|
|
|
5605
5749
|
}
|
|
5606
5750
|
await deleteChallengeToken(c.env.OAUTH_STATES, challengeToken);
|
|
5607
5751
|
clearChallengeCookie(c, c.env);
|
|
5608
|
-
const [user] = await db.select().from(schema.users).where((0,
|
|
5752
|
+
const [user] = await db.select().from(schema.users).where((0, import_drizzle_orm31.eq)(schema.users.id, payload.userId)).limit(1);
|
|
5609
5753
|
if (!user) {
|
|
5610
5754
|
logger_default.error("User not found during 2FA challenge", { userId: payload.userId });
|
|
5611
5755
|
return problems.notFound(c, "User not found");
|
|
@@ -5646,7 +5790,7 @@ var challengeHandler = async (c) => {
|
|
|
5646
5790
|
|
|
5647
5791
|
// src/routes/2fa/challenge-resend.ts
|
|
5648
5792
|
var import_zod_openapi35 = require("@hono/zod-openapi");
|
|
5649
|
-
var
|
|
5793
|
+
var import_drizzle_orm32 = require("drizzle-orm");
|
|
5650
5794
|
var import_cookie4 = require("hono/cookie");
|
|
5651
5795
|
var challengeResendRoute = (0, import_zod_openapi35.createRoute)({
|
|
5652
5796
|
method: "post",
|
|
@@ -5684,7 +5828,7 @@ var challengeResendHandler = async (c) => {
|
|
|
5684
5828
|
if (!payload.methods.includes("email")) {
|
|
5685
5829
|
return problems.badRequest(c, "Email verification not available");
|
|
5686
5830
|
}
|
|
5687
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0,
|
|
5831
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where((0, import_drizzle_orm32.eq)(schema.users.id, payload.userId)).limit(1);
|
|
5688
5832
|
if (!user) {
|
|
5689
5833
|
logger_default.error("User not found during 2FA resend", { userId: payload.userId });
|
|
5690
5834
|
return problems.notFound(c, "User not found");
|
|
@@ -5915,7 +6059,7 @@ var authorizeHandler = async (c) => {
|
|
|
5915
6059
|
|
|
5916
6060
|
// src/routes/oauth/callback.ts
|
|
5917
6061
|
var import_zod_openapi39 = require("@hono/zod-openapi");
|
|
5918
|
-
var
|
|
6062
|
+
var import_drizzle_orm33 = require("drizzle-orm");
|
|
5919
6063
|
var callbackRoute = (0, import_zod_openapi39.createRoute)({
|
|
5920
6064
|
method: "get",
|
|
5921
6065
|
path: "/{provider}/callback",
|
|
@@ -6041,10 +6185,24 @@ var callbackHandler = async (c) => {
|
|
|
6041
6185
|
logger_default.warn("No user ID returned from OAuth provider", { provider });
|
|
6042
6186
|
return c.redirect(`${appUrl}/login?error=oauth_failed`);
|
|
6043
6187
|
}
|
|
6044
|
-
const { db, schema } = getAuthContext(c);
|
|
6045
|
-
const [existingUser] = await db.select({ id: schema.users.id, email: schema.users.email }).from(schema.users).where((0,
|
|
6188
|
+
const { db, schema, env, invitations, signup } = getAuthContext(c);
|
|
6189
|
+
const [existingUser] = await db.select({ id: schema.users.id, email: schema.users.email }).from(schema.users).where((0, import_drizzle_orm33.eq)(schema.users.email, email)).limit(1);
|
|
6046
6190
|
let authUser = existingUser;
|
|
6047
6191
|
if (!authUser) {
|
|
6192
|
+
const invited = await verifyInvitation(invitations, {
|
|
6193
|
+
token: invitationToken,
|
|
6194
|
+
email,
|
|
6195
|
+
db,
|
|
6196
|
+
env
|
|
6197
|
+
});
|
|
6198
|
+
if (signup === "invitation" && !invited) {
|
|
6199
|
+
logSecurityEvent("oauth_signup_refused_no_invitation", "medium", {
|
|
6200
|
+
email,
|
|
6201
|
+
provider,
|
|
6202
|
+
hadInvitation: !!invitationToken
|
|
6203
|
+
});
|
|
6204
|
+
return c.redirect(`${appUrl}/login?error=invitation_required`);
|
|
6205
|
+
}
|
|
6048
6206
|
const [createdUser] = await db.insert(schema.users).values({
|
|
6049
6207
|
email,
|
|
6050
6208
|
name: name || null,
|
|
@@ -6055,6 +6213,15 @@ var callbackHandler = async (c) => {
|
|
|
6055
6213
|
activatedAt: /* @__PURE__ */ new Date()
|
|
6056
6214
|
}).returning({ id: schema.users.id, email: schema.users.email });
|
|
6057
6215
|
authUser = createdUser;
|
|
6216
|
+
if (invited) {
|
|
6217
|
+
await acceptInvitation(invitations, {
|
|
6218
|
+
token: invited,
|
|
6219
|
+
user: { id: createdUser.id, email: createdUser.email, name: name || null },
|
|
6220
|
+
db,
|
|
6221
|
+
env,
|
|
6222
|
+
context: "oauth_invitation_onAccepted_failed"
|
|
6223
|
+
});
|
|
6224
|
+
}
|
|
6058
6225
|
logSecurityEvent("user_signup_oauth", "low", {
|
|
6059
6226
|
userId: createdUser.id,
|
|
6060
6227
|
email: createdUser.email,
|
|
@@ -6094,7 +6261,7 @@ var callbackHandler = async (c) => {
|
|
|
6094
6261
|
|
|
6095
6262
|
// src/routes/oauth/complete.ts
|
|
6096
6263
|
var import_zod_openapi40 = require("@hono/zod-openapi");
|
|
6097
|
-
var
|
|
6264
|
+
var import_drizzle_orm34 = require("drizzle-orm");
|
|
6098
6265
|
var completeRoute = (0, import_zod_openapi40.createRoute)({
|
|
6099
6266
|
method: "post",
|
|
6100
6267
|
path: "/complete",
|
|
@@ -6142,7 +6309,7 @@ var completeHandler = async (c) => {
|
|
|
6142
6309
|
redirect: storedRedirect
|
|
6143
6310
|
} = JSON.parse(stored);
|
|
6144
6311
|
const { db, schema } = getAuthContext(c);
|
|
6145
|
-
await db.update(schema.users).set({ emailVerified: true }).where((0,
|
|
6312
|
+
await db.update(schema.users).set({ emailVerified: true }).where((0, import_drizzle_orm34.eq)(schema.users.id, userId));
|
|
6146
6313
|
await db.insert(schema.oauthAccounts).values({ userId, provider, providerUserId, email }).onConflictDoNothing();
|
|
6147
6314
|
const fingerprint = await generateFingerprint(c.req.raw);
|
|
6148
6315
|
const ipAddress = getClientIp(c.req.raw);
|
|
@@ -6187,19 +6354,12 @@ var routes_default = authRoutes;
|
|
|
6187
6354
|
|
|
6188
6355
|
// src/lib/email/webhook-verifier.ts
|
|
6189
6356
|
var import_svix = require("svix");
|
|
6190
|
-
function verifyWebhookSignature(payload,
|
|
6357
|
+
function verifyWebhookSignature(payload, headers, secret) {
|
|
6191
6358
|
if (!secret || secret.trim() === "") {
|
|
6192
6359
|
throw new Error("Webhook secret is required");
|
|
6193
6360
|
}
|
|
6194
6361
|
try {
|
|
6195
|
-
|
|
6196
|
-
wh.verify(payload, {
|
|
6197
|
-
"svix-id": "",
|
|
6198
|
-
// Not used by Resend
|
|
6199
|
-
"svix-timestamp": "",
|
|
6200
|
-
// Not used by Resend
|
|
6201
|
-
"svix-signature": signature
|
|
6202
|
-
});
|
|
6362
|
+
new import_svix.Webhook(secret).verify(payload, { ...headers });
|
|
6203
6363
|
return true;
|
|
6204
6364
|
} catch (error) {
|
|
6205
6365
|
logger_default.warn("Webhook signature verification failed", {
|
|
@@ -6215,6 +6375,8 @@ function verifyWebhookSignature(payload, signature, secret) {
|
|
|
6215
6375
|
CloudflareEmailAdapter,
|
|
6216
6376
|
EmailService,
|
|
6217
6377
|
MAX_CHALLENGE_ATTEMPTS,
|
|
6378
|
+
PASSWORDLESS_MAX_ATTEMPTS,
|
|
6379
|
+
PASSWORDLESS_TTL_MS,
|
|
6218
6380
|
ProblemTypes,
|
|
6219
6381
|
ResendAdapter,
|
|
6220
6382
|
SCHEMA_VERSION,
|
|
@@ -6226,6 +6388,7 @@ function verifyWebhookSignature(payload, signature, secret) {
|
|
|
6226
6388
|
cancelEmailChange,
|
|
6227
6389
|
checkAccountLocked,
|
|
6228
6390
|
checkBreachedPassword,
|
|
6391
|
+
cleanupExpiredPasswordlessChallenges,
|
|
6229
6392
|
clearAccountLockout,
|
|
6230
6393
|
clearChallengeCookie,
|
|
6231
6394
|
clearSessionCookie,
|
|
@@ -6236,6 +6399,7 @@ function verifyWebhookSignature(payload, signature, secret) {
|
|
|
6236
6399
|
createChallengeToken,
|
|
6237
6400
|
createDeviceToken,
|
|
6238
6401
|
createEmailAdapter,
|
|
6402
|
+
createPasswordlessChallenge,
|
|
6239
6403
|
createProblemDetails,
|
|
6240
6404
|
createSession,
|
|
6241
6405
|
csrf,
|
|
@@ -6252,6 +6416,7 @@ function verifyWebhookSignature(payload, signature, secret) {
|
|
|
6252
6416
|
generate2faEnabledEmail,
|
|
6253
6417
|
generateApiKey,
|
|
6254
6418
|
generateBackupCodes,
|
|
6419
|
+
generateCode,
|
|
6255
6420
|
generateCodeChallenge,
|
|
6256
6421
|
generateCodeVerifier,
|
|
6257
6422
|
generateEmailChangeConfirmation,
|
|
@@ -6290,14 +6455,17 @@ function verifyWebhookSignature(payload, signature, secret) {
|
|
|
6290
6455
|
optionalAuth,
|
|
6291
6456
|
parseDeviceName,
|
|
6292
6457
|
parseDeviceType,
|
|
6458
|
+
passwordlessEmail,
|
|
6293
6459
|
problemJson,
|
|
6294
6460
|
problems,
|
|
6295
6461
|
rateLimit,
|
|
6462
|
+
recentPasswordlessFailures,
|
|
6296
6463
|
refreshSession,
|
|
6297
6464
|
requestEmailChange,
|
|
6298
6465
|
requireAuth,
|
|
6299
6466
|
requireVerifiedEmail,
|
|
6300
6467
|
retrieveChallengeToken,
|
|
6468
|
+
sessionRefreshDue,
|
|
6301
6469
|
setChallengeCookie,
|
|
6302
6470
|
setSessionCookie,
|
|
6303
6471
|
setTrustedDeviceCookie,
|
|
@@ -6315,6 +6483,8 @@ function verifyWebhookSignature(payload, signature, secret) {
|
|
|
6315
6483
|
verifyCodeChallenge,
|
|
6316
6484
|
verifyPassword,
|
|
6317
6485
|
verifyPasswordWithRotation,
|
|
6486
|
+
verifyPasswordlessCode,
|
|
6487
|
+
verifyPasswordlessLink,
|
|
6318
6488
|
verifyTotpCode,
|
|
6319
6489
|
verifyTurnstileToken,
|
|
6320
6490
|
verifyWebhookSignature
|