@cequrebackends/plugin-security 0.12.2
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 +23 -0
- package/dist/audit.d.ts +39 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/auth-routes.d.ts +3 -0
- package/dist/auth-routes.d.ts.map +1 -0
- package/dist/auth.d.ts +61 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/encryption.d.ts +25 -0
- package/dist/encryption.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1199 -0
- package/dist/mfa.d.ts +18 -0
- package/dist/mfa.d.ts.map +1 -0
- package/dist/rate-limiter.d.ts +20 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/secrets.d.ts +25 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/token-store.d.ts +58 -0
- package/dist/token-store.d.ts.map +1 -0
- package/dist/webhooks.d.ts +38 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/package.json +68 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
function __accessProp(key) {
|
|
7
|
+
return this[key];
|
|
8
|
+
}
|
|
9
|
+
var __toCommonJS = (from) => {
|
|
10
|
+
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
|
|
11
|
+
if (entry)
|
|
12
|
+
return entry;
|
|
13
|
+
entry = __defProp({}, "__esModule", { value: true });
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (var key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(entry, key))
|
|
17
|
+
__defProp(entry, key, {
|
|
18
|
+
get: __accessProp.bind(from, key),
|
|
19
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
__moduleCache.set(from, entry);
|
|
23
|
+
return entry;
|
|
24
|
+
};
|
|
25
|
+
var __moduleCache;
|
|
26
|
+
var __returnValue = (v) => v;
|
|
27
|
+
function __exportSetter(name, newValue) {
|
|
28
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
29
|
+
}
|
|
30
|
+
var __export = (target, all) => {
|
|
31
|
+
for (var name in all)
|
|
32
|
+
__defProp(target, name, {
|
|
33
|
+
get: all[name],
|
|
34
|
+
enumerable: true,
|
|
35
|
+
configurable: true,
|
|
36
|
+
set: __exportSetter.bind(all, name)
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
40
|
+
|
|
41
|
+
// src/mfa.ts
|
|
42
|
+
import { sha256Hex as sha256Hex2, timingSafeEqual as timingSafeEqual2 } from "@cequrebackends/cequre-ts";
|
|
43
|
+
function generateTOTPSecret() {
|
|
44
|
+
const bytes = crypto.getRandomValues(new Uint8Array(20));
|
|
45
|
+
let bits = 0, value = 0, output = "";
|
|
46
|
+
for (const byte of bytes) {
|
|
47
|
+
value = value << 8 | byte;
|
|
48
|
+
bits += 8;
|
|
49
|
+
while (bits >= 5) {
|
|
50
|
+
output += BASE32_ALPHABET[value >>> bits - 5 & 31];
|
|
51
|
+
bits -= 5;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (bits > 0)
|
|
55
|
+
output += BASE32_ALPHABET[value << 5 - bits & 31];
|
|
56
|
+
return output;
|
|
57
|
+
}
|
|
58
|
+
function base32Decode(secret) {
|
|
59
|
+
const cleaned = secret.replace(/=+$/, "").replace(/\s/g, "").toUpperCase();
|
|
60
|
+
let bits = 0, value = 0, output = [];
|
|
61
|
+
for (const char of cleaned) {
|
|
62
|
+
const idx = BASE32_ALPHABET.indexOf(char);
|
|
63
|
+
if (idx === -1)
|
|
64
|
+
continue;
|
|
65
|
+
value = value << 5 | idx;
|
|
66
|
+
bits += 5;
|
|
67
|
+
if (bits >= 8) {
|
|
68
|
+
output.push(value >>> bits - 8 & 255);
|
|
69
|
+
bits -= 8;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return new Uint8Array(output);
|
|
73
|
+
}
|
|
74
|
+
async function generateTOTPAtCounter(secret, counter, digits = 6) {
|
|
75
|
+
const buffer = new ArrayBuffer(8);
|
|
76
|
+
const view = new DataView(buffer);
|
|
77
|
+
view.setUint32(0, Math.floor(counter / 4294967296));
|
|
78
|
+
view.setUint32(4, counter & 4294967295);
|
|
79
|
+
const key = await crypto.subtle.importKey("raw", base32Decode(secret), { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
|
|
80
|
+
const hmac = new Uint8Array(await crypto.subtle.sign("HMAC", key, buffer));
|
|
81
|
+
const offset = hmac[hmac.length - 1] & 15;
|
|
82
|
+
const code = ((hmac[offset] & 127) << 24 | hmac[offset + 1] << 16 | hmac[offset + 2] << 8 | hmac[offset + 3]) % Math.pow(10, digits);
|
|
83
|
+
return code.toString().padStart(digits, "0");
|
|
84
|
+
}
|
|
85
|
+
async function generateTOTP(secret, timeStep = 30, digits = 6) {
|
|
86
|
+
const counter = Math.floor(Date.now() / 1000 / timeStep);
|
|
87
|
+
return generateTOTPAtCounter(secret, counter, digits);
|
|
88
|
+
}
|
|
89
|
+
async function verifyTOTP(secret, code, window = 1) {
|
|
90
|
+
for (let i = -window;i <= window; i++) {
|
|
91
|
+
const counter = Math.floor(Date.now() / 1000 / 30) + i;
|
|
92
|
+
const expected = await generateTOTPAtCounter(secret, counter);
|
|
93
|
+
if (timingSafeEqual2(expected, code))
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
function generateOTPAuthURI(secret, account, issuer = "Cequre") {
|
|
99
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
|
|
100
|
+
const params = new URLSearchParams({ secret, issuer, algorithm: "SHA1", digits: "6", period: "30" });
|
|
101
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
102
|
+
}
|
|
103
|
+
function generateBackupCodes(count = 10) {
|
|
104
|
+
const codes = [];
|
|
105
|
+
for (let i = 0;i < count; i++) {
|
|
106
|
+
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
|
107
|
+
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
108
|
+
codes.push(`${hex.slice(0, 5)}-${hex.slice(5, 10)}-${hex.slice(10, 16)}`);
|
|
109
|
+
}
|
|
110
|
+
return codes;
|
|
111
|
+
}
|
|
112
|
+
async function hashBackupCode(code) {
|
|
113
|
+
return sha256Hex2(code);
|
|
114
|
+
}
|
|
115
|
+
var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
116
|
+
var init_mfa = () => {};
|
|
117
|
+
|
|
118
|
+
// src/auth-routes.ts
|
|
119
|
+
var exports_auth_routes = {};
|
|
120
|
+
__export(exports_auth_routes, {
|
|
121
|
+
setupAuthRoutes: () => setupAuthRoutes
|
|
122
|
+
});
|
|
123
|
+
import { parseDuration as parseDuration2 } from "@cequrebackends/cequre-ts";
|
|
124
|
+
function validatePasswordPolicy(password, policy) {
|
|
125
|
+
if (!policy)
|
|
126
|
+
return null;
|
|
127
|
+
if (policy.minLength && password.length < policy.minLength) {
|
|
128
|
+
return `Password must be at least ${policy.minLength} characters`;
|
|
129
|
+
}
|
|
130
|
+
if (policy.requireUppercase && !/[A-Z]/.test(password)) {
|
|
131
|
+
return "Password must contain at least one uppercase letter";
|
|
132
|
+
}
|
|
133
|
+
if (policy.requireNumber && !/[0-9]/.test(password)) {
|
|
134
|
+
return "Password must contain at least one number";
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
function getRefreshTokenFromCookie(req, cookieName) {
|
|
139
|
+
const cookieHeader = req.headers.get("cookie");
|
|
140
|
+
if (!cookieHeader)
|
|
141
|
+
return;
|
|
142
|
+
const match = cookieHeader.match(new RegExp(`(^|;\\s*)${cookieName}_refresh=([^;]+)`));
|
|
143
|
+
return match ? match[2] : undefined;
|
|
144
|
+
}
|
|
145
|
+
function applyAuthCookies(headers, jwtConfig, tokens, clear = false) {
|
|
146
|
+
const config = jwtConfig?.cookies;
|
|
147
|
+
if (!config)
|
|
148
|
+
return;
|
|
149
|
+
const cookieName = config.name || "cequre_auth";
|
|
150
|
+
const domain = config.domain ? `Domain=${config.domain};` : "";
|
|
151
|
+
const sameSite = config.sameSite || "lax";
|
|
152
|
+
const secure = config.secure ? "Secure;" : "";
|
|
153
|
+
const httpOnly = config.httpOnly ? "HttpOnly;" : "";
|
|
154
|
+
if (clear) {
|
|
155
|
+
headers.append("Set-Cookie", `${cookieName}=; Path=/; Max-Age=0; SameSite=${sameSite}; ${secure} ${httpOnly} ${domain}`.trim());
|
|
156
|
+
if (config.refreshToken) {
|
|
157
|
+
const refreshCookieName = `${cookieName}_refresh`;
|
|
158
|
+
headers.append("Set-Cookie", `${refreshCookieName}=; Path=/; Max-Age=0; SameSite=${sameSite}; ${secure} ${httpOnly} ${domain}`.trim());
|
|
159
|
+
}
|
|
160
|
+
} else if (tokens) {
|
|
161
|
+
const maxAge = parseDuration2(config.timeout || "7d");
|
|
162
|
+
headers.append("Set-Cookie", `${cookieName}=${tokens.accessToken}; Path=/; Max-Age=${maxAge}; SameSite=${sameSite}; ${secure} ${httpOnly} ${domain}`.trim());
|
|
163
|
+
if (config.refreshToken) {
|
|
164
|
+
const refreshCookieName = `${cookieName}_refresh`;
|
|
165
|
+
const refreshMaxAge = parseDuration2(jwtConfig?.refreshTokenExpiry || "7d");
|
|
166
|
+
headers.append("Set-Cookie", `${refreshCookieName}=${tokens.refreshToken}; Path=/; Max-Age=${refreshMaxAge}; SameSite=${sameSite}; ${secure} ${httpOnly} ${domain}`.trim());
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function setupAuthRoutes(cequre) {
|
|
171
|
+
const prefix = cequre.schema.core?.api?.prefix || "/api";
|
|
172
|
+
if (cequre.schema.collections) {
|
|
173
|
+
for (const collection of cequre.schema.collections) {
|
|
174
|
+
const basePath = `${prefix}/${collection.slug}`;
|
|
175
|
+
if (collection.auth) {
|
|
176
|
+
cequre.router.post(`${basePath}/register`, async (ctx) => {
|
|
177
|
+
const req = ctx.request;
|
|
178
|
+
let data = await req.json().catch(() => ({}));
|
|
179
|
+
const hookCtx = { ...ctx, collection: collection.slug };
|
|
180
|
+
if (cequre.hooksMap.get(collection.slug)?.beforeRegister) {
|
|
181
|
+
const mutated = await cequre.hooksMap.get(collection.slug)?.beforeRegister?.({ ...hookCtx, data });
|
|
182
|
+
if (mutated !== undefined)
|
|
183
|
+
data = mutated;
|
|
184
|
+
}
|
|
185
|
+
await cequre.checkAccess(collection.slug, "register", { ...ctx, data });
|
|
186
|
+
if (!data.email || !data.password)
|
|
187
|
+
return new Response(JSON.stringify({ error: "Email and password required" }), { status: 400 });
|
|
188
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
189
|
+
if (!emailRegex.test(data.email))
|
|
190
|
+
return new Response(JSON.stringify({ error: "Invalid email format" }), { status: 400 });
|
|
191
|
+
const policyError = validatePasswordPolicy(data.password, cequre.schema.security?.auth?.passwordPolicy);
|
|
192
|
+
if (policyError)
|
|
193
|
+
return new Response(JSON.stringify({ error: policyError }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
194
|
+
const existing = await cequre.secureFind(collection.slug, { where: { email: { eq: data.email } }, limit: 1 });
|
|
195
|
+
if (existing.docs.length > 0)
|
|
196
|
+
return new Response(JSON.stringify({ error: "User already exists" }), { status: 409 });
|
|
197
|
+
const hashedPassword = await Bun.password.hash(data.password, "bcrypt");
|
|
198
|
+
const registerData = cequre.stripForbiddenFields(collection.slug, { ...data, password: hashedPassword });
|
|
199
|
+
registerData.password = hashedPassword;
|
|
200
|
+
const user = await cequre.secureCreate(collection.slug, registerData);
|
|
201
|
+
let tokens;
|
|
202
|
+
if (cequre.authEngine)
|
|
203
|
+
tokens = await cequre.authEngine.generateTokenPair(user, collection.slug);
|
|
204
|
+
const filteredUser = { ...user };
|
|
205
|
+
delete filteredUser.password;
|
|
206
|
+
if (cequre.hooksMap.get(collection.slug)?.afterRegister) {
|
|
207
|
+
await cequre.hooksMap.get(collection.slug)?.afterRegister?.({ ...hookCtx, data: { user: filteredUser, tokens } });
|
|
208
|
+
}
|
|
209
|
+
return new Response(JSON.stringify({
|
|
210
|
+
user: filteredUser,
|
|
211
|
+
...tokens ? { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken } : {},
|
|
212
|
+
message: "Registration successful"
|
|
213
|
+
}), { status: 201, headers: { "Content-Type": "application/json" } });
|
|
214
|
+
});
|
|
215
|
+
cequre.router.post(`${basePath}/login`, async (ctx) => {
|
|
216
|
+
const req = ctx.request;
|
|
217
|
+
let data = await req.json().catch(() => ({}));
|
|
218
|
+
const hookCtx = { ...ctx, collection: collection.slug };
|
|
219
|
+
if (cequre.hooksMap.get(collection.slug)?.beforeLogin) {
|
|
220
|
+
const mutated = await cequre.hooksMap.get(collection.slug)?.beforeLogin?.({ ...hookCtx, data });
|
|
221
|
+
if (mutated !== undefined)
|
|
222
|
+
data = mutated;
|
|
223
|
+
}
|
|
224
|
+
await cequre.checkAccess(collection.slug, "login", { ...ctx, data });
|
|
225
|
+
if (!data.email || !data.password)
|
|
226
|
+
return new Response(JSON.stringify({ error: "Email and password required" }), { status: 400 });
|
|
227
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
228
|
+
if (!emailRegex.test(data.email))
|
|
229
|
+
return new Response(JSON.stringify({ error: "Invalid email format" }), { status: 400 });
|
|
230
|
+
const lockoutConfig = cequre.schema.security?.auth?.lockout;
|
|
231
|
+
const lockoutState = await cequre.checkLockout(collection.slug, data.email, lockoutConfig);
|
|
232
|
+
if (lockoutState.locked) {
|
|
233
|
+
const mins = Math.ceil((lockoutState.remainingMs ?? 0) / 60000);
|
|
234
|
+
return new Response(JSON.stringify({ error: `Account temporarily locked. Try again in ${mins} minute(s).` }), { status: 429, headers: { "Content-Type": "application/json" } });
|
|
235
|
+
}
|
|
236
|
+
const result = await cequre.secureFind(collection.slug, { where: { email: { eq: data.email } }, limit: 1 });
|
|
237
|
+
const user = result.docs[0];
|
|
238
|
+
if (!user) {
|
|
239
|
+
await cequre.recordFailedLogin(collection.slug, data.email, lockoutConfig);
|
|
240
|
+
return new Response(JSON.stringify({ error: "Oops! The email or password credentials provided don't match our records." }), { status: 401 });
|
|
241
|
+
}
|
|
242
|
+
const valid = await Bun.password.verify(data.password, user.password);
|
|
243
|
+
if (!valid) {
|
|
244
|
+
await cequre.recordFailedLogin(collection.slug, data.email, lockoutConfig);
|
|
245
|
+
return new Response(JSON.stringify({ error: "Oops! The email or password credentials provided don't match our records." }), { status: 401 });
|
|
246
|
+
}
|
|
247
|
+
await cequre.clearFailedLogins(collection.slug, data.email);
|
|
248
|
+
if (user.mfaEnabled && user.mfaSecret) {
|
|
249
|
+
const mfaToken = await cequre.authEngine.generateMFAChallengeToken(user.id, collection.slug);
|
|
250
|
+
return new Response(JSON.stringify({
|
|
251
|
+
mfaRequired: true,
|
|
252
|
+
mfaToken
|
|
253
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
254
|
+
}
|
|
255
|
+
let tokens;
|
|
256
|
+
if (cequre.authEngine)
|
|
257
|
+
tokens = await cequre.authEngine.generateTokenPair(user, collection.slug);
|
|
258
|
+
const filteredUser = { ...user };
|
|
259
|
+
delete filteredUser.password;
|
|
260
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
261
|
+
applyAuthCookies(headers, cequre.schema.security?.auth?.jwt, tokens);
|
|
262
|
+
if (cequre.hooksMap.get(collection.slug)?.afterLogin) {
|
|
263
|
+
await cequre.hooksMap.get(collection.slug)?.afterLogin?.({ ...hookCtx, data: { user: filteredUser, tokens } });
|
|
264
|
+
}
|
|
265
|
+
return new Response(JSON.stringify({
|
|
266
|
+
user: filteredUser,
|
|
267
|
+
...tokens ? { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken } : {},
|
|
268
|
+
message: "Login successful"
|
|
269
|
+
}), { status: 200, headers });
|
|
270
|
+
});
|
|
271
|
+
cequre.router.post(`${basePath}/refresh`, async (ctx) => {
|
|
272
|
+
const req = ctx.request;
|
|
273
|
+
let data = await req.json().catch(() => ({}));
|
|
274
|
+
await cequre.checkAccess(collection.slug, "refresh", { ...ctx, data });
|
|
275
|
+
const cookiesConfig = cequre.schema.security?.auth?.jwt?.cookies;
|
|
276
|
+
let refreshToken = data.refreshToken;
|
|
277
|
+
if (!refreshToken && cookiesConfig?.refreshToken) {
|
|
278
|
+
const cookieName = cookiesConfig.name || "cequre_auth";
|
|
279
|
+
refreshToken = getRefreshTokenFromCookie(req, cookieName);
|
|
280
|
+
}
|
|
281
|
+
if (!refreshToken)
|
|
282
|
+
return new Response(JSON.stringify({ error: "refreshToken required" }), { status: 400 });
|
|
283
|
+
if (!cequre.authEngine)
|
|
284
|
+
return new Response(JSON.stringify({ error: "JWT auth not configured" }), { status: 501 });
|
|
285
|
+
const tokens = await cequre.authEngine.refreshTokenPair(refreshToken);
|
|
286
|
+
if (!tokens)
|
|
287
|
+
return new Response(JSON.stringify({ error: "Invalid or expired refresh token" }), { status: 401 });
|
|
288
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
289
|
+
applyAuthCookies(headers, cequre.schema.security?.auth?.jwt, tokens);
|
|
290
|
+
return new Response(JSON.stringify({
|
|
291
|
+
user: tokens.user,
|
|
292
|
+
accessToken: tokens.accessToken,
|
|
293
|
+
refreshToken: tokens.refreshToken
|
|
294
|
+
}), { status: 200, headers });
|
|
295
|
+
});
|
|
296
|
+
cequre.router.post(`${basePath}/logout`, async (ctx) => {
|
|
297
|
+
const req = ctx.request;
|
|
298
|
+
await cequre.checkAccess(collection.slug, "logout", ctx);
|
|
299
|
+
let data = await req.json().catch(() => ({}));
|
|
300
|
+
const cookiesConfig = cequre.schema.security?.auth?.jwt?.cookies;
|
|
301
|
+
let refreshToken = data.refreshToken;
|
|
302
|
+
if (!refreshToken && cookiesConfig?.refreshToken) {
|
|
303
|
+
const cookieName = cookiesConfig.name || "cequre_auth";
|
|
304
|
+
refreshToken = getRefreshTokenFromCookie(req, cookieName);
|
|
305
|
+
}
|
|
306
|
+
if (refreshToken && cequre.authEngine) {
|
|
307
|
+
await cequre.authEngine.revokeRefreshToken(refreshToken);
|
|
308
|
+
}
|
|
309
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
310
|
+
applyAuthCookies(headers, cequre.schema.security?.auth?.jwt, undefined, true);
|
|
311
|
+
return new Response(JSON.stringify({ message: "Logged out successfully" }), { status: 200, headers });
|
|
312
|
+
});
|
|
313
|
+
if (cequre.schema.security?.auth?.mfa?.enabled) {
|
|
314
|
+
cequre.router.post(`${basePath}/mfa/login`, async (ctx) => {
|
|
315
|
+
const req = ctx.request;
|
|
316
|
+
let data = await req.json().catch(() => ({}));
|
|
317
|
+
const hookCtx = { ...ctx, collection: collection.slug };
|
|
318
|
+
if (cequre.hooksMap.get(collection.slug)?.beforeMfaLogin) {
|
|
319
|
+
const mutated = await cequre.hooksMap.get(collection.slug)?.beforeMfaLogin?.({ ...hookCtx, data });
|
|
320
|
+
if (mutated !== undefined)
|
|
321
|
+
data = mutated;
|
|
322
|
+
}
|
|
323
|
+
await cequre.checkAccess(collection.slug, "mfaLogin", { ...ctx, data });
|
|
324
|
+
if (!data.mfaToken || !data.mfaCode)
|
|
325
|
+
return new Response(JSON.stringify({ error: "mfaToken and mfaCode required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
326
|
+
if (!cequre.authEngine)
|
|
327
|
+
return new Response(JSON.stringify({ error: "JWT auth not configured" }), { status: 501 });
|
|
328
|
+
const tokenInfo = await cequre.authEngine.verifyMFAChallengeToken(data.mfaToken);
|
|
329
|
+
if (!tokenInfo)
|
|
330
|
+
return new Response(JSON.stringify({ error: "Invalid or expired MFA token" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
331
|
+
const user = await cequre.secureFindById(tokenInfo.collection, tokenInfo.userId);
|
|
332
|
+
if (!user || !user.mfaEnabled || !user.mfaSecret) {
|
|
333
|
+
return new Response(JSON.stringify({ error: "MFA not enabled for this user" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
334
|
+
}
|
|
335
|
+
if (data.backupCode) {
|
|
336
|
+
const hashedInput = await hashBackupCode(data.backupCode);
|
|
337
|
+
const backupHashes = user.mfaBackupCodes || [];
|
|
338
|
+
const matchIdx = backupHashes.indexOf(hashedInput);
|
|
339
|
+
if (matchIdx >= 0) {
|
|
340
|
+
backupHashes.splice(matchIdx, 1);
|
|
341
|
+
await cequre.secureUpdate(tokenInfo.collection, tokenInfo.userId, { mfaBackupCodes: backupHashes });
|
|
342
|
+
} else {
|
|
343
|
+
return new Response(JSON.stringify({ error: "Invalid backup code" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
const valid = await verifyTOTP(user.mfaSecret, data.mfaCode);
|
|
347
|
+
if (!valid)
|
|
348
|
+
return new Response(JSON.stringify({ error: "Invalid MFA code" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
349
|
+
}
|
|
350
|
+
const tokens = await cequre.authEngine.generateTokenPair(user, tokenInfo.collection);
|
|
351
|
+
const filteredUser = { ...user };
|
|
352
|
+
delete filteredUser.password;
|
|
353
|
+
delete filteredUser.mfaSecret;
|
|
354
|
+
delete filteredUser.mfaBackupCodes;
|
|
355
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
356
|
+
applyAuthCookies(headers, cequre.schema.security?.auth?.jwt, tokens);
|
|
357
|
+
if (cequre.hooksMap.get(collection.slug)?.afterMfaLogin) {
|
|
358
|
+
await cequre.hooksMap.get(collection.slug)?.afterMfaLogin?.({ ...hookCtx, data: { user: filteredUser, tokens } });
|
|
359
|
+
}
|
|
360
|
+
return new Response(JSON.stringify({
|
|
361
|
+
user: filteredUser,
|
|
362
|
+
accessToken: tokens.accessToken,
|
|
363
|
+
refreshToken: tokens.refreshToken,
|
|
364
|
+
message: "Login successful"
|
|
365
|
+
}), { status: 200, headers });
|
|
366
|
+
});
|
|
367
|
+
cequre.router.post(`${basePath}/mfa/enroll`, async (ctx) => {
|
|
368
|
+
const req = ctx.request;
|
|
369
|
+
let data = await req.json().catch(() => ({}));
|
|
370
|
+
const hookCtx = { ...ctx, collection: collection.slug };
|
|
371
|
+
if (cequre.hooksMap.get(collection.slug)?.beforeMfaEnroll) {
|
|
372
|
+
const mutated = await cequre.hooksMap.get(collection.slug)?.beforeMfaEnroll?.({ ...hookCtx, data });
|
|
373
|
+
if (mutated !== undefined)
|
|
374
|
+
data = mutated;
|
|
375
|
+
}
|
|
376
|
+
await cequre.checkAccess(collection.slug, "mfaEnroll", { ...ctx, data });
|
|
377
|
+
const reqCtx = await cequre.getContext(req);
|
|
378
|
+
if (!reqCtx.user)
|
|
379
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
380
|
+
const user = await cequre.secureFindById(reqCtx.user.collection, reqCtx.user.id);
|
|
381
|
+
if (!user)
|
|
382
|
+
return new Response(JSON.stringify({ error: "User not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
383
|
+
const secret = generateTOTPSecret();
|
|
384
|
+
const issuer = cequre.schema.security?.auth?.mfa?.issuer || "Cequre";
|
|
385
|
+
const account = user.email || reqCtx.user.id;
|
|
386
|
+
const otpauthUri = generateOTPAuthURI(secret, account, issuer);
|
|
387
|
+
const backupCodeCount = cequre.schema.security?.auth?.mfa?.backupCodeCount ?? 10;
|
|
388
|
+
const backupCodes = generateBackupCodes(backupCodeCount);
|
|
389
|
+
const hashedBackupCodes = await Promise.all(backupCodes.map(hashBackupCode));
|
|
390
|
+
await cequre.secureUpdate(reqCtx.user.collection, reqCtx.user.id, {
|
|
391
|
+
mfaSecret: secret,
|
|
392
|
+
mfaBackupCodes: hashedBackupCodes,
|
|
393
|
+
mfaEnabled: false
|
|
394
|
+
});
|
|
395
|
+
if (cequre.hooksMap.get(collection.slug)?.afterMfaEnroll) {
|
|
396
|
+
await cequre.hooksMap.get(collection.slug)?.afterMfaEnroll?.({ ...hookCtx, data: { user } });
|
|
397
|
+
}
|
|
398
|
+
return new Response(JSON.stringify({
|
|
399
|
+
secret,
|
|
400
|
+
otpauthUri,
|
|
401
|
+
backupCodes,
|
|
402
|
+
message: "Scan the QR code with your authenticator app, then verify with a code to complete enrollment."
|
|
403
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
404
|
+
});
|
|
405
|
+
cequre.router.post(`${basePath}/mfa/verify`, async (ctx) => {
|
|
406
|
+
const req = ctx.request;
|
|
407
|
+
let data = await req.json().catch(() => ({}));
|
|
408
|
+
const hookCtx = { ...ctx, collection: collection.slug };
|
|
409
|
+
if (cequre.hooksMap.get(collection.slug)?.beforeMfaVerify) {
|
|
410
|
+
const mutated = await cequre.hooksMap.get(collection.slug)?.beforeMfaVerify?.({ ...hookCtx, data });
|
|
411
|
+
if (mutated !== undefined)
|
|
412
|
+
data = mutated;
|
|
413
|
+
}
|
|
414
|
+
await cequre.checkAccess(collection.slug, "mfaVerify", { ...ctx, data });
|
|
415
|
+
const reqCtx = await cequre.getContext(req);
|
|
416
|
+
if (!reqCtx.user)
|
|
417
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
418
|
+
if (!data.mfaCode)
|
|
419
|
+
return new Response(JSON.stringify({ error: "mfaCode required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
420
|
+
const user = await cequre.secureFindById(reqCtx.user.collection, reqCtx.user.id);
|
|
421
|
+
if (!user || !user.mfaSecret) {
|
|
422
|
+
return new Response(JSON.stringify({ error: "MFA not initiated. Call /mfa/enroll first." }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
423
|
+
}
|
|
424
|
+
const valid = await verifyTOTP(user.mfaSecret, data.mfaCode);
|
|
425
|
+
if (!valid)
|
|
426
|
+
return new Response(JSON.stringify({ error: "Invalid MFA code" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
427
|
+
await cequre.secureUpdate(reqCtx.user.collection, reqCtx.user.id, { mfaEnabled: true });
|
|
428
|
+
if (cequre.hooksMap.get(collection.slug)?.afterMfaVerify) {
|
|
429
|
+
await cequre.hooksMap.get(collection.slug)?.afterMfaVerify?.({ ...hookCtx, data: { user } });
|
|
430
|
+
}
|
|
431
|
+
return new Response(JSON.stringify({ message: "MFA enabled successfully" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
432
|
+
});
|
|
433
|
+
cequre.router.post(`${basePath}/mfa/disable`, async (ctx) => {
|
|
434
|
+
const req = ctx.request;
|
|
435
|
+
let data = await req.json().catch(() => ({}));
|
|
436
|
+
await cequre.checkAccess(collection.slug, "mfaDisable", { ...ctx, data });
|
|
437
|
+
const reqCtx = await cequre.getContext(req);
|
|
438
|
+
if (!reqCtx.user)
|
|
439
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
440
|
+
if (!data.mfaCode)
|
|
441
|
+
return new Response(JSON.stringify({ error: "mfaCode required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
442
|
+
const user = await cequre.secureFindById(reqCtx.user.collection, reqCtx.user.id);
|
|
443
|
+
if (!user || !user.mfaEnabled || !user.mfaSecret) {
|
|
444
|
+
return new Response(JSON.stringify({ error: "MFA not enabled" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
445
|
+
}
|
|
446
|
+
const valid = await verifyTOTP(user.mfaSecret, data.mfaCode);
|
|
447
|
+
if (!valid)
|
|
448
|
+
return new Response(JSON.stringify({ error: "Invalid MFA code" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
449
|
+
await cequre.secureUpdate(reqCtx.user.collection, reqCtx.user.id, {
|
|
450
|
+
mfaEnabled: false,
|
|
451
|
+
mfaSecret: null,
|
|
452
|
+
mfaBackupCodes: null
|
|
453
|
+
});
|
|
454
|
+
return new Response(JSON.stringify({ message: "MFA disabled successfully" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
cequre.router.post(`${basePath}/forgot-password`, async (ctx) => {
|
|
458
|
+
const req = ctx.request;
|
|
459
|
+
let data = await req.json().catch(() => ({}));
|
|
460
|
+
const hookCtx = { ...ctx, collection: collection.slug };
|
|
461
|
+
if (cequre.hooksMap.get(collection.slug)?.beforeForgotPassword) {
|
|
462
|
+
const mutated = await cequre.hooksMap.get(collection.slug)?.beforeForgotPassword?.({ ...hookCtx, data });
|
|
463
|
+
if (mutated !== undefined)
|
|
464
|
+
data = mutated;
|
|
465
|
+
}
|
|
466
|
+
await cequre.checkAccess(collection.slug, "forgotPassword", { ...ctx, data });
|
|
467
|
+
if (!data.email)
|
|
468
|
+
return new Response(JSON.stringify({ error: "Email required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
469
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
470
|
+
if (!emailRegex.test(data.email))
|
|
471
|
+
return new Response(JSON.stringify({ error: "Invalid email format" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
472
|
+
const existing = await cequre.secureFind(collection.slug, { where: { email: { eq: data.email } }, limit: 1 });
|
|
473
|
+
const user = existing.docs[0];
|
|
474
|
+
if (user && cequre.authEngine && cequre.mailer) {
|
|
475
|
+
const token = await cequre.authEngine.generatePasswordResetToken(user.id, collection.slug);
|
|
476
|
+
const resetUrl = cequre.schema.email?.resetPasswordUrl ? `${cequre.schema.email.resetPasswordUrl}?token=${token}` : `http://localhost:3000/reset-password?token=${token}`;
|
|
477
|
+
await cequre.mailer.sendForgotPassword(user.email, resetUrl);
|
|
478
|
+
if (cequre.hooksMap.get(collection.slug)?.afterForgotPassword) {
|
|
479
|
+
await cequre.hooksMap.get(collection.slug)?.afterForgotPassword?.({ ...hookCtx, data: { user, resetUrl, token } });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return new Response(JSON.stringify({ message: "Password reset email sent (if account exists)" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
483
|
+
});
|
|
484
|
+
cequre.router.post(`${basePath}/reset-password`, async (ctx) => {
|
|
485
|
+
const req = ctx.request;
|
|
486
|
+
let data = await req.json().catch(() => ({}));
|
|
487
|
+
const hookCtx = { ...ctx, collection: collection.slug };
|
|
488
|
+
if (cequre.hooksMap.get(collection.slug)?.beforeResetPassword) {
|
|
489
|
+
const mutated = await cequre.hooksMap.get(collection.slug)?.beforeResetPassword?.({ ...hookCtx, data });
|
|
490
|
+
if (mutated !== undefined)
|
|
491
|
+
data = mutated;
|
|
492
|
+
}
|
|
493
|
+
await cequre.checkAccess(collection.slug, "resetPassword", { ...ctx, data });
|
|
494
|
+
if (!data.token || !data.password) {
|
|
495
|
+
return new Response(JSON.stringify({ error: "Token and new password are required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
496
|
+
}
|
|
497
|
+
const policyError = validatePasswordPolicy(data.password, cequre.schema.security?.auth?.passwordPolicy);
|
|
498
|
+
if (policyError)
|
|
499
|
+
return new Response(JSON.stringify({ error: policyError }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
500
|
+
if (!cequre.authEngine)
|
|
501
|
+
return new Response(JSON.stringify({ error: "Auth engine not configured" }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
502
|
+
const verified = await cequre.authEngine.verifyPasswordResetToken(data.token);
|
|
503
|
+
if (!verified || verified.collection !== collection.slug) {
|
|
504
|
+
return new Response(JSON.stringify({ error: "Invalid or expired reset token" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
505
|
+
}
|
|
506
|
+
const hashedPassword = await Bun.password.hash(data.password, "bcrypt");
|
|
507
|
+
await cequre.secureUpdate(collection.slug, verified.userId, { password: hashedPassword });
|
|
508
|
+
await cequre.authEngine.revokeAllForUser(verified.userId);
|
|
509
|
+
const user = await cequre.secureFindById(collection.slug, verified.userId);
|
|
510
|
+
if (user && cequre.mailer && user.email) {
|
|
511
|
+
await cequre.mailer.sendPasswordResetSuccess(user.email);
|
|
512
|
+
}
|
|
513
|
+
if (cequre.hooksMap.get(collection.slug)?.afterResetPassword) {
|
|
514
|
+
await cequre.hooksMap.get(collection.slug)?.afterResetPassword?.({ ...hookCtx, data: { user } });
|
|
515
|
+
}
|
|
516
|
+
return new Response(JSON.stringify({ message: "Password reset successfully" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
517
|
+
});
|
|
518
|
+
cequre.router.get(`${basePath}/me`, async (ctx) => {
|
|
519
|
+
if (!ctx.user) {
|
|
520
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
521
|
+
}
|
|
522
|
+
if (ctx.user.collection !== collection.slug) {
|
|
523
|
+
return new Response(JSON.stringify({ error: "Unauthorized for this collection" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
524
|
+
}
|
|
525
|
+
const result = await cequre.secureFindById(collection.slug, ctx.user.id);
|
|
526
|
+
if (!result) {
|
|
527
|
+
return new Response(JSON.stringify({ error: "User not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
528
|
+
}
|
|
529
|
+
const filteredUser = { ...result };
|
|
530
|
+
delete filteredUser.password;
|
|
531
|
+
return new Response(JSON.stringify(filteredUser), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
var init_auth_routes = __esm(() => {
|
|
538
|
+
init_mfa();
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// src/auth.ts
|
|
542
|
+
import { SignJWT, jwtVerify } from "jose";
|
|
543
|
+
|
|
544
|
+
// src/token-store.ts
|
|
545
|
+
import { createLogger } from "@cequrebackends/cequre-ts";
|
|
546
|
+
var log = createLogger("token-store");
|
|
547
|
+
|
|
548
|
+
class InMemoryTokenStore {
|
|
549
|
+
store_ = new Map;
|
|
550
|
+
async store(hash, entry) {
|
|
551
|
+
this.store_.set(hash, entry);
|
|
552
|
+
}
|
|
553
|
+
async lookup(hash) {
|
|
554
|
+
return this.store_.get(hash) ?? null;
|
|
555
|
+
}
|
|
556
|
+
async revoke(hash) {
|
|
557
|
+
this.store_.delete(hash);
|
|
558
|
+
}
|
|
559
|
+
async revokeAllForUser(userId) {
|
|
560
|
+
for (const [hash, entry] of this.store_) {
|
|
561
|
+
if (entry.userId === userId)
|
|
562
|
+
this.store_.delete(hash);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
async purgeExpired() {
|
|
566
|
+
const now = Date.now();
|
|
567
|
+
for (const [hash, entry] of this.store_) {
|
|
568
|
+
if (entry.expiresAt <= now)
|
|
569
|
+
this.store_.delete(hash);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
var REFRESH_TOKEN_COLLECTION = "cequre_refresh_tokens";
|
|
574
|
+
|
|
575
|
+
class AdapterTokenStore {
|
|
576
|
+
adapter;
|
|
577
|
+
constructor(adapter) {
|
|
578
|
+
this.adapter = adapter;
|
|
579
|
+
}
|
|
580
|
+
async store(hash, entry) {
|
|
581
|
+
const doc = {
|
|
582
|
+
id: hash,
|
|
583
|
+
tokenHash: hash,
|
|
584
|
+
userId: entry.userId,
|
|
585
|
+
collection: entry.collection,
|
|
586
|
+
expiresAt: entry.expiresAt
|
|
587
|
+
};
|
|
588
|
+
if (entry.role !== undefined && entry.role !== null) {
|
|
589
|
+
doc.role = entry.role;
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
const existing = await this.adapter.findById(REFRESH_TOKEN_COLLECTION, hash);
|
|
593
|
+
if (existing) {
|
|
594
|
+
const updateData = {
|
|
595
|
+
userId: entry.userId,
|
|
596
|
+
collection: entry.collection,
|
|
597
|
+
expiresAt: entry.expiresAt
|
|
598
|
+
};
|
|
599
|
+
if (entry.role !== undefined && entry.role !== null) {
|
|
600
|
+
updateData.role = entry.role;
|
|
601
|
+
}
|
|
602
|
+
await this.adapter.update(REFRESH_TOKEN_COLLECTION, hash, updateData);
|
|
603
|
+
} else {
|
|
604
|
+
await this.adapter.create(REFRESH_TOKEN_COLLECTION, doc);
|
|
605
|
+
}
|
|
606
|
+
} catch (err) {
|
|
607
|
+
log.warn({ err }, "Failed to persist refresh token to adapter \u2014 table may not exist yet");
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async lookup(hash) {
|
|
611
|
+
try {
|
|
612
|
+
const doc = await this.adapter.findById(REFRESH_TOKEN_COLLECTION, hash);
|
|
613
|
+
if (!doc)
|
|
614
|
+
return null;
|
|
615
|
+
return {
|
|
616
|
+
userId: doc.userId,
|
|
617
|
+
collection: doc.collection,
|
|
618
|
+
role: doc.role,
|
|
619
|
+
expiresAt: doc.expiresAt
|
|
620
|
+
};
|
|
621
|
+
} catch (err) {
|
|
622
|
+
log.warn({ err }, "Failed to look up refresh token from adapter \u2014 table may not exist yet");
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async revoke(hash) {
|
|
627
|
+
try {
|
|
628
|
+
await this.adapter.delete(REFRESH_TOKEN_COLLECTION, hash);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
log.warn({ err }, "Failed to revoke refresh token from adapter \u2014 table may not exist yet");
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async revokeAllForUser(userId) {
|
|
634
|
+
try {
|
|
635
|
+
const result = await this.adapter.find(REFRESH_TOKEN_COLLECTION, {
|
|
636
|
+
where: { userId: { eq: userId } },
|
|
637
|
+
limit: 1000
|
|
638
|
+
});
|
|
639
|
+
for (const doc of result.docs) {
|
|
640
|
+
await this.adapter.delete(REFRESH_TOKEN_COLLECTION, doc.id);
|
|
641
|
+
}
|
|
642
|
+
} catch (err) {
|
|
643
|
+
if (err?.message === "Database not connected")
|
|
644
|
+
return;
|
|
645
|
+
log.warn({ err }, "Failed to revoke all refresh tokens for user \u2014 table may not exist yet");
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async purgeExpired() {
|
|
649
|
+
const now = Date.now();
|
|
650
|
+
try {
|
|
651
|
+
const result = await this.adapter.find(REFRESH_TOKEN_COLLECTION, {
|
|
652
|
+
where: { expiresAt: { lt: now } },
|
|
653
|
+
limit: 1000
|
|
654
|
+
});
|
|
655
|
+
for (const doc of result.docs) {
|
|
656
|
+
await this.adapter.delete(REFRESH_TOKEN_COLLECTION, doc.id);
|
|
657
|
+
}
|
|
658
|
+
} catch (err) {
|
|
659
|
+
if (err?.message === "Database not connected")
|
|
660
|
+
return;
|
|
661
|
+
log.warn({ err }, "Failed to purge expired refresh tokens \u2014 table may not exist yet");
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// src/auth.ts
|
|
667
|
+
import { sha256Hex } from "@cequrebackends/cequre-ts";
|
|
668
|
+
import { createLogger as createLogger2 } from "@cequrebackends/cequre-ts";
|
|
669
|
+
import { ulid } from "@cequrebackends/cequre-ts";
|
|
670
|
+
var log2 = createLogger2("auth");
|
|
671
|
+
function generateId() {
|
|
672
|
+
return ulid();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
class JWTAuthenticator {
|
|
676
|
+
config;
|
|
677
|
+
adapter;
|
|
678
|
+
tokenStore;
|
|
679
|
+
collections;
|
|
680
|
+
platformAdminCollection;
|
|
681
|
+
cleanupTimer;
|
|
682
|
+
constructor(config, adapter, tokenStore, collections, platformAdminCollection) {
|
|
683
|
+
this.config = config;
|
|
684
|
+
this.adapter = adapter;
|
|
685
|
+
this.tokenStore = tokenStore ?? new InMemoryTokenStore;
|
|
686
|
+
this.collections = collections;
|
|
687
|
+
this.platformAdminCollection = platformAdminCollection;
|
|
688
|
+
this.cleanupTimer = setInterval(() => this.tokenStore.purgeExpired(), 5 * 60 * 1000);
|
|
689
|
+
this.cleanupTimer.unref();
|
|
690
|
+
}
|
|
691
|
+
async generateTokenPair(user, collection) {
|
|
692
|
+
const userId = user.id;
|
|
693
|
+
const role = user.role;
|
|
694
|
+
const secretKey = new TextEncoder().encode(this.config.secret);
|
|
695
|
+
let additionalPayload = {};
|
|
696
|
+
if (this.platformAdminCollection && collection === this.platformAdminCollection) {
|
|
697
|
+
additionalPayload.isPlatformAdmin = true;
|
|
698
|
+
}
|
|
699
|
+
if (this.collections) {
|
|
700
|
+
const colConfig = this.collections.find((c) => c.slug === collection);
|
|
701
|
+
if (colConfig) {
|
|
702
|
+
for (const field of colConfig.fields ?? []) {
|
|
703
|
+
if (field.saveToJWT) {
|
|
704
|
+
if (field.type === "link" && field.on && field.target) {
|
|
705
|
+
try {
|
|
706
|
+
const linkRes = await this.adapter.find(field.target, { where: { [field.on]: { eq: userId } }, limit: 1 });
|
|
707
|
+
if (linkRes && linkRes.docs && linkRes.docs.length > 0) {
|
|
708
|
+
additionalPayload[field.name] = linkRes.docs[0].id;
|
|
709
|
+
}
|
|
710
|
+
} catch (e) {}
|
|
711
|
+
} else if (user[field.name] !== undefined) {
|
|
712
|
+
additionalPayload[field.name] = user[field.name];
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
let accessBuilder = new SignJWT({ sub: userId, collection, role, ...additionalPayload }).setProtectedHeader({ alg: "HS256" }).setJti(generateId()).setExpirationTime(this.config.accessTokenExpiry ?? "15m");
|
|
719
|
+
const accessToken = await accessBuilder.sign(secretKey);
|
|
720
|
+
let refreshBuilder = new SignJWT({
|
|
721
|
+
sub: userId,
|
|
722
|
+
collection,
|
|
723
|
+
role,
|
|
724
|
+
type: "refresh"
|
|
725
|
+
}).setProtectedHeader({ alg: "HS256" }).setJti(generateId()).setExpirationTime(this.config.refreshTokenExpiry ?? "7d");
|
|
726
|
+
const refreshToken = await refreshBuilder.sign(secretKey);
|
|
727
|
+
const tokenHash = await sha256Hex(refreshToken);
|
|
728
|
+
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
|
|
729
|
+
await this.tokenStore.store(tokenHash, { userId, collection, role, expiresAt });
|
|
730
|
+
return {
|
|
731
|
+
accessToken,
|
|
732
|
+
refreshToken,
|
|
733
|
+
user: { id: userId, collection, role, ...user }
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
async refreshTokenPair(refreshToken) {
|
|
737
|
+
let result;
|
|
738
|
+
try {
|
|
739
|
+
result = await jwtVerify(refreshToken, new TextEncoder().encode(this.config.secret));
|
|
740
|
+
} catch {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
const { payload } = result;
|
|
744
|
+
if (payload.type !== "refresh")
|
|
745
|
+
return null;
|
|
746
|
+
const tokenHash = await sha256Hex(refreshToken);
|
|
747
|
+
const entry = await this.tokenStore.lookup(tokenHash);
|
|
748
|
+
if (!entry)
|
|
749
|
+
return null;
|
|
750
|
+
await this.tokenStore.revoke(tokenHash);
|
|
751
|
+
try {
|
|
752
|
+
const user = await this.adapter.findById(payload.collection, payload.sub);
|
|
753
|
+
if (!user)
|
|
754
|
+
return null;
|
|
755
|
+
return this.generateTokenPair(user, payload.collection);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
log2.error({ err }, "Failed to look up user during refresh token rotation");
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
async verifyAccessToken(token) {
|
|
762
|
+
try {
|
|
763
|
+
const { payload } = await jwtVerify(token, new TextEncoder().encode(this.config.secret));
|
|
764
|
+
const { sub, collection, role, exp, iat, nbf, iss, aud, jti, type, ...rest } = payload;
|
|
765
|
+
return {
|
|
766
|
+
id: sub,
|
|
767
|
+
collection,
|
|
768
|
+
role,
|
|
769
|
+
...rest
|
|
770
|
+
};
|
|
771
|
+
} catch {
|
|
772
|
+
if (this.config.previousSecret) {
|
|
773
|
+
try {
|
|
774
|
+
const { payload } = await jwtVerify(token, new TextEncoder().encode(this.config.previousSecret));
|
|
775
|
+
const { sub, collection, role, exp, iat, nbf, iss, aud, jti, type, ...rest } = payload;
|
|
776
|
+
return {
|
|
777
|
+
id: sub,
|
|
778
|
+
collection,
|
|
779
|
+
role,
|
|
780
|
+
...rest
|
|
781
|
+
};
|
|
782
|
+
} catch {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async revokeRefreshToken(refreshToken) {
|
|
790
|
+
const tokenHash = await sha256Hex(refreshToken);
|
|
791
|
+
await this.tokenStore.revoke(tokenHash);
|
|
792
|
+
}
|
|
793
|
+
async revokeAllForUser(userId) {
|
|
794
|
+
await this.tokenStore.revokeAllForUser(userId);
|
|
795
|
+
}
|
|
796
|
+
async generatePasswordResetToken(userId, collection) {
|
|
797
|
+
const secretKey = new TextEncoder().encode(this.config.secret);
|
|
798
|
+
let resetBuilder = new SignJWT({ sub: userId, collection, type: "reset" }).setProtectedHeader({ alg: "HS256" }).setJti(generateId()).setExpirationTime("1h");
|
|
799
|
+
return resetBuilder.sign(secretKey);
|
|
800
|
+
}
|
|
801
|
+
async verifyPasswordResetToken(token) {
|
|
802
|
+
return this.verifyTypedToken(token, "reset");
|
|
803
|
+
}
|
|
804
|
+
async generateMFAChallengeToken(userId, collection) {
|
|
805
|
+
const secretKey = new TextEncoder().encode(this.config.secret);
|
|
806
|
+
let mfaBuilder = new SignJWT({ sub: userId, collection, type: "mfa" }).setProtectedHeader({ alg: "HS256" }).setJti(generateId()).setExpirationTime("5m");
|
|
807
|
+
return mfaBuilder.sign(secretKey);
|
|
808
|
+
}
|
|
809
|
+
async verifyMFAChallengeToken(token) {
|
|
810
|
+
return this.verifyTypedToken(token, "mfa");
|
|
811
|
+
}
|
|
812
|
+
async verifyTypedToken(token, expectedType) {
|
|
813
|
+
for (const secret of [this.config.secret, this.config.previousSecret]) {
|
|
814
|
+
if (!secret)
|
|
815
|
+
continue;
|
|
816
|
+
try {
|
|
817
|
+
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret));
|
|
818
|
+
if (payload.type !== expectedType)
|
|
819
|
+
return null;
|
|
820
|
+
return {
|
|
821
|
+
userId: payload.sub,
|
|
822
|
+
collection: payload.collection
|
|
823
|
+
};
|
|
824
|
+
} catch {}
|
|
825
|
+
}
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// src/audit.ts
|
|
831
|
+
import { timingSafeEqual } from "@cequrebackends/cequre-ts";
|
|
832
|
+
var AUDIT_HKDF_LABEL = "cequre-audit-v1";
|
|
833
|
+
async function resolveAuditSecret(config) {
|
|
834
|
+
const explicit = config.security?.audit?.secret ?? process.env.CEQURE_AUDIT_SECRET;
|
|
835
|
+
const baseKey = explicit ?? process.env.JWT_SECRET ?? "";
|
|
836
|
+
if (!baseKey) {
|
|
837
|
+
throw new Error("Audit secret could not be resolved: provide audit.secret, CEQURE_AUDIT_SECRET, or JWT_SECRET.");
|
|
838
|
+
}
|
|
839
|
+
if (explicit) {
|
|
840
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(explicit), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
841
|
+
"sign",
|
|
842
|
+
"verify"
|
|
843
|
+
]);
|
|
844
|
+
}
|
|
845
|
+
const ikm = await crypto.subtle.importKey("raw", new TextEncoder().encode(baseKey), "HKDF", false, ["deriveKey"]);
|
|
846
|
+
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array, info: new TextEncoder().encode(AUDIT_HKDF_LABEL) }, ikm, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
847
|
+
}
|
|
848
|
+
function canonicalAuditPayload(event, prevHmac) {
|
|
849
|
+
return JSON.stringify({
|
|
850
|
+
action: event.action,
|
|
851
|
+
collection: event.collection ?? null,
|
|
852
|
+
recordId: event.recordId ?? null,
|
|
853
|
+
userId: event.userId ?? null,
|
|
854
|
+
requestId: event.requestId ?? null,
|
|
855
|
+
metadata: event.metadata ?? {},
|
|
856
|
+
prevHmac
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
async function computeAuditHmac(key, event, prevHmac) {
|
|
860
|
+
const data = new TextEncoder().encode(canonicalAuditPayload(event, prevHmac));
|
|
861
|
+
const sig = await crypto.subtle.sign("HMAC", key, data);
|
|
862
|
+
return Buffer.from(new Uint8Array(sig)).toString("hex");
|
|
863
|
+
}
|
|
864
|
+
async function verifyAuditHmac(key, event, prevHmac, storedHmac) {
|
|
865
|
+
const expected = await computeAuditHmac(key, event, prevHmac);
|
|
866
|
+
return timingSafeEqual(expected, storedHmac);
|
|
867
|
+
}
|
|
868
|
+
async function verifyAuditChain(key, entries) {
|
|
869
|
+
let prevHmac = null;
|
|
870
|
+
for (let i = 0;i < entries.length; i++) {
|
|
871
|
+
const entry = entries[i];
|
|
872
|
+
if (!entry)
|
|
873
|
+
continue;
|
|
874
|
+
const parsedMetadata = typeof entry.metadata === "string" ? JSON.parse(entry.metadata) : entry.metadata;
|
|
875
|
+
const ok = await verifyAuditHmac(key, { action: entry.action, collection: entry.collection, recordId: entry.recordId, userId: entry.userId, requestId: entry.requestId, metadata: parsedMetadata }, prevHmac, entry.hmac);
|
|
876
|
+
if (!ok)
|
|
877
|
+
return { ok: false, firstBadIndex: i };
|
|
878
|
+
prevHmac = entry.hmac;
|
|
879
|
+
}
|
|
880
|
+
return { ok: true, firstBadIndex: null };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// src/encryption.ts
|
|
884
|
+
import { createLogger as createLogger3 } from "@cequrebackends/cequre-ts";
|
|
885
|
+
var log3 = createLogger3("encryption");
|
|
886
|
+
var ENCRYPTION_PREFIX = "enc:v1:";
|
|
887
|
+
var HKDF_LABEL = "cequre-field-encryption-v1";
|
|
888
|
+
async function resolveEncryptionKey(passphrase) {
|
|
889
|
+
if (!passphrase || passphrase.length < 16) {
|
|
890
|
+
throw new Error("Encryption key must be at least 16 characters. Set CEQURE_ENCRYPTION_KEY.");
|
|
891
|
+
}
|
|
892
|
+
const ikm = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "HKDF", false, ["deriveKey"]);
|
|
893
|
+
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array, info: new TextEncoder().encode(HKDF_LABEL) }, ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
894
|
+
}
|
|
895
|
+
async function encryptField(key, plaintext) {
|
|
896
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
897
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
898
|
+
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
|
|
899
|
+
const ivB64 = Buffer.from(iv).toString("base64");
|
|
900
|
+
const ctB64 = Buffer.from(new Uint8Array(ciphertext)).toString("base64");
|
|
901
|
+
return `${ENCRYPTION_PREFIX}${ivB64}:${ctB64}`;
|
|
902
|
+
}
|
|
903
|
+
async function decryptField(key, value) {
|
|
904
|
+
if (!value || !value.startsWith(ENCRYPTION_PREFIX))
|
|
905
|
+
return null;
|
|
906
|
+
try {
|
|
907
|
+
const payload = value.slice(ENCRYPTION_PREFIX.length);
|
|
908
|
+
const colonIdx = payload.indexOf(":");
|
|
909
|
+
if (colonIdx === -1)
|
|
910
|
+
return null;
|
|
911
|
+
const ivB64 = payload.slice(0, colonIdx);
|
|
912
|
+
const ctB64 = payload.slice(colonIdx + 1);
|
|
913
|
+
if (!ivB64 || !ctB64)
|
|
914
|
+
return null;
|
|
915
|
+
const iv = Buffer.from(ivB64, "base64");
|
|
916
|
+
const ciphertext = Buffer.from(ctB64, "base64");
|
|
917
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
|
|
918
|
+
return new TextDecoder().decode(plaintext);
|
|
919
|
+
} catch (err) {
|
|
920
|
+
log3.warn({ err }, "Field decryption failed \u2014 wrong key or tampered ciphertext");
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function isEncrypted(value) {
|
|
925
|
+
return typeof value === "string" && value.startsWith(ENCRYPTION_PREFIX);
|
|
926
|
+
}
|
|
927
|
+
// src/rate-limiter.ts
|
|
928
|
+
import { parseDuration } from "@cequrebackends/cequre-ts";
|
|
929
|
+
import { extractPathname } from "@cequrebackends/cequre-ts";
|
|
930
|
+
function resolveClientIP(request, trustedProxies) {
|
|
931
|
+
const remoteAddr = request.__remoteAddr;
|
|
932
|
+
if (remoteAddr) {
|
|
933
|
+
if (!trustedProxies || trustedProxies.length === 0) {
|
|
934
|
+
return remoteAddr;
|
|
935
|
+
}
|
|
936
|
+
if (!trustedProxies.includes(remoteAddr)) {
|
|
937
|
+
return remoteAddr;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const xff = request.headers.get("x-forwarded-for");
|
|
941
|
+
if (xff) {
|
|
942
|
+
const first = xff.split(",")[0]?.trim();
|
|
943
|
+
if (first)
|
|
944
|
+
return first;
|
|
945
|
+
}
|
|
946
|
+
const xRealIP = request.headers.get("x-real-ip");
|
|
947
|
+
if (xRealIP)
|
|
948
|
+
return xRealIP.trim();
|
|
949
|
+
return remoteAddr || "unknown-ip";
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
class RateLimiter {
|
|
953
|
+
kv;
|
|
954
|
+
schema;
|
|
955
|
+
hasRateLimit;
|
|
956
|
+
trustedProxies;
|
|
957
|
+
constructor(schema, kv, trustedProxies) {
|
|
958
|
+
this.kv = kv;
|
|
959
|
+
this.schema = schema;
|
|
960
|
+
this.hasRateLimit = !!schema.security?.rateLimit;
|
|
961
|
+
this.trustedProxies = trustedProxies ?? schema.core?.api?.trustedProxies ?? [];
|
|
962
|
+
}
|
|
963
|
+
check(request) {
|
|
964
|
+
if (!this.hasRateLimit)
|
|
965
|
+
return null;
|
|
966
|
+
const rateLimitConfig = this.schema.security.rateLimit;
|
|
967
|
+
const method = request.method;
|
|
968
|
+
const pathname = extractPathname(request.url);
|
|
969
|
+
const isLogin = method === "POST" && (pathname.endsWith("/login") || pathname.endsWith("/register"));
|
|
970
|
+
const isWrite = (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") && !isLogin;
|
|
971
|
+
const isRead = method === "GET";
|
|
972
|
+
let config;
|
|
973
|
+
let type = "";
|
|
974
|
+
if (isLogin && rateLimitConfig.login) {
|
|
975
|
+
config = rateLimitConfig.login;
|
|
976
|
+
type = "login";
|
|
977
|
+
} else if (isWrite && rateLimitConfig.write) {
|
|
978
|
+
config = rateLimitConfig.write;
|
|
979
|
+
type = "write";
|
|
980
|
+
} else if (isRead && rateLimitConfig.read) {
|
|
981
|
+
config = rateLimitConfig.read;
|
|
982
|
+
type = "read";
|
|
983
|
+
}
|
|
984
|
+
if (!config || !config.max)
|
|
985
|
+
return null;
|
|
986
|
+
const envMax = Number(globalThis.process?.env?.RATE_LIMIT_MAX);
|
|
987
|
+
if (!Number.isNaN(envMax) && envMax > 0) {
|
|
988
|
+
config = { ...config, max: Math.max(config.max, envMax) };
|
|
989
|
+
}
|
|
990
|
+
const windowSec = parseDuration(config.window || "1m");
|
|
991
|
+
const clientIP = resolveClientIP(request, this.trustedProxies);
|
|
992
|
+
const key = `ratelimit:${type}:${clientIP}`;
|
|
993
|
+
return this._doCheckAsync(key, config.max, windowSec);
|
|
994
|
+
}
|
|
995
|
+
async _doCheckAsync(key, max, windowSec) {
|
|
996
|
+
const current = await this.kv.get(key) || 0;
|
|
997
|
+
if (current >= max) {
|
|
998
|
+
return new Response(JSON.stringify({ error: "Too Many Requests" }), { status: 429, headers: { "Content-Type": "application/json" } });
|
|
999
|
+
}
|
|
1000
|
+
await this.kv.set(key, current + 1, windowSec);
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// src/index.ts
|
|
1006
|
+
init_mfa();
|
|
1007
|
+
|
|
1008
|
+
// src/secrets.ts
|
|
1009
|
+
class EnvVarSecrets {
|
|
1010
|
+
async getSecret(name) {
|
|
1011
|
+
return process.env[name] ?? null;
|
|
1012
|
+
}
|
|
1013
|
+
async listSecrets() {
|
|
1014
|
+
return Object.keys(process.env).filter((k) => k.startsWith("CEQURE_") || k === "JWT_SECRET");
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
// src/webhooks.ts
|
|
1018
|
+
var HEADER_PREFIX = "X-Cequre-Signature";
|
|
1019
|
+
async function signWebhook(secret, payload, timestamp) {
|
|
1020
|
+
const ts = timestamp ?? Math.floor(Date.now() / 1000);
|
|
1021
|
+
const signedPayload = `${ts}.${payload}`;
|
|
1022
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
1023
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signedPayload));
|
|
1024
|
+
const hex = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1025
|
+
return `t=${ts},v1=${hex}`;
|
|
1026
|
+
}
|
|
1027
|
+
var WEBHOOK_SIGNATURE_HEADER = HEADER_PREFIX;
|
|
1028
|
+
async function verifyWebhookSignature(secret, headerValue, payload, toleranceSeconds = 300) {
|
|
1029
|
+
const parts = headerValue.split(",");
|
|
1030
|
+
let ts = null;
|
|
1031
|
+
let v1 = null;
|
|
1032
|
+
for (const part of parts) {
|
|
1033
|
+
const [key2, value] = part.split("=");
|
|
1034
|
+
if (key2 === "t" && value)
|
|
1035
|
+
ts = parseInt(value, 10);
|
|
1036
|
+
if (key2 === "v1" && value)
|
|
1037
|
+
v1 = value;
|
|
1038
|
+
}
|
|
1039
|
+
if (ts === null || v1 === null)
|
|
1040
|
+
return false;
|
|
1041
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1042
|
+
if (Math.abs(now - ts) > toleranceSeconds)
|
|
1043
|
+
return false;
|
|
1044
|
+
const signedPayload = `${ts}.${payload}`;
|
|
1045
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
1046
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signedPayload));
|
|
1047
|
+
const expectedHex = Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1048
|
+
if (expectedHex.length !== v1.length)
|
|
1049
|
+
return false;
|
|
1050
|
+
let diff = 0;
|
|
1051
|
+
for (let i = 0;i < expectedHex.length; i++) {
|
|
1052
|
+
diff |= expectedHex.charCodeAt(i) ^ v1.charCodeAt(i);
|
|
1053
|
+
}
|
|
1054
|
+
return diff === 0;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// src/index.ts
|
|
1058
|
+
init_auth_routes();
|
|
1059
|
+
function defaultSecurity() {
|
|
1060
|
+
return {
|
|
1061
|
+
name: "core:security",
|
|
1062
|
+
onInit: async (cequre) => {
|
|
1063
|
+
if (cequre.schema.security?.auth?.strategies?.includes("jwt")) {
|
|
1064
|
+
const secret = process.env.JWT_SECRET;
|
|
1065
|
+
if (!secret) {
|
|
1066
|
+
throw new Error("JWT_SECRET environment variable is required when JWT auth strategy is enabled in config.cequre.");
|
|
1067
|
+
}
|
|
1068
|
+
const jwtSettings = cequre.schema.security.auth.jwt || {};
|
|
1069
|
+
const tokenStore = cequre.adapter.getSystemTableStatements ? new AdapterTokenStore(cequre.adapter) : new InMemoryTokenStore;
|
|
1070
|
+
const { setupAuthRoutes: setupAuthRoutes2 } = (init_auth_routes(), __toCommonJS(exports_auth_routes));
|
|
1071
|
+
setupAuthRoutes2(cequre);
|
|
1072
|
+
cequre.setAuthEngine(new JWTAuthenticator({
|
|
1073
|
+
secret,
|
|
1074
|
+
previousSecret: process.env.JWT_PREVIOUS_SECRET || undefined,
|
|
1075
|
+
accessTokenExpiry: jwtSettings.accessTokenExpiry || "15m",
|
|
1076
|
+
refreshTokenExpiry: jwtSettings.refreshTokenExpiry || "7d"
|
|
1077
|
+
}, cequre.adapter, tokenStore, cequre.schema.collections, cequre.schema.adminUI?.user));
|
|
1078
|
+
}
|
|
1079
|
+
if (cequre.schema.security?.audit?.enabled === true) {
|
|
1080
|
+
cequre.enableAudit(resolveAuditSecret(cequre.schema), cequre.schema.security?.audit?.retentionDays);
|
|
1081
|
+
}
|
|
1082
|
+
if (cequre.schema.security?.secrets?.enabled) {
|
|
1083
|
+
const keyEnvVar = cequre.schema.security.secrets.encryptionKey || "CEQURE_ENCRYPTION_KEY";
|
|
1084
|
+
const passphrase = process.env[keyEnvVar];
|
|
1085
|
+
if (!passphrase) {
|
|
1086
|
+
console.error(`Field encryption is enabled but ${keyEnvVar} is not set. Encryption will not be active.`);
|
|
1087
|
+
} else {
|
|
1088
|
+
cequre.enableEncryption(resolveEncryptionKey(passphrase));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
setupAuthRoutes(cequre);
|
|
1092
|
+
},
|
|
1093
|
+
onCompile: (ctx) => {
|
|
1094
|
+
if (ctx.phase === "request.start") {
|
|
1095
|
+
let snippet = "";
|
|
1096
|
+
snippet += `
|
|
1097
|
+
if (!!${ctx.vars.ctx}.options.rateLimiter) {
|
|
1098
|
+
const rateResult = ${ctx.vars.ctx}.options.rateLimiter.check(${ctx.vars.request});
|
|
1099
|
+
if (rateResult instanceof Promise) {
|
|
1100
|
+
return rateResult.then((rateRes) => rateRes ?? dispatch(${ctx.vars.request}, ${ctx.vars.server}, ${ctx.vars.pluginState}));
|
|
1101
|
+
}
|
|
1102
|
+
if (rateResult) return ${ctx.vars.ctx}.hasResponseHeaders ? ${ctx.vars.ctx}.withHeaders(rateResult, ${ctx.vars.ctx}.requestResponseHeaders(${ctx.vars.request}, undefined)) : rateResult;
|
|
1103
|
+
}
|
|
1104
|
+
`;
|
|
1105
|
+
snippet += `
|
|
1106
|
+
if (!!${ctx.vars.ctx}.options.idempotencyKV && ["POST", "PUT", "PATCH"].includes(${ctx.vars.ctx}.route.method)) {
|
|
1107
|
+
const idempotencyKey = ${ctx.vars.request}.headers.get("idempotency-key");
|
|
1108
|
+
if (idempotencyKey) {
|
|
1109
|
+
const cacheKey = \`idempotency:\${idempotencyKey}\`;
|
|
1110
|
+
return ${ctx.vars.ctx}.options.idempotencyKV.get(cacheKey).then(async (cached) => {
|
|
1111
|
+
if (cached) {
|
|
1112
|
+
const { status, body, contentType } = JSON.parse(cached);
|
|
1113
|
+
const cachedRes = new Response(body, { status, headers: { "Content-Type": contentType || "application/json", "X-Idempotent-Replay": "true" } });
|
|
1114
|
+
return ${ctx.vars.ctx}.hasResponseHeaders ? ${ctx.vars.ctx}.withHeaders(cachedRes, ${ctx.vars.ctx}.requestResponseHeaders(${ctx.vars.request}, undefined)) : cachedRes;
|
|
1115
|
+
}
|
|
1116
|
+
const res = dispatch(${ctx.vars.request}, ${ctx.vars.server}, ${ctx.vars.pluginState});
|
|
1117
|
+
const resolved = res instanceof Promise ? await res : res;
|
|
1118
|
+
if (resolved && resolved.status >= 200 && resolved.status < 400) {
|
|
1119
|
+
const resBody = await resolved.text();
|
|
1120
|
+
const entry = JSON.stringify({ status: resolved.status, body: resBody, contentType: resolved.headers.get("Content-Type") || "application/json" });
|
|
1121
|
+
await ${ctx.vars.ctx}.options.idempotencyKV.set(cacheKey, entry, 24 * 60 * 60);
|
|
1122
|
+
const newRes = new Response(resBody, { status: resolved.status, headers: { "Content-Type": resolved.headers.get("Content-Type") || "application/json" } });
|
|
1123
|
+
return ${ctx.vars.ctx}.hasResponseHeaders ? ${ctx.vars.ctx}.withHeaders(newRes, ${ctx.vars.ctx}.requestResponseHeaders(${ctx.vars.request}, undefined)) : newRes;
|
|
1124
|
+
}
|
|
1125
|
+
return resolved;
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
`;
|
|
1130
|
+
return snippet;
|
|
1131
|
+
}
|
|
1132
|
+
if (ctx.phase === "beforeHandler") {
|
|
1133
|
+
let snippet = "";
|
|
1134
|
+
snippet += `
|
|
1135
|
+
if (["POST", "PUT", "PATCH", "DELETE"].includes(${ctx.vars.ctx}.route.method)) {
|
|
1136
|
+
const handlerReq = ${ctx.vars.routeCtx}.request;
|
|
1137
|
+
const hasCookie = handlerReq.headers.has("cookie");
|
|
1138
|
+
if (hasCookie) {
|
|
1139
|
+
const origin = handlerReq.headers.get("origin");
|
|
1140
|
+
const referer = handlerReq.headers.get("referer");
|
|
1141
|
+
const host = handlerReq.headers.get("host");
|
|
1142
|
+
const hasCustomHeader = handlerReq.headers.get("x-cequre-csrf") !== null ||
|
|
1143
|
+
handlerReq.headers.get("x-csrf-token") !== null ||
|
|
1144
|
+
handlerReq.headers.get("x-requested-with") !== null;
|
|
1145
|
+
|
|
1146
|
+
let isSameOrigin = false;
|
|
1147
|
+
if (origin) {
|
|
1148
|
+
try { isSameOrigin = new URL(origin).host === host; } catch {}
|
|
1149
|
+
} else if (referer) {
|
|
1150
|
+
try { isSameOrigin = new URL(referer).host === host; } catch {}
|
|
1151
|
+
}
|
|
1152
|
+
if (!isSameOrigin && origin && Array.isArray(${ctx.vars.ctx}.options.csrfTrustedOrigins)) {
|
|
1153
|
+
isSameOrigin = ${ctx.vars.ctx}.options.csrfTrustedOrigins.includes(origin);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if (!isSameOrigin && !hasCustomHeader) {
|
|
1157
|
+
throw new ${ctx.vars.ctx}.CequreError("CSRF verification failed. Missing secure origin or custom header.", "CSRF_ERROR", 403);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
`;
|
|
1162
|
+
snippet += `
|
|
1163
|
+
if (${ctx.vars.ctx}.route.options?.authRequired === true && !${ctx.vars.routeCtx}.user) {
|
|
1164
|
+
throw new ${ctx.vars.ctx}.CequreError("Authentication required", "UNAUTHORIZED", 401);
|
|
1165
|
+
}
|
|
1166
|
+
`;
|
|
1167
|
+
return snippet;
|
|
1168
|
+
}
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
export {
|
|
1174
|
+
verifyWebhookSignature,
|
|
1175
|
+
verifyTOTP,
|
|
1176
|
+
verifyAuditHmac,
|
|
1177
|
+
verifyAuditChain,
|
|
1178
|
+
signWebhook,
|
|
1179
|
+
resolveEncryptionKey,
|
|
1180
|
+
resolveClientIP,
|
|
1181
|
+
resolveAuditSecret,
|
|
1182
|
+
isEncrypted,
|
|
1183
|
+
hashBackupCode,
|
|
1184
|
+
generateTOTPSecret,
|
|
1185
|
+
generateTOTP,
|
|
1186
|
+
generateOTPAuthURI,
|
|
1187
|
+
generateBackupCodes,
|
|
1188
|
+
encryptField,
|
|
1189
|
+
defaultSecurity,
|
|
1190
|
+
decryptField,
|
|
1191
|
+
computeAuditHmac,
|
|
1192
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
1193
|
+
RateLimiter,
|
|
1194
|
+
REFRESH_TOKEN_COLLECTION,
|
|
1195
|
+
JWTAuthenticator,
|
|
1196
|
+
InMemoryTokenStore,
|
|
1197
|
+
EnvVarSecrets,
|
|
1198
|
+
AdapterTokenStore
|
|
1199
|
+
};
|