@syncello/auth 3.6.0 → 4.0.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 +183 -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 +1076 -912
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +177 -41
- package/dist/index.d.ts +177 -41
- package/dist/index.js +1035 -899
- 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.js
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
enums,
|
|
4
4
|
tableNames,
|
|
5
5
|
tables
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-W35EDOHW.js";
|
|
7
7
|
|
|
8
8
|
// src/core/password.ts
|
|
9
9
|
import bcrypt from "bcryptjs";
|
|
@@ -255,6 +255,8 @@ var AUTH_DEFAULTS = {
|
|
|
255
255
|
// Absolute cap: a session dies this long after creation regardless of
|
|
256
256
|
// sliding-window activity (limits the value of a stolen session token).
|
|
257
257
|
SESSION_MAX_LIFETIME_DAYS: 30,
|
|
258
|
+
// The sliding window moves at most this often, so a busy session is not a write per request.
|
|
259
|
+
SESSION_REFRESH_INTERVAL_MINUTES: 15,
|
|
258
260
|
LOCKOUT_DURATION_MINUTES: 30,
|
|
259
261
|
LOCKOUT_MAX_ATTEMPTS: 5,
|
|
260
262
|
PASSWORD_RESET_TTL_MINUTES: 15,
|
|
@@ -335,7 +337,7 @@ async function validateSession(db, tables2, sessionId, currentFingerprint, sessi
|
|
|
335
337
|
note: "SSR requests have different User-Agent, causing expected mismatch"
|
|
336
338
|
});
|
|
337
339
|
}
|
|
338
|
-
await refreshSession(db, { sessions }, sessionId);
|
|
340
|
+
if (sessionRefreshDue(row.sessionLastActiveAt)) await refreshSession(db, { sessions }, sessionId);
|
|
339
341
|
return {
|
|
340
342
|
user: {
|
|
341
343
|
id: row.userId,
|
|
@@ -354,6 +356,10 @@ async function validateSession(db, tables2, sessionId, currentFingerprint, sessi
|
|
|
354
356
|
}
|
|
355
357
|
};
|
|
356
358
|
}
|
|
359
|
+
function sessionRefreshDue(lastActiveAt, now = Date.now()) {
|
|
360
|
+
const interval = AUTH_DEFAULTS.SESSION_REFRESH_INTERVAL_MINUTES * 60 * 1e3;
|
|
361
|
+
return lastActiveAt === null || now - lastActiveAt >= interval;
|
|
362
|
+
}
|
|
357
363
|
async function refreshSession(db, tables2, sessionId, sessionTtlMs = AUTH_DEFAULTS.SESSION_TTL_DAYS * 24 * 60 * 60 * 1e3) {
|
|
358
364
|
const { sessions } = tables2;
|
|
359
365
|
const newExpiration = Date.now() + sessionTtlMs;
|
|
@@ -521,16 +527,16 @@ function createDeviceToken() {
|
|
|
521
527
|
};
|
|
522
528
|
}
|
|
523
529
|
async function validateTrustedDevice(db, userTrustedDevicesTable, userId, tokenHash) {
|
|
524
|
-
const { eq:
|
|
530
|
+
const { eq: eq34, and: and15, gt: gt7 } = await import("drizzle-orm");
|
|
525
531
|
const [device] = await db.select().from(userTrustedDevicesTable).where(
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
532
|
+
and15(
|
|
533
|
+
eq34(userTrustedDevicesTable.userId, userId),
|
|
534
|
+
eq34(userTrustedDevicesTable.tokenHash, tokenHash),
|
|
535
|
+
gt7(userTrustedDevicesTable.expiresAt, Date.now())
|
|
530
536
|
)
|
|
531
537
|
).limit(1);
|
|
532
538
|
if (device) {
|
|
533
|
-
await db.update(userTrustedDevicesTable).set({ lastUsedAt: Date.now() }).where(
|
|
539
|
+
await db.update(userTrustedDevicesTable).set({ lastUsedAt: Date.now() }).where(eq34(userTrustedDevicesTable.id, device.id));
|
|
534
540
|
return true;
|
|
535
541
|
}
|
|
536
542
|
return false;
|
|
@@ -943,582 +949,48 @@ function base64UrlEncode(data) {
|
|
|
943
949
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
944
950
|
}
|
|
945
951
|
|
|
946
|
-
// src/
|
|
947
|
-
import {
|
|
948
|
-
import { eq as eq4, and as and2, gt as gt2 } from "drizzle-orm";
|
|
952
|
+
// src/core/passwordless.ts
|
|
953
|
+
import { and as and2, desc, eq as eq4, gt as gt2, isNull, lt, sql as sql2 } from "drizzle-orm";
|
|
949
954
|
|
|
950
|
-
// src/
|
|
951
|
-
function
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
invitations
|
|
959
|
-
});
|
|
960
|
-
return {
|
|
961
|
-
db,
|
|
962
|
-
schema,
|
|
963
|
-
getContext
|
|
964
|
-
};
|
|
965
|
-
}
|
|
966
|
-
function createAuthMiddleware(auth2) {
|
|
967
|
-
return async (c, next) => {
|
|
968
|
-
const ctx = auth2.getContext(c);
|
|
969
|
-
c.set("authContext", ctx);
|
|
970
|
-
c.set("db", auth2.db);
|
|
971
|
-
await next();
|
|
972
|
-
};
|
|
973
|
-
}
|
|
974
|
-
function getAuthContext(c) {
|
|
975
|
-
const ctx = c.get("authContext");
|
|
976
|
-
if (!ctx) {
|
|
977
|
-
throw new Error(
|
|
978
|
-
"AuthContext not found. Did you apply createAuthMiddleware before the auth routes?"
|
|
979
|
-
);
|
|
955
|
+
// src/lib/email/security.ts
|
|
956
|
+
function getAllowedDomains(appUrl) {
|
|
957
|
+
const base = ["localhost", "127.0.0.1"];
|
|
958
|
+
try {
|
|
959
|
+
const url = new URL(appUrl);
|
|
960
|
+
return [...base, url.hostname];
|
|
961
|
+
} catch {
|
|
962
|
+
return base;
|
|
980
963
|
}
|
|
981
|
-
return ctx;
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
// src/lib/problem-json.ts
|
|
985
|
-
var ProblemTypes = {
|
|
986
|
-
// 4xx Client Errors
|
|
987
|
-
BAD_REQUEST: "/errors/bad-request",
|
|
988
|
-
UNAUTHORIZED: "/errors/unauthorized",
|
|
989
|
-
FORBIDDEN: "/errors/forbidden",
|
|
990
|
-
NOT_FOUND: "/errors/not-found",
|
|
991
|
-
CONFLICT: "/errors/conflict",
|
|
992
|
-
GONE: "/errors/gone",
|
|
993
|
-
VALIDATION_ERROR: "/errors/validation-error",
|
|
994
|
-
RATE_LIMIT_EXCEEDED: "/errors/rate-limit-exceeded",
|
|
995
|
-
// 5xx Server Errors
|
|
996
|
-
INTERNAL_ERROR: "/errors/internal-error",
|
|
997
|
-
SERVICE_UNAVAILABLE: "/errors/service-unavailable"
|
|
998
|
-
};
|
|
999
|
-
function generateTraceId() {
|
|
1000
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1001
|
-
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1002
964
|
}
|
|
1003
|
-
function
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
if (options?.detail) {
|
|
1010
|
-
problem.detail = options.detail;
|
|
1011
|
-
}
|
|
1012
|
-
if (options?.instance) {
|
|
1013
|
-
problem.instance = options.instance;
|
|
965
|
+
function sanitizeEmailUrl(url, appUrl = "") {
|
|
966
|
+
let parsed;
|
|
967
|
+
try {
|
|
968
|
+
parsed = new URL(url);
|
|
969
|
+
} catch {
|
|
970
|
+
throw new Error("Invalid URL format");
|
|
1014
971
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
problem.requestId = options.requestId;
|
|
972
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
973
|
+
throw new Error(`Disallowed protocol: ${parsed.protocol}`);
|
|
1018
974
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
975
|
+
const allowedDomains = getAllowedDomains(appUrl);
|
|
976
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
977
|
+
const isAllowed = allowedDomains.some((domain) => {
|
|
978
|
+
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
979
|
+
});
|
|
980
|
+
if (!isAllowed) {
|
|
981
|
+
throw new Error(`Disallowed domain: ${hostname}`);
|
|
1021
982
|
}
|
|
1022
|
-
return
|
|
983
|
+
return parsed.toString();
|
|
1023
984
|
}
|
|
1024
|
-
function
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
return c.json(problem, problem.status, {
|
|
1034
|
-
"Content-Type": "application/problem+json"
|
|
1035
|
-
});
|
|
1036
|
-
}
|
|
1037
|
-
var problems = {
|
|
1038
|
-
/**
|
|
1039
|
-
* 400 Bad Request - Generic client error
|
|
1040
|
-
*/
|
|
1041
|
-
badRequest(c, detail) {
|
|
1042
|
-
return problemJson(
|
|
1043
|
-
c,
|
|
1044
|
-
createProblemDetails(ProblemTypes.BAD_REQUEST, "Bad Request", 400, {
|
|
1045
|
-
detail: detail || "The request could not be understood by the server",
|
|
1046
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1047
|
-
requestId: c.get("requestId"),
|
|
1048
|
-
traceId: c.get("traceId")
|
|
1049
|
-
})
|
|
1050
|
-
);
|
|
1051
|
-
},
|
|
1052
|
-
/**
|
|
1053
|
-
* 400 Bad Request - Validation errors with field-specific details
|
|
1054
|
-
*/
|
|
1055
|
-
validationError(c, detail, errors) {
|
|
1056
|
-
return problemJson(
|
|
1057
|
-
c,
|
|
1058
|
-
createProblemDetails(ProblemTypes.VALIDATION_ERROR, "Validation Error", 400, {
|
|
1059
|
-
detail,
|
|
1060
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1061
|
-
requestId: c.get("requestId"),
|
|
1062
|
-
traceId: c.get("traceId"),
|
|
1063
|
-
extensions: errors ? { errors } : void 0
|
|
1064
|
-
})
|
|
1065
|
-
);
|
|
1066
|
-
},
|
|
1067
|
-
/**
|
|
1068
|
-
* 401 Unauthorized - Authentication required
|
|
1069
|
-
*/
|
|
1070
|
-
unauthorized(c, detail) {
|
|
1071
|
-
return problemJson(
|
|
1072
|
-
c,
|
|
1073
|
-
createProblemDetails(ProblemTypes.UNAUTHORIZED, "Unauthorized", 401, {
|
|
1074
|
-
detail: detail || "Authentication is required to access this resource",
|
|
1075
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1076
|
-
requestId: c.get("requestId"),
|
|
1077
|
-
traceId: c.get("traceId")
|
|
1078
|
-
})
|
|
1079
|
-
);
|
|
1080
|
-
},
|
|
1081
|
-
/**
|
|
1082
|
-
* 403 Forbidden - Insufficient permissions
|
|
1083
|
-
*/
|
|
1084
|
-
forbidden(c, detail) {
|
|
1085
|
-
return problemJson(
|
|
1086
|
-
c,
|
|
1087
|
-
createProblemDetails(ProblemTypes.FORBIDDEN, "Forbidden", 403, {
|
|
1088
|
-
detail: detail || "You do not have permission to access this resource",
|
|
1089
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1090
|
-
requestId: c.get("requestId"),
|
|
1091
|
-
traceId: c.get("traceId")
|
|
1092
|
-
})
|
|
1093
|
-
);
|
|
1094
|
-
},
|
|
1095
|
-
/**
|
|
1096
|
-
* 404 Not Found
|
|
1097
|
-
*/
|
|
1098
|
-
notFound(c, detail) {
|
|
1099
|
-
return problemJson(
|
|
1100
|
-
c,
|
|
1101
|
-
createProblemDetails(ProblemTypes.NOT_FOUND, "Not Found", 404, {
|
|
1102
|
-
detail: detail || "The requested resource was not found",
|
|
1103
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1104
|
-
requestId: c.get("requestId"),
|
|
1105
|
-
traceId: c.get("traceId")
|
|
1106
|
-
})
|
|
1107
|
-
);
|
|
1108
|
-
},
|
|
1109
|
-
/**
|
|
1110
|
-
* 409 Conflict - Resource conflict (e.g., duplicate email)
|
|
1111
|
-
*/
|
|
1112
|
-
conflict(c, detail) {
|
|
1113
|
-
return problemJson(
|
|
1114
|
-
c,
|
|
1115
|
-
createProblemDetails(ProblemTypes.CONFLICT, "Conflict", 409, {
|
|
1116
|
-
detail,
|
|
1117
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1118
|
-
requestId: c.get("requestId"),
|
|
1119
|
-
traceId: c.get("traceId")
|
|
1120
|
-
})
|
|
1121
|
-
);
|
|
1122
|
-
},
|
|
1123
|
-
/**
|
|
1124
|
-
* 410 Gone - Resource is no longer available
|
|
1125
|
-
*/
|
|
1126
|
-
gone(c, detail) {
|
|
1127
|
-
return problemJson(
|
|
1128
|
-
c,
|
|
1129
|
-
createProblemDetails(ProblemTypes.GONE, "Gone", 410, {
|
|
1130
|
-
detail: detail || "The requested resource is no longer available",
|
|
1131
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1132
|
-
requestId: c.get("requestId"),
|
|
1133
|
-
traceId: c.get("traceId")
|
|
1134
|
-
})
|
|
1135
|
-
);
|
|
1136
|
-
},
|
|
1137
|
-
/**
|
|
1138
|
-
* 429 Too Many Requests - Rate limit exceeded
|
|
1139
|
-
*/
|
|
1140
|
-
rateLimitExceeded(c, retryAfter) {
|
|
1141
|
-
const extensions = retryAfter ? { retryAfter } : void 0;
|
|
1142
|
-
return problemJson(
|
|
1143
|
-
c,
|
|
1144
|
-
createProblemDetails(ProblemTypes.RATE_LIMIT_EXCEEDED, "Rate Limit Exceeded", 429, {
|
|
1145
|
-
detail: retryAfter ? `Too many requests. Please retry after ${retryAfter} seconds` : "Too many requests. Please slow down",
|
|
1146
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1147
|
-
requestId: c.get("requestId"),
|
|
1148
|
-
traceId: c.get("traceId"),
|
|
1149
|
-
extensions
|
|
1150
|
-
})
|
|
1151
|
-
);
|
|
1152
|
-
},
|
|
1153
|
-
/**
|
|
1154
|
-
* 500 Internal Server Error - Generic server error
|
|
1155
|
-
* SECURITY: Never include error details or stack traces
|
|
1156
|
-
*/
|
|
1157
|
-
internalError(c, error) {
|
|
1158
|
-
const traceId = c.get("traceId") || generateTraceId();
|
|
1159
|
-
if (error) {
|
|
1160
|
-
logError(error, {
|
|
1161
|
-
context: "internal_error",
|
|
1162
|
-
path: c.req.path,
|
|
1163
|
-
method: c.req.method,
|
|
1164
|
-
traceId,
|
|
1165
|
-
requestId: c.get("requestId")
|
|
1166
|
-
});
|
|
1167
|
-
}
|
|
1168
|
-
return problemJson(
|
|
1169
|
-
c,
|
|
1170
|
-
createProblemDetails(ProblemTypes.INTERNAL_ERROR, "Internal Server Error", 500, {
|
|
1171
|
-
detail: "An unexpected error occurred. Please try again later",
|
|
1172
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1173
|
-
traceId,
|
|
1174
|
-
requestId: c.get("requestId")
|
|
1175
|
-
})
|
|
1176
|
-
);
|
|
1177
|
-
},
|
|
1178
|
-
/**
|
|
1179
|
-
* 503 Service Unavailable - Service is temporarily unavailable
|
|
1180
|
-
*/
|
|
1181
|
-
serviceUnavailable(c, detail) {
|
|
1182
|
-
return problemJson(
|
|
1183
|
-
c,
|
|
1184
|
-
createProblemDetails(ProblemTypes.SERVICE_UNAVAILABLE, "Service Unavailable", 503, {
|
|
1185
|
-
detail: detail || "The service is temporarily unavailable. Please try again later",
|
|
1186
|
-
instance: `${c.req.method} ${c.req.path}`,
|
|
1187
|
-
requestId: c.get("requestId"),
|
|
1188
|
-
traceId: c.get("traceId")
|
|
1189
|
-
})
|
|
1190
|
-
);
|
|
1191
|
-
}
|
|
1192
|
-
};
|
|
1193
|
-
|
|
1194
|
-
// src/middleware/auth.ts
|
|
1195
|
-
var SESSION_MAX_LIFETIME_MS = AUTH_DEFAULTS.SESSION_MAX_LIFETIME_DAYS * 24 * 60 * 60 * 1e3;
|
|
1196
|
-
async function revokeIfPastMaxLifetime(db, sessions, sessionId, session) {
|
|
1197
|
-
if (Date.now() - session.createdAt <= SESSION_MAX_LIFETIME_MS) return false;
|
|
1198
|
-
logger_default.info("Session exceeded absolute lifetime - revoked", {
|
|
1199
|
-
type: "security",
|
|
1200
|
-
event: "session_max_lifetime_exceeded",
|
|
1201
|
-
severity: "low",
|
|
1202
|
-
sessionId: sessionId.slice(0, 8),
|
|
1203
|
-
userId: session.userId
|
|
1204
|
-
});
|
|
1205
|
-
await deleteSession(db, { sessions }, sessionId);
|
|
1206
|
-
return true;
|
|
1207
|
-
}
|
|
1208
|
-
async function getSessionFromCookie(db, sessions, cookieHeader, request, env) {
|
|
1209
|
-
const cookieName = getSessionCookieName(env);
|
|
1210
|
-
const cookiePattern = new RegExp(`${cookieName}=([^;]+)`);
|
|
1211
|
-
const sessionIdMatch = cookieHeader.match(cookiePattern);
|
|
1212
|
-
if (!sessionIdMatch) return null;
|
|
1213
|
-
const sessionId = sessionIdMatch[1];
|
|
1214
|
-
const foundSessions = await db.select({
|
|
1215
|
-
userId: sessions.userId,
|
|
1216
|
-
expiresAt: sessions.expiresAt,
|
|
1217
|
-
createdAt: sessions.createdAt,
|
|
1218
|
-
fingerprint: sessions.fingerprint
|
|
1219
|
-
}).from(sessions).where(and2(eq4(sessions.id, sessionId), gt2(sessions.expiresAt, Date.now()))).limit(1);
|
|
1220
|
-
if (foundSessions.length === 0) return null;
|
|
1221
|
-
const session = foundSessions[0];
|
|
1222
|
-
if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
|
|
1223
|
-
await refreshSession(db, { sessions }, sessionId);
|
|
1224
|
-
const userAgent = request.headers.get("user-agent") || "";
|
|
1225
|
-
const cfWorker = request.headers.get("cf-worker") || "";
|
|
1226
|
-
const clientIp = request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || "";
|
|
1227
|
-
const isLocalSSR = userAgent.toLowerCase().includes("node");
|
|
1228
|
-
const isSvelteKitSSR = userAgent.toLowerCase().includes("sveltekit");
|
|
1229
|
-
const isCloudflareSSR = !userAgent && cfWorker.includes(".pages.dev");
|
|
1230
|
-
const isSSR = isLocalSSR || isSvelteKitSSR || isCloudflareSSR;
|
|
1231
|
-
if (isSSR) {
|
|
1232
|
-
logger_default.info("SSR request detected, using IP validation", {
|
|
1233
|
-
type: "security",
|
|
1234
|
-
event: "ssr_session_validation",
|
|
1235
|
-
sessionId: sessionId.slice(0, 8),
|
|
1236
|
-
userId: session.userId,
|
|
1237
|
-
clientIp,
|
|
1238
|
-
cfWorker,
|
|
1239
|
-
isLocalSSR,
|
|
1240
|
-
isSvelteKitSSR,
|
|
1241
|
-
isCloudflareSSR
|
|
1242
|
-
});
|
|
1243
|
-
} else {
|
|
1244
|
-
if (session.fingerprint) {
|
|
1245
|
-
const currentFingerprint = await generateFingerprint(request);
|
|
1246
|
-
if (session.fingerprint !== currentFingerprint) {
|
|
1247
|
-
logger_default.info("Session fingerprint changed (not enforced)", {
|
|
1248
|
-
type: "security",
|
|
1249
|
-
event: "fingerprint_changed",
|
|
1250
|
-
sessionId: sessionId.slice(0, 8),
|
|
1251
|
-
userId: session.userId,
|
|
1252
|
-
clientIp
|
|
1253
|
-
});
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
return {
|
|
1258
|
-
userId: session.userId,
|
|
1259
|
-
expiresAt: session.expiresAt
|
|
1260
|
-
};
|
|
1261
|
-
}
|
|
1262
|
-
async function getSessionFromBearerToken(db, sessions, authHeader) {
|
|
1263
|
-
if (!authHeader.startsWith("Bearer ")) return null;
|
|
1264
|
-
const sessionId = authHeader.slice(7);
|
|
1265
|
-
const foundSessions = await db.select({
|
|
1266
|
-
userId: sessions.userId,
|
|
1267
|
-
expiresAt: sessions.expiresAt,
|
|
1268
|
-
createdAt: sessions.createdAt
|
|
1269
|
-
}).from(sessions).where(and2(eq4(sessions.id, sessionId), gt2(sessions.expiresAt, Date.now()))).limit(1);
|
|
1270
|
-
if (foundSessions.length === 0) return null;
|
|
1271
|
-
const session = foundSessions[0];
|
|
1272
|
-
if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
|
|
1273
|
-
await refreshSession(db, { sessions }, sessionId);
|
|
1274
|
-
return {
|
|
1275
|
-
userId: session.userId,
|
|
1276
|
-
expiresAt: session.expiresAt
|
|
1277
|
-
};
|
|
1278
|
-
}
|
|
1279
|
-
var requireAuth = createMiddleware(
|
|
1280
|
-
async (c, next) => {
|
|
1281
|
-
const { db, schema } = getAuthContext(c);
|
|
1282
|
-
const authHeader = c.req.header("Authorization");
|
|
1283
|
-
if (authHeader) {
|
|
1284
|
-
const session2 = await getSessionFromBearerToken(db, schema.sessions, authHeader);
|
|
1285
|
-
if (session2) {
|
|
1286
|
-
c.set("userId", session2.userId);
|
|
1287
|
-
await next();
|
|
1288
|
-
return;
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
const cookieHeader = c.req.header("cookie");
|
|
1292
|
-
if (!cookieHeader) {
|
|
1293
|
-
return problems.unauthorized(c, "Please log in to continue");
|
|
1294
|
-
}
|
|
1295
|
-
const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
|
|
1296
|
-
if (!session) {
|
|
1297
|
-
c.header("Set-Cookie", clearSessionCookie(c.env));
|
|
1298
|
-
return problems.unauthorized(c, "Your session has expired. Please log in again.");
|
|
1299
|
-
}
|
|
1300
|
-
c.set("userId", session.userId);
|
|
1301
|
-
await next();
|
|
1302
|
-
}
|
|
1303
|
-
);
|
|
1304
|
-
var optionalAuth = createMiddleware(
|
|
1305
|
-
async (c, next) => {
|
|
1306
|
-
const { db, schema } = getAuthContext(c);
|
|
1307
|
-
const authHeader = c.req.header("Authorization");
|
|
1308
|
-
if (authHeader) {
|
|
1309
|
-
const session = await getSessionFromBearerToken(db, schema.sessions, authHeader);
|
|
1310
|
-
if (session) {
|
|
1311
|
-
c.set("userId", session.userId);
|
|
1312
|
-
await next();
|
|
1313
|
-
return;
|
|
1314
|
-
}
|
|
1315
|
-
}
|
|
1316
|
-
const cookieHeader = c.req.header("cookie");
|
|
1317
|
-
if (cookieHeader) {
|
|
1318
|
-
const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
|
|
1319
|
-
if (session) {
|
|
1320
|
-
c.set("userId", session.userId);
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
await next();
|
|
1324
|
-
}
|
|
1325
|
-
);
|
|
1326
|
-
|
|
1327
|
-
// src/middleware/csrf.ts
|
|
1328
|
-
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
1329
|
-
var csrf = createMiddleware2(async (c, next) => {
|
|
1330
|
-
const method = c.req.method;
|
|
1331
|
-
const path = c.req.path;
|
|
1332
|
-
const isWebhook = path.startsWith("/v1/webhooks/");
|
|
1333
|
-
if (isWebhook) {
|
|
1334
|
-
await next();
|
|
1335
|
-
return;
|
|
1336
|
-
}
|
|
1337
|
-
const isMcpEndpoint = path === "/v1/mcp";
|
|
1338
|
-
if (isMcpEndpoint) {
|
|
1339
|
-
await next();
|
|
1340
|
-
return;
|
|
1341
|
-
}
|
|
1342
|
-
const isOAuthMcpEndpoint = path.startsWith("/v1/oauth/");
|
|
1343
|
-
if (isOAuthMcpEndpoint) {
|
|
1344
|
-
await next();
|
|
1345
|
-
return;
|
|
1346
|
-
}
|
|
1347
|
-
const isToolsApi = path.startsWith("/v1/tools/") && !path.startsWith("/v1/tools/catalog");
|
|
1348
|
-
if (isToolsApi) {
|
|
1349
|
-
await next();
|
|
1350
|
-
return;
|
|
1351
|
-
}
|
|
1352
|
-
const isAdminApi = path.startsWith("/v1/admin/");
|
|
1353
|
-
if (isAdminApi) {
|
|
1354
|
-
await next();
|
|
1355
|
-
return;
|
|
1356
|
-
}
|
|
1357
|
-
const isMobileAuthEndpoint = path === "/v1/auth/login" || path === "/v1/auth/signup" || path === "/v1/auth/refresh";
|
|
1358
|
-
const authHeader = c.req.header("Authorization");
|
|
1359
|
-
const hasBearerToken = authHeader?.startsWith("Bearer ");
|
|
1360
|
-
const hasNoOrigin = !c.req.header("origin") && !c.req.header("referer");
|
|
1361
|
-
if (isMobileAuthEndpoint && hasNoOrigin || hasBearerToken) {
|
|
1362
|
-
await next();
|
|
1363
|
-
return;
|
|
1364
|
-
}
|
|
1365
|
-
const isInternalSync = path.startsWith("/v1/internal/sync/");
|
|
1366
|
-
if (isInternalSync) {
|
|
1367
|
-
await next();
|
|
1368
|
-
return;
|
|
1369
|
-
}
|
|
1370
|
-
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
|
1371
|
-
const appUrl = c.env.APP_URL || "http://localhost:5173";
|
|
1372
|
-
const allowedOrigins = getAllowedOrigins(appUrl);
|
|
1373
|
-
const request = c.req.raw;
|
|
1374
|
-
const isTestEndpoint = c.req.path.startsWith("/test");
|
|
1375
|
-
if (isTestEndpoint) {
|
|
1376
|
-
const origin = request.headers.get("origin");
|
|
1377
|
-
const referer = request.headers.get("referer");
|
|
1378
|
-
logger_default.debug("CSRF validation for test endpoint", {
|
|
1379
|
-
path: c.req.path,
|
|
1380
|
-
method,
|
|
1381
|
-
origin,
|
|
1382
|
-
referer,
|
|
1383
|
-
appUrl,
|
|
1384
|
-
allowedOrigins
|
|
1385
|
-
});
|
|
1386
|
-
}
|
|
1387
|
-
const pagesPattern = c.env.CORS_PAGES_PATTERN;
|
|
1388
|
-
if (!validateOrigin(request, allowedOrigins, pagesPattern)) {
|
|
1389
|
-
return problems.forbidden(c, "Request blocked for security reasons. Please try again.");
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
await next();
|
|
1393
|
-
});
|
|
1394
|
-
|
|
1395
|
-
// src/middleware/rateLimit.ts
|
|
1396
|
-
import { createMiddleware as createMiddleware3 } from "hono/factory";
|
|
1397
|
-
|
|
1398
|
-
// src/lib/rate-limit-kv.ts
|
|
1399
|
-
async function checkRateLimitKV(kv, key, maxAttempts, windowSeconds) {
|
|
1400
|
-
try {
|
|
1401
|
-
const rateLimitKey = `rate_limit:${key}`;
|
|
1402
|
-
const currentAttemptsStr = await kv.get(rateLimitKey);
|
|
1403
|
-
const currentAttempts = currentAttemptsStr ? parseInt(currentAttemptsStr, 10) : 0;
|
|
1404
|
-
if (currentAttempts >= maxAttempts) {
|
|
1405
|
-
return {
|
|
1406
|
-
allowed: false,
|
|
1407
|
-
remaining: 0
|
|
1408
|
-
};
|
|
1409
|
-
}
|
|
1410
|
-
const newAttempts = currentAttempts + 1;
|
|
1411
|
-
await kv.put(rateLimitKey, newAttempts.toString(), {
|
|
1412
|
-
expirationTtl: windowSeconds
|
|
1413
|
-
});
|
|
1414
|
-
return {
|
|
1415
|
-
allowed: true,
|
|
1416
|
-
remaining: maxAttempts - newAttempts
|
|
1417
|
-
};
|
|
1418
|
-
} catch (error) {
|
|
1419
|
-
logger_default.error("Rate limit KV error, failing open", {
|
|
1420
|
-
key,
|
|
1421
|
-
error: error instanceof Error ? error.message : "Unknown error"
|
|
1422
|
-
});
|
|
1423
|
-
return {
|
|
1424
|
-
allowed: true,
|
|
1425
|
-
remaining: maxAttempts
|
|
1426
|
-
};
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
// src/middleware/rateLimit.ts
|
|
1431
|
-
var rateLimit = (config) => createMiddleware3(async (c, next) => {
|
|
1432
|
-
const kv = c.env.RATE_LIMIT_KV;
|
|
1433
|
-
const identifier = await config.identifier(c);
|
|
1434
|
-
const key = `${config.action}:${identifier}`;
|
|
1435
|
-
const windowSeconds = Math.ceil(config.windowMs / 1e3);
|
|
1436
|
-
const { allowed, remaining } = await checkRateLimitKV(
|
|
1437
|
-
kv,
|
|
1438
|
-
key,
|
|
1439
|
-
config.maxAttempts,
|
|
1440
|
-
windowSeconds
|
|
1441
|
-
);
|
|
1442
|
-
const resetTimestamp = Math.ceil(Date.now() / 1e3) + windowSeconds;
|
|
1443
|
-
c.header("X-RateLimit-Limit", config.maxAttempts.toString());
|
|
1444
|
-
c.header("X-RateLimit-Remaining", Math.max(0, remaining).toString());
|
|
1445
|
-
c.header("X-RateLimit-Reset", resetTimestamp.toString());
|
|
1446
|
-
if (!allowed) {
|
|
1447
|
-
c.header("Retry-After", windowSeconds.toString());
|
|
1448
|
-
return problems.rateLimitExceeded(c, windowSeconds);
|
|
1449
|
-
}
|
|
1450
|
-
await next();
|
|
1451
|
-
});
|
|
1452
|
-
|
|
1453
|
-
// src/middleware/require-verified-email.ts
|
|
1454
|
-
import { createMiddleware as createMiddleware4 } from "hono/factory";
|
|
1455
|
-
import { eq as eq5 } from "drizzle-orm";
|
|
1456
|
-
var requireVerifiedEmail = createMiddleware4(
|
|
1457
|
-
async (c, next) => {
|
|
1458
|
-
const userId = c.get("userId");
|
|
1459
|
-
if (!userId) {
|
|
1460
|
-
return problems.unauthorized(c, "Not authenticated");
|
|
1461
|
-
}
|
|
1462
|
-
const { db, schema } = getAuthContext(c);
|
|
1463
|
-
const database = db;
|
|
1464
|
-
const users = schema.users;
|
|
1465
|
-
const [user] = await database.select({ emailVerified: users.emailVerified }).from(users).where(eq5(users.id, userId)).limit(1);
|
|
1466
|
-
if (!user || !user.emailVerified) {
|
|
1467
|
-
return problems.forbidden(c, "Please verify your email address to continue");
|
|
1468
|
-
}
|
|
1469
|
-
await next();
|
|
1470
|
-
}
|
|
1471
|
-
);
|
|
1472
|
-
|
|
1473
|
-
// src/routes/index.ts
|
|
1474
|
-
import { OpenAPIHono as OpenAPIHono3 } from "@hono/zod-openapi";
|
|
1475
|
-
|
|
1476
|
-
// src/routes/signup.ts
|
|
1477
|
-
import { createRoute } from "@hono/zod-openapi";
|
|
1478
|
-
import { eq as eq8 } from "drizzle-orm";
|
|
1479
|
-
|
|
1480
|
-
// src/lib/email/email-service.ts
|
|
1481
|
-
import { eq as eq6 } from "drizzle-orm";
|
|
1482
|
-
|
|
1483
|
-
// src/lib/email/security.ts
|
|
1484
|
-
function getAllowedDomains(appUrl) {
|
|
1485
|
-
const base = ["localhost", "127.0.0.1"];
|
|
1486
|
-
try {
|
|
1487
|
-
const url = new URL(appUrl);
|
|
1488
|
-
return [...base, url.hostname];
|
|
1489
|
-
} catch {
|
|
1490
|
-
return base;
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
function sanitizeEmailUrl(url, appUrl = "") {
|
|
1494
|
-
let parsed;
|
|
1495
|
-
try {
|
|
1496
|
-
parsed = new URL(url);
|
|
1497
|
-
} catch {
|
|
1498
|
-
throw new Error("Invalid URL format");
|
|
1499
|
-
}
|
|
1500
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1501
|
-
throw new Error(`Disallowed protocol: ${parsed.protocol}`);
|
|
1502
|
-
}
|
|
1503
|
-
const allowedDomains = getAllowedDomains(appUrl);
|
|
1504
|
-
const hostname = parsed.hostname.toLowerCase();
|
|
1505
|
-
const isAllowed = allowedDomains.some((domain) => {
|
|
1506
|
-
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
1507
|
-
});
|
|
1508
|
-
if (!isAllowed) {
|
|
1509
|
-
throw new Error(`Disallowed domain: ${hostname}`);
|
|
1510
|
-
}
|
|
1511
|
-
return parsed.toString();
|
|
1512
|
-
}
|
|
1513
|
-
function escapeHtml(text) {
|
|
1514
|
-
const htmlEscapeMap = {
|
|
1515
|
-
"&": "&",
|
|
1516
|
-
"<": "<",
|
|
1517
|
-
">": ">",
|
|
1518
|
-
'"': """,
|
|
1519
|
-
"'": "'"
|
|
1520
|
-
};
|
|
1521
|
-
return text.replace(/[&<>"']/g, (char) => htmlEscapeMap[char]);
|
|
985
|
+
function escapeHtml(text) {
|
|
986
|
+
const htmlEscapeMap = {
|
|
987
|
+
"&": "&",
|
|
988
|
+
"<": "<",
|
|
989
|
+
">": ">",
|
|
990
|
+
'"': """,
|
|
991
|
+
"'": "'"
|
|
992
|
+
};
|
|
993
|
+
return text.replace(/[&<>"']/g, (char) => htmlEscapeMap[char]);
|
|
1522
994
|
}
|
|
1523
995
|
|
|
1524
996
|
// src/core/email-template.ts
|
|
@@ -1540,8 +1012,10 @@ function generateEmailTemplate(options) {
|
|
|
1540
1012
|
buttonFontWeight = 500,
|
|
1541
1013
|
buttonRadius = "6px",
|
|
1542
1014
|
logoUrl,
|
|
1543
|
-
footerColor = "#1e1b4b"
|
|
1015
|
+
footerColor = "#1e1b4b",
|
|
1016
|
+
headingColor = "#1e1b4b"
|
|
1544
1017
|
} = options;
|
|
1018
|
+
const safeAppName = escapeHtml(appName);
|
|
1545
1019
|
const safeButtonUrl = buttonUrl ? sanitizeEmailUrl(buttonUrl, appUrl) : "";
|
|
1546
1020
|
const safeAppUrl = appUrl ? sanitizeEmailUrl(appUrl, appUrl) : "";
|
|
1547
1021
|
const safeMarketingUrl = marketingUrl ? sanitizeEmailUrl(marketingUrl, appUrl) : "";
|
|
@@ -1685,7 +1159,7 @@ function generateEmailTemplate(options) {
|
|
|
1685
1159
|
<td class="pad" style="padding-left:40px;padding-right:40px;width:100%;">
|
|
1686
1160
|
<div class="alignment" align="center">
|
|
1687
1161
|
<div class="fullWidth" style="max-width: 50%;">
|
|
1688
|
-
<img src="${safeLogoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${
|
|
1162
|
+
<img src="${safeLogoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${safeAppName}" title="${safeAppName}" height="auto">
|
|
1689
1163
|
</div>
|
|
1690
1164
|
</div>
|
|
1691
1165
|
</td>
|
|
@@ -1697,7 +1171,7 @@ function generateEmailTemplate(options) {
|
|
|
1697
1171
|
<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;">
|
|
1698
1172
|
<tr>
|
|
1699
1173
|
<td class="pad" style="padding-top:30px;padding-left:40px;padding-right:40px;">
|
|
1700
|
-
<div style="color
|
|
1174
|
+
<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;">
|
|
1701
1175
|
<p style="margin: 0; word-break: break-word;">${welcomeMessage}</p>
|
|
1702
1176
|
</div>
|
|
1703
1177
|
</td>
|
|
@@ -1710,7 +1184,7 @@ function generateEmailTemplate(options) {
|
|
|
1710
1184
|
<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;">
|
|
1711
1185
|
<tr>
|
|
1712
1186
|
<td class="pad" style="padding-bottom:10px;padding-left:40px;padding-right:40px;padding-top:10px;">
|
|
1713
|
-
<div style="color
|
|
1187
|
+
<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;">
|
|
1714
1188
|
<p style="margin: 0; word-break: break-word;">${heading}</p>
|
|
1715
1189
|
</div>
|
|
1716
1190
|
</td>
|
|
@@ -1766,122 +1240,785 @@ function generateEmailTemplate(options) {
|
|
|
1766
1240
|
</td>
|
|
1767
1241
|
</tr>
|
|
1768
1242
|
</table>
|
|
1769
|
-
` : ""}
|
|
1243
|
+
` : ""}
|
|
1244
|
+
|
|
1245
|
+
${securityWarning ? `
|
|
1246
|
+
<!-- Security Warning -->
|
|
1247
|
+
<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;">
|
|
1248
|
+
<tr>
|
|
1249
|
+
<td class="pad" style="padding-left:40px;padding-right:40px;padding-bottom:20px;">
|
|
1250
|
+
<div style="background-color: #a1f1ff; border-radius: 8px; padding: 16px; display: flex; align-items: center; justify-content: center;">
|
|
1251
|
+
<p style="margin: 0; color: #01001b; font-family: 'Montserrat', Arial, sans-serif; font-size: 13px; line-height: 1.5; text-align: center;">
|
|
1252
|
+
${securityWarning}
|
|
1253
|
+
</p>
|
|
1254
|
+
</div>
|
|
1255
|
+
</td>
|
|
1256
|
+
</tr>
|
|
1257
|
+
</table>
|
|
1258
|
+
` : ""}
|
|
1259
|
+
|
|
1260
|
+
<!-- Footer Note -->
|
|
1261
|
+
<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;">
|
|
1262
|
+
<tr>
|
|
1263
|
+
<td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
|
|
1264
|
+
<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;">
|
|
1265
|
+
<p style="margin: 0; word-break: break-word;">${footerNote}</p>
|
|
1266
|
+
</div>
|
|
1267
|
+
</td>
|
|
1268
|
+
</tr>
|
|
1269
|
+
</table>
|
|
1270
|
+
|
|
1271
|
+
</td>
|
|
1272
|
+
</tr>
|
|
1273
|
+
</tbody>
|
|
1274
|
+
</table>
|
|
1275
|
+
</td>
|
|
1276
|
+
</tr>
|
|
1277
|
+
</tbody>
|
|
1278
|
+
</table>
|
|
1279
|
+
|
|
1280
|
+
<!-- Spacer Row -->
|
|
1281
|
+
<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;">
|
|
1282
|
+
<tbody>
|
|
1283
|
+
<tr>
|
|
1284
|
+
<td>
|
|
1285
|
+
<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;">
|
|
1286
|
+
<tbody>
|
|
1287
|
+
<tr>
|
|
1288
|
+
<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;">
|
|
1289
|
+
<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;">
|
|
1290
|
+
<tr>
|
|
1291
|
+
<td class="pad">
|
|
1292
|
+
<div></div>
|
|
1293
|
+
</td>
|
|
1294
|
+
</tr>
|
|
1295
|
+
</table>
|
|
1296
|
+
</td>
|
|
1297
|
+
</tr>
|
|
1298
|
+
</tbody>
|
|
1299
|
+
</table>
|
|
1300
|
+
</td>
|
|
1301
|
+
</tr>
|
|
1302
|
+
</tbody>
|
|
1303
|
+
</table>
|
|
1304
|
+
|
|
1305
|
+
<!-- Footer Row -->
|
|
1306
|
+
<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;">
|
|
1307
|
+
<tbody>
|
|
1308
|
+
<tr>
|
|
1309
|
+
<td>
|
|
1310
|
+
<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;">
|
|
1311
|
+
<tbody>
|
|
1312
|
+
<tr>
|
|
1313
|
+
<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;">
|
|
1314
|
+
|
|
1315
|
+
<!-- Policy Links -->
|
|
1316
|
+
<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;">
|
|
1317
|
+
<tr>
|
|
1318
|
+
<td class="pad" style="padding-bottom:20px;padding-left:40px;padding-right:40px;padding-top:20px;">
|
|
1319
|
+
<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;">
|
|
1320
|
+
<p style="margin: 0; word-break: break-word;">
|
|
1321
|
+
<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>
|
|
1322
|
+
</p>
|
|
1323
|
+
</div>
|
|
1324
|
+
</td>
|
|
1325
|
+
</tr>
|
|
1326
|
+
</table>
|
|
1327
|
+
|
|
1328
|
+
<!-- Copyright & Disclaimer -->
|
|
1329
|
+
<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;">
|
|
1330
|
+
<tr>
|
|
1331
|
+
<td class="pad" style="padding-bottom:15px;padding-left:40px;padding-right:40px;">
|
|
1332
|
+
<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;">
|
|
1333
|
+
<p style="margin: 0 0 12px 0; word-break: break-word;">\xA9 ${(/* @__PURE__ */ new Date()).getFullYear()} ${safeAppName}. All rights reserved.</p>
|
|
1334
|
+
<p style="margin: 0; font-size: 11px; line-height: 1.5; word-break: break-word;">
|
|
1335
|
+
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.
|
|
1336
|
+
</p>
|
|
1337
|
+
</div>
|
|
1338
|
+
</td>
|
|
1339
|
+
</tr>
|
|
1340
|
+
</table>
|
|
1341
|
+
|
|
1342
|
+
</td>
|
|
1343
|
+
</tr>
|
|
1344
|
+
</tbody>
|
|
1345
|
+
</table>
|
|
1346
|
+
</td>
|
|
1347
|
+
</tr>
|
|
1348
|
+
</tbody>
|
|
1349
|
+
</table>
|
|
1350
|
+
|
|
1351
|
+
</td>
|
|
1352
|
+
</tr>
|
|
1353
|
+
</tbody>
|
|
1354
|
+
</table>
|
|
1355
|
+
</body>
|
|
1356
|
+
</html>
|
|
1357
|
+
`;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// src/core/passwordless.ts
|
|
1361
|
+
var PASSWORDLESS_TTL_MS = 10 * 60 * 1e3;
|
|
1362
|
+
var PASSWORDLESS_MAX_ATTEMPTS = 5;
|
|
1363
|
+
var CODE_SPACE = 1e6;
|
|
1364
|
+
var CODE_LIMIT = Math.floor(4294967296 / CODE_SPACE) * CODE_SPACE;
|
|
1365
|
+
var CLEANUP_GRACE_MS = 24 * 60 * 60 * 1e3;
|
|
1366
|
+
var normalizeEmail = (email) => email.trim().toLowerCase();
|
|
1367
|
+
function generateCode() {
|
|
1368
|
+
const buffer = new Uint32Array(1);
|
|
1369
|
+
for (; ; ) {
|
|
1370
|
+
crypto.getRandomValues(buffer);
|
|
1371
|
+
if (buffer[0] < CODE_LIMIT) return String(buffer[0] % CODE_SPACE).padStart(6, "0");
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
var codeHash = (challengeId, code) => hashToken(`${challengeId}:${code}`);
|
|
1375
|
+
function constantTimeEqual(a, b) {
|
|
1376
|
+
if (a.length !== b.length) return false;
|
|
1377
|
+
let diff = 0;
|
|
1378
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1379
|
+
return diff === 0;
|
|
1380
|
+
}
|
|
1381
|
+
async function consumeOpen(db, t, email, now) {
|
|
1382
|
+
await db.update(t).set({ consumedAt: now }).where(and2(eq4(t.email, email), isNull(t.consumedAt)));
|
|
1383
|
+
}
|
|
1384
|
+
async function createPasswordlessChallenge(db, tables2, input) {
|
|
1385
|
+
const t = tables2.passwordlessChallenges;
|
|
1386
|
+
const email = normalizeEmail(input.email);
|
|
1387
|
+
const now = Date.now();
|
|
1388
|
+
const challengeId = crypto.randomUUID();
|
|
1389
|
+
const linkToken = generateSecureToken(32);
|
|
1390
|
+
const browserSecret = generateSecureToken(32);
|
|
1391
|
+
const code = generateCode();
|
|
1392
|
+
await consumeOpen(db, t, email, now);
|
|
1393
|
+
await db.insert(t).values({
|
|
1394
|
+
id: challengeId,
|
|
1395
|
+
email,
|
|
1396
|
+
subjectId: input.subjectId ?? null,
|
|
1397
|
+
linkTokenHash: await hashToken(linkToken),
|
|
1398
|
+
codeHash: await codeHash(challengeId, code),
|
|
1399
|
+
browserHash: await hashToken(browserSecret),
|
|
1400
|
+
attempts: 0,
|
|
1401
|
+
payload: input.payload ?? null,
|
|
1402
|
+
expiresAt: now + (input.ttlMs ?? PASSWORDLESS_TTL_MS),
|
|
1403
|
+
createdAt: now
|
|
1404
|
+
});
|
|
1405
|
+
return { challengeId, linkToken, code, browserSecret };
|
|
1406
|
+
}
|
|
1407
|
+
async function verifyPasswordlessLink(db, tables2, input) {
|
|
1408
|
+
const t = tables2.passwordlessChallenges;
|
|
1409
|
+
const now = Date.now();
|
|
1410
|
+
const hash = await hashToken(input.linkToken);
|
|
1411
|
+
const [row] = await db.update(t).set({ consumedAt: now }).where(and2(eq4(t.linkTokenHash, hash), isNull(t.consumedAt), gt2(t.expiresAt, now))).returning();
|
|
1412
|
+
if (!row) {
|
|
1413
|
+
const [stale] = await db.select({ expiresAt: t.expiresAt, consumedAt: t.consumedAt }).from(t).where(eq4(t.linkTokenHash, hash)).limit(1);
|
|
1414
|
+
const expired = stale && stale.consumedAt === null && stale.expiresAt <= now;
|
|
1415
|
+
return { ok: false, reason: expired ? "expired" : "invalid" };
|
|
1416
|
+
}
|
|
1417
|
+
await consumeOpen(db, t, row.email, now);
|
|
1418
|
+
return { ok: true, email: row.email, subjectId: row.subjectId, payload: row.payload };
|
|
1419
|
+
}
|
|
1420
|
+
async function verifyPasswordlessCode(db, tables2, input) {
|
|
1421
|
+
const t = tables2.passwordlessChallenges;
|
|
1422
|
+
const max = input.maxAttempts ?? PASSWORDLESS_MAX_ATTEMPTS;
|
|
1423
|
+
const email = normalizeEmail(input.email);
|
|
1424
|
+
const now = Date.now();
|
|
1425
|
+
const [row] = await db.select().from(t).where(and2(eq4(t.email, email), isNull(t.consumedAt))).orderBy(desc(t.createdAt)).limit(1);
|
|
1426
|
+
if (!row) return { ok: false, reason: "invalid" };
|
|
1427
|
+
if (!constantTimeEqual(row.browserHash, await hashToken(input.browserSecret))) {
|
|
1428
|
+
return { ok: false, reason: "invalid" };
|
|
1429
|
+
}
|
|
1430
|
+
if (row.expiresAt <= now) return { ok: false, reason: "expired" };
|
|
1431
|
+
if (row.attempts >= max) return { ok: false, reason: "locked" };
|
|
1432
|
+
if (!constantTimeEqual(row.codeHash, await codeHash(row.id, input.code.trim()))) {
|
|
1433
|
+
const [counted] = await db.update(t).set({ attempts: sql2`${t.attempts} + 1` }).where(and2(eq4(t.id, row.id), lt(t.attempts, max))).returning({ attempts: t.attempts });
|
|
1434
|
+
return { ok: false, reason: counted && counted.attempts < max ? "invalid" : "locked" };
|
|
1435
|
+
}
|
|
1436
|
+
const [won] = await db.update(t).set({ consumedAt: now }).where(and2(eq4(t.id, row.id), isNull(t.consumedAt), lt(t.attempts, max))).returning();
|
|
1437
|
+
if (!won) return { ok: false, reason: "invalid" };
|
|
1438
|
+
await consumeOpen(db, t, email, now);
|
|
1439
|
+
return { ok: true, email, subjectId: won.subjectId, payload: won.payload };
|
|
1440
|
+
}
|
|
1441
|
+
async function recentPasswordlessFailures(db, tables2, input) {
|
|
1442
|
+
const t = tables2.passwordlessChallenges;
|
|
1443
|
+
const [row] = await db.select({ total: sql2`coalesce(sum(${t.attempts}), 0)::int` }).from(t).where(and2(eq4(t.email, normalizeEmail(input.email)), gt2(t.createdAt, Date.now() - input.windowMs)));
|
|
1444
|
+
return Number(row?.total ?? 0);
|
|
1445
|
+
}
|
|
1446
|
+
async function cleanupExpiredPasswordlessChallenges(db, tables2) {
|
|
1447
|
+
const t = tables2.passwordlessChallenges;
|
|
1448
|
+
await db.delete(t).where(lt(t.expiresAt, Date.now() - CLEANUP_GRACE_MS));
|
|
1449
|
+
}
|
|
1450
|
+
function passwordlessEmail(input) {
|
|
1451
|
+
const { appName, appUrl, linkUrl, code } = input;
|
|
1452
|
+
const minutes = Math.max(1, Math.round((input.ttlMs ?? PASSWORDLESS_TTL_MS) / 6e4));
|
|
1453
|
+
const lifetime = `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
1454
|
+
const safeLink = sanitizeEmailUrl(linkUrl, appUrl);
|
|
1455
|
+
const html = generateEmailTemplate({
|
|
1456
|
+
heading: `Sign in to ${escapeHtml(appName)}`,
|
|
1457
|
+
body: `<p>Use this code, or the button below, to sign in:</p>
|
|
1458
|
+
<p style="font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 24px 0; font-family: monospace;">${escapeHtml(code)}</p>
|
|
1459
|
+
<p>The code and the link expire in ${lifetime}.</p>`,
|
|
1460
|
+
buttonText: "Sign in",
|
|
1461
|
+
buttonUrl: safeLink,
|
|
1462
|
+
footerNote: "If you didn't ask to sign in, you can ignore this email.",
|
|
1463
|
+
appUrl,
|
|
1464
|
+
appName
|
|
1465
|
+
});
|
|
1466
|
+
return {
|
|
1467
|
+
subject: `${code} is your sign-in code`,
|
|
1468
|
+
html,
|
|
1469
|
+
text: `Your ${appName} sign-in code is ${code}.
|
|
1470
|
+
|
|
1471
|
+
Or sign in with this link:
|
|
1472
|
+
${safeLink}
|
|
1473
|
+
|
|
1474
|
+
The code and the link expire in ${lifetime}.
|
|
1475
|
+
|
|
1476
|
+
If you didn't ask to sign in, you can ignore this email.`
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// src/middleware/auth.ts
|
|
1481
|
+
import { createMiddleware } from "hono/factory";
|
|
1482
|
+
import { eq as eq5, and as and3, gt as gt3 } from "drizzle-orm";
|
|
1483
|
+
|
|
1484
|
+
// src/factory.ts
|
|
1485
|
+
function createAuth(config) {
|
|
1486
|
+
const { db, schema, getEnv, emailBranding, invitations, signup = "open" } = config;
|
|
1487
|
+
if (signup === "invitation" && !invitations) {
|
|
1488
|
+
throw new Error("createAuth: signup: 'invitation' requires the invitations hooks");
|
|
1489
|
+
}
|
|
1490
|
+
const getContext = (c) => ({
|
|
1491
|
+
db,
|
|
1492
|
+
schema,
|
|
1493
|
+
env: getEnv(c),
|
|
1494
|
+
emailBranding,
|
|
1495
|
+
invitations,
|
|
1496
|
+
signup
|
|
1497
|
+
});
|
|
1498
|
+
return {
|
|
1499
|
+
db,
|
|
1500
|
+
schema,
|
|
1501
|
+
getContext
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
function createAuthMiddleware(auth2) {
|
|
1505
|
+
return async (c, next) => {
|
|
1506
|
+
const ctx = auth2.getContext(c);
|
|
1507
|
+
c.set("authContext", ctx);
|
|
1508
|
+
c.set("db", auth2.db);
|
|
1509
|
+
await next();
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
function getAuthContext(c) {
|
|
1513
|
+
const ctx = c.get("authContext");
|
|
1514
|
+
if (!ctx) {
|
|
1515
|
+
throw new Error(
|
|
1516
|
+
"AuthContext not found. Did you apply createAuthMiddleware before the auth routes?"
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
return ctx;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// src/lib/problem-json.ts
|
|
1523
|
+
var ProblemTypes = {
|
|
1524
|
+
// 4xx Client Errors
|
|
1525
|
+
BAD_REQUEST: "/errors/bad-request",
|
|
1526
|
+
UNAUTHORIZED: "/errors/unauthorized",
|
|
1527
|
+
FORBIDDEN: "/errors/forbidden",
|
|
1528
|
+
NOT_FOUND: "/errors/not-found",
|
|
1529
|
+
CONFLICT: "/errors/conflict",
|
|
1530
|
+
GONE: "/errors/gone",
|
|
1531
|
+
VALIDATION_ERROR: "/errors/validation-error",
|
|
1532
|
+
RATE_LIMIT_EXCEEDED: "/errors/rate-limit-exceeded",
|
|
1533
|
+
// 5xx Server Errors
|
|
1534
|
+
INTERNAL_ERROR: "/errors/internal-error",
|
|
1535
|
+
SERVICE_UNAVAILABLE: "/errors/service-unavailable"
|
|
1536
|
+
};
|
|
1537
|
+
function generateTraceId() {
|
|
1538
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1539
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1540
|
+
}
|
|
1541
|
+
function createProblemDetails(type, title, status, options) {
|
|
1542
|
+
const problem = {
|
|
1543
|
+
type,
|
|
1544
|
+
title,
|
|
1545
|
+
status
|
|
1546
|
+
};
|
|
1547
|
+
if (options?.detail) {
|
|
1548
|
+
problem.detail = options.detail;
|
|
1549
|
+
}
|
|
1550
|
+
if (options?.instance) {
|
|
1551
|
+
problem.instance = options.instance;
|
|
1552
|
+
}
|
|
1553
|
+
problem.traceId = options?.traceId || generateTraceId();
|
|
1554
|
+
if (options?.requestId) {
|
|
1555
|
+
problem.requestId = options.requestId;
|
|
1556
|
+
}
|
|
1557
|
+
if (options?.extensions) {
|
|
1558
|
+
Object.assign(problem, options.extensions);
|
|
1559
|
+
}
|
|
1560
|
+
return problem;
|
|
1561
|
+
}
|
|
1562
|
+
function problemJson(c, problem) {
|
|
1563
|
+
logger_default.warn("API error response", {
|
|
1564
|
+
type: problem.type,
|
|
1565
|
+
status: problem.status,
|
|
1566
|
+
traceId: problem.traceId,
|
|
1567
|
+
requestId: problem.requestId,
|
|
1568
|
+
path: c.req.path,
|
|
1569
|
+
method: c.req.method
|
|
1570
|
+
});
|
|
1571
|
+
return c.json(problem, problem.status, {
|
|
1572
|
+
"Content-Type": "application/problem+json"
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
var problems = {
|
|
1576
|
+
/**
|
|
1577
|
+
* 400 Bad Request - Generic client error
|
|
1578
|
+
*/
|
|
1579
|
+
badRequest(c, detail) {
|
|
1580
|
+
return problemJson(
|
|
1581
|
+
c,
|
|
1582
|
+
createProblemDetails(ProblemTypes.BAD_REQUEST, "Bad Request", 400, {
|
|
1583
|
+
detail: detail || "The request could not be understood by the server",
|
|
1584
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1585
|
+
requestId: c.get("requestId"),
|
|
1586
|
+
traceId: c.get("traceId")
|
|
1587
|
+
})
|
|
1588
|
+
);
|
|
1589
|
+
},
|
|
1590
|
+
/**
|
|
1591
|
+
* 400 Bad Request - Validation errors with field-specific details
|
|
1592
|
+
*/
|
|
1593
|
+
validationError(c, detail, errors) {
|
|
1594
|
+
return problemJson(
|
|
1595
|
+
c,
|
|
1596
|
+
createProblemDetails(ProblemTypes.VALIDATION_ERROR, "Validation Error", 400, {
|
|
1597
|
+
detail,
|
|
1598
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1599
|
+
requestId: c.get("requestId"),
|
|
1600
|
+
traceId: c.get("traceId"),
|
|
1601
|
+
extensions: errors ? { errors } : void 0
|
|
1602
|
+
})
|
|
1603
|
+
);
|
|
1604
|
+
},
|
|
1605
|
+
/**
|
|
1606
|
+
* 401 Unauthorized - Authentication required
|
|
1607
|
+
*/
|
|
1608
|
+
unauthorized(c, detail) {
|
|
1609
|
+
return problemJson(
|
|
1610
|
+
c,
|
|
1611
|
+
createProblemDetails(ProblemTypes.UNAUTHORIZED, "Unauthorized", 401, {
|
|
1612
|
+
detail: detail || "Authentication is required to access this resource",
|
|
1613
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1614
|
+
requestId: c.get("requestId"),
|
|
1615
|
+
traceId: c.get("traceId")
|
|
1616
|
+
})
|
|
1617
|
+
);
|
|
1618
|
+
},
|
|
1619
|
+
/**
|
|
1620
|
+
* 403 Forbidden - Insufficient permissions
|
|
1621
|
+
*/
|
|
1622
|
+
forbidden(c, detail) {
|
|
1623
|
+
return problemJson(
|
|
1624
|
+
c,
|
|
1625
|
+
createProblemDetails(ProblemTypes.FORBIDDEN, "Forbidden", 403, {
|
|
1626
|
+
detail: detail || "You do not have permission to access this resource",
|
|
1627
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1628
|
+
requestId: c.get("requestId"),
|
|
1629
|
+
traceId: c.get("traceId")
|
|
1630
|
+
})
|
|
1631
|
+
);
|
|
1632
|
+
},
|
|
1633
|
+
/**
|
|
1634
|
+
* 404 Not Found
|
|
1635
|
+
*/
|
|
1636
|
+
notFound(c, detail) {
|
|
1637
|
+
return problemJson(
|
|
1638
|
+
c,
|
|
1639
|
+
createProblemDetails(ProblemTypes.NOT_FOUND, "Not Found", 404, {
|
|
1640
|
+
detail: detail || "The requested resource was not found",
|
|
1641
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1642
|
+
requestId: c.get("requestId"),
|
|
1643
|
+
traceId: c.get("traceId")
|
|
1644
|
+
})
|
|
1645
|
+
);
|
|
1646
|
+
},
|
|
1647
|
+
/**
|
|
1648
|
+
* 409 Conflict - Resource conflict (e.g., duplicate email)
|
|
1649
|
+
*/
|
|
1650
|
+
conflict(c, detail) {
|
|
1651
|
+
return problemJson(
|
|
1652
|
+
c,
|
|
1653
|
+
createProblemDetails(ProblemTypes.CONFLICT, "Conflict", 409, {
|
|
1654
|
+
detail,
|
|
1655
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1656
|
+
requestId: c.get("requestId"),
|
|
1657
|
+
traceId: c.get("traceId")
|
|
1658
|
+
})
|
|
1659
|
+
);
|
|
1660
|
+
},
|
|
1661
|
+
/**
|
|
1662
|
+
* 410 Gone - Resource is no longer available
|
|
1663
|
+
*/
|
|
1664
|
+
gone(c, detail) {
|
|
1665
|
+
return problemJson(
|
|
1666
|
+
c,
|
|
1667
|
+
createProblemDetails(ProblemTypes.GONE, "Gone", 410, {
|
|
1668
|
+
detail: detail || "The requested resource is no longer available",
|
|
1669
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1670
|
+
requestId: c.get("requestId"),
|
|
1671
|
+
traceId: c.get("traceId")
|
|
1672
|
+
})
|
|
1673
|
+
);
|
|
1674
|
+
},
|
|
1675
|
+
/**
|
|
1676
|
+
* 429 Too Many Requests - Rate limit exceeded
|
|
1677
|
+
*/
|
|
1678
|
+
rateLimitExceeded(c, retryAfter) {
|
|
1679
|
+
const extensions = retryAfter ? { retryAfter } : void 0;
|
|
1680
|
+
return problemJson(
|
|
1681
|
+
c,
|
|
1682
|
+
createProblemDetails(ProblemTypes.RATE_LIMIT_EXCEEDED, "Rate Limit Exceeded", 429, {
|
|
1683
|
+
detail: retryAfter ? `Too many requests. Please retry after ${retryAfter} seconds` : "Too many requests. Please slow down",
|
|
1684
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1685
|
+
requestId: c.get("requestId"),
|
|
1686
|
+
traceId: c.get("traceId"),
|
|
1687
|
+
extensions
|
|
1688
|
+
})
|
|
1689
|
+
);
|
|
1690
|
+
},
|
|
1691
|
+
/**
|
|
1692
|
+
* 500 Internal Server Error - Generic server error
|
|
1693
|
+
* SECURITY: Never include error details or stack traces
|
|
1694
|
+
*/
|
|
1695
|
+
internalError(c, error) {
|
|
1696
|
+
const traceId = c.get("traceId") || generateTraceId();
|
|
1697
|
+
if (error) {
|
|
1698
|
+
logError(error, {
|
|
1699
|
+
context: "internal_error",
|
|
1700
|
+
path: c.req.path,
|
|
1701
|
+
method: c.req.method,
|
|
1702
|
+
traceId,
|
|
1703
|
+
requestId: c.get("requestId")
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
return problemJson(
|
|
1707
|
+
c,
|
|
1708
|
+
createProblemDetails(ProblemTypes.INTERNAL_ERROR, "Internal Server Error", 500, {
|
|
1709
|
+
detail: "An unexpected error occurred. Please try again later",
|
|
1710
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1711
|
+
traceId,
|
|
1712
|
+
requestId: c.get("requestId")
|
|
1713
|
+
})
|
|
1714
|
+
);
|
|
1715
|
+
},
|
|
1716
|
+
/**
|
|
1717
|
+
* 503 Service Unavailable - Service is temporarily unavailable
|
|
1718
|
+
*/
|
|
1719
|
+
serviceUnavailable(c, detail) {
|
|
1720
|
+
return problemJson(
|
|
1721
|
+
c,
|
|
1722
|
+
createProblemDetails(ProblemTypes.SERVICE_UNAVAILABLE, "Service Unavailable", 503, {
|
|
1723
|
+
detail: detail || "The service is temporarily unavailable. Please try again later",
|
|
1724
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1725
|
+
requestId: c.get("requestId"),
|
|
1726
|
+
traceId: c.get("traceId")
|
|
1727
|
+
})
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1770
1731
|
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1732
|
+
// src/middleware/auth.ts
|
|
1733
|
+
var SESSION_MAX_LIFETIME_MS = AUTH_DEFAULTS.SESSION_MAX_LIFETIME_DAYS * 24 * 60 * 60 * 1e3;
|
|
1734
|
+
async function revokeIfPastMaxLifetime(db, sessions, sessionId, session) {
|
|
1735
|
+
if (Date.now() - session.createdAt <= SESSION_MAX_LIFETIME_MS) return false;
|
|
1736
|
+
logger_default.info("Session exceeded absolute lifetime - revoked", {
|
|
1737
|
+
type: "security",
|
|
1738
|
+
event: "session_max_lifetime_exceeded",
|
|
1739
|
+
severity: "low",
|
|
1740
|
+
sessionId: sessionId.slice(0, 8),
|
|
1741
|
+
userId: session.userId
|
|
1742
|
+
});
|
|
1743
|
+
await deleteSession(db, { sessions }, sessionId);
|
|
1744
|
+
return true;
|
|
1745
|
+
}
|
|
1746
|
+
async function getSessionFromCookie(db, sessions, cookieHeader, request, env) {
|
|
1747
|
+
const cookieName = getSessionCookieName(env);
|
|
1748
|
+
const cookiePattern = new RegExp(`${cookieName}=([^;]+)`);
|
|
1749
|
+
const sessionIdMatch = cookieHeader.match(cookiePattern);
|
|
1750
|
+
if (!sessionIdMatch) return null;
|
|
1751
|
+
const sessionId = sessionIdMatch[1];
|
|
1752
|
+
const foundSessions = await db.select({
|
|
1753
|
+
userId: sessions.userId,
|
|
1754
|
+
expiresAt: sessions.expiresAt,
|
|
1755
|
+
createdAt: sessions.createdAt,
|
|
1756
|
+
lastActiveAt: sessions.lastActiveAt,
|
|
1757
|
+
fingerprint: sessions.fingerprint
|
|
1758
|
+
}).from(sessions).where(and3(eq5(sessions.id, sessionId), gt3(sessions.expiresAt, Date.now()))).limit(1);
|
|
1759
|
+
if (foundSessions.length === 0) return null;
|
|
1760
|
+
const session = foundSessions[0];
|
|
1761
|
+
if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
|
|
1762
|
+
if (sessionRefreshDue(session.lastActiveAt)) await refreshSession(db, { sessions }, sessionId);
|
|
1763
|
+
const userAgent = request.headers.get("user-agent") || "";
|
|
1764
|
+
const cfWorker = request.headers.get("cf-worker") || "";
|
|
1765
|
+
const clientIp = request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || "";
|
|
1766
|
+
const isLocalSSR = userAgent.toLowerCase().includes("node");
|
|
1767
|
+
const isSvelteKitSSR = userAgent.toLowerCase().includes("sveltekit");
|
|
1768
|
+
const isCloudflareSSR = !userAgent && cfWorker.includes(".pages.dev");
|
|
1769
|
+
const isSSR = isLocalSSR || isSvelteKitSSR || isCloudflareSSR;
|
|
1770
|
+
if (isSSR) {
|
|
1771
|
+
logger_default.info("SSR request detected, using IP validation", {
|
|
1772
|
+
type: "security",
|
|
1773
|
+
event: "ssr_session_validation",
|
|
1774
|
+
sessionId: sessionId.slice(0, 8),
|
|
1775
|
+
userId: session.userId,
|
|
1776
|
+
clientIp,
|
|
1777
|
+
cfWorker,
|
|
1778
|
+
isLocalSSR,
|
|
1779
|
+
isSvelteKitSSR,
|
|
1780
|
+
isCloudflareSSR
|
|
1781
|
+
});
|
|
1782
|
+
} else {
|
|
1783
|
+
if (session.fingerprint) {
|
|
1784
|
+
const currentFingerprint = await generateFingerprint(request);
|
|
1785
|
+
if (session.fingerprint !== currentFingerprint) {
|
|
1786
|
+
logger_default.info("Session fingerprint changed (not enforced)", {
|
|
1787
|
+
type: "security",
|
|
1788
|
+
event: "fingerprint_changed",
|
|
1789
|
+
sessionId: sessionId.slice(0, 8),
|
|
1790
|
+
userId: session.userId,
|
|
1791
|
+
clientIp
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
return {
|
|
1797
|
+
userId: session.userId,
|
|
1798
|
+
expiresAt: session.expiresAt
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
async function getSessionFromBearerToken(db, sessions, authHeader) {
|
|
1802
|
+
if (!authHeader.startsWith("Bearer ")) return null;
|
|
1803
|
+
const sessionId = authHeader.slice(7);
|
|
1804
|
+
const foundSessions = await db.select({
|
|
1805
|
+
userId: sessions.userId,
|
|
1806
|
+
expiresAt: sessions.expiresAt,
|
|
1807
|
+
createdAt: sessions.createdAt,
|
|
1808
|
+
lastActiveAt: sessions.lastActiveAt
|
|
1809
|
+
}).from(sessions).where(and3(eq5(sessions.id, sessionId), gt3(sessions.expiresAt, Date.now()))).limit(1);
|
|
1810
|
+
if (foundSessions.length === 0) return null;
|
|
1811
|
+
const session = foundSessions[0];
|
|
1812
|
+
if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
|
|
1813
|
+
if (sessionRefreshDue(session.lastActiveAt)) await refreshSession(db, { sessions }, sessionId);
|
|
1814
|
+
return {
|
|
1815
|
+
userId: session.userId,
|
|
1816
|
+
expiresAt: session.expiresAt
|
|
1817
|
+
};
|
|
1818
|
+
}
|
|
1819
|
+
var requireAuth = createMiddleware(
|
|
1820
|
+
async (c, next) => {
|
|
1821
|
+
const { db, schema } = getAuthContext(c);
|
|
1822
|
+
const authHeader = c.req.header("Authorization");
|
|
1823
|
+
if (authHeader) {
|
|
1824
|
+
const session2 = await getSessionFromBearerToken(db, schema.sessions, authHeader);
|
|
1825
|
+
if (session2) {
|
|
1826
|
+
c.set("userId", session2.userId);
|
|
1827
|
+
await next();
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
const cookieHeader = c.req.header("cookie");
|
|
1832
|
+
if (!cookieHeader) {
|
|
1833
|
+
return problems.unauthorized(c, "Please log in to continue");
|
|
1834
|
+
}
|
|
1835
|
+
const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
|
|
1836
|
+
if (!session) {
|
|
1837
|
+
c.header("Set-Cookie", clearSessionCookie(c.env));
|
|
1838
|
+
return problems.unauthorized(c, "Your session has expired. Please log in again.");
|
|
1839
|
+
}
|
|
1840
|
+
c.set("userId", session.userId);
|
|
1841
|
+
await next();
|
|
1842
|
+
}
|
|
1843
|
+
);
|
|
1844
|
+
var optionalAuth = createMiddleware(
|
|
1845
|
+
async (c, next) => {
|
|
1846
|
+
const { db, schema } = getAuthContext(c);
|
|
1847
|
+
const authHeader = c.req.header("Authorization");
|
|
1848
|
+
if (authHeader) {
|
|
1849
|
+
const session = await getSessionFromBearerToken(db, schema.sessions, authHeader);
|
|
1850
|
+
if (session) {
|
|
1851
|
+
c.set("userId", session.userId);
|
|
1852
|
+
await next();
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
const cookieHeader = c.req.header("cookie");
|
|
1857
|
+
if (cookieHeader) {
|
|
1858
|
+
const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
|
|
1859
|
+
if (session) {
|
|
1860
|
+
c.set("userId", session.userId);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
await next();
|
|
1864
|
+
}
|
|
1865
|
+
);
|
|
1785
1866
|
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1867
|
+
// src/middleware/csrf.ts
|
|
1868
|
+
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
1869
|
+
var csrf = createMiddleware2(async (c, next) => {
|
|
1870
|
+
const method = c.req.method;
|
|
1871
|
+
const path = c.req.path;
|
|
1872
|
+
const isWebhook = path.startsWith("/v1/webhooks/");
|
|
1873
|
+
if (isWebhook) {
|
|
1874
|
+
await next();
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
const isMcpEndpoint = path === "/v1/mcp";
|
|
1878
|
+
if (isMcpEndpoint) {
|
|
1879
|
+
await next();
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
const isOAuthMcpEndpoint = path.startsWith("/v1/oauth/");
|
|
1883
|
+
if (isOAuthMcpEndpoint) {
|
|
1884
|
+
await next();
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
const isToolsApi = path.startsWith("/v1/tools/") && !path.startsWith("/v1/tools/catalog");
|
|
1888
|
+
if (isToolsApi) {
|
|
1889
|
+
await next();
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
const isAdminApi = path.startsWith("/v1/admin/");
|
|
1893
|
+
if (isAdminApi) {
|
|
1894
|
+
await next();
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
const isMobileAuthEndpoint = path === "/v1/auth/login" || path === "/v1/auth/signup" || path === "/v1/auth/refresh";
|
|
1898
|
+
const authHeader = c.req.header("Authorization");
|
|
1899
|
+
const hasBearerToken = authHeader?.startsWith("Bearer ");
|
|
1900
|
+
const hasNoOrigin = !c.req.header("origin") && !c.req.header("referer");
|
|
1901
|
+
if (isMobileAuthEndpoint && hasNoOrigin || hasBearerToken) {
|
|
1902
|
+
await next();
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
const isInternalSync = path.startsWith("/v1/internal/sync/");
|
|
1906
|
+
if (isInternalSync) {
|
|
1907
|
+
await next();
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
|
1911
|
+
const appUrl = c.env.APP_URL || "http://localhost:5173";
|
|
1912
|
+
const allowedOrigins = getAllowedOrigins(appUrl);
|
|
1913
|
+
const request = c.req.raw;
|
|
1914
|
+
const isTestEndpoint = c.req.path.startsWith("/test");
|
|
1915
|
+
if (isTestEndpoint) {
|
|
1916
|
+
const origin = request.headers.get("origin");
|
|
1917
|
+
const referer = request.headers.get("referer");
|
|
1918
|
+
logger_default.debug("CSRF validation for test endpoint", {
|
|
1919
|
+
path: c.req.path,
|
|
1920
|
+
method,
|
|
1921
|
+
origin,
|
|
1922
|
+
referer,
|
|
1923
|
+
appUrl,
|
|
1924
|
+
allowedOrigins
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
const pagesPattern = c.env.CORS_PAGES_PATTERN;
|
|
1928
|
+
if (!validateOrigin(request, allowedOrigins, pagesPattern)) {
|
|
1929
|
+
return problems.forbidden(c, "Request blocked for security reasons. Please try again.");
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
await next();
|
|
1933
|
+
});
|
|
1796
1934
|
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
</tbody>
|
|
1800
|
-
</table>
|
|
1801
|
-
</td>
|
|
1802
|
-
</tr>
|
|
1803
|
-
</tbody>
|
|
1804
|
-
</table>
|
|
1935
|
+
// src/middleware/rateLimit.ts
|
|
1936
|
+
import { createMiddleware as createMiddleware3 } from "hono/factory";
|
|
1805
1937
|
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1938
|
+
// src/lib/rate-limit-kv.ts
|
|
1939
|
+
async function checkRateLimitKV(kv, key, maxAttempts, windowSeconds) {
|
|
1940
|
+
try {
|
|
1941
|
+
const rateLimitKey = `rate_limit:${key}`;
|
|
1942
|
+
const currentAttemptsStr = await kv.get(rateLimitKey);
|
|
1943
|
+
const currentAttempts = currentAttemptsStr ? parseInt(currentAttemptsStr, 10) : 0;
|
|
1944
|
+
if (currentAttempts >= maxAttempts) {
|
|
1945
|
+
return {
|
|
1946
|
+
allowed: false,
|
|
1947
|
+
remaining: 0
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
const newAttempts = currentAttempts + 1;
|
|
1951
|
+
await kv.put(rateLimitKey, newAttempts.toString(), {
|
|
1952
|
+
expirationTtl: windowSeconds
|
|
1953
|
+
});
|
|
1954
|
+
return {
|
|
1955
|
+
allowed: true,
|
|
1956
|
+
remaining: maxAttempts - newAttempts
|
|
1957
|
+
};
|
|
1958
|
+
} catch (error) {
|
|
1959
|
+
logger_default.error("Rate limit KV error, failing open", {
|
|
1960
|
+
key,
|
|
1961
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1962
|
+
});
|
|
1963
|
+
return {
|
|
1964
|
+
allowed: true,
|
|
1965
|
+
remaining: maxAttempts
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1830
1969
|
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1970
|
+
// src/middleware/rateLimit.ts
|
|
1971
|
+
var rateLimit = (config) => createMiddleware3(async (c, next) => {
|
|
1972
|
+
const kv = c.env.RATE_LIMIT_KV;
|
|
1973
|
+
const identifier = await config.identifier(c);
|
|
1974
|
+
const key = `${config.action}:${identifier}`;
|
|
1975
|
+
const windowSeconds = Math.ceil(config.windowMs / 1e3);
|
|
1976
|
+
const { allowed, remaining } = await checkRateLimitKV(
|
|
1977
|
+
kv,
|
|
1978
|
+
key,
|
|
1979
|
+
config.maxAttempts,
|
|
1980
|
+
windowSeconds
|
|
1981
|
+
);
|
|
1982
|
+
const resetTimestamp = Math.ceil(Date.now() / 1e3) + windowSeconds;
|
|
1983
|
+
c.header("X-RateLimit-Limit", config.maxAttempts.toString());
|
|
1984
|
+
c.header("X-RateLimit-Remaining", Math.max(0, remaining).toString());
|
|
1985
|
+
c.header("X-RateLimit-Reset", resetTimestamp.toString());
|
|
1986
|
+
if (!allowed) {
|
|
1987
|
+
c.header("Retry-After", windowSeconds.toString());
|
|
1988
|
+
return problems.rateLimitExceeded(c, windowSeconds);
|
|
1989
|
+
}
|
|
1990
|
+
await next();
|
|
1991
|
+
});
|
|
1840
1992
|
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1993
|
+
// src/middleware/require-verified-email.ts
|
|
1994
|
+
import { createMiddleware as createMiddleware4 } from "hono/factory";
|
|
1995
|
+
import { eq as eq6 } from "drizzle-orm";
|
|
1996
|
+
var requireVerifiedEmail = createMiddleware4(
|
|
1997
|
+
async (c, next) => {
|
|
1998
|
+
const userId = c.get("userId");
|
|
1999
|
+
if (!userId) {
|
|
2000
|
+
return problems.unauthorized(c, "Not authenticated");
|
|
2001
|
+
}
|
|
2002
|
+
const { db, schema } = getAuthContext(c);
|
|
2003
|
+
const database = db;
|
|
2004
|
+
const users = schema.users;
|
|
2005
|
+
const [user] = await database.select({ emailVerified: users.emailVerified }).from(users).where(eq6(users.id, userId)).limit(1);
|
|
2006
|
+
if (!user || !user.emailVerified) {
|
|
2007
|
+
return problems.forbidden(c, "Please verify your email address to continue");
|
|
2008
|
+
}
|
|
2009
|
+
await next();
|
|
2010
|
+
}
|
|
2011
|
+
);
|
|
1853
2012
|
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
<tr>
|
|
1857
|
-
<td class="pad" style="padding-bottom:15px;padding-left:40px;padding-right:40px;">
|
|
1858
|
-
<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;">
|
|
1859
|
-
<p style="margin: 0 0 12px 0; word-break: break-word;">\xA9 ${(/* @__PURE__ */ new Date()).getFullYear()} ${appName}. All rights reserved.</p>
|
|
1860
|
-
<p style="margin: 0; font-size: 11px; line-height: 1.5; word-break: break-word;">
|
|
1861
|
-
Please do not reply to this email as this address does not accept incoming messages. This service email contains essential information relating to your ${appName} account.
|
|
1862
|
-
</p>
|
|
1863
|
-
</div>
|
|
1864
|
-
</td>
|
|
1865
|
-
</tr>
|
|
1866
|
-
</table>
|
|
2013
|
+
// src/routes/index.ts
|
|
2014
|
+
import { OpenAPIHono as OpenAPIHono3 } from "@hono/zod-openapi";
|
|
1867
2015
|
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
</table>
|
|
1872
|
-
</td>
|
|
1873
|
-
</tr>
|
|
1874
|
-
</tbody>
|
|
1875
|
-
</table>
|
|
2016
|
+
// src/routes/signup.ts
|
|
2017
|
+
import { createRoute } from "@hono/zod-openapi";
|
|
2018
|
+
import { eq as eq9 } from "drizzle-orm";
|
|
1876
2019
|
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
</tbody>
|
|
1880
|
-
</table>
|
|
1881
|
-
</body>
|
|
1882
|
-
</html>
|
|
1883
|
-
`;
|
|
1884
|
-
}
|
|
2020
|
+
// src/lib/email/email-service.ts
|
|
2021
|
+
import { eq as eq7 } from "drizzle-orm";
|
|
1885
2022
|
|
|
1886
2023
|
// src/lib/email/templates.ts
|
|
1887
2024
|
function extractAppUrl(url) {
|
|
@@ -1905,6 +2042,7 @@ function generateVerificationEmail(data, appName = "Your App", configuredAppUrl
|
|
|
1905
2042
|
welcomeMessage: safeFirstName ? `Hi ${safeFirstName}, Welcome to ${escapeHtml(appName)}!` : void 0,
|
|
1906
2043
|
footerNote: "",
|
|
1907
2044
|
appUrl,
|
|
2045
|
+
appName,
|
|
1908
2046
|
...branding
|
|
1909
2047
|
});
|
|
1910
2048
|
return {
|
|
@@ -1940,6 +2078,7 @@ function generatePasswordResetEmail(data, appName = "Your App", configuredAppUrl
|
|
|
1940
2078
|
`,
|
|
1941
2079
|
footerNote: "If you didn't request this change, no action is needed. Your password will remain unchanged.",
|
|
1942
2080
|
appUrl,
|
|
2081
|
+
appName,
|
|
1943
2082
|
...branding
|
|
1944
2083
|
});
|
|
1945
2084
|
return {
|
|
@@ -1975,6 +2114,7 @@ function generateEmailChangeConfirmation(data, appName = "Your App", configuredA
|
|
|
1975
2114
|
`,
|
|
1976
2115
|
footerNote: "If you didn't request this change, you can safely ignore this email.",
|
|
1977
2116
|
appUrl,
|
|
2117
|
+
appName,
|
|
1978
2118
|
...branding
|
|
1979
2119
|
});
|
|
1980
2120
|
return {
|
|
@@ -2013,6 +2153,7 @@ function generateEmailChangeNotification(data, appName = "Your App", configuredA
|
|
|
2013
2153
|
`,
|
|
2014
2154
|
footerNote: "If you didn't request this change, click the button above to cancel it.",
|
|
2015
2155
|
appUrl,
|
|
2156
|
+
appName,
|
|
2016
2157
|
...branding
|
|
2017
2158
|
});
|
|
2018
2159
|
return {
|
|
@@ -2049,6 +2190,7 @@ function generate2faCodeEmail(data, appName = "Your App", appUrl = "", branding)
|
|
|
2049
2190
|
buttonUrl: "",
|
|
2050
2191
|
footerNote: "",
|
|
2051
2192
|
appUrl,
|
|
2193
|
+
appName,
|
|
2052
2194
|
...branding
|
|
2053
2195
|
});
|
|
2054
2196
|
return {
|
|
@@ -2084,6 +2226,7 @@ function generate2faEnabledEmail(data, appName = "Your App", appUrl = "", brandi
|
|
|
2084
2226
|
buttonUrl: "",
|
|
2085
2227
|
footerNote: "",
|
|
2086
2228
|
appUrl,
|
|
2229
|
+
appName,
|
|
2087
2230
|
...branding
|
|
2088
2231
|
});
|
|
2089
2232
|
return {
|
|
@@ -2114,6 +2257,7 @@ function generate2faDisabledEmail(data, appName = "Your App", appUrl = "", brand
|
|
|
2114
2257
|
buttonUrl: appUrl ? `${appUrl}/forgot-password` : "",
|
|
2115
2258
|
footerNote: "",
|
|
2116
2259
|
appUrl,
|
|
2260
|
+
appName,
|
|
2117
2261
|
...branding
|
|
2118
2262
|
});
|
|
2119
2263
|
return {
|
|
@@ -2330,7 +2474,7 @@ var EmailService = class {
|
|
|
2330
2474
|
const [user] = await db.select({
|
|
2331
2475
|
emailBounced: usersTable.emailBounced,
|
|
2332
2476
|
emailComplained: usersTable.emailComplained
|
|
2333
|
-
}).from(usersTable).where(
|
|
2477
|
+
}).from(usersTable).where(eq7(usersTable.email, emailAddress.toLowerCase())).limit(1);
|
|
2334
2478
|
if (!user) {
|
|
2335
2479
|
return { blocked: false };
|
|
2336
2480
|
}
|
|
@@ -2779,7 +2923,7 @@ var EmailService = class {
|
|
|
2779
2923
|
import { Resend as Resend2 } from "resend";
|
|
2780
2924
|
|
|
2781
2925
|
// src/lib/email/webhook-handler.ts
|
|
2782
|
-
import { eq as
|
|
2926
|
+
import { eq as eq8 } from "drizzle-orm";
|
|
2783
2927
|
async function handleWebhookEvent(payload, db, tables2) {
|
|
2784
2928
|
const { type, data } = payload;
|
|
2785
2929
|
const emailAddress = data.to[0];
|
|
@@ -2838,7 +2982,7 @@ async function handleBounce(emailAddress, emailId, bounce, db, usersTable) {
|
|
|
2838
2982
|
await db.update(usersTable).set({
|
|
2839
2983
|
emailBounced: true,
|
|
2840
2984
|
emailBouncedAt: /* @__PURE__ */ new Date()
|
|
2841
|
-
}).where(
|
|
2985
|
+
}).where(eq8(usersTable.email, emailAddress.toLowerCase()));
|
|
2842
2986
|
logError(new Error("Email bounced"), {
|
|
2843
2987
|
context: "email_webhook",
|
|
2844
2988
|
emailAddress,
|
|
@@ -2854,7 +2998,7 @@ async function handleComplaint(emailAddress, emailId, db, usersTable) {
|
|
|
2854
2998
|
await db.update(usersTable).set({
|
|
2855
2999
|
emailComplained: true,
|
|
2856
3000
|
emailComplainedAt: /* @__PURE__ */ new Date()
|
|
2857
|
-
}).where(
|
|
3001
|
+
}).where(eq8(usersTable.email, emailAddress.toLowerCase()));
|
|
2858
3002
|
logError(new Error("Email spam complaint received"), {
|
|
2859
3003
|
context: "email_webhook",
|
|
2860
3004
|
emailAddress,
|
|
@@ -2876,15 +3020,15 @@ async function handleFailure(emailAddress, emailId, failed) {
|
|
|
2876
3020
|
}
|
|
2877
3021
|
|
|
2878
3022
|
// src/lib/email/monitoring.ts
|
|
2879
|
-
import { sql as
|
|
3023
|
+
import { sql as sql3 } from "drizzle-orm";
|
|
2880
3024
|
async function calculateBounceRate(db, emailEventsTable, hoursAgo = 24) {
|
|
2881
3025
|
const cutoff = new Date(Date.now() - hoursAgo * 60 * 60 * 1e3);
|
|
2882
3026
|
const [stats] = await db.select({
|
|
2883
|
-
totalSent:
|
|
2884
|
-
totalBounced:
|
|
3027
|
+
totalSent: sql3`count(*) filter (where event_type = 'email.sent')`.as("total_sent"),
|
|
3028
|
+
totalBounced: sql3`count(*) filter (where event_type = 'email.bounced')`.as(
|
|
2885
3029
|
"total_bounced"
|
|
2886
3030
|
)
|
|
2887
|
-
}).from(emailEventsTable).where(
|
|
3031
|
+
}).from(emailEventsTable).where(sql3`created_at > ${cutoff}`);
|
|
2888
3032
|
if (!stats || stats.totalSent === 0) {
|
|
2889
3033
|
return 0;
|
|
2890
3034
|
}
|
|
@@ -2903,11 +3047,11 @@ async function calculateBounceRate(db, emailEventsTable, hoursAgo = 24) {
|
|
|
2903
3047
|
async function calculateComplaintRate(db, emailEventsTable, hoursAgo = 24) {
|
|
2904
3048
|
const cutoff = new Date(Date.now() - hoursAgo * 60 * 60 * 1e3);
|
|
2905
3049
|
const [stats] = await db.select({
|
|
2906
|
-
totalSent:
|
|
2907
|
-
totalComplained:
|
|
3050
|
+
totalSent: sql3`count(*) filter (where event_type = 'email.sent')`.as("total_sent"),
|
|
3051
|
+
totalComplained: sql3`count(*) filter (where event_type = 'email.complained')`.as(
|
|
2908
3052
|
"total_complained"
|
|
2909
3053
|
)
|
|
2910
|
-
}).from(emailEventsTable).where(
|
|
3054
|
+
}).from(emailEventsTable).where(sql3`created_at > ${cutoff}`);
|
|
2911
3055
|
if (!stats || stats.totalSent === 0) {
|
|
2912
3056
|
return 0;
|
|
2913
3057
|
}
|
|
@@ -2924,16 +3068,34 @@ async function calculateComplaintRate(db, emailEventsTable, hoursAgo = 24) {
|
|
|
2924
3068
|
return complaintRate;
|
|
2925
3069
|
}
|
|
2926
3070
|
|
|
3071
|
+
// src/core/invitations.ts
|
|
3072
|
+
async function verifyInvitation(invitations, args) {
|
|
3073
|
+
if (!invitations || !args.token) return null;
|
|
3074
|
+
const email = args.email.toLowerCase();
|
|
3075
|
+
try {
|
|
3076
|
+
const match = await invitations.verify({ token: args.token, email, db: args.db, env: args.env });
|
|
3077
|
+
return match && match.email.toLowerCase() === email ? args.token : null;
|
|
3078
|
+
} catch (err) {
|
|
3079
|
+
logError(err, { context: "invitation_verify_failed", email });
|
|
3080
|
+
return null;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
async function acceptInvitation(invitations, args) {
|
|
3084
|
+
if (!invitations?.onAccepted) return;
|
|
3085
|
+
try {
|
|
3086
|
+
await invitations.onAccepted({ token: args.token, user: args.user, db: args.db, env: args.env });
|
|
3087
|
+
} catch (err) {
|
|
3088
|
+
logError(err, { context: args.context, userId: args.user.id });
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
|
|
2927
3092
|
// src/routes/schemas.ts
|
|
2928
3093
|
import { z } from "@hono/zod-openapi";
|
|
2929
3094
|
var signupRequestSchema = z.object({
|
|
2930
3095
|
email: z.string().email().openapi({ example: "user@example.com" }),
|
|
2931
|
-
|
|
2932
|
-
password: z.string().min(8).optional().openapi({ example: "SecurePass123!" }),
|
|
3096
|
+
password: z.string().min(8).openapi({ example: "SecurePass123!" }),
|
|
2933
3097
|
name: z.string().min(1).optional().openapi({ example: "John Doe" }),
|
|
2934
|
-
turnstileToken: z.string().
|
|
2935
|
-
// OAuth signup completion token (from /signup?oauth_token=...)
|
|
2936
|
-
oauthToken: z.string().optional().openapi({ example: "a1b2c3d4-..." }),
|
|
3098
|
+
turnstileToken: z.string().openapi({ example: "XXXX.DUMMY.TOKEN.XXXX" }),
|
|
2937
3099
|
// App invitation token (from /signup?token=...). When it validates for this
|
|
2938
3100
|
// email, the account is created already-verified with no verification email.
|
|
2939
3101
|
invitationToken: z.string().optional().openapi({ example: "iIB8tDJJ..." })
|
|
@@ -3059,76 +3221,22 @@ var signupRoute = createRoute({
|
|
|
3059
3221
|
description: "Invalid input",
|
|
3060
3222
|
content: { "application/json": { schema: errorResponseSchema } }
|
|
3061
3223
|
},
|
|
3224
|
+
403: {
|
|
3225
|
+
description: "Sign-up is by invitation only and no valid invitation was given",
|
|
3226
|
+
content: { "application/json": { schema: errorResponseSchema } }
|
|
3227
|
+
},
|
|
3062
3228
|
409: {
|
|
3063
3229
|
description: "Email already registered",
|
|
3064
3230
|
content: { "application/json": { schema: errorResponseSchema } }
|
|
3065
3231
|
}
|
|
3066
3232
|
}
|
|
3067
3233
|
});
|
|
3234
|
+
var INVITATION_ONLY = "Sign-up is by invitation only";
|
|
3235
|
+
var INVITATION_INVALID = "This invitation is invalid, expired, or for a different email address";
|
|
3068
3236
|
var signupHandler = async (c) => {
|
|
3069
|
-
const { email, password, name, turnstileToken,
|
|
3070
|
-
const { db: database, schema, emailBranding, invitations } = getAuthContext(c);
|
|
3237
|
+
const { email, password, name, turnstileToken, invitationToken } = c.req.valid("json");
|
|
3238
|
+
const { db: database, schema, env: authEnv, emailBranding, invitations, signup } = getAuthContext(c);
|
|
3071
3239
|
const env = c.env;
|
|
3072
|
-
if (oauthToken) {
|
|
3073
|
-
const stored = await env.OAUTH_STATES.get(`oauth:signup:${oauthToken}`);
|
|
3074
|
-
if (!stored) {
|
|
3075
|
-
return problems.badRequest(c, "Invalid or expired OAuth token");
|
|
3076
|
-
}
|
|
3077
|
-
const {
|
|
3078
|
-
email: tokenEmail,
|
|
3079
|
-
provider,
|
|
3080
|
-
providerUserId
|
|
3081
|
-
} = JSON.parse(stored);
|
|
3082
|
-
if (email.toLowerCase() !== tokenEmail.toLowerCase()) {
|
|
3083
|
-
return problems.badRequest(c, "Email does not match OAuth token");
|
|
3084
|
-
}
|
|
3085
|
-
await env.OAUTH_STATES.delete(`oauth:signup:${oauthToken}`);
|
|
3086
|
-
const existingUser2 = await database.query.users.findFirst({
|
|
3087
|
-
where: eq8(schema.users.email, tokenEmail.toLowerCase())
|
|
3088
|
-
});
|
|
3089
|
-
if (existingUser2) {
|
|
3090
|
-
return problems.conflict(c, "Email already registered");
|
|
3091
|
-
}
|
|
3092
|
-
let hashedPassword2 = null;
|
|
3093
|
-
if (password) {
|
|
3094
|
-
const passwordValidation2 = await validatePasswordWithBreachCheck(password);
|
|
3095
|
-
if (!passwordValidation2.valid) {
|
|
3096
|
-
return problems.badRequest(c, passwordValidation2.error || "Invalid password");
|
|
3097
|
-
}
|
|
3098
|
-
hashedPassword2 = await hashPassword(password, env.PASSWORD_PEPPER_V1);
|
|
3099
|
-
}
|
|
3100
|
-
const [newUser2] = await database.insert(schema.users).values({
|
|
3101
|
-
email: tokenEmail.toLowerCase(),
|
|
3102
|
-
name: name || null,
|
|
3103
|
-
hashedPassword: hashedPassword2,
|
|
3104
|
-
pepperKid: hashedPassword2 ? "v1" : null,
|
|
3105
|
-
emailVerified: true,
|
|
3106
|
-
// Provider verified the email
|
|
3107
|
-
activatedAt: /* @__PURE__ */ new Date()
|
|
3108
|
-
}).returning();
|
|
3109
|
-
await database.insert(schema.oauthAccounts).values({ userId: newUser2.id, provider, providerUserId, email: tokenEmail.toLowerCase() }).onConflictDoNothing();
|
|
3110
|
-
const fingerprint2 = await generateFingerprint(c.req.raw);
|
|
3111
|
-
const ipAddress2 = getClientIp(c.req.raw);
|
|
3112
|
-
const sessionId2 = await createSession(database, { sessions: schema.sessions }, newUser2.id, fingerprint2, ipAddress2);
|
|
3113
|
-
logSecurityEvent("user_signup_oauth", "low", {
|
|
3114
|
-
userId: newUser2.id,
|
|
3115
|
-
email: newUser2.email,
|
|
3116
|
-
provider
|
|
3117
|
-
});
|
|
3118
|
-
c.header("Set-Cookie", setSessionCookie(sessionId2, env), { append: true });
|
|
3119
|
-
return c.json({
|
|
3120
|
-
user: {
|
|
3121
|
-
id: newUser2.id,
|
|
3122
|
-
email: newUser2.email,
|
|
3123
|
-
name: newUser2.name,
|
|
3124
|
-
emailVerified: true
|
|
3125
|
-
},
|
|
3126
|
-
redirect: "/"
|
|
3127
|
-
});
|
|
3128
|
-
}
|
|
3129
|
-
if (!password || !turnstileToken) {
|
|
3130
|
-
return problems.badRequest(c, "Password and captcha are required");
|
|
3131
|
-
}
|
|
3132
3240
|
const turnstileValid = await verifyTurnstileToken(
|
|
3133
3241
|
turnstileToken,
|
|
3134
3242
|
env.TURNSTILE_SECRET_KEY,
|
|
@@ -3138,8 +3246,22 @@ var signupHandler = async (c) => {
|
|
|
3138
3246
|
if (!turnstileValid) {
|
|
3139
3247
|
return problems.badRequest(c, "Invalid captcha");
|
|
3140
3248
|
}
|
|
3249
|
+
const invited = await verifyInvitation(invitations, {
|
|
3250
|
+
token: invitationToken,
|
|
3251
|
+
email,
|
|
3252
|
+
db: database,
|
|
3253
|
+
env: authEnv
|
|
3254
|
+
});
|
|
3255
|
+
if (invitationToken && invitations && !invited && signup !== "invitation") {
|
|
3256
|
+
return problems.badRequest(c, INVITATION_INVALID);
|
|
3257
|
+
}
|
|
3258
|
+
if (signup === "invitation" && !invited) {
|
|
3259
|
+
logSecurityEvent("signup_refused_no_invitation", "medium", { email: email.toLowerCase() });
|
|
3260
|
+
return problems.forbidden(c, invitationToken ? INVITATION_INVALID : INVITATION_ONLY);
|
|
3261
|
+
}
|
|
3262
|
+
const invitedVerified = invited !== null;
|
|
3141
3263
|
const existingUser = await database.query.users.findFirst({
|
|
3142
|
-
where:
|
|
3264
|
+
where: eq9(schema.users.email, email.toLowerCase())
|
|
3143
3265
|
});
|
|
3144
3266
|
if (existingUser) {
|
|
3145
3267
|
return problems.conflict(c, "Email already registered");
|
|
@@ -3149,22 +3271,6 @@ var signupHandler = async (c) => {
|
|
|
3149
3271
|
return problems.badRequest(c, passwordValidation.error || "Invalid password");
|
|
3150
3272
|
}
|
|
3151
3273
|
const hashedPassword = await hashPassword(password, env.PASSWORD_PEPPER_V1);
|
|
3152
|
-
let invitedVerified = false;
|
|
3153
|
-
if (invitationToken && invitations) {
|
|
3154
|
-
const match = await invitations.verify({
|
|
3155
|
-
token: invitationToken,
|
|
3156
|
-
email: email.toLowerCase(),
|
|
3157
|
-
db: database,
|
|
3158
|
-
env
|
|
3159
|
-
});
|
|
3160
|
-
if (!match || match.email.toLowerCase() !== email.toLowerCase()) {
|
|
3161
|
-
return problems.badRequest(
|
|
3162
|
-
c,
|
|
3163
|
-
"This invitation is invalid, expired, or for a different email address"
|
|
3164
|
-
);
|
|
3165
|
-
}
|
|
3166
|
-
invitedVerified = true;
|
|
3167
|
-
}
|
|
3168
3274
|
const [newUser] = await database.insert(schema.users).values({
|
|
3169
3275
|
email: email.toLowerCase(),
|
|
3170
3276
|
name: name || null,
|
|
@@ -3197,17 +3303,14 @@ var signupHandler = async (c) => {
|
|
|
3197
3303
|
schema.users
|
|
3198
3304
|
);
|
|
3199
3305
|
}
|
|
3200
|
-
if (
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
} catch (err) {
|
|
3209
|
-
logError(err, { context: "signup_invitation_onAccepted_failed", userId: newUser.id });
|
|
3210
|
-
}
|
|
3306
|
+
if (invited) {
|
|
3307
|
+
await acceptInvitation(invitations, {
|
|
3308
|
+
token: invited,
|
|
3309
|
+
user: { id: newUser.id, email: newUser.email, name: newUser.name },
|
|
3310
|
+
db: database,
|
|
3311
|
+
env: authEnv,
|
|
3312
|
+
context: "signup_invitation_onAccepted_failed"
|
|
3313
|
+
});
|
|
3211
3314
|
}
|
|
3212
3315
|
const fingerprint = await generateFingerprint(c.req.raw);
|
|
3213
3316
|
const ipAddress = getClientIp(c.req.raw);
|
|
@@ -3240,24 +3343,34 @@ var signupMiddleware = [
|
|
|
3240
3343
|
|
|
3241
3344
|
// src/routes/login.ts
|
|
3242
3345
|
import { createRoute as createRoute2 } from "@hono/zod-openapi";
|
|
3243
|
-
import { eq as
|
|
3346
|
+
import { eq as eq11 } from "drizzle-orm";
|
|
3244
3347
|
import { getCookie } from "hono/cookie";
|
|
3245
3348
|
|
|
3246
3349
|
// src/routes/2fa/helpers.ts
|
|
3247
|
-
import { eq as
|
|
3350
|
+
import { eq as eq10, and as and4, isNull as isNull2 } from "drizzle-orm";
|
|
3248
3351
|
async function getUserEnabled2faMethods(database, userId, user2faMethodsTable) {
|
|
3249
3352
|
return database.select({
|
|
3250
3353
|
method: user2faMethodsTable.method,
|
|
3251
3354
|
isPrimary: user2faMethodsTable.isPrimary,
|
|
3252
3355
|
totpSecret: user2faMethodsTable.totpSecret,
|
|
3253
3356
|
lastTotpCounter: user2faMethodsTable.lastTotpCounter
|
|
3254
|
-
}).from(user2faMethodsTable).where(
|
|
3357
|
+
}).from(user2faMethodsTable).where(eq10(user2faMethodsTable.userId, userId));
|
|
3255
3358
|
}
|
|
3256
3359
|
function generateEmailOtp() {
|
|
3257
3360
|
const bytes = crypto.getRandomValues(new Uint8Array(4));
|
|
3258
3361
|
const num = (bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]) >>> 0;
|
|
3259
3362
|
return String(num % 1e6).padStart(6, "0");
|
|
3260
3363
|
}
|
|
3364
|
+
var EMAIL_CODE_TTL_SECONDS = 300;
|
|
3365
|
+
async function sendEmailChallengeCode(env, database, usersTable, user, emailBranding) {
|
|
3366
|
+
const code = generateEmailOtp();
|
|
3367
|
+
await env.OAUTH_STATES.put(`email_2fa_challenge:${user.id}`, await hashToken(code), {
|
|
3368
|
+
expirationTtl: EMAIL_CODE_TTL_SECONDS
|
|
3369
|
+
});
|
|
3370
|
+
const emailService = new EmailService(env, void 0, emailBranding);
|
|
3371
|
+
const firstName = user.name?.split(" ")[0] || null;
|
|
3372
|
+
await emailService.send2faCodeEmail({ email: user.email, firstName, code }, database, usersTable);
|
|
3373
|
+
}
|
|
3261
3374
|
async function verifyAny2faCode(database, env, userId, code, method, tables2) {
|
|
3262
3375
|
const { user2faMethods: user2faMethodsTable, userBackupCodes: userBackupCodesTable } = tables2;
|
|
3263
3376
|
if (method === "totp") {
|
|
@@ -3275,7 +3388,7 @@ async function verifyAny2faCode(database, env, userId, code, method, tables2) {
|
|
|
3275
3388
|
const secret = await decryptTotpSecret(totpMethod.totpSecret, env.TOTP_ENCRYPTION_KEY);
|
|
3276
3389
|
const result = verifyTotpCode(secret, code, totpMethod.lastTotpCounter);
|
|
3277
3390
|
if (result.valid) {
|
|
3278
|
-
await database.update(user2faMethodsTable).set({ lastTotpCounter: result.counter }).where(
|
|
3391
|
+
await database.update(user2faMethodsTable).set({ lastTotpCounter: result.counter }).where(and4(eq10(user2faMethodsTable.userId, userId), eq10(user2faMethodsTable.method, "totp")));
|
|
3279
3392
|
return { valid: true };
|
|
3280
3393
|
}
|
|
3281
3394
|
return { valid: false };
|
|
@@ -3287,18 +3400,18 @@ async function verifyAny2faCode(database, env, userId, code, method, tables2) {
|
|
|
3287
3400
|
const kvKey = `email_2fa_challenge:${userId}`;
|
|
3288
3401
|
const storedHash = await env.OAUTH_STATES.get(kvKey);
|
|
3289
3402
|
if (!storedHash) return { valid: false };
|
|
3290
|
-
const
|
|
3291
|
-
if (
|
|
3403
|
+
const codeHash2 = await hashToken(code);
|
|
3404
|
+
if (codeHash2 === storedHash) {
|
|
3292
3405
|
await env.OAUTH_STATES.delete(kvKey);
|
|
3293
3406
|
return { valid: true };
|
|
3294
3407
|
}
|
|
3295
3408
|
return { valid: false };
|
|
3296
3409
|
}
|
|
3297
3410
|
if (method === "backup") {
|
|
3298
|
-
const backupCodes = await database.select({ id: userBackupCodesTable.id, codeHash: userBackupCodesTable.codeHash }).from(userBackupCodesTable).where(
|
|
3411
|
+
const backupCodes = await database.select({ id: userBackupCodesTable.id, codeHash: userBackupCodesTable.codeHash }).from(userBackupCodesTable).where(and4(eq10(userBackupCodesTable.userId, userId), isNull2(userBackupCodesTable.usedAt)));
|
|
3299
3412
|
for (const bc of backupCodes) {
|
|
3300
3413
|
if (await verifyBackupCode(code, bc.codeHash)) {
|
|
3301
|
-
await database.update(userBackupCodesTable).set({ usedAt: Date.now() }).where(
|
|
3414
|
+
await database.update(userBackupCodesTable).set({ usedAt: Date.now() }).where(eq10(userBackupCodesTable.id, bc.id));
|
|
3302
3415
|
logSecurityEvent("2fa_backup_code_used", "medium", { userId, codeId: bc.id });
|
|
3303
3416
|
return { valid: true };
|
|
3304
3417
|
}
|
|
@@ -3342,7 +3455,7 @@ var loginRoute = createRoute2({
|
|
|
3342
3455
|
});
|
|
3343
3456
|
var loginHandler = async (c) => {
|
|
3344
3457
|
const { email, password, turnstileToken } = c.req.valid("json");
|
|
3345
|
-
const { db: database, schema } = getAuthContext(c);
|
|
3458
|
+
const { db: database, schema, emailBranding } = getAuthContext(c);
|
|
3346
3459
|
const env = c.env;
|
|
3347
3460
|
const turnstileValid = await verifyTurnstileToken(
|
|
3348
3461
|
turnstileToken,
|
|
@@ -3354,7 +3467,7 @@ var loginHandler = async (c) => {
|
|
|
3354
3467
|
return problems.badRequest(c, "Invalid captcha");
|
|
3355
3468
|
}
|
|
3356
3469
|
const user = await database.query.users.findFirst({
|
|
3357
|
-
where:
|
|
3470
|
+
where: eq11(schema.users.email, email.toLowerCase())
|
|
3358
3471
|
});
|
|
3359
3472
|
if (!user) {
|
|
3360
3473
|
return problems.unauthorized(c, "Invalid email or password");
|
|
@@ -3427,6 +3540,10 @@ var loginHandler = async (c) => {
|
|
|
3427
3540
|
};
|
|
3428
3541
|
await storeChallengeToken(env.OAUTH_STATES, challengeToken, challengePayload);
|
|
3429
3542
|
setChallengeCookie(c, env, challengeToken);
|
|
3543
|
+
const emailMethod = enabled2faMethods.find((m) => m.method === "email");
|
|
3544
|
+
if (emailMethod && (emailMethod.isPrimary || enabled2faMethods.length === 1)) {
|
|
3545
|
+
await sendEmailChallengeCode(env, database, schema.users, user, emailBranding);
|
|
3546
|
+
}
|
|
3430
3547
|
return c.json({
|
|
3431
3548
|
requires2fa: true,
|
|
3432
3549
|
methods: enabled2faMethods.map((m) => m.method)
|
|
@@ -3459,7 +3576,7 @@ var loginMiddleware = [
|
|
|
3459
3576
|
|
|
3460
3577
|
// src/routes/logout.ts
|
|
3461
3578
|
import { createRoute as createRoute3 } from "@hono/zod-openapi";
|
|
3462
|
-
import { eq as
|
|
3579
|
+
import { eq as eq12 } from "drizzle-orm";
|
|
3463
3580
|
var logoutRoute = createRoute3({
|
|
3464
3581
|
method: "post",
|
|
3465
3582
|
path: "/logout",
|
|
@@ -3482,7 +3599,7 @@ var logoutHandler = async (c) => {
|
|
|
3482
3599
|
const sessionIdMatch = cookieHeader.match(cookiePattern);
|
|
3483
3600
|
if (sessionIdMatch) {
|
|
3484
3601
|
const sessionId = sessionIdMatch[1];
|
|
3485
|
-
await database.delete(schema.sessions).where(
|
|
3602
|
+
await database.delete(schema.sessions).where(eq12(schema.sessions.id, sessionId));
|
|
3486
3603
|
}
|
|
3487
3604
|
}
|
|
3488
3605
|
c.header("Set-Cookie", clearSessionCookie(c.env), { append: true });
|
|
@@ -3491,7 +3608,7 @@ var logoutHandler = async (c) => {
|
|
|
3491
3608
|
|
|
3492
3609
|
// src/routes/me.ts
|
|
3493
3610
|
import { createRoute as createRoute4 } from "@hono/zod-openapi";
|
|
3494
|
-
import { eq as
|
|
3611
|
+
import { eq as eq13 } from "drizzle-orm";
|
|
3495
3612
|
var meRoute = createRoute4({
|
|
3496
3613
|
method: "get",
|
|
3497
3614
|
path: "/me",
|
|
@@ -3519,7 +3636,7 @@ var meHandler = async (c) => {
|
|
|
3519
3636
|
if (!userId) return problems.unauthorized(c, "Not authenticated");
|
|
3520
3637
|
const { db: database, schema } = getAuthContext(c);
|
|
3521
3638
|
const user = await database.query.users.findFirst({
|
|
3522
|
-
where:
|
|
3639
|
+
where: eq13(schema.users.id, userId)
|
|
3523
3640
|
});
|
|
3524
3641
|
if (!user) {
|
|
3525
3642
|
return problems.notFound(c, "User not found");
|
|
@@ -3545,7 +3662,7 @@ var meMiddleware = [requireAuth];
|
|
|
3545
3662
|
|
|
3546
3663
|
// src/routes/verify-email.ts
|
|
3547
3664
|
import { createRoute as createRoute5 } from "@hono/zod-openapi";
|
|
3548
|
-
import { eq as
|
|
3665
|
+
import { eq as eq14 } from "drizzle-orm";
|
|
3549
3666
|
var verifyEmailRoute = createRoute5({
|
|
3550
3667
|
method: "post",
|
|
3551
3668
|
path: "/verify-email",
|
|
@@ -3574,17 +3691,17 @@ var verifyEmailHandler = async (c) => {
|
|
|
3574
3691
|
const { db: database, schema } = getAuthContext(c);
|
|
3575
3692
|
const tokenHash = await hashToken(token);
|
|
3576
3693
|
const verificationToken = await database.query.emailVerificationTokens.findFirst({
|
|
3577
|
-
where:
|
|
3694
|
+
where: eq14(schema.emailVerificationTokens.tokenHash, tokenHash)
|
|
3578
3695
|
});
|
|
3579
3696
|
if (!verificationToken) {
|
|
3580
3697
|
return problems.badRequest(c, "Invalid or expired verification token");
|
|
3581
3698
|
}
|
|
3582
3699
|
if (verificationToken.expiresAt < Date.now()) {
|
|
3583
|
-
await database.delete(schema.emailVerificationTokens).where(
|
|
3700
|
+
await database.delete(schema.emailVerificationTokens).where(eq14(schema.emailVerificationTokens.tokenHash, tokenHash));
|
|
3584
3701
|
return problems.badRequest(c, "Verification token has expired");
|
|
3585
3702
|
}
|
|
3586
|
-
await database.update(schema.users).set({ emailVerified: true }).where(
|
|
3587
|
-
await database.delete(schema.emailVerificationTokens).where(
|
|
3703
|
+
await database.update(schema.users).set({ emailVerified: true }).where(eq14(schema.users.id, verificationToken.userId));
|
|
3704
|
+
await database.delete(schema.emailVerificationTokens).where(eq14(schema.emailVerificationTokens.tokenHash, tokenHash));
|
|
3588
3705
|
logSecurityEvent("email_verified", "low", { userId: verificationToken.userId });
|
|
3589
3706
|
return c.json({ success: true, redirect: "/" });
|
|
3590
3707
|
};
|
|
@@ -3593,18 +3710,18 @@ var verifyEmailHandler = async (c) => {
|
|
|
3593
3710
|
import { createRoute as createRoute6 } from "@hono/zod-openapi";
|
|
3594
3711
|
|
|
3595
3712
|
// src/core/password-reset.ts
|
|
3596
|
-
import { eq as
|
|
3713
|
+
import { eq as eq15, and as and5, gt as gt4, lt as lt2, sql as sql4 } from "drizzle-orm";
|
|
3597
3714
|
var TOKEN_EXPIRATION_MS = AUTH_DEFAULTS.PASSWORD_RESET_TTL_MINUTES * 60 * 1e3;
|
|
3598
3715
|
async function createPasswordResetToken(db, tables2, email) {
|
|
3599
3716
|
const { users, passwordResetTokens } = tables2;
|
|
3600
|
-
const [user] = await db.select({ id: users.id, emailVerified: users.emailVerified }).from(users).where(
|
|
3717
|
+
const [user] = await db.select({ id: users.id, emailVerified: users.emailVerified }).from(users).where(eq15(users.email, email.toLowerCase())).limit(1);
|
|
3601
3718
|
if (!user) {
|
|
3602
3719
|
return null;
|
|
3603
3720
|
}
|
|
3604
3721
|
if (!user.emailVerified) {
|
|
3605
3722
|
return null;
|
|
3606
3723
|
}
|
|
3607
|
-
await db.delete(passwordResetTokens).where(
|
|
3724
|
+
await db.delete(passwordResetTokens).where(eq15(passwordResetTokens.userId, user.id));
|
|
3608
3725
|
const token = generateSecureToken(64);
|
|
3609
3726
|
const tokenHash = await hashToken(token);
|
|
3610
3727
|
const expiresAt = Date.now() + TOKEN_EXPIRATION_MS;
|
|
@@ -3626,10 +3743,10 @@ async function validateResetToken(db, tables2, token) {
|
|
|
3626
3743
|
used: passwordResetTokens.used,
|
|
3627
3744
|
expiresAt: passwordResetTokens.expiresAt
|
|
3628
3745
|
}).from(passwordResetTokens).where(
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3746
|
+
and5(
|
|
3747
|
+
eq15(passwordResetTokens.tokenHash, tokenHash),
|
|
3748
|
+
eq15(passwordResetTokens.used, false),
|
|
3749
|
+
gt4(passwordResetTokens.expiresAt, Date.now())
|
|
3633
3750
|
)
|
|
3634
3751
|
).limit(1);
|
|
3635
3752
|
if (!resetToken) {
|
|
@@ -3650,11 +3767,11 @@ async function resetPassword(db, tables2, token, newPassword, pepper, pepperKid
|
|
|
3650
3767
|
await db.update(users).set({
|
|
3651
3768
|
hashedPassword,
|
|
3652
3769
|
pepperKid,
|
|
3653
|
-
sessionVersion:
|
|
3654
|
-
}).where(
|
|
3770
|
+
sessionVersion: sql4`${users.sessionVersion} + 1`
|
|
3771
|
+
}).where(eq15(users.id, userId));
|
|
3655
3772
|
const tokenHash = await hashToken(token);
|
|
3656
|
-
await db.update(passwordResetTokens).set({ used: true }).where(
|
|
3657
|
-
await db.delete(sessions).where(
|
|
3773
|
+
await db.update(passwordResetTokens).set({ used: true }).where(eq15(passwordResetTokens.tokenHash, tokenHash));
|
|
3774
|
+
await db.delete(sessions).where(eq15(sessions.userId, userId));
|
|
3658
3775
|
logger_default.info("Password reset successful", { userId });
|
|
3659
3776
|
return { success: true };
|
|
3660
3777
|
}
|
|
@@ -3802,14 +3919,14 @@ var resetPasswordMiddleware = [
|
|
|
3802
3919
|
import { createRoute as createRoute8 } from "@hono/zod-openapi";
|
|
3803
3920
|
|
|
3804
3921
|
// src/core/change-password.ts
|
|
3805
|
-
import { eq as
|
|
3922
|
+
import { eq as eq16, and as and6, ne } from "drizzle-orm";
|
|
3806
3923
|
async function changePassword(params) {
|
|
3807
3924
|
const { userId, currentPassword, newPassword, currentSessionId, pepper, db, tables: tables2 } = params;
|
|
3808
3925
|
const { users, sessions } = tables2;
|
|
3809
3926
|
const [user] = await db.select({
|
|
3810
3927
|
id: users.id,
|
|
3811
3928
|
hashedPassword: users.hashedPassword
|
|
3812
|
-
}).from(users).where(
|
|
3929
|
+
}).from(users).where(eq16(users.id, userId)).limit(1);
|
|
3813
3930
|
if (!user || !user.hashedPassword) {
|
|
3814
3931
|
return { success: false, error: "User not found" };
|
|
3815
3932
|
}
|
|
@@ -3825,8 +3942,8 @@ async function changePassword(params) {
|
|
|
3825
3942
|
await db.update(users).set({
|
|
3826
3943
|
hashedPassword: newHashedPassword,
|
|
3827
3944
|
updatedAt: /* @__PURE__ */ new Date()
|
|
3828
|
-
}).where(
|
|
3829
|
-
await db.delete(sessions).where(
|
|
3945
|
+
}).where(eq16(users.id, userId));
|
|
3946
|
+
await db.delete(sessions).where(and6(eq16(sessions.userId, userId), ne(sessions.id, currentSessionId)));
|
|
3830
3947
|
logSecurityEvent("password_changed", "low", {
|
|
3831
3948
|
userId
|
|
3832
3949
|
});
|
|
@@ -4099,7 +4216,7 @@ var cancelEmailChangeHandler = async (c) => {
|
|
|
4099
4216
|
// src/routes/delete-account.ts
|
|
4100
4217
|
import { createRoute as createRoute13 } from "@hono/zod-openapi";
|
|
4101
4218
|
import { z as z5 } from "@hono/zod-openapi";
|
|
4102
|
-
import { eq as
|
|
4219
|
+
import { eq as eq17 } from "drizzle-orm";
|
|
4103
4220
|
var deleteAccountRequestSchema = z5.object({
|
|
4104
4221
|
password: z5.string().min(1).openapi({ example: "CurrentPass123!" })
|
|
4105
4222
|
}).openapi("DeleteAccountRequest");
|
|
@@ -4147,7 +4264,7 @@ var deleteAccountHandler = async (c) => {
|
|
|
4147
4264
|
const [user] = await database.select({
|
|
4148
4265
|
id: schema.users.id,
|
|
4149
4266
|
hashedPassword: schema.users.hashedPassword
|
|
4150
|
-
}).from(schema.users).where(
|
|
4267
|
+
}).from(schema.users).where(eq17(schema.users.id, userId)).limit(1);
|
|
4151
4268
|
if (!user || !user.hashedPassword) {
|
|
4152
4269
|
return problems.unauthorized(c, "Invalid credentials");
|
|
4153
4270
|
}
|
|
@@ -4162,8 +4279,8 @@ var deleteAccountHandler = async (c) => {
|
|
|
4162
4279
|
}
|
|
4163
4280
|
const now = /* @__PURE__ */ new Date();
|
|
4164
4281
|
const purgeAt = new Date(now.getTime() + PURGE_WINDOW_DAYS * 24 * 60 * 60 * 1e3);
|
|
4165
|
-
await database.update(schema.users).set({ deletedAt: now, scheduledPurgeAt: purgeAt, updatedAt: now }).where(
|
|
4166
|
-
await database.delete(schema.sessions).where(
|
|
4282
|
+
await database.update(schema.users).set({ deletedAt: now, scheduledPurgeAt: purgeAt, updatedAt: now }).where(eq17(schema.users.id, userId));
|
|
4283
|
+
await database.delete(schema.sessions).where(eq17(schema.sessions.userId, userId));
|
|
4167
4284
|
logSecurityEvent("account_deletion_requested", "high", { userId });
|
|
4168
4285
|
return c.json(
|
|
4169
4286
|
{
|
|
@@ -4184,7 +4301,7 @@ var deleteAccountMiddleware = [requireAuth];
|
|
|
4184
4301
|
// src/routes/refresh.ts
|
|
4185
4302
|
import { createRoute as createRoute14 } from "@hono/zod-openapi";
|
|
4186
4303
|
import { z as z6 } from "@hono/zod-openapi";
|
|
4187
|
-
import { eq as
|
|
4304
|
+
import { eq as eq18, and as and7, gt as gt5 } from "drizzle-orm";
|
|
4188
4305
|
var refreshRequestSchema = z6.object({
|
|
4189
4306
|
refreshToken: z6.string().min(1).openapi({ example: "abc123..." })
|
|
4190
4307
|
}).openapi("RefreshRequest");
|
|
@@ -4224,7 +4341,7 @@ var refreshHandler = async (c) => {
|
|
|
4224
4341
|
expiresAt: schema.sessions.expiresAt,
|
|
4225
4342
|
fingerprint: schema.sessions.fingerprint,
|
|
4226
4343
|
ipAddress: schema.sessions.ipAddress
|
|
4227
|
-
}).from(schema.sessions).where(
|
|
4344
|
+
}).from(schema.sessions).where(and7(eq18(schema.sessions.id, refreshToken), gt5(schema.sessions.expiresAt, Date.now()))).limit(1);
|
|
4228
4345
|
if (!session) {
|
|
4229
4346
|
return problems.unauthorized(c, "Invalid or expired refresh token");
|
|
4230
4347
|
}
|
|
@@ -4247,7 +4364,7 @@ var refreshMiddleware = [];
|
|
|
4247
4364
|
// src/routes/resend-verification.ts
|
|
4248
4365
|
import { createRoute as createRoute15 } from "@hono/zod-openapi";
|
|
4249
4366
|
import { z as z7 } from "@hono/zod-openapi";
|
|
4250
|
-
import { eq as
|
|
4367
|
+
import { eq as eq19 } from "drizzle-orm";
|
|
4251
4368
|
var resendVerificationResponseSchema = z7.object({
|
|
4252
4369
|
success: z7.boolean().openapi({ example: true }),
|
|
4253
4370
|
message: z7.string().openapi({ example: "Verification email sent" })
|
|
@@ -4292,14 +4409,14 @@ var resendVerificationHandler = async (c) => {
|
|
|
4292
4409
|
email: schema.users.email,
|
|
4293
4410
|
name: schema.users.name,
|
|
4294
4411
|
emailVerified: schema.users.emailVerified
|
|
4295
|
-
}).from(schema.users).where(
|
|
4412
|
+
}).from(schema.users).where(eq19(schema.users.id, userId)).limit(1);
|
|
4296
4413
|
if (!user) {
|
|
4297
4414
|
return problems.unauthorized(c, "User not found");
|
|
4298
4415
|
}
|
|
4299
4416
|
if (user.emailVerified) {
|
|
4300
4417
|
return problems.badRequest(c, "Email is already verified");
|
|
4301
4418
|
}
|
|
4302
|
-
await db.delete(schema.emailVerificationTokens).where(
|
|
4419
|
+
await db.delete(schema.emailVerificationTokens).where(eq19(schema.emailVerificationTokens.userId, userId));
|
|
4303
4420
|
const emailToken = generateSecureToken(32);
|
|
4304
4421
|
const tokenHash = await hashToken(emailToken);
|
|
4305
4422
|
const expiresAt = Date.now() + 24 * 60 * 60 * 1e3;
|
|
@@ -4344,7 +4461,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
|
|
|
4344
4461
|
|
|
4345
4462
|
// src/routes/2fa/status.ts
|
|
4346
4463
|
import { createRoute as createRoute16 } from "@hono/zod-openapi";
|
|
4347
|
-
import { eq as
|
|
4464
|
+
import { eq as eq20, and as and8, isNull as isNull3 } from "drizzle-orm";
|
|
4348
4465
|
|
|
4349
4466
|
// src/routes/2fa/schemas.ts
|
|
4350
4467
|
import { z as z8 } from "@hono/zod-openapi";
|
|
@@ -4505,7 +4622,7 @@ var statusHandler = async (c) => {
|
|
|
4505
4622
|
if (!userId) return problems.unauthorized(c);
|
|
4506
4623
|
const { db, schema } = getAuthContext(c);
|
|
4507
4624
|
const methods = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4508
|
-
const backupCodes = await db.select({ id: schema.userBackupCodes.id }).from(schema.userBackupCodes).where(
|
|
4625
|
+
const backupCodes = await db.select({ id: schema.userBackupCodes.id }).from(schema.userBackupCodes).where(and8(eq20(schema.userBackupCodes.userId, userId), isNull3(schema.userBackupCodes.usedAt)));
|
|
4509
4626
|
c.header("Cache-Control", "private, max-age=60");
|
|
4510
4627
|
c.header("Vary", "Cookie");
|
|
4511
4628
|
return c.json({
|
|
@@ -4517,7 +4634,7 @@ var statusHandler = async (c) => {
|
|
|
4517
4634
|
|
|
4518
4635
|
// src/routes/2fa/totp-setup.ts
|
|
4519
4636
|
import { createRoute as createRoute17 } from "@hono/zod-openapi";
|
|
4520
|
-
import { eq as
|
|
4637
|
+
import { eq as eq21, and as and9 } from "drizzle-orm";
|
|
4521
4638
|
var totpSetupRoute = createRoute17({
|
|
4522
4639
|
method: "post",
|
|
4523
4640
|
path: "/totp/setup",
|
|
@@ -4547,11 +4664,11 @@ var totpSetupHandler = async (c) => {
|
|
|
4547
4664
|
const userId = c.get("userId");
|
|
4548
4665
|
if (!userId) return problems.unauthorized(c);
|
|
4549
4666
|
const { db, schema, env } = getAuthContext(c);
|
|
4550
|
-
const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where(
|
|
4667
|
+
const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where(and9(eq21(schema.user2faMethods.userId, userId), eq21(schema.user2faMethods.method, "totp"))).limit(1);
|
|
4551
4668
|
if (existing.length > 0) {
|
|
4552
4669
|
return problems.badRequest(c, "TOTP already enabled");
|
|
4553
4670
|
}
|
|
4554
|
-
const [user] = await db.select({ email: schema.users.email }).from(schema.users).where(
|
|
4671
|
+
const [user] = await db.select({ email: schema.users.email }).from(schema.users).where(eq21(schema.users.id, userId)).limit(1);
|
|
4555
4672
|
if (!user) return problems.notFound(c, "User not found");
|
|
4556
4673
|
const secret = generateTotpSecret();
|
|
4557
4674
|
const qrCodeUri = generateQrCodeUri(secret, user.email);
|
|
@@ -4562,7 +4679,7 @@ var totpSetupHandler = async (c) => {
|
|
|
4562
4679
|
|
|
4563
4680
|
// src/routes/2fa/totp-verify.ts
|
|
4564
4681
|
import { createRoute as createRoute18 } from "@hono/zod-openapi";
|
|
4565
|
-
import { eq as
|
|
4682
|
+
import { eq as eq22 } from "drizzle-orm";
|
|
4566
4683
|
var totpVerifyRoute = createRoute18({
|
|
4567
4684
|
method: "post",
|
|
4568
4685
|
path: "/totp/verify",
|
|
@@ -4608,7 +4725,7 @@ var totpVerifyHandler = async (c) => {
|
|
|
4608
4725
|
logSecurityEvent("2fa_totp_setup_failed", "medium", { userId });
|
|
4609
4726
|
return problems.badRequest(c, "Invalid code");
|
|
4610
4727
|
}
|
|
4611
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
4728
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq22(schema.users.id, userId)).limit(1);
|
|
4612
4729
|
const encryptionKey = env.TOTP_ENCRYPTION_KEY;
|
|
4613
4730
|
if (!encryptionKey) {
|
|
4614
4731
|
logger_default.error("TOTP_ENCRYPTION_KEY not configured");
|
|
@@ -4656,7 +4773,7 @@ var totpVerifyHandler = async (c) => {
|
|
|
4656
4773
|
|
|
4657
4774
|
// src/routes/2fa/totp-disable.ts
|
|
4658
4775
|
import { createRoute as createRoute19 } from "@hono/zod-openapi";
|
|
4659
|
-
import { eq as
|
|
4776
|
+
import { eq as eq23, and as and10 } from "drizzle-orm";
|
|
4660
4777
|
var totpDisableRoute = createRoute19({
|
|
4661
4778
|
method: "post",
|
|
4662
4779
|
path: "/totp/disable",
|
|
@@ -4699,13 +4816,13 @@ var totpDisableHandler = async (c) => {
|
|
|
4699
4816
|
logSecurityEvent("2fa_disable_failed", "medium", { userId, method: "totp" });
|
|
4700
4817
|
return problems.badRequest(c, "Invalid verification code");
|
|
4701
4818
|
}
|
|
4702
|
-
await db.delete(schema.user2faMethods).where(
|
|
4819
|
+
await db.delete(schema.user2faMethods).where(and10(eq23(schema.user2faMethods.userId, userId), eq23(schema.user2faMethods.method, "totp")));
|
|
4703
4820
|
const remaining = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4704
4821
|
if (remaining.length === 0) {
|
|
4705
|
-
await db.delete(schema.userBackupCodes).where(
|
|
4706
|
-
await db.delete(schema.userTrustedDevices).where(
|
|
4822
|
+
await db.delete(schema.userBackupCodes).where(eq23(schema.userBackupCodes.userId, userId));
|
|
4823
|
+
await db.delete(schema.userTrustedDevices).where(eq23(schema.userTrustedDevices.userId, userId));
|
|
4707
4824
|
await invalidateAllUserSessions(db, { sessions: schema.sessions, users: schema.users }, userId);
|
|
4708
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
4825
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq23(schema.users.id, userId)).limit(1);
|
|
4709
4826
|
if (user) {
|
|
4710
4827
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
4711
4828
|
const firstName = user.name?.split(" ")[0] || null;
|
|
@@ -4724,7 +4841,7 @@ var totpDisableHandler = async (c) => {
|
|
|
4724
4841
|
|
|
4725
4842
|
// src/routes/2fa/email-setup.ts
|
|
4726
4843
|
import { createRoute as createRoute20 } from "@hono/zod-openapi";
|
|
4727
|
-
import { eq as
|
|
4844
|
+
import { eq as eq24, and as and11 } from "drizzle-orm";
|
|
4728
4845
|
var emailSetupRoute = createRoute20({
|
|
4729
4846
|
method: "post",
|
|
4730
4847
|
path: "/email/setup",
|
|
@@ -4754,15 +4871,15 @@ var emailSetupHandler = async (c) => {
|
|
|
4754
4871
|
const userId = c.get("userId");
|
|
4755
4872
|
if (!userId) return problems.unauthorized(c);
|
|
4756
4873
|
const { db, schema, env, emailBranding } = getAuthContext(c);
|
|
4757
|
-
const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where(
|
|
4874
|
+
const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where(and11(eq24(schema.user2faMethods.userId, userId), eq24(schema.user2faMethods.method, "email"))).limit(1);
|
|
4758
4875
|
if (existing.length > 0) {
|
|
4759
4876
|
return problems.badRequest(c, "Email 2FA already enabled");
|
|
4760
4877
|
}
|
|
4761
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
4878
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq24(schema.users.id, userId)).limit(1);
|
|
4762
4879
|
if (!user) return problems.notFound(c, "User not found");
|
|
4763
4880
|
const code = generateEmailOtp();
|
|
4764
|
-
const
|
|
4765
|
-
await env.OAUTH_STATES.put(`email_2fa_setup:${userId}`,
|
|
4881
|
+
const codeHash2 = await hashToken(code);
|
|
4882
|
+
await env.OAUTH_STATES.put(`email_2fa_setup:${userId}`, codeHash2, { expirationTtl: 300 });
|
|
4766
4883
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
4767
4884
|
const firstName = user.name?.split(" ")[0] || null;
|
|
4768
4885
|
await emailService.send2faCodeEmail({ email: user.email, firstName, code }, db, schema.users);
|
|
@@ -4772,7 +4889,7 @@ var emailSetupHandler = async (c) => {
|
|
|
4772
4889
|
|
|
4773
4890
|
// src/routes/2fa/email-verify.ts
|
|
4774
4891
|
import { createRoute as createRoute21 } from "@hono/zod-openapi";
|
|
4775
|
-
import { eq as
|
|
4892
|
+
import { eq as eq25 } from "drizzle-orm";
|
|
4776
4893
|
var emailVerifyRoute = createRoute21({
|
|
4777
4894
|
method: "post",
|
|
4778
4895
|
path: "/email/verify",
|
|
@@ -4809,12 +4926,12 @@ var emailVerifyHandler = async (c) => {
|
|
|
4809
4926
|
if (!storedHash) {
|
|
4810
4927
|
return problems.badRequest(c, "Code expired. Please request a new one.");
|
|
4811
4928
|
}
|
|
4812
|
-
const
|
|
4813
|
-
if (
|
|
4929
|
+
const codeHash2 = await hashToken(code);
|
|
4930
|
+
if (codeHash2 !== storedHash) {
|
|
4814
4931
|
logSecurityEvent("2fa_email_setup_failed", "medium", { userId });
|
|
4815
4932
|
return problems.badRequest(c, "Invalid code");
|
|
4816
4933
|
}
|
|
4817
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
4934
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq25(schema.users.id, userId)).limit(1);
|
|
4818
4935
|
const now = Date.now();
|
|
4819
4936
|
const existingMethods = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4820
4937
|
const isFirstMethod = existingMethods.length === 0;
|
|
@@ -4856,7 +4973,7 @@ var emailVerifyHandler = async (c) => {
|
|
|
4856
4973
|
|
|
4857
4974
|
// src/routes/2fa/email-send-code.ts
|
|
4858
4975
|
import { createRoute as createRoute22 } from "@hono/zod-openapi";
|
|
4859
|
-
import { eq as
|
|
4976
|
+
import { eq as eq26, and as and12 } from "drizzle-orm";
|
|
4860
4977
|
var emailSendCodeRoute = createRoute22({
|
|
4861
4978
|
method: "post",
|
|
4862
4979
|
path: "/email/send-code",
|
|
@@ -4886,15 +5003,15 @@ var emailSendCodeHandler = async (c) => {
|
|
|
4886
5003
|
const userId = c.get("userId");
|
|
4887
5004
|
if (!userId) return problems.unauthorized(c);
|
|
4888
5005
|
const { db, schema, env, emailBranding } = getAuthContext(c);
|
|
4889
|
-
const [emailMethod] = await db.select().from(schema.user2faMethods).where(
|
|
5006
|
+
const [emailMethod] = await db.select().from(schema.user2faMethods).where(and12(eq26(schema.user2faMethods.userId, userId), eq26(schema.user2faMethods.method, "email"))).limit(1);
|
|
4890
5007
|
if (!emailMethod) {
|
|
4891
5008
|
return problems.badRequest(c, "Email 2FA not enabled");
|
|
4892
5009
|
}
|
|
4893
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
5010
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq26(schema.users.id, userId)).limit(1);
|
|
4894
5011
|
if (!user) return problems.notFound(c, "User not found");
|
|
4895
5012
|
const code = generateEmailOtp();
|
|
4896
|
-
const
|
|
4897
|
-
await env.OAUTH_STATES.put(`email_2fa_challenge:${userId}`,
|
|
5013
|
+
const codeHash2 = await hashToken(code);
|
|
5014
|
+
await env.OAUTH_STATES.put(`email_2fa_challenge:${userId}`, codeHash2, {
|
|
4898
5015
|
expirationTtl: 300
|
|
4899
5016
|
});
|
|
4900
5017
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
@@ -4916,7 +5033,7 @@ var emailSendCodeHandler = async (c) => {
|
|
|
4916
5033
|
|
|
4917
5034
|
// src/routes/2fa/email-disable.ts
|
|
4918
5035
|
import { createRoute as createRoute23 } from "@hono/zod-openapi";
|
|
4919
|
-
import { eq as
|
|
5036
|
+
import { eq as eq27, and as and13 } from "drizzle-orm";
|
|
4920
5037
|
var emailDisableRoute = createRoute23({
|
|
4921
5038
|
method: "post",
|
|
4922
5039
|
path: "/email/disable",
|
|
@@ -4959,13 +5076,13 @@ var emailDisableHandler = async (c) => {
|
|
|
4959
5076
|
logSecurityEvent("2fa_disable_failed", "medium", { userId, method: "email" });
|
|
4960
5077
|
return problems.badRequest(c, "Invalid verification code");
|
|
4961
5078
|
}
|
|
4962
|
-
await db.delete(schema.user2faMethods).where(
|
|
5079
|
+
await db.delete(schema.user2faMethods).where(and13(eq27(schema.user2faMethods.userId, userId), eq27(schema.user2faMethods.method, "email")));
|
|
4963
5080
|
const remaining = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4964
5081
|
if (remaining.length === 0) {
|
|
4965
|
-
await db.delete(schema.userBackupCodes).where(
|
|
4966
|
-
await db.delete(schema.userTrustedDevices).where(
|
|
5082
|
+
await db.delete(schema.userBackupCodes).where(eq27(schema.userBackupCodes.userId, userId));
|
|
5083
|
+
await db.delete(schema.userTrustedDevices).where(eq27(schema.userTrustedDevices.userId, userId));
|
|
4967
5084
|
await invalidateAllUserSessions(db, { sessions: schema.sessions, users: schema.users }, userId);
|
|
4968
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
5085
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq27(schema.users.id, userId)).limit(1);
|
|
4969
5086
|
if (user) {
|
|
4970
5087
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
4971
5088
|
const firstName = user.name?.split(" ")[0] || null;
|
|
@@ -4984,7 +5101,7 @@ var emailDisableHandler = async (c) => {
|
|
|
4984
5101
|
|
|
4985
5102
|
// src/routes/2fa/trusted-devices.ts
|
|
4986
5103
|
import { createRoute as createRoute24, z as z9 } from "@hono/zod-openapi";
|
|
4987
|
-
import { eq as
|
|
5104
|
+
import { eq as eq28, and as and14, gt as gt6 } from "drizzle-orm";
|
|
4988
5105
|
var trustedDevicesGetRoute = createRoute24({
|
|
4989
5106
|
method: "get",
|
|
4990
5107
|
path: "/trusted-devices",
|
|
@@ -5023,7 +5140,7 @@ var trustedDevicesGetHandler = async (c) => {
|
|
|
5023
5140
|
ipAddress: schema.userTrustedDevices.ipAddress,
|
|
5024
5141
|
lastUsedAt: schema.userTrustedDevices.lastUsedAt,
|
|
5025
5142
|
createdAt: schema.userTrustedDevices.createdAt
|
|
5026
|
-
}).from(schema.userTrustedDevices).where(
|
|
5143
|
+
}).from(schema.userTrustedDevices).where(and14(eq28(schema.userTrustedDevices.userId, userId), gt6(schema.userTrustedDevices.expiresAt, now)));
|
|
5027
5144
|
c.header("Cache-Control", "private, max-age=60");
|
|
5028
5145
|
c.header("Vary", "Cookie");
|
|
5029
5146
|
return c.json({ devices });
|
|
@@ -5059,7 +5176,7 @@ var trustedDevicesDeleteHandler = async (c) => {
|
|
|
5059
5176
|
if (!userId) return problems.unauthorized(c);
|
|
5060
5177
|
const deviceId = c.req.param("id");
|
|
5061
5178
|
const { db, schema } = getAuthContext(c);
|
|
5062
|
-
const result = await db.delete(schema.userTrustedDevices).where(
|
|
5179
|
+
const result = await db.delete(schema.userTrustedDevices).where(and14(eq28(schema.userTrustedDevices.id, deviceId), eq28(schema.userTrustedDevices.userId, userId))).returning({ id: schema.userTrustedDevices.id });
|
|
5063
5180
|
if (result.length === 0) {
|
|
5064
5181
|
return problems.notFound(c, "Device not found");
|
|
5065
5182
|
}
|
|
@@ -5069,7 +5186,7 @@ var trustedDevicesDeleteHandler = async (c) => {
|
|
|
5069
5186
|
|
|
5070
5187
|
// src/routes/2fa/backup-codes.ts
|
|
5071
5188
|
import { createRoute as createRoute25 } from "@hono/zod-openapi";
|
|
5072
|
-
import { eq as
|
|
5189
|
+
import { eq as eq29 } from "drizzle-orm";
|
|
5073
5190
|
var backupCodesRegenerateRoute = createRoute25({
|
|
5074
5191
|
method: "post",
|
|
5075
5192
|
path: "/backup-codes/regenerate",
|
|
@@ -5110,7 +5227,7 @@ var backupCodesRegenerateHandler = async (c) => {
|
|
|
5110
5227
|
logSecurityEvent("2fa_backup_regenerate_failed", "medium", { userId });
|
|
5111
5228
|
return problems.badRequest(c, "Invalid verification code");
|
|
5112
5229
|
}
|
|
5113
|
-
await db.delete(schema.userBackupCodes).where(
|
|
5230
|
+
await db.delete(schema.userBackupCodes).where(eq29(schema.userBackupCodes.userId, userId));
|
|
5114
5231
|
const codes = generateBackupCodes();
|
|
5115
5232
|
const now = Date.now();
|
|
5116
5233
|
const hashedCodes = await Promise.all(
|
|
@@ -5127,7 +5244,7 @@ var backupCodesRegenerateHandler = async (c) => {
|
|
|
5127
5244
|
|
|
5128
5245
|
// src/routes/2fa/challenge.ts
|
|
5129
5246
|
import { createRoute as createRoute26 } from "@hono/zod-openapi";
|
|
5130
|
-
import { eq as
|
|
5247
|
+
import { eq as eq30 } from "drizzle-orm";
|
|
5131
5248
|
import { getCookie as getCookie2 } from "hono/cookie";
|
|
5132
5249
|
var challengeRoute = createRoute26({
|
|
5133
5250
|
method: "post",
|
|
@@ -5199,7 +5316,7 @@ var challengeHandler = async (c) => {
|
|
|
5199
5316
|
}
|
|
5200
5317
|
await deleteChallengeToken(c.env.OAUTH_STATES, challengeToken);
|
|
5201
5318
|
clearChallengeCookie(c, c.env);
|
|
5202
|
-
const [user] = await db.select().from(schema.users).where(
|
|
5319
|
+
const [user] = await db.select().from(schema.users).where(eq30(schema.users.id, payload.userId)).limit(1);
|
|
5203
5320
|
if (!user) {
|
|
5204
5321
|
logger_default.error("User not found during 2FA challenge", { userId: payload.userId });
|
|
5205
5322
|
return problems.notFound(c, "User not found");
|
|
@@ -5240,7 +5357,7 @@ var challengeHandler = async (c) => {
|
|
|
5240
5357
|
|
|
5241
5358
|
// src/routes/2fa/challenge-resend.ts
|
|
5242
5359
|
import { createRoute as createRoute27 } from "@hono/zod-openapi";
|
|
5243
|
-
import { eq as
|
|
5360
|
+
import { eq as eq31 } from "drizzle-orm";
|
|
5244
5361
|
import { getCookie as getCookie3 } from "hono/cookie";
|
|
5245
5362
|
var challengeResendRoute = createRoute27({
|
|
5246
5363
|
method: "post",
|
|
@@ -5278,19 +5395,12 @@ var challengeResendHandler = async (c) => {
|
|
|
5278
5395
|
if (!payload.methods.includes("email")) {
|
|
5279
5396
|
return problems.badRequest(c, "Email verification not available");
|
|
5280
5397
|
}
|
|
5281
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
5398
|
+
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(eq31(schema.users.id, payload.userId)).limit(1);
|
|
5282
5399
|
if (!user) {
|
|
5283
5400
|
logger_default.error("User not found during 2FA resend", { userId: payload.userId });
|
|
5284
5401
|
return problems.notFound(c, "User not found");
|
|
5285
5402
|
}
|
|
5286
|
-
|
|
5287
|
-
const codeHash = await hashToken(code);
|
|
5288
|
-
await c.env.OAUTH_STATES.put(`email_2fa_challenge:${payload.userId}`, codeHash, {
|
|
5289
|
-
expirationTtl: 300
|
|
5290
|
-
});
|
|
5291
|
-
const emailService = new EmailService(c.env, void 0, emailBranding);
|
|
5292
|
-
const firstName = user.name?.split(" ")[0] || null;
|
|
5293
|
-
await emailService.send2faCodeEmail({ email: user.email, firstName, code }, db, schema.users);
|
|
5403
|
+
await sendEmailChallengeCode(c.env, db, schema.users, { id: payload.userId, ...user }, emailBranding);
|
|
5294
5404
|
logger_default.info("Email 2FA code resent", { userId: payload.userId });
|
|
5295
5405
|
return c.json({ success: true });
|
|
5296
5406
|
};
|
|
@@ -5516,7 +5626,7 @@ var authorizeHandler = async (c) => {
|
|
|
5516
5626
|
|
|
5517
5627
|
// src/routes/oauth/callback.ts
|
|
5518
5628
|
import { createRoute as createRoute29 } from "@hono/zod-openapi";
|
|
5519
|
-
import { eq as
|
|
5629
|
+
import { eq as eq32 } from "drizzle-orm";
|
|
5520
5630
|
var callbackRoute = createRoute29({
|
|
5521
5631
|
method: "get",
|
|
5522
5632
|
path: "/{provider}/callback",
|
|
@@ -5642,10 +5752,24 @@ var callbackHandler = async (c) => {
|
|
|
5642
5752
|
logger_default.warn("No user ID returned from OAuth provider", { provider });
|
|
5643
5753
|
return c.redirect(`${appUrl}/login?error=oauth_failed`);
|
|
5644
5754
|
}
|
|
5645
|
-
const { db, schema } = getAuthContext(c);
|
|
5646
|
-
const [existingUser] = await db.select({ id: schema.users.id, email: schema.users.email }).from(schema.users).where(
|
|
5755
|
+
const { db, schema, env, invitations, signup } = getAuthContext(c);
|
|
5756
|
+
const [existingUser] = await db.select({ id: schema.users.id, email: schema.users.email }).from(schema.users).where(eq32(schema.users.email, email)).limit(1);
|
|
5647
5757
|
let authUser = existingUser;
|
|
5648
5758
|
if (!authUser) {
|
|
5759
|
+
const invited = await verifyInvitation(invitations, {
|
|
5760
|
+
token: invitationToken,
|
|
5761
|
+
email,
|
|
5762
|
+
db,
|
|
5763
|
+
env
|
|
5764
|
+
});
|
|
5765
|
+
if (signup === "invitation" && !invited) {
|
|
5766
|
+
logSecurityEvent("oauth_signup_refused_no_invitation", "medium", {
|
|
5767
|
+
email,
|
|
5768
|
+
provider,
|
|
5769
|
+
hadInvitation: !!invitationToken
|
|
5770
|
+
});
|
|
5771
|
+
return c.redirect(`${appUrl}/login?error=invitation_required`);
|
|
5772
|
+
}
|
|
5649
5773
|
const [createdUser] = await db.insert(schema.users).values({
|
|
5650
5774
|
email,
|
|
5651
5775
|
name: name || null,
|
|
@@ -5656,6 +5780,15 @@ var callbackHandler = async (c) => {
|
|
|
5656
5780
|
activatedAt: /* @__PURE__ */ new Date()
|
|
5657
5781
|
}).returning({ id: schema.users.id, email: schema.users.email });
|
|
5658
5782
|
authUser = createdUser;
|
|
5783
|
+
if (invited) {
|
|
5784
|
+
await acceptInvitation(invitations, {
|
|
5785
|
+
token: invited,
|
|
5786
|
+
user: { id: createdUser.id, email: createdUser.email, name: name || null },
|
|
5787
|
+
db,
|
|
5788
|
+
env,
|
|
5789
|
+
context: "oauth_invitation_onAccepted_failed"
|
|
5790
|
+
});
|
|
5791
|
+
}
|
|
5659
5792
|
logSecurityEvent("user_signup_oauth", "low", {
|
|
5660
5793
|
userId: createdUser.id,
|
|
5661
5794
|
email: createdUser.email,
|
|
@@ -5695,7 +5828,7 @@ var callbackHandler = async (c) => {
|
|
|
5695
5828
|
|
|
5696
5829
|
// src/routes/oauth/complete.ts
|
|
5697
5830
|
import { createRoute as createRoute30 } from "@hono/zod-openapi";
|
|
5698
|
-
import { eq as
|
|
5831
|
+
import { eq as eq33 } from "drizzle-orm";
|
|
5699
5832
|
var completeRoute = createRoute30({
|
|
5700
5833
|
method: "post",
|
|
5701
5834
|
path: "/complete",
|
|
@@ -5743,7 +5876,7 @@ var completeHandler = async (c) => {
|
|
|
5743
5876
|
redirect: storedRedirect
|
|
5744
5877
|
} = JSON.parse(stored);
|
|
5745
5878
|
const { db, schema } = getAuthContext(c);
|
|
5746
|
-
await db.update(schema.users).set({ emailVerified: true }).where(
|
|
5879
|
+
await db.update(schema.users).set({ emailVerified: true }).where(eq33(schema.users.id, userId));
|
|
5747
5880
|
await db.insert(schema.oauthAccounts).values({ userId, provider, providerUserId, email }).onConflictDoNothing();
|
|
5748
5881
|
const fingerprint = await generateFingerprint(c.req.raw);
|
|
5749
5882
|
const ipAddress = getClientIp(c.req.raw);
|
|
@@ -5788,19 +5921,12 @@ var routes_default = authRoutes;
|
|
|
5788
5921
|
|
|
5789
5922
|
// src/lib/email/webhook-verifier.ts
|
|
5790
5923
|
import { Webhook } from "svix";
|
|
5791
|
-
function verifyWebhookSignature(payload,
|
|
5924
|
+
function verifyWebhookSignature(payload, headers, secret) {
|
|
5792
5925
|
if (!secret || secret.trim() === "") {
|
|
5793
5926
|
throw new Error("Webhook secret is required");
|
|
5794
5927
|
}
|
|
5795
5928
|
try {
|
|
5796
|
-
|
|
5797
|
-
wh.verify(payload, {
|
|
5798
|
-
"svix-id": "",
|
|
5799
|
-
// Not used by Resend
|
|
5800
|
-
"svix-timestamp": "",
|
|
5801
|
-
// Not used by Resend
|
|
5802
|
-
"svix-signature": signature
|
|
5803
|
-
});
|
|
5929
|
+
new Webhook(secret).verify(payload, { ...headers });
|
|
5804
5930
|
return true;
|
|
5805
5931
|
} catch (error) {
|
|
5806
5932
|
logger_default.warn("Webhook signature verification failed", {
|
|
@@ -5815,6 +5941,8 @@ export {
|
|
|
5815
5941
|
CloudflareEmailAdapter,
|
|
5816
5942
|
EmailService,
|
|
5817
5943
|
MAX_CHALLENGE_ATTEMPTS,
|
|
5944
|
+
PASSWORDLESS_MAX_ATTEMPTS,
|
|
5945
|
+
PASSWORDLESS_TTL_MS,
|
|
5818
5946
|
ProblemTypes,
|
|
5819
5947
|
ResendAdapter,
|
|
5820
5948
|
SCHEMA_VERSION,
|
|
@@ -5826,6 +5954,7 @@ export {
|
|
|
5826
5954
|
cancelEmailChange,
|
|
5827
5955
|
checkAccountLocked,
|
|
5828
5956
|
checkBreachedPassword,
|
|
5957
|
+
cleanupExpiredPasswordlessChallenges,
|
|
5829
5958
|
clearAccountLockout,
|
|
5830
5959
|
clearChallengeCookie,
|
|
5831
5960
|
clearSessionCookie,
|
|
@@ -5836,6 +5965,7 @@ export {
|
|
|
5836
5965
|
createChallengeToken,
|
|
5837
5966
|
createDeviceToken,
|
|
5838
5967
|
createEmailAdapter,
|
|
5968
|
+
createPasswordlessChallenge,
|
|
5839
5969
|
createProblemDetails,
|
|
5840
5970
|
createSession,
|
|
5841
5971
|
csrf,
|
|
@@ -5852,6 +5982,7 @@ export {
|
|
|
5852
5982
|
generate2faEnabledEmail,
|
|
5853
5983
|
generateApiKey,
|
|
5854
5984
|
generateBackupCodes,
|
|
5985
|
+
generateCode,
|
|
5855
5986
|
generateCodeChallenge,
|
|
5856
5987
|
generateCodeVerifier,
|
|
5857
5988
|
generateEmailChangeConfirmation,
|
|
@@ -5890,14 +6021,17 @@ export {
|
|
|
5890
6021
|
optionalAuth,
|
|
5891
6022
|
parseDeviceName,
|
|
5892
6023
|
parseDeviceType,
|
|
6024
|
+
passwordlessEmail,
|
|
5893
6025
|
problemJson,
|
|
5894
6026
|
problems,
|
|
5895
6027
|
rateLimit,
|
|
6028
|
+
recentPasswordlessFailures,
|
|
5896
6029
|
refreshSession,
|
|
5897
6030
|
requestEmailChange,
|
|
5898
6031
|
requireAuth,
|
|
5899
6032
|
requireVerifiedEmail,
|
|
5900
6033
|
retrieveChallengeToken,
|
|
6034
|
+
sessionRefreshDue,
|
|
5901
6035
|
setChallengeCookie,
|
|
5902
6036
|
setSessionCookie,
|
|
5903
6037
|
setTrustedDeviceCookie,
|
|
@@ -5915,6 +6049,8 @@ export {
|
|
|
5915
6049
|
verifyCodeChallenge,
|
|
5916
6050
|
verifyPassword,
|
|
5917
6051
|
verifyPasswordWithRotation,
|
|
6052
|
+
verifyPasswordlessCode,
|
|
6053
|
+
verifyPasswordlessLink,
|
|
5918
6054
|
verifyTotpCode,
|
|
5919
6055
|
verifyTurnstileToken,
|
|
5920
6056
|
verifyWebhookSignature
|