@vertz/server 0.2.12 → 0.2.14
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 +76 -0
- package/dist/index.d.ts +1001 -156
- package/dist/index.js +3491 -512
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -17,275 +17,2145 @@ import {
|
|
|
17
17
|
vertz
|
|
18
18
|
} from "@vertz/core";
|
|
19
19
|
|
|
20
|
-
// src/action/action.ts
|
|
21
|
-
import { deepFreeze } from "@vertz/core";
|
|
22
|
-
var ACTION_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
23
|
-
function action(name, config) {
|
|
24
|
-
if (!name || !ACTION_NAME_PATTERN.test(name)) {
|
|
25
|
-
throw new Error(`action() name must be a non-empty lowercase string matching /^[a-z][a-z0-9-]*$/. Got: "${name}"`);
|
|
26
|
-
}
|
|
27
|
-
if (!config.actions || Object.keys(config.actions).length === 0) {
|
|
28
|
-
throw new Error("action() requires at least one action in the actions config.");
|
|
29
|
-
}
|
|
30
|
-
const def = {
|
|
31
|
-
kind: "action",
|
|
32
|
-
name,
|
|
33
|
-
inject: config.inject ?? {},
|
|
34
|
-
access: config.access ?? {},
|
|
35
|
-
actions: config.actions
|
|
36
|
-
};
|
|
37
|
-
return deepFreeze(def);
|
|
38
|
-
}
|
|
39
20
|
// src/auth/index.ts
|
|
40
21
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
41
22
|
import { join } from "node:path";
|
|
42
23
|
import {
|
|
43
24
|
createAuthRateLimitedError,
|
|
44
|
-
createAuthValidationError,
|
|
25
|
+
createAuthValidationError as createAuthValidationError2,
|
|
45
26
|
createInvalidCredentialsError,
|
|
27
|
+
createMfaAlreadyEnabledError,
|
|
28
|
+
createMfaInvalidCodeError,
|
|
29
|
+
createMfaNotEnabledError,
|
|
30
|
+
createMfaRequiredError,
|
|
46
31
|
createSessionExpiredError,
|
|
32
|
+
createSessionNotFoundError,
|
|
33
|
+
createTokenExpiredError,
|
|
34
|
+
createTokenInvalidError,
|
|
47
35
|
createUserExistsError,
|
|
48
36
|
err,
|
|
49
37
|
ok
|
|
50
38
|
} from "@vertz/errors";
|
|
51
|
-
import bcrypt from "bcryptjs";
|
|
52
|
-
import * as jose from "jose";
|
|
53
39
|
|
|
54
|
-
// src/auth/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
constructor(message, entitlement, userId) {
|
|
59
|
-
super(message);
|
|
60
|
-
this.entitlement = entitlement;
|
|
61
|
-
this.userId = userId;
|
|
62
|
-
this.name = "AuthorizationError";
|
|
40
|
+
// src/auth/billing-period.ts
|
|
41
|
+
function calculateBillingPeriod(startedAt, per, now = new Date) {
|
|
42
|
+
if (per === "month") {
|
|
43
|
+
return calculateMonthlyPeriod(startedAt, now);
|
|
63
44
|
}
|
|
45
|
+
const durationMs = per === "day" ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
|
46
|
+
const elapsed = now.getTime() - startedAt.getTime();
|
|
47
|
+
const periodsElapsed = Math.floor(elapsed / durationMs);
|
|
48
|
+
const periodStart = new Date(startedAt.getTime() + periodsElapsed * durationMs);
|
|
49
|
+
const periodEnd = new Date(periodStart.getTime() + durationMs);
|
|
50
|
+
return { periodStart, periodEnd };
|
|
64
51
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
for (const [roleName, roleDef] of Object.entries(roles)) {
|
|
69
|
-
roleEntitlements.set(roleName, new Set(roleDef.entitlements));
|
|
52
|
+
function calculateMonthlyPeriod(startedAt, now) {
|
|
53
|
+
if (now.getTime() < startedAt.getTime()) {
|
|
54
|
+
return { periodStart: new Date(startedAt), periodEnd: addMonths(startedAt, 1) };
|
|
70
55
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
56
|
+
let periodStart = new Date(startedAt);
|
|
57
|
+
const approxMonths = (now.getUTCFullYear() - startedAt.getUTCFullYear()) * 12 + (now.getUTCMonth() - startedAt.getUTCMonth());
|
|
58
|
+
if (approxMonths > 0) {
|
|
59
|
+
periodStart = addMonths(startedAt, approxMonths);
|
|
60
|
+
if (periodStart.getTime() > now.getTime()) {
|
|
61
|
+
periodStart = addMonths(startedAt, approxMonths - 1);
|
|
62
|
+
}
|
|
74
63
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
64
|
+
const periodEnd = addMonths(startedAt, getMonthOffset(startedAt, periodStart) + 1);
|
|
65
|
+
return { periodStart, periodEnd };
|
|
66
|
+
}
|
|
67
|
+
function addMonths(base, months) {
|
|
68
|
+
const result = new Date(base);
|
|
69
|
+
const targetMonth = result.getUTCMonth() + months;
|
|
70
|
+
result.setUTCMonth(targetMonth);
|
|
71
|
+
const expectedMonth = ((base.getUTCMonth() + months) % 12 + 12) % 12;
|
|
72
|
+
if (result.getUTCMonth() !== expectedMonth) {
|
|
73
|
+
result.setUTCDate(0);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
function getMonthOffset(base, target) {
|
|
78
|
+
return (target.getUTCFullYear() - base.getUTCFullYear()) * 12 + (target.getUTCMonth() - base.getUTCMonth());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/auth/plan-store.ts
|
|
82
|
+
function resolveEffectivePlan(orgPlan, plans, defaultPlan = "free", now) {
|
|
83
|
+
if (!orgPlan)
|
|
84
|
+
return null;
|
|
85
|
+
if (orgPlan.expiresAt && orgPlan.expiresAt.getTime() < (now ?? Date.now())) {
|
|
86
|
+
return plans?.[defaultPlan] ? defaultPlan : null;
|
|
87
|
+
}
|
|
88
|
+
if (!plans?.[orgPlan.planId])
|
|
89
|
+
return null;
|
|
90
|
+
return orgPlan.planId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
class InMemoryPlanStore {
|
|
94
|
+
plans = new Map;
|
|
95
|
+
async assignPlan(orgId, planId, startedAt = new Date, expiresAt = null) {
|
|
96
|
+
this.plans.set(orgId, {
|
|
97
|
+
orgId,
|
|
98
|
+
planId,
|
|
99
|
+
startedAt,
|
|
100
|
+
expiresAt,
|
|
101
|
+
overrides: {}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async getPlan(orgId) {
|
|
105
|
+
return this.plans.get(orgId) ?? null;
|
|
106
|
+
}
|
|
107
|
+
async updateOverrides(orgId, overrides) {
|
|
108
|
+
const plan = this.plans.get(orgId);
|
|
109
|
+
if (!plan)
|
|
110
|
+
return;
|
|
111
|
+
plan.overrides = { ...plan.overrides, ...overrides };
|
|
112
|
+
}
|
|
113
|
+
async removePlan(orgId) {
|
|
114
|
+
this.plans.delete(orgId);
|
|
115
|
+
}
|
|
116
|
+
dispose() {
|
|
117
|
+
this.plans.clear();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/auth/access-set.ts
|
|
122
|
+
async function computeAccessSet(config) {
|
|
123
|
+
const {
|
|
124
|
+
userId,
|
|
125
|
+
accessDef,
|
|
126
|
+
roleStore,
|
|
127
|
+
closureStore,
|
|
128
|
+
plan,
|
|
129
|
+
flagStore,
|
|
130
|
+
planStore,
|
|
131
|
+
walletStore,
|
|
132
|
+
orgId
|
|
133
|
+
} = config;
|
|
134
|
+
const entitlements = {};
|
|
135
|
+
let resolvedPlan = plan ?? null;
|
|
136
|
+
if (!userId) {
|
|
137
|
+
for (const name of Object.keys(accessDef.entitlements)) {
|
|
138
|
+
entitlements[name] = {
|
|
139
|
+
allowed: false,
|
|
140
|
+
reasons: ["not_authenticated"],
|
|
141
|
+
reason: "not_authenticated"
|
|
142
|
+
};
|
|
86
143
|
}
|
|
144
|
+
return {
|
|
145
|
+
entitlements,
|
|
146
|
+
flags: {},
|
|
147
|
+
plan: plan ?? null,
|
|
148
|
+
computedAt: new Date().toISOString()
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const assignments = await roleStore.getRolesForUser(userId);
|
|
152
|
+
const effectiveRolesByType = new Map;
|
|
153
|
+
for (const assignment of assignments) {
|
|
154
|
+
addRole(effectiveRolesByType, assignment.resourceType, assignment.role);
|
|
155
|
+
const descendants = await closureStore.getDescendants(assignment.resourceType, assignment.resourceId);
|
|
156
|
+
for (const desc of descendants) {
|
|
157
|
+
if (desc.depth === 0)
|
|
158
|
+
continue;
|
|
159
|
+
const inheritedRole = resolveInheritedRole(assignment.resourceType, assignment.role, desc.type, accessDef);
|
|
160
|
+
if (inheritedRole) {
|
|
161
|
+
addRole(effectiveRolesByType, desc.type, inheritedRole);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const allRoles = new Set;
|
|
166
|
+
for (const roles of effectiveRolesByType.values()) {
|
|
167
|
+
for (const role of roles) {
|
|
168
|
+
allRoles.add(role);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const [name, entDef] of Object.entries(accessDef.entitlements)) {
|
|
172
|
+
if (entDef.roles.length === 0) {
|
|
173
|
+
entitlements[name] = { allowed: true, reasons: [] };
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const hasRequiredRole = entDef.roles.some((r) => allRoles.has(r));
|
|
177
|
+
if (hasRequiredRole) {
|
|
178
|
+
entitlements[name] = { allowed: true, reasons: [] };
|
|
179
|
+
} else {
|
|
180
|
+
entitlements[name] = {
|
|
181
|
+
allowed: false,
|
|
182
|
+
reasons: ["role_required"],
|
|
183
|
+
reason: "role_required",
|
|
184
|
+
meta: { requiredRoles: [...entDef.roles] }
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const resolvedFlags = {};
|
|
189
|
+
if (flagStore && orgId) {
|
|
190
|
+
const orgFlags = flagStore.getFlags(orgId);
|
|
191
|
+
Object.assign(resolvedFlags, orgFlags);
|
|
192
|
+
for (const [name, entDef] of Object.entries(accessDef.entitlements)) {
|
|
193
|
+
if (entDef.flags?.length) {
|
|
194
|
+
const disabledFlags = [];
|
|
195
|
+
for (const flag of entDef.flags) {
|
|
196
|
+
if (!flagStore.getFlag(orgId, flag)) {
|
|
197
|
+
disabledFlags.push(flag);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (disabledFlags.length > 0) {
|
|
201
|
+
const entry = entitlements[name];
|
|
202
|
+
const reasons = [...entry.reasons];
|
|
203
|
+
if (!reasons.includes("flag_disabled"))
|
|
204
|
+
reasons.push("flag_disabled");
|
|
205
|
+
entitlements[name] = {
|
|
206
|
+
...entry,
|
|
207
|
+
allowed: false,
|
|
208
|
+
reasons,
|
|
209
|
+
reason: reasons[0],
|
|
210
|
+
meta: { ...entry.meta, disabledFlags }
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (planStore && orgId) {
|
|
217
|
+
const orgPlan = await planStore.getPlan(orgId);
|
|
218
|
+
if (orgPlan) {
|
|
219
|
+
const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
|
|
220
|
+
resolvedPlan = effectivePlanId;
|
|
221
|
+
if (effectivePlanId) {
|
|
222
|
+
const planDef = accessDef.plans?.[effectivePlanId];
|
|
223
|
+
if (planDef) {
|
|
224
|
+
for (const [name, entDef] of Object.entries(accessDef.entitlements)) {
|
|
225
|
+
if (entDef.plans?.length && !planDef.entitlements.includes(name)) {
|
|
226
|
+
const entry = entitlements[name];
|
|
227
|
+
const reasons = [...entry.reasons];
|
|
228
|
+
if (!reasons.includes("plan_required"))
|
|
229
|
+
reasons.push("plan_required");
|
|
230
|
+
entitlements[name] = {
|
|
231
|
+
...entry,
|
|
232
|
+
allowed: false,
|
|
233
|
+
reasons,
|
|
234
|
+
reason: reasons[0],
|
|
235
|
+
meta: { ...entry.meta, requiredPlans: [...entDef.plans] }
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (walletStore && planDef.limits?.[name]) {
|
|
239
|
+
const limitDef = planDef.limits[name];
|
|
240
|
+
const override = orgPlan.overrides[name];
|
|
241
|
+
const effectiveLimit = override ? Math.max(override.max, limitDef.max) : limitDef.max;
|
|
242
|
+
const { periodStart, periodEnd } = calculateBillingPeriod(orgPlan.startedAt, limitDef.per);
|
|
243
|
+
const consumed = await walletStore.getConsumption(orgId, name, periodStart, periodEnd);
|
|
244
|
+
const remaining = Math.max(0, effectiveLimit - consumed);
|
|
245
|
+
const entry = entitlements[name];
|
|
246
|
+
if (consumed >= effectiveLimit) {
|
|
247
|
+
const reasons = [...entry.reasons];
|
|
248
|
+
if (!reasons.includes("limit_reached"))
|
|
249
|
+
reasons.push("limit_reached");
|
|
250
|
+
entitlements[name] = {
|
|
251
|
+
...entry,
|
|
252
|
+
allowed: false,
|
|
253
|
+
reasons,
|
|
254
|
+
reason: reasons[0],
|
|
255
|
+
meta: {
|
|
256
|
+
...entry.meta,
|
|
257
|
+
limit: { max: effectiveLimit, consumed, remaining }
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
} else {
|
|
261
|
+
entitlements[name] = {
|
|
262
|
+
...entry,
|
|
263
|
+
meta: {
|
|
264
|
+
...entry.meta,
|
|
265
|
+
limit: { max: effectiveLimit, consumed, remaining }
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
entitlements,
|
|
277
|
+
flags: resolvedFlags,
|
|
278
|
+
plan: resolvedPlan,
|
|
279
|
+
computedAt: new Date().toISOString()
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function encodeAccessSet(set) {
|
|
283
|
+
const entitlements = {};
|
|
284
|
+
for (const [name, check] of Object.entries(set.entitlements)) {
|
|
285
|
+
if (check.allowed) {
|
|
286
|
+
const entry = { allowed: true };
|
|
287
|
+
if (check.meta?.limit) {
|
|
288
|
+
entry.meta = { limit: { ...check.meta.limit } };
|
|
289
|
+
}
|
|
290
|
+
entitlements[name] = entry;
|
|
291
|
+
} else if (check.meta && Object.keys(check.meta).length > 0) {
|
|
292
|
+
const strippedMeta = { ...check.meta };
|
|
293
|
+
delete strippedMeta.requiredRoles;
|
|
294
|
+
delete strippedMeta.requiredPlans;
|
|
295
|
+
const hasRemainingMeta = Object.keys(strippedMeta).length > 0;
|
|
296
|
+
entitlements[name] = {
|
|
297
|
+
allowed: false,
|
|
298
|
+
...check.reasons.length > 0 ? { reasons: [...check.reasons] } : {},
|
|
299
|
+
...check.reason ? { reason: check.reason } : {},
|
|
300
|
+
...hasRemainingMeta ? { meta: strippedMeta } : {}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
entitlements,
|
|
306
|
+
flags: { ...set.flags },
|
|
307
|
+
plan: set.plan,
|
|
308
|
+
computedAt: set.computedAt
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function decodeAccessSet(encoded, accessDef) {
|
|
312
|
+
const entitlements = {};
|
|
313
|
+
for (const [name, check] of Object.entries(encoded.entitlements)) {
|
|
314
|
+
entitlements[name] = {
|
|
315
|
+
allowed: check.allowed,
|
|
316
|
+
reasons: check.reasons ?? [],
|
|
317
|
+
...check.reason ? { reason: check.reason } : {},
|
|
318
|
+
...check.meta ? { meta: check.meta } : {}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
for (const name of Object.keys(accessDef.entitlements)) {
|
|
322
|
+
if (!(name in entitlements)) {
|
|
323
|
+
entitlements[name] = {
|
|
324
|
+
allowed: false,
|
|
325
|
+
reasons: ["role_required"],
|
|
326
|
+
reason: "role_required"
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
entitlements,
|
|
332
|
+
flags: { ...encoded.flags },
|
|
333
|
+
plan: encoded.plan,
|
|
334
|
+
computedAt: encoded.computedAt
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function addRole(map, resourceType, role) {
|
|
338
|
+
let roles = map.get(resourceType);
|
|
339
|
+
if (!roles) {
|
|
340
|
+
roles = new Set;
|
|
341
|
+
map.set(resourceType, roles);
|
|
342
|
+
}
|
|
343
|
+
roles.add(role);
|
|
344
|
+
}
|
|
345
|
+
function resolveInheritedRole(sourceType, sourceRole, targetType, accessDef) {
|
|
346
|
+
const hierarchy = accessDef.hierarchy;
|
|
347
|
+
const sourceIdx = hierarchy.indexOf(sourceType);
|
|
348
|
+
const targetIdx = hierarchy.indexOf(targetType);
|
|
349
|
+
if (sourceIdx === -1 || targetIdx === -1 || sourceIdx >= targetIdx)
|
|
350
|
+
return null;
|
|
351
|
+
let currentRole = sourceRole;
|
|
352
|
+
for (let i = sourceIdx;i < targetIdx; i++) {
|
|
353
|
+
const currentType = hierarchy[i];
|
|
354
|
+
const inheritanceMap = accessDef.inheritance[currentType];
|
|
355
|
+
if (!inheritanceMap || !(currentRole in inheritanceMap))
|
|
356
|
+
return null;
|
|
357
|
+
currentRole = inheritanceMap[currentRole];
|
|
358
|
+
}
|
|
359
|
+
return currentRole;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/auth/cookies.ts
|
|
363
|
+
var DEFAULT_COOKIE_CONFIG = {
|
|
364
|
+
name: "vertz.sid",
|
|
365
|
+
httpOnly: true,
|
|
366
|
+
secure: true,
|
|
367
|
+
sameSite: "lax",
|
|
368
|
+
path: "/",
|
|
369
|
+
maxAge: 60
|
|
370
|
+
};
|
|
371
|
+
function buildSessionCookie(value, cookieConfig, clear = false) {
|
|
372
|
+
const name = cookieConfig.name || "vertz.sid";
|
|
373
|
+
const maxAge = cookieConfig.maxAge ?? 60;
|
|
374
|
+
const path = cookieConfig.path || "/";
|
|
375
|
+
const sameSite = cookieConfig.sameSite || "lax";
|
|
376
|
+
const secure = cookieConfig.secure ?? true;
|
|
377
|
+
if (clear) {
|
|
378
|
+
return `${name}=; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=0`;
|
|
379
|
+
}
|
|
380
|
+
return `${name}=${value}; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=${maxAge}`;
|
|
381
|
+
}
|
|
382
|
+
function buildRefreshCookie(value, cookieConfig, refreshName, refreshMaxAge, clear = false) {
|
|
383
|
+
const name = refreshName;
|
|
384
|
+
const sameSite = cookieConfig.sameSite || "lax";
|
|
385
|
+
const secure = cookieConfig.secure ?? true;
|
|
386
|
+
const path = "/api/auth/refresh";
|
|
387
|
+
if (clear) {
|
|
388
|
+
return `${name}=; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=0`;
|
|
389
|
+
}
|
|
390
|
+
return `${name}=${value}; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=${refreshMaxAge}`;
|
|
391
|
+
}
|
|
392
|
+
function buildMfaChallengeCookie(value, cookieConfig, clear = false) {
|
|
393
|
+
const name = "vertz.mfa";
|
|
394
|
+
const path = "/api/auth/mfa";
|
|
395
|
+
const sameSite = cookieConfig.sameSite || "lax";
|
|
396
|
+
const secure = cookieConfig.secure ?? true;
|
|
397
|
+
if (clear) {
|
|
398
|
+
return `${name}=; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=0`;
|
|
399
|
+
}
|
|
400
|
+
return `${name}=${value}; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=300`;
|
|
401
|
+
}
|
|
402
|
+
function buildOAuthStateCookie(value, cookieConfig, clear = false) {
|
|
403
|
+
const name = "vertz.oauth";
|
|
404
|
+
const path = "/api/auth/oauth";
|
|
405
|
+
const sameSite = cookieConfig.sameSite || "lax";
|
|
406
|
+
const secure = cookieConfig.secure ?? true;
|
|
407
|
+
if (clear) {
|
|
408
|
+
return `${name}=; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=0`;
|
|
409
|
+
}
|
|
410
|
+
return `${name}=${value}; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=300`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/auth/crypto.ts
|
|
414
|
+
function timingSafeEqual(a, b) {
|
|
415
|
+
if (a.length !== b.length)
|
|
87
416
|
return false;
|
|
417
|
+
const encoder = new TextEncoder;
|
|
418
|
+
const bufA = encoder.encode(a);
|
|
419
|
+
const bufB = encoder.encode(b);
|
|
420
|
+
let result = 0;
|
|
421
|
+
for (let i = 0;i < bufA.length; i++) {
|
|
422
|
+
result |= bufA[i] ^ bufB[i];
|
|
88
423
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
424
|
+
return result === 0;
|
|
425
|
+
}
|
|
426
|
+
async function sha256Hex(input) {
|
|
427
|
+
const data = new TextEncoder().encode(input);
|
|
428
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
429
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
430
|
+
return Array.from(hashArray).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
431
|
+
}
|
|
432
|
+
function toBase64Url(buffer) {
|
|
433
|
+
return btoa(String.fromCharCode(...buffer)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
434
|
+
}
|
|
435
|
+
function fromBase64Url(str) {
|
|
436
|
+
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
437
|
+
const binary = atob(padded);
|
|
438
|
+
const bytes = new Uint8Array(binary.length);
|
|
439
|
+
for (let i = 0;i < binary.length; i++) {
|
|
440
|
+
bytes[i] = binary.charCodeAt(i);
|
|
441
|
+
}
|
|
442
|
+
return bytes;
|
|
443
|
+
}
|
|
444
|
+
async function deriveKey(passphrase) {
|
|
445
|
+
const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "HKDF", false, ["deriveKey"]);
|
|
446
|
+
return crypto.subtle.deriveKey({
|
|
447
|
+
name: "HKDF",
|
|
448
|
+
hash: "SHA-256",
|
|
449
|
+
salt: new TextEncoder().encode("vertz-oauth-state"),
|
|
450
|
+
info: new TextEncoder().encode("aes-256-gcm")
|
|
451
|
+
}, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
452
|
+
}
|
|
453
|
+
async function encrypt(plaintext, key) {
|
|
454
|
+
const aesKey = await deriveKey(key);
|
|
455
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
456
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
457
|
+
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded);
|
|
458
|
+
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
|
459
|
+
combined.set(iv, 0);
|
|
460
|
+
combined.set(new Uint8Array(ciphertext), iv.length);
|
|
461
|
+
return toBase64Url(combined);
|
|
462
|
+
}
|
|
463
|
+
async function decrypt(ciphertext, key) {
|
|
464
|
+
try {
|
|
465
|
+
const aesKey = await deriveKey(key);
|
|
466
|
+
const combined = fromBase64Url(ciphertext);
|
|
467
|
+
const iv = combined.slice(0, 12);
|
|
468
|
+
const data = combined.slice(12);
|
|
469
|
+
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, data);
|
|
470
|
+
return new TextDecoder().decode(decrypted);
|
|
471
|
+
} catch {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function generateCodeVerifier() {
|
|
476
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
477
|
+
return toBase64Url(bytes);
|
|
478
|
+
}
|
|
479
|
+
async function generateCodeChallenge(verifier) {
|
|
480
|
+
const data = new TextEncoder().encode(verifier);
|
|
481
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
482
|
+
return toBase64Url(new Uint8Array(hash));
|
|
483
|
+
}
|
|
484
|
+
function generateNonce() {
|
|
485
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
486
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/auth/device-name.ts
|
|
490
|
+
function parseDeviceName(userAgent) {
|
|
491
|
+
if (!userAgent)
|
|
492
|
+
return "Unknown device";
|
|
493
|
+
let browser = "";
|
|
494
|
+
if (/Edg\//.test(userAgent))
|
|
495
|
+
browser = "Edge";
|
|
496
|
+
else if (/Chrome\//.test(userAgent) && !/Chromium\//.test(userAgent))
|
|
497
|
+
browser = "Chrome";
|
|
498
|
+
else if (/Firefox\//.test(userAgent))
|
|
499
|
+
browser = "Firefox";
|
|
500
|
+
else if (/Safari\//.test(userAgent) && !/Chrome\//.test(userAgent))
|
|
501
|
+
browser = "Safari";
|
|
502
|
+
let os = "";
|
|
503
|
+
if (/iPhone/.test(userAgent))
|
|
504
|
+
os = "iPhone";
|
|
505
|
+
else if (/iPad/.test(userAgent))
|
|
506
|
+
os = "iPad";
|
|
507
|
+
else if (/Mac OS X/.test(userAgent))
|
|
508
|
+
os = "macOS";
|
|
509
|
+
else if (/Windows/.test(userAgent))
|
|
510
|
+
os = "Windows";
|
|
511
|
+
else if (/Linux/.test(userAgent))
|
|
512
|
+
os = "Linux";
|
|
513
|
+
else if (/Android/.test(userAgent))
|
|
514
|
+
os = "Android";
|
|
515
|
+
if (browser && os)
|
|
516
|
+
return `${browser} on ${os}`;
|
|
517
|
+
if (browser)
|
|
518
|
+
return browser;
|
|
519
|
+
if (os)
|
|
520
|
+
return os;
|
|
521
|
+
return "Unknown device";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/auth/email-verification-store.ts
|
|
525
|
+
class InMemoryEmailVerificationStore {
|
|
526
|
+
byId = new Map;
|
|
527
|
+
byTokenHash = new Map;
|
|
528
|
+
byUserId = new Map;
|
|
529
|
+
async createVerification(data) {
|
|
530
|
+
const verification = {
|
|
531
|
+
id: crypto.randomUUID(),
|
|
532
|
+
userId: data.userId,
|
|
533
|
+
tokenHash: data.tokenHash,
|
|
534
|
+
expiresAt: data.expiresAt,
|
|
535
|
+
createdAt: new Date
|
|
536
|
+
};
|
|
537
|
+
this.byId.set(verification.id, verification);
|
|
538
|
+
this.byTokenHash.set(data.tokenHash, verification);
|
|
539
|
+
const userSet = this.byUserId.get(data.userId) ?? new Set;
|
|
540
|
+
userSet.add(verification.id);
|
|
541
|
+
this.byUserId.set(data.userId, userSet);
|
|
542
|
+
return verification;
|
|
543
|
+
}
|
|
544
|
+
async findByTokenHash(tokenHash) {
|
|
545
|
+
return this.byTokenHash.get(tokenHash) ?? null;
|
|
546
|
+
}
|
|
547
|
+
async deleteByUserId(userId) {
|
|
548
|
+
const ids = this.byUserId.get(userId);
|
|
549
|
+
if (ids) {
|
|
550
|
+
for (const id of ids) {
|
|
551
|
+
const verification = this.byId.get(id);
|
|
552
|
+
if (verification) {
|
|
553
|
+
this.byTokenHash.delete(verification.tokenHash);
|
|
554
|
+
this.byId.delete(id);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
this.byUserId.delete(userId);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
async deleteByTokenHash(tokenHash) {
|
|
561
|
+
const verification = this.byTokenHash.get(tokenHash);
|
|
562
|
+
if (verification) {
|
|
563
|
+
this.byTokenHash.delete(tokenHash);
|
|
564
|
+
this.byId.delete(verification.id);
|
|
565
|
+
const userSet = this.byUserId.get(verification.userId);
|
|
566
|
+
if (userSet) {
|
|
567
|
+
userSet.delete(verification.id);
|
|
568
|
+
if (userSet.size === 0) {
|
|
569
|
+
this.byUserId.delete(verification.userId);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
dispose() {
|
|
575
|
+
this.byId.clear();
|
|
576
|
+
this.byTokenHash.clear();
|
|
577
|
+
this.byUserId.clear();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// src/auth/jwt.ts
|
|
582
|
+
import * as jose from "jose";
|
|
583
|
+
function parseDuration(duration) {
|
|
584
|
+
if (typeof duration === "number")
|
|
585
|
+
return duration;
|
|
586
|
+
const match = duration.match(/^(\d+)([smhd])$/);
|
|
587
|
+
if (!match) {
|
|
588
|
+
throw new Error(`Invalid duration: "${duration}". Expected format: <number><unit> where unit is s (seconds), m (minutes), h (hours), or d (days). Examples: "60s", "15m", "7d".`);
|
|
589
|
+
}
|
|
590
|
+
const value = parseInt(match[1], 10);
|
|
591
|
+
const unit = match[2];
|
|
592
|
+
const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
593
|
+
return value * multipliers[unit] * 1000;
|
|
594
|
+
}
|
|
595
|
+
async function createJWT(user, secret, ttl, algorithm, customClaims) {
|
|
596
|
+
const claims = customClaims ? customClaims(user) : {};
|
|
597
|
+
const jwt = await new jose.SignJWT({
|
|
598
|
+
sub: user.id,
|
|
599
|
+
email: user.email,
|
|
600
|
+
role: user.role,
|
|
601
|
+
...claims
|
|
602
|
+
}).setProtectedHeader({ alg: algorithm }).setIssuedAt().setExpirationTime(`${Math.floor(ttl / 1000)}s`).sign(new TextEncoder().encode(secret));
|
|
603
|
+
return jwt;
|
|
604
|
+
}
|
|
605
|
+
async function verifyJWT(token, secret, algorithm) {
|
|
606
|
+
try {
|
|
607
|
+
const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(secret), {
|
|
608
|
+
algorithms: [algorithm]
|
|
609
|
+
});
|
|
610
|
+
if (typeof payload.sub !== "string" || typeof payload.email !== "string" || typeof payload.role !== "string" || typeof payload.jti !== "string" || typeof payload.sid !== "string") {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
return payload;
|
|
614
|
+
} catch {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/auth/mfa-store.ts
|
|
620
|
+
class InMemoryMFAStore {
|
|
621
|
+
secrets = new Map;
|
|
622
|
+
backupCodes = new Map;
|
|
623
|
+
enabled = new Set;
|
|
624
|
+
async enableMfa(userId, encryptedSecret) {
|
|
625
|
+
this.secrets.set(userId, encryptedSecret);
|
|
626
|
+
this.enabled.add(userId);
|
|
627
|
+
}
|
|
628
|
+
async disableMfa(userId) {
|
|
629
|
+
this.secrets.delete(userId);
|
|
630
|
+
this.backupCodes.delete(userId);
|
|
631
|
+
this.enabled.delete(userId);
|
|
632
|
+
}
|
|
633
|
+
async getSecret(userId) {
|
|
634
|
+
return this.secrets.get(userId) ?? null;
|
|
635
|
+
}
|
|
636
|
+
async isMfaEnabled(userId) {
|
|
637
|
+
return this.enabled.has(userId);
|
|
638
|
+
}
|
|
639
|
+
async setBackupCodes(userId, hashedCodes) {
|
|
640
|
+
this.backupCodes.set(userId, [...hashedCodes]);
|
|
641
|
+
}
|
|
642
|
+
async getBackupCodes(userId) {
|
|
643
|
+
return this.backupCodes.get(userId) ?? [];
|
|
644
|
+
}
|
|
645
|
+
async consumeBackupCode(userId, hashedCode) {
|
|
646
|
+
const codes = this.backupCodes.get(userId);
|
|
647
|
+
if (codes) {
|
|
648
|
+
this.backupCodes.set(userId, codes.filter((c) => c !== hashedCode));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
dispose() {
|
|
652
|
+
this.secrets.clear();
|
|
653
|
+
this.backupCodes.clear();
|
|
654
|
+
this.enabled.clear();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// src/auth/oauth-account-store.ts
|
|
659
|
+
class InMemoryOAuthAccountStore {
|
|
660
|
+
byProviderAccount = new Map;
|
|
661
|
+
byUserId = new Map;
|
|
662
|
+
providerKey(provider, providerId) {
|
|
663
|
+
return `${provider}:${providerId}`;
|
|
664
|
+
}
|
|
665
|
+
async linkAccount(userId, provider, providerId, email) {
|
|
666
|
+
const link = { userId, provider, providerId, email };
|
|
667
|
+
this.byProviderAccount.set(this.providerKey(provider, providerId), link);
|
|
668
|
+
const userLinks = this.byUserId.get(userId) ?? [];
|
|
669
|
+
const exists = userLinks.some((l) => l.provider === provider && l.providerId === providerId);
|
|
670
|
+
if (!exists) {
|
|
671
|
+
userLinks.push(link);
|
|
672
|
+
this.byUserId.set(userId, userLinks);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
async findByProviderAccount(provider, providerId) {
|
|
676
|
+
const link = this.byProviderAccount.get(this.providerKey(provider, providerId));
|
|
677
|
+
return link?.userId ?? null;
|
|
678
|
+
}
|
|
679
|
+
async findByUserId(userId) {
|
|
680
|
+
const links = this.byUserId.get(userId) ?? [];
|
|
681
|
+
return links.map(({ provider, providerId }) => ({ provider, providerId }));
|
|
682
|
+
}
|
|
683
|
+
async unlinkAccount(userId, provider) {
|
|
684
|
+
const userLinks = this.byUserId.get(userId) ?? [];
|
|
685
|
+
const linkToRemove = userLinks.find((l) => l.provider === provider);
|
|
686
|
+
if (linkToRemove) {
|
|
687
|
+
this.byProviderAccount.delete(this.providerKey(provider, linkToRemove.providerId));
|
|
688
|
+
this.byUserId.set(userId, userLinks.filter((l) => l.provider !== provider));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
dispose() {
|
|
692
|
+
this.byProviderAccount.clear();
|
|
693
|
+
this.byUserId.clear();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/auth/password.ts
|
|
698
|
+
import { createAuthValidationError } from "@vertz/errors";
|
|
699
|
+
import bcrypt from "bcryptjs";
|
|
700
|
+
var DEFAULT_PASSWORD_REQUIREMENTS = {
|
|
701
|
+
minLength: 8,
|
|
702
|
+
requireUppercase: false,
|
|
703
|
+
requireNumbers: false,
|
|
704
|
+
requireSymbols: false
|
|
705
|
+
};
|
|
706
|
+
var BCRYPT_ROUNDS = 12;
|
|
707
|
+
async function hashPassword(password) {
|
|
708
|
+
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
709
|
+
}
|
|
710
|
+
async function verifyPassword(password, hash) {
|
|
711
|
+
return bcrypt.compare(password, hash);
|
|
712
|
+
}
|
|
713
|
+
function validatePassword(password, requirements) {
|
|
714
|
+
const req = { ...DEFAULT_PASSWORD_REQUIREMENTS, ...requirements };
|
|
715
|
+
if (password.length < (req.minLength ?? 8)) {
|
|
716
|
+
return createAuthValidationError(`Password must be at least ${req.minLength} characters`, "password", "TOO_SHORT");
|
|
717
|
+
}
|
|
718
|
+
if (req.requireUppercase && !/[A-Z]/.test(password)) {
|
|
719
|
+
return createAuthValidationError("Password must contain at least one uppercase letter", "password", "NO_UPPERCASE");
|
|
720
|
+
}
|
|
721
|
+
if (req.requireNumbers && !/\d/.test(password)) {
|
|
722
|
+
return createAuthValidationError("Password must contain at least one number", "password", "NO_NUMBER");
|
|
723
|
+
}
|
|
724
|
+
if (req.requireSymbols && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
|
725
|
+
return createAuthValidationError("Password must contain at least one symbol", "password", "NO_SYMBOL");
|
|
726
|
+
}
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// src/auth/password-reset-store.ts
|
|
731
|
+
class InMemoryPasswordResetStore {
|
|
732
|
+
byId = new Map;
|
|
733
|
+
byTokenHash = new Map;
|
|
734
|
+
byUserId = new Map;
|
|
735
|
+
async createReset(data) {
|
|
736
|
+
const reset = {
|
|
737
|
+
id: crypto.randomUUID(),
|
|
738
|
+
userId: data.userId,
|
|
739
|
+
tokenHash: data.tokenHash,
|
|
740
|
+
expiresAt: data.expiresAt,
|
|
741
|
+
createdAt: new Date
|
|
742
|
+
};
|
|
743
|
+
this.byId.set(reset.id, reset);
|
|
744
|
+
this.byTokenHash.set(data.tokenHash, reset);
|
|
745
|
+
const userSet = this.byUserId.get(data.userId) ?? new Set;
|
|
746
|
+
userSet.add(reset.id);
|
|
747
|
+
this.byUserId.set(data.userId, userSet);
|
|
748
|
+
return reset;
|
|
749
|
+
}
|
|
750
|
+
async findByTokenHash(tokenHash) {
|
|
751
|
+
return this.byTokenHash.get(tokenHash) ?? null;
|
|
752
|
+
}
|
|
753
|
+
async deleteByUserId(userId) {
|
|
754
|
+
const ids = this.byUserId.get(userId);
|
|
755
|
+
if (ids) {
|
|
756
|
+
for (const id of ids) {
|
|
757
|
+
const reset = this.byId.get(id);
|
|
758
|
+
if (reset) {
|
|
759
|
+
this.byTokenHash.delete(reset.tokenHash);
|
|
760
|
+
this.byId.delete(id);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
this.byUserId.delete(userId);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
dispose() {
|
|
767
|
+
this.byId.clear();
|
|
768
|
+
this.byTokenHash.clear();
|
|
769
|
+
this.byUserId.clear();
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/auth/rate-limit-store.ts
|
|
774
|
+
var CLEANUP_INTERVAL_MS = 60000;
|
|
775
|
+
|
|
776
|
+
class InMemoryRateLimitStore {
|
|
777
|
+
store = new Map;
|
|
778
|
+
cleanupTimer;
|
|
779
|
+
constructor() {
|
|
780
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
781
|
+
}
|
|
782
|
+
async check(key, maxAttempts, windowMs) {
|
|
783
|
+
const now = new Date;
|
|
784
|
+
const entry = this.store.get(key);
|
|
785
|
+
if (!entry || entry.resetAt < now) {
|
|
786
|
+
const resetAt = new Date(now.getTime() + windowMs);
|
|
787
|
+
this.store.set(key, { count: 1, resetAt });
|
|
788
|
+
return { allowed: true, remaining: maxAttempts - 1, resetAt };
|
|
789
|
+
}
|
|
790
|
+
if (entry.count >= maxAttempts) {
|
|
791
|
+
return { allowed: false, remaining: 0, resetAt: entry.resetAt };
|
|
792
|
+
}
|
|
793
|
+
entry.count++;
|
|
794
|
+
return { allowed: true, remaining: maxAttempts - entry.count, resetAt: entry.resetAt };
|
|
795
|
+
}
|
|
796
|
+
dispose() {
|
|
797
|
+
clearInterval(this.cleanupTimer);
|
|
798
|
+
}
|
|
799
|
+
cleanup() {
|
|
800
|
+
const now = new Date;
|
|
801
|
+
for (const [key, entry] of this.store) {
|
|
802
|
+
if (entry.resetAt < now) {
|
|
803
|
+
this.store.delete(key);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// src/auth/session-store.ts
|
|
810
|
+
var DEFAULT_MAX_SESSIONS_PER_USER = 50;
|
|
811
|
+
var CLEANUP_INTERVAL_MS2 = 60000;
|
|
812
|
+
|
|
813
|
+
class InMemorySessionStore {
|
|
814
|
+
sessions = new Map;
|
|
815
|
+
currentTokens = new Map;
|
|
816
|
+
cleanupTimer;
|
|
817
|
+
maxSessionsPerUser;
|
|
818
|
+
constructor(maxSessionsPerUser = DEFAULT_MAX_SESSIONS_PER_USER) {
|
|
819
|
+
this.maxSessionsPerUser = maxSessionsPerUser;
|
|
820
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS2);
|
|
821
|
+
}
|
|
822
|
+
async createSession(data) {
|
|
823
|
+
const activeSessions = await this.listActiveSessions(data.userId);
|
|
824
|
+
if (activeSessions.length >= this.maxSessionsPerUser) {
|
|
825
|
+
const sorted = activeSessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
826
|
+
const toRevoke = sorted.slice(0, activeSessions.length - this.maxSessionsPerUser + 1);
|
|
827
|
+
for (const s of toRevoke) {
|
|
828
|
+
await this.revokeSession(s.id);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
const now = new Date;
|
|
832
|
+
const session = {
|
|
833
|
+
id: crypto.randomUUID(),
|
|
834
|
+
userId: data.userId,
|
|
835
|
+
refreshTokenHash: data.refreshTokenHash,
|
|
836
|
+
previousRefreshHash: null,
|
|
837
|
+
ipAddress: data.ipAddress,
|
|
838
|
+
userAgent: data.userAgent,
|
|
839
|
+
createdAt: now,
|
|
840
|
+
lastActiveAt: now,
|
|
841
|
+
expiresAt: data.expiresAt,
|
|
842
|
+
revokedAt: null
|
|
843
|
+
};
|
|
844
|
+
this.sessions.set(session.id, session);
|
|
845
|
+
return session;
|
|
846
|
+
}
|
|
847
|
+
async createSessionWithId(id, data) {
|
|
848
|
+
const activeSessions = await this.listActiveSessions(data.userId);
|
|
849
|
+
if (activeSessions.length >= this.maxSessionsPerUser) {
|
|
850
|
+
const sorted = activeSessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
851
|
+
const toRevoke = sorted.slice(0, activeSessions.length - this.maxSessionsPerUser + 1);
|
|
852
|
+
for (const s of toRevoke) {
|
|
853
|
+
await this.revokeSession(s.id);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const now = new Date;
|
|
857
|
+
const session = {
|
|
858
|
+
id,
|
|
859
|
+
userId: data.userId,
|
|
860
|
+
refreshTokenHash: data.refreshTokenHash,
|
|
861
|
+
previousRefreshHash: null,
|
|
862
|
+
ipAddress: data.ipAddress,
|
|
863
|
+
userAgent: data.userAgent,
|
|
864
|
+
createdAt: now,
|
|
865
|
+
lastActiveAt: now,
|
|
866
|
+
expiresAt: data.expiresAt,
|
|
867
|
+
revokedAt: null
|
|
868
|
+
};
|
|
869
|
+
this.sessions.set(session.id, session);
|
|
870
|
+
if (data.currentTokens) {
|
|
871
|
+
this.currentTokens.set(id, data.currentTokens);
|
|
872
|
+
}
|
|
873
|
+
return session;
|
|
874
|
+
}
|
|
875
|
+
async findByRefreshHash(hash) {
|
|
876
|
+
for (const session of this.sessions.values()) {
|
|
877
|
+
if (timingSafeEqual(session.refreshTokenHash, hash) && !session.revokedAt && session.expiresAt > new Date) {
|
|
878
|
+
return session;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
async findByPreviousRefreshHash(hash) {
|
|
884
|
+
for (const session of this.sessions.values()) {
|
|
885
|
+
if (session.previousRefreshHash !== null && timingSafeEqual(session.previousRefreshHash, hash) && !session.revokedAt && session.expiresAt > new Date) {
|
|
886
|
+
return session;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
async revokeSession(id) {
|
|
892
|
+
const session = this.sessions.get(id);
|
|
893
|
+
if (session) {
|
|
894
|
+
session.revokedAt = new Date;
|
|
895
|
+
this.currentTokens.delete(id);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
async listActiveSessions(userId) {
|
|
899
|
+
const now = new Date;
|
|
900
|
+
const result = [];
|
|
901
|
+
for (const session of this.sessions.values()) {
|
|
902
|
+
if (session.userId === userId && !session.revokedAt && session.expiresAt > now) {
|
|
903
|
+
result.push(session);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return result;
|
|
907
|
+
}
|
|
908
|
+
async countActiveSessions(userId) {
|
|
909
|
+
const sessions = await this.listActiveSessions(userId);
|
|
910
|
+
return sessions.length;
|
|
911
|
+
}
|
|
912
|
+
async getCurrentTokens(sessionId) {
|
|
913
|
+
return this.currentTokens.get(sessionId) ?? null;
|
|
914
|
+
}
|
|
915
|
+
async updateSession(id, data) {
|
|
916
|
+
const session = this.sessions.get(id);
|
|
917
|
+
if (session) {
|
|
918
|
+
session.refreshTokenHash = data.refreshTokenHash;
|
|
919
|
+
session.previousRefreshHash = data.previousRefreshHash;
|
|
920
|
+
session.lastActiveAt = data.lastActiveAt;
|
|
921
|
+
if (data.currentTokens) {
|
|
922
|
+
this.currentTokens.set(id, data.currentTokens);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
dispose() {
|
|
927
|
+
clearInterval(this.cleanupTimer);
|
|
928
|
+
}
|
|
929
|
+
cleanup() {
|
|
930
|
+
const now = new Date;
|
|
931
|
+
for (const [id, session] of this.sessions) {
|
|
932
|
+
if (session.expiresAt < now || session.revokedAt) {
|
|
933
|
+
this.sessions.delete(id);
|
|
934
|
+
this.currentTokens.delete(id);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// src/auth/totp.ts
|
|
941
|
+
import { timingSafeEqual as cryptoTimingSafeEqual } from "node:crypto";
|
|
942
|
+
var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
943
|
+
function base32Encode(buffer) {
|
|
944
|
+
let bits = 0;
|
|
945
|
+
let value = 0;
|
|
946
|
+
let result = "";
|
|
947
|
+
for (let i = 0;i < buffer.length; i++) {
|
|
948
|
+
value = value << 8 | buffer[i];
|
|
949
|
+
bits += 8;
|
|
950
|
+
while (bits >= 5) {
|
|
951
|
+
result += BASE32_ALPHABET[value >>> bits - 5 & 31];
|
|
952
|
+
bits -= 5;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (bits > 0) {
|
|
956
|
+
result += BASE32_ALPHABET[value << 5 - bits & 31];
|
|
957
|
+
}
|
|
958
|
+
return result;
|
|
959
|
+
}
|
|
960
|
+
function base32Decode(encoded) {
|
|
961
|
+
const str = encoded.replace(/=+$/, "").toUpperCase();
|
|
962
|
+
const output = [];
|
|
963
|
+
let bits = 0;
|
|
964
|
+
let value = 0;
|
|
965
|
+
for (let i = 0;i < str.length; i++) {
|
|
966
|
+
const idx = BASE32_ALPHABET.indexOf(str[i]);
|
|
967
|
+
if (idx === -1)
|
|
968
|
+
continue;
|
|
969
|
+
value = value << 5 | idx;
|
|
970
|
+
bits += 5;
|
|
971
|
+
if (bits >= 8) {
|
|
972
|
+
output.push(value >>> bits - 8 & 255);
|
|
973
|
+
bits -= 8;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return new Uint8Array(output);
|
|
977
|
+
}
|
|
978
|
+
var TOTP_PERIOD = 30;
|
|
979
|
+
var TOTP_DIGITS = 6;
|
|
980
|
+
function generateTotpSecret() {
|
|
981
|
+
const bytes = crypto.getRandomValues(new Uint8Array(20));
|
|
982
|
+
return base32Encode(bytes);
|
|
983
|
+
}
|
|
984
|
+
async function verifyTotpCode(secret, code, timestamp, drift = 1) {
|
|
985
|
+
const time = timestamp ?? Date.now();
|
|
986
|
+
const counter = Math.floor(time / 1000 / TOTP_PERIOD);
|
|
987
|
+
for (let i = -drift;i <= drift; i++) {
|
|
988
|
+
const expected = await hmacOtp(secret, counter + i);
|
|
989
|
+
if (timingSafeEqual2(code, expected)) {
|
|
990
|
+
return true;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
function generateTotpUri(secret, email, issuer) {
|
|
996
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(email)}`;
|
|
997
|
+
const params = new URLSearchParams({
|
|
998
|
+
secret,
|
|
999
|
+
issuer,
|
|
1000
|
+
algorithm: "SHA1",
|
|
1001
|
+
digits: String(TOTP_DIGITS),
|
|
1002
|
+
period: String(TOTP_PERIOD)
|
|
1003
|
+
});
|
|
1004
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
1005
|
+
}
|
|
1006
|
+
var BACKUP_CODE_LENGTH = 8;
|
|
1007
|
+
var BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
1008
|
+
function generateBackupCodes(count = 10) {
|
|
1009
|
+
const codes = [];
|
|
1010
|
+
const seen = new Set;
|
|
1011
|
+
while (codes.length < count) {
|
|
1012
|
+
const bytes = crypto.getRandomValues(new Uint8Array(BACKUP_CODE_LENGTH));
|
|
1013
|
+
let code = "";
|
|
1014
|
+
for (let i = 0;i < BACKUP_CODE_LENGTH; i++) {
|
|
1015
|
+
code += BACKUP_CODE_CHARS[bytes[i] % BACKUP_CODE_CHARS.length];
|
|
1016
|
+
}
|
|
1017
|
+
if (!seen.has(code)) {
|
|
1018
|
+
seen.add(code);
|
|
1019
|
+
codes.push(code);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return codes;
|
|
1023
|
+
}
|
|
1024
|
+
async function hashBackupCode(code) {
|
|
1025
|
+
return hashPassword(code);
|
|
1026
|
+
}
|
|
1027
|
+
async function verifyBackupCode(code, hash) {
|
|
1028
|
+
return verifyPassword(code, hash);
|
|
1029
|
+
}
|
|
1030
|
+
async function hmacOtp(secret, counter) {
|
|
1031
|
+
const keyBytes = base32Decode(secret);
|
|
1032
|
+
const key = await crypto.subtle.importKey("raw", keyBytes.buffer, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
|
|
1033
|
+
const counterBuffer = new ArrayBuffer(8);
|
|
1034
|
+
const view = new DataView(counterBuffer);
|
|
1035
|
+
view.setUint32(4, counter, false);
|
|
1036
|
+
const hmac = await crypto.subtle.sign("HMAC", key, counterBuffer);
|
|
1037
|
+
const hmacBytes = new Uint8Array(hmac);
|
|
1038
|
+
const offset = hmacBytes[hmacBytes.length - 1] & 15;
|
|
1039
|
+
const binary = (hmacBytes[offset] & 127) << 24 | (hmacBytes[offset + 1] & 255) << 16 | (hmacBytes[offset + 2] & 255) << 8 | hmacBytes[offset + 3] & 255;
|
|
1040
|
+
const otp = binary % 10 ** TOTP_DIGITS;
|
|
1041
|
+
return otp.toString().padStart(TOTP_DIGITS, "0");
|
|
1042
|
+
}
|
|
1043
|
+
function timingSafeEqual2(a, b) {
|
|
1044
|
+
const maxLen = Math.max(a.length, b.length);
|
|
1045
|
+
const bufA = Buffer.alloc(maxLen);
|
|
1046
|
+
const bufB = Buffer.alloc(maxLen);
|
|
1047
|
+
bufA.write(a);
|
|
1048
|
+
bufB.write(b);
|
|
1049
|
+
const equal = cryptoTimingSafeEqual(bufA, bufB);
|
|
1050
|
+
return equal && a.length === b.length;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// src/auth/user-store.ts
|
|
1054
|
+
class InMemoryUserStore {
|
|
1055
|
+
byEmail = new Map;
|
|
1056
|
+
byId = new Map;
|
|
1057
|
+
async createUser(user, passwordHash) {
|
|
1058
|
+
this.byEmail.set(user.email.toLowerCase(), { user, passwordHash });
|
|
1059
|
+
this.byId.set(user.id, user);
|
|
1060
|
+
}
|
|
1061
|
+
async findByEmail(email) {
|
|
1062
|
+
return this.byEmail.get(email.toLowerCase()) ?? null;
|
|
1063
|
+
}
|
|
1064
|
+
async findById(id) {
|
|
1065
|
+
return this.byId.get(id) ?? null;
|
|
1066
|
+
}
|
|
1067
|
+
async updatePasswordHash(userId, passwordHash) {
|
|
1068
|
+
const user = this.byId.get(userId);
|
|
1069
|
+
if (user) {
|
|
1070
|
+
const entry = this.byEmail.get(user.email.toLowerCase());
|
|
1071
|
+
if (entry) {
|
|
1072
|
+
entry.passwordHash = passwordHash;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async updateEmailVerified(userId, verified) {
|
|
1077
|
+
const user = this.byId.get(userId);
|
|
1078
|
+
if (user) {
|
|
1079
|
+
user.emailVerified = verified;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
// src/auth/access.ts
|
|
1084
|
+
class AuthorizationError extends Error {
|
|
1085
|
+
entitlement;
|
|
1086
|
+
userId;
|
|
1087
|
+
constructor(message, entitlement, userId) {
|
|
1088
|
+
super(message);
|
|
1089
|
+
this.entitlement = entitlement;
|
|
1090
|
+
this.userId = userId;
|
|
1091
|
+
this.name = "AuthorizationError";
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
function createAccess(config) {
|
|
1095
|
+
const { roles, entitlements } = config;
|
|
1096
|
+
const roleEntitlements = new Map;
|
|
1097
|
+
for (const [roleName, roleDef] of Object.entries(roles)) {
|
|
1098
|
+
roleEntitlements.set(roleName, new Set(roleDef.entitlements));
|
|
1099
|
+
}
|
|
1100
|
+
const entitlementRoles = new Map;
|
|
1101
|
+
for (const [entName, entDef] of Object.entries(entitlements)) {
|
|
1102
|
+
entitlementRoles.set(entName, new Set(entDef.roles));
|
|
1103
|
+
}
|
|
1104
|
+
function roleHasEntitlement(role, entitlement) {
|
|
1105
|
+
const roleEnts = roleEntitlements.get(role);
|
|
1106
|
+
if (!roleEnts)
|
|
1107
|
+
return false;
|
|
1108
|
+
if (roleEnts.has(entitlement))
|
|
1109
|
+
return true;
|
|
1110
|
+
const [resource, action] = entitlement.split(":");
|
|
1111
|
+
if (action && resource !== "*") {
|
|
1112
|
+
const wildcard = `${resource}:*`;
|
|
1113
|
+
if (roleEnts.has(wildcard))
|
|
1114
|
+
return true;
|
|
1115
|
+
}
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
async function checkEntitlement(entitlement, user) {
|
|
1119
|
+
if (!user)
|
|
1120
|
+
return false;
|
|
1121
|
+
const allowedRoles = entitlementRoles.get(entitlement);
|
|
1122
|
+
if (!allowedRoles) {
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
return allowedRoles.has(user.role) || roleHasEntitlement(user.role, entitlement);
|
|
1126
|
+
}
|
|
1127
|
+
async function can(entitlement, user) {
|
|
1128
|
+
return checkEntitlement(entitlement, user);
|
|
1129
|
+
}
|
|
1130
|
+
async function canWithResource(entitlement, resource, user) {
|
|
1131
|
+
const hasEntitlement = await checkEntitlement(entitlement, user);
|
|
1132
|
+
if (!hasEntitlement)
|
|
1133
|
+
return false;
|
|
1134
|
+
if (resource.ownerId != null && resource.ownerId !== "" && resource.ownerId !== user?.id) {
|
|
1135
|
+
return false;
|
|
1136
|
+
}
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
1139
|
+
async function authorize(entitlement, user) {
|
|
1140
|
+
const allowed = await can(entitlement, user);
|
|
1141
|
+
if (!allowed) {
|
|
1142
|
+
throw new AuthorizationError(`Not authorized to perform this action: ${entitlement}`, entitlement, user?.id);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
async function authorizeWithResource(entitlement, resource, user) {
|
|
1146
|
+
const allowed = await canWithResource(entitlement, resource, user);
|
|
1147
|
+
if (!allowed) {
|
|
1148
|
+
throw new AuthorizationError(`Not authorized to perform this action on this resource: ${entitlement}`, entitlement, user?.id);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
async function canAll(checks, user) {
|
|
1152
|
+
const results = new Map;
|
|
1153
|
+
for (const { entitlement, resource } of checks) {
|
|
1154
|
+
const key = resource ? `${entitlement}:${resource.id}` : entitlement;
|
|
1155
|
+
const allowed = resource ? await canWithResource(entitlement, resource, user) : await can(entitlement, user);
|
|
1156
|
+
results.set(key, allowed);
|
|
1157
|
+
}
|
|
1158
|
+
return results;
|
|
1159
|
+
}
|
|
1160
|
+
function getEntitlementsForRole(role) {
|
|
1161
|
+
const roleEnts = roleEntitlements.get(role);
|
|
1162
|
+
return roleEnts ? Array.from(roleEnts) : [];
|
|
1163
|
+
}
|
|
1164
|
+
function createMiddleware() {
|
|
1165
|
+
return async (ctx, next) => {
|
|
1166
|
+
ctx.can = async (entitlement, resource) => {
|
|
1167
|
+
if (resource) {
|
|
1168
|
+
return canWithResource(entitlement, resource, ctx.user ?? null);
|
|
1169
|
+
}
|
|
1170
|
+
return can(entitlement, ctx.user ?? null);
|
|
1171
|
+
};
|
|
1172
|
+
ctx.authorize = async (entitlement, resource) => {
|
|
1173
|
+
if (resource) {
|
|
1174
|
+
return authorizeWithResource(entitlement, resource, ctx.user ?? null);
|
|
1175
|
+
}
|
|
1176
|
+
return authorize(entitlement, ctx.user ?? null);
|
|
1177
|
+
};
|
|
1178
|
+
await next();
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
return {
|
|
1182
|
+
can,
|
|
1183
|
+
canWithResource,
|
|
1184
|
+
authorize,
|
|
1185
|
+
authorizeWithResource,
|
|
1186
|
+
canAll,
|
|
1187
|
+
getEntitlementsForRole,
|
|
1188
|
+
middleware: createMiddleware
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
var defaultAccess = createAccess({
|
|
1192
|
+
roles: {
|
|
1193
|
+
user: { entitlements: ["read", "create"] },
|
|
1194
|
+
editor: { entitlements: ["read", "create", "update"] },
|
|
1195
|
+
admin: { entitlements: ["read", "create", "update", "delete"] }
|
|
1196
|
+
},
|
|
1197
|
+
entitlements: {
|
|
1198
|
+
"user:read": { roles: ["user", "editor", "admin"] },
|
|
1199
|
+
"user:create": { roles: ["user", "editor", "admin"] },
|
|
1200
|
+
"user:update": { roles: ["editor", "admin"] },
|
|
1201
|
+
"user:delete": { roles: ["admin"] },
|
|
1202
|
+
read: { roles: ["user", "editor", "admin"] },
|
|
1203
|
+
create: { roles: ["user", "editor", "admin"] },
|
|
1204
|
+
update: { roles: ["editor", "admin"] },
|
|
1205
|
+
delete: { roles: ["admin"] }
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
// src/auth/access-context.ts
|
|
1209
|
+
var MAX_BULK_CHECKS = 100;
|
|
1210
|
+
function createAccessContext(config) {
|
|
1211
|
+
const {
|
|
1212
|
+
userId,
|
|
1213
|
+
accessDef,
|
|
1214
|
+
closureStore,
|
|
1215
|
+
roleStore,
|
|
1216
|
+
flagStore,
|
|
1217
|
+
planStore,
|
|
1218
|
+
walletStore,
|
|
1219
|
+
orgResolver
|
|
1220
|
+
} = config;
|
|
1221
|
+
async function checkLayers1to4(entitlement, resource, resolvedOrgId) {
|
|
1222
|
+
if (!userId)
|
|
1223
|
+
return false;
|
|
1224
|
+
const entDef = accessDef.entitlements[entitlement];
|
|
1225
|
+
if (!entDef)
|
|
1226
|
+
return false;
|
|
1227
|
+
if (entDef.flags?.length && flagStore && orgResolver) {
|
|
1228
|
+
if (!resolvedOrgId)
|
|
1229
|
+
return false;
|
|
1230
|
+
for (const flag of entDef.flags) {
|
|
1231
|
+
if (!flagStore.getFlag(resolvedOrgId, flag)) {
|
|
1232
|
+
return false;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
if (entDef.roles.length > 0 && resource) {
|
|
1237
|
+
const effectiveRole = await roleStore.getEffectiveRole(userId, resource.type, resource.id, accessDef, closureStore);
|
|
1238
|
+
if (!effectiveRole || !entDef.roles.includes(effectiveRole)) {
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
} else if (entDef.roles.length > 0 && !resource) {
|
|
1242
|
+
return false;
|
|
1243
|
+
}
|
|
1244
|
+
if (entDef.plans?.length && planStore && orgResolver) {
|
|
1245
|
+
if (!resolvedOrgId)
|
|
1246
|
+
return false;
|
|
1247
|
+
const orgPlan = await planStore.getPlan(resolvedOrgId);
|
|
1248
|
+
const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
|
|
1249
|
+
if (!effectivePlanId)
|
|
1250
|
+
return false;
|
|
1251
|
+
const planDef = accessDef.plans?.[effectivePlanId];
|
|
1252
|
+
if (!planDef || !planDef.entitlements.includes(entitlement)) {
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return true;
|
|
1257
|
+
}
|
|
1258
|
+
async function can(entitlement, resource) {
|
|
1259
|
+
const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
|
|
1260
|
+
if (!await checkLayers1to4(entitlement, resource, resolvedOrgId))
|
|
1261
|
+
return false;
|
|
1262
|
+
const entDef = accessDef.entitlements[entitlement];
|
|
1263
|
+
if (entDef?.plans?.length && walletStore && planStore && resolvedOrgId) {
|
|
1264
|
+
const walletState = await resolveWalletStateFromOrgId(entitlement, resolvedOrgId, accessDef, planStore, walletStore);
|
|
1265
|
+
if (walletState && walletState.consumed >= walletState.limit) {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
async function check(entitlement, resource) {
|
|
1272
|
+
const reasons = [];
|
|
1273
|
+
const meta = {};
|
|
1274
|
+
if (!userId) {
|
|
1275
|
+
return {
|
|
1276
|
+
allowed: false,
|
|
1277
|
+
reasons: ["not_authenticated"],
|
|
1278
|
+
reason: "not_authenticated"
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
const entDef = accessDef.entitlements[entitlement];
|
|
1282
|
+
if (!entDef) {
|
|
1283
|
+
return {
|
|
1284
|
+
allowed: false,
|
|
1285
|
+
reasons: ["role_required"],
|
|
1286
|
+
reason: "role_required"
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
|
|
1290
|
+
if (entDef.flags?.length && flagStore && orgResolver) {
|
|
1291
|
+
if (resolvedOrgId) {
|
|
1292
|
+
const disabledFlags = [];
|
|
1293
|
+
for (const flag of entDef.flags) {
|
|
1294
|
+
if (!flagStore.getFlag(resolvedOrgId, flag)) {
|
|
1295
|
+
disabledFlags.push(flag);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (disabledFlags.length > 0) {
|
|
1299
|
+
reasons.push("flag_disabled");
|
|
1300
|
+
meta.disabledFlags = disabledFlags;
|
|
1301
|
+
}
|
|
1302
|
+
} else {
|
|
1303
|
+
reasons.push("flag_disabled");
|
|
1304
|
+
meta.disabledFlags = [...entDef.flags];
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (entDef.roles.length > 0) {
|
|
1308
|
+
let hasRole = false;
|
|
1309
|
+
if (resource) {
|
|
1310
|
+
const effectiveRole = await roleStore.getEffectiveRole(userId, resource.type, resource.id, accessDef, closureStore);
|
|
1311
|
+
hasRole = !!effectiveRole && entDef.roles.includes(effectiveRole);
|
|
1312
|
+
}
|
|
1313
|
+
if (!hasRole) {
|
|
1314
|
+
reasons.push("role_required");
|
|
1315
|
+
meta.requiredRoles = [...entDef.roles];
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
if (entDef.plans?.length && planStore && orgResolver) {
|
|
1319
|
+
let planDenied = false;
|
|
1320
|
+
if (!resolvedOrgId) {
|
|
1321
|
+
planDenied = true;
|
|
1322
|
+
} else {
|
|
1323
|
+
const orgPlan = await planStore.getPlan(resolvedOrgId);
|
|
1324
|
+
const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
|
|
1325
|
+
if (!effectivePlanId) {
|
|
1326
|
+
planDenied = true;
|
|
1327
|
+
} else {
|
|
1328
|
+
const planDef = accessDef.plans?.[effectivePlanId];
|
|
1329
|
+
if (!planDef || !planDef.entitlements.includes(entitlement)) {
|
|
1330
|
+
planDenied = true;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (planDenied) {
|
|
1335
|
+
reasons.push("plan_required");
|
|
1336
|
+
meta.requiredPlans = [...entDef.plans];
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (entDef.plans?.length && walletStore && planStore && resolvedOrgId) {
|
|
1340
|
+
const walletState = await resolveWalletStateFromOrgId(entitlement, resolvedOrgId, accessDef, planStore, walletStore);
|
|
1341
|
+
if (walletState) {
|
|
1342
|
+
meta.limit = {
|
|
1343
|
+
max: walletState.limit,
|
|
1344
|
+
consumed: walletState.consumed,
|
|
1345
|
+
remaining: Math.max(0, walletState.limit - walletState.consumed)
|
|
1346
|
+
};
|
|
1347
|
+
if (walletState.consumed >= walletState.limit) {
|
|
1348
|
+
reasons.push("limit_reached");
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const orderedReasons = orderDenialReasons(reasons);
|
|
1353
|
+
return {
|
|
1354
|
+
allowed: orderedReasons.length === 0,
|
|
1355
|
+
reasons: orderedReasons,
|
|
1356
|
+
reason: orderedReasons[0],
|
|
1357
|
+
meta: Object.keys(meta).length > 0 ? meta : undefined
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
async function authorize(entitlement, resource) {
|
|
1361
|
+
const result = await can(entitlement, resource);
|
|
1362
|
+
if (!result) {
|
|
1363
|
+
throw new AuthorizationError(`Not authorized: ${entitlement}`, entitlement, userId ?? undefined);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
async function canAll(checks) {
|
|
1367
|
+
if (checks.length > MAX_BULK_CHECKS) {
|
|
1368
|
+
throw new Error(`canAll() is limited to ${MAX_BULK_CHECKS} checks per call`);
|
|
1369
|
+
}
|
|
1370
|
+
const results = new Map;
|
|
1371
|
+
for (const { entitlement, resource } of checks) {
|
|
1372
|
+
const key = resource ? `${entitlement}:${resource.id}` : entitlement;
|
|
1373
|
+
const allowed = await can(entitlement, resource);
|
|
1374
|
+
results.set(key, allowed);
|
|
1375
|
+
}
|
|
1376
|
+
return results;
|
|
1377
|
+
}
|
|
1378
|
+
async function canAndConsume(entitlement, resource, amount = 1) {
|
|
1379
|
+
const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
|
|
1380
|
+
if (!await checkLayers1to4(entitlement, resource, resolvedOrgId))
|
|
1381
|
+
return false;
|
|
1382
|
+
if (!walletStore || !planStore || !orgResolver)
|
|
1383
|
+
return true;
|
|
1384
|
+
const entDef = accessDef.entitlements[entitlement];
|
|
1385
|
+
if (!entDef?.plans?.length)
|
|
1386
|
+
return true;
|
|
1387
|
+
if (!resolvedOrgId)
|
|
1388
|
+
return false;
|
|
1389
|
+
const orgPlan = await planStore.getPlan(resolvedOrgId);
|
|
1390
|
+
if (!orgPlan)
|
|
1391
|
+
return false;
|
|
1392
|
+
const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
|
|
1393
|
+
if (!effectivePlanId)
|
|
1394
|
+
return false;
|
|
1395
|
+
const planDef = accessDef.plans?.[effectivePlanId];
|
|
1396
|
+
if (!planDef)
|
|
1397
|
+
return false;
|
|
1398
|
+
const limitDef = planDef.limits?.[entitlement];
|
|
1399
|
+
if (!limitDef)
|
|
1400
|
+
return true;
|
|
1401
|
+
const effectiveLimit = resolveEffectiveLimit(orgPlan, entitlement, limitDef);
|
|
1402
|
+
const { periodStart, periodEnd } = calculateBillingPeriod(orgPlan.startedAt, limitDef.per);
|
|
1403
|
+
const result = await walletStore.consume(resolvedOrgId, entitlement, periodStart, periodEnd, effectiveLimit, amount);
|
|
1404
|
+
return result.success;
|
|
1405
|
+
}
|
|
1406
|
+
async function unconsume(entitlement, resource, amount = 1) {
|
|
1407
|
+
if (!walletStore || !planStore || !orgResolver)
|
|
1408
|
+
return;
|
|
1409
|
+
const entDef = accessDef.entitlements[entitlement];
|
|
1410
|
+
if (!entDef?.plans?.length)
|
|
1411
|
+
return;
|
|
1412
|
+
const orgId = await orgResolver(resource);
|
|
1413
|
+
if (!orgId)
|
|
1414
|
+
return;
|
|
1415
|
+
const orgPlan = await planStore.getPlan(orgId);
|
|
1416
|
+
if (!orgPlan)
|
|
1417
|
+
return;
|
|
1418
|
+
const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
|
|
1419
|
+
if (!effectivePlanId)
|
|
1420
|
+
return;
|
|
1421
|
+
const planDef = accessDef.plans?.[effectivePlanId];
|
|
1422
|
+
const limitDef = planDef?.limits?.[entitlement];
|
|
1423
|
+
if (!limitDef)
|
|
1424
|
+
return;
|
|
1425
|
+
const { periodStart, periodEnd } = calculateBillingPeriod(orgPlan.startedAt, limitDef.per);
|
|
1426
|
+
await walletStore.unconsume(orgId, entitlement, periodStart, periodEnd, amount);
|
|
1427
|
+
}
|
|
1428
|
+
return { can, check, authorize, canAll, canAndConsume, unconsume };
|
|
1429
|
+
}
|
|
1430
|
+
var DENIAL_ORDER = [
|
|
1431
|
+
"plan_required",
|
|
1432
|
+
"role_required",
|
|
1433
|
+
"limit_reached",
|
|
1434
|
+
"flag_disabled",
|
|
1435
|
+
"hierarchy_denied",
|
|
1436
|
+
"step_up_required",
|
|
1437
|
+
"not_authenticated"
|
|
1438
|
+
];
|
|
1439
|
+
function orderDenialReasons(reasons) {
|
|
1440
|
+
return [...reasons].sort((a, b) => DENIAL_ORDER.indexOf(a) - DENIAL_ORDER.indexOf(b));
|
|
1441
|
+
}
|
|
1442
|
+
async function resolveWalletStateFromOrgId(entitlement, orgId, accessDef, planStore, walletStore) {
|
|
1443
|
+
const orgPlan = await planStore.getPlan(orgId);
|
|
1444
|
+
const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
|
|
1445
|
+
if (!effectivePlanId || !orgPlan)
|
|
1446
|
+
return null;
|
|
1447
|
+
const planDef = accessDef.plans?.[effectivePlanId];
|
|
1448
|
+
const limitDef = planDef?.limits?.[entitlement];
|
|
1449
|
+
if (!limitDef)
|
|
1450
|
+
return null;
|
|
1451
|
+
const effectiveLimit = resolveEffectiveLimit(orgPlan, entitlement, limitDef);
|
|
1452
|
+
const { periodStart, periodEnd } = calculateBillingPeriod(orgPlan.startedAt, limitDef.per);
|
|
1453
|
+
const consumed = await walletStore.getConsumption(orgId, entitlement, periodStart, periodEnd);
|
|
1454
|
+
return { limit: effectiveLimit, consumed };
|
|
1455
|
+
}
|
|
1456
|
+
function resolveEffectiveLimit(orgPlan, entitlement, planLimit) {
|
|
1457
|
+
const override = orgPlan.overrides[entitlement];
|
|
1458
|
+
if (!override)
|
|
1459
|
+
return planLimit.max;
|
|
1460
|
+
return Math.max(override.max, planLimit.max);
|
|
1461
|
+
}
|
|
1462
|
+
// src/auth/access-event-broadcaster.ts
|
|
1463
|
+
function createAccessEventBroadcaster(config) {
|
|
1464
|
+
const {
|
|
1465
|
+
jwtSecret,
|
|
1466
|
+
jwtAlgorithm = "HS256",
|
|
1467
|
+
path = "/api/auth/access-events",
|
|
1468
|
+
cookieName = "vertz.sid"
|
|
1469
|
+
} = config;
|
|
1470
|
+
const connectionsByOrg = new Map;
|
|
1471
|
+
const connectionsByUser = new Map;
|
|
1472
|
+
let connectionCount = 0;
|
|
1473
|
+
const allConnections = new Set;
|
|
1474
|
+
const PING_INTERVAL_MS = 30000;
|
|
1475
|
+
const pingTimer = setInterval(() => {
|
|
1476
|
+
for (const ws of allConnections) {
|
|
1477
|
+
try {
|
|
1478
|
+
ws.ping();
|
|
1479
|
+
} catch {}
|
|
1480
|
+
}
|
|
1481
|
+
}, PING_INTERVAL_MS);
|
|
1482
|
+
if (typeof pingTimer === "object" && "unref" in pingTimer) {
|
|
1483
|
+
pingTimer.unref();
|
|
1484
|
+
}
|
|
1485
|
+
function addConnection(ws) {
|
|
1486
|
+
const { orgId, userId } = ws.data;
|
|
1487
|
+
let orgSet = connectionsByOrg.get(orgId);
|
|
1488
|
+
if (!orgSet) {
|
|
1489
|
+
orgSet = new Set;
|
|
1490
|
+
connectionsByOrg.set(orgId, orgSet);
|
|
1491
|
+
}
|
|
1492
|
+
orgSet.add(ws);
|
|
1493
|
+
let userSet = connectionsByUser.get(userId);
|
|
1494
|
+
if (!userSet) {
|
|
1495
|
+
userSet = new Set;
|
|
1496
|
+
connectionsByUser.set(userId, userSet);
|
|
1497
|
+
}
|
|
1498
|
+
userSet.add(ws);
|
|
1499
|
+
allConnections.add(ws);
|
|
1500
|
+
connectionCount++;
|
|
1501
|
+
}
|
|
1502
|
+
function removeConnection(ws) {
|
|
1503
|
+
const { orgId, userId } = ws.data;
|
|
1504
|
+
const orgSet = connectionsByOrg.get(orgId);
|
|
1505
|
+
if (orgSet) {
|
|
1506
|
+
orgSet.delete(ws);
|
|
1507
|
+
if (orgSet.size === 0)
|
|
1508
|
+
connectionsByOrg.delete(orgId);
|
|
1509
|
+
}
|
|
1510
|
+
const userSet = connectionsByUser.get(userId);
|
|
1511
|
+
if (userSet) {
|
|
1512
|
+
userSet.delete(ws);
|
|
1513
|
+
if (userSet.size === 0)
|
|
1514
|
+
connectionsByUser.delete(userId);
|
|
1515
|
+
}
|
|
1516
|
+
allConnections.delete(ws);
|
|
1517
|
+
connectionCount--;
|
|
1518
|
+
}
|
|
1519
|
+
function broadcastToOrg(orgId, message) {
|
|
1520
|
+
const orgSet = connectionsByOrg.get(orgId);
|
|
1521
|
+
if (!orgSet)
|
|
1522
|
+
return;
|
|
1523
|
+
for (const ws of orgSet) {
|
|
1524
|
+
ws.send(message);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
function broadcastToUser(userId, message) {
|
|
1528
|
+
const userSet = connectionsByUser.get(userId);
|
|
1529
|
+
if (!userSet)
|
|
1530
|
+
return;
|
|
1531
|
+
for (const ws of userSet) {
|
|
1532
|
+
ws.send(message);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
function parseCookie(request) {
|
|
1536
|
+
const cookieHeader = request.headers.get("cookie");
|
|
1537
|
+
if (!cookieHeader)
|
|
1538
|
+
return null;
|
|
1539
|
+
const cookies = cookieHeader.split(";").map((c) => c.trim());
|
|
1540
|
+
for (const cookie of cookies) {
|
|
1541
|
+
const [name, ...rest] = cookie.split("=");
|
|
1542
|
+
if (name === cookieName) {
|
|
1543
|
+
return rest.join("=");
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return null;
|
|
1547
|
+
}
|
|
1548
|
+
async function handleUpgrade(request, server) {
|
|
1549
|
+
const url = new URL(request.url);
|
|
1550
|
+
if (url.pathname !== path)
|
|
1551
|
+
return false;
|
|
1552
|
+
const token = parseCookie(request);
|
|
1553
|
+
if (!token)
|
|
1554
|
+
return false;
|
|
1555
|
+
try {
|
|
1556
|
+
const payload = await verifyJWT(token, jwtSecret, jwtAlgorithm);
|
|
1557
|
+
if (!payload || !payload.sub)
|
|
1558
|
+
return false;
|
|
1559
|
+
const claims = payload.claims;
|
|
1560
|
+
const orgId = claims?.orgId ?? "";
|
|
1561
|
+
return server.upgrade(request, {
|
|
1562
|
+
data: {
|
|
1563
|
+
userId: payload.sub,
|
|
1564
|
+
orgId
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
} catch {
|
|
1568
|
+
return false;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
const websocket = {
|
|
1572
|
+
open(ws) {
|
|
1573
|
+
addConnection(ws);
|
|
1574
|
+
},
|
|
1575
|
+
message(_ws, msg) {
|
|
1576
|
+
const msgStr = typeof msg === "string" ? msg : msg.toString();
|
|
1577
|
+
if (msgStr === "pong") {
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
},
|
|
1581
|
+
close(ws) {
|
|
1582
|
+
removeConnection(ws);
|
|
1583
|
+
}
|
|
1584
|
+
};
|
|
1585
|
+
function broadcastFlagToggle(orgId, flag, enabled) {
|
|
1586
|
+
const event = { type: "access:flag_toggled", orgId, flag, enabled };
|
|
1587
|
+
broadcastToOrg(orgId, JSON.stringify(event));
|
|
1588
|
+
}
|
|
1589
|
+
function broadcastLimitUpdate(orgId, entitlement, consumed, remaining, max) {
|
|
1590
|
+
const event = {
|
|
1591
|
+
type: "access:limit_updated",
|
|
1592
|
+
orgId,
|
|
1593
|
+
entitlement,
|
|
1594
|
+
consumed,
|
|
1595
|
+
remaining,
|
|
1596
|
+
max
|
|
1597
|
+
};
|
|
1598
|
+
broadcastToOrg(orgId, JSON.stringify(event));
|
|
1599
|
+
}
|
|
1600
|
+
function broadcastRoleChange(userId) {
|
|
1601
|
+
const event = { type: "access:role_changed", userId };
|
|
1602
|
+
broadcastToUser(userId, JSON.stringify(event));
|
|
1603
|
+
}
|
|
1604
|
+
function broadcastPlanChange(orgId) {
|
|
1605
|
+
const event = { type: "access:plan_changed", orgId };
|
|
1606
|
+
broadcastToOrg(orgId, JSON.stringify(event));
|
|
1607
|
+
}
|
|
1608
|
+
return {
|
|
1609
|
+
handleUpgrade,
|
|
1610
|
+
websocket,
|
|
1611
|
+
broadcastFlagToggle,
|
|
1612
|
+
broadcastLimitUpdate,
|
|
1613
|
+
broadcastRoleChange,
|
|
1614
|
+
broadcastPlanChange,
|
|
1615
|
+
get getConnectionCount() {
|
|
1616
|
+
return connectionCount;
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
// src/auth/closure-store.ts
|
|
1621
|
+
var MAX_DEPTH = 4;
|
|
1622
|
+
|
|
1623
|
+
class InMemoryClosureStore {
|
|
1624
|
+
rows = [];
|
|
1625
|
+
async addResource(type, id, parent) {
|
|
1626
|
+
this.rows.push({
|
|
1627
|
+
ancestorType: type,
|
|
1628
|
+
ancestorId: id,
|
|
1629
|
+
descendantType: type,
|
|
1630
|
+
descendantId: id,
|
|
1631
|
+
depth: 0
|
|
1632
|
+
});
|
|
1633
|
+
if (parent) {
|
|
1634
|
+
const parentAncestors = await this.getAncestors(parent.parentType, parent.parentId);
|
|
1635
|
+
const maxParentDepth = Math.max(...parentAncestors.map((a) => a.depth), 0);
|
|
1636
|
+
if (maxParentDepth + 1 >= MAX_DEPTH) {
|
|
1637
|
+
this.rows.pop();
|
|
1638
|
+
throw new Error("Hierarchy depth exceeds maximum of 4 levels");
|
|
1639
|
+
}
|
|
1640
|
+
for (const ancestor of parentAncestors) {
|
|
1641
|
+
this.rows.push({
|
|
1642
|
+
ancestorType: ancestor.type,
|
|
1643
|
+
ancestorId: ancestor.id,
|
|
1644
|
+
descendantType: type,
|
|
1645
|
+
descendantId: id,
|
|
1646
|
+
depth: ancestor.depth + 1
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
async removeResource(type, id) {
|
|
1652
|
+
const descendants = await this.getDescendants(type, id);
|
|
1653
|
+
const descendantKeys = new Set(descendants.map((d) => `${d.type}:${d.id}`));
|
|
1654
|
+
this.rows = this.rows.filter((row) => {
|
|
1655
|
+
const ancestorKey = `${row.ancestorType}:${row.ancestorId}`;
|
|
1656
|
+
const descendantKey = `${row.descendantType}:${row.descendantId}`;
|
|
1657
|
+
return !descendantKeys.has(ancestorKey) && !descendantKeys.has(descendantKey);
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
async getAncestors(type, id) {
|
|
1661
|
+
return this.rows.filter((row) => row.descendantType === type && row.descendantId === id).map((row) => ({
|
|
1662
|
+
type: row.ancestorType,
|
|
1663
|
+
id: row.ancestorId,
|
|
1664
|
+
depth: row.depth
|
|
1665
|
+
}));
|
|
1666
|
+
}
|
|
1667
|
+
async getDescendants(type, id) {
|
|
1668
|
+
return this.rows.filter((row) => row.ancestorType === type && row.ancestorId === id).map((row) => ({
|
|
1669
|
+
type: row.descendantType,
|
|
1670
|
+
id: row.descendantId,
|
|
1671
|
+
depth: row.depth
|
|
1672
|
+
}));
|
|
1673
|
+
}
|
|
1674
|
+
async hasPath(ancestorType, ancestorId, descendantType, descendantId) {
|
|
1675
|
+
return this.rows.some((row) => row.ancestorType === ancestorType && row.ancestorId === ancestorId && row.descendantType === descendantType && row.descendantId === descendantId);
|
|
1676
|
+
}
|
|
1677
|
+
dispose() {
|
|
1678
|
+
this.rows = [];
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
// src/auth/define-access.ts
|
|
1682
|
+
function defineAccess(input) {
|
|
1683
|
+
if (input.hierarchy.length === 0) {
|
|
1684
|
+
throw new Error("Hierarchy must have at least one resource type");
|
|
1685
|
+
}
|
|
1686
|
+
if (input.hierarchy.length > 4) {
|
|
1687
|
+
throw new Error("Hierarchy depth must not exceed 4 levels");
|
|
1688
|
+
}
|
|
1689
|
+
const hierarchySet = new Set(input.hierarchy);
|
|
1690
|
+
for (const resourceType of Object.keys(input.roles)) {
|
|
1691
|
+
if (!hierarchySet.has(resourceType)) {
|
|
1692
|
+
throw new Error(`Roles reference unknown resource type: ${resourceType}`);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
const inheritance = input.inheritance ?? {};
|
|
1696
|
+
for (const [resourceType, mapping] of Object.entries(inheritance)) {
|
|
1697
|
+
if (!hierarchySet.has(resourceType)) {
|
|
1698
|
+
throw new Error(`Inheritance references unknown resource type: ${resourceType}`);
|
|
1699
|
+
}
|
|
1700
|
+
const parentRoles = new Set(input.roles[resourceType] ?? []);
|
|
1701
|
+
const hierarchyIdx = input.hierarchy.indexOf(resourceType);
|
|
1702
|
+
const childType = input.hierarchy[hierarchyIdx + 1];
|
|
1703
|
+
const childRoles = childType ? new Set(input.roles[childType] ?? []) : new Set;
|
|
1704
|
+
for (const [parentRole, childRole] of Object.entries(mapping)) {
|
|
1705
|
+
if (!parentRoles.has(parentRole)) {
|
|
1706
|
+
throw new Error(`Inheritance for ${resourceType} references undefined role: ${parentRole}`);
|
|
1707
|
+
}
|
|
1708
|
+
if (!childRoles.has(childRole)) {
|
|
1709
|
+
throw new Error(`Inheritance for ${resourceType} maps to undefined child role: ${childRole}`);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
const planNameSet = new Set(Object.keys(input.plans ?? {}));
|
|
1714
|
+
for (const [entName, entDef] of Object.entries(input.entitlements)) {
|
|
1715
|
+
if (entDef.plans) {
|
|
1716
|
+
for (const planRef of entDef.plans) {
|
|
1717
|
+
if (!planNameSet.has(planRef)) {
|
|
1718
|
+
throw new Error(`Entitlement "${entName}" references unknown plan: ${planRef}`);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
const entitlementSet = new Set(Object.keys(input.entitlements));
|
|
1724
|
+
if (input.plans) {
|
|
1725
|
+
for (const [planName, planDef] of Object.entries(input.plans)) {
|
|
1726
|
+
const planEntitlementSet = new Set(planDef.entitlements);
|
|
1727
|
+
for (const ent of planDef.entitlements) {
|
|
1728
|
+
if (!entitlementSet.has(ent)) {
|
|
1729
|
+
throw new Error(`Plan "${planName}" references unknown entitlement: ${ent}`);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
if (planDef.limits) {
|
|
1733
|
+
for (const limitKey of Object.keys(planDef.limits)) {
|
|
1734
|
+
if (!planEntitlementSet.has(limitKey)) {
|
|
1735
|
+
throw new Error(`Plan "${planName}" has limit for "${limitKey}" which is not in the plan's entitlements`);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
const config = {
|
|
1742
|
+
hierarchy: Object.freeze([...input.hierarchy]),
|
|
1743
|
+
roles: Object.freeze(Object.fromEntries(Object.entries(input.roles).map(([k, v]) => [k, Object.freeze([...v])]))),
|
|
1744
|
+
inheritance: Object.freeze(Object.fromEntries(Object.entries(input.inheritance ?? {}).map(([k, v]) => [k, Object.freeze({ ...v })]))),
|
|
1745
|
+
entitlements: Object.freeze(Object.fromEntries(Object.entries(input.entitlements).map(([k, v]) => [k, Object.freeze({ ...v })]))),
|
|
1746
|
+
...input.defaultPlan ? { defaultPlan: input.defaultPlan } : {},
|
|
1747
|
+
...input.plans ? {
|
|
1748
|
+
plans: Object.freeze(Object.fromEntries(Object.entries(input.plans).map(([planName, planDef]) => [
|
|
1749
|
+
planName,
|
|
1750
|
+
Object.freeze({
|
|
1751
|
+
entitlements: Object.freeze([...planDef.entitlements]),
|
|
1752
|
+
...planDef.limits ? {
|
|
1753
|
+
limits: Object.freeze(Object.fromEntries(Object.entries(planDef.limits).map(([k, v]) => [
|
|
1754
|
+
k,
|
|
1755
|
+
Object.freeze({ ...v })
|
|
1756
|
+
])))
|
|
1757
|
+
} : {}
|
|
1758
|
+
})
|
|
1759
|
+
])))
|
|
1760
|
+
} : {}
|
|
1761
|
+
};
|
|
1762
|
+
return Object.freeze(config);
|
|
1763
|
+
}
|
|
1764
|
+
// src/auth/entity-access.ts
|
|
1765
|
+
async function computeEntityAccess(entitlements, entity, accessContext) {
|
|
1766
|
+
const results = {};
|
|
1767
|
+
for (const entitlement of entitlements) {
|
|
1768
|
+
results[entitlement] = await accessContext.check(entitlement, {
|
|
1769
|
+
type: entity.type,
|
|
1770
|
+
id: entity.id
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
return results;
|
|
1774
|
+
}
|
|
1775
|
+
// src/auth/flag-store.ts
|
|
1776
|
+
class InMemoryFlagStore {
|
|
1777
|
+
flags = new Map;
|
|
1778
|
+
setFlag(orgId, flag, enabled) {
|
|
1779
|
+
let orgFlags = this.flags.get(orgId);
|
|
1780
|
+
if (!orgFlags) {
|
|
1781
|
+
orgFlags = new Map;
|
|
1782
|
+
this.flags.set(orgId, orgFlags);
|
|
1783
|
+
}
|
|
1784
|
+
orgFlags.set(flag, enabled);
|
|
1785
|
+
}
|
|
1786
|
+
getFlag(orgId, flag) {
|
|
1787
|
+
return this.flags.get(orgId)?.get(flag) ?? false;
|
|
1788
|
+
}
|
|
1789
|
+
getFlags(orgId) {
|
|
1790
|
+
const orgFlags = this.flags.get(orgId);
|
|
1791
|
+
if (!orgFlags)
|
|
1792
|
+
return {};
|
|
1793
|
+
const result = {};
|
|
1794
|
+
for (const [key, value] of orgFlags) {
|
|
1795
|
+
result[key] = value;
|
|
1796
|
+
}
|
|
1797
|
+
return result;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
// src/auth/fva.ts
|
|
1801
|
+
function checkFva(payload, maxAgeSeconds) {
|
|
1802
|
+
if (payload.fva === undefined)
|
|
1803
|
+
return false;
|
|
1804
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1805
|
+
return now - payload.fva < maxAgeSeconds;
|
|
1806
|
+
}
|
|
1807
|
+
// src/auth/providers/discord.ts
|
|
1808
|
+
var AUTHORIZATION_URL = "https://discord.com/api/oauth2/authorize";
|
|
1809
|
+
var TOKEN_URL = "https://discord.com/api/oauth2/token";
|
|
1810
|
+
var USER_URL = "https://discord.com/api/users/@me";
|
|
1811
|
+
var DEFAULT_SCOPES = ["identify", "email"];
|
|
1812
|
+
function discord(config) {
|
|
1813
|
+
const scopes = config.scopes ?? DEFAULT_SCOPES;
|
|
1814
|
+
return {
|
|
1815
|
+
id: "discord",
|
|
1816
|
+
name: "Discord",
|
|
1817
|
+
scopes,
|
|
1818
|
+
trustEmail: false,
|
|
1819
|
+
getAuthorizationUrl(state, codeChallenge) {
|
|
1820
|
+
const params = new URLSearchParams({
|
|
1821
|
+
client_id: config.clientId,
|
|
1822
|
+
redirect_uri: config.redirectUrl ?? "",
|
|
1823
|
+
response_type: "code",
|
|
1824
|
+
scope: scopes.join(" "),
|
|
1825
|
+
state
|
|
1826
|
+
});
|
|
1827
|
+
if (codeChallenge) {
|
|
1828
|
+
params.set("code_challenge", codeChallenge);
|
|
1829
|
+
params.set("code_challenge_method", "S256");
|
|
1830
|
+
}
|
|
1831
|
+
return `${AUTHORIZATION_URL}?${params.toString()}`;
|
|
1832
|
+
},
|
|
1833
|
+
async exchangeCode(code, codeVerifier) {
|
|
1834
|
+
const body = new URLSearchParams({
|
|
1835
|
+
client_id: config.clientId,
|
|
1836
|
+
client_secret: config.clientSecret,
|
|
1837
|
+
code,
|
|
1838
|
+
grant_type: "authorization_code",
|
|
1839
|
+
redirect_uri: config.redirectUrl ?? ""
|
|
1840
|
+
});
|
|
1841
|
+
if (codeVerifier) {
|
|
1842
|
+
body.set("code_verifier", codeVerifier);
|
|
1843
|
+
}
|
|
1844
|
+
const response = await fetch(TOKEN_URL, {
|
|
1845
|
+
method: "POST",
|
|
1846
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1847
|
+
body: body.toString()
|
|
1848
|
+
});
|
|
1849
|
+
const data = await response.json();
|
|
1850
|
+
return {
|
|
1851
|
+
accessToken: data.access_token,
|
|
1852
|
+
refreshToken: data.refresh_token,
|
|
1853
|
+
expiresIn: data.expires_in
|
|
1854
|
+
};
|
|
1855
|
+
},
|
|
1856
|
+
async getUserInfo(accessToken) {
|
|
1857
|
+
const response = await fetch(USER_URL, {
|
|
1858
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
1859
|
+
});
|
|
1860
|
+
const data = await response.json();
|
|
1861
|
+
return {
|
|
1862
|
+
providerId: data.id,
|
|
1863
|
+
email: data.email ?? "",
|
|
1864
|
+
emailVerified: data.verified ?? false,
|
|
1865
|
+
name: data.global_name ?? data.username,
|
|
1866
|
+
avatarUrl: data.avatar ? `https://cdn.discordapp.com/avatars/${data.id}/${data.avatar}.png` : undefined
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
// src/auth/providers/github.ts
|
|
1872
|
+
var AUTHORIZATION_URL2 = "https://github.com/login/oauth/authorize";
|
|
1873
|
+
var TOKEN_URL2 = "https://github.com/login/oauth/access_token";
|
|
1874
|
+
var USER_URL2 = "https://api.github.com/user";
|
|
1875
|
+
var EMAILS_URL = "https://api.github.com/user/emails";
|
|
1876
|
+
var DEFAULT_SCOPES2 = ["read:user", "user:email"];
|
|
1877
|
+
function github(config) {
|
|
1878
|
+
const scopes = config.scopes ?? DEFAULT_SCOPES2;
|
|
1879
|
+
return {
|
|
1880
|
+
id: "github",
|
|
1881
|
+
name: "GitHub",
|
|
1882
|
+
scopes,
|
|
1883
|
+
trustEmail: false,
|
|
1884
|
+
getAuthorizationUrl(state) {
|
|
1885
|
+
const params = new URLSearchParams({
|
|
1886
|
+
client_id: config.clientId,
|
|
1887
|
+
redirect_uri: config.redirectUrl ?? "",
|
|
1888
|
+
scope: scopes.join(" "),
|
|
1889
|
+
state
|
|
1890
|
+
});
|
|
1891
|
+
return `${AUTHORIZATION_URL2}?${params.toString()}`;
|
|
1892
|
+
},
|
|
1893
|
+
async exchangeCode(code) {
|
|
1894
|
+
const response = await fetch(TOKEN_URL2, {
|
|
1895
|
+
method: "POST",
|
|
1896
|
+
headers: {
|
|
1897
|
+
Accept: "application/json",
|
|
1898
|
+
"Content-Type": "application/json"
|
|
1899
|
+
},
|
|
1900
|
+
body: JSON.stringify({
|
|
1901
|
+
client_id: config.clientId,
|
|
1902
|
+
client_secret: config.clientSecret,
|
|
1903
|
+
code,
|
|
1904
|
+
redirect_uri: config.redirectUrl ?? ""
|
|
1905
|
+
})
|
|
1906
|
+
});
|
|
1907
|
+
const data = await response.json();
|
|
1908
|
+
return {
|
|
1909
|
+
accessToken: data.access_token
|
|
1910
|
+
};
|
|
1911
|
+
},
|
|
1912
|
+
async getUserInfo(accessToken) {
|
|
1913
|
+
const [userResponse, emailsResponse] = await Promise.all([
|
|
1914
|
+
fetch(USER_URL2, {
|
|
1915
|
+
headers: {
|
|
1916
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1917
|
+
"User-Agent": "vertz-auth/1.0"
|
|
1918
|
+
}
|
|
1919
|
+
}),
|
|
1920
|
+
fetch(EMAILS_URL, {
|
|
1921
|
+
headers: {
|
|
1922
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1923
|
+
"User-Agent": "vertz-auth/1.0"
|
|
1924
|
+
}
|
|
1925
|
+
})
|
|
1926
|
+
]);
|
|
1927
|
+
const userData = await userResponse.json();
|
|
1928
|
+
const emails = await emailsResponse.json();
|
|
1929
|
+
const primaryEmail = emails.find((e) => e.primary && e.verified);
|
|
1930
|
+
const fallbackEmail = emails.find((e) => e.verified);
|
|
1931
|
+
const email = primaryEmail ?? fallbackEmail;
|
|
1932
|
+
return {
|
|
1933
|
+
providerId: String(userData.id),
|
|
1934
|
+
email: email?.email ?? "",
|
|
1935
|
+
emailVerified: email?.verified ?? false,
|
|
1936
|
+
name: userData.name,
|
|
1937
|
+
avatarUrl: userData.avatar_url
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
// src/auth/providers/google.ts
|
|
1943
|
+
var AUTHORIZATION_URL3 = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
1944
|
+
var TOKEN_URL3 = "https://oauth2.googleapis.com/token";
|
|
1945
|
+
var DEFAULT_SCOPES3 = ["openid", "email", "profile"];
|
|
1946
|
+
function google(config) {
|
|
1947
|
+
const scopes = config.scopes ?? DEFAULT_SCOPES3;
|
|
1948
|
+
return {
|
|
1949
|
+
id: "google",
|
|
1950
|
+
name: "Google",
|
|
1951
|
+
scopes,
|
|
1952
|
+
trustEmail: true,
|
|
1953
|
+
getAuthorizationUrl(state, codeChallenge, nonce) {
|
|
1954
|
+
const params = new URLSearchParams({
|
|
1955
|
+
client_id: config.clientId,
|
|
1956
|
+
redirect_uri: config.redirectUrl ?? "",
|
|
1957
|
+
response_type: "code",
|
|
1958
|
+
scope: scopes.join(" "),
|
|
1959
|
+
state,
|
|
1960
|
+
access_type: "offline",
|
|
1961
|
+
prompt: "consent"
|
|
1962
|
+
});
|
|
1963
|
+
if (codeChallenge) {
|
|
1964
|
+
params.set("code_challenge", codeChallenge);
|
|
1965
|
+
params.set("code_challenge_method", "S256");
|
|
1966
|
+
}
|
|
1967
|
+
if (nonce) {
|
|
1968
|
+
params.set("nonce", nonce);
|
|
1969
|
+
}
|
|
1970
|
+
return `${AUTHORIZATION_URL3}?${params.toString()}`;
|
|
1971
|
+
},
|
|
1972
|
+
async exchangeCode(code, codeVerifier) {
|
|
1973
|
+
const body = new URLSearchParams({
|
|
1974
|
+
client_id: config.clientId,
|
|
1975
|
+
client_secret: config.clientSecret,
|
|
1976
|
+
code,
|
|
1977
|
+
grant_type: "authorization_code",
|
|
1978
|
+
redirect_uri: config.redirectUrl ?? ""
|
|
1979
|
+
});
|
|
1980
|
+
if (codeVerifier) {
|
|
1981
|
+
body.set("code_verifier", codeVerifier);
|
|
1982
|
+
}
|
|
1983
|
+
const response = await fetch(TOKEN_URL3, {
|
|
1984
|
+
method: "POST",
|
|
1985
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1986
|
+
body: body.toString()
|
|
1987
|
+
});
|
|
1988
|
+
const data = await response.json();
|
|
1989
|
+
return {
|
|
1990
|
+
accessToken: data.access_token,
|
|
1991
|
+
refreshToken: data.refresh_token,
|
|
1992
|
+
expiresIn: data.expires_in,
|
|
1993
|
+
idToken: data.id_token
|
|
1994
|
+
};
|
|
1995
|
+
},
|
|
1996
|
+
async getUserInfo(_accessToken, idToken, nonce) {
|
|
1997
|
+
if (!idToken) {
|
|
1998
|
+
throw new Error("Google provider requires an ID token");
|
|
1999
|
+
}
|
|
2000
|
+
const parts = idToken.split(".");
|
|
2001
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
2002
|
+
if (nonce && payload.nonce !== nonce) {
|
|
2003
|
+
throw new Error("ID token nonce mismatch");
|
|
2004
|
+
}
|
|
2005
|
+
return {
|
|
2006
|
+
providerId: payload.sub,
|
|
2007
|
+
email: payload.email,
|
|
2008
|
+
emailVerified: payload.email_verified,
|
|
2009
|
+
name: payload.name,
|
|
2010
|
+
avatarUrl: payload.picture
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
2015
|
+
// src/auth/role-assignment-store.ts
|
|
2016
|
+
class InMemoryRoleAssignmentStore {
|
|
2017
|
+
assignments = [];
|
|
2018
|
+
async assign(userId, resourceType, resourceId, role) {
|
|
2019
|
+
const exists = this.assignments.some((a) => a.userId === userId && a.resourceType === resourceType && a.resourceId === resourceId && a.role === role);
|
|
2020
|
+
if (!exists) {
|
|
2021
|
+
this.assignments.push({ userId, resourceType, resourceId, role });
|
|
95
2022
|
}
|
|
96
|
-
return allowedRoles.has(user.role) || roleHasEntitlement(user.role, entitlement);
|
|
97
2023
|
}
|
|
98
|
-
async
|
|
99
|
-
|
|
2024
|
+
async revoke(userId, resourceType, resourceId, role) {
|
|
2025
|
+
this.assignments = this.assignments.filter((a) => !(a.userId === userId && a.resourceType === resourceType && a.resourceId === resourceId && a.role === role));
|
|
100
2026
|
}
|
|
101
|
-
async
|
|
102
|
-
|
|
103
|
-
if (!hasEntitlement)
|
|
104
|
-
return false;
|
|
105
|
-
if (resource.ownerId != null && resource.ownerId !== "" && resource.ownerId !== user?.id) {
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
return true;
|
|
2027
|
+
async getRoles(userId, resourceType, resourceId) {
|
|
2028
|
+
return this.assignments.filter((a) => a.userId === userId && a.resourceType === resourceType && a.resourceId === resourceId).map((a) => a.role);
|
|
109
2029
|
}
|
|
110
|
-
async
|
|
111
|
-
|
|
112
|
-
if (!allowed) {
|
|
113
|
-
throw new AuthorizationError(`Not authorized to perform this action: ${entitlement}`, entitlement, user?.id);
|
|
114
|
-
}
|
|
2030
|
+
async getRolesForUser(userId) {
|
|
2031
|
+
return this.assignments.filter((a) => a.userId === userId);
|
|
115
2032
|
}
|
|
116
|
-
async
|
|
117
|
-
const
|
|
118
|
-
if (!
|
|
119
|
-
|
|
2033
|
+
async getEffectiveRole(userId, resourceType, resourceId, accessDef, closureStore) {
|
|
2034
|
+
const rolesForType = accessDef.roles[resourceType];
|
|
2035
|
+
if (!rolesForType || rolesForType.length === 0)
|
|
2036
|
+
return null;
|
|
2037
|
+
const candidateRoles = [];
|
|
2038
|
+
const directRoles = await this.getRoles(userId, resourceType, resourceId);
|
|
2039
|
+
candidateRoles.push(...directRoles);
|
|
2040
|
+
const ancestors = await closureStore.getAncestors(resourceType, resourceId);
|
|
2041
|
+
for (const ancestor of ancestors) {
|
|
2042
|
+
if (ancestor.depth === 0)
|
|
2043
|
+
continue;
|
|
2044
|
+
const ancestorRoles = await this.getRoles(userId, ancestor.type, ancestor.id);
|
|
2045
|
+
for (const ancestorRole of ancestorRoles) {
|
|
2046
|
+
const inheritedRole = resolveInheritedRole2(ancestor.type, ancestorRole, resourceType, accessDef);
|
|
2047
|
+
if (inheritedRole) {
|
|
2048
|
+
candidateRoles.push(inheritedRole);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
120
2051
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
2052
|
+
if (candidateRoles.length === 0)
|
|
2053
|
+
return null;
|
|
2054
|
+
const roleOrder = [...rolesForType];
|
|
2055
|
+
let bestIndex = roleOrder.length;
|
|
2056
|
+
for (const role of candidateRoles) {
|
|
2057
|
+
const idx = roleOrder.indexOf(role);
|
|
2058
|
+
if (idx !== -1 && idx < bestIndex) {
|
|
2059
|
+
bestIndex = idx;
|
|
2060
|
+
}
|
|
128
2061
|
}
|
|
129
|
-
return
|
|
2062
|
+
return bestIndex < roleOrder.length ? roleOrder[bestIndex] : null;
|
|
130
2063
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return roleEnts ? Array.from(roleEnts) : [];
|
|
2064
|
+
dispose() {
|
|
2065
|
+
this.assignments = [];
|
|
134
2066
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await next();
|
|
150
|
-
};
|
|
2067
|
+
}
|
|
2068
|
+
function resolveInheritedRole2(sourceType, sourceRole, targetType, accessDef) {
|
|
2069
|
+
const hierarchy = accessDef.hierarchy;
|
|
2070
|
+
const sourceIdx = hierarchy.indexOf(sourceType);
|
|
2071
|
+
const targetIdx = hierarchy.indexOf(targetType);
|
|
2072
|
+
if (sourceIdx === -1 || targetIdx === -1 || sourceIdx >= targetIdx)
|
|
2073
|
+
return null;
|
|
2074
|
+
let currentRole = sourceRole;
|
|
2075
|
+
for (let i = sourceIdx;i < targetIdx; i++) {
|
|
2076
|
+
const currentType = hierarchy[i];
|
|
2077
|
+
const inheritanceMap = accessDef.inheritance[currentType];
|
|
2078
|
+
if (!inheritanceMap || !(currentRole in inheritanceMap))
|
|
2079
|
+
return null;
|
|
2080
|
+
currentRole = inheritanceMap[currentRole];
|
|
151
2081
|
}
|
|
152
|
-
return
|
|
153
|
-
can,
|
|
154
|
-
canWithResource,
|
|
155
|
-
authorize,
|
|
156
|
-
authorizeWithResource,
|
|
157
|
-
canAll,
|
|
158
|
-
getEntitlementsForRole,
|
|
159
|
-
middleware: createMiddleware
|
|
160
|
-
};
|
|
2082
|
+
return currentRole;
|
|
161
2083
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
2084
|
+
// src/auth/rules.ts
|
|
2085
|
+
var userMarkers = {
|
|
2086
|
+
id: Object.freeze({ __marker: "user.id" }),
|
|
2087
|
+
tenantId: Object.freeze({ __marker: "user.tenantId" })
|
|
2088
|
+
};
|
|
2089
|
+
var rules = {
|
|
2090
|
+
role(...roleNames) {
|
|
2091
|
+
return { type: "role", roles: Object.freeze([...roleNames]) };
|
|
167
2092
|
},
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
2093
|
+
entitlement(name) {
|
|
2094
|
+
return { type: "entitlement", entitlement: name };
|
|
2095
|
+
},
|
|
2096
|
+
where(conditions) {
|
|
2097
|
+
return { type: "where", conditions: Object.freeze({ ...conditions }) };
|
|
2098
|
+
},
|
|
2099
|
+
all(...ruleList) {
|
|
2100
|
+
return { type: "all", rules: Object.freeze([...ruleList]) };
|
|
2101
|
+
},
|
|
2102
|
+
any(...ruleList) {
|
|
2103
|
+
return { type: "any", rules: Object.freeze([...ruleList]) };
|
|
2104
|
+
},
|
|
2105
|
+
authenticated() {
|
|
2106
|
+
return { type: "authenticated" };
|
|
2107
|
+
},
|
|
2108
|
+
fva(maxAge) {
|
|
2109
|
+
return { type: "fva", maxAge };
|
|
2110
|
+
},
|
|
2111
|
+
user: userMarkers
|
|
2112
|
+
};
|
|
2113
|
+
// src/auth/wallet-store.ts
|
|
2114
|
+
class InMemoryWalletStore {
|
|
2115
|
+
entries = new Map;
|
|
2116
|
+
key(orgId, entitlement, periodStart) {
|
|
2117
|
+
return `${orgId}:${entitlement}:${periodStart.getTime()}`;
|
|
177
2118
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
store = new Map;
|
|
183
|
-
windowMs;
|
|
184
|
-
constructor(window) {
|
|
185
|
-
this.windowMs = this.parseDuration(window);
|
|
186
|
-
}
|
|
187
|
-
parseDuration(duration) {
|
|
188
|
-
const match = duration.match(/^(\d+)([smh])$/);
|
|
189
|
-
if (!match)
|
|
190
|
-
throw new Error(`Invalid duration: ${duration}`);
|
|
191
|
-
const value = parseInt(match[1], 10);
|
|
192
|
-
const unit = match[2];
|
|
193
|
-
const multipliers = { s: 1000, m: 60000, h: 3600000 };
|
|
194
|
-
return value * multipliers[unit];
|
|
195
|
-
}
|
|
196
|
-
check(key, maxAttempts) {
|
|
197
|
-
const now = new Date;
|
|
198
|
-
const entry = this.store.get(key);
|
|
199
|
-
if (!entry || entry.resetAt < now) {
|
|
200
|
-
const resetAt = new Date(now.getTime() + this.windowMs);
|
|
201
|
-
this.store.set(key, { count: 1, resetAt });
|
|
202
|
-
return { allowed: true, remaining: maxAttempts - 1, resetAt };
|
|
203
|
-
}
|
|
204
|
-
if (entry.count >= maxAttempts) {
|
|
205
|
-
return { allowed: false, remaining: 0, resetAt: entry.resetAt };
|
|
2119
|
+
async consume(orgId, entitlement, periodStart, periodEnd, limit, amount = 1) {
|
|
2120
|
+
const k = this.key(orgId, entitlement, periodStart);
|
|
2121
|
+
if (!this.entries.has(k)) {
|
|
2122
|
+
this.entries.set(k, { orgId, entitlement, periodStart, periodEnd, consumed: 0 });
|
|
206
2123
|
}
|
|
207
|
-
entry.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
2124
|
+
const entry = this.entries.get(k);
|
|
2125
|
+
if (entry.consumed + amount > limit) {
|
|
2126
|
+
return {
|
|
2127
|
+
success: false,
|
|
2128
|
+
consumed: entry.consumed,
|
|
2129
|
+
limit,
|
|
2130
|
+
remaining: Math.max(0, limit - entry.consumed)
|
|
2131
|
+
};
|
|
216
2132
|
}
|
|
2133
|
+
entry.consumed += amount;
|
|
2134
|
+
return {
|
|
2135
|
+
success: true,
|
|
2136
|
+
consumed: entry.consumed,
|
|
2137
|
+
limit,
|
|
2138
|
+
remaining: limit - entry.consumed
|
|
2139
|
+
};
|
|
217
2140
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
};
|
|
225
|
-
var BCRYPT_ROUNDS = 12;
|
|
226
|
-
async function hashPassword(password) {
|
|
227
|
-
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
228
|
-
}
|
|
229
|
-
async function verifyPassword(password, hash) {
|
|
230
|
-
return bcrypt.compare(password, hash);
|
|
231
|
-
}
|
|
232
|
-
function validatePassword(password, requirements) {
|
|
233
|
-
const req = { ...DEFAULT_PASSWORD_REQUIREMENTS, ...requirements };
|
|
234
|
-
if (password.length < (req.minLength ?? 8)) {
|
|
235
|
-
return createAuthValidationError(`Password must be at least ${req.minLength} characters`, "password", "TOO_SHORT");
|
|
236
|
-
}
|
|
237
|
-
if (req.requireUppercase && !/[A-Z]/.test(password)) {
|
|
238
|
-
return createAuthValidationError("Password must contain at least one uppercase letter", "password", "NO_UPPERCASE");
|
|
239
|
-
}
|
|
240
|
-
if (req.requireNumbers && !/\d/.test(password)) {
|
|
241
|
-
return createAuthValidationError("Password must contain at least one number", "password", "NO_NUMBER");
|
|
2141
|
+
async unconsume(orgId, entitlement, periodStart, _periodEnd, amount = 1) {
|
|
2142
|
+
const k = this.key(orgId, entitlement, periodStart);
|
|
2143
|
+
const entry = this.entries.get(k);
|
|
2144
|
+
if (!entry)
|
|
2145
|
+
return;
|
|
2146
|
+
entry.consumed = Math.max(0, entry.consumed - amount);
|
|
242
2147
|
}
|
|
243
|
-
|
|
244
|
-
|
|
2148
|
+
async getConsumption(orgId, entitlement, periodStart, _periodEnd) {
|
|
2149
|
+
const k = this.key(orgId, entitlement, periodStart);
|
|
2150
|
+
const entry = this.entries.get(k);
|
|
2151
|
+
return entry?.consumed ?? 0;
|
|
245
2152
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
var DEFAULT_COOKIE_CONFIG = {
|
|
249
|
-
name: "vertz.sid",
|
|
250
|
-
httpOnly: true,
|
|
251
|
-
secure: true,
|
|
252
|
-
sameSite: "lax",
|
|
253
|
-
path: "/",
|
|
254
|
-
maxAge: 60 * 60 * 24 * 7
|
|
255
|
-
};
|
|
256
|
-
function parseDuration(duration) {
|
|
257
|
-
if (typeof duration === "number")
|
|
258
|
-
return duration;
|
|
259
|
-
const match = duration.match(/^(\d+)([smhd])$/);
|
|
260
|
-
if (!match)
|
|
261
|
-
throw new Error(`Invalid duration: ${duration}`);
|
|
262
|
-
const value = parseInt(match[1], 10);
|
|
263
|
-
const unit = match[2];
|
|
264
|
-
const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
265
|
-
return value * multipliers[unit] * 1000;
|
|
266
|
-
}
|
|
267
|
-
async function createJWT(user, secret, ttl, algorithm, customClaims) {
|
|
268
|
-
const claims = customClaims ? customClaims(user) : {};
|
|
269
|
-
const jwt = await new jose.SignJWT({
|
|
270
|
-
sub: user.id,
|
|
271
|
-
email: user.email,
|
|
272
|
-
role: user.role,
|
|
273
|
-
...claims
|
|
274
|
-
}).setProtectedHeader({ alg: algorithm }).setIssuedAt().setExpirationTime(Math.floor(ttl / 1000)).sign(new TextEncoder().encode(secret));
|
|
275
|
-
return jwt;
|
|
276
|
-
}
|
|
277
|
-
async function verifyJWT(token, secret, algorithm) {
|
|
278
|
-
try {
|
|
279
|
-
const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(secret), {
|
|
280
|
-
algorithms: [algorithm]
|
|
281
|
-
});
|
|
282
|
-
return payload;
|
|
283
|
-
} catch {
|
|
284
|
-
return null;
|
|
2153
|
+
dispose() {
|
|
2154
|
+
this.entries.clear();
|
|
285
2155
|
}
|
|
286
2156
|
}
|
|
287
|
-
|
|
288
|
-
|
|
2157
|
+
|
|
2158
|
+
// src/auth/index.ts
|
|
289
2159
|
function createAuth(config) {
|
|
290
2160
|
const {
|
|
291
2161
|
session,
|
|
@@ -312,7 +2182,13 @@ function createAuth(config) {
|
|
|
312
2182
|
console.warn(`[Auth] Auto-generated dev JWT secret at ${secretFile}. Add this path to .gitignore.`);
|
|
313
2183
|
}
|
|
314
2184
|
}
|
|
2185
|
+
if (session.strategy !== "jwt") {
|
|
2186
|
+
throw new Error(`Session strategy "${session.strategy}" is not yet supported. Use "jwt".`);
|
|
2187
|
+
}
|
|
315
2188
|
const cookieConfig = { ...DEFAULT_COOKIE_CONFIG, ...session.cookie };
|
|
2189
|
+
const refreshName = session.refreshName ?? "vertz.ref";
|
|
2190
|
+
const refreshTtlMs = parseDuration(session.refreshTtl ?? "7d");
|
|
2191
|
+
const refreshMaxAge = Math.floor(refreshTtlMs / 1000);
|
|
316
2192
|
if (cookieConfig.sameSite === "none" && cookieConfig.secure !== true) {
|
|
317
2193
|
throw new Error("SameSite=None requires secure=true");
|
|
318
2194
|
}
|
|
@@ -323,145 +2199,346 @@ function createAuth(config) {
|
|
|
323
2199
|
console.warn("Cookie 'secure' flag is disabled. This is allowed in development but must be enabled in production.");
|
|
324
2200
|
}
|
|
325
2201
|
const ttlMs = parseDuration(session.ttl);
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
2202
|
+
const DUMMY_HASH = "$2a$12$000000000000000000000uGWDREoC/y2KhZ5l2QkI4j0LpDjWcaq";
|
|
2203
|
+
const sessionStore = config.sessionStore ?? new InMemorySessionStore;
|
|
2204
|
+
const userStore = config.userStore ?? new InMemoryUserStore;
|
|
2205
|
+
const providers = new Map;
|
|
2206
|
+
if (config.providers) {
|
|
2207
|
+
for (const provider of config.providers) {
|
|
2208
|
+
providers.set(provider.id, provider);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
const oauthAccountStore = config.oauthAccountStore ?? (providers.size > 0 ? new InMemoryOAuthAccountStore : undefined);
|
|
2212
|
+
const oauthEncryptionKey = config.oauthEncryptionKey;
|
|
2213
|
+
const oauthSuccessRedirect = config.oauthSuccessRedirect ?? "/";
|
|
2214
|
+
const oauthErrorRedirect = config.oauthErrorRedirect ?? "/auth/error";
|
|
2215
|
+
const mfaConfig = config.mfa;
|
|
2216
|
+
const mfaEnabled = mfaConfig?.enabled ?? false;
|
|
2217
|
+
const mfaIssuer = mfaConfig?.issuer ?? "Vertz";
|
|
2218
|
+
const mfaBackupCodeCount = mfaConfig?.backupCodeCount ?? 10;
|
|
2219
|
+
const mfaStore = config.mfaStore ?? (mfaEnabled ? new InMemoryMFAStore : undefined);
|
|
2220
|
+
const PENDING_MFA_TTL = 10 * 60 * 1000;
|
|
2221
|
+
const pendingMfaSecrets = new Map;
|
|
2222
|
+
const emailVerificationConfig = config.emailVerification;
|
|
2223
|
+
const emailVerificationEnabled = emailVerificationConfig?.enabled ?? false;
|
|
2224
|
+
const emailVerificationTtlMs = parseDuration(emailVerificationConfig?.tokenTtl ?? "24h");
|
|
2225
|
+
const emailVerificationStore = config.emailVerificationStore ?? (emailVerificationEnabled ? new InMemoryEmailVerificationStore : undefined);
|
|
2226
|
+
const passwordResetConfig = config.passwordReset;
|
|
2227
|
+
const passwordResetEnabled = passwordResetConfig?.enabled ?? false;
|
|
2228
|
+
const passwordResetTtlMs = parseDuration(passwordResetConfig?.tokenTtl ?? "1h");
|
|
2229
|
+
const revokeSessionsOnReset = passwordResetConfig?.revokeSessionsOnReset ?? true;
|
|
2230
|
+
const passwordResetStore = config.passwordResetStore ?? (passwordResetEnabled ? new InMemoryPasswordResetStore : undefined);
|
|
2231
|
+
const rateLimitStore = config.rateLimitStore ?? new InMemoryRateLimitStore;
|
|
2232
|
+
const signInWindowMs = parseDuration(emailPassword?.rateLimit?.window || "15m");
|
|
2233
|
+
const signUpWindowMs = parseDuration("1h");
|
|
2234
|
+
const refreshWindowMs = parseDuration("1m");
|
|
2235
|
+
async function createSessionTokens(user, sessionId, options) {
|
|
2236
|
+
const jti = crypto.randomUUID();
|
|
2237
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
2238
|
+
const userClaims = claims ? claims(user) : {};
|
|
2239
|
+
const fvaClaim = options?.fva !== undefined ? { fva: options.fva } : {};
|
|
2240
|
+
let aclClaim = {};
|
|
2241
|
+
if (config.access) {
|
|
2242
|
+
const accessSet = await computeAccessSet({
|
|
2243
|
+
userId: user.id,
|
|
2244
|
+
accessDef: config.access.definition,
|
|
2245
|
+
roleStore: config.access.roleStore,
|
|
2246
|
+
closureStore: config.access.closureStore,
|
|
2247
|
+
flagStore: config.access.flagStore,
|
|
2248
|
+
plan: user.plan ?? null
|
|
2249
|
+
});
|
|
2250
|
+
const encoded = encodeAccessSet(accessSet);
|
|
2251
|
+
const canonicalJson = JSON.stringify(encoded);
|
|
2252
|
+
const stablePayload = {
|
|
2253
|
+
entitlements: encoded.entitlements,
|
|
2254
|
+
flags: encoded.flags,
|
|
2255
|
+
plan: encoded.plan
|
|
2256
|
+
};
|
|
2257
|
+
const hash = await sha256Hex(JSON.stringify(stablePayload));
|
|
2258
|
+
const byteLength = new TextEncoder().encode(canonicalJson).length;
|
|
2259
|
+
if (byteLength <= 2048) {
|
|
2260
|
+
aclClaim = { acl: { set: encoded, hash, overflow: false } };
|
|
2261
|
+
} else {
|
|
2262
|
+
aclClaim = { acl: { hash, overflow: true } };
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
const jwt = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, () => ({
|
|
2266
|
+
...userClaims,
|
|
2267
|
+
...fvaClaim,
|
|
2268
|
+
...aclClaim,
|
|
2269
|
+
jti,
|
|
2270
|
+
sid: sessionId
|
|
2271
|
+
}));
|
|
2272
|
+
const refreshToken = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
2273
|
+
const payload = {
|
|
2274
|
+
sub: user.id,
|
|
2275
|
+
email: user.email,
|
|
2276
|
+
role: user.role,
|
|
2277
|
+
iat: Math.floor(Date.now() / 1000),
|
|
2278
|
+
exp: Math.floor(expiresAt.getTime() / 1000),
|
|
2279
|
+
jti,
|
|
2280
|
+
sid: sessionId,
|
|
2281
|
+
claims: userClaims || undefined,
|
|
2282
|
+
...options?.fva !== undefined ? { fva: options.fva } : {},
|
|
2283
|
+
...aclClaim
|
|
2284
|
+
};
|
|
2285
|
+
return { jwt, refreshToken, payload, expiresAt };
|
|
2286
|
+
}
|
|
2287
|
+
function generateToken() {
|
|
2288
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
2289
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2290
|
+
}
|
|
2291
|
+
async function signUp(data, ctx) {
|
|
338
2292
|
const { email, password, role = "user", ...additionalFields } = data;
|
|
339
2293
|
if (!email || !email.includes("@")) {
|
|
340
|
-
return err(
|
|
2294
|
+
return err(createAuthValidationError2("Invalid email format", "email", "INVALID_FORMAT"));
|
|
341
2295
|
}
|
|
342
2296
|
const passwordError = validatePassword(password, emailPassword?.password);
|
|
343
2297
|
if (passwordError) {
|
|
344
2298
|
return err(passwordError);
|
|
345
2299
|
}
|
|
346
|
-
|
|
347
|
-
return err(createUserExistsError("User already exists", email.toLowerCase()));
|
|
348
|
-
}
|
|
349
|
-
const signUpRateLimit = signUpLimiter.check(`signup:${email.toLowerCase()}`, emailPassword?.rateLimit?.maxAttempts || 3);
|
|
2300
|
+
const signUpRateLimit = await rateLimitStore.check(`signup:${email.toLowerCase()}`, emailPassword?.rateLimit?.maxAttempts || 3, signUpWindowMs);
|
|
350
2301
|
if (!signUpRateLimit.allowed) {
|
|
351
2302
|
return err(createAuthRateLimitedError("Too many sign up attempts"));
|
|
352
2303
|
}
|
|
2304
|
+
const existing = await userStore.findByEmail(email.toLowerCase());
|
|
2305
|
+
if (existing) {
|
|
2306
|
+
return err(createUserExistsError("User already exists", email.toLowerCase()));
|
|
2307
|
+
}
|
|
353
2308
|
const passwordHash = await hashPassword(password);
|
|
354
2309
|
const now = new Date;
|
|
2310
|
+
const {
|
|
2311
|
+
id: _id,
|
|
2312
|
+
createdAt: _c,
|
|
2313
|
+
updatedAt: _u,
|
|
2314
|
+
...safeFields
|
|
2315
|
+
} = additionalFields;
|
|
355
2316
|
const user = {
|
|
2317
|
+
...safeFields,
|
|
356
2318
|
id: crypto.randomUUID(),
|
|
357
2319
|
email: email.toLowerCase(),
|
|
358
2320
|
role,
|
|
2321
|
+
emailVerified: !emailVerificationEnabled,
|
|
359
2322
|
createdAt: now,
|
|
360
|
-
updatedAt: now
|
|
361
|
-
...additionalFields
|
|
362
|
-
};
|
|
363
|
-
users.set(email.toLowerCase(), { user, passwordHash });
|
|
364
|
-
const token = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, claims);
|
|
365
|
-
const expiresAt = new Date(Date.now() + ttlMs);
|
|
366
|
-
const payload = {
|
|
367
|
-
sub: user.id,
|
|
368
|
-
email: user.email,
|
|
369
|
-
role: user.role,
|
|
370
|
-
iat: Math.floor(Date.now() / 1000),
|
|
371
|
-
exp: Math.floor(expiresAt.getTime() / 1000),
|
|
372
|
-
claims: claims ? claims(user) : undefined
|
|
2323
|
+
updatedAt: now
|
|
373
2324
|
};
|
|
374
|
-
|
|
375
|
-
|
|
2325
|
+
await userStore.createUser(user, passwordHash);
|
|
2326
|
+
if (emailVerificationEnabled && emailVerificationStore && emailVerificationConfig?.onSend) {
|
|
2327
|
+
const token = generateToken();
|
|
2328
|
+
const tokenHash = await sha256Hex(token);
|
|
2329
|
+
await emailVerificationStore.createVerification({
|
|
2330
|
+
userId: user.id,
|
|
2331
|
+
tokenHash,
|
|
2332
|
+
expiresAt: new Date(Date.now() + emailVerificationTtlMs)
|
|
2333
|
+
});
|
|
2334
|
+
await emailVerificationConfig.onSend(user, token);
|
|
2335
|
+
}
|
|
2336
|
+
const sessionId = crypto.randomUUID();
|
|
2337
|
+
const tokens = await createSessionTokens(user, sessionId);
|
|
2338
|
+
const refreshTokenHash = await sha256Hex(tokens.refreshToken);
|
|
2339
|
+
const ipAddress = ctx?.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? ctx?.headers.get("x-real-ip") ?? "";
|
|
2340
|
+
const userAgent = ctx?.headers.get("user-agent") ?? "";
|
|
2341
|
+
await sessionStore.createSessionWithId(sessionId, {
|
|
2342
|
+
userId: user.id,
|
|
2343
|
+
refreshTokenHash,
|
|
2344
|
+
ipAddress,
|
|
2345
|
+
userAgent,
|
|
2346
|
+
expiresAt: new Date(Date.now() + refreshTtlMs),
|
|
2347
|
+
currentTokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
|
|
2348
|
+
});
|
|
2349
|
+
return ok({
|
|
2350
|
+
user,
|
|
2351
|
+
expiresAt: tokens.expiresAt,
|
|
2352
|
+
payload: tokens.payload,
|
|
2353
|
+
tokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
|
|
2354
|
+
});
|
|
376
2355
|
}
|
|
377
|
-
async function signIn(data) {
|
|
2356
|
+
async function signIn(data, ctx) {
|
|
378
2357
|
const { email, password } = data;
|
|
379
|
-
const
|
|
2358
|
+
const signInRateLimit = await rateLimitStore.check(`signin:${email.toLowerCase()}`, emailPassword?.rateLimit?.maxAttempts || 5, signInWindowMs);
|
|
2359
|
+
if (!signInRateLimit.allowed) {
|
|
2360
|
+
return err(createAuthRateLimitedError("Too many sign in attempts"));
|
|
2361
|
+
}
|
|
2362
|
+
const stored = await userStore.findByEmail(email.toLowerCase());
|
|
380
2363
|
if (!stored) {
|
|
2364
|
+
await verifyPassword(password, DUMMY_HASH);
|
|
381
2365
|
return err(createInvalidCredentialsError());
|
|
382
2366
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
return err(
|
|
2367
|
+
if (stored.passwordHash === null) {
|
|
2368
|
+
await verifyPassword(password, DUMMY_HASH);
|
|
2369
|
+
return err(createInvalidCredentialsError());
|
|
386
2370
|
}
|
|
387
2371
|
const valid = await verifyPassword(password, stored.passwordHash);
|
|
388
2372
|
if (!valid) {
|
|
389
2373
|
return err(createInvalidCredentialsError());
|
|
390
2374
|
}
|
|
391
|
-
const user =
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
2375
|
+
const user = stored.user;
|
|
2376
|
+
if (mfaStore && await mfaStore.isMfaEnabled(user.id)) {
|
|
2377
|
+
return err(createMfaRequiredError("MFA verification required"));
|
|
2378
|
+
}
|
|
2379
|
+
const sessionId = crypto.randomUUID();
|
|
2380
|
+
const tokens = await createSessionTokens(user, sessionId);
|
|
2381
|
+
const refreshTokenHash = await sha256Hex(tokens.refreshToken);
|
|
2382
|
+
const ipAddress = ctx?.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? ctx?.headers.get("x-real-ip") ?? "";
|
|
2383
|
+
const userAgent = ctx?.headers.get("user-agent") ?? "";
|
|
2384
|
+
await sessionStore.createSessionWithId(sessionId, {
|
|
2385
|
+
userId: user.id,
|
|
2386
|
+
refreshTokenHash,
|
|
2387
|
+
ipAddress,
|
|
2388
|
+
userAgent,
|
|
2389
|
+
expiresAt: new Date(Date.now() + refreshTtlMs),
|
|
2390
|
+
currentTokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
|
|
2391
|
+
});
|
|
2392
|
+
return ok({
|
|
2393
|
+
user,
|
|
2394
|
+
expiresAt: tokens.expiresAt,
|
|
2395
|
+
payload: tokens.payload,
|
|
2396
|
+
tokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
|
|
2397
|
+
});
|
|
404
2398
|
}
|
|
405
2399
|
async function signOut(ctx) {
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
sessions.delete(token);
|
|
2400
|
+
const sessionResult = await getSession(ctx.headers);
|
|
2401
|
+
if (sessionResult.ok && sessionResult.data) {
|
|
2402
|
+
await sessionStore.revokeSession(sessionResult.data.payload.sid);
|
|
410
2403
|
}
|
|
411
2404
|
return ok(undefined);
|
|
412
2405
|
}
|
|
413
2406
|
async function getSession(headers) {
|
|
414
2407
|
const cookieName = cookieConfig.name || "vertz.sid";
|
|
415
|
-
const
|
|
2408
|
+
const cookieEntry = headers.get("cookie")?.split(";").find((c) => c.trim().startsWith(`${cookieName}=`));
|
|
2409
|
+
const token = cookieEntry ? cookieEntry.trim().slice(`${cookieName}=`.length) : undefined;
|
|
416
2410
|
if (!token) {
|
|
417
2411
|
return ok(null);
|
|
418
2412
|
}
|
|
419
|
-
const session2 = sessions.get(token);
|
|
420
|
-
if (!session2) {
|
|
421
|
-
return ok(null);
|
|
422
|
-
}
|
|
423
|
-
if (session2.expiresAt < new Date) {
|
|
424
|
-
sessions.delete(token);
|
|
425
|
-
return ok(null);
|
|
426
|
-
}
|
|
427
2413
|
const payload = await verifyJWT(token, jwtSecret, jwtAlgorithm);
|
|
428
2414
|
if (!payload) {
|
|
429
|
-
sessions.delete(token);
|
|
430
2415
|
return ok(null);
|
|
431
2416
|
}
|
|
432
|
-
const
|
|
433
|
-
if (!
|
|
2417
|
+
const user = await userStore.findById(payload.sub);
|
|
2418
|
+
if (!user) {
|
|
434
2419
|
return ok(null);
|
|
435
2420
|
}
|
|
436
|
-
const user = buildAuthUser(stored);
|
|
437
2421
|
const expiresAt = new Date(payload.exp * 1000);
|
|
438
2422
|
return ok({ user, expiresAt, payload });
|
|
439
2423
|
}
|
|
440
2424
|
async function refreshSession(ctx) {
|
|
441
|
-
const refreshRateLimit =
|
|
2425
|
+
const refreshRateLimit = await rateLimitStore.check(`refresh:${ctx.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "default"}`, 10, refreshWindowMs);
|
|
442
2426
|
if (!refreshRateLimit.allowed) {
|
|
443
2427
|
return err(createAuthRateLimitedError("Too many refresh attempts"));
|
|
444
2428
|
}
|
|
445
|
-
const
|
|
446
|
-
|
|
2429
|
+
const refreshEntry = ctx.headers.get("cookie")?.split(";").find((c) => c.trim().startsWith(`${refreshName}=`));
|
|
2430
|
+
const refreshToken = refreshEntry ? refreshEntry.trim().slice(`${refreshName}=`.length) : undefined;
|
|
2431
|
+
if (!refreshToken) {
|
|
2432
|
+
return err(createSessionExpiredError("No refresh token"));
|
|
2433
|
+
}
|
|
2434
|
+
const refreshHash = await sha256Hex(refreshToken);
|
|
2435
|
+
let storedSession = await sessionStore.findByRefreshHash(refreshHash);
|
|
2436
|
+
let isGracePeriod = false;
|
|
2437
|
+
if (!storedSession) {
|
|
2438
|
+
storedSession = await sessionStore.findByPreviousRefreshHash(refreshHash);
|
|
2439
|
+
if (storedSession && storedSession.lastActiveAt.getTime() + 1e4 > Date.now()) {
|
|
2440
|
+
isGracePeriod = true;
|
|
2441
|
+
} else {
|
|
2442
|
+
return err(createSessionExpiredError("Invalid refresh token"));
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
const user = await userStore.findById(storedSession.userId);
|
|
2446
|
+
if (!user) {
|
|
2447
|
+
return err(createSessionExpiredError("User not found"));
|
|
2448
|
+
}
|
|
2449
|
+
if (isGracePeriod) {
|
|
2450
|
+
const currentTokens = await sessionStore.getCurrentTokens(storedSession.id);
|
|
2451
|
+
if (currentTokens) {
|
|
2452
|
+
const payload = await verifyJWT(currentTokens.jwt, jwtSecret, jwtAlgorithm);
|
|
2453
|
+
return ok({
|
|
2454
|
+
user,
|
|
2455
|
+
expiresAt: payload ? new Date(payload.exp * 1000) : new Date(Date.now() + ttlMs),
|
|
2456
|
+
payload: payload ?? {
|
|
2457
|
+
sub: user.id,
|
|
2458
|
+
email: user.email,
|
|
2459
|
+
role: user.role,
|
|
2460
|
+
iat: Math.floor(Date.now() / 1000),
|
|
2461
|
+
exp: Math.floor((Date.now() + ttlMs) / 1000),
|
|
2462
|
+
jti: "",
|
|
2463
|
+
sid: storedSession.id
|
|
2464
|
+
},
|
|
2465
|
+
tokens: currentTokens
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
let existingFva;
|
|
2470
|
+
const oldTokens = await sessionStore.getCurrentTokens(storedSession.id);
|
|
2471
|
+
if (oldTokens) {
|
|
2472
|
+
const oldPayload = await verifyJWT(oldTokens.jwt, jwtSecret, jwtAlgorithm);
|
|
2473
|
+
existingFva = oldPayload?.fva;
|
|
2474
|
+
}
|
|
2475
|
+
const newTokens = await createSessionTokens(user, storedSession.id, existingFva !== undefined ? { fva: existingFva } : undefined);
|
|
2476
|
+
const newRefreshHash = await sha256Hex(newTokens.refreshToken);
|
|
2477
|
+
await sessionStore.updateSession(storedSession.id, {
|
|
2478
|
+
refreshTokenHash: newRefreshHash,
|
|
2479
|
+
previousRefreshHash: storedSession.refreshTokenHash,
|
|
2480
|
+
lastActiveAt: new Date,
|
|
2481
|
+
currentTokens: { jwt: newTokens.jwt, refreshToken: newTokens.refreshToken }
|
|
2482
|
+
});
|
|
2483
|
+
return ok({
|
|
2484
|
+
user,
|
|
2485
|
+
expiresAt: newTokens.expiresAt,
|
|
2486
|
+
payload: newTokens.payload,
|
|
2487
|
+
tokens: { jwt: newTokens.jwt, refreshToken: newTokens.refreshToken }
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
async function listSessions(headers) {
|
|
2491
|
+
const sessionResult = await getSession(headers);
|
|
2492
|
+
if (!sessionResult.ok)
|
|
2493
|
+
return sessionResult;
|
|
2494
|
+
if (!sessionResult.data) {
|
|
2495
|
+
return err(createSessionExpiredError("Not authenticated"));
|
|
2496
|
+
}
|
|
2497
|
+
const currentSid = sessionResult.data.payload.sid;
|
|
2498
|
+
const sessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
|
|
2499
|
+
const infos = sessions.map((s) => ({
|
|
2500
|
+
id: s.id,
|
|
2501
|
+
userId: s.userId,
|
|
2502
|
+
ipAddress: s.ipAddress,
|
|
2503
|
+
userAgent: s.userAgent,
|
|
2504
|
+
deviceName: parseDeviceName(s.userAgent),
|
|
2505
|
+
createdAt: s.createdAt,
|
|
2506
|
+
lastActiveAt: s.lastActiveAt,
|
|
2507
|
+
expiresAt: s.expiresAt,
|
|
2508
|
+
isCurrent: s.id === currentSid
|
|
2509
|
+
}));
|
|
2510
|
+
return ok(infos);
|
|
2511
|
+
}
|
|
2512
|
+
async function revokeSessionById(sessionId, headers) {
|
|
2513
|
+
const sessionResult = await getSession(headers);
|
|
2514
|
+
if (!sessionResult.ok)
|
|
2515
|
+
return sessionResult;
|
|
2516
|
+
if (!sessionResult.data) {
|
|
2517
|
+
return err(createSessionExpiredError("Not authenticated"));
|
|
2518
|
+
}
|
|
2519
|
+
const targetSessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
|
|
2520
|
+
const target = targetSessions.find((s) => s.id === sessionId);
|
|
2521
|
+
if (!target) {
|
|
2522
|
+
return err(createSessionNotFoundError("Session not found"));
|
|
2523
|
+
}
|
|
2524
|
+
await sessionStore.revokeSession(sessionId);
|
|
2525
|
+
return ok(undefined);
|
|
2526
|
+
}
|
|
2527
|
+
async function revokeAllSessions(headers) {
|
|
2528
|
+
const sessionResult = await getSession(headers);
|
|
2529
|
+
if (!sessionResult.ok)
|
|
447
2530
|
return sessionResult;
|
|
448
|
-
}
|
|
449
2531
|
if (!sessionResult.data) {
|
|
450
|
-
return err(createSessionExpiredError("
|
|
2532
|
+
return err(createSessionExpiredError("Not authenticated"));
|
|
451
2533
|
}
|
|
452
|
-
const
|
|
453
|
-
const
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
exp: Math.floor(expiresAt.getTime() / 1000),
|
|
461
|
-
claims: claims ? claims(user) : undefined
|
|
462
|
-
};
|
|
463
|
-
sessions.set(token, { userId: user.id, expiresAt });
|
|
464
|
-
return ok({ user, expiresAt, payload });
|
|
2534
|
+
const currentSid = sessionResult.data.payload.sid;
|
|
2535
|
+
const sessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
|
|
2536
|
+
for (const s of sessions) {
|
|
2537
|
+
if (s.id !== currentSid) {
|
|
2538
|
+
await sessionStore.revokeSession(s.id);
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
return ok(undefined);
|
|
465
2542
|
}
|
|
466
2543
|
function authErrorToStatus(error) {
|
|
467
2544
|
switch (error.code) {
|
|
@@ -471,6 +2548,8 @@ function createAuth(config) {
|
|
|
471
2548
|
return 401;
|
|
472
2549
|
case "SESSION_EXPIRED":
|
|
473
2550
|
return 401;
|
|
2551
|
+
case "SESSION_NOT_FOUND":
|
|
2552
|
+
return 404;
|
|
474
2553
|
case "USER_EXISTS":
|
|
475
2554
|
return 409;
|
|
476
2555
|
case "RATE_LIMITED":
|
|
@@ -481,6 +2560,14 @@ function createAuth(config) {
|
|
|
481
2560
|
return 500;
|
|
482
2561
|
}
|
|
483
2562
|
}
|
|
2563
|
+
function securityHeaders() {
|
|
2564
|
+
return {
|
|
2565
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
2566
|
+
Pragma: "no-cache",
|
|
2567
|
+
"X-Content-Type-Options": "nosniff",
|
|
2568
|
+
"Referrer-Policy": "strict-origin-when-cross-origin"
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
484
2571
|
async function handleAuthRequest(request) {
|
|
485
2572
|
const url = new URL(request.url);
|
|
486
2573
|
const path = url.pathname.replace("/api/auth", "") || "/";
|
|
@@ -504,7 +2591,7 @@ function createAuth(config) {
|
|
|
504
2591
|
if (isProduction) {
|
|
505
2592
|
return new Response(JSON.stringify({ error: "CSRF validation failed" }), {
|
|
506
2593
|
status: 403,
|
|
507
|
-
headers: { "Content-Type": "application/json" }
|
|
2594
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
508
2595
|
});
|
|
509
2596
|
} else {
|
|
510
2597
|
console.warn("[Auth] CSRF warning: Origin/Referer missing or mismatched (allowed in development)");
|
|
@@ -515,7 +2602,7 @@ function createAuth(config) {
|
|
|
515
2602
|
if (isProduction) {
|
|
516
2603
|
return new Response(JSON.stringify({ error: "Missing required X-VTZ-Request header" }), {
|
|
517
2604
|
status: 403,
|
|
518
|
-
headers: { "Content-Type": "application/json" }
|
|
2605
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
519
2606
|
});
|
|
520
2607
|
} else {
|
|
521
2608
|
console.warn("[Auth] CSRF warning: Missing X-VTZ-Request header (allowed in development)");
|
|
@@ -525,78 +2612,916 @@ function createAuth(config) {
|
|
|
525
2612
|
try {
|
|
526
2613
|
if (method === "POST" && path === "/signup") {
|
|
527
2614
|
const body = await request.json();
|
|
528
|
-
const result = await signUp(body);
|
|
2615
|
+
const result = await signUp(body, { headers: request.headers });
|
|
529
2616
|
if (!result.ok) {
|
|
530
2617
|
return new Response(JSON.stringify({ error: result.error }), {
|
|
531
2618
|
status: authErrorToStatus(result.error),
|
|
532
|
-
headers: { "Content-Type": "application/json" }
|
|
2619
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
533
2620
|
});
|
|
534
2621
|
}
|
|
535
|
-
const
|
|
536
|
-
|
|
2622
|
+
const headers = new Headers({
|
|
2623
|
+
"Content-Type": "application/json",
|
|
2624
|
+
...securityHeaders()
|
|
2625
|
+
});
|
|
2626
|
+
if (result.data.tokens) {
|
|
2627
|
+
headers.append("Set-Cookie", buildSessionCookie(result.data.tokens.jwt, cookieConfig));
|
|
2628
|
+
headers.append("Set-Cookie", buildRefreshCookie(result.data.tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
|
|
2629
|
+
}
|
|
2630
|
+
return new Response(JSON.stringify({
|
|
2631
|
+
user: result.data.user,
|
|
2632
|
+
expiresAt: result.data.expiresAt.getTime()
|
|
2633
|
+
}), {
|
|
537
2634
|
status: 201,
|
|
538
|
-
headers
|
|
539
|
-
"Content-Type": "application/json",
|
|
540
|
-
"Set-Cookie": buildCookie(cookieValue)
|
|
541
|
-
}
|
|
2635
|
+
headers
|
|
542
2636
|
});
|
|
543
2637
|
}
|
|
544
2638
|
if (method === "POST" && path === "/signin") {
|
|
545
2639
|
const body = await request.json();
|
|
546
|
-
const result = await signIn(body);
|
|
2640
|
+
const result = await signIn(body, { headers: request.headers });
|
|
547
2641
|
if (!result.ok) {
|
|
2642
|
+
if (result.error.code === "MFA_REQUIRED" && oauthEncryptionKey) {
|
|
2643
|
+
const stored = await userStore.findByEmail(body.email.toLowerCase());
|
|
2644
|
+
if (stored) {
|
|
2645
|
+
const challengeData = JSON.stringify({
|
|
2646
|
+
userId: stored.user.id,
|
|
2647
|
+
expiresAt: Date.now() + 300000
|
|
2648
|
+
});
|
|
2649
|
+
const encryptedChallenge = await encrypt(challengeData, oauthEncryptionKey);
|
|
2650
|
+
const headers2 = new Headers({
|
|
2651
|
+
"Content-Type": "application/json",
|
|
2652
|
+
...securityHeaders()
|
|
2653
|
+
});
|
|
2654
|
+
headers2.append("Set-Cookie", buildMfaChallengeCookie(encryptedChallenge, cookieConfig));
|
|
2655
|
+
return new Response(JSON.stringify({ error: result.error }), {
|
|
2656
|
+
status: 403,
|
|
2657
|
+
headers: headers2
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
548
2661
|
return new Response(JSON.stringify({ error: result.error }), {
|
|
549
2662
|
status: authErrorToStatus(result.error),
|
|
550
|
-
headers: { "Content-Type": "application/json" }
|
|
2663
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
551
2664
|
});
|
|
552
2665
|
}
|
|
553
|
-
const
|
|
554
|
-
|
|
2666
|
+
const headers = new Headers({
|
|
2667
|
+
"Content-Type": "application/json",
|
|
2668
|
+
...securityHeaders()
|
|
2669
|
+
});
|
|
2670
|
+
if (result.data.tokens) {
|
|
2671
|
+
headers.append("Set-Cookie", buildSessionCookie(result.data.tokens.jwt, cookieConfig));
|
|
2672
|
+
headers.append("Set-Cookie", buildRefreshCookie(result.data.tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
|
|
2673
|
+
}
|
|
2674
|
+
return new Response(JSON.stringify({
|
|
2675
|
+
user: result.data.user,
|
|
2676
|
+
expiresAt: result.data.expiresAt.getTime()
|
|
2677
|
+
}), {
|
|
555
2678
|
status: 200,
|
|
556
|
-
headers
|
|
557
|
-
"Content-Type": "application/json",
|
|
558
|
-
"Set-Cookie": buildCookie(cookieValue)
|
|
559
|
-
}
|
|
2679
|
+
headers
|
|
560
2680
|
});
|
|
561
2681
|
}
|
|
562
2682
|
if (method === "POST" && path === "/signout") {
|
|
563
2683
|
await signOut({ headers: request.headers });
|
|
2684
|
+
const headers = new Headers({
|
|
2685
|
+
"Content-Type": "application/json",
|
|
2686
|
+
...securityHeaders()
|
|
2687
|
+
});
|
|
2688
|
+
headers.append("Set-Cookie", buildSessionCookie("", cookieConfig, true));
|
|
2689
|
+
headers.append("Set-Cookie", buildRefreshCookie("", cookieConfig, refreshName, refreshMaxAge, true));
|
|
564
2690
|
return new Response(JSON.stringify({ ok: true }), {
|
|
2691
|
+
status: 200,
|
|
2692
|
+
headers
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
if (method === "GET" && path === "/session") {
|
|
2696
|
+
const result = await getSession(request.headers);
|
|
2697
|
+
if (!result.ok) {
|
|
2698
|
+
return new Response(JSON.stringify({ error: result.error }), {
|
|
2699
|
+
status: 500,
|
|
2700
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
return new Response(JSON.stringify({ session: result.data }), {
|
|
2704
|
+
status: 200,
|
|
2705
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
if (method === "GET" && path === "/access-set") {
|
|
2709
|
+
if (!config.access) {
|
|
2710
|
+
return new Response(JSON.stringify({ error: "Access control not configured" }), {
|
|
2711
|
+
status: 404,
|
|
2712
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
const sessionResult = await getSession(request.headers);
|
|
2716
|
+
if (!sessionResult.ok || !sessionResult.data) {
|
|
2717
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
2718
|
+
status: 401,
|
|
2719
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
const accessSet = await computeAccessSet({
|
|
2723
|
+
userId: sessionResult.data.user.id,
|
|
2724
|
+
accessDef: config.access.definition,
|
|
2725
|
+
roleStore: config.access.roleStore,
|
|
2726
|
+
closureStore: config.access.closureStore,
|
|
2727
|
+
flagStore: config.access.flagStore,
|
|
2728
|
+
plan: sessionResult.data.user.plan ?? null
|
|
2729
|
+
});
|
|
2730
|
+
const encoded = encodeAccessSet(accessSet);
|
|
2731
|
+
const hashPayload = {
|
|
2732
|
+
entitlements: encoded.entitlements,
|
|
2733
|
+
flags: encoded.flags,
|
|
2734
|
+
plan: encoded.plan
|
|
2735
|
+
};
|
|
2736
|
+
const hash = await sha256Hex(JSON.stringify(hashPayload));
|
|
2737
|
+
const quotedEtag = `"${hash}"`;
|
|
2738
|
+
const ifNoneMatch = request.headers.get("If-None-Match");
|
|
2739
|
+
if (ifNoneMatch && ifNoneMatch.replace(/"/g, "") === hash) {
|
|
2740
|
+
return new Response(null, {
|
|
2741
|
+
status: 304,
|
|
2742
|
+
headers: {
|
|
2743
|
+
ETag: quotedEtag,
|
|
2744
|
+
...securityHeaders(),
|
|
2745
|
+
"Cache-Control": "no-cache"
|
|
2746
|
+
}
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2749
|
+
return new Response(JSON.stringify({ accessSet }), {
|
|
565
2750
|
status: 200,
|
|
566
2751
|
headers: {
|
|
567
2752
|
"Content-Type": "application/json",
|
|
568
|
-
|
|
2753
|
+
ETag: quotedEtag,
|
|
2754
|
+
...securityHeaders(),
|
|
2755
|
+
"Cache-Control": "no-cache"
|
|
569
2756
|
}
|
|
570
2757
|
});
|
|
571
2758
|
}
|
|
572
|
-
if (method === "
|
|
573
|
-
const result = await
|
|
2759
|
+
if (method === "POST" && path === "/refresh") {
|
|
2760
|
+
const result = await refreshSession({ headers: request.headers });
|
|
2761
|
+
if (!result.ok) {
|
|
2762
|
+
const headers2 = new Headers({
|
|
2763
|
+
"Content-Type": "application/json",
|
|
2764
|
+
...securityHeaders()
|
|
2765
|
+
});
|
|
2766
|
+
headers2.append("Set-Cookie", buildSessionCookie("", cookieConfig, true));
|
|
2767
|
+
headers2.append("Set-Cookie", buildRefreshCookie("", cookieConfig, refreshName, refreshMaxAge, true));
|
|
2768
|
+
return new Response(JSON.stringify({ error: result.error }), {
|
|
2769
|
+
status: authErrorToStatus(result.error),
|
|
2770
|
+
headers: headers2
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
const headers = new Headers({
|
|
2774
|
+
"Content-Type": "application/json",
|
|
2775
|
+
...securityHeaders()
|
|
2776
|
+
});
|
|
2777
|
+
if (result.data.tokens) {
|
|
2778
|
+
headers.append("Set-Cookie", buildSessionCookie(result.data.tokens.jwt, cookieConfig));
|
|
2779
|
+
headers.append("Set-Cookie", buildRefreshCookie(result.data.tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
|
|
2780
|
+
}
|
|
2781
|
+
return new Response(JSON.stringify({
|
|
2782
|
+
user: result.data.user,
|
|
2783
|
+
expiresAt: result.data.expiresAt.getTime()
|
|
2784
|
+
}), {
|
|
2785
|
+
status: 200,
|
|
2786
|
+
headers
|
|
2787
|
+
});
|
|
2788
|
+
}
|
|
2789
|
+
if (method === "GET" && path === "/sessions") {
|
|
2790
|
+
const result = await listSessions(request.headers);
|
|
2791
|
+
if (!result.ok) {
|
|
2792
|
+
return new Response(JSON.stringify({ error: result.error }), {
|
|
2793
|
+
status: authErrorToStatus(result.error),
|
|
2794
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
return new Response(JSON.stringify({ sessions: result.data }), {
|
|
2798
|
+
status: 200,
|
|
2799
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
if (method === "DELETE" && path.startsWith("/sessions/")) {
|
|
2803
|
+
const sessionId = path.replace("/sessions/", "");
|
|
2804
|
+
const result = await revokeSessionById(sessionId, request.headers);
|
|
2805
|
+
if (!result.ok) {
|
|
2806
|
+
return new Response(JSON.stringify({ error: result.error }), {
|
|
2807
|
+
status: authErrorToStatus(result.error),
|
|
2808
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
2812
|
+
status: 200,
|
|
2813
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2814
|
+
});
|
|
2815
|
+
}
|
|
2816
|
+
if (method === "DELETE" && path === "/sessions") {
|
|
2817
|
+
const result = await revokeAllSessions(request.headers);
|
|
574
2818
|
if (!result.ok) {
|
|
575
2819
|
return new Response(JSON.stringify({ error: result.error }), {
|
|
2820
|
+
status: authErrorToStatus(result.error),
|
|
2821
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2822
|
+
});
|
|
2823
|
+
}
|
|
2824
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
2825
|
+
status: 200,
|
|
2826
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2827
|
+
});
|
|
2828
|
+
}
|
|
2829
|
+
if (method === "GET" && path.startsWith("/oauth/") && !path.includes("/callback")) {
|
|
2830
|
+
const providerId = path.replace("/oauth/", "");
|
|
2831
|
+
const provider = providers.get(providerId);
|
|
2832
|
+
if (!provider) {
|
|
2833
|
+
return new Response(JSON.stringify({ error: "Provider not found" }), {
|
|
2834
|
+
status: 404,
|
|
2835
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2838
|
+
const oauthIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "default";
|
|
2839
|
+
const oauthRateLimit = await rateLimitStore.check(`oauth:${oauthIp}`, 10, 5 * 60 * 1000);
|
|
2840
|
+
if (!oauthRateLimit.allowed) {
|
|
2841
|
+
return new Response(JSON.stringify({ error: "Too many requests" }), {
|
|
2842
|
+
status: 429,
|
|
2843
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
if (!oauthEncryptionKey) {
|
|
2847
|
+
return new Response(JSON.stringify({ error: "OAuth not configured" }), {
|
|
2848
|
+
status: 500,
|
|
2849
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
const codeVerifier = generateCodeVerifier();
|
|
2853
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
2854
|
+
const state = generateNonce();
|
|
2855
|
+
const nonce = generateNonce();
|
|
2856
|
+
const stateData = {
|
|
2857
|
+
provider: providerId,
|
|
2858
|
+
state,
|
|
2859
|
+
codeVerifier,
|
|
2860
|
+
nonce,
|
|
2861
|
+
expiresAt: Date.now() + 300000
|
|
2862
|
+
};
|
|
2863
|
+
const encryptedState = await encrypt(JSON.stringify(stateData), oauthEncryptionKey);
|
|
2864
|
+
const authUrl = provider.getAuthorizationUrl(state, codeChallenge, nonce);
|
|
2865
|
+
const headers = new Headers({
|
|
2866
|
+
Location: authUrl,
|
|
2867
|
+
...securityHeaders()
|
|
2868
|
+
});
|
|
2869
|
+
headers.append("Set-Cookie", buildOAuthStateCookie(encryptedState, cookieConfig));
|
|
2870
|
+
return new Response(null, { status: 302, headers });
|
|
2871
|
+
}
|
|
2872
|
+
if (method === "GET" && path.includes("/oauth/") && path.endsWith("/callback")) {
|
|
2873
|
+
const providerId = path.replace("/oauth/", "").replace("/callback", "");
|
|
2874
|
+
const provider = providers.get(providerId);
|
|
2875
|
+
const errorRedirect = oauthErrorRedirect;
|
|
2876
|
+
if (!provider || !oauthEncryptionKey || !oauthAccountStore) {
|
|
2877
|
+
return new Response(null, {
|
|
2878
|
+
status: 302,
|
|
2879
|
+
headers: {
|
|
2880
|
+
Location: `${errorRedirect}?error=provider_not_configured`,
|
|
2881
|
+
...securityHeaders()
|
|
2882
|
+
}
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
const oauthCookieEntry = request.headers.get("cookie")?.split(";").find((c) => c.trim().startsWith("vertz.oauth="));
|
|
2886
|
+
const encryptedState = oauthCookieEntry ? oauthCookieEntry.trim().slice("vertz.oauth=".length) : undefined;
|
|
2887
|
+
if (!encryptedState) {
|
|
2888
|
+
return new Response(null, {
|
|
2889
|
+
status: 302,
|
|
2890
|
+
headers: {
|
|
2891
|
+
Location: `${errorRedirect}?error=invalid_state`,
|
|
2892
|
+
...securityHeaders()
|
|
2893
|
+
}
|
|
2894
|
+
});
|
|
2895
|
+
}
|
|
2896
|
+
const decryptedState = await decrypt(encryptedState, oauthEncryptionKey);
|
|
2897
|
+
if (!decryptedState) {
|
|
2898
|
+
return new Response(null, {
|
|
2899
|
+
status: 302,
|
|
2900
|
+
headers: {
|
|
2901
|
+
Location: `${errorRedirect}?error=invalid_state`,
|
|
2902
|
+
...securityHeaders()
|
|
2903
|
+
}
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2906
|
+
const stateData = JSON.parse(decryptedState);
|
|
2907
|
+
const queryState = url.searchParams.get("state");
|
|
2908
|
+
const code = url.searchParams.get("code");
|
|
2909
|
+
const providerError = url.searchParams.get("error");
|
|
2910
|
+
if (providerError) {
|
|
2911
|
+
const headers = new Headers({
|
|
2912
|
+
Location: `${errorRedirect}?error=${encodeURIComponent(providerError)}`,
|
|
2913
|
+
...securityHeaders()
|
|
2914
|
+
});
|
|
2915
|
+
headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
2916
|
+
return new Response(null, { status: 302, headers });
|
|
2917
|
+
}
|
|
2918
|
+
if (stateData.state !== queryState || stateData.provider !== providerId) {
|
|
2919
|
+
const headers = new Headers({
|
|
2920
|
+
Location: `${errorRedirect}?error=invalid_state`,
|
|
2921
|
+
...securityHeaders()
|
|
2922
|
+
});
|
|
2923
|
+
headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
2924
|
+
return new Response(null, { status: 302, headers });
|
|
2925
|
+
}
|
|
2926
|
+
if (stateData.expiresAt < Date.now()) {
|
|
2927
|
+
const headers = new Headers({
|
|
2928
|
+
Location: `${errorRedirect}?error=invalid_state`,
|
|
2929
|
+
...securityHeaders()
|
|
2930
|
+
});
|
|
2931
|
+
headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
2932
|
+
return new Response(null, { status: 302, headers });
|
|
2933
|
+
}
|
|
2934
|
+
try {
|
|
2935
|
+
const tokens = await provider.exchangeCode(code ?? "", stateData.codeVerifier);
|
|
2936
|
+
const userInfo = await provider.getUserInfo(tokens.accessToken, tokens.idToken, stateData.nonce);
|
|
2937
|
+
const responseHeaders = new Headers({
|
|
2938
|
+
Location: oauthSuccessRedirect,
|
|
2939
|
+
...securityHeaders()
|
|
2940
|
+
});
|
|
2941
|
+
responseHeaders.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
2942
|
+
let userId = null;
|
|
2943
|
+
userId = await oauthAccountStore.findByProviderAccount(provider.id, userInfo.providerId);
|
|
2944
|
+
if (!userId) {
|
|
2945
|
+
if (provider.trustEmail && userInfo.emailVerified) {
|
|
2946
|
+
const existingUser = await userStore.findByEmail(userInfo.email.toLowerCase());
|
|
2947
|
+
if (existingUser) {
|
|
2948
|
+
userId = existingUser.user.id;
|
|
2949
|
+
await oauthAccountStore.linkAccount(userId, provider.id, userInfo.providerId, userInfo.email);
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
if (!userId) {
|
|
2953
|
+
if (!userInfo.email || !userInfo.email.includes("@")) {
|
|
2954
|
+
const headers = new Headers({
|
|
2955
|
+
Location: `${errorRedirect}?error=email_required`,
|
|
2956
|
+
...securityHeaders()
|
|
2957
|
+
});
|
|
2958
|
+
headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
2959
|
+
return new Response(null, { status: 302, headers });
|
|
2960
|
+
}
|
|
2961
|
+
const now = new Date;
|
|
2962
|
+
const newUser = {
|
|
2963
|
+
id: crypto.randomUUID(),
|
|
2964
|
+
email: userInfo.email.toLowerCase(),
|
|
2965
|
+
role: "user",
|
|
2966
|
+
createdAt: now,
|
|
2967
|
+
updatedAt: now
|
|
2968
|
+
};
|
|
2969
|
+
await userStore.createUser(newUser, null);
|
|
2970
|
+
userId = newUser.id;
|
|
2971
|
+
await oauthAccountStore.linkAccount(userId, provider.id, userInfo.providerId, userInfo.email);
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
const user = await userStore.findById(userId);
|
|
2975
|
+
if (!user) {
|
|
2976
|
+
return new Response(null, {
|
|
2977
|
+
status: 302,
|
|
2978
|
+
headers: {
|
|
2979
|
+
Location: `${errorRedirect}?error=user_info_failed`,
|
|
2980
|
+
...securityHeaders()
|
|
2981
|
+
}
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
const sessionId = crypto.randomUUID();
|
|
2985
|
+
const sessionTokens = await createSessionTokens(user, sessionId);
|
|
2986
|
+
const refreshTokenHash = await sha256Hex(sessionTokens.refreshToken);
|
|
2987
|
+
const ipAddress = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip") ?? "";
|
|
2988
|
+
const userAgent = request.headers.get("user-agent") ?? "";
|
|
2989
|
+
await sessionStore.createSessionWithId(sessionId, {
|
|
2990
|
+
userId: user.id,
|
|
2991
|
+
refreshTokenHash,
|
|
2992
|
+
ipAddress,
|
|
2993
|
+
userAgent,
|
|
2994
|
+
expiresAt: new Date(Date.now() + refreshTtlMs),
|
|
2995
|
+
currentTokens: { jwt: sessionTokens.jwt, refreshToken: sessionTokens.refreshToken }
|
|
2996
|
+
});
|
|
2997
|
+
responseHeaders.append("Set-Cookie", buildSessionCookie(sessionTokens.jwt, cookieConfig));
|
|
2998
|
+
responseHeaders.append("Set-Cookie", buildRefreshCookie(sessionTokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
|
|
2999
|
+
return new Response(null, { status: 302, headers: responseHeaders });
|
|
3000
|
+
} catch {
|
|
3001
|
+
const headers = new Headers({
|
|
3002
|
+
Location: `${errorRedirect}?error=token_exchange_failed`,
|
|
3003
|
+
...securityHeaders()
|
|
3004
|
+
});
|
|
3005
|
+
headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
3006
|
+
return new Response(null, { status: 302, headers });
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
if (method === "POST" && path === "/mfa/challenge") {
|
|
3010
|
+
if (!mfaStore || !oauthEncryptionKey) {
|
|
3011
|
+
return new Response(JSON.stringify({ error: "MFA not configured" }), {
|
|
3012
|
+
status: 400,
|
|
3013
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
const challengeIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "default";
|
|
3017
|
+
const challengeRateLimit = await rateLimitStore.check(`mfa-challenge:${challengeIp}`, 5, signInWindowMs);
|
|
3018
|
+
if (!challengeRateLimit.allowed) {
|
|
3019
|
+
return new Response(JSON.stringify({ error: createAuthRateLimitedError("Too many MFA attempts") }), {
|
|
3020
|
+
status: 429,
|
|
3021
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
3024
|
+
const mfaCookieEntry = request.headers.get("cookie")?.split(";").find((c) => c.trim().startsWith("vertz.mfa="));
|
|
3025
|
+
const mfaToken = mfaCookieEntry ? mfaCookieEntry.trim().slice("vertz.mfa=".length) : undefined;
|
|
3026
|
+
if (!mfaToken) {
|
|
3027
|
+
return new Response(JSON.stringify({
|
|
3028
|
+
error: { code: "SESSION_EXPIRED", message: "No MFA challenge token" }
|
|
3029
|
+
}), {
|
|
3030
|
+
status: 401,
|
|
3031
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3032
|
+
});
|
|
3033
|
+
}
|
|
3034
|
+
const decrypted = await decrypt(mfaToken, oauthEncryptionKey);
|
|
3035
|
+
if (!decrypted) {
|
|
3036
|
+
return new Response(JSON.stringify({
|
|
3037
|
+
error: { code: "SESSION_EXPIRED", message: "Invalid MFA challenge token" }
|
|
3038
|
+
}), {
|
|
3039
|
+
status: 401,
|
|
3040
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3041
|
+
});
|
|
3042
|
+
}
|
|
3043
|
+
const challengeData = JSON.parse(decrypted);
|
|
3044
|
+
if (challengeData.expiresAt < Date.now()) {
|
|
3045
|
+
return new Response(JSON.stringify({
|
|
3046
|
+
error: { code: "SESSION_EXPIRED", message: "MFA challenge expired" }
|
|
3047
|
+
}), {
|
|
3048
|
+
status: 401,
|
|
3049
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3050
|
+
});
|
|
3051
|
+
}
|
|
3052
|
+
const body = await request.json();
|
|
3053
|
+
const userId = challengeData.userId;
|
|
3054
|
+
const encryptedSecret = await mfaStore.getSecret(userId);
|
|
3055
|
+
if (!encryptedSecret) {
|
|
3056
|
+
return new Response(JSON.stringify({ error: createMfaNotEnabledError() }), {
|
|
3057
|
+
status: 400,
|
|
3058
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3059
|
+
});
|
|
3060
|
+
}
|
|
3061
|
+
const totpSecret = await decrypt(encryptedSecret, oauthEncryptionKey);
|
|
3062
|
+
if (!totpSecret) {
|
|
3063
|
+
return new Response(JSON.stringify({ error: "Internal error" }), {
|
|
3064
|
+
status: 500,
|
|
3065
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3066
|
+
});
|
|
3067
|
+
}
|
|
3068
|
+
let codeValid = await verifyTotpCode(totpSecret, body.code);
|
|
3069
|
+
if (!codeValid) {
|
|
3070
|
+
const hashedCodes = await mfaStore.getBackupCodes(userId);
|
|
3071
|
+
for (const hashed of hashedCodes) {
|
|
3072
|
+
if (await verifyBackupCode(body.code, hashed)) {
|
|
3073
|
+
await mfaStore.consumeBackupCode(userId, hashed);
|
|
3074
|
+
codeValid = true;
|
|
3075
|
+
break;
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
if (!codeValid) {
|
|
3080
|
+
return new Response(JSON.stringify({ error: createMfaInvalidCodeError() }), {
|
|
3081
|
+
status: 400,
|
|
3082
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
3085
|
+
const user = await userStore.findById(userId);
|
|
3086
|
+
if (!user) {
|
|
3087
|
+
return new Response(JSON.stringify({ error: "User not found" }), {
|
|
3088
|
+
status: 500,
|
|
3089
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
const sessionId = crypto.randomUUID();
|
|
3093
|
+
const fvaTimestamp = Math.floor(Date.now() / 1000);
|
|
3094
|
+
const tokens = await createSessionTokens(user, sessionId, { fva: fvaTimestamp });
|
|
3095
|
+
const refreshTokenHash = await sha256Hex(tokens.refreshToken);
|
|
3096
|
+
const ipAddress = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip") ?? "";
|
|
3097
|
+
const userAgent = request.headers.get("user-agent") ?? "";
|
|
3098
|
+
await sessionStore.createSessionWithId(sessionId, {
|
|
3099
|
+
userId: user.id,
|
|
3100
|
+
refreshTokenHash,
|
|
3101
|
+
ipAddress,
|
|
3102
|
+
userAgent,
|
|
3103
|
+
expiresAt: new Date(Date.now() + refreshTtlMs),
|
|
3104
|
+
currentTokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
|
|
3105
|
+
});
|
|
3106
|
+
const headers = new Headers({
|
|
3107
|
+
"Content-Type": "application/json",
|
|
3108
|
+
...securityHeaders()
|
|
3109
|
+
});
|
|
3110
|
+
headers.append("Set-Cookie", buildSessionCookie(tokens.jwt, cookieConfig));
|
|
3111
|
+
headers.append("Set-Cookie", buildRefreshCookie(tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
|
|
3112
|
+
headers.append("Set-Cookie", buildMfaChallengeCookie("", cookieConfig, true));
|
|
3113
|
+
return new Response(JSON.stringify({ user }), {
|
|
3114
|
+
status: 200,
|
|
3115
|
+
headers
|
|
3116
|
+
});
|
|
3117
|
+
}
|
|
3118
|
+
if (method === "POST" && path === "/mfa/setup") {
|
|
3119
|
+
if (!mfaStore) {
|
|
3120
|
+
return new Response(JSON.stringify({ error: "MFA not configured" }), {
|
|
3121
|
+
status: 400,
|
|
3122
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3123
|
+
});
|
|
3124
|
+
}
|
|
3125
|
+
const sessionResult = await getSession(request.headers);
|
|
3126
|
+
if (!sessionResult.ok || !sessionResult.data) {
|
|
3127
|
+
return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
|
|
3128
|
+
status: 401,
|
|
3129
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
const userId = sessionResult.data.user.id;
|
|
3133
|
+
const alreadyEnabled = await mfaStore.isMfaEnabled(userId);
|
|
3134
|
+
if (alreadyEnabled) {
|
|
3135
|
+
return new Response(JSON.stringify({ error: createMfaAlreadyEnabledError() }), {
|
|
3136
|
+
status: 409,
|
|
3137
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3138
|
+
});
|
|
3139
|
+
}
|
|
3140
|
+
const secret = generateTotpSecret();
|
|
3141
|
+
const uri = generateTotpUri(secret, sessionResult.data.user.email, mfaIssuer);
|
|
3142
|
+
pendingMfaSecrets.set(userId, { secret, createdAt: Date.now() });
|
|
3143
|
+
return new Response(JSON.stringify({ secret, uri }), {
|
|
3144
|
+
status: 200,
|
|
3145
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
if (method === "POST" && path === "/mfa/verify-setup") {
|
|
3149
|
+
if (!mfaStore || !oauthEncryptionKey) {
|
|
3150
|
+
return new Response(JSON.stringify({ error: "MFA not configured" }), {
|
|
3151
|
+
status: 400,
|
|
3152
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3153
|
+
});
|
|
3154
|
+
}
|
|
3155
|
+
const sessionResult = await getSession(request.headers);
|
|
3156
|
+
if (!sessionResult.ok || !sessionResult.data) {
|
|
3157
|
+
return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
|
|
3158
|
+
status: 401,
|
|
3159
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
const userId = sessionResult.data.user.id;
|
|
3163
|
+
const pendingEntry = pendingMfaSecrets.get(userId);
|
|
3164
|
+
if (!pendingEntry || Date.now() - pendingEntry.createdAt > PENDING_MFA_TTL) {
|
|
3165
|
+
if (pendingEntry)
|
|
3166
|
+
pendingMfaSecrets.delete(userId);
|
|
3167
|
+
return new Response(JSON.stringify({ error: createMfaNotEnabledError("No pending MFA setup") }), {
|
|
3168
|
+
status: 400,
|
|
3169
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3170
|
+
});
|
|
3171
|
+
}
|
|
3172
|
+
const body = await request.json();
|
|
3173
|
+
const valid = await verifyTotpCode(pendingEntry.secret, body.code);
|
|
3174
|
+
if (!valid) {
|
|
3175
|
+
return new Response(JSON.stringify({ error: createMfaInvalidCodeError() }), {
|
|
3176
|
+
status: 400,
|
|
3177
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3178
|
+
});
|
|
3179
|
+
}
|
|
3180
|
+
const encryptedSecret = await encrypt(pendingEntry.secret, oauthEncryptionKey);
|
|
3181
|
+
await mfaStore.enableMfa(userId, encryptedSecret);
|
|
3182
|
+
pendingMfaSecrets.delete(userId);
|
|
3183
|
+
const backupCodes = generateBackupCodes(mfaBackupCodeCount);
|
|
3184
|
+
const hashedCodes = await Promise.all(backupCodes.map((c) => hashBackupCode(c)));
|
|
3185
|
+
await mfaStore.setBackupCodes(userId, hashedCodes);
|
|
3186
|
+
return new Response(JSON.stringify({ backupCodes }), {
|
|
3187
|
+
status: 200,
|
|
3188
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3189
|
+
});
|
|
3190
|
+
}
|
|
3191
|
+
if (method === "POST" && path === "/mfa/disable") {
|
|
3192
|
+
if (!mfaStore) {
|
|
3193
|
+
return new Response(JSON.stringify({ error: "MFA not configured" }), {
|
|
3194
|
+
status: 400,
|
|
3195
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
const sessionResult = await getSession(request.headers);
|
|
3199
|
+
if (!sessionResult.ok || !sessionResult.data) {
|
|
3200
|
+
return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
|
|
3201
|
+
status: 401,
|
|
3202
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3203
|
+
});
|
|
3204
|
+
}
|
|
3205
|
+
const userId = sessionResult.data.user.id;
|
|
3206
|
+
const body = await request.json();
|
|
3207
|
+
const stored = await userStore.findByEmail(sessionResult.data.user.email);
|
|
3208
|
+
if (!stored || !stored.passwordHash) {
|
|
3209
|
+
return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
|
|
3210
|
+
status: 401,
|
|
3211
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3212
|
+
});
|
|
3213
|
+
}
|
|
3214
|
+
const valid = await verifyPassword(body.password, stored.passwordHash);
|
|
3215
|
+
if (!valid) {
|
|
3216
|
+
return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
|
|
3217
|
+
status: 401,
|
|
3218
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3219
|
+
});
|
|
3220
|
+
}
|
|
3221
|
+
await mfaStore.disableMfa(userId);
|
|
3222
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
3223
|
+
status: 200,
|
|
3224
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
if (method === "POST" && path === "/mfa/backup-codes") {
|
|
3228
|
+
if (!mfaStore) {
|
|
3229
|
+
return new Response(JSON.stringify({ error: "MFA not configured" }), {
|
|
3230
|
+
status: 400,
|
|
3231
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3232
|
+
});
|
|
3233
|
+
}
|
|
3234
|
+
const sessionResult = await getSession(request.headers);
|
|
3235
|
+
if (!sessionResult.ok || !sessionResult.data) {
|
|
3236
|
+
return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
|
|
3237
|
+
status: 401,
|
|
3238
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
3241
|
+
const body = await request.json();
|
|
3242
|
+
const stored = await userStore.findByEmail(sessionResult.data.user.email);
|
|
3243
|
+
if (!stored || !stored.passwordHash) {
|
|
3244
|
+
return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
|
|
3245
|
+
status: 401,
|
|
3246
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3247
|
+
});
|
|
3248
|
+
}
|
|
3249
|
+
const valid = await verifyPassword(body.password, stored.passwordHash);
|
|
3250
|
+
if (!valid) {
|
|
3251
|
+
return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
|
|
3252
|
+
status: 401,
|
|
3253
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3254
|
+
});
|
|
3255
|
+
}
|
|
3256
|
+
const userId = sessionResult.data.user.id;
|
|
3257
|
+
const backupCodes = generateBackupCodes(mfaBackupCodeCount);
|
|
3258
|
+
const hashedCodes = await Promise.all(backupCodes.map((c) => hashBackupCode(c)));
|
|
3259
|
+
await mfaStore.setBackupCodes(userId, hashedCodes);
|
|
3260
|
+
return new Response(JSON.stringify({ backupCodes }), {
|
|
3261
|
+
status: 200,
|
|
3262
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3263
|
+
});
|
|
3264
|
+
}
|
|
3265
|
+
if (method === "GET" && path === "/mfa/status") {
|
|
3266
|
+
if (!mfaStore) {
|
|
3267
|
+
return new Response(JSON.stringify({ error: "MFA not configured" }), {
|
|
3268
|
+
status: 400,
|
|
3269
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3270
|
+
});
|
|
3271
|
+
}
|
|
3272
|
+
const sessionResult = await getSession(request.headers);
|
|
3273
|
+
if (!sessionResult.ok || !sessionResult.data) {
|
|
3274
|
+
return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
|
|
3275
|
+
status: 401,
|
|
3276
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3277
|
+
});
|
|
3278
|
+
}
|
|
3279
|
+
const userId = sessionResult.data.user.id;
|
|
3280
|
+
const enabled = await mfaStore.isMfaEnabled(userId);
|
|
3281
|
+
const codes = await mfaStore.getBackupCodes(userId);
|
|
3282
|
+
return new Response(JSON.stringify({
|
|
3283
|
+
enabled,
|
|
3284
|
+
hasBackupCodes: codes.length > 0,
|
|
3285
|
+
backupCodesRemaining: codes.length
|
|
3286
|
+
}), {
|
|
3287
|
+
status: 200,
|
|
3288
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3289
|
+
});
|
|
3290
|
+
}
|
|
3291
|
+
if (method === "POST" && path === "/mfa/step-up") {
|
|
3292
|
+
if (!mfaStore || !oauthEncryptionKey) {
|
|
3293
|
+
return new Response(JSON.stringify({ error: "MFA not configured" }), {
|
|
3294
|
+
status: 400,
|
|
3295
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3296
|
+
});
|
|
3297
|
+
}
|
|
3298
|
+
const stepUpIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "default";
|
|
3299
|
+
const stepUpRateLimit = await rateLimitStore.check(`mfa-stepup:${stepUpIp}`, 5, signInWindowMs);
|
|
3300
|
+
if (!stepUpRateLimit.allowed) {
|
|
3301
|
+
return new Response(JSON.stringify({ error: createAuthRateLimitedError("Too many step-up attempts") }), {
|
|
3302
|
+
status: 429,
|
|
3303
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
const sessionResult = await getSession(request.headers);
|
|
3307
|
+
if (!sessionResult.ok || !sessionResult.data) {
|
|
3308
|
+
return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
|
|
3309
|
+
status: 401,
|
|
3310
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
const userId = sessionResult.data.user.id;
|
|
3314
|
+
const encryptedSecret = await mfaStore.getSecret(userId);
|
|
3315
|
+
if (!encryptedSecret) {
|
|
3316
|
+
return new Response(JSON.stringify({ error: createMfaNotEnabledError() }), {
|
|
3317
|
+
status: 400,
|
|
3318
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
const totpSecret = await decrypt(encryptedSecret, oauthEncryptionKey);
|
|
3322
|
+
if (!totpSecret) {
|
|
3323
|
+
return new Response(JSON.stringify({ error: "Internal error" }), {
|
|
576
3324
|
status: 500,
|
|
577
|
-
headers: { "Content-Type": "application/json" }
|
|
3325
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3326
|
+
});
|
|
3327
|
+
}
|
|
3328
|
+
const body = await request.json();
|
|
3329
|
+
const valid = await verifyTotpCode(totpSecret, body.code);
|
|
3330
|
+
if (!valid) {
|
|
3331
|
+
return new Response(JSON.stringify({ error: createMfaInvalidCodeError() }), {
|
|
3332
|
+
status: 400,
|
|
3333
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3334
|
+
});
|
|
3335
|
+
}
|
|
3336
|
+
const user = sessionResult.data.user;
|
|
3337
|
+
const currentSid = sessionResult.data.payload.sid;
|
|
3338
|
+
const fvaTimestamp = Math.floor(Date.now() / 1000);
|
|
3339
|
+
const tokens = await createSessionTokens(user, currentSid, { fva: fvaTimestamp });
|
|
3340
|
+
const newRefreshHash = await sha256Hex(tokens.refreshToken);
|
|
3341
|
+
const oldTokens = await sessionStore.getCurrentTokens(currentSid);
|
|
3342
|
+
const previousRefreshHash = oldTokens ? await sha256Hex(oldTokens.refreshToken) : newRefreshHash;
|
|
3343
|
+
await sessionStore.updateSession(currentSid, {
|
|
3344
|
+
refreshTokenHash: newRefreshHash,
|
|
3345
|
+
previousRefreshHash,
|
|
3346
|
+
lastActiveAt: new Date,
|
|
3347
|
+
currentTokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
|
|
3348
|
+
});
|
|
3349
|
+
const headers = new Headers({
|
|
3350
|
+
"Content-Type": "application/json",
|
|
3351
|
+
...securityHeaders()
|
|
3352
|
+
});
|
|
3353
|
+
headers.append("Set-Cookie", buildSessionCookie(tokens.jwt, cookieConfig));
|
|
3354
|
+
headers.append("Set-Cookie", buildRefreshCookie(tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
|
|
3355
|
+
return new Response(JSON.stringify({ user }), {
|
|
3356
|
+
status: 200,
|
|
3357
|
+
headers
|
|
3358
|
+
});
|
|
3359
|
+
}
|
|
3360
|
+
if (method === "POST" && path === "/verify-email") {
|
|
3361
|
+
if (!emailVerificationStore || !emailVerificationEnabled) {
|
|
3362
|
+
return new Response(JSON.stringify({
|
|
3363
|
+
error: {
|
|
3364
|
+
code: "AUTH_VALIDATION_ERROR",
|
|
3365
|
+
message: "Email verification not configured"
|
|
3366
|
+
}
|
|
3367
|
+
}), {
|
|
3368
|
+
status: 400,
|
|
3369
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3370
|
+
});
|
|
3371
|
+
}
|
|
3372
|
+
const body = await request.json();
|
|
3373
|
+
if (!body.token) {
|
|
3374
|
+
return new Response(JSON.stringify({ error: createTokenInvalidError("Token is required") }), {
|
|
3375
|
+
status: 400,
|
|
3376
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3377
|
+
});
|
|
3378
|
+
}
|
|
3379
|
+
const tokenHash = await sha256Hex(body.token);
|
|
3380
|
+
const verification = await emailVerificationStore.findByTokenHash(tokenHash);
|
|
3381
|
+
if (!verification) {
|
|
3382
|
+
return new Response(JSON.stringify({ error: createTokenInvalidError() }), {
|
|
3383
|
+
status: 400,
|
|
3384
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3385
|
+
});
|
|
3386
|
+
}
|
|
3387
|
+
if (verification.expiresAt < new Date) {
|
|
3388
|
+
await emailVerificationStore.deleteByTokenHash(tokenHash);
|
|
3389
|
+
return new Response(JSON.stringify({ error: createTokenExpiredError() }), {
|
|
3390
|
+
status: 400,
|
|
3391
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3392
|
+
});
|
|
3393
|
+
}
|
|
3394
|
+
await userStore.updateEmailVerified(verification.userId, true);
|
|
3395
|
+
await emailVerificationStore.deleteByUserId(verification.userId);
|
|
3396
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
3397
|
+
status: 200,
|
|
3398
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3399
|
+
});
|
|
3400
|
+
}
|
|
3401
|
+
if (method === "POST" && path === "/resend-verification") {
|
|
3402
|
+
if (!emailVerificationStore || !emailVerificationEnabled || !emailVerificationConfig?.onSend) {
|
|
3403
|
+
return new Response(JSON.stringify({
|
|
3404
|
+
error: {
|
|
3405
|
+
code: "AUTH_VALIDATION_ERROR",
|
|
3406
|
+
message: "Email verification not configured"
|
|
3407
|
+
}
|
|
3408
|
+
}), {
|
|
3409
|
+
status: 400,
|
|
3410
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3411
|
+
});
|
|
3412
|
+
}
|
|
3413
|
+
const sessionResult = await getSession(request.headers);
|
|
3414
|
+
if (!sessionResult.ok || !sessionResult.data) {
|
|
3415
|
+
return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
|
|
3416
|
+
status: 401,
|
|
3417
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3418
|
+
});
|
|
3419
|
+
}
|
|
3420
|
+
const userId = sessionResult.data.user.id;
|
|
3421
|
+
const resendRateLimit = await rateLimitStore.check(`resend-verification:${userId}`, 3, parseDuration("1h"));
|
|
3422
|
+
if (!resendRateLimit.allowed) {
|
|
3423
|
+
return new Response(JSON.stringify({ error: createAuthRateLimitedError("Too many resend attempts") }), {
|
|
3424
|
+
status: 429,
|
|
3425
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3426
|
+
});
|
|
3427
|
+
}
|
|
3428
|
+
await emailVerificationStore.deleteByUserId(userId);
|
|
3429
|
+
const token = generateToken();
|
|
3430
|
+
const tokenHash = await sha256Hex(token);
|
|
3431
|
+
await emailVerificationStore.createVerification({
|
|
3432
|
+
userId,
|
|
3433
|
+
tokenHash,
|
|
3434
|
+
expiresAt: new Date(Date.now() + emailVerificationTtlMs)
|
|
3435
|
+
});
|
|
3436
|
+
await emailVerificationConfig.onSend(sessionResult.data.user, token);
|
|
3437
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
3438
|
+
status: 200,
|
|
3439
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3440
|
+
});
|
|
3441
|
+
}
|
|
3442
|
+
if (method === "POST" && path === "/forgot-password") {
|
|
3443
|
+
if (!passwordResetStore || !passwordResetEnabled || !passwordResetConfig?.onSend) {
|
|
3444
|
+
return new Response(JSON.stringify({
|
|
3445
|
+
error: { code: "AUTH_VALIDATION_ERROR", message: "Password reset not configured" }
|
|
3446
|
+
}), {
|
|
3447
|
+
status: 400,
|
|
3448
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3449
|
+
});
|
|
3450
|
+
}
|
|
3451
|
+
const body = await request.json();
|
|
3452
|
+
const forgotRateLimit = await rateLimitStore.check(`forgot-password:${(body.email ?? "").toLowerCase()}`, 3, parseDuration("1h"));
|
|
3453
|
+
if (!forgotRateLimit.allowed) {
|
|
3454
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
3455
|
+
status: 200,
|
|
3456
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
578
3457
|
});
|
|
579
3458
|
}
|
|
580
|
-
|
|
3459
|
+
const stored = await userStore.findByEmail((body.email ?? "").toLowerCase());
|
|
3460
|
+
if (stored) {
|
|
3461
|
+
const token = generateToken();
|
|
3462
|
+
const tokenHash = await sha256Hex(token);
|
|
3463
|
+
await passwordResetStore.createReset({
|
|
3464
|
+
userId: stored.user.id,
|
|
3465
|
+
tokenHash,
|
|
3466
|
+
expiresAt: new Date(Date.now() + passwordResetTtlMs)
|
|
3467
|
+
});
|
|
3468
|
+
await passwordResetConfig.onSend(stored.user, token);
|
|
3469
|
+
}
|
|
3470
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
581
3471
|
status: 200,
|
|
582
|
-
headers: { "Content-Type": "application/json" }
|
|
3472
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
583
3473
|
});
|
|
584
3474
|
}
|
|
585
|
-
if (method === "POST" && path === "/
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
3475
|
+
if (method === "POST" && path === "/reset-password") {
|
|
3476
|
+
if (!passwordResetStore || !passwordResetEnabled) {
|
|
3477
|
+
return new Response(JSON.stringify({
|
|
3478
|
+
error: { code: "AUTH_VALIDATION_ERROR", message: "Password reset not configured" }
|
|
3479
|
+
}), {
|
|
3480
|
+
status: 400,
|
|
3481
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
591
3482
|
});
|
|
592
3483
|
}
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
"Content-Type": "application/json",
|
|
598
|
-
|
|
3484
|
+
const body = await request.json();
|
|
3485
|
+
if (!body.token) {
|
|
3486
|
+
return new Response(JSON.stringify({ error: createTokenInvalidError("Token is required") }), {
|
|
3487
|
+
status: 400,
|
|
3488
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3489
|
+
});
|
|
3490
|
+
}
|
|
3491
|
+
const passwordError = validatePassword(body.password, emailPassword?.password);
|
|
3492
|
+
if (passwordError) {
|
|
3493
|
+
return new Response(JSON.stringify({ error: passwordError }), {
|
|
3494
|
+
status: 400,
|
|
3495
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3496
|
+
});
|
|
3497
|
+
}
|
|
3498
|
+
const tokenHash = await sha256Hex(body.token);
|
|
3499
|
+
const resetRecord = await passwordResetStore.findByTokenHash(tokenHash);
|
|
3500
|
+
if (!resetRecord) {
|
|
3501
|
+
return new Response(JSON.stringify({ error: createTokenInvalidError() }), {
|
|
3502
|
+
status: 400,
|
|
3503
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3504
|
+
});
|
|
3505
|
+
}
|
|
3506
|
+
if (resetRecord.expiresAt < new Date) {
|
|
3507
|
+
await passwordResetStore.deleteByUserId(resetRecord.userId);
|
|
3508
|
+
return new Response(JSON.stringify({ error: createTokenExpiredError() }), {
|
|
3509
|
+
status: 400,
|
|
3510
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
const newPasswordHash = await hashPassword(body.password);
|
|
3514
|
+
await userStore.updatePasswordHash(resetRecord.userId, newPasswordHash);
|
|
3515
|
+
await passwordResetStore.deleteByUserId(resetRecord.userId);
|
|
3516
|
+
if (revokeSessionsOnReset) {
|
|
3517
|
+
const activeSessions = await sessionStore.listActiveSessions(resetRecord.userId);
|
|
3518
|
+
for (const s of activeSessions) {
|
|
3519
|
+
await sessionStore.revokeSession(s.id);
|
|
599
3520
|
}
|
|
3521
|
+
}
|
|
3522
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
3523
|
+
status: 200,
|
|
3524
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
600
3525
|
});
|
|
601
3526
|
}
|
|
602
3527
|
return new Response(JSON.stringify({ error: "Not found" }), {
|
|
@@ -610,17 +3535,6 @@ function createAuth(config) {
|
|
|
610
3535
|
});
|
|
611
3536
|
}
|
|
612
3537
|
}
|
|
613
|
-
function buildCookie(value, clear = false) {
|
|
614
|
-
const name = cookieConfig.name || "vertz.sid";
|
|
615
|
-
const maxAge = cookieConfig.maxAge ?? 60 * 60 * 24 * 7;
|
|
616
|
-
const path = cookieConfig.path || "/";
|
|
617
|
-
const sameSite = cookieConfig.sameSite || "lax";
|
|
618
|
-
const secure = cookieConfig.secure ?? true;
|
|
619
|
-
if (clear) {
|
|
620
|
-
return `${name}=; Path=${path}; HttpOnly; SameSite=${sameSite}; Max-Age=0`;
|
|
621
|
-
}
|
|
622
|
-
return `${name}=${value}; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=${maxAge}`;
|
|
623
|
-
}
|
|
624
3538
|
function createMiddleware() {
|
|
625
3539
|
return async (ctx, next) => {
|
|
626
3540
|
const sessionResult = await getSession(ctx.headers);
|
|
@@ -642,13 +3556,25 @@ function createAuth(config) {
|
|
|
642
3556
|
signIn,
|
|
643
3557
|
signOut,
|
|
644
3558
|
getSession,
|
|
645
|
-
refreshSession
|
|
3559
|
+
refreshSession,
|
|
3560
|
+
listSessions,
|
|
3561
|
+
revokeSession: revokeSessionById,
|
|
3562
|
+
revokeAllSessions
|
|
646
3563
|
};
|
|
647
3564
|
return {
|
|
648
3565
|
handler: handleAuthRequest,
|
|
649
3566
|
api,
|
|
650
3567
|
middleware: createMiddleware,
|
|
651
|
-
initialize
|
|
3568
|
+
initialize,
|
|
3569
|
+
dispose() {
|
|
3570
|
+
sessionStore.dispose();
|
|
3571
|
+
rateLimitStore.dispose();
|
|
3572
|
+
oauthAccountStore?.dispose();
|
|
3573
|
+
mfaStore?.dispose();
|
|
3574
|
+
emailVerificationStore?.dispose();
|
|
3575
|
+
passwordResetStore?.dispose();
|
|
3576
|
+
pendingMfaSecrets.clear();
|
|
3577
|
+
}
|
|
652
3578
|
};
|
|
653
3579
|
}
|
|
654
3580
|
// src/create-server.ts
|
|
@@ -657,116 +3583,6 @@ import {
|
|
|
657
3583
|
createDatabaseBridgeAdapter
|
|
658
3584
|
} from "@vertz/db";
|
|
659
3585
|
|
|
660
|
-
// src/entity/access-enforcer.ts
|
|
661
|
-
import { EntityForbiddenError, err as err2, ok as ok2 } from "@vertz/errors";
|
|
662
|
-
async function enforceAccess(operation, accessRules, ctx, row) {
|
|
663
|
-
const rule = accessRules[operation];
|
|
664
|
-
if (rule === undefined) {
|
|
665
|
-
return err2(new EntityForbiddenError(`Access denied: no access rule for operation "${operation}"`));
|
|
666
|
-
}
|
|
667
|
-
if (rule === false) {
|
|
668
|
-
return err2(new EntityForbiddenError(`Operation "${operation}" is disabled`));
|
|
669
|
-
}
|
|
670
|
-
const allowed = await rule(ctx, row ?? {});
|
|
671
|
-
if (!allowed) {
|
|
672
|
-
return err2(new EntityForbiddenError(`Access denied for operation "${operation}"`));
|
|
673
|
-
}
|
|
674
|
-
return ok2(undefined);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// src/action/context.ts
|
|
678
|
-
function createActionContext(request, registryProxy) {
|
|
679
|
-
const userId = request.userId ?? null;
|
|
680
|
-
const roles = request.roles ?? [];
|
|
681
|
-
const tenantId = request.tenantId ?? null;
|
|
682
|
-
return {
|
|
683
|
-
userId,
|
|
684
|
-
authenticated() {
|
|
685
|
-
return userId !== null;
|
|
686
|
-
},
|
|
687
|
-
tenant() {
|
|
688
|
-
return tenantId !== null;
|
|
689
|
-
},
|
|
690
|
-
role(...rolesToCheck) {
|
|
691
|
-
return rolesToCheck.some((r) => roles.includes(r));
|
|
692
|
-
},
|
|
693
|
-
entities: registryProxy
|
|
694
|
-
};
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
// src/action/route-generator.ts
|
|
698
|
-
function jsonResponse(data, status = 200) {
|
|
699
|
-
return new Response(JSON.stringify(data), {
|
|
700
|
-
status,
|
|
701
|
-
headers: { "content-type": "application/json" }
|
|
702
|
-
});
|
|
703
|
-
}
|
|
704
|
-
function extractRequestInfo(ctx) {
|
|
705
|
-
return {
|
|
706
|
-
userId: ctx.userId ?? null,
|
|
707
|
-
tenantId: ctx.tenantId ?? null,
|
|
708
|
-
roles: ctx.roles ?? []
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
function generateActionRoutes(def, registry, options) {
|
|
712
|
-
const prefix = options?.apiPrefix ?? "/api";
|
|
713
|
-
const inject = def.inject ?? {};
|
|
714
|
-
const registryProxy = Object.keys(inject).length > 0 ? registry.createScopedProxy(inject) : {};
|
|
715
|
-
const routes = [];
|
|
716
|
-
for (const [handlerName, handlerDef] of Object.entries(def.actions)) {
|
|
717
|
-
const accessRule = def.access[handlerName];
|
|
718
|
-
if (accessRule === undefined)
|
|
719
|
-
continue;
|
|
720
|
-
const method = (handlerDef.method ?? "POST").toUpperCase();
|
|
721
|
-
const handlerPath = handlerDef.path ?? `${prefix}/${def.name}/${handlerName}`;
|
|
722
|
-
const routePath = handlerDef.path ? handlerPath : `${prefix}/${def.name}/${handlerName}`;
|
|
723
|
-
if (accessRule === false) {
|
|
724
|
-
routes.push({
|
|
725
|
-
method,
|
|
726
|
-
path: routePath,
|
|
727
|
-
handler: async () => jsonResponse({
|
|
728
|
-
error: {
|
|
729
|
-
code: "MethodNotAllowed",
|
|
730
|
-
message: `Action "${handlerName}" is disabled for ${def.name}`
|
|
731
|
-
}
|
|
732
|
-
}, 405)
|
|
733
|
-
});
|
|
734
|
-
continue;
|
|
735
|
-
}
|
|
736
|
-
routes.push({
|
|
737
|
-
method,
|
|
738
|
-
path: routePath,
|
|
739
|
-
handler: async (ctx) => {
|
|
740
|
-
try {
|
|
741
|
-
const requestInfo = extractRequestInfo(ctx);
|
|
742
|
-
const actionCtx = createActionContext(requestInfo, registryProxy);
|
|
743
|
-
const accessResult = await enforceAccess(handlerName, def.access, actionCtx);
|
|
744
|
-
if (!accessResult.ok) {
|
|
745
|
-
return jsonResponse({ error: { code: "Forbidden", message: accessResult.error.message } }, 403);
|
|
746
|
-
}
|
|
747
|
-
const rawBody = ctx.body ?? {};
|
|
748
|
-
const parsed = handlerDef.body.parse(rawBody);
|
|
749
|
-
if (!parsed.ok) {
|
|
750
|
-
const message = parsed.error instanceof Error ? parsed.error.message : "Invalid request body";
|
|
751
|
-
return jsonResponse({ error: { code: "BadRequest", message } }, 400);
|
|
752
|
-
}
|
|
753
|
-
const result = await handlerDef.handler(parsed.data, actionCtx);
|
|
754
|
-
const responseParsed = handlerDef.response.parse(result);
|
|
755
|
-
if (!responseParsed.ok) {
|
|
756
|
-
const message = responseParsed.error instanceof Error ? responseParsed.error.message : "Response validation failed";
|
|
757
|
-
console.warn(`[vertz] Action response validation warning: ${message}`);
|
|
758
|
-
}
|
|
759
|
-
return jsonResponse(result, 200);
|
|
760
|
-
} catch (error) {
|
|
761
|
-
const message = error instanceof Error ? error.message : "Internal server error";
|
|
762
|
-
return jsonResponse({ error: { code: "InternalServerError", message } }, 500);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
return routes;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
3586
|
// src/entity/entity-registry.ts
|
|
771
3587
|
class EntityRegistry {
|
|
772
3588
|
entries = new Map;
|
|
@@ -903,6 +3719,25 @@ import {
|
|
|
903
3719
|
err as err3,
|
|
904
3720
|
ok as ok3
|
|
905
3721
|
} from "@vertz/errors";
|
|
3722
|
+
|
|
3723
|
+
// src/entity/access-enforcer.ts
|
|
3724
|
+
import { EntityForbiddenError, err as err2, ok as ok2 } from "@vertz/errors";
|
|
3725
|
+
async function enforceAccess(operation, accessRules, ctx, row) {
|
|
3726
|
+
const rule = accessRules[operation];
|
|
3727
|
+
if (rule === undefined) {
|
|
3728
|
+
return err2(new EntityForbiddenError(`Access denied: no access rule for operation "${operation}"`));
|
|
3729
|
+
}
|
|
3730
|
+
if (rule === false) {
|
|
3731
|
+
return err2(new EntityForbiddenError(`Operation "${operation}" is disabled`));
|
|
3732
|
+
}
|
|
3733
|
+
const allowed = await rule(ctx, row ?? {});
|
|
3734
|
+
if (!allowed) {
|
|
3735
|
+
return err2(new EntityForbiddenError(`Access denied for operation "${operation}"`));
|
|
3736
|
+
}
|
|
3737
|
+
return ok2(undefined);
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
// src/entity/action-pipeline.ts
|
|
906
3741
|
function createActionHandler(def, actionName, actionDef, db, hasId) {
|
|
907
3742
|
return async (ctx, id, rawInput) => {
|
|
908
3743
|
let row = null;
|
|
@@ -1263,7 +4098,7 @@ function validateVertzQL(options, table, relationsConfig) {
|
|
|
1263
4098
|
}
|
|
1264
4099
|
|
|
1265
4100
|
// src/entity/route-generator.ts
|
|
1266
|
-
function
|
|
4101
|
+
function jsonResponse(data, status = 200) {
|
|
1267
4102
|
return new Response(JSON.stringify(data), {
|
|
1268
4103
|
status,
|
|
1269
4104
|
headers: { "content-type": "application/json" }
|
|
@@ -1272,7 +4107,7 @@ function jsonResponse2(data, status = 200) {
|
|
|
1272
4107
|
function emptyResponse(status) {
|
|
1273
4108
|
return new Response(null, { status });
|
|
1274
4109
|
}
|
|
1275
|
-
function
|
|
4110
|
+
function extractRequestInfo(ctx) {
|
|
1276
4111
|
return {
|
|
1277
4112
|
userId: ctx.userId ?? null,
|
|
1278
4113
|
tenantId: ctx.tenantId ?? null,
|
|
@@ -1290,7 +4125,7 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1290
4125
|
const registryProxy = Object.keys(inject).length > 0 ? registry.createScopedProxy(inject) : {};
|
|
1291
4126
|
const routes = [];
|
|
1292
4127
|
function makeEntityCtx(ctx) {
|
|
1293
|
-
const requestInfo =
|
|
4128
|
+
const requestInfo = extractRequestInfo(ctx);
|
|
1294
4129
|
const entityOps = {};
|
|
1295
4130
|
return createEntityContext(requestInfo, entityOps, registryProxy);
|
|
1296
4131
|
}
|
|
@@ -1299,7 +4134,7 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1299
4134
|
routes.push({
|
|
1300
4135
|
method: "GET",
|
|
1301
4136
|
path: basePath,
|
|
1302
|
-
handler: async () =>
|
|
4137
|
+
handler: async () => jsonResponse({
|
|
1303
4138
|
error: {
|
|
1304
4139
|
code: "MethodNotAllowed",
|
|
1305
4140
|
message: `Operation "list" is disabled for ${def.name}`
|
|
@@ -1318,7 +4153,7 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1318
4153
|
const relationsConfig = def.relations;
|
|
1319
4154
|
const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
|
|
1320
4155
|
if (!validation.ok) {
|
|
1321
|
-
return
|
|
4156
|
+
return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
|
|
1322
4157
|
}
|
|
1323
4158
|
const options2 = {
|
|
1324
4159
|
where: parsed.where ? parsed.where : undefined,
|
|
@@ -1329,15 +4164,15 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1329
4164
|
const result = await crudHandlers.list(entityCtx, options2);
|
|
1330
4165
|
if (!result.ok) {
|
|
1331
4166
|
const { status, body } = entityErrorHandler(result.error);
|
|
1332
|
-
return
|
|
4167
|
+
return jsonResponse(body, status);
|
|
1333
4168
|
}
|
|
1334
4169
|
if (parsed.select && result.data.body.items) {
|
|
1335
4170
|
result.data.body.items = result.data.body.items.map((row) => applySelect(parsed.select, row));
|
|
1336
4171
|
}
|
|
1337
|
-
return
|
|
4172
|
+
return jsonResponse(result.data.body, result.data.status);
|
|
1338
4173
|
} catch (error) {
|
|
1339
4174
|
const { status, body } = entityErrorHandler(error);
|
|
1340
|
-
return
|
|
4175
|
+
return jsonResponse(body, status);
|
|
1341
4176
|
}
|
|
1342
4177
|
}
|
|
1343
4178
|
});
|
|
@@ -1360,7 +4195,7 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1360
4195
|
const relationsConfig = def.relations;
|
|
1361
4196
|
const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
|
|
1362
4197
|
if (!validation.ok) {
|
|
1363
|
-
return
|
|
4198
|
+
return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
|
|
1364
4199
|
}
|
|
1365
4200
|
const options2 = {
|
|
1366
4201
|
where: parsed.where,
|
|
@@ -1371,15 +4206,15 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1371
4206
|
const result = await crudHandlers.list(entityCtx, options2);
|
|
1372
4207
|
if (!result.ok) {
|
|
1373
4208
|
const { status, body: errBody } = entityErrorHandler(result.error);
|
|
1374
|
-
return
|
|
4209
|
+
return jsonResponse(errBody, status);
|
|
1375
4210
|
}
|
|
1376
4211
|
if (parsed.select && result.data.body.items) {
|
|
1377
4212
|
result.data.body.items = result.data.body.items.map((row) => applySelect(parsed.select, row));
|
|
1378
4213
|
}
|
|
1379
|
-
return
|
|
4214
|
+
return jsonResponse(result.data.body, result.data.status);
|
|
1380
4215
|
} catch (error) {
|
|
1381
4216
|
const { status, body: errBody } = entityErrorHandler(error);
|
|
1382
|
-
return
|
|
4217
|
+
return jsonResponse(errBody, status);
|
|
1383
4218
|
}
|
|
1384
4219
|
}
|
|
1385
4220
|
});
|
|
@@ -1389,7 +4224,7 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1389
4224
|
routes.push({
|
|
1390
4225
|
method: "GET",
|
|
1391
4226
|
path: `${basePath}/:id`,
|
|
1392
|
-
handler: async () =>
|
|
4227
|
+
handler: async () => jsonResponse({
|
|
1393
4228
|
error: {
|
|
1394
4229
|
code: "MethodNotAllowed",
|
|
1395
4230
|
message: `Operation "get" is disabled for ${def.name}`
|
|
@@ -1409,18 +4244,18 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1409
4244
|
const relationsConfig = def.relations;
|
|
1410
4245
|
const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
|
|
1411
4246
|
if (!validation.ok) {
|
|
1412
|
-
return
|
|
4247
|
+
return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
|
|
1413
4248
|
}
|
|
1414
4249
|
const result = await crudHandlers.get(entityCtx, id);
|
|
1415
4250
|
if (!result.ok) {
|
|
1416
4251
|
const { status, body: body2 } = entityErrorHandler(result.error);
|
|
1417
|
-
return
|
|
4252
|
+
return jsonResponse(body2, status);
|
|
1418
4253
|
}
|
|
1419
4254
|
const body = parsed.select ? applySelect(parsed.select, result.data.body) : result.data.body;
|
|
1420
|
-
return
|
|
4255
|
+
return jsonResponse(body, result.data.status);
|
|
1421
4256
|
} catch (error) {
|
|
1422
4257
|
const { status, body } = entityErrorHandler(error);
|
|
1423
|
-
return
|
|
4258
|
+
return jsonResponse(body, status);
|
|
1424
4259
|
}
|
|
1425
4260
|
}
|
|
1426
4261
|
});
|
|
@@ -1431,7 +4266,7 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1431
4266
|
routes.push({
|
|
1432
4267
|
method: "POST",
|
|
1433
4268
|
path: basePath,
|
|
1434
|
-
handler: async () =>
|
|
4269
|
+
handler: async () => jsonResponse({
|
|
1435
4270
|
error: {
|
|
1436
4271
|
code: "MethodNotAllowed",
|
|
1437
4272
|
message: `Operation "create" is disabled for ${def.name}`
|
|
@@ -1449,12 +4284,12 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1449
4284
|
const result = await crudHandlers.create(entityCtx, data);
|
|
1450
4285
|
if (!result.ok) {
|
|
1451
4286
|
const { status, body } = entityErrorHandler(result.error);
|
|
1452
|
-
return
|
|
4287
|
+
return jsonResponse(body, status);
|
|
1453
4288
|
}
|
|
1454
|
-
return
|
|
4289
|
+
return jsonResponse(result.data.body, result.data.status);
|
|
1455
4290
|
} catch (error) {
|
|
1456
4291
|
const { status, body } = entityErrorHandler(error);
|
|
1457
|
-
return
|
|
4292
|
+
return jsonResponse(body, status);
|
|
1458
4293
|
}
|
|
1459
4294
|
}
|
|
1460
4295
|
});
|
|
@@ -1465,7 +4300,7 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1465
4300
|
routes.push({
|
|
1466
4301
|
method: "PATCH",
|
|
1467
4302
|
path: `${basePath}/:id`,
|
|
1468
|
-
handler: async () =>
|
|
4303
|
+
handler: async () => jsonResponse({
|
|
1469
4304
|
error: {
|
|
1470
4305
|
code: "MethodNotAllowed",
|
|
1471
4306
|
message: `Operation "update" is disabled for ${def.name}`
|
|
@@ -1484,12 +4319,12 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1484
4319
|
const result = await crudHandlers.update(entityCtx, id, data);
|
|
1485
4320
|
if (!result.ok) {
|
|
1486
4321
|
const { status, body } = entityErrorHandler(result.error);
|
|
1487
|
-
return
|
|
4322
|
+
return jsonResponse(body, status);
|
|
1488
4323
|
}
|
|
1489
|
-
return
|
|
4324
|
+
return jsonResponse(result.data.body, result.data.status);
|
|
1490
4325
|
} catch (error) {
|
|
1491
4326
|
const { status, body } = entityErrorHandler(error);
|
|
1492
|
-
return
|
|
4327
|
+
return jsonResponse(body, status);
|
|
1493
4328
|
}
|
|
1494
4329
|
}
|
|
1495
4330
|
});
|
|
@@ -1500,7 +4335,7 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1500
4335
|
routes.push({
|
|
1501
4336
|
method: "DELETE",
|
|
1502
4337
|
path: `${basePath}/:id`,
|
|
1503
|
-
handler: async () =>
|
|
4338
|
+
handler: async () => jsonResponse({
|
|
1504
4339
|
error: {
|
|
1505
4340
|
code: "MethodNotAllowed",
|
|
1506
4341
|
message: `Operation "delete" is disabled for ${def.name}`
|
|
@@ -1518,15 +4353,15 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1518
4353
|
const result = await crudHandlers.delete(entityCtx, id);
|
|
1519
4354
|
if (!result.ok) {
|
|
1520
4355
|
const { status, body } = entityErrorHandler(result.error);
|
|
1521
|
-
return
|
|
4356
|
+
return jsonResponse(body, status);
|
|
1522
4357
|
}
|
|
1523
4358
|
if (result.data.status === 204) {
|
|
1524
4359
|
return emptyResponse(204);
|
|
1525
4360
|
}
|
|
1526
|
-
return
|
|
4361
|
+
return jsonResponse(result.data.body, result.data.status);
|
|
1527
4362
|
} catch (error) {
|
|
1528
4363
|
const { status, body } = entityErrorHandler(error);
|
|
1529
|
-
return
|
|
4364
|
+
return jsonResponse(body, status);
|
|
1530
4365
|
}
|
|
1531
4366
|
}
|
|
1532
4367
|
});
|
|
@@ -1542,7 +4377,7 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1542
4377
|
routes.push({
|
|
1543
4378
|
method,
|
|
1544
4379
|
path: actionPath,
|
|
1545
|
-
handler: async () =>
|
|
4380
|
+
handler: async () => jsonResponse({
|
|
1546
4381
|
error: {
|
|
1547
4382
|
code: "MethodNotAllowed",
|
|
1548
4383
|
message: `Action "${actionName}" is disabled for ${def.name}`
|
|
@@ -1562,12 +4397,12 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1562
4397
|
const result = await actionHandler(entityCtx, id, input);
|
|
1563
4398
|
if (!result.ok) {
|
|
1564
4399
|
const { status, body } = entityErrorHandler(result.error);
|
|
1565
|
-
return
|
|
4400
|
+
return jsonResponse(body, status);
|
|
1566
4401
|
}
|
|
1567
|
-
return
|
|
4402
|
+
return jsonResponse(result.data.body, result.data.status);
|
|
1568
4403
|
} catch (error) {
|
|
1569
4404
|
const { status, body } = entityErrorHandler(error);
|
|
1570
|
-
return
|
|
4405
|
+
return jsonResponse(body, status);
|
|
1571
4406
|
}
|
|
1572
4407
|
}
|
|
1573
4408
|
});
|
|
@@ -1576,6 +4411,99 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
1576
4411
|
return routes;
|
|
1577
4412
|
}
|
|
1578
4413
|
|
|
4414
|
+
// src/service/context.ts
|
|
4415
|
+
function createServiceContext(request, registryProxy) {
|
|
4416
|
+
const userId = request.userId ?? null;
|
|
4417
|
+
const roles = request.roles ?? [];
|
|
4418
|
+
const tenantId = request.tenantId ?? null;
|
|
4419
|
+
return {
|
|
4420
|
+
userId,
|
|
4421
|
+
authenticated() {
|
|
4422
|
+
return userId !== null;
|
|
4423
|
+
},
|
|
4424
|
+
tenant() {
|
|
4425
|
+
return tenantId !== null;
|
|
4426
|
+
},
|
|
4427
|
+
role(...rolesToCheck) {
|
|
4428
|
+
return rolesToCheck.some((r) => roles.includes(r));
|
|
4429
|
+
},
|
|
4430
|
+
entities: registryProxy
|
|
4431
|
+
};
|
|
4432
|
+
}
|
|
4433
|
+
|
|
4434
|
+
// src/service/route-generator.ts
|
|
4435
|
+
function jsonResponse2(data, status = 200) {
|
|
4436
|
+
return new Response(JSON.stringify(data), {
|
|
4437
|
+
status,
|
|
4438
|
+
headers: { "content-type": "application/json" }
|
|
4439
|
+
});
|
|
4440
|
+
}
|
|
4441
|
+
function extractRequestInfo2(ctx) {
|
|
4442
|
+
return {
|
|
4443
|
+
userId: ctx.userId ?? null,
|
|
4444
|
+
tenantId: ctx.tenantId ?? null,
|
|
4445
|
+
roles: ctx.roles ?? []
|
|
4446
|
+
};
|
|
4447
|
+
}
|
|
4448
|
+
function generateServiceRoutes(def, registry, options) {
|
|
4449
|
+
const prefix = options?.apiPrefix ?? "/api";
|
|
4450
|
+
const inject = def.inject ?? {};
|
|
4451
|
+
const registryProxy = Object.keys(inject).length > 0 ? registry.createScopedProxy(inject) : {};
|
|
4452
|
+
const routes = [];
|
|
4453
|
+
for (const [handlerName, handlerDef] of Object.entries(def.actions)) {
|
|
4454
|
+
const accessRule = def.access[handlerName];
|
|
4455
|
+
if (accessRule === undefined)
|
|
4456
|
+
continue;
|
|
4457
|
+
const method = (handlerDef.method ?? "POST").toUpperCase();
|
|
4458
|
+
const handlerPath = handlerDef.path ?? `${prefix}/${def.name}/${handlerName}`;
|
|
4459
|
+
const routePath = handlerDef.path ? handlerPath : `${prefix}/${def.name}/${handlerName}`;
|
|
4460
|
+
if (accessRule === false) {
|
|
4461
|
+
routes.push({
|
|
4462
|
+
method,
|
|
4463
|
+
path: routePath,
|
|
4464
|
+
handler: async () => jsonResponse2({
|
|
4465
|
+
error: {
|
|
4466
|
+
code: "MethodNotAllowed",
|
|
4467
|
+
message: `Action "${handlerName}" is disabled for ${def.name}`
|
|
4468
|
+
}
|
|
4469
|
+
}, 405)
|
|
4470
|
+
});
|
|
4471
|
+
continue;
|
|
4472
|
+
}
|
|
4473
|
+
routes.push({
|
|
4474
|
+
method,
|
|
4475
|
+
path: routePath,
|
|
4476
|
+
handler: async (ctx) => {
|
|
4477
|
+
try {
|
|
4478
|
+
const requestInfo = extractRequestInfo2(ctx);
|
|
4479
|
+
const serviceCtx = createServiceContext(requestInfo, registryProxy);
|
|
4480
|
+
const accessResult = await enforceAccess(handlerName, def.access, serviceCtx);
|
|
4481
|
+
if (!accessResult.ok) {
|
|
4482
|
+
return jsonResponse2({ error: { code: "Forbidden", message: accessResult.error.message } }, 403);
|
|
4483
|
+
}
|
|
4484
|
+
const rawBody = ctx.body ?? {};
|
|
4485
|
+
const parsed = handlerDef.body.parse(rawBody);
|
|
4486
|
+
if (!parsed.ok) {
|
|
4487
|
+
const message = parsed.error instanceof Error ? parsed.error.message : "Invalid request body";
|
|
4488
|
+
return jsonResponse2({ error: { code: "BadRequest", message } }, 400);
|
|
4489
|
+
}
|
|
4490
|
+
const result = await handlerDef.handler(parsed.data, serviceCtx);
|
|
4491
|
+
const responseParsed = handlerDef.response.parse(result);
|
|
4492
|
+
if (!responseParsed.ok) {
|
|
4493
|
+
const message = responseParsed.error instanceof Error ? responseParsed.error.message : "Response validation failed";
|
|
4494
|
+
console.warn(`[vertz] Service response validation warning: ${message}`);
|
|
4495
|
+
}
|
|
4496
|
+
return jsonResponse2(result, 200);
|
|
4497
|
+
} catch (error) {
|
|
4498
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
4499
|
+
return jsonResponse2({ error: { code: "InternalServerError", message } }, 500);
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
});
|
|
4503
|
+
}
|
|
4504
|
+
return routes;
|
|
4505
|
+
}
|
|
4506
|
+
|
|
1579
4507
|
// src/create-server.ts
|
|
1580
4508
|
function isDatabaseClient(db) {
|
|
1581
4509
|
return db !== null && typeof db === "object" && "_internals" in db;
|
|
@@ -1641,6 +4569,13 @@ function createServer(config) {
|
|
|
1641
4569
|
const { db } = config;
|
|
1642
4570
|
let dbFactory;
|
|
1643
4571
|
if (db && isDatabaseClient(db)) {
|
|
4572
|
+
const dbModels = db._internals.models;
|
|
4573
|
+
const missing = config.entities.filter((e) => !(e.name in dbModels)).map((e) => `"${e.name}"`);
|
|
4574
|
+
if (missing.length > 0) {
|
|
4575
|
+
const registered = Object.keys(dbModels).map((k) => `"${k}"`).join(", ");
|
|
4576
|
+
const plural = missing.length > 1;
|
|
4577
|
+
throw new Error(`${plural ? "Entities" : "Entity"} ${missing.join(", ")} ${plural ? "are" : "is"} not registered in createDb(). ` + `Add the missing model${plural ? "s" : ""} to the models object in your createDb() call. ` + `Registered models: ${registered || "(none)"}`);
|
|
4578
|
+
}
|
|
1644
4579
|
dbFactory = (entityDef) => createDatabaseBridgeAdapter(db, entityDef.name);
|
|
1645
4580
|
} else if (db) {
|
|
1646
4581
|
dbFactory = () => db;
|
|
@@ -1660,9 +4595,9 @@ function createServer(config) {
|
|
|
1660
4595
|
allRoutes.push(...routes);
|
|
1661
4596
|
}
|
|
1662
4597
|
}
|
|
1663
|
-
if (config.
|
|
1664
|
-
for (const
|
|
1665
|
-
const routes =
|
|
4598
|
+
if (config.services && config.services.length > 0) {
|
|
4599
|
+
for (const serviceDef of config.services) {
|
|
4600
|
+
const routes = generateServiceRoutes(serviceDef, registry, { apiPrefix });
|
|
1666
4601
|
allRoutes.push(...routes);
|
|
1667
4602
|
}
|
|
1668
4603
|
}
|
|
@@ -1672,7 +4607,7 @@ function createServer(config) {
|
|
|
1672
4607
|
});
|
|
1673
4608
|
}
|
|
1674
4609
|
// src/entity/entity.ts
|
|
1675
|
-
import { deepFreeze
|
|
4610
|
+
import { deepFreeze } from "@vertz/core";
|
|
1676
4611
|
var ENTITY_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
1677
4612
|
function entity(name, config) {
|
|
1678
4613
|
if (!name || !ENTITY_NAME_PATTERN.test(name)) {
|
|
@@ -1692,6 +4627,25 @@ function entity(name, config) {
|
|
|
1692
4627
|
actions: config.actions ?? {},
|
|
1693
4628
|
relations: config.relations ?? {}
|
|
1694
4629
|
};
|
|
4630
|
+
return deepFreeze(def);
|
|
4631
|
+
}
|
|
4632
|
+
// src/service/service.ts
|
|
4633
|
+
import { deepFreeze as deepFreeze2 } from "@vertz/core";
|
|
4634
|
+
var SERVICE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
4635
|
+
function service(name, config) {
|
|
4636
|
+
if (!name || !SERVICE_NAME_PATTERN.test(name)) {
|
|
4637
|
+
throw new Error(`service() name must be a non-empty lowercase string matching /^[a-z][a-z0-9-]*$/. Got: "${name}"`);
|
|
4638
|
+
}
|
|
4639
|
+
if (!config.actions || Object.keys(config.actions).length === 0) {
|
|
4640
|
+
throw new Error("service() requires at least one action in the actions config.");
|
|
4641
|
+
}
|
|
4642
|
+
const def = {
|
|
4643
|
+
kind: "service",
|
|
4644
|
+
name,
|
|
4645
|
+
inject: config.inject ?? {},
|
|
4646
|
+
access: config.access ?? {},
|
|
4647
|
+
actions: config.actions
|
|
4648
|
+
};
|
|
1695
4649
|
return deepFreeze2(def);
|
|
1696
4650
|
}
|
|
1697
4651
|
export {
|
|
@@ -1700,14 +4654,22 @@ export {
|
|
|
1700
4654
|
validatePassword,
|
|
1701
4655
|
stripReadOnlyFields,
|
|
1702
4656
|
stripHiddenFields,
|
|
4657
|
+
service,
|
|
4658
|
+
rules,
|
|
1703
4659
|
makeImmutable,
|
|
1704
4660
|
hashPassword,
|
|
4661
|
+
google,
|
|
4662
|
+
github,
|
|
1705
4663
|
generateEntityRoutes,
|
|
1706
4664
|
entityErrorHandler,
|
|
1707
4665
|
entity,
|
|
1708
4666
|
enforceAccess,
|
|
4667
|
+
encodeAccessSet,
|
|
4668
|
+
discord,
|
|
4669
|
+
defineAccess,
|
|
1709
4670
|
defaultAccess,
|
|
1710
4671
|
deepFreeze3 as deepFreeze,
|
|
4672
|
+
decodeAccessSet,
|
|
1711
4673
|
createServer,
|
|
1712
4674
|
createMiddleware,
|
|
1713
4675
|
createImmutableProxy,
|
|
@@ -1715,14 +4677,31 @@ export {
|
|
|
1715
4677
|
createEntityContext,
|
|
1716
4678
|
createCrudHandlers,
|
|
1717
4679
|
createAuth,
|
|
4680
|
+
createAccessEventBroadcaster,
|
|
4681
|
+
createAccessContext,
|
|
1718
4682
|
createAccess,
|
|
1719
|
-
|
|
4683
|
+
computeEntityAccess,
|
|
4684
|
+
computeAccessSet,
|
|
4685
|
+
checkFva,
|
|
4686
|
+
calculateBillingPeriod,
|
|
1720
4687
|
VertzException2 as VertzException,
|
|
1721
4688
|
ValidationException2 as ValidationException,
|
|
1722
4689
|
UnauthorizedException,
|
|
1723
4690
|
ServiceUnavailableException,
|
|
1724
4691
|
NotFoundException,
|
|
1725
4692
|
InternalServerErrorException,
|
|
4693
|
+
InMemoryWalletStore,
|
|
4694
|
+
InMemoryUserStore,
|
|
4695
|
+
InMemorySessionStore,
|
|
4696
|
+
InMemoryRoleAssignmentStore,
|
|
4697
|
+
InMemoryRateLimitStore,
|
|
4698
|
+
InMemoryPlanStore,
|
|
4699
|
+
InMemoryPasswordResetStore,
|
|
4700
|
+
InMemoryOAuthAccountStore,
|
|
4701
|
+
InMemoryMFAStore,
|
|
4702
|
+
InMemoryFlagStore,
|
|
4703
|
+
InMemoryEmailVerificationStore,
|
|
4704
|
+
InMemoryClosureStore,
|
|
1726
4705
|
ForbiddenException,
|
|
1727
4706
|
EntityRegistry,
|
|
1728
4707
|
ConflictException,
|