@syncello/auth 3.6.1 → 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 +1027 -870
- 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 +1025 -896
- 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>
|
|
@@ -1755,133 +1229,796 @@ function generateEmailTemplate(options) {
|
|
|
1755
1229
|
</tr>
|
|
1756
1230
|
</table>
|
|
1757
1231
|
|
|
1758
|
-
<!-- Copy Link -->
|
|
1759
|
-
<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;">
|
|
1232
|
+
<!-- Copy Link -->
|
|
1233
|
+
<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;">
|
|
1234
|
+
<tr>
|
|
1235
|
+
<td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
|
|
1236
|
+
<div style="color:#737373;font-family:'Montserrat',Arial,sans-serif;font-size:12px;line-height:1.5;text-align:center;mso-line-height-alt:18px;">
|
|
1237
|
+
<p style="margin: 0 0 5px 0; word-break: break-word;">or copy and paste this link into your browser:</p>
|
|
1238
|
+
<a href="${safeButtonUrl}" style="color:#4a90d9;text-decoration:underline;word-break:break-all;">${safeButtonUrl}</a>
|
|
1239
|
+
</div>
|
|
1240
|
+
</td>
|
|
1241
|
+
</tr>
|
|
1242
|
+
</table>
|
|
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;">
|
|
1760
1330
|
<tr>
|
|
1761
|
-
<td class="pad" style="padding-bottom:
|
|
1762
|
-
<div style="color:#
|
|
1763
|
-
<p style="margin: 0 0
|
|
1764
|
-
<
|
|
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>
|
|
1765
1337
|
</div>
|
|
1766
1338
|
</td>
|
|
1767
1339
|
</tr>
|
|
1768
1340
|
</table>
|
|
1769
|
-
` : ""}
|
|
1770
1341
|
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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
|
+
};
|
|
1731
|
+
|
|
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,18 +3343,18 @@ 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));
|
|
@@ -3285,7 +3388,7 @@ async function verifyAny2faCode(database, env, userId, code, method, tables2) {
|
|
|
3285
3388
|
const secret = await decryptTotpSecret(totpMethod.totpSecret, env.TOTP_ENCRYPTION_KEY);
|
|
3286
3389
|
const result = verifyTotpCode(secret, code, totpMethod.lastTotpCounter);
|
|
3287
3390
|
if (result.valid) {
|
|
3288
|
-
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")));
|
|
3289
3392
|
return { valid: true };
|
|
3290
3393
|
}
|
|
3291
3394
|
return { valid: false };
|
|
@@ -3297,18 +3400,18 @@ async function verifyAny2faCode(database, env, userId, code, method, tables2) {
|
|
|
3297
3400
|
const kvKey = `email_2fa_challenge:${userId}`;
|
|
3298
3401
|
const storedHash = await env.OAUTH_STATES.get(kvKey);
|
|
3299
3402
|
if (!storedHash) return { valid: false };
|
|
3300
|
-
const
|
|
3301
|
-
if (
|
|
3403
|
+
const codeHash2 = await hashToken(code);
|
|
3404
|
+
if (codeHash2 === storedHash) {
|
|
3302
3405
|
await env.OAUTH_STATES.delete(kvKey);
|
|
3303
3406
|
return { valid: true };
|
|
3304
3407
|
}
|
|
3305
3408
|
return { valid: false };
|
|
3306
3409
|
}
|
|
3307
3410
|
if (method === "backup") {
|
|
3308
|
-
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)));
|
|
3309
3412
|
for (const bc of backupCodes) {
|
|
3310
3413
|
if (await verifyBackupCode(code, bc.codeHash)) {
|
|
3311
|
-
await database.update(userBackupCodesTable).set({ usedAt: Date.now() }).where(
|
|
3414
|
+
await database.update(userBackupCodesTable).set({ usedAt: Date.now() }).where(eq10(userBackupCodesTable.id, bc.id));
|
|
3312
3415
|
logSecurityEvent("2fa_backup_code_used", "medium", { userId, codeId: bc.id });
|
|
3313
3416
|
return { valid: true };
|
|
3314
3417
|
}
|
|
@@ -3364,7 +3467,7 @@ var loginHandler = async (c) => {
|
|
|
3364
3467
|
return problems.badRequest(c, "Invalid captcha");
|
|
3365
3468
|
}
|
|
3366
3469
|
const user = await database.query.users.findFirst({
|
|
3367
|
-
where:
|
|
3470
|
+
where: eq11(schema.users.email, email.toLowerCase())
|
|
3368
3471
|
});
|
|
3369
3472
|
if (!user) {
|
|
3370
3473
|
return problems.unauthorized(c, "Invalid email or password");
|
|
@@ -3473,7 +3576,7 @@ var loginMiddleware = [
|
|
|
3473
3576
|
|
|
3474
3577
|
// src/routes/logout.ts
|
|
3475
3578
|
import { createRoute as createRoute3 } from "@hono/zod-openapi";
|
|
3476
|
-
import { eq as
|
|
3579
|
+
import { eq as eq12 } from "drizzle-orm";
|
|
3477
3580
|
var logoutRoute = createRoute3({
|
|
3478
3581
|
method: "post",
|
|
3479
3582
|
path: "/logout",
|
|
@@ -3496,7 +3599,7 @@ var logoutHandler = async (c) => {
|
|
|
3496
3599
|
const sessionIdMatch = cookieHeader.match(cookiePattern);
|
|
3497
3600
|
if (sessionIdMatch) {
|
|
3498
3601
|
const sessionId = sessionIdMatch[1];
|
|
3499
|
-
await database.delete(schema.sessions).where(
|
|
3602
|
+
await database.delete(schema.sessions).where(eq12(schema.sessions.id, sessionId));
|
|
3500
3603
|
}
|
|
3501
3604
|
}
|
|
3502
3605
|
c.header("Set-Cookie", clearSessionCookie(c.env), { append: true });
|
|
@@ -3505,7 +3608,7 @@ var logoutHandler = async (c) => {
|
|
|
3505
3608
|
|
|
3506
3609
|
// src/routes/me.ts
|
|
3507
3610
|
import { createRoute as createRoute4 } from "@hono/zod-openapi";
|
|
3508
|
-
import { eq as
|
|
3611
|
+
import { eq as eq13 } from "drizzle-orm";
|
|
3509
3612
|
var meRoute = createRoute4({
|
|
3510
3613
|
method: "get",
|
|
3511
3614
|
path: "/me",
|
|
@@ -3533,7 +3636,7 @@ var meHandler = async (c) => {
|
|
|
3533
3636
|
if (!userId) return problems.unauthorized(c, "Not authenticated");
|
|
3534
3637
|
const { db: database, schema } = getAuthContext(c);
|
|
3535
3638
|
const user = await database.query.users.findFirst({
|
|
3536
|
-
where:
|
|
3639
|
+
where: eq13(schema.users.id, userId)
|
|
3537
3640
|
});
|
|
3538
3641
|
if (!user) {
|
|
3539
3642
|
return problems.notFound(c, "User not found");
|
|
@@ -3559,7 +3662,7 @@ var meMiddleware = [requireAuth];
|
|
|
3559
3662
|
|
|
3560
3663
|
// src/routes/verify-email.ts
|
|
3561
3664
|
import { createRoute as createRoute5 } from "@hono/zod-openapi";
|
|
3562
|
-
import { eq as
|
|
3665
|
+
import { eq as eq14 } from "drizzle-orm";
|
|
3563
3666
|
var verifyEmailRoute = createRoute5({
|
|
3564
3667
|
method: "post",
|
|
3565
3668
|
path: "/verify-email",
|
|
@@ -3588,17 +3691,17 @@ var verifyEmailHandler = async (c) => {
|
|
|
3588
3691
|
const { db: database, schema } = getAuthContext(c);
|
|
3589
3692
|
const tokenHash = await hashToken(token);
|
|
3590
3693
|
const verificationToken = await database.query.emailVerificationTokens.findFirst({
|
|
3591
|
-
where:
|
|
3694
|
+
where: eq14(schema.emailVerificationTokens.tokenHash, tokenHash)
|
|
3592
3695
|
});
|
|
3593
3696
|
if (!verificationToken) {
|
|
3594
3697
|
return problems.badRequest(c, "Invalid or expired verification token");
|
|
3595
3698
|
}
|
|
3596
3699
|
if (verificationToken.expiresAt < Date.now()) {
|
|
3597
|
-
await database.delete(schema.emailVerificationTokens).where(
|
|
3700
|
+
await database.delete(schema.emailVerificationTokens).where(eq14(schema.emailVerificationTokens.tokenHash, tokenHash));
|
|
3598
3701
|
return problems.badRequest(c, "Verification token has expired");
|
|
3599
3702
|
}
|
|
3600
|
-
await database.update(schema.users).set({ emailVerified: true }).where(
|
|
3601
|
-
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));
|
|
3602
3705
|
logSecurityEvent("email_verified", "low", { userId: verificationToken.userId });
|
|
3603
3706
|
return c.json({ success: true, redirect: "/" });
|
|
3604
3707
|
};
|
|
@@ -3607,18 +3710,18 @@ var verifyEmailHandler = async (c) => {
|
|
|
3607
3710
|
import { createRoute as createRoute6 } from "@hono/zod-openapi";
|
|
3608
3711
|
|
|
3609
3712
|
// src/core/password-reset.ts
|
|
3610
|
-
import { eq as
|
|
3713
|
+
import { eq as eq15, and as and5, gt as gt4, lt as lt2, sql as sql4 } from "drizzle-orm";
|
|
3611
3714
|
var TOKEN_EXPIRATION_MS = AUTH_DEFAULTS.PASSWORD_RESET_TTL_MINUTES * 60 * 1e3;
|
|
3612
3715
|
async function createPasswordResetToken(db, tables2, email) {
|
|
3613
3716
|
const { users, passwordResetTokens } = tables2;
|
|
3614
|
-
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);
|
|
3615
3718
|
if (!user) {
|
|
3616
3719
|
return null;
|
|
3617
3720
|
}
|
|
3618
3721
|
if (!user.emailVerified) {
|
|
3619
3722
|
return null;
|
|
3620
3723
|
}
|
|
3621
|
-
await db.delete(passwordResetTokens).where(
|
|
3724
|
+
await db.delete(passwordResetTokens).where(eq15(passwordResetTokens.userId, user.id));
|
|
3622
3725
|
const token = generateSecureToken(64);
|
|
3623
3726
|
const tokenHash = await hashToken(token);
|
|
3624
3727
|
const expiresAt = Date.now() + TOKEN_EXPIRATION_MS;
|
|
@@ -3640,10 +3743,10 @@ async function validateResetToken(db, tables2, token) {
|
|
|
3640
3743
|
used: passwordResetTokens.used,
|
|
3641
3744
|
expiresAt: passwordResetTokens.expiresAt
|
|
3642
3745
|
}).from(passwordResetTokens).where(
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3746
|
+
and5(
|
|
3747
|
+
eq15(passwordResetTokens.tokenHash, tokenHash),
|
|
3748
|
+
eq15(passwordResetTokens.used, false),
|
|
3749
|
+
gt4(passwordResetTokens.expiresAt, Date.now())
|
|
3647
3750
|
)
|
|
3648
3751
|
).limit(1);
|
|
3649
3752
|
if (!resetToken) {
|
|
@@ -3664,11 +3767,11 @@ async function resetPassword(db, tables2, token, newPassword, pepper, pepperKid
|
|
|
3664
3767
|
await db.update(users).set({
|
|
3665
3768
|
hashedPassword,
|
|
3666
3769
|
pepperKid,
|
|
3667
|
-
sessionVersion:
|
|
3668
|
-
}).where(
|
|
3770
|
+
sessionVersion: sql4`${users.sessionVersion} + 1`
|
|
3771
|
+
}).where(eq15(users.id, userId));
|
|
3669
3772
|
const tokenHash = await hashToken(token);
|
|
3670
|
-
await db.update(passwordResetTokens).set({ used: true }).where(
|
|
3671
|
-
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));
|
|
3672
3775
|
logger_default.info("Password reset successful", { userId });
|
|
3673
3776
|
return { success: true };
|
|
3674
3777
|
}
|
|
@@ -3816,14 +3919,14 @@ var resetPasswordMiddleware = [
|
|
|
3816
3919
|
import { createRoute as createRoute8 } from "@hono/zod-openapi";
|
|
3817
3920
|
|
|
3818
3921
|
// src/core/change-password.ts
|
|
3819
|
-
import { eq as
|
|
3922
|
+
import { eq as eq16, and as and6, ne } from "drizzle-orm";
|
|
3820
3923
|
async function changePassword(params) {
|
|
3821
3924
|
const { userId, currentPassword, newPassword, currentSessionId, pepper, db, tables: tables2 } = params;
|
|
3822
3925
|
const { users, sessions } = tables2;
|
|
3823
3926
|
const [user] = await db.select({
|
|
3824
3927
|
id: users.id,
|
|
3825
3928
|
hashedPassword: users.hashedPassword
|
|
3826
|
-
}).from(users).where(
|
|
3929
|
+
}).from(users).where(eq16(users.id, userId)).limit(1);
|
|
3827
3930
|
if (!user || !user.hashedPassword) {
|
|
3828
3931
|
return { success: false, error: "User not found" };
|
|
3829
3932
|
}
|
|
@@ -3839,8 +3942,8 @@ async function changePassword(params) {
|
|
|
3839
3942
|
await db.update(users).set({
|
|
3840
3943
|
hashedPassword: newHashedPassword,
|
|
3841
3944
|
updatedAt: /* @__PURE__ */ new Date()
|
|
3842
|
-
}).where(
|
|
3843
|
-
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)));
|
|
3844
3947
|
logSecurityEvent("password_changed", "low", {
|
|
3845
3948
|
userId
|
|
3846
3949
|
});
|
|
@@ -4113,7 +4216,7 @@ var cancelEmailChangeHandler = async (c) => {
|
|
|
4113
4216
|
// src/routes/delete-account.ts
|
|
4114
4217
|
import { createRoute as createRoute13 } from "@hono/zod-openapi";
|
|
4115
4218
|
import { z as z5 } from "@hono/zod-openapi";
|
|
4116
|
-
import { eq as
|
|
4219
|
+
import { eq as eq17 } from "drizzle-orm";
|
|
4117
4220
|
var deleteAccountRequestSchema = z5.object({
|
|
4118
4221
|
password: z5.string().min(1).openapi({ example: "CurrentPass123!" })
|
|
4119
4222
|
}).openapi("DeleteAccountRequest");
|
|
@@ -4161,7 +4264,7 @@ var deleteAccountHandler = async (c) => {
|
|
|
4161
4264
|
const [user] = await database.select({
|
|
4162
4265
|
id: schema.users.id,
|
|
4163
4266
|
hashedPassword: schema.users.hashedPassword
|
|
4164
|
-
}).from(schema.users).where(
|
|
4267
|
+
}).from(schema.users).where(eq17(schema.users.id, userId)).limit(1);
|
|
4165
4268
|
if (!user || !user.hashedPassword) {
|
|
4166
4269
|
return problems.unauthorized(c, "Invalid credentials");
|
|
4167
4270
|
}
|
|
@@ -4176,8 +4279,8 @@ var deleteAccountHandler = async (c) => {
|
|
|
4176
4279
|
}
|
|
4177
4280
|
const now = /* @__PURE__ */ new Date();
|
|
4178
4281
|
const purgeAt = new Date(now.getTime() + PURGE_WINDOW_DAYS * 24 * 60 * 60 * 1e3);
|
|
4179
|
-
await database.update(schema.users).set({ deletedAt: now, scheduledPurgeAt: purgeAt, updatedAt: now }).where(
|
|
4180
|
-
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));
|
|
4181
4284
|
logSecurityEvent("account_deletion_requested", "high", { userId });
|
|
4182
4285
|
return c.json(
|
|
4183
4286
|
{
|
|
@@ -4198,7 +4301,7 @@ var deleteAccountMiddleware = [requireAuth];
|
|
|
4198
4301
|
// src/routes/refresh.ts
|
|
4199
4302
|
import { createRoute as createRoute14 } from "@hono/zod-openapi";
|
|
4200
4303
|
import { z as z6 } from "@hono/zod-openapi";
|
|
4201
|
-
import { eq as
|
|
4304
|
+
import { eq as eq18, and as and7, gt as gt5 } from "drizzle-orm";
|
|
4202
4305
|
var refreshRequestSchema = z6.object({
|
|
4203
4306
|
refreshToken: z6.string().min(1).openapi({ example: "abc123..." })
|
|
4204
4307
|
}).openapi("RefreshRequest");
|
|
@@ -4238,7 +4341,7 @@ var refreshHandler = async (c) => {
|
|
|
4238
4341
|
expiresAt: schema.sessions.expiresAt,
|
|
4239
4342
|
fingerprint: schema.sessions.fingerprint,
|
|
4240
4343
|
ipAddress: schema.sessions.ipAddress
|
|
4241
|
-
}).from(schema.sessions).where(
|
|
4344
|
+
}).from(schema.sessions).where(and7(eq18(schema.sessions.id, refreshToken), gt5(schema.sessions.expiresAt, Date.now()))).limit(1);
|
|
4242
4345
|
if (!session) {
|
|
4243
4346
|
return problems.unauthorized(c, "Invalid or expired refresh token");
|
|
4244
4347
|
}
|
|
@@ -4261,7 +4364,7 @@ var refreshMiddleware = [];
|
|
|
4261
4364
|
// src/routes/resend-verification.ts
|
|
4262
4365
|
import { createRoute as createRoute15 } from "@hono/zod-openapi";
|
|
4263
4366
|
import { z as z7 } from "@hono/zod-openapi";
|
|
4264
|
-
import { eq as
|
|
4367
|
+
import { eq as eq19 } from "drizzle-orm";
|
|
4265
4368
|
var resendVerificationResponseSchema = z7.object({
|
|
4266
4369
|
success: z7.boolean().openapi({ example: true }),
|
|
4267
4370
|
message: z7.string().openapi({ example: "Verification email sent" })
|
|
@@ -4306,14 +4409,14 @@ var resendVerificationHandler = async (c) => {
|
|
|
4306
4409
|
email: schema.users.email,
|
|
4307
4410
|
name: schema.users.name,
|
|
4308
4411
|
emailVerified: schema.users.emailVerified
|
|
4309
|
-
}).from(schema.users).where(
|
|
4412
|
+
}).from(schema.users).where(eq19(schema.users.id, userId)).limit(1);
|
|
4310
4413
|
if (!user) {
|
|
4311
4414
|
return problems.unauthorized(c, "User not found");
|
|
4312
4415
|
}
|
|
4313
4416
|
if (user.emailVerified) {
|
|
4314
4417
|
return problems.badRequest(c, "Email is already verified");
|
|
4315
4418
|
}
|
|
4316
|
-
await db.delete(schema.emailVerificationTokens).where(
|
|
4419
|
+
await db.delete(schema.emailVerificationTokens).where(eq19(schema.emailVerificationTokens.userId, userId));
|
|
4317
4420
|
const emailToken = generateSecureToken(32);
|
|
4318
4421
|
const tokenHash = await hashToken(emailToken);
|
|
4319
4422
|
const expiresAt = Date.now() + 24 * 60 * 60 * 1e3;
|
|
@@ -4358,7 +4461,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
|
|
|
4358
4461
|
|
|
4359
4462
|
// src/routes/2fa/status.ts
|
|
4360
4463
|
import { createRoute as createRoute16 } from "@hono/zod-openapi";
|
|
4361
|
-
import { eq as
|
|
4464
|
+
import { eq as eq20, and as and8, isNull as isNull3 } from "drizzle-orm";
|
|
4362
4465
|
|
|
4363
4466
|
// src/routes/2fa/schemas.ts
|
|
4364
4467
|
import { z as z8 } from "@hono/zod-openapi";
|
|
@@ -4519,7 +4622,7 @@ var statusHandler = async (c) => {
|
|
|
4519
4622
|
if (!userId) return problems.unauthorized(c);
|
|
4520
4623
|
const { db, schema } = getAuthContext(c);
|
|
4521
4624
|
const methods = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4522
|
-
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)));
|
|
4523
4626
|
c.header("Cache-Control", "private, max-age=60");
|
|
4524
4627
|
c.header("Vary", "Cookie");
|
|
4525
4628
|
return c.json({
|
|
@@ -4531,7 +4634,7 @@ var statusHandler = async (c) => {
|
|
|
4531
4634
|
|
|
4532
4635
|
// src/routes/2fa/totp-setup.ts
|
|
4533
4636
|
import { createRoute as createRoute17 } from "@hono/zod-openapi";
|
|
4534
|
-
import { eq as
|
|
4637
|
+
import { eq as eq21, and as and9 } from "drizzle-orm";
|
|
4535
4638
|
var totpSetupRoute = createRoute17({
|
|
4536
4639
|
method: "post",
|
|
4537
4640
|
path: "/totp/setup",
|
|
@@ -4561,11 +4664,11 @@ var totpSetupHandler = async (c) => {
|
|
|
4561
4664
|
const userId = c.get("userId");
|
|
4562
4665
|
if (!userId) return problems.unauthorized(c);
|
|
4563
4666
|
const { db, schema, env } = getAuthContext(c);
|
|
4564
|
-
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);
|
|
4565
4668
|
if (existing.length > 0) {
|
|
4566
4669
|
return problems.badRequest(c, "TOTP already enabled");
|
|
4567
4670
|
}
|
|
4568
|
-
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);
|
|
4569
4672
|
if (!user) return problems.notFound(c, "User not found");
|
|
4570
4673
|
const secret = generateTotpSecret();
|
|
4571
4674
|
const qrCodeUri = generateQrCodeUri(secret, user.email);
|
|
@@ -4576,7 +4679,7 @@ var totpSetupHandler = async (c) => {
|
|
|
4576
4679
|
|
|
4577
4680
|
// src/routes/2fa/totp-verify.ts
|
|
4578
4681
|
import { createRoute as createRoute18 } from "@hono/zod-openapi";
|
|
4579
|
-
import { eq as
|
|
4682
|
+
import { eq as eq22 } from "drizzle-orm";
|
|
4580
4683
|
var totpVerifyRoute = createRoute18({
|
|
4581
4684
|
method: "post",
|
|
4582
4685
|
path: "/totp/verify",
|
|
@@ -4622,7 +4725,7 @@ var totpVerifyHandler = async (c) => {
|
|
|
4622
4725
|
logSecurityEvent("2fa_totp_setup_failed", "medium", { userId });
|
|
4623
4726
|
return problems.badRequest(c, "Invalid code");
|
|
4624
4727
|
}
|
|
4625
|
-
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);
|
|
4626
4729
|
const encryptionKey = env.TOTP_ENCRYPTION_KEY;
|
|
4627
4730
|
if (!encryptionKey) {
|
|
4628
4731
|
logger_default.error("TOTP_ENCRYPTION_KEY not configured");
|
|
@@ -4670,7 +4773,7 @@ var totpVerifyHandler = async (c) => {
|
|
|
4670
4773
|
|
|
4671
4774
|
// src/routes/2fa/totp-disable.ts
|
|
4672
4775
|
import { createRoute as createRoute19 } from "@hono/zod-openapi";
|
|
4673
|
-
import { eq as
|
|
4776
|
+
import { eq as eq23, and as and10 } from "drizzle-orm";
|
|
4674
4777
|
var totpDisableRoute = createRoute19({
|
|
4675
4778
|
method: "post",
|
|
4676
4779
|
path: "/totp/disable",
|
|
@@ -4713,13 +4816,13 @@ var totpDisableHandler = async (c) => {
|
|
|
4713
4816
|
logSecurityEvent("2fa_disable_failed", "medium", { userId, method: "totp" });
|
|
4714
4817
|
return problems.badRequest(c, "Invalid verification code");
|
|
4715
4818
|
}
|
|
4716
|
-
await db.delete(schema.user2faMethods).where(
|
|
4819
|
+
await db.delete(schema.user2faMethods).where(and10(eq23(schema.user2faMethods.userId, userId), eq23(schema.user2faMethods.method, "totp")));
|
|
4717
4820
|
const remaining = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4718
4821
|
if (remaining.length === 0) {
|
|
4719
|
-
await db.delete(schema.userBackupCodes).where(
|
|
4720
|
-
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));
|
|
4721
4824
|
await invalidateAllUserSessions(db, { sessions: schema.sessions, users: schema.users }, userId);
|
|
4722
|
-
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);
|
|
4723
4826
|
if (user) {
|
|
4724
4827
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
4725
4828
|
const firstName = user.name?.split(" ")[0] || null;
|
|
@@ -4738,7 +4841,7 @@ var totpDisableHandler = async (c) => {
|
|
|
4738
4841
|
|
|
4739
4842
|
// src/routes/2fa/email-setup.ts
|
|
4740
4843
|
import { createRoute as createRoute20 } from "@hono/zod-openapi";
|
|
4741
|
-
import { eq as
|
|
4844
|
+
import { eq as eq24, and as and11 } from "drizzle-orm";
|
|
4742
4845
|
var emailSetupRoute = createRoute20({
|
|
4743
4846
|
method: "post",
|
|
4744
4847
|
path: "/email/setup",
|
|
@@ -4768,15 +4871,15 @@ var emailSetupHandler = async (c) => {
|
|
|
4768
4871
|
const userId = c.get("userId");
|
|
4769
4872
|
if (!userId) return problems.unauthorized(c);
|
|
4770
4873
|
const { db, schema, env, emailBranding } = getAuthContext(c);
|
|
4771
|
-
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);
|
|
4772
4875
|
if (existing.length > 0) {
|
|
4773
4876
|
return problems.badRequest(c, "Email 2FA already enabled");
|
|
4774
4877
|
}
|
|
4775
|
-
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);
|
|
4776
4879
|
if (!user) return problems.notFound(c, "User not found");
|
|
4777
4880
|
const code = generateEmailOtp();
|
|
4778
|
-
const
|
|
4779
|
-
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 });
|
|
4780
4883
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
4781
4884
|
const firstName = user.name?.split(" ")[0] || null;
|
|
4782
4885
|
await emailService.send2faCodeEmail({ email: user.email, firstName, code }, db, schema.users);
|
|
@@ -4786,7 +4889,7 @@ var emailSetupHandler = async (c) => {
|
|
|
4786
4889
|
|
|
4787
4890
|
// src/routes/2fa/email-verify.ts
|
|
4788
4891
|
import { createRoute as createRoute21 } from "@hono/zod-openapi";
|
|
4789
|
-
import { eq as
|
|
4892
|
+
import { eq as eq25 } from "drizzle-orm";
|
|
4790
4893
|
var emailVerifyRoute = createRoute21({
|
|
4791
4894
|
method: "post",
|
|
4792
4895
|
path: "/email/verify",
|
|
@@ -4823,12 +4926,12 @@ var emailVerifyHandler = async (c) => {
|
|
|
4823
4926
|
if (!storedHash) {
|
|
4824
4927
|
return problems.badRequest(c, "Code expired. Please request a new one.");
|
|
4825
4928
|
}
|
|
4826
|
-
const
|
|
4827
|
-
if (
|
|
4929
|
+
const codeHash2 = await hashToken(code);
|
|
4930
|
+
if (codeHash2 !== storedHash) {
|
|
4828
4931
|
logSecurityEvent("2fa_email_setup_failed", "medium", { userId });
|
|
4829
4932
|
return problems.badRequest(c, "Invalid code");
|
|
4830
4933
|
}
|
|
4831
|
-
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);
|
|
4832
4935
|
const now = Date.now();
|
|
4833
4936
|
const existingMethods = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4834
4937
|
const isFirstMethod = existingMethods.length === 0;
|
|
@@ -4870,7 +4973,7 @@ var emailVerifyHandler = async (c) => {
|
|
|
4870
4973
|
|
|
4871
4974
|
// src/routes/2fa/email-send-code.ts
|
|
4872
4975
|
import { createRoute as createRoute22 } from "@hono/zod-openapi";
|
|
4873
|
-
import { eq as
|
|
4976
|
+
import { eq as eq26, and as and12 } from "drizzle-orm";
|
|
4874
4977
|
var emailSendCodeRoute = createRoute22({
|
|
4875
4978
|
method: "post",
|
|
4876
4979
|
path: "/email/send-code",
|
|
@@ -4900,15 +5003,15 @@ var emailSendCodeHandler = async (c) => {
|
|
|
4900
5003
|
const userId = c.get("userId");
|
|
4901
5004
|
if (!userId) return problems.unauthorized(c);
|
|
4902
5005
|
const { db, schema, env, emailBranding } = getAuthContext(c);
|
|
4903
|
-
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);
|
|
4904
5007
|
if (!emailMethod) {
|
|
4905
5008
|
return problems.badRequest(c, "Email 2FA not enabled");
|
|
4906
5009
|
}
|
|
4907
|
-
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);
|
|
4908
5011
|
if (!user) return problems.notFound(c, "User not found");
|
|
4909
5012
|
const code = generateEmailOtp();
|
|
4910
|
-
const
|
|
4911
|
-
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, {
|
|
4912
5015
|
expirationTtl: 300
|
|
4913
5016
|
});
|
|
4914
5017
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
@@ -4930,7 +5033,7 @@ var emailSendCodeHandler = async (c) => {
|
|
|
4930
5033
|
|
|
4931
5034
|
// src/routes/2fa/email-disable.ts
|
|
4932
5035
|
import { createRoute as createRoute23 } from "@hono/zod-openapi";
|
|
4933
|
-
import { eq as
|
|
5036
|
+
import { eq as eq27, and as and13 } from "drizzle-orm";
|
|
4934
5037
|
var emailDisableRoute = createRoute23({
|
|
4935
5038
|
method: "post",
|
|
4936
5039
|
path: "/email/disable",
|
|
@@ -4973,13 +5076,13 @@ var emailDisableHandler = async (c) => {
|
|
|
4973
5076
|
logSecurityEvent("2fa_disable_failed", "medium", { userId, method: "email" });
|
|
4974
5077
|
return problems.badRequest(c, "Invalid verification code");
|
|
4975
5078
|
}
|
|
4976
|
-
await db.delete(schema.user2faMethods).where(
|
|
5079
|
+
await db.delete(schema.user2faMethods).where(and13(eq27(schema.user2faMethods.userId, userId), eq27(schema.user2faMethods.method, "email")));
|
|
4977
5080
|
const remaining = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4978
5081
|
if (remaining.length === 0) {
|
|
4979
|
-
await db.delete(schema.userBackupCodes).where(
|
|
4980
|
-
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));
|
|
4981
5084
|
await invalidateAllUserSessions(db, { sessions: schema.sessions, users: schema.users }, userId);
|
|
4982
|
-
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);
|
|
4983
5086
|
if (user) {
|
|
4984
5087
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
4985
5088
|
const firstName = user.name?.split(" ")[0] || null;
|
|
@@ -4998,7 +5101,7 @@ var emailDisableHandler = async (c) => {
|
|
|
4998
5101
|
|
|
4999
5102
|
// src/routes/2fa/trusted-devices.ts
|
|
5000
5103
|
import { createRoute as createRoute24, z as z9 } from "@hono/zod-openapi";
|
|
5001
|
-
import { eq as
|
|
5104
|
+
import { eq as eq28, and as and14, gt as gt6 } from "drizzle-orm";
|
|
5002
5105
|
var trustedDevicesGetRoute = createRoute24({
|
|
5003
5106
|
method: "get",
|
|
5004
5107
|
path: "/trusted-devices",
|
|
@@ -5037,7 +5140,7 @@ var trustedDevicesGetHandler = async (c) => {
|
|
|
5037
5140
|
ipAddress: schema.userTrustedDevices.ipAddress,
|
|
5038
5141
|
lastUsedAt: schema.userTrustedDevices.lastUsedAt,
|
|
5039
5142
|
createdAt: schema.userTrustedDevices.createdAt
|
|
5040
|
-
}).from(schema.userTrustedDevices).where(
|
|
5143
|
+
}).from(schema.userTrustedDevices).where(and14(eq28(schema.userTrustedDevices.userId, userId), gt6(schema.userTrustedDevices.expiresAt, now)));
|
|
5041
5144
|
c.header("Cache-Control", "private, max-age=60");
|
|
5042
5145
|
c.header("Vary", "Cookie");
|
|
5043
5146
|
return c.json({ devices });
|
|
@@ -5073,7 +5176,7 @@ var trustedDevicesDeleteHandler = async (c) => {
|
|
|
5073
5176
|
if (!userId) return problems.unauthorized(c);
|
|
5074
5177
|
const deviceId = c.req.param("id");
|
|
5075
5178
|
const { db, schema } = getAuthContext(c);
|
|
5076
|
-
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 });
|
|
5077
5180
|
if (result.length === 0) {
|
|
5078
5181
|
return problems.notFound(c, "Device not found");
|
|
5079
5182
|
}
|
|
@@ -5083,7 +5186,7 @@ var trustedDevicesDeleteHandler = async (c) => {
|
|
|
5083
5186
|
|
|
5084
5187
|
// src/routes/2fa/backup-codes.ts
|
|
5085
5188
|
import { createRoute as createRoute25 } from "@hono/zod-openapi";
|
|
5086
|
-
import { eq as
|
|
5189
|
+
import { eq as eq29 } from "drizzle-orm";
|
|
5087
5190
|
var backupCodesRegenerateRoute = createRoute25({
|
|
5088
5191
|
method: "post",
|
|
5089
5192
|
path: "/backup-codes/regenerate",
|
|
@@ -5124,7 +5227,7 @@ var backupCodesRegenerateHandler = async (c) => {
|
|
|
5124
5227
|
logSecurityEvent("2fa_backup_regenerate_failed", "medium", { userId });
|
|
5125
5228
|
return problems.badRequest(c, "Invalid verification code");
|
|
5126
5229
|
}
|
|
5127
|
-
await db.delete(schema.userBackupCodes).where(
|
|
5230
|
+
await db.delete(schema.userBackupCodes).where(eq29(schema.userBackupCodes.userId, userId));
|
|
5128
5231
|
const codes = generateBackupCodes();
|
|
5129
5232
|
const now = Date.now();
|
|
5130
5233
|
const hashedCodes = await Promise.all(
|
|
@@ -5141,7 +5244,7 @@ var backupCodesRegenerateHandler = async (c) => {
|
|
|
5141
5244
|
|
|
5142
5245
|
// src/routes/2fa/challenge.ts
|
|
5143
5246
|
import { createRoute as createRoute26 } from "@hono/zod-openapi";
|
|
5144
|
-
import { eq as
|
|
5247
|
+
import { eq as eq30 } from "drizzle-orm";
|
|
5145
5248
|
import { getCookie as getCookie2 } from "hono/cookie";
|
|
5146
5249
|
var challengeRoute = createRoute26({
|
|
5147
5250
|
method: "post",
|
|
@@ -5213,7 +5316,7 @@ var challengeHandler = async (c) => {
|
|
|
5213
5316
|
}
|
|
5214
5317
|
await deleteChallengeToken(c.env.OAUTH_STATES, challengeToken);
|
|
5215
5318
|
clearChallengeCookie(c, c.env);
|
|
5216
|
-
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);
|
|
5217
5320
|
if (!user) {
|
|
5218
5321
|
logger_default.error("User not found during 2FA challenge", { userId: payload.userId });
|
|
5219
5322
|
return problems.notFound(c, "User not found");
|
|
@@ -5254,7 +5357,7 @@ var challengeHandler = async (c) => {
|
|
|
5254
5357
|
|
|
5255
5358
|
// src/routes/2fa/challenge-resend.ts
|
|
5256
5359
|
import { createRoute as createRoute27 } from "@hono/zod-openapi";
|
|
5257
|
-
import { eq as
|
|
5360
|
+
import { eq as eq31 } from "drizzle-orm";
|
|
5258
5361
|
import { getCookie as getCookie3 } from "hono/cookie";
|
|
5259
5362
|
var challengeResendRoute = createRoute27({
|
|
5260
5363
|
method: "post",
|
|
@@ -5292,7 +5395,7 @@ var challengeResendHandler = async (c) => {
|
|
|
5292
5395
|
if (!payload.methods.includes("email")) {
|
|
5293
5396
|
return problems.badRequest(c, "Email verification not available");
|
|
5294
5397
|
}
|
|
5295
|
-
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);
|
|
5296
5399
|
if (!user) {
|
|
5297
5400
|
logger_default.error("User not found during 2FA resend", { userId: payload.userId });
|
|
5298
5401
|
return problems.notFound(c, "User not found");
|
|
@@ -5523,7 +5626,7 @@ var authorizeHandler = async (c) => {
|
|
|
5523
5626
|
|
|
5524
5627
|
// src/routes/oauth/callback.ts
|
|
5525
5628
|
import { createRoute as createRoute29 } from "@hono/zod-openapi";
|
|
5526
|
-
import { eq as
|
|
5629
|
+
import { eq as eq32 } from "drizzle-orm";
|
|
5527
5630
|
var callbackRoute = createRoute29({
|
|
5528
5631
|
method: "get",
|
|
5529
5632
|
path: "/{provider}/callback",
|
|
@@ -5649,10 +5752,24 @@ var callbackHandler = async (c) => {
|
|
|
5649
5752
|
logger_default.warn("No user ID returned from OAuth provider", { provider });
|
|
5650
5753
|
return c.redirect(`${appUrl}/login?error=oauth_failed`);
|
|
5651
5754
|
}
|
|
5652
|
-
const { db, schema } = getAuthContext(c);
|
|
5653
|
-
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);
|
|
5654
5757
|
let authUser = existingUser;
|
|
5655
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
|
+
}
|
|
5656
5773
|
const [createdUser] = await db.insert(schema.users).values({
|
|
5657
5774
|
email,
|
|
5658
5775
|
name: name || null,
|
|
@@ -5663,6 +5780,15 @@ var callbackHandler = async (c) => {
|
|
|
5663
5780
|
activatedAt: /* @__PURE__ */ new Date()
|
|
5664
5781
|
}).returning({ id: schema.users.id, email: schema.users.email });
|
|
5665
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
|
+
}
|
|
5666
5792
|
logSecurityEvent("user_signup_oauth", "low", {
|
|
5667
5793
|
userId: createdUser.id,
|
|
5668
5794
|
email: createdUser.email,
|
|
@@ -5702,7 +5828,7 @@ var callbackHandler = async (c) => {
|
|
|
5702
5828
|
|
|
5703
5829
|
// src/routes/oauth/complete.ts
|
|
5704
5830
|
import { createRoute as createRoute30 } from "@hono/zod-openapi";
|
|
5705
|
-
import { eq as
|
|
5831
|
+
import { eq as eq33 } from "drizzle-orm";
|
|
5706
5832
|
var completeRoute = createRoute30({
|
|
5707
5833
|
method: "post",
|
|
5708
5834
|
path: "/complete",
|
|
@@ -5750,7 +5876,7 @@ var completeHandler = async (c) => {
|
|
|
5750
5876
|
redirect: storedRedirect
|
|
5751
5877
|
} = JSON.parse(stored);
|
|
5752
5878
|
const { db, schema } = getAuthContext(c);
|
|
5753
|
-
await db.update(schema.users).set({ emailVerified: true }).where(
|
|
5879
|
+
await db.update(schema.users).set({ emailVerified: true }).where(eq33(schema.users.id, userId));
|
|
5754
5880
|
await db.insert(schema.oauthAccounts).values({ userId, provider, providerUserId, email }).onConflictDoNothing();
|
|
5755
5881
|
const fingerprint = await generateFingerprint(c.req.raw);
|
|
5756
5882
|
const ipAddress = getClientIp(c.req.raw);
|
|
@@ -5795,19 +5921,12 @@ var routes_default = authRoutes;
|
|
|
5795
5921
|
|
|
5796
5922
|
// src/lib/email/webhook-verifier.ts
|
|
5797
5923
|
import { Webhook } from "svix";
|
|
5798
|
-
function verifyWebhookSignature(payload,
|
|
5924
|
+
function verifyWebhookSignature(payload, headers, secret) {
|
|
5799
5925
|
if (!secret || secret.trim() === "") {
|
|
5800
5926
|
throw new Error("Webhook secret is required");
|
|
5801
5927
|
}
|
|
5802
5928
|
try {
|
|
5803
|
-
|
|
5804
|
-
wh.verify(payload, {
|
|
5805
|
-
"svix-id": "",
|
|
5806
|
-
// Not used by Resend
|
|
5807
|
-
"svix-timestamp": "",
|
|
5808
|
-
// Not used by Resend
|
|
5809
|
-
"svix-signature": signature
|
|
5810
|
-
});
|
|
5929
|
+
new Webhook(secret).verify(payload, { ...headers });
|
|
5811
5930
|
return true;
|
|
5812
5931
|
} catch (error) {
|
|
5813
5932
|
logger_default.warn("Webhook signature verification failed", {
|
|
@@ -5822,6 +5941,8 @@ export {
|
|
|
5822
5941
|
CloudflareEmailAdapter,
|
|
5823
5942
|
EmailService,
|
|
5824
5943
|
MAX_CHALLENGE_ATTEMPTS,
|
|
5944
|
+
PASSWORDLESS_MAX_ATTEMPTS,
|
|
5945
|
+
PASSWORDLESS_TTL_MS,
|
|
5825
5946
|
ProblemTypes,
|
|
5826
5947
|
ResendAdapter,
|
|
5827
5948
|
SCHEMA_VERSION,
|
|
@@ -5833,6 +5954,7 @@ export {
|
|
|
5833
5954
|
cancelEmailChange,
|
|
5834
5955
|
checkAccountLocked,
|
|
5835
5956
|
checkBreachedPassword,
|
|
5957
|
+
cleanupExpiredPasswordlessChallenges,
|
|
5836
5958
|
clearAccountLockout,
|
|
5837
5959
|
clearChallengeCookie,
|
|
5838
5960
|
clearSessionCookie,
|
|
@@ -5843,6 +5965,7 @@ export {
|
|
|
5843
5965
|
createChallengeToken,
|
|
5844
5966
|
createDeviceToken,
|
|
5845
5967
|
createEmailAdapter,
|
|
5968
|
+
createPasswordlessChallenge,
|
|
5846
5969
|
createProblemDetails,
|
|
5847
5970
|
createSession,
|
|
5848
5971
|
csrf,
|
|
@@ -5859,6 +5982,7 @@ export {
|
|
|
5859
5982
|
generate2faEnabledEmail,
|
|
5860
5983
|
generateApiKey,
|
|
5861
5984
|
generateBackupCodes,
|
|
5985
|
+
generateCode,
|
|
5862
5986
|
generateCodeChallenge,
|
|
5863
5987
|
generateCodeVerifier,
|
|
5864
5988
|
generateEmailChangeConfirmation,
|
|
@@ -5897,14 +6021,17 @@ export {
|
|
|
5897
6021
|
optionalAuth,
|
|
5898
6022
|
parseDeviceName,
|
|
5899
6023
|
parseDeviceType,
|
|
6024
|
+
passwordlessEmail,
|
|
5900
6025
|
problemJson,
|
|
5901
6026
|
problems,
|
|
5902
6027
|
rateLimit,
|
|
6028
|
+
recentPasswordlessFailures,
|
|
5903
6029
|
refreshSession,
|
|
5904
6030
|
requestEmailChange,
|
|
5905
6031
|
requireAuth,
|
|
5906
6032
|
requireVerifiedEmail,
|
|
5907
6033
|
retrieveChallengeToken,
|
|
6034
|
+
sessionRefreshDue,
|
|
5908
6035
|
setChallengeCookie,
|
|
5909
6036
|
setSessionCookie,
|
|
5910
6037
|
setTrustedDeviceCookie,
|
|
@@ -5922,6 +6049,8 @@ export {
|
|
|
5922
6049
|
verifyCodeChallenge,
|
|
5923
6050
|
verifyPassword,
|
|
5924
6051
|
verifyPasswordWithRotation,
|
|
6052
|
+
verifyPasswordlessCode,
|
|
6053
|
+
verifyPasswordlessLink,
|
|
5925
6054
|
verifyTotpCode,
|
|
5926
6055
|
verifyTurnstileToken,
|
|
5927
6056
|
verifyWebhookSignature
|