@syncello/auth 3.6.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -0
- package/dist/{chunk-OZ26T3ZA.js → chunk-W35EDOHW.js} +21 -3
- package/dist/chunk-W35EDOHW.js.map +1 -0
- package/dist/cli/index.js +27 -18
- package/dist/index.cjs +1055 -885
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +185 -41
- package/dist/index.d.ts +185 -41
- package/dist/index.js +1053 -911
- package/dist/index.js.map +1 -1
- package/dist/schema/index.cjs +20 -2
- package/dist/schema/index.cjs.map +1 -1
- package/dist/schema/index.d.cts +62 -2
- package/dist/schema/index.d.ts +62 -2
- package/dist/schema/index.js +1 -1
- package/package.json +2 -1
- package/dist/chunk-OZ26T3ZA.js.map +0 -1
package/dist/index.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,9 +255,14 @@ 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,
|
|
263
|
+
EMAIL_VERIFICATION_TTL_HOURS: 24,
|
|
264
|
+
EMAIL_CHANGE_TTL_HOURS: 24,
|
|
265
|
+
TWO_FACTOR_EMAIL_CODE_TTL_MINUTES: 5,
|
|
261
266
|
TWO_FACTOR_CHALLENGE_TTL_SECONDS: 300,
|
|
262
267
|
// 5 minutes
|
|
263
268
|
APP_NAME: "App"
|
|
@@ -335,7 +340,7 @@ async function validateSession(db, tables2, sessionId, currentFingerprint, sessi
|
|
|
335
340
|
note: "SSR requests have different User-Agent, causing expected mismatch"
|
|
336
341
|
});
|
|
337
342
|
}
|
|
338
|
-
await refreshSession(db, { sessions }, sessionId);
|
|
343
|
+
if (sessionRefreshDue(row.sessionLastActiveAt)) await refreshSession(db, { sessions }, sessionId);
|
|
339
344
|
return {
|
|
340
345
|
user: {
|
|
341
346
|
id: row.userId,
|
|
@@ -354,6 +359,10 @@ async function validateSession(db, tables2, sessionId, currentFingerprint, sessi
|
|
|
354
359
|
}
|
|
355
360
|
};
|
|
356
361
|
}
|
|
362
|
+
function sessionRefreshDue(lastActiveAt, now = Date.now()) {
|
|
363
|
+
const interval = AUTH_DEFAULTS.SESSION_REFRESH_INTERVAL_MINUTES * 60 * 1e3;
|
|
364
|
+
return lastActiveAt === null || now - lastActiveAt >= interval;
|
|
365
|
+
}
|
|
357
366
|
async function refreshSession(db, tables2, sessionId, sessionTtlMs = AUTH_DEFAULTS.SESSION_TTL_DAYS * 24 * 60 * 60 * 1e3) {
|
|
358
367
|
const { sessions } = tables2;
|
|
359
368
|
const newExpiration = Date.now() + sessionTtlMs;
|
|
@@ -521,16 +530,16 @@ function createDeviceToken() {
|
|
|
521
530
|
};
|
|
522
531
|
}
|
|
523
532
|
async function validateTrustedDevice(db, userTrustedDevicesTable, userId, tokenHash) {
|
|
524
|
-
const { eq:
|
|
533
|
+
const { eq: eq34, and: and15, gt: gt7 } = await import("drizzle-orm");
|
|
525
534
|
const [device] = await db.select().from(userTrustedDevicesTable).where(
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
535
|
+
and15(
|
|
536
|
+
eq34(userTrustedDevicesTable.userId, userId),
|
|
537
|
+
eq34(userTrustedDevicesTable.tokenHash, tokenHash),
|
|
538
|
+
gt7(userTrustedDevicesTable.expiresAt, Date.now())
|
|
530
539
|
)
|
|
531
540
|
).limit(1);
|
|
532
541
|
if (device) {
|
|
533
|
-
await db.update(userTrustedDevicesTable).set({ lastUsedAt: Date.now() }).where(
|
|
542
|
+
await db.update(userTrustedDevicesTable).set({ lastUsedAt: Date.now() }).where(eq34(userTrustedDevicesTable.id, device.id));
|
|
534
543
|
return true;
|
|
535
544
|
}
|
|
536
545
|
return false;
|
|
@@ -743,7 +752,7 @@ async function verifyTurnstileToken(token, secretKey, remoteIp, environment) {
|
|
|
743
752
|
|
|
744
753
|
// src/core/email-change.ts
|
|
745
754
|
import { eq as eq3 } from "drizzle-orm";
|
|
746
|
-
var TOKEN_EXPIRY_MS =
|
|
755
|
+
var TOKEN_EXPIRY_MS = AUTH_DEFAULTS.EMAIL_CHANGE_TTL_HOURS * 60 * 60 * 1e3;
|
|
747
756
|
async function requestEmailChange(params) {
|
|
748
757
|
const { userId, password, newEmail, pepper, db, tables: tables2 } = params;
|
|
749
758
|
const { users, emailChangeTokens } = tables2;
|
|
@@ -943,582 +952,48 @@ function base64UrlEncode(data) {
|
|
|
943
952
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
944
953
|
}
|
|
945
954
|
|
|
946
|
-
// src/
|
|
947
|
-
import {
|
|
948
|
-
import { eq as eq4, and as and2, gt as gt2 } from "drizzle-orm";
|
|
955
|
+
// src/core/passwordless.ts
|
|
956
|
+
import { and as and2, desc, eq as eq4, gt as gt2, isNull, lt, sql as sql2 } from "drizzle-orm";
|
|
949
957
|
|
|
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
|
-
);
|
|
958
|
+
// src/lib/email/security.ts
|
|
959
|
+
function getAllowedDomains(appUrl) {
|
|
960
|
+
const base = ["localhost", "127.0.0.1"];
|
|
961
|
+
try {
|
|
962
|
+
const url = new URL(appUrl);
|
|
963
|
+
return [...base, url.hostname];
|
|
964
|
+
} catch {
|
|
965
|
+
return base;
|
|
980
966
|
}
|
|
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
967
|
}
|
|
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;
|
|
968
|
+
function sanitizeEmailUrl(url, appUrl = "") {
|
|
969
|
+
let parsed;
|
|
970
|
+
try {
|
|
971
|
+
parsed = new URL(url);
|
|
972
|
+
} catch {
|
|
973
|
+
throw new Error("Invalid URL format");
|
|
1014
974
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
problem.requestId = options.requestId;
|
|
975
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
976
|
+
throw new Error(`Disallowed protocol: ${parsed.protocol}`);
|
|
1018
977
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
978
|
+
const allowedDomains = getAllowedDomains(appUrl);
|
|
979
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
980
|
+
const isAllowed = allowedDomains.some((domain) => {
|
|
981
|
+
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
982
|
+
});
|
|
983
|
+
if (!isAllowed) {
|
|
984
|
+
throw new Error(`Disallowed domain: ${hostname}`);
|
|
1021
985
|
}
|
|
1022
|
-
return
|
|
986
|
+
return parsed.toString();
|
|
1023
987
|
}
|
|
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]);
|
|
988
|
+
function escapeHtml(text) {
|
|
989
|
+
const htmlEscapeMap = {
|
|
990
|
+
"&": "&",
|
|
991
|
+
"<": "<",
|
|
992
|
+
">": ">",
|
|
993
|
+
'"': """,
|
|
994
|
+
"'": "'"
|
|
995
|
+
};
|
|
996
|
+
return text.replace(/[&<>"']/g, (char) => htmlEscapeMap[char]);
|
|
1522
997
|
}
|
|
1523
998
|
|
|
1524
999
|
// src/core/email-template.ts
|
|
@@ -1532,19 +1007,20 @@ function generateEmailTemplate(options) {
|
|
|
1532
1007
|
securityWarning,
|
|
1533
1008
|
footerNote,
|
|
1534
1009
|
appUrl,
|
|
1535
|
-
marketingUrl
|
|
1536
|
-
// Default to appUrl if no marketing site
|
|
1010
|
+
marketingUrl,
|
|
1537
1011
|
appName = "App",
|
|
1538
1012
|
buttonBgColor = "#00fe9a",
|
|
1539
1013
|
buttonTextColor = "#000004",
|
|
1540
1014
|
buttonFontWeight = 500,
|
|
1541
1015
|
buttonRadius = "6px",
|
|
1542
1016
|
logoUrl,
|
|
1543
|
-
footerColor = "#1e1b4b"
|
|
1017
|
+
footerColor = "#1e1b4b",
|
|
1018
|
+
headingColor = "#1e1b4b"
|
|
1544
1019
|
} = options;
|
|
1020
|
+
const safeAppName = escapeHtml(appName);
|
|
1545
1021
|
const safeButtonUrl = buttonUrl ? sanitizeEmailUrl(buttonUrl, appUrl) : "";
|
|
1546
1022
|
const safeAppUrl = appUrl ? sanitizeEmailUrl(appUrl, appUrl) : "";
|
|
1547
|
-
const safeMarketingUrl = marketingUrl ? sanitizeEmailUrl(marketingUrl,
|
|
1023
|
+
const safeMarketingUrl = marketingUrl ? sanitizeEmailUrl(marketingUrl, marketingUrl).replace(/\/$/, "") : "";
|
|
1548
1024
|
const safeLogoUrl = logoUrl ? sanitizeEmailUrl(logoUrl, appUrl) : `${safeAppUrl.replace(/\/$/, "")}/email-header-image.png`;
|
|
1549
1025
|
return `
|
|
1550
1026
|
<!DOCTYPE html>
|
|
@@ -1685,7 +1161,7 @@ function generateEmailTemplate(options) {
|
|
|
1685
1161
|
<td class="pad" style="padding-left:40px;padding-right:40px;width:100%;">
|
|
1686
1162
|
<div class="alignment" align="center">
|
|
1687
1163
|
<div class="fullWidth" style="max-width: 50%;">
|
|
1688
|
-
<img src="${safeLogoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${
|
|
1164
|
+
<img src="${safeLogoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${safeAppName}" title="${safeAppName}" height="auto">
|
|
1689
1165
|
</div>
|
|
1690
1166
|
</div>
|
|
1691
1167
|
</td>
|
|
@@ -1697,7 +1173,7 @@ function generateEmailTemplate(options) {
|
|
|
1697
1173
|
<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
1174
|
<tr>
|
|
1699
1175
|
<td class="pad" style="padding-top:30px;padding-left:40px;padding-right:40px;">
|
|
1700
|
-
<div style="color
|
|
1176
|
+
<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
1177
|
<p style="margin: 0; word-break: break-word;">${welcomeMessage}</p>
|
|
1702
1178
|
</div>
|
|
1703
1179
|
</td>
|
|
@@ -1710,7 +1186,7 @@ function generateEmailTemplate(options) {
|
|
|
1710
1186
|
<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
1187
|
<tr>
|
|
1712
1188
|
<td class="pad" style="padding-bottom:10px;padding-left:40px;padding-right:40px;padding-top:10px;">
|
|
1713
|
-
<div style="color
|
|
1189
|
+
<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
1190
|
<p style="margin: 0; word-break: break-word;">${heading}</p>
|
|
1715
1191
|
</div>
|
|
1716
1192
|
</td>
|
|
@@ -1755,135 +1231,809 @@ function generateEmailTemplate(options) {
|
|
|
1755
1231
|
</tr>
|
|
1756
1232
|
</table>
|
|
1757
1233
|
|
|
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;">
|
|
1234
|
+
<!-- Copy Link -->
|
|
1235
|
+
<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;">
|
|
1236
|
+
<tr>
|
|
1237
|
+
<td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
|
|
1238
|
+
<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;">
|
|
1239
|
+
<p style="margin: 0 0 5px 0; word-break: break-word;">or copy and paste this link into your browser:</p>
|
|
1240
|
+
<a href="${safeButtonUrl}" style="color:#4a90d9;text-decoration:underline;word-break:break-all;">${safeButtonUrl}</a>
|
|
1241
|
+
</div>
|
|
1242
|
+
</td>
|
|
1243
|
+
</tr>
|
|
1244
|
+
</table>
|
|
1245
|
+
` : ""}
|
|
1246
|
+
|
|
1247
|
+
${securityWarning ? `
|
|
1248
|
+
<!-- Security Warning -->
|
|
1249
|
+
<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;">
|
|
1250
|
+
<tr>
|
|
1251
|
+
<td class="pad" style="padding-left:40px;padding-right:40px;padding-bottom:20px;">
|
|
1252
|
+
<div style="background-color: #a1f1ff; border-radius: 8px; padding: 16px; display: flex; align-items: center; justify-content: center;">
|
|
1253
|
+
<p style="margin: 0; color: #01001b; font-family: 'Montserrat', Arial, sans-serif; font-size: 13px; line-height: 1.5; text-align: center;">
|
|
1254
|
+
${securityWarning}
|
|
1255
|
+
</p>
|
|
1256
|
+
</div>
|
|
1257
|
+
</td>
|
|
1258
|
+
</tr>
|
|
1259
|
+
</table>
|
|
1260
|
+
` : ""}
|
|
1261
|
+
|
|
1262
|
+
<!-- Footer Note -->
|
|
1263
|
+
<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;">
|
|
1264
|
+
<tr>
|
|
1265
|
+
<td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
|
|
1266
|
+
<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;">
|
|
1267
|
+
<p style="margin: 0; word-break: break-word;">${footerNote}</p>
|
|
1268
|
+
</div>
|
|
1269
|
+
</td>
|
|
1270
|
+
</tr>
|
|
1271
|
+
</table>
|
|
1272
|
+
|
|
1273
|
+
</td>
|
|
1274
|
+
</tr>
|
|
1275
|
+
</tbody>
|
|
1276
|
+
</table>
|
|
1277
|
+
</td>
|
|
1278
|
+
</tr>
|
|
1279
|
+
</tbody>
|
|
1280
|
+
</table>
|
|
1281
|
+
|
|
1282
|
+
<!-- Spacer Row -->
|
|
1283
|
+
<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;">
|
|
1284
|
+
<tbody>
|
|
1285
|
+
<tr>
|
|
1286
|
+
<td>
|
|
1287
|
+
<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;">
|
|
1288
|
+
<tbody>
|
|
1289
|
+
<tr>
|
|
1290
|
+
<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;">
|
|
1291
|
+
<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;">
|
|
1292
|
+
<tr>
|
|
1293
|
+
<td class="pad">
|
|
1294
|
+
<div></div>
|
|
1295
|
+
</td>
|
|
1296
|
+
</tr>
|
|
1297
|
+
</table>
|
|
1298
|
+
</td>
|
|
1299
|
+
</tr>
|
|
1300
|
+
</tbody>
|
|
1301
|
+
</table>
|
|
1302
|
+
</td>
|
|
1303
|
+
</tr>
|
|
1304
|
+
</tbody>
|
|
1305
|
+
</table>
|
|
1306
|
+
|
|
1307
|
+
<!-- Footer Row -->
|
|
1308
|
+
<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;">
|
|
1309
|
+
<tbody>
|
|
1310
|
+
<tr>
|
|
1311
|
+
<td>
|
|
1312
|
+
<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;">
|
|
1313
|
+
<tbody>
|
|
1314
|
+
<tr>
|
|
1315
|
+
<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;">
|
|
1316
|
+
|
|
1317
|
+
${safeMarketingUrl ? `
|
|
1318
|
+
<!-- Policy Links -->
|
|
1319
|
+
<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;">
|
|
1320
|
+
<tr>
|
|
1321
|
+
<td class="pad" style="padding-bottom:20px;padding-left:40px;padding-right:40px;padding-top:20px;">
|
|
1322
|
+
<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;">
|
|
1323
|
+
<p style="margin: 0; word-break: break-word;">
|
|
1324
|
+
<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>
|
|
1325
|
+
</p>
|
|
1326
|
+
</div>
|
|
1327
|
+
</td>
|
|
1328
|
+
</tr>
|
|
1329
|
+
</table>
|
|
1330
|
+
` : ""}
|
|
1331
|
+
|
|
1332
|
+
<!-- Copyright & Disclaimer -->
|
|
1333
|
+
<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
1334
|
<tr>
|
|
1761
|
-
<td class="pad" style="padding-bottom:
|
|
1762
|
-
<div style="color:#
|
|
1763
|
-
<p style="margin: 0 0
|
|
1764
|
-
<
|
|
1335
|
+
<td class="pad" style="padding-bottom:15px;padding-left:40px;padding-right:40px;">
|
|
1336
|
+
<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;">
|
|
1337
|
+
<p style="margin: 0 0 12px 0; word-break: break-word;">\xA9 ${(/* @__PURE__ */ new Date()).getFullYear()} ${safeAppName}. All rights reserved.</p>
|
|
1338
|
+
<p style="margin: 0; font-size: 11px; line-height: 1.5; word-break: break-word;">
|
|
1339
|
+
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.
|
|
1340
|
+
</p>
|
|
1765
1341
|
</div>
|
|
1766
1342
|
</td>
|
|
1767
1343
|
</tr>
|
|
1768
1344
|
</table>
|
|
1769
|
-
` : ""}
|
|
1770
1345
|
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1346
|
+
</td>
|
|
1347
|
+
</tr>
|
|
1348
|
+
</tbody>
|
|
1349
|
+
</table>
|
|
1350
|
+
</td>
|
|
1351
|
+
</tr>
|
|
1352
|
+
</tbody>
|
|
1353
|
+
</table>
|
|
1354
|
+
|
|
1355
|
+
</td>
|
|
1356
|
+
</tr>
|
|
1357
|
+
</tbody>
|
|
1358
|
+
</table>
|
|
1359
|
+
</body>
|
|
1360
|
+
</html>
|
|
1361
|
+
`;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// src/core/passwordless.ts
|
|
1365
|
+
var PASSWORDLESS_TTL_MS = 10 * 60 * 1e3;
|
|
1366
|
+
var PASSWORDLESS_MAX_ATTEMPTS = 5;
|
|
1367
|
+
var CODE_SPACE = 1e6;
|
|
1368
|
+
var CODE_LIMIT = Math.floor(4294967296 / CODE_SPACE) * CODE_SPACE;
|
|
1369
|
+
var CLEANUP_GRACE_MS = 24 * 60 * 60 * 1e3;
|
|
1370
|
+
var normalizeEmail = (email) => email.trim().toLowerCase();
|
|
1371
|
+
function generateCode() {
|
|
1372
|
+
const buffer = new Uint32Array(1);
|
|
1373
|
+
for (; ; ) {
|
|
1374
|
+
crypto.getRandomValues(buffer);
|
|
1375
|
+
if (buffer[0] < CODE_LIMIT) return String(buffer[0] % CODE_SPACE).padStart(6, "0");
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
var codeHash = (challengeId, code) => hashToken(`${challengeId}:${code}`);
|
|
1379
|
+
function constantTimeEqual(a, b) {
|
|
1380
|
+
if (a.length !== b.length) return false;
|
|
1381
|
+
let diff = 0;
|
|
1382
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1383
|
+
return diff === 0;
|
|
1384
|
+
}
|
|
1385
|
+
async function consumeOpen(db, t, email, now) {
|
|
1386
|
+
await db.update(t).set({ consumedAt: now }).where(and2(eq4(t.email, email), isNull(t.consumedAt)));
|
|
1387
|
+
}
|
|
1388
|
+
async function createPasswordlessChallenge(db, tables2, input) {
|
|
1389
|
+
const t = tables2.passwordlessChallenges;
|
|
1390
|
+
const email = normalizeEmail(input.email);
|
|
1391
|
+
const now = Date.now();
|
|
1392
|
+
const challengeId = crypto.randomUUID();
|
|
1393
|
+
const linkToken = generateSecureToken(32);
|
|
1394
|
+
const browserSecret = generateSecureToken(32);
|
|
1395
|
+
const code = generateCode();
|
|
1396
|
+
await consumeOpen(db, t, email, now);
|
|
1397
|
+
await db.insert(t).values({
|
|
1398
|
+
id: challengeId,
|
|
1399
|
+
email,
|
|
1400
|
+
subjectId: input.subjectId ?? null,
|
|
1401
|
+
linkTokenHash: await hashToken(linkToken),
|
|
1402
|
+
codeHash: await codeHash(challengeId, code),
|
|
1403
|
+
browserHash: await hashToken(browserSecret),
|
|
1404
|
+
attempts: 0,
|
|
1405
|
+
payload: input.payload ?? null,
|
|
1406
|
+
expiresAt: now + (input.ttlMs ?? PASSWORDLESS_TTL_MS),
|
|
1407
|
+
createdAt: now
|
|
1408
|
+
});
|
|
1409
|
+
return { challengeId, linkToken, code, browserSecret };
|
|
1410
|
+
}
|
|
1411
|
+
async function verifyPasswordlessLink(db, tables2, input) {
|
|
1412
|
+
const t = tables2.passwordlessChallenges;
|
|
1413
|
+
const now = Date.now();
|
|
1414
|
+
const hash = await hashToken(input.linkToken);
|
|
1415
|
+
const [row] = await db.update(t).set({ consumedAt: now }).where(and2(eq4(t.linkTokenHash, hash), isNull(t.consumedAt), gt2(t.expiresAt, now))).returning();
|
|
1416
|
+
if (!row) {
|
|
1417
|
+
const [stale] = await db.select({ expiresAt: t.expiresAt, consumedAt: t.consumedAt }).from(t).where(eq4(t.linkTokenHash, hash)).limit(1);
|
|
1418
|
+
const expired = stale && stale.consumedAt === null && stale.expiresAt <= now;
|
|
1419
|
+
return { ok: false, reason: expired ? "expired" : "invalid" };
|
|
1420
|
+
}
|
|
1421
|
+
await consumeOpen(db, t, row.email, now);
|
|
1422
|
+
return { ok: true, email: row.email, subjectId: row.subjectId, payload: row.payload };
|
|
1423
|
+
}
|
|
1424
|
+
async function verifyPasswordlessCode(db, tables2, input) {
|
|
1425
|
+
const t = tables2.passwordlessChallenges;
|
|
1426
|
+
const max = input.maxAttempts ?? PASSWORDLESS_MAX_ATTEMPTS;
|
|
1427
|
+
const email = normalizeEmail(input.email);
|
|
1428
|
+
const now = Date.now();
|
|
1429
|
+
const [row] = await db.select().from(t).where(and2(eq4(t.email, email), isNull(t.consumedAt))).orderBy(desc(t.createdAt)).limit(1);
|
|
1430
|
+
if (!row) return { ok: false, reason: "invalid" };
|
|
1431
|
+
if (!constantTimeEqual(row.browserHash, await hashToken(input.browserSecret))) {
|
|
1432
|
+
return { ok: false, reason: "invalid" };
|
|
1433
|
+
}
|
|
1434
|
+
if (row.expiresAt <= now) return { ok: false, reason: "expired" };
|
|
1435
|
+
if (row.attempts >= max) return { ok: false, reason: "locked" };
|
|
1436
|
+
if (!constantTimeEqual(row.codeHash, await codeHash(row.id, input.code.trim()))) {
|
|
1437
|
+
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 });
|
|
1438
|
+
return { ok: false, reason: counted && counted.attempts < max ? "invalid" : "locked" };
|
|
1439
|
+
}
|
|
1440
|
+
const [won] = await db.update(t).set({ consumedAt: now }).where(and2(eq4(t.id, row.id), isNull(t.consumedAt), lt(t.attempts, max))).returning();
|
|
1441
|
+
if (!won) return { ok: false, reason: "invalid" };
|
|
1442
|
+
await consumeOpen(db, t, email, now);
|
|
1443
|
+
return { ok: true, email, subjectId: won.subjectId, payload: won.payload };
|
|
1444
|
+
}
|
|
1445
|
+
async function recentPasswordlessFailures(db, tables2, input) {
|
|
1446
|
+
const t = tables2.passwordlessChallenges;
|
|
1447
|
+
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)));
|
|
1448
|
+
return Number(row?.total ?? 0);
|
|
1449
|
+
}
|
|
1450
|
+
async function cleanupExpiredPasswordlessChallenges(db, tables2) {
|
|
1451
|
+
const t = tables2.passwordlessChallenges;
|
|
1452
|
+
await db.delete(t).where(lt(t.expiresAt, Date.now() - CLEANUP_GRACE_MS));
|
|
1453
|
+
}
|
|
1454
|
+
function passwordlessEmail(input) {
|
|
1455
|
+
const { appName, appUrl, linkUrl, code } = input;
|
|
1456
|
+
const minutes = Math.max(1, Math.round((input.ttlMs ?? PASSWORDLESS_TTL_MS) / 6e4));
|
|
1457
|
+
const lifetime2 = `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
1458
|
+
const safeLink = sanitizeEmailUrl(linkUrl, appUrl);
|
|
1459
|
+
const html = generateEmailTemplate({
|
|
1460
|
+
heading: `Sign in to ${escapeHtml(appName)}`,
|
|
1461
|
+
body: `<p>Use this code, or the button below, to sign in:</p>
|
|
1462
|
+
<p style="font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 24px 0; font-family: monospace;">${escapeHtml(code)}</p>
|
|
1463
|
+
<p>The code and the link expire in ${lifetime2}.</p>`,
|
|
1464
|
+
buttonText: "Sign in",
|
|
1465
|
+
buttonUrl: safeLink,
|
|
1466
|
+
footerNote: "If you didn't ask to sign in, you can ignore this email.",
|
|
1467
|
+
appUrl,
|
|
1468
|
+
appName
|
|
1469
|
+
});
|
|
1470
|
+
return {
|
|
1471
|
+
subject: `${code} is your sign-in code`,
|
|
1472
|
+
html,
|
|
1473
|
+
text: `Your ${appName} sign-in code is ${code}.
|
|
1474
|
+
|
|
1475
|
+
Or sign in with this link:
|
|
1476
|
+
${safeLink}
|
|
1477
|
+
|
|
1478
|
+
The code and the link expire in ${lifetime2}.
|
|
1479
|
+
|
|
1480
|
+
If you didn't ask to sign in, you can ignore this email.`
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// src/middleware/auth.ts
|
|
1485
|
+
import { createMiddleware } from "hono/factory";
|
|
1486
|
+
import { eq as eq5, and as and3, gt as gt3 } from "drizzle-orm";
|
|
1487
|
+
|
|
1488
|
+
// src/factory.ts
|
|
1489
|
+
function createAuth(config) {
|
|
1490
|
+
const { db, schema, getEnv, emailBranding, invitations, signup = "open" } = config;
|
|
1491
|
+
if (signup === "invitation" && !invitations) {
|
|
1492
|
+
throw new Error("createAuth: signup: 'invitation' requires the invitations hooks");
|
|
1493
|
+
}
|
|
1494
|
+
const getContext = (c) => ({
|
|
1495
|
+
db,
|
|
1496
|
+
schema,
|
|
1497
|
+
env: getEnv(c),
|
|
1498
|
+
emailBranding,
|
|
1499
|
+
invitations,
|
|
1500
|
+
signup
|
|
1501
|
+
});
|
|
1502
|
+
return {
|
|
1503
|
+
db,
|
|
1504
|
+
schema,
|
|
1505
|
+
getContext
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
function createAuthMiddleware(auth2) {
|
|
1509
|
+
return async (c, next) => {
|
|
1510
|
+
const ctx = auth2.getContext(c);
|
|
1511
|
+
c.set("authContext", ctx);
|
|
1512
|
+
c.set("db", auth2.db);
|
|
1513
|
+
await next();
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
function getAuthContext(c) {
|
|
1517
|
+
const ctx = c.get("authContext");
|
|
1518
|
+
if (!ctx) {
|
|
1519
|
+
throw new Error(
|
|
1520
|
+
"AuthContext not found. Did you apply createAuthMiddleware before the auth routes?"
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
return ctx;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// src/lib/problem-json.ts
|
|
1527
|
+
var ProblemTypes = {
|
|
1528
|
+
// 4xx Client Errors
|
|
1529
|
+
BAD_REQUEST: "/errors/bad-request",
|
|
1530
|
+
UNAUTHORIZED: "/errors/unauthorized",
|
|
1531
|
+
FORBIDDEN: "/errors/forbidden",
|
|
1532
|
+
NOT_FOUND: "/errors/not-found",
|
|
1533
|
+
CONFLICT: "/errors/conflict",
|
|
1534
|
+
GONE: "/errors/gone",
|
|
1535
|
+
VALIDATION_ERROR: "/errors/validation-error",
|
|
1536
|
+
RATE_LIMIT_EXCEEDED: "/errors/rate-limit-exceeded",
|
|
1537
|
+
// 5xx Server Errors
|
|
1538
|
+
INTERNAL_ERROR: "/errors/internal-error",
|
|
1539
|
+
SERVICE_UNAVAILABLE: "/errors/service-unavailable"
|
|
1540
|
+
};
|
|
1541
|
+
function generateTraceId() {
|
|
1542
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1543
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1544
|
+
}
|
|
1545
|
+
function createProblemDetails(type, title, status, options) {
|
|
1546
|
+
const problem = {
|
|
1547
|
+
type,
|
|
1548
|
+
title,
|
|
1549
|
+
status
|
|
1550
|
+
};
|
|
1551
|
+
if (options?.detail) {
|
|
1552
|
+
problem.detail = options.detail;
|
|
1553
|
+
}
|
|
1554
|
+
if (options?.instance) {
|
|
1555
|
+
problem.instance = options.instance;
|
|
1556
|
+
}
|
|
1557
|
+
problem.traceId = options?.traceId || generateTraceId();
|
|
1558
|
+
if (options?.requestId) {
|
|
1559
|
+
problem.requestId = options.requestId;
|
|
1560
|
+
}
|
|
1561
|
+
if (options?.extensions) {
|
|
1562
|
+
Object.assign(problem, options.extensions);
|
|
1563
|
+
}
|
|
1564
|
+
return problem;
|
|
1565
|
+
}
|
|
1566
|
+
function problemJson(c, problem) {
|
|
1567
|
+
logger_default.warn("API error response", {
|
|
1568
|
+
type: problem.type,
|
|
1569
|
+
status: problem.status,
|
|
1570
|
+
traceId: problem.traceId,
|
|
1571
|
+
requestId: problem.requestId,
|
|
1572
|
+
path: c.req.path,
|
|
1573
|
+
method: c.req.method
|
|
1574
|
+
});
|
|
1575
|
+
return c.json(problem, problem.status, {
|
|
1576
|
+
"Content-Type": "application/problem+json"
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
var problems = {
|
|
1580
|
+
/**
|
|
1581
|
+
* 400 Bad Request - Generic client error
|
|
1582
|
+
*/
|
|
1583
|
+
badRequest(c, detail) {
|
|
1584
|
+
return problemJson(
|
|
1585
|
+
c,
|
|
1586
|
+
createProblemDetails(ProblemTypes.BAD_REQUEST, "Bad Request", 400, {
|
|
1587
|
+
detail: detail || "The request could not be understood by the server",
|
|
1588
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1589
|
+
requestId: c.get("requestId"),
|
|
1590
|
+
traceId: c.get("traceId")
|
|
1591
|
+
})
|
|
1592
|
+
);
|
|
1593
|
+
},
|
|
1594
|
+
/**
|
|
1595
|
+
* 400 Bad Request - Validation errors with field-specific details
|
|
1596
|
+
*/
|
|
1597
|
+
validationError(c, detail, errors) {
|
|
1598
|
+
return problemJson(
|
|
1599
|
+
c,
|
|
1600
|
+
createProblemDetails(ProblemTypes.VALIDATION_ERROR, "Validation Error", 400, {
|
|
1601
|
+
detail,
|
|
1602
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1603
|
+
requestId: c.get("requestId"),
|
|
1604
|
+
traceId: c.get("traceId"),
|
|
1605
|
+
extensions: errors ? { errors } : void 0
|
|
1606
|
+
})
|
|
1607
|
+
);
|
|
1608
|
+
},
|
|
1609
|
+
/**
|
|
1610
|
+
* 401 Unauthorized - Authentication required
|
|
1611
|
+
*/
|
|
1612
|
+
unauthorized(c, detail) {
|
|
1613
|
+
return problemJson(
|
|
1614
|
+
c,
|
|
1615
|
+
createProblemDetails(ProblemTypes.UNAUTHORIZED, "Unauthorized", 401, {
|
|
1616
|
+
detail: detail || "Authentication is required to access this resource",
|
|
1617
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1618
|
+
requestId: c.get("requestId"),
|
|
1619
|
+
traceId: c.get("traceId")
|
|
1620
|
+
})
|
|
1621
|
+
);
|
|
1622
|
+
},
|
|
1623
|
+
/**
|
|
1624
|
+
* 403 Forbidden - Insufficient permissions
|
|
1625
|
+
*/
|
|
1626
|
+
forbidden(c, detail) {
|
|
1627
|
+
return problemJson(
|
|
1628
|
+
c,
|
|
1629
|
+
createProblemDetails(ProblemTypes.FORBIDDEN, "Forbidden", 403, {
|
|
1630
|
+
detail: detail || "You do not have permission to access this resource",
|
|
1631
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1632
|
+
requestId: c.get("requestId"),
|
|
1633
|
+
traceId: c.get("traceId")
|
|
1634
|
+
})
|
|
1635
|
+
);
|
|
1636
|
+
},
|
|
1637
|
+
/**
|
|
1638
|
+
* 404 Not Found
|
|
1639
|
+
*/
|
|
1640
|
+
notFound(c, detail) {
|
|
1641
|
+
return problemJson(
|
|
1642
|
+
c,
|
|
1643
|
+
createProblemDetails(ProblemTypes.NOT_FOUND, "Not Found", 404, {
|
|
1644
|
+
detail: detail || "The requested resource was not found",
|
|
1645
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1646
|
+
requestId: c.get("requestId"),
|
|
1647
|
+
traceId: c.get("traceId")
|
|
1648
|
+
})
|
|
1649
|
+
);
|
|
1650
|
+
},
|
|
1651
|
+
/**
|
|
1652
|
+
* 409 Conflict - Resource conflict (e.g., duplicate email)
|
|
1653
|
+
*/
|
|
1654
|
+
conflict(c, detail) {
|
|
1655
|
+
return problemJson(
|
|
1656
|
+
c,
|
|
1657
|
+
createProblemDetails(ProblemTypes.CONFLICT, "Conflict", 409, {
|
|
1658
|
+
detail,
|
|
1659
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1660
|
+
requestId: c.get("requestId"),
|
|
1661
|
+
traceId: c.get("traceId")
|
|
1662
|
+
})
|
|
1663
|
+
);
|
|
1664
|
+
},
|
|
1665
|
+
/**
|
|
1666
|
+
* 410 Gone - Resource is no longer available
|
|
1667
|
+
*/
|
|
1668
|
+
gone(c, detail) {
|
|
1669
|
+
return problemJson(
|
|
1670
|
+
c,
|
|
1671
|
+
createProblemDetails(ProblemTypes.GONE, "Gone", 410, {
|
|
1672
|
+
detail: detail || "The requested resource is no longer available",
|
|
1673
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1674
|
+
requestId: c.get("requestId"),
|
|
1675
|
+
traceId: c.get("traceId")
|
|
1676
|
+
})
|
|
1677
|
+
);
|
|
1678
|
+
},
|
|
1679
|
+
/**
|
|
1680
|
+
* 429 Too Many Requests - Rate limit exceeded
|
|
1681
|
+
*/
|
|
1682
|
+
rateLimitExceeded(c, retryAfter) {
|
|
1683
|
+
const extensions = retryAfter ? { retryAfter } : void 0;
|
|
1684
|
+
return problemJson(
|
|
1685
|
+
c,
|
|
1686
|
+
createProblemDetails(ProblemTypes.RATE_LIMIT_EXCEEDED, "Rate Limit Exceeded", 429, {
|
|
1687
|
+
detail: retryAfter ? `Too many requests. Please retry after ${retryAfter} seconds` : "Too many requests. Please slow down",
|
|
1688
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1689
|
+
requestId: c.get("requestId"),
|
|
1690
|
+
traceId: c.get("traceId"),
|
|
1691
|
+
extensions
|
|
1692
|
+
})
|
|
1693
|
+
);
|
|
1694
|
+
},
|
|
1695
|
+
/**
|
|
1696
|
+
* 500 Internal Server Error - Generic server error
|
|
1697
|
+
* SECURITY: Never include error details or stack traces
|
|
1698
|
+
*/
|
|
1699
|
+
internalError(c, error) {
|
|
1700
|
+
const traceId = c.get("traceId") || generateTraceId();
|
|
1701
|
+
if (error) {
|
|
1702
|
+
logError(error, {
|
|
1703
|
+
context: "internal_error",
|
|
1704
|
+
path: c.req.path,
|
|
1705
|
+
method: c.req.method,
|
|
1706
|
+
traceId,
|
|
1707
|
+
requestId: c.get("requestId")
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
return problemJson(
|
|
1711
|
+
c,
|
|
1712
|
+
createProblemDetails(ProblemTypes.INTERNAL_ERROR, "Internal Server Error", 500, {
|
|
1713
|
+
detail: "An unexpected error occurred. Please try again later",
|
|
1714
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1715
|
+
traceId,
|
|
1716
|
+
requestId: c.get("requestId")
|
|
1717
|
+
})
|
|
1718
|
+
);
|
|
1719
|
+
},
|
|
1720
|
+
/**
|
|
1721
|
+
* 503 Service Unavailable - Service is temporarily unavailable
|
|
1722
|
+
*/
|
|
1723
|
+
serviceUnavailable(c, detail) {
|
|
1724
|
+
return problemJson(
|
|
1725
|
+
c,
|
|
1726
|
+
createProblemDetails(ProblemTypes.SERVICE_UNAVAILABLE, "Service Unavailable", 503, {
|
|
1727
|
+
detail: detail || "The service is temporarily unavailable. Please try again later",
|
|
1728
|
+
instance: `${c.req.method} ${c.req.path}`,
|
|
1729
|
+
requestId: c.get("requestId"),
|
|
1730
|
+
traceId: c.get("traceId")
|
|
1731
|
+
})
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
|
|
1736
|
+
// src/middleware/auth.ts
|
|
1737
|
+
var SESSION_MAX_LIFETIME_MS = AUTH_DEFAULTS.SESSION_MAX_LIFETIME_DAYS * 24 * 60 * 60 * 1e3;
|
|
1738
|
+
async function revokeIfPastMaxLifetime(db, sessions, sessionId, session) {
|
|
1739
|
+
if (Date.now() - session.createdAt <= SESSION_MAX_LIFETIME_MS) return false;
|
|
1740
|
+
logger_default.info("Session exceeded absolute lifetime - revoked", {
|
|
1741
|
+
type: "security",
|
|
1742
|
+
event: "session_max_lifetime_exceeded",
|
|
1743
|
+
severity: "low",
|
|
1744
|
+
sessionId: sessionId.slice(0, 8),
|
|
1745
|
+
userId: session.userId
|
|
1746
|
+
});
|
|
1747
|
+
await deleteSession(db, { sessions }, sessionId);
|
|
1748
|
+
return true;
|
|
1749
|
+
}
|
|
1750
|
+
async function getSessionFromCookie(db, sessions, cookieHeader, request, env) {
|
|
1751
|
+
const cookieName = getSessionCookieName(env);
|
|
1752
|
+
const cookiePattern = new RegExp(`${cookieName}=([^;]+)`);
|
|
1753
|
+
const sessionIdMatch = cookieHeader.match(cookiePattern);
|
|
1754
|
+
if (!sessionIdMatch) return null;
|
|
1755
|
+
const sessionId = sessionIdMatch[1];
|
|
1756
|
+
const foundSessions = await db.select({
|
|
1757
|
+
userId: sessions.userId,
|
|
1758
|
+
expiresAt: sessions.expiresAt,
|
|
1759
|
+
createdAt: sessions.createdAt,
|
|
1760
|
+
lastActiveAt: sessions.lastActiveAt,
|
|
1761
|
+
fingerprint: sessions.fingerprint
|
|
1762
|
+
}).from(sessions).where(and3(eq5(sessions.id, sessionId), gt3(sessions.expiresAt, Date.now()))).limit(1);
|
|
1763
|
+
if (foundSessions.length === 0) return null;
|
|
1764
|
+
const session = foundSessions[0];
|
|
1765
|
+
if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
|
|
1766
|
+
if (sessionRefreshDue(session.lastActiveAt)) await refreshSession(db, { sessions }, sessionId);
|
|
1767
|
+
const userAgent = request.headers.get("user-agent") || "";
|
|
1768
|
+
const cfWorker = request.headers.get("cf-worker") || "";
|
|
1769
|
+
const clientIp = request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || "";
|
|
1770
|
+
const isLocalSSR = userAgent.toLowerCase().includes("node");
|
|
1771
|
+
const isSvelteKitSSR = userAgent.toLowerCase().includes("sveltekit");
|
|
1772
|
+
const isCloudflareSSR = !userAgent && cfWorker.includes(".pages.dev");
|
|
1773
|
+
const isSSR = isLocalSSR || isSvelteKitSSR || isCloudflareSSR;
|
|
1774
|
+
if (isSSR) {
|
|
1775
|
+
logger_default.info("SSR request detected, using IP validation", {
|
|
1776
|
+
type: "security",
|
|
1777
|
+
event: "ssr_session_validation",
|
|
1778
|
+
sessionId: sessionId.slice(0, 8),
|
|
1779
|
+
userId: session.userId,
|
|
1780
|
+
clientIp,
|
|
1781
|
+
cfWorker,
|
|
1782
|
+
isLocalSSR,
|
|
1783
|
+
isSvelteKitSSR,
|
|
1784
|
+
isCloudflareSSR
|
|
1785
|
+
});
|
|
1786
|
+
} else {
|
|
1787
|
+
if (session.fingerprint) {
|
|
1788
|
+
const currentFingerprint = await generateFingerprint(request);
|
|
1789
|
+
if (session.fingerprint !== currentFingerprint) {
|
|
1790
|
+
logger_default.info("Session fingerprint changed (not enforced)", {
|
|
1791
|
+
type: "security",
|
|
1792
|
+
event: "fingerprint_changed",
|
|
1793
|
+
sessionId: sessionId.slice(0, 8),
|
|
1794
|
+
userId: session.userId,
|
|
1795
|
+
clientIp
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
return {
|
|
1801
|
+
userId: session.userId,
|
|
1802
|
+
expiresAt: session.expiresAt
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
async function getSessionFromBearerToken(db, sessions, authHeader) {
|
|
1806
|
+
if (!authHeader.startsWith("Bearer ")) return null;
|
|
1807
|
+
const sessionId = authHeader.slice(7);
|
|
1808
|
+
const foundSessions = await db.select({
|
|
1809
|
+
userId: sessions.userId,
|
|
1810
|
+
expiresAt: sessions.expiresAt,
|
|
1811
|
+
createdAt: sessions.createdAt,
|
|
1812
|
+
lastActiveAt: sessions.lastActiveAt
|
|
1813
|
+
}).from(sessions).where(and3(eq5(sessions.id, sessionId), gt3(sessions.expiresAt, Date.now()))).limit(1);
|
|
1814
|
+
if (foundSessions.length === 0) return null;
|
|
1815
|
+
const session = foundSessions[0];
|
|
1816
|
+
if (await revokeIfPastMaxLifetime(db, sessions, sessionId, session)) return null;
|
|
1817
|
+
if (sessionRefreshDue(session.lastActiveAt)) await refreshSession(db, { sessions }, sessionId);
|
|
1818
|
+
return {
|
|
1819
|
+
userId: session.userId,
|
|
1820
|
+
expiresAt: session.expiresAt
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
var requireAuth = createMiddleware(
|
|
1824
|
+
async (c, next) => {
|
|
1825
|
+
const { db, schema } = getAuthContext(c);
|
|
1826
|
+
const authHeader = c.req.header("Authorization");
|
|
1827
|
+
if (authHeader) {
|
|
1828
|
+
const session2 = await getSessionFromBearerToken(db, schema.sessions, authHeader);
|
|
1829
|
+
if (session2) {
|
|
1830
|
+
c.set("userId", session2.userId);
|
|
1831
|
+
await next();
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
const cookieHeader = c.req.header("cookie");
|
|
1836
|
+
if (!cookieHeader) {
|
|
1837
|
+
return problems.unauthorized(c, "Please log in to continue");
|
|
1838
|
+
}
|
|
1839
|
+
const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
|
|
1840
|
+
if (!session) {
|
|
1841
|
+
c.header("Set-Cookie", clearSessionCookie(c.env));
|
|
1842
|
+
return problems.unauthorized(c, "Your session has expired. Please log in again.");
|
|
1843
|
+
}
|
|
1844
|
+
c.set("userId", session.userId);
|
|
1845
|
+
await next();
|
|
1846
|
+
}
|
|
1847
|
+
);
|
|
1848
|
+
var optionalAuth = createMiddleware(
|
|
1849
|
+
async (c, next) => {
|
|
1850
|
+
const { db, schema } = getAuthContext(c);
|
|
1851
|
+
const authHeader = c.req.header("Authorization");
|
|
1852
|
+
if (authHeader) {
|
|
1853
|
+
const session = await getSessionFromBearerToken(db, schema.sessions, authHeader);
|
|
1854
|
+
if (session) {
|
|
1855
|
+
c.set("userId", session.userId);
|
|
1856
|
+
await next();
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
const cookieHeader = c.req.header("cookie");
|
|
1861
|
+
if (cookieHeader) {
|
|
1862
|
+
const session = await getSessionFromCookie(db, schema.sessions, cookieHeader, c.req.raw, c.env);
|
|
1863
|
+
if (session) {
|
|
1864
|
+
c.set("userId", session.userId);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
await next();
|
|
1868
|
+
}
|
|
1869
|
+
);
|
|
1785
1870
|
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1871
|
+
// src/middleware/csrf.ts
|
|
1872
|
+
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
1873
|
+
var csrf = createMiddleware2(async (c, next) => {
|
|
1874
|
+
const method = c.req.method;
|
|
1875
|
+
const path = c.req.path;
|
|
1876
|
+
const isWebhook = path.startsWith("/v1/webhooks/");
|
|
1877
|
+
if (isWebhook) {
|
|
1878
|
+
await next();
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
const isMcpEndpoint = path === "/v1/mcp";
|
|
1882
|
+
if (isMcpEndpoint) {
|
|
1883
|
+
await next();
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
const isOAuthMcpEndpoint = path.startsWith("/v1/oauth/");
|
|
1887
|
+
if (isOAuthMcpEndpoint) {
|
|
1888
|
+
await next();
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
const isToolsApi = path.startsWith("/v1/tools/") && !path.startsWith("/v1/tools/catalog");
|
|
1892
|
+
if (isToolsApi) {
|
|
1893
|
+
await next();
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
const isAdminApi = path.startsWith("/v1/admin/");
|
|
1897
|
+
if (isAdminApi) {
|
|
1898
|
+
await next();
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
const isMobileAuthEndpoint = path === "/v1/auth/login" || path === "/v1/auth/signup" || path === "/v1/auth/refresh";
|
|
1902
|
+
const authHeader = c.req.header("Authorization");
|
|
1903
|
+
const hasBearerToken = authHeader?.startsWith("Bearer ");
|
|
1904
|
+
const hasNoOrigin = !c.req.header("origin") && !c.req.header("referer");
|
|
1905
|
+
if (isMobileAuthEndpoint && hasNoOrigin || hasBearerToken) {
|
|
1906
|
+
await next();
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
const isInternalSync = path.startsWith("/v1/internal/sync/");
|
|
1910
|
+
if (isInternalSync) {
|
|
1911
|
+
await next();
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
|
1915
|
+
const appUrl = c.env.APP_URL || "http://localhost:5173";
|
|
1916
|
+
const allowedOrigins = getAllowedOrigins(appUrl);
|
|
1917
|
+
const request = c.req.raw;
|
|
1918
|
+
const isTestEndpoint = c.req.path.startsWith("/test");
|
|
1919
|
+
if (isTestEndpoint) {
|
|
1920
|
+
const origin = request.headers.get("origin");
|
|
1921
|
+
const referer = request.headers.get("referer");
|
|
1922
|
+
logger_default.debug("CSRF validation for test endpoint", {
|
|
1923
|
+
path: c.req.path,
|
|
1924
|
+
method,
|
|
1925
|
+
origin,
|
|
1926
|
+
referer,
|
|
1927
|
+
appUrl,
|
|
1928
|
+
allowedOrigins
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
const pagesPattern = c.env.CORS_PAGES_PATTERN;
|
|
1932
|
+
if (!validateOrigin(request, allowedOrigins, pagesPattern)) {
|
|
1933
|
+
return problems.forbidden(c, "Request blocked for security reasons. Please try again.");
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
await next();
|
|
1937
|
+
});
|
|
1796
1938
|
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
</tbody>
|
|
1800
|
-
</table>
|
|
1801
|
-
</td>
|
|
1802
|
-
</tr>
|
|
1803
|
-
</tbody>
|
|
1804
|
-
</table>
|
|
1939
|
+
// src/middleware/rateLimit.ts
|
|
1940
|
+
import { createMiddleware as createMiddleware3 } from "hono/factory";
|
|
1805
1941
|
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1942
|
+
// src/lib/rate-limit-kv.ts
|
|
1943
|
+
async function checkRateLimitKV(kv, key, maxAttempts, windowSeconds) {
|
|
1944
|
+
try {
|
|
1945
|
+
const rateLimitKey = `rate_limit:${key}`;
|
|
1946
|
+
const currentAttemptsStr = await kv.get(rateLimitKey);
|
|
1947
|
+
const currentAttempts = currentAttemptsStr ? parseInt(currentAttemptsStr, 10) : 0;
|
|
1948
|
+
if (currentAttempts >= maxAttempts) {
|
|
1949
|
+
return {
|
|
1950
|
+
allowed: false,
|
|
1951
|
+
remaining: 0
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
const newAttempts = currentAttempts + 1;
|
|
1955
|
+
await kv.put(rateLimitKey, newAttempts.toString(), {
|
|
1956
|
+
expirationTtl: windowSeconds
|
|
1957
|
+
});
|
|
1958
|
+
return {
|
|
1959
|
+
allowed: true,
|
|
1960
|
+
remaining: maxAttempts - newAttempts
|
|
1961
|
+
};
|
|
1962
|
+
} catch (error) {
|
|
1963
|
+
logger_default.error("Rate limit KV error, failing open", {
|
|
1964
|
+
key,
|
|
1965
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1966
|
+
});
|
|
1967
|
+
return {
|
|
1968
|
+
allowed: true,
|
|
1969
|
+
remaining: maxAttempts
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1830
1973
|
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1974
|
+
// src/middleware/rateLimit.ts
|
|
1975
|
+
var rateLimit = (config) => createMiddleware3(async (c, next) => {
|
|
1976
|
+
const kv = c.env.RATE_LIMIT_KV;
|
|
1977
|
+
const identifier = await config.identifier(c);
|
|
1978
|
+
const key = `${config.action}:${identifier}`;
|
|
1979
|
+
const windowSeconds = Math.ceil(config.windowMs / 1e3);
|
|
1980
|
+
const { allowed, remaining } = await checkRateLimitKV(
|
|
1981
|
+
kv,
|
|
1982
|
+
key,
|
|
1983
|
+
config.maxAttempts,
|
|
1984
|
+
windowSeconds
|
|
1985
|
+
);
|
|
1986
|
+
const resetTimestamp = Math.ceil(Date.now() / 1e3) + windowSeconds;
|
|
1987
|
+
c.header("X-RateLimit-Limit", config.maxAttempts.toString());
|
|
1988
|
+
c.header("X-RateLimit-Remaining", Math.max(0, remaining).toString());
|
|
1989
|
+
c.header("X-RateLimit-Reset", resetTimestamp.toString());
|
|
1990
|
+
if (!allowed) {
|
|
1991
|
+
c.header("Retry-After", windowSeconds.toString());
|
|
1992
|
+
return problems.rateLimitExceeded(c, windowSeconds);
|
|
1993
|
+
}
|
|
1994
|
+
await next();
|
|
1995
|
+
});
|
|
1840
1996
|
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1997
|
+
// src/middleware/require-verified-email.ts
|
|
1998
|
+
import { createMiddleware as createMiddleware4 } from "hono/factory";
|
|
1999
|
+
import { eq as eq6 } from "drizzle-orm";
|
|
2000
|
+
var requireVerifiedEmail = createMiddleware4(
|
|
2001
|
+
async (c, next) => {
|
|
2002
|
+
const userId = c.get("userId");
|
|
2003
|
+
if (!userId) {
|
|
2004
|
+
return problems.unauthorized(c, "Not authenticated");
|
|
2005
|
+
}
|
|
2006
|
+
const { db, schema } = getAuthContext(c);
|
|
2007
|
+
const database = db;
|
|
2008
|
+
const users = schema.users;
|
|
2009
|
+
const [user] = await database.select({ emailVerified: users.emailVerified }).from(users).where(eq6(users.id, userId)).limit(1);
|
|
2010
|
+
if (!user || !user.emailVerified) {
|
|
2011
|
+
return problems.forbidden(c, "Please verify your email address to continue");
|
|
2012
|
+
}
|
|
2013
|
+
await next();
|
|
2014
|
+
}
|
|
2015
|
+
);
|
|
1853
2016
|
|
|
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>
|
|
2017
|
+
// src/routes/index.ts
|
|
2018
|
+
import { OpenAPIHono as OpenAPIHono3 } from "@hono/zod-openapi";
|
|
1867
2019
|
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
</table>
|
|
1872
|
-
</td>
|
|
1873
|
-
</tr>
|
|
1874
|
-
</tbody>
|
|
1875
|
-
</table>
|
|
2020
|
+
// src/routes/signup.ts
|
|
2021
|
+
import { createRoute } from "@hono/zod-openapi";
|
|
2022
|
+
import { eq as eq9 } from "drizzle-orm";
|
|
1876
2023
|
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
</tbody>
|
|
1880
|
-
</table>
|
|
1881
|
-
</body>
|
|
1882
|
-
</html>
|
|
1883
|
-
`;
|
|
1884
|
-
}
|
|
2024
|
+
// src/lib/email/email-service.ts
|
|
2025
|
+
import { eq as eq7 } from "drizzle-orm";
|
|
1885
2026
|
|
|
1886
2027
|
// src/lib/email/templates.ts
|
|
2028
|
+
function lifetime(minutes) {
|
|
2029
|
+
if (minutes % 60 !== 0) return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
2030
|
+
const hours = minutes / 60;
|
|
2031
|
+
return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
2032
|
+
}
|
|
2033
|
+
var VERIFICATION_LIFETIME = lifetime(AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60);
|
|
2034
|
+
var PASSWORD_RESET_LIFETIME = lifetime(AUTH_DEFAULTS.PASSWORD_RESET_TTL_MINUTES);
|
|
2035
|
+
var EMAIL_CHANGE_LIFETIME = lifetime(AUTH_DEFAULTS.EMAIL_CHANGE_TTL_HOURS * 60);
|
|
2036
|
+
var TWO_FACTOR_CODE_LIFETIME = lifetime(AUTH_DEFAULTS.TWO_FACTOR_EMAIL_CODE_TTL_MINUTES);
|
|
1887
2037
|
function extractAppUrl(url) {
|
|
1888
2038
|
try {
|
|
1889
2039
|
const parsed = new URL(url);
|
|
@@ -1899,12 +2049,13 @@ function generateVerificationEmail(data, appName = "Your App", configuredAppUrl
|
|
|
1899
2049
|
const safeFirstName = firstName ? escapeHtml(firstName) : null;
|
|
1900
2050
|
const html = generateEmailTemplate({
|
|
1901
2051
|
heading: "",
|
|
1902
|
-
body: `Please verify that your email address is <strong>${escapeHtml(email)}</strong>, and that you entered it when signing up for ${escapeHtml(appName)}.`,
|
|
2052
|
+
body: `Please verify that your email address is <strong>${escapeHtml(email)}</strong>, and that you entered it when signing up for ${escapeHtml(appName)}. This link expires in ${VERIFICATION_LIFETIME}.`,
|
|
1903
2053
|
buttonText: "Verify Email",
|
|
1904
2054
|
buttonUrl: safeUrl,
|
|
1905
2055
|
welcomeMessage: safeFirstName ? `Hi ${safeFirstName}, Welcome to ${escapeHtml(appName)}!` : void 0,
|
|
1906
2056
|
footerNote: "",
|
|
1907
2057
|
appUrl,
|
|
2058
|
+
appName,
|
|
1908
2059
|
...branding
|
|
1909
2060
|
});
|
|
1910
2061
|
return {
|
|
@@ -1917,7 +2068,7 @@ Thank you for signing up for ${appName}! Please verify your email address by cli
|
|
|
1917
2068
|
|
|
1918
2069
|
${safeUrl}
|
|
1919
2070
|
|
|
1920
|
-
This link expires in
|
|
2071
|
+
This link expires in ${VERIFICATION_LIFETIME}.
|
|
1921
2072
|
|
|
1922
2073
|
If you didn't create an account with ${appName}, you can safely ignore this email.`,
|
|
1923
2074
|
tags: {
|
|
@@ -1935,11 +2086,12 @@ function generatePasswordResetEmail(data, appName = "Your App", configuredAppUrl
|
|
|
1935
2086
|
buttonText: "Reset Password",
|
|
1936
2087
|
buttonUrl: safeUrl,
|
|
1937
2088
|
securityWarning: `
|
|
1938
|
-
<strong>Link expires in
|
|
2089
|
+
<strong>Link expires in ${PASSWORD_RESET_LIFETIME}.</strong><br>
|
|
1939
2090
|
For security, this link can only be used once.
|
|
1940
2091
|
`,
|
|
1941
2092
|
footerNote: "If you didn't request this change, no action is needed. Your password will remain unchanged.",
|
|
1942
2093
|
appUrl,
|
|
2094
|
+
appName,
|
|
1943
2095
|
...branding
|
|
1944
2096
|
});
|
|
1945
2097
|
return {
|
|
@@ -1952,7 +2104,7 @@ We received a request to reset your password. Click the link below to create a n
|
|
|
1952
2104
|
|
|
1953
2105
|
${safeUrl}
|
|
1954
2106
|
|
|
1955
|
-
This link
|
|
2107
|
+
This link expires in ${PASSWORD_RESET_LIFETIME}.
|
|
1956
2108
|
|
|
1957
2109
|
If you didn't request a password reset, you can safely ignore this email.`,
|
|
1958
2110
|
tags: {
|
|
@@ -1970,11 +2122,12 @@ function generateEmailChangeConfirmation(data, appName = "Your App", configuredA
|
|
|
1970
2122
|
buttonText: "Confirm Email Change",
|
|
1971
2123
|
buttonUrl: safeUrl,
|
|
1972
2124
|
securityWarning: `
|
|
1973
|
-
<strong>This link expires in
|
|
2125
|
+
<strong>This link expires in ${EMAIL_CHANGE_LIFETIME}.</strong><br>
|
|
1974
2126
|
For security, this link can only be used once.
|
|
1975
2127
|
`,
|
|
1976
2128
|
footerNote: "If you didn't request this change, you can safely ignore this email.",
|
|
1977
2129
|
appUrl,
|
|
2130
|
+
appName,
|
|
1978
2131
|
...branding
|
|
1979
2132
|
});
|
|
1980
2133
|
return {
|
|
@@ -1989,7 +2142,7 @@ Click the link below to confirm this change:
|
|
|
1989
2142
|
|
|
1990
2143
|
${safeUrl}
|
|
1991
2144
|
|
|
1992
|
-
This link expires in
|
|
2145
|
+
This link expires in ${EMAIL_CHANGE_LIFETIME}.
|
|
1993
2146
|
|
|
1994
2147
|
If you didn't request this change, you can safely ignore this email.`,
|
|
1995
2148
|
tags: {
|
|
@@ -2013,6 +2166,7 @@ function generateEmailChangeNotification(data, appName = "Your App", configuredA
|
|
|
2013
2166
|
`,
|
|
2014
2167
|
footerNote: "If you didn't request this change, click the button above to cancel it.",
|
|
2015
2168
|
appUrl,
|
|
2169
|
+
appName,
|
|
2016
2170
|
...branding
|
|
2017
2171
|
});
|
|
2018
2172
|
return {
|
|
@@ -2042,13 +2196,14 @@ function generate2faCodeEmail(data, appName = "Your App", appUrl = "", branding)
|
|
|
2042
2196
|
<p>Hi ${safeFirstName},</p>
|
|
2043
2197
|
<p>Your verification code is:</p>
|
|
2044
2198
|
<p style="font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 24px 0; font-family: monospace;">${escapeHtml(code)}</p>
|
|
2045
|
-
<p>This code expires in
|
|
2199
|
+
<p>This code expires in ${TWO_FACTOR_CODE_LIFETIME}.</p>
|
|
2046
2200
|
<p>If you didn't request this code, you can safely ignore this email.</p>
|
|
2047
2201
|
`,
|
|
2048
2202
|
buttonText: "",
|
|
2049
2203
|
buttonUrl: "",
|
|
2050
2204
|
footerNote: "",
|
|
2051
2205
|
appUrl,
|
|
2206
|
+
appName,
|
|
2052
2207
|
...branding
|
|
2053
2208
|
});
|
|
2054
2209
|
return {
|
|
@@ -2061,7 +2216,7 @@ Your verification code is:
|
|
|
2061
2216
|
|
|
2062
2217
|
${code}
|
|
2063
2218
|
|
|
2064
|
-
This code expires in
|
|
2219
|
+
This code expires in ${TWO_FACTOR_CODE_LIFETIME}.
|
|
2065
2220
|
|
|
2066
2221
|
If you didn't request this code, you can safely ignore this email.
|
|
2067
2222
|
|
|
@@ -2084,6 +2239,7 @@ function generate2faEnabledEmail(data, appName = "Your App", appUrl = "", brandi
|
|
|
2084
2239
|
buttonUrl: "",
|
|
2085
2240
|
footerNote: "",
|
|
2086
2241
|
appUrl,
|
|
2242
|
+
appName,
|
|
2087
2243
|
...branding
|
|
2088
2244
|
});
|
|
2089
2245
|
return {
|
|
@@ -2114,6 +2270,7 @@ function generate2faDisabledEmail(data, appName = "Your App", appUrl = "", brand
|
|
|
2114
2270
|
buttonUrl: appUrl ? `${appUrl}/forgot-password` : "",
|
|
2115
2271
|
footerNote: "",
|
|
2116
2272
|
appUrl,
|
|
2273
|
+
appName,
|
|
2117
2274
|
...branding
|
|
2118
2275
|
});
|
|
2119
2276
|
return {
|
|
@@ -2330,7 +2487,7 @@ var EmailService = class {
|
|
|
2330
2487
|
const [user] = await db.select({
|
|
2331
2488
|
emailBounced: usersTable.emailBounced,
|
|
2332
2489
|
emailComplained: usersTable.emailComplained
|
|
2333
|
-
}).from(usersTable).where(
|
|
2490
|
+
}).from(usersTable).where(eq7(usersTable.email, emailAddress.toLowerCase())).limit(1);
|
|
2334
2491
|
if (!user) {
|
|
2335
2492
|
return { blocked: false };
|
|
2336
2493
|
}
|
|
@@ -2779,7 +2936,7 @@ var EmailService = class {
|
|
|
2779
2936
|
import { Resend as Resend2 } from "resend";
|
|
2780
2937
|
|
|
2781
2938
|
// src/lib/email/webhook-handler.ts
|
|
2782
|
-
import { eq as
|
|
2939
|
+
import { eq as eq8 } from "drizzle-orm";
|
|
2783
2940
|
async function handleWebhookEvent(payload, db, tables2) {
|
|
2784
2941
|
const { type, data } = payload;
|
|
2785
2942
|
const emailAddress = data.to[0];
|
|
@@ -2838,7 +2995,7 @@ async function handleBounce(emailAddress, emailId, bounce, db, usersTable) {
|
|
|
2838
2995
|
await db.update(usersTable).set({
|
|
2839
2996
|
emailBounced: true,
|
|
2840
2997
|
emailBouncedAt: /* @__PURE__ */ new Date()
|
|
2841
|
-
}).where(
|
|
2998
|
+
}).where(eq8(usersTable.email, emailAddress.toLowerCase()));
|
|
2842
2999
|
logError(new Error("Email bounced"), {
|
|
2843
3000
|
context: "email_webhook",
|
|
2844
3001
|
emailAddress,
|
|
@@ -2854,7 +3011,7 @@ async function handleComplaint(emailAddress, emailId, db, usersTable) {
|
|
|
2854
3011
|
await db.update(usersTable).set({
|
|
2855
3012
|
emailComplained: true,
|
|
2856
3013
|
emailComplainedAt: /* @__PURE__ */ new Date()
|
|
2857
|
-
}).where(
|
|
3014
|
+
}).where(eq8(usersTable.email, emailAddress.toLowerCase()));
|
|
2858
3015
|
logError(new Error("Email spam complaint received"), {
|
|
2859
3016
|
context: "email_webhook",
|
|
2860
3017
|
emailAddress,
|
|
@@ -2876,15 +3033,15 @@ async function handleFailure(emailAddress, emailId, failed) {
|
|
|
2876
3033
|
}
|
|
2877
3034
|
|
|
2878
3035
|
// src/lib/email/monitoring.ts
|
|
2879
|
-
import { sql as
|
|
3036
|
+
import { sql as sql3 } from "drizzle-orm";
|
|
2880
3037
|
async function calculateBounceRate(db, emailEventsTable, hoursAgo = 24) {
|
|
2881
3038
|
const cutoff = new Date(Date.now() - hoursAgo * 60 * 60 * 1e3);
|
|
2882
3039
|
const [stats] = await db.select({
|
|
2883
|
-
totalSent:
|
|
2884
|
-
totalBounced:
|
|
3040
|
+
totalSent: sql3`count(*) filter (where event_type = 'email.sent')`.as("total_sent"),
|
|
3041
|
+
totalBounced: sql3`count(*) filter (where event_type = 'email.bounced')`.as(
|
|
2885
3042
|
"total_bounced"
|
|
2886
3043
|
)
|
|
2887
|
-
}).from(emailEventsTable).where(
|
|
3044
|
+
}).from(emailEventsTable).where(sql3`created_at > ${cutoff}`);
|
|
2888
3045
|
if (!stats || stats.totalSent === 0) {
|
|
2889
3046
|
return 0;
|
|
2890
3047
|
}
|
|
@@ -2903,11 +3060,11 @@ async function calculateBounceRate(db, emailEventsTable, hoursAgo = 24) {
|
|
|
2903
3060
|
async function calculateComplaintRate(db, emailEventsTable, hoursAgo = 24) {
|
|
2904
3061
|
const cutoff = new Date(Date.now() - hoursAgo * 60 * 60 * 1e3);
|
|
2905
3062
|
const [stats] = await db.select({
|
|
2906
|
-
totalSent:
|
|
2907
|
-
totalComplained:
|
|
3063
|
+
totalSent: sql3`count(*) filter (where event_type = 'email.sent')`.as("total_sent"),
|
|
3064
|
+
totalComplained: sql3`count(*) filter (where event_type = 'email.complained')`.as(
|
|
2908
3065
|
"total_complained"
|
|
2909
3066
|
)
|
|
2910
|
-
}).from(emailEventsTable).where(
|
|
3067
|
+
}).from(emailEventsTable).where(sql3`created_at > ${cutoff}`);
|
|
2911
3068
|
if (!stats || stats.totalSent === 0) {
|
|
2912
3069
|
return 0;
|
|
2913
3070
|
}
|
|
@@ -2924,16 +3081,34 @@ async function calculateComplaintRate(db, emailEventsTable, hoursAgo = 24) {
|
|
|
2924
3081
|
return complaintRate;
|
|
2925
3082
|
}
|
|
2926
3083
|
|
|
3084
|
+
// src/core/invitations.ts
|
|
3085
|
+
async function verifyInvitation(invitations, args) {
|
|
3086
|
+
if (!invitations || !args.token) return null;
|
|
3087
|
+
const email = args.email.toLowerCase();
|
|
3088
|
+
try {
|
|
3089
|
+
const match = await invitations.verify({ token: args.token, email, db: args.db, env: args.env });
|
|
3090
|
+
return match && match.email.toLowerCase() === email ? args.token : null;
|
|
3091
|
+
} catch (err) {
|
|
3092
|
+
logError(err, { context: "invitation_verify_failed", email });
|
|
3093
|
+
return null;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
async function acceptInvitation(invitations, args) {
|
|
3097
|
+
if (!invitations?.onAccepted) return;
|
|
3098
|
+
try {
|
|
3099
|
+
await invitations.onAccepted({ token: args.token, user: args.user, db: args.db, env: args.env });
|
|
3100
|
+
} catch (err) {
|
|
3101
|
+
logError(err, { context: args.context, userId: args.user.id });
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
|
|
2927
3105
|
// src/routes/schemas.ts
|
|
2928
3106
|
import { z } from "@hono/zod-openapi";
|
|
2929
3107
|
var signupRequestSchema = z.object({
|
|
2930
3108
|
email: z.string().email().openapi({ example: "user@example.com" }),
|
|
2931
|
-
|
|
2932
|
-
password: z.string().min(8).optional().openapi({ example: "SecurePass123!" }),
|
|
3109
|
+
password: z.string().min(8).openapi({ example: "SecurePass123!" }),
|
|
2933
3110
|
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-..." }),
|
|
3111
|
+
turnstileToken: z.string().openapi({ example: "XXXX.DUMMY.TOKEN.XXXX" }),
|
|
2937
3112
|
// App invitation token (from /signup?token=...). When it validates for this
|
|
2938
3113
|
// email, the account is created already-verified with no verification email.
|
|
2939
3114
|
invitationToken: z.string().optional().openapi({ example: "iIB8tDJJ..." })
|
|
@@ -3059,76 +3234,22 @@ var signupRoute = createRoute({
|
|
|
3059
3234
|
description: "Invalid input",
|
|
3060
3235
|
content: { "application/json": { schema: errorResponseSchema } }
|
|
3061
3236
|
},
|
|
3237
|
+
403: {
|
|
3238
|
+
description: "Sign-up is by invitation only and no valid invitation was given",
|
|
3239
|
+
content: { "application/json": { schema: errorResponseSchema } }
|
|
3240
|
+
},
|
|
3062
3241
|
409: {
|
|
3063
3242
|
description: "Email already registered",
|
|
3064
3243
|
content: { "application/json": { schema: errorResponseSchema } }
|
|
3065
3244
|
}
|
|
3066
3245
|
}
|
|
3067
3246
|
});
|
|
3247
|
+
var INVITATION_ONLY = "Sign-up is by invitation only";
|
|
3248
|
+
var INVITATION_INVALID = "This invitation is invalid, expired, or for a different email address";
|
|
3068
3249
|
var signupHandler = async (c) => {
|
|
3069
|
-
const { email, password, name, turnstileToken,
|
|
3070
|
-
const { db: database, schema, emailBranding, invitations } = getAuthContext(c);
|
|
3250
|
+
const { email, password, name, turnstileToken, invitationToken } = c.req.valid("json");
|
|
3251
|
+
const { db: database, schema, env: authEnv, emailBranding, invitations, signup } = getAuthContext(c);
|
|
3071
3252
|
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
3253
|
const turnstileValid = await verifyTurnstileToken(
|
|
3133
3254
|
turnstileToken,
|
|
3134
3255
|
env.TURNSTILE_SECRET_KEY,
|
|
@@ -3138,8 +3259,22 @@ var signupHandler = async (c) => {
|
|
|
3138
3259
|
if (!turnstileValid) {
|
|
3139
3260
|
return problems.badRequest(c, "Invalid captcha");
|
|
3140
3261
|
}
|
|
3262
|
+
const invited = await verifyInvitation(invitations, {
|
|
3263
|
+
token: invitationToken,
|
|
3264
|
+
email,
|
|
3265
|
+
db: database,
|
|
3266
|
+
env: authEnv
|
|
3267
|
+
});
|
|
3268
|
+
if (invitationToken && invitations && !invited && signup !== "invitation") {
|
|
3269
|
+
return problems.badRequest(c, INVITATION_INVALID);
|
|
3270
|
+
}
|
|
3271
|
+
if (signup === "invitation" && !invited) {
|
|
3272
|
+
logSecurityEvent("signup_refused_no_invitation", "medium", { email: email.toLowerCase() });
|
|
3273
|
+
return problems.forbidden(c, invitationToken ? INVITATION_INVALID : INVITATION_ONLY);
|
|
3274
|
+
}
|
|
3275
|
+
const invitedVerified = invited !== null;
|
|
3141
3276
|
const existingUser = await database.query.users.findFirst({
|
|
3142
|
-
where:
|
|
3277
|
+
where: eq9(schema.users.email, email.toLowerCase())
|
|
3143
3278
|
});
|
|
3144
3279
|
if (existingUser) {
|
|
3145
3280
|
return problems.conflict(c, "Email already registered");
|
|
@@ -3149,22 +3284,6 @@ var signupHandler = async (c) => {
|
|
|
3149
3284
|
return problems.badRequest(c, passwordValidation.error || "Invalid password");
|
|
3150
3285
|
}
|
|
3151
3286
|
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
3287
|
const [newUser] = await database.insert(schema.users).values({
|
|
3169
3288
|
email: email.toLowerCase(),
|
|
3170
3289
|
name: name || null,
|
|
@@ -3176,7 +3295,7 @@ var signupHandler = async (c) => {
|
|
|
3176
3295
|
if (!invitedVerified) {
|
|
3177
3296
|
const emailToken = generateSecureToken(32);
|
|
3178
3297
|
const tokenHash = await hashToken(emailToken);
|
|
3179
|
-
const expiresAt = Date.now() +
|
|
3298
|
+
const expiresAt = Date.now() + AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60 * 60 * 1e3;
|
|
3180
3299
|
await database.insert(schema.emailVerificationTokens).values({
|
|
3181
3300
|
tokenHash,
|
|
3182
3301
|
userId: newUser.id,
|
|
@@ -3197,17 +3316,14 @@ var signupHandler = async (c) => {
|
|
|
3197
3316
|
schema.users
|
|
3198
3317
|
);
|
|
3199
3318
|
}
|
|
3200
|
-
if (
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
} catch (err) {
|
|
3209
|
-
logError(err, { context: "signup_invitation_onAccepted_failed", userId: newUser.id });
|
|
3210
|
-
}
|
|
3319
|
+
if (invited) {
|
|
3320
|
+
await acceptInvitation(invitations, {
|
|
3321
|
+
token: invited,
|
|
3322
|
+
user: { id: newUser.id, email: newUser.email, name: newUser.name },
|
|
3323
|
+
db: database,
|
|
3324
|
+
env: authEnv,
|
|
3325
|
+
context: "signup_invitation_onAccepted_failed"
|
|
3326
|
+
});
|
|
3211
3327
|
}
|
|
3212
3328
|
const fingerprint = await generateFingerprint(c.req.raw);
|
|
3213
3329
|
const ipAddress = getClientIp(c.req.raw);
|
|
@@ -3240,25 +3356,25 @@ var signupMiddleware = [
|
|
|
3240
3356
|
|
|
3241
3357
|
// src/routes/login.ts
|
|
3242
3358
|
import { createRoute as createRoute2 } from "@hono/zod-openapi";
|
|
3243
|
-
import { eq as
|
|
3359
|
+
import { eq as eq11 } from "drizzle-orm";
|
|
3244
3360
|
import { getCookie } from "hono/cookie";
|
|
3245
3361
|
|
|
3246
3362
|
// src/routes/2fa/helpers.ts
|
|
3247
|
-
import { eq as
|
|
3363
|
+
import { eq as eq10, and as and4, isNull as isNull2 } from "drizzle-orm";
|
|
3248
3364
|
async function getUserEnabled2faMethods(database, userId, user2faMethodsTable) {
|
|
3249
3365
|
return database.select({
|
|
3250
3366
|
method: user2faMethodsTable.method,
|
|
3251
3367
|
isPrimary: user2faMethodsTable.isPrimary,
|
|
3252
3368
|
totpSecret: user2faMethodsTable.totpSecret,
|
|
3253
3369
|
lastTotpCounter: user2faMethodsTable.lastTotpCounter
|
|
3254
|
-
}).from(user2faMethodsTable).where(
|
|
3370
|
+
}).from(user2faMethodsTable).where(eq10(user2faMethodsTable.userId, userId));
|
|
3255
3371
|
}
|
|
3256
3372
|
function generateEmailOtp() {
|
|
3257
3373
|
const bytes = crypto.getRandomValues(new Uint8Array(4));
|
|
3258
3374
|
const num = (bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]) >>> 0;
|
|
3259
3375
|
return String(num % 1e6).padStart(6, "0");
|
|
3260
3376
|
}
|
|
3261
|
-
var EMAIL_CODE_TTL_SECONDS =
|
|
3377
|
+
var EMAIL_CODE_TTL_SECONDS = AUTH_DEFAULTS.TWO_FACTOR_EMAIL_CODE_TTL_MINUTES * 60;
|
|
3262
3378
|
async function sendEmailChallengeCode(env, database, usersTable, user, emailBranding) {
|
|
3263
3379
|
const code = generateEmailOtp();
|
|
3264
3380
|
await env.OAUTH_STATES.put(`email_2fa_challenge:${user.id}`, await hashToken(code), {
|
|
@@ -3285,7 +3401,7 @@ async function verifyAny2faCode(database, env, userId, code, method, tables2) {
|
|
|
3285
3401
|
const secret = await decryptTotpSecret(totpMethod.totpSecret, env.TOTP_ENCRYPTION_KEY);
|
|
3286
3402
|
const result = verifyTotpCode(secret, code, totpMethod.lastTotpCounter);
|
|
3287
3403
|
if (result.valid) {
|
|
3288
|
-
await database.update(user2faMethodsTable).set({ lastTotpCounter: result.counter }).where(
|
|
3404
|
+
await database.update(user2faMethodsTable).set({ lastTotpCounter: result.counter }).where(and4(eq10(user2faMethodsTable.userId, userId), eq10(user2faMethodsTable.method, "totp")));
|
|
3289
3405
|
return { valid: true };
|
|
3290
3406
|
}
|
|
3291
3407
|
return { valid: false };
|
|
@@ -3297,18 +3413,18 @@ async function verifyAny2faCode(database, env, userId, code, method, tables2) {
|
|
|
3297
3413
|
const kvKey = `email_2fa_challenge:${userId}`;
|
|
3298
3414
|
const storedHash = await env.OAUTH_STATES.get(kvKey);
|
|
3299
3415
|
if (!storedHash) return { valid: false };
|
|
3300
|
-
const
|
|
3301
|
-
if (
|
|
3416
|
+
const codeHash2 = await hashToken(code);
|
|
3417
|
+
if (codeHash2 === storedHash) {
|
|
3302
3418
|
await env.OAUTH_STATES.delete(kvKey);
|
|
3303
3419
|
return { valid: true };
|
|
3304
3420
|
}
|
|
3305
3421
|
return { valid: false };
|
|
3306
3422
|
}
|
|
3307
3423
|
if (method === "backup") {
|
|
3308
|
-
const backupCodes = await database.select({ id: userBackupCodesTable.id, codeHash: userBackupCodesTable.codeHash }).from(userBackupCodesTable).where(
|
|
3424
|
+
const backupCodes = await database.select({ id: userBackupCodesTable.id, codeHash: userBackupCodesTable.codeHash }).from(userBackupCodesTable).where(and4(eq10(userBackupCodesTable.userId, userId), isNull2(userBackupCodesTable.usedAt)));
|
|
3309
3425
|
for (const bc of backupCodes) {
|
|
3310
3426
|
if (await verifyBackupCode(code, bc.codeHash)) {
|
|
3311
|
-
await database.update(userBackupCodesTable).set({ usedAt: Date.now() }).where(
|
|
3427
|
+
await database.update(userBackupCodesTable).set({ usedAt: Date.now() }).where(eq10(userBackupCodesTable.id, bc.id));
|
|
3312
3428
|
logSecurityEvent("2fa_backup_code_used", "medium", { userId, codeId: bc.id });
|
|
3313
3429
|
return { valid: true };
|
|
3314
3430
|
}
|
|
@@ -3364,7 +3480,7 @@ var loginHandler = async (c) => {
|
|
|
3364
3480
|
return problems.badRequest(c, "Invalid captcha");
|
|
3365
3481
|
}
|
|
3366
3482
|
const user = await database.query.users.findFirst({
|
|
3367
|
-
where:
|
|
3483
|
+
where: eq11(schema.users.email, email.toLowerCase())
|
|
3368
3484
|
});
|
|
3369
3485
|
if (!user) {
|
|
3370
3486
|
return problems.unauthorized(c, "Invalid email or password");
|
|
@@ -3473,7 +3589,7 @@ var loginMiddleware = [
|
|
|
3473
3589
|
|
|
3474
3590
|
// src/routes/logout.ts
|
|
3475
3591
|
import { createRoute as createRoute3 } from "@hono/zod-openapi";
|
|
3476
|
-
import { eq as
|
|
3592
|
+
import { eq as eq12 } from "drizzle-orm";
|
|
3477
3593
|
var logoutRoute = createRoute3({
|
|
3478
3594
|
method: "post",
|
|
3479
3595
|
path: "/logout",
|
|
@@ -3496,7 +3612,7 @@ var logoutHandler = async (c) => {
|
|
|
3496
3612
|
const sessionIdMatch = cookieHeader.match(cookiePattern);
|
|
3497
3613
|
if (sessionIdMatch) {
|
|
3498
3614
|
const sessionId = sessionIdMatch[1];
|
|
3499
|
-
await database.delete(schema.sessions).where(
|
|
3615
|
+
await database.delete(schema.sessions).where(eq12(schema.sessions.id, sessionId));
|
|
3500
3616
|
}
|
|
3501
3617
|
}
|
|
3502
3618
|
c.header("Set-Cookie", clearSessionCookie(c.env), { append: true });
|
|
@@ -3505,7 +3621,7 @@ var logoutHandler = async (c) => {
|
|
|
3505
3621
|
|
|
3506
3622
|
// src/routes/me.ts
|
|
3507
3623
|
import { createRoute as createRoute4 } from "@hono/zod-openapi";
|
|
3508
|
-
import { eq as
|
|
3624
|
+
import { eq as eq13 } from "drizzle-orm";
|
|
3509
3625
|
var meRoute = createRoute4({
|
|
3510
3626
|
method: "get",
|
|
3511
3627
|
path: "/me",
|
|
@@ -3533,7 +3649,7 @@ var meHandler = async (c) => {
|
|
|
3533
3649
|
if (!userId) return problems.unauthorized(c, "Not authenticated");
|
|
3534
3650
|
const { db: database, schema } = getAuthContext(c);
|
|
3535
3651
|
const user = await database.query.users.findFirst({
|
|
3536
|
-
where:
|
|
3652
|
+
where: eq13(schema.users.id, userId)
|
|
3537
3653
|
});
|
|
3538
3654
|
if (!user) {
|
|
3539
3655
|
return problems.notFound(c, "User not found");
|
|
@@ -3559,7 +3675,7 @@ var meMiddleware = [requireAuth];
|
|
|
3559
3675
|
|
|
3560
3676
|
// src/routes/verify-email.ts
|
|
3561
3677
|
import { createRoute as createRoute5 } from "@hono/zod-openapi";
|
|
3562
|
-
import { eq as
|
|
3678
|
+
import { eq as eq14 } from "drizzle-orm";
|
|
3563
3679
|
var verifyEmailRoute = createRoute5({
|
|
3564
3680
|
method: "post",
|
|
3565
3681
|
path: "/verify-email",
|
|
@@ -3588,17 +3704,17 @@ var verifyEmailHandler = async (c) => {
|
|
|
3588
3704
|
const { db: database, schema } = getAuthContext(c);
|
|
3589
3705
|
const tokenHash = await hashToken(token);
|
|
3590
3706
|
const verificationToken = await database.query.emailVerificationTokens.findFirst({
|
|
3591
|
-
where:
|
|
3707
|
+
where: eq14(schema.emailVerificationTokens.tokenHash, tokenHash)
|
|
3592
3708
|
});
|
|
3593
3709
|
if (!verificationToken) {
|
|
3594
3710
|
return problems.badRequest(c, "Invalid or expired verification token");
|
|
3595
3711
|
}
|
|
3596
3712
|
if (verificationToken.expiresAt < Date.now()) {
|
|
3597
|
-
await database.delete(schema.emailVerificationTokens).where(
|
|
3713
|
+
await database.delete(schema.emailVerificationTokens).where(eq14(schema.emailVerificationTokens.tokenHash, tokenHash));
|
|
3598
3714
|
return problems.badRequest(c, "Verification token has expired");
|
|
3599
3715
|
}
|
|
3600
|
-
await database.update(schema.users).set({ emailVerified: true }).where(
|
|
3601
|
-
await database.delete(schema.emailVerificationTokens).where(
|
|
3716
|
+
await database.update(schema.users).set({ emailVerified: true }).where(eq14(schema.users.id, verificationToken.userId));
|
|
3717
|
+
await database.delete(schema.emailVerificationTokens).where(eq14(schema.emailVerificationTokens.tokenHash, tokenHash));
|
|
3602
3718
|
logSecurityEvent("email_verified", "low", { userId: verificationToken.userId });
|
|
3603
3719
|
return c.json({ success: true, redirect: "/" });
|
|
3604
3720
|
};
|
|
@@ -3607,18 +3723,18 @@ var verifyEmailHandler = async (c) => {
|
|
|
3607
3723
|
import { createRoute as createRoute6 } from "@hono/zod-openapi";
|
|
3608
3724
|
|
|
3609
3725
|
// src/core/password-reset.ts
|
|
3610
|
-
import { eq as
|
|
3726
|
+
import { eq as eq15, and as and5, gt as gt4, lt as lt2, sql as sql4 } from "drizzle-orm";
|
|
3611
3727
|
var TOKEN_EXPIRATION_MS = AUTH_DEFAULTS.PASSWORD_RESET_TTL_MINUTES * 60 * 1e3;
|
|
3612
3728
|
async function createPasswordResetToken(db, tables2, email) {
|
|
3613
3729
|
const { users, passwordResetTokens } = tables2;
|
|
3614
|
-
const [user] = await db.select({ id: users.id, emailVerified: users.emailVerified }).from(users).where(
|
|
3730
|
+
const [user] = await db.select({ id: users.id, emailVerified: users.emailVerified }).from(users).where(eq15(users.email, email.toLowerCase())).limit(1);
|
|
3615
3731
|
if (!user) {
|
|
3616
3732
|
return null;
|
|
3617
3733
|
}
|
|
3618
3734
|
if (!user.emailVerified) {
|
|
3619
3735
|
return null;
|
|
3620
3736
|
}
|
|
3621
|
-
await db.delete(passwordResetTokens).where(
|
|
3737
|
+
await db.delete(passwordResetTokens).where(eq15(passwordResetTokens.userId, user.id));
|
|
3622
3738
|
const token = generateSecureToken(64);
|
|
3623
3739
|
const tokenHash = await hashToken(token);
|
|
3624
3740
|
const expiresAt = Date.now() + TOKEN_EXPIRATION_MS;
|
|
@@ -3640,10 +3756,10 @@ async function validateResetToken(db, tables2, token) {
|
|
|
3640
3756
|
used: passwordResetTokens.used,
|
|
3641
3757
|
expiresAt: passwordResetTokens.expiresAt
|
|
3642
3758
|
}).from(passwordResetTokens).where(
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3759
|
+
and5(
|
|
3760
|
+
eq15(passwordResetTokens.tokenHash, tokenHash),
|
|
3761
|
+
eq15(passwordResetTokens.used, false),
|
|
3762
|
+
gt4(passwordResetTokens.expiresAt, Date.now())
|
|
3647
3763
|
)
|
|
3648
3764
|
).limit(1);
|
|
3649
3765
|
if (!resetToken) {
|
|
@@ -3664,11 +3780,11 @@ async function resetPassword(db, tables2, token, newPassword, pepper, pepperKid
|
|
|
3664
3780
|
await db.update(users).set({
|
|
3665
3781
|
hashedPassword,
|
|
3666
3782
|
pepperKid,
|
|
3667
|
-
sessionVersion:
|
|
3668
|
-
}).where(
|
|
3783
|
+
sessionVersion: sql4`${users.sessionVersion} + 1`
|
|
3784
|
+
}).where(eq15(users.id, userId));
|
|
3669
3785
|
const tokenHash = await hashToken(token);
|
|
3670
|
-
await db.update(passwordResetTokens).set({ used: true }).where(
|
|
3671
|
-
await db.delete(sessions).where(
|
|
3786
|
+
await db.update(passwordResetTokens).set({ used: true }).where(eq15(passwordResetTokens.tokenHash, tokenHash));
|
|
3787
|
+
await db.delete(sessions).where(eq15(sessions.userId, userId));
|
|
3672
3788
|
logger_default.info("Password reset successful", { userId });
|
|
3673
3789
|
return { success: true };
|
|
3674
3790
|
}
|
|
@@ -3816,14 +3932,14 @@ var resetPasswordMiddleware = [
|
|
|
3816
3932
|
import { createRoute as createRoute8 } from "@hono/zod-openapi";
|
|
3817
3933
|
|
|
3818
3934
|
// src/core/change-password.ts
|
|
3819
|
-
import { eq as
|
|
3935
|
+
import { eq as eq16, and as and6, ne } from "drizzle-orm";
|
|
3820
3936
|
async function changePassword(params) {
|
|
3821
3937
|
const { userId, currentPassword, newPassword, currentSessionId, pepper, db, tables: tables2 } = params;
|
|
3822
3938
|
const { users, sessions } = tables2;
|
|
3823
3939
|
const [user] = await db.select({
|
|
3824
3940
|
id: users.id,
|
|
3825
3941
|
hashedPassword: users.hashedPassword
|
|
3826
|
-
}).from(users).where(
|
|
3942
|
+
}).from(users).where(eq16(users.id, userId)).limit(1);
|
|
3827
3943
|
if (!user || !user.hashedPassword) {
|
|
3828
3944
|
return { success: false, error: "User not found" };
|
|
3829
3945
|
}
|
|
@@ -3839,8 +3955,8 @@ async function changePassword(params) {
|
|
|
3839
3955
|
await db.update(users).set({
|
|
3840
3956
|
hashedPassword: newHashedPassword,
|
|
3841
3957
|
updatedAt: /* @__PURE__ */ new Date()
|
|
3842
|
-
}).where(
|
|
3843
|
-
await db.delete(sessions).where(
|
|
3958
|
+
}).where(eq16(users.id, userId));
|
|
3959
|
+
await db.delete(sessions).where(and6(eq16(sessions.userId, userId), ne(sessions.id, currentSessionId)));
|
|
3844
3960
|
logSecurityEvent("password_changed", "low", {
|
|
3845
3961
|
userId
|
|
3846
3962
|
});
|
|
@@ -4113,7 +4229,7 @@ var cancelEmailChangeHandler = async (c) => {
|
|
|
4113
4229
|
// src/routes/delete-account.ts
|
|
4114
4230
|
import { createRoute as createRoute13 } from "@hono/zod-openapi";
|
|
4115
4231
|
import { z as z5 } from "@hono/zod-openapi";
|
|
4116
|
-
import { eq as
|
|
4232
|
+
import { eq as eq17 } from "drizzle-orm";
|
|
4117
4233
|
var deleteAccountRequestSchema = z5.object({
|
|
4118
4234
|
password: z5.string().min(1).openapi({ example: "CurrentPass123!" })
|
|
4119
4235
|
}).openapi("DeleteAccountRequest");
|
|
@@ -4161,7 +4277,7 @@ var deleteAccountHandler = async (c) => {
|
|
|
4161
4277
|
const [user] = await database.select({
|
|
4162
4278
|
id: schema.users.id,
|
|
4163
4279
|
hashedPassword: schema.users.hashedPassword
|
|
4164
|
-
}).from(schema.users).where(
|
|
4280
|
+
}).from(schema.users).where(eq17(schema.users.id, userId)).limit(1);
|
|
4165
4281
|
if (!user || !user.hashedPassword) {
|
|
4166
4282
|
return problems.unauthorized(c, "Invalid credentials");
|
|
4167
4283
|
}
|
|
@@ -4176,8 +4292,8 @@ var deleteAccountHandler = async (c) => {
|
|
|
4176
4292
|
}
|
|
4177
4293
|
const now = /* @__PURE__ */ new Date();
|
|
4178
4294
|
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(
|
|
4295
|
+
await database.update(schema.users).set({ deletedAt: now, scheduledPurgeAt: purgeAt, updatedAt: now }).where(eq17(schema.users.id, userId));
|
|
4296
|
+
await database.delete(schema.sessions).where(eq17(schema.sessions.userId, userId));
|
|
4181
4297
|
logSecurityEvent("account_deletion_requested", "high", { userId });
|
|
4182
4298
|
return c.json(
|
|
4183
4299
|
{
|
|
@@ -4198,7 +4314,7 @@ var deleteAccountMiddleware = [requireAuth];
|
|
|
4198
4314
|
// src/routes/refresh.ts
|
|
4199
4315
|
import { createRoute as createRoute14 } from "@hono/zod-openapi";
|
|
4200
4316
|
import { z as z6 } from "@hono/zod-openapi";
|
|
4201
|
-
import { eq as
|
|
4317
|
+
import { eq as eq18, and as and7, gt as gt5 } from "drizzle-orm";
|
|
4202
4318
|
var refreshRequestSchema = z6.object({
|
|
4203
4319
|
refreshToken: z6.string().min(1).openapi({ example: "abc123..." })
|
|
4204
4320
|
}).openapi("RefreshRequest");
|
|
@@ -4238,7 +4354,7 @@ var refreshHandler = async (c) => {
|
|
|
4238
4354
|
expiresAt: schema.sessions.expiresAt,
|
|
4239
4355
|
fingerprint: schema.sessions.fingerprint,
|
|
4240
4356
|
ipAddress: schema.sessions.ipAddress
|
|
4241
|
-
}).from(schema.sessions).where(
|
|
4357
|
+
}).from(schema.sessions).where(and7(eq18(schema.sessions.id, refreshToken), gt5(schema.sessions.expiresAt, Date.now()))).limit(1);
|
|
4242
4358
|
if (!session) {
|
|
4243
4359
|
return problems.unauthorized(c, "Invalid or expired refresh token");
|
|
4244
4360
|
}
|
|
@@ -4261,7 +4377,7 @@ var refreshMiddleware = [];
|
|
|
4261
4377
|
// src/routes/resend-verification.ts
|
|
4262
4378
|
import { createRoute as createRoute15 } from "@hono/zod-openapi";
|
|
4263
4379
|
import { z as z7 } from "@hono/zod-openapi";
|
|
4264
|
-
import { eq as
|
|
4380
|
+
import { eq as eq19 } from "drizzle-orm";
|
|
4265
4381
|
var resendVerificationResponseSchema = z7.object({
|
|
4266
4382
|
success: z7.boolean().openapi({ example: true }),
|
|
4267
4383
|
message: z7.string().openapi({ example: "Verification email sent" })
|
|
@@ -4306,17 +4422,17 @@ var resendVerificationHandler = async (c) => {
|
|
|
4306
4422
|
email: schema.users.email,
|
|
4307
4423
|
name: schema.users.name,
|
|
4308
4424
|
emailVerified: schema.users.emailVerified
|
|
4309
|
-
}).from(schema.users).where(
|
|
4425
|
+
}).from(schema.users).where(eq19(schema.users.id, userId)).limit(1);
|
|
4310
4426
|
if (!user) {
|
|
4311
4427
|
return problems.unauthorized(c, "User not found");
|
|
4312
4428
|
}
|
|
4313
4429
|
if (user.emailVerified) {
|
|
4314
4430
|
return problems.badRequest(c, "Email is already verified");
|
|
4315
4431
|
}
|
|
4316
|
-
await db.delete(schema.emailVerificationTokens).where(
|
|
4432
|
+
await db.delete(schema.emailVerificationTokens).where(eq19(schema.emailVerificationTokens.userId, userId));
|
|
4317
4433
|
const emailToken = generateSecureToken(32);
|
|
4318
4434
|
const tokenHash = await hashToken(emailToken);
|
|
4319
|
-
const expiresAt = Date.now() +
|
|
4435
|
+
const expiresAt = Date.now() + AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60 * 60 * 1e3;
|
|
4320
4436
|
await db.insert(schema.emailVerificationTokens).values({
|
|
4321
4437
|
tokenHash,
|
|
4322
4438
|
userId: user.id,
|
|
@@ -4358,7 +4474,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
|
|
|
4358
4474
|
|
|
4359
4475
|
// src/routes/2fa/status.ts
|
|
4360
4476
|
import { createRoute as createRoute16 } from "@hono/zod-openapi";
|
|
4361
|
-
import { eq as
|
|
4477
|
+
import { eq as eq20, and as and8, isNull as isNull3 } from "drizzle-orm";
|
|
4362
4478
|
|
|
4363
4479
|
// src/routes/2fa/schemas.ts
|
|
4364
4480
|
import { z as z8 } from "@hono/zod-openapi";
|
|
@@ -4519,7 +4635,7 @@ var statusHandler = async (c) => {
|
|
|
4519
4635
|
if (!userId) return problems.unauthorized(c);
|
|
4520
4636
|
const { db, schema } = getAuthContext(c);
|
|
4521
4637
|
const methods = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4522
|
-
const backupCodes = await db.select({ id: schema.userBackupCodes.id }).from(schema.userBackupCodes).where(
|
|
4638
|
+
const backupCodes = await db.select({ id: schema.userBackupCodes.id }).from(schema.userBackupCodes).where(and8(eq20(schema.userBackupCodes.userId, userId), isNull3(schema.userBackupCodes.usedAt)));
|
|
4523
4639
|
c.header("Cache-Control", "private, max-age=60");
|
|
4524
4640
|
c.header("Vary", "Cookie");
|
|
4525
4641
|
return c.json({
|
|
@@ -4531,7 +4647,7 @@ var statusHandler = async (c) => {
|
|
|
4531
4647
|
|
|
4532
4648
|
// src/routes/2fa/totp-setup.ts
|
|
4533
4649
|
import { createRoute as createRoute17 } from "@hono/zod-openapi";
|
|
4534
|
-
import { eq as
|
|
4650
|
+
import { eq as eq21, and as and9 } from "drizzle-orm";
|
|
4535
4651
|
var totpSetupRoute = createRoute17({
|
|
4536
4652
|
method: "post",
|
|
4537
4653
|
path: "/totp/setup",
|
|
@@ -4561,11 +4677,11 @@ var totpSetupHandler = async (c) => {
|
|
|
4561
4677
|
const userId = c.get("userId");
|
|
4562
4678
|
if (!userId) return problems.unauthorized(c);
|
|
4563
4679
|
const { db, schema, env } = getAuthContext(c);
|
|
4564
|
-
const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where(
|
|
4680
|
+
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
4681
|
if (existing.length > 0) {
|
|
4566
4682
|
return problems.badRequest(c, "TOTP already enabled");
|
|
4567
4683
|
}
|
|
4568
|
-
const [user] = await db.select({ email: schema.users.email }).from(schema.users).where(
|
|
4684
|
+
const [user] = await db.select({ email: schema.users.email }).from(schema.users).where(eq21(schema.users.id, userId)).limit(1);
|
|
4569
4685
|
if (!user) return problems.notFound(c, "User not found");
|
|
4570
4686
|
const secret = generateTotpSecret();
|
|
4571
4687
|
const qrCodeUri = generateQrCodeUri(secret, user.email);
|
|
@@ -4576,7 +4692,7 @@ var totpSetupHandler = async (c) => {
|
|
|
4576
4692
|
|
|
4577
4693
|
// src/routes/2fa/totp-verify.ts
|
|
4578
4694
|
import { createRoute as createRoute18 } from "@hono/zod-openapi";
|
|
4579
|
-
import { eq as
|
|
4695
|
+
import { eq as eq22 } from "drizzle-orm";
|
|
4580
4696
|
var totpVerifyRoute = createRoute18({
|
|
4581
4697
|
method: "post",
|
|
4582
4698
|
path: "/totp/verify",
|
|
@@ -4622,7 +4738,7 @@ var totpVerifyHandler = async (c) => {
|
|
|
4622
4738
|
logSecurityEvent("2fa_totp_setup_failed", "medium", { userId });
|
|
4623
4739
|
return problems.badRequest(c, "Invalid code");
|
|
4624
4740
|
}
|
|
4625
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
4741
|
+
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
4742
|
const encryptionKey = env.TOTP_ENCRYPTION_KEY;
|
|
4627
4743
|
if (!encryptionKey) {
|
|
4628
4744
|
logger_default.error("TOTP_ENCRYPTION_KEY not configured");
|
|
@@ -4670,7 +4786,7 @@ var totpVerifyHandler = async (c) => {
|
|
|
4670
4786
|
|
|
4671
4787
|
// src/routes/2fa/totp-disable.ts
|
|
4672
4788
|
import { createRoute as createRoute19 } from "@hono/zod-openapi";
|
|
4673
|
-
import { eq as
|
|
4789
|
+
import { eq as eq23, and as and10 } from "drizzle-orm";
|
|
4674
4790
|
var totpDisableRoute = createRoute19({
|
|
4675
4791
|
method: "post",
|
|
4676
4792
|
path: "/totp/disable",
|
|
@@ -4713,13 +4829,13 @@ var totpDisableHandler = async (c) => {
|
|
|
4713
4829
|
logSecurityEvent("2fa_disable_failed", "medium", { userId, method: "totp" });
|
|
4714
4830
|
return problems.badRequest(c, "Invalid verification code");
|
|
4715
4831
|
}
|
|
4716
|
-
await db.delete(schema.user2faMethods).where(
|
|
4832
|
+
await db.delete(schema.user2faMethods).where(and10(eq23(schema.user2faMethods.userId, userId), eq23(schema.user2faMethods.method, "totp")));
|
|
4717
4833
|
const remaining = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4718
4834
|
if (remaining.length === 0) {
|
|
4719
|
-
await db.delete(schema.userBackupCodes).where(
|
|
4720
|
-
await db.delete(schema.userTrustedDevices).where(
|
|
4835
|
+
await db.delete(schema.userBackupCodes).where(eq23(schema.userBackupCodes.userId, userId));
|
|
4836
|
+
await db.delete(schema.userTrustedDevices).where(eq23(schema.userTrustedDevices.userId, userId));
|
|
4721
4837
|
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(
|
|
4838
|
+
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
4839
|
if (user) {
|
|
4724
4840
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
4725
4841
|
const firstName = user.name?.split(" ")[0] || null;
|
|
@@ -4738,7 +4854,7 @@ var totpDisableHandler = async (c) => {
|
|
|
4738
4854
|
|
|
4739
4855
|
// src/routes/2fa/email-setup.ts
|
|
4740
4856
|
import { createRoute as createRoute20 } from "@hono/zod-openapi";
|
|
4741
|
-
import { eq as
|
|
4857
|
+
import { eq as eq24, and as and11 } from "drizzle-orm";
|
|
4742
4858
|
var emailSetupRoute = createRoute20({
|
|
4743
4859
|
method: "post",
|
|
4744
4860
|
path: "/email/setup",
|
|
@@ -4768,15 +4884,15 @@ var emailSetupHandler = async (c) => {
|
|
|
4768
4884
|
const userId = c.get("userId");
|
|
4769
4885
|
if (!userId) return problems.unauthorized(c);
|
|
4770
4886
|
const { db, schema, env, emailBranding } = getAuthContext(c);
|
|
4771
|
-
const existing = await db.select({ id: schema.user2faMethods.id }).from(schema.user2faMethods).where(
|
|
4887
|
+
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
4888
|
if (existing.length > 0) {
|
|
4773
4889
|
return problems.badRequest(c, "Email 2FA already enabled");
|
|
4774
4890
|
}
|
|
4775
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
4891
|
+
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
4892
|
if (!user) return problems.notFound(c, "User not found");
|
|
4777
4893
|
const code = generateEmailOtp();
|
|
4778
|
-
const
|
|
4779
|
-
await env.OAUTH_STATES.put(`email_2fa_setup:${userId}`,
|
|
4894
|
+
const codeHash2 = await hashToken(code);
|
|
4895
|
+
await env.OAUTH_STATES.put(`email_2fa_setup:${userId}`, codeHash2, { expirationTtl: 300 });
|
|
4780
4896
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
4781
4897
|
const firstName = user.name?.split(" ")[0] || null;
|
|
4782
4898
|
await emailService.send2faCodeEmail({ email: user.email, firstName, code }, db, schema.users);
|
|
@@ -4786,7 +4902,7 @@ var emailSetupHandler = async (c) => {
|
|
|
4786
4902
|
|
|
4787
4903
|
// src/routes/2fa/email-verify.ts
|
|
4788
4904
|
import { createRoute as createRoute21 } from "@hono/zod-openapi";
|
|
4789
|
-
import { eq as
|
|
4905
|
+
import { eq as eq25 } from "drizzle-orm";
|
|
4790
4906
|
var emailVerifyRoute = createRoute21({
|
|
4791
4907
|
method: "post",
|
|
4792
4908
|
path: "/email/verify",
|
|
@@ -4823,12 +4939,12 @@ var emailVerifyHandler = async (c) => {
|
|
|
4823
4939
|
if (!storedHash) {
|
|
4824
4940
|
return problems.badRequest(c, "Code expired. Please request a new one.");
|
|
4825
4941
|
}
|
|
4826
|
-
const
|
|
4827
|
-
if (
|
|
4942
|
+
const codeHash2 = await hashToken(code);
|
|
4943
|
+
if (codeHash2 !== storedHash) {
|
|
4828
4944
|
logSecurityEvent("2fa_email_setup_failed", "medium", { userId });
|
|
4829
4945
|
return problems.badRequest(c, "Invalid code");
|
|
4830
4946
|
}
|
|
4831
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
4947
|
+
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
4948
|
const now = Date.now();
|
|
4833
4949
|
const existingMethods = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4834
4950
|
const isFirstMethod = existingMethods.length === 0;
|
|
@@ -4870,7 +4986,7 @@ var emailVerifyHandler = async (c) => {
|
|
|
4870
4986
|
|
|
4871
4987
|
// src/routes/2fa/email-send-code.ts
|
|
4872
4988
|
import { createRoute as createRoute22 } from "@hono/zod-openapi";
|
|
4873
|
-
import { eq as
|
|
4989
|
+
import { eq as eq26, and as and12 } from "drizzle-orm";
|
|
4874
4990
|
var emailSendCodeRoute = createRoute22({
|
|
4875
4991
|
method: "post",
|
|
4876
4992
|
path: "/email/send-code",
|
|
@@ -4900,15 +5016,15 @@ var emailSendCodeHandler = async (c) => {
|
|
|
4900
5016
|
const userId = c.get("userId");
|
|
4901
5017
|
if (!userId) return problems.unauthorized(c);
|
|
4902
5018
|
const { db, schema, env, emailBranding } = getAuthContext(c);
|
|
4903
|
-
const [emailMethod] = await db.select().from(schema.user2faMethods).where(
|
|
5019
|
+
const [emailMethod] = await db.select().from(schema.user2faMethods).where(and12(eq26(schema.user2faMethods.userId, userId), eq26(schema.user2faMethods.method, "email"))).limit(1);
|
|
4904
5020
|
if (!emailMethod) {
|
|
4905
5021
|
return problems.badRequest(c, "Email 2FA not enabled");
|
|
4906
5022
|
}
|
|
4907
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
5023
|
+
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
5024
|
if (!user) return problems.notFound(c, "User not found");
|
|
4909
5025
|
const code = generateEmailOtp();
|
|
4910
|
-
const
|
|
4911
|
-
await env.OAUTH_STATES.put(`email_2fa_challenge:${userId}`,
|
|
5026
|
+
const codeHash2 = await hashToken(code);
|
|
5027
|
+
await env.OAUTH_STATES.put(`email_2fa_challenge:${userId}`, codeHash2, {
|
|
4912
5028
|
expirationTtl: 300
|
|
4913
5029
|
});
|
|
4914
5030
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
@@ -4930,7 +5046,7 @@ var emailSendCodeHandler = async (c) => {
|
|
|
4930
5046
|
|
|
4931
5047
|
// src/routes/2fa/email-disable.ts
|
|
4932
5048
|
import { createRoute as createRoute23 } from "@hono/zod-openapi";
|
|
4933
|
-
import { eq as
|
|
5049
|
+
import { eq as eq27, and as and13 } from "drizzle-orm";
|
|
4934
5050
|
var emailDisableRoute = createRoute23({
|
|
4935
5051
|
method: "post",
|
|
4936
5052
|
path: "/email/disable",
|
|
@@ -4973,13 +5089,13 @@ var emailDisableHandler = async (c) => {
|
|
|
4973
5089
|
logSecurityEvent("2fa_disable_failed", "medium", { userId, method: "email" });
|
|
4974
5090
|
return problems.badRequest(c, "Invalid verification code");
|
|
4975
5091
|
}
|
|
4976
|
-
await db.delete(schema.user2faMethods).where(
|
|
5092
|
+
await db.delete(schema.user2faMethods).where(and13(eq27(schema.user2faMethods.userId, userId), eq27(schema.user2faMethods.method, "email")));
|
|
4977
5093
|
const remaining = await getUserEnabled2faMethods(db, userId, schema.user2faMethods);
|
|
4978
5094
|
if (remaining.length === 0) {
|
|
4979
|
-
await db.delete(schema.userBackupCodes).where(
|
|
4980
|
-
await db.delete(schema.userTrustedDevices).where(
|
|
5095
|
+
await db.delete(schema.userBackupCodes).where(eq27(schema.userBackupCodes.userId, userId));
|
|
5096
|
+
await db.delete(schema.userTrustedDevices).where(eq27(schema.userTrustedDevices.userId, userId));
|
|
4981
5097
|
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(
|
|
5098
|
+
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
5099
|
if (user) {
|
|
4984
5100
|
const emailService = new EmailService(env, void 0, emailBranding);
|
|
4985
5101
|
const firstName = user.name?.split(" ")[0] || null;
|
|
@@ -4998,7 +5114,7 @@ var emailDisableHandler = async (c) => {
|
|
|
4998
5114
|
|
|
4999
5115
|
// src/routes/2fa/trusted-devices.ts
|
|
5000
5116
|
import { createRoute as createRoute24, z as z9 } from "@hono/zod-openapi";
|
|
5001
|
-
import { eq as
|
|
5117
|
+
import { eq as eq28, and as and14, gt as gt6 } from "drizzle-orm";
|
|
5002
5118
|
var trustedDevicesGetRoute = createRoute24({
|
|
5003
5119
|
method: "get",
|
|
5004
5120
|
path: "/trusted-devices",
|
|
@@ -5037,7 +5153,7 @@ var trustedDevicesGetHandler = async (c) => {
|
|
|
5037
5153
|
ipAddress: schema.userTrustedDevices.ipAddress,
|
|
5038
5154
|
lastUsedAt: schema.userTrustedDevices.lastUsedAt,
|
|
5039
5155
|
createdAt: schema.userTrustedDevices.createdAt
|
|
5040
|
-
}).from(schema.userTrustedDevices).where(
|
|
5156
|
+
}).from(schema.userTrustedDevices).where(and14(eq28(schema.userTrustedDevices.userId, userId), gt6(schema.userTrustedDevices.expiresAt, now)));
|
|
5041
5157
|
c.header("Cache-Control", "private, max-age=60");
|
|
5042
5158
|
c.header("Vary", "Cookie");
|
|
5043
5159
|
return c.json({ devices });
|
|
@@ -5073,7 +5189,7 @@ var trustedDevicesDeleteHandler = async (c) => {
|
|
|
5073
5189
|
if (!userId) return problems.unauthorized(c);
|
|
5074
5190
|
const deviceId = c.req.param("id");
|
|
5075
5191
|
const { db, schema } = getAuthContext(c);
|
|
5076
|
-
const result = await db.delete(schema.userTrustedDevices).where(
|
|
5192
|
+
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
5193
|
if (result.length === 0) {
|
|
5078
5194
|
return problems.notFound(c, "Device not found");
|
|
5079
5195
|
}
|
|
@@ -5083,7 +5199,7 @@ var trustedDevicesDeleteHandler = async (c) => {
|
|
|
5083
5199
|
|
|
5084
5200
|
// src/routes/2fa/backup-codes.ts
|
|
5085
5201
|
import { createRoute as createRoute25 } from "@hono/zod-openapi";
|
|
5086
|
-
import { eq as
|
|
5202
|
+
import { eq as eq29 } from "drizzle-orm";
|
|
5087
5203
|
var backupCodesRegenerateRoute = createRoute25({
|
|
5088
5204
|
method: "post",
|
|
5089
5205
|
path: "/backup-codes/regenerate",
|
|
@@ -5124,7 +5240,7 @@ var backupCodesRegenerateHandler = async (c) => {
|
|
|
5124
5240
|
logSecurityEvent("2fa_backup_regenerate_failed", "medium", { userId });
|
|
5125
5241
|
return problems.badRequest(c, "Invalid verification code");
|
|
5126
5242
|
}
|
|
5127
|
-
await db.delete(schema.userBackupCodes).where(
|
|
5243
|
+
await db.delete(schema.userBackupCodes).where(eq29(schema.userBackupCodes.userId, userId));
|
|
5128
5244
|
const codes = generateBackupCodes();
|
|
5129
5245
|
const now = Date.now();
|
|
5130
5246
|
const hashedCodes = await Promise.all(
|
|
@@ -5141,7 +5257,7 @@ var backupCodesRegenerateHandler = async (c) => {
|
|
|
5141
5257
|
|
|
5142
5258
|
// src/routes/2fa/challenge.ts
|
|
5143
5259
|
import { createRoute as createRoute26 } from "@hono/zod-openapi";
|
|
5144
|
-
import { eq as
|
|
5260
|
+
import { eq as eq30 } from "drizzle-orm";
|
|
5145
5261
|
import { getCookie as getCookie2 } from "hono/cookie";
|
|
5146
5262
|
var challengeRoute = createRoute26({
|
|
5147
5263
|
method: "post",
|
|
@@ -5213,7 +5329,7 @@ var challengeHandler = async (c) => {
|
|
|
5213
5329
|
}
|
|
5214
5330
|
await deleteChallengeToken(c.env.OAUTH_STATES, challengeToken);
|
|
5215
5331
|
clearChallengeCookie(c, c.env);
|
|
5216
|
-
const [user] = await db.select().from(schema.users).where(
|
|
5332
|
+
const [user] = await db.select().from(schema.users).where(eq30(schema.users.id, payload.userId)).limit(1);
|
|
5217
5333
|
if (!user) {
|
|
5218
5334
|
logger_default.error("User not found during 2FA challenge", { userId: payload.userId });
|
|
5219
5335
|
return problems.notFound(c, "User not found");
|
|
@@ -5254,7 +5370,7 @@ var challengeHandler = async (c) => {
|
|
|
5254
5370
|
|
|
5255
5371
|
// src/routes/2fa/challenge-resend.ts
|
|
5256
5372
|
import { createRoute as createRoute27 } from "@hono/zod-openapi";
|
|
5257
|
-
import { eq as
|
|
5373
|
+
import { eq as eq31 } from "drizzle-orm";
|
|
5258
5374
|
import { getCookie as getCookie3 } from "hono/cookie";
|
|
5259
5375
|
var challengeResendRoute = createRoute27({
|
|
5260
5376
|
method: "post",
|
|
@@ -5292,7 +5408,7 @@ var challengeResendHandler = async (c) => {
|
|
|
5292
5408
|
if (!payload.methods.includes("email")) {
|
|
5293
5409
|
return problems.badRequest(c, "Email verification not available");
|
|
5294
5410
|
}
|
|
5295
|
-
const [user] = await db.select({ email: schema.users.email, name: schema.users.name }).from(schema.users).where(
|
|
5411
|
+
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
5412
|
if (!user) {
|
|
5297
5413
|
logger_default.error("User not found during 2FA resend", { userId: payload.userId });
|
|
5298
5414
|
return problems.notFound(c, "User not found");
|
|
@@ -5523,7 +5639,7 @@ var authorizeHandler = async (c) => {
|
|
|
5523
5639
|
|
|
5524
5640
|
// src/routes/oauth/callback.ts
|
|
5525
5641
|
import { createRoute as createRoute29 } from "@hono/zod-openapi";
|
|
5526
|
-
import { eq as
|
|
5642
|
+
import { eq as eq32 } from "drizzle-orm";
|
|
5527
5643
|
var callbackRoute = createRoute29({
|
|
5528
5644
|
method: "get",
|
|
5529
5645
|
path: "/{provider}/callback",
|
|
@@ -5649,10 +5765,24 @@ var callbackHandler = async (c) => {
|
|
|
5649
5765
|
logger_default.warn("No user ID returned from OAuth provider", { provider });
|
|
5650
5766
|
return c.redirect(`${appUrl}/login?error=oauth_failed`);
|
|
5651
5767
|
}
|
|
5652
|
-
const { db, schema } = getAuthContext(c);
|
|
5653
|
-
const [existingUser] = await db.select({ id: schema.users.id, email: schema.users.email }).from(schema.users).where(
|
|
5768
|
+
const { db, schema, env, invitations, signup } = getAuthContext(c);
|
|
5769
|
+
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
5770
|
let authUser = existingUser;
|
|
5655
5771
|
if (!authUser) {
|
|
5772
|
+
const invited = await verifyInvitation(invitations, {
|
|
5773
|
+
token: invitationToken,
|
|
5774
|
+
email,
|
|
5775
|
+
db,
|
|
5776
|
+
env
|
|
5777
|
+
});
|
|
5778
|
+
if (signup === "invitation" && !invited) {
|
|
5779
|
+
logSecurityEvent("oauth_signup_refused_no_invitation", "medium", {
|
|
5780
|
+
email,
|
|
5781
|
+
provider,
|
|
5782
|
+
hadInvitation: !!invitationToken
|
|
5783
|
+
});
|
|
5784
|
+
return c.redirect(`${appUrl}/login?error=invitation_required`);
|
|
5785
|
+
}
|
|
5656
5786
|
const [createdUser] = await db.insert(schema.users).values({
|
|
5657
5787
|
email,
|
|
5658
5788
|
name: name || null,
|
|
@@ -5663,6 +5793,15 @@ var callbackHandler = async (c) => {
|
|
|
5663
5793
|
activatedAt: /* @__PURE__ */ new Date()
|
|
5664
5794
|
}).returning({ id: schema.users.id, email: schema.users.email });
|
|
5665
5795
|
authUser = createdUser;
|
|
5796
|
+
if (invited) {
|
|
5797
|
+
await acceptInvitation(invitations, {
|
|
5798
|
+
token: invited,
|
|
5799
|
+
user: { id: createdUser.id, email: createdUser.email, name: name || null },
|
|
5800
|
+
db,
|
|
5801
|
+
env,
|
|
5802
|
+
context: "oauth_invitation_onAccepted_failed"
|
|
5803
|
+
});
|
|
5804
|
+
}
|
|
5666
5805
|
logSecurityEvent("user_signup_oauth", "low", {
|
|
5667
5806
|
userId: createdUser.id,
|
|
5668
5807
|
email: createdUser.email,
|
|
@@ -5702,7 +5841,7 @@ var callbackHandler = async (c) => {
|
|
|
5702
5841
|
|
|
5703
5842
|
// src/routes/oauth/complete.ts
|
|
5704
5843
|
import { createRoute as createRoute30 } from "@hono/zod-openapi";
|
|
5705
|
-
import { eq as
|
|
5844
|
+
import { eq as eq33 } from "drizzle-orm";
|
|
5706
5845
|
var completeRoute = createRoute30({
|
|
5707
5846
|
method: "post",
|
|
5708
5847
|
path: "/complete",
|
|
@@ -5750,7 +5889,7 @@ var completeHandler = async (c) => {
|
|
|
5750
5889
|
redirect: storedRedirect
|
|
5751
5890
|
} = JSON.parse(stored);
|
|
5752
5891
|
const { db, schema } = getAuthContext(c);
|
|
5753
|
-
await db.update(schema.users).set({ emailVerified: true }).where(
|
|
5892
|
+
await db.update(schema.users).set({ emailVerified: true }).where(eq33(schema.users.id, userId));
|
|
5754
5893
|
await db.insert(schema.oauthAccounts).values({ userId, provider, providerUserId, email }).onConflictDoNothing();
|
|
5755
5894
|
const fingerprint = await generateFingerprint(c.req.raw);
|
|
5756
5895
|
const ipAddress = getClientIp(c.req.raw);
|
|
@@ -5795,19 +5934,12 @@ var routes_default = authRoutes;
|
|
|
5795
5934
|
|
|
5796
5935
|
// src/lib/email/webhook-verifier.ts
|
|
5797
5936
|
import { Webhook } from "svix";
|
|
5798
|
-
function verifyWebhookSignature(payload,
|
|
5937
|
+
function verifyWebhookSignature(payload, headers, secret) {
|
|
5799
5938
|
if (!secret || secret.trim() === "") {
|
|
5800
5939
|
throw new Error("Webhook secret is required");
|
|
5801
5940
|
}
|
|
5802
5941
|
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
|
-
});
|
|
5942
|
+
new Webhook(secret).verify(payload, { ...headers });
|
|
5811
5943
|
return true;
|
|
5812
5944
|
} catch (error) {
|
|
5813
5945
|
logger_default.warn("Webhook signature verification failed", {
|
|
@@ -5822,6 +5954,8 @@ export {
|
|
|
5822
5954
|
CloudflareEmailAdapter,
|
|
5823
5955
|
EmailService,
|
|
5824
5956
|
MAX_CHALLENGE_ATTEMPTS,
|
|
5957
|
+
PASSWORDLESS_MAX_ATTEMPTS,
|
|
5958
|
+
PASSWORDLESS_TTL_MS,
|
|
5825
5959
|
ProblemTypes,
|
|
5826
5960
|
ResendAdapter,
|
|
5827
5961
|
SCHEMA_VERSION,
|
|
@@ -5833,6 +5967,7 @@ export {
|
|
|
5833
5967
|
cancelEmailChange,
|
|
5834
5968
|
checkAccountLocked,
|
|
5835
5969
|
checkBreachedPassword,
|
|
5970
|
+
cleanupExpiredPasswordlessChallenges,
|
|
5836
5971
|
clearAccountLockout,
|
|
5837
5972
|
clearChallengeCookie,
|
|
5838
5973
|
clearSessionCookie,
|
|
@@ -5843,6 +5978,7 @@ export {
|
|
|
5843
5978
|
createChallengeToken,
|
|
5844
5979
|
createDeviceToken,
|
|
5845
5980
|
createEmailAdapter,
|
|
5981
|
+
createPasswordlessChallenge,
|
|
5846
5982
|
createProblemDetails,
|
|
5847
5983
|
createSession,
|
|
5848
5984
|
csrf,
|
|
@@ -5859,6 +5995,7 @@ export {
|
|
|
5859
5995
|
generate2faEnabledEmail,
|
|
5860
5996
|
generateApiKey,
|
|
5861
5997
|
generateBackupCodes,
|
|
5998
|
+
generateCode,
|
|
5862
5999
|
generateCodeChallenge,
|
|
5863
6000
|
generateCodeVerifier,
|
|
5864
6001
|
generateEmailChangeConfirmation,
|
|
@@ -5897,14 +6034,17 @@ export {
|
|
|
5897
6034
|
optionalAuth,
|
|
5898
6035
|
parseDeviceName,
|
|
5899
6036
|
parseDeviceType,
|
|
6037
|
+
passwordlessEmail,
|
|
5900
6038
|
problemJson,
|
|
5901
6039
|
problems,
|
|
5902
6040
|
rateLimit,
|
|
6041
|
+
recentPasswordlessFailures,
|
|
5903
6042
|
refreshSession,
|
|
5904
6043
|
requestEmailChange,
|
|
5905
6044
|
requireAuth,
|
|
5906
6045
|
requireVerifiedEmail,
|
|
5907
6046
|
retrieveChallengeToken,
|
|
6047
|
+
sessionRefreshDue,
|
|
5908
6048
|
setChallengeCookie,
|
|
5909
6049
|
setSessionCookie,
|
|
5910
6050
|
setTrustedDeviceCookie,
|
|
@@ -5922,6 +6062,8 @@ export {
|
|
|
5922
6062
|
verifyCodeChallenge,
|
|
5923
6063
|
verifyPassword,
|
|
5924
6064
|
verifyPasswordWithRotation,
|
|
6065
|
+
verifyPasswordlessCode,
|
|
6066
|
+
verifyPasswordlessLink,
|
|
5925
6067
|
verifyTotpCode,
|
|
5926
6068
|
verifyTurnstileToken,
|
|
5927
6069
|
verifyWebhookSignature
|