@manablox/auth 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +344 -0
- package/dist/index.js +585 -0
- package/package.json +14 -7
- package/src/api-key.ts +0 -164
- package/src/index.ts +0 -164
- package/src/password.ts +0 -17
- package/src/rbac.ts +0 -134
- package/src/user.service.ts +0 -192
- package/test/api-key.test.ts +0 -18
- package/test/rbac.test.ts +0 -128
- package/tsconfig.json +0 -1
- package/vitest.config.ts +0 -2
package/dist/index.js
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import { ALL_PERMISSIONS, ALL_PERMISSIONS as ALL_PERMISSIONS$1, BUILT_IN_ROLES, CONTENT_ACTIONS, ManabloxError, PERMISSION_GROUPS, diffRecords, grantsCover, grantsCover as grantsCover$1, intersectGrants, intersectGrants as intersectGrants$1, isBuiltInRole, normaliseGrants, parseGrant, permissionsFor, permissionsFor as permissionsFor$1, systemActor, typesCoveredBy, typesCoveredBy as typesCoveredBy$1 } from "@manablox/core";
|
|
2
|
+
import { rethrowUniqueViolation, schema } from "@manablox/db";
|
|
3
|
+
import { betterAuth } from "better-auth";
|
|
4
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
5
|
+
import { APIError } from "better-auth/api";
|
|
6
|
+
import { bearer } from "better-auth/plugins";
|
|
7
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
9
|
+
//#region src/password.ts
|
|
10
|
+
/**
|
|
11
|
+
* Argon2id, the current OWASP recommendation over bcrypt — which also silently truncates
|
|
12
|
+
* passwords at 72 bytes. One definition serves better-auth's own sign-in path and the
|
|
13
|
+
* accounts an administrator creates, so both write the same hash format.
|
|
14
|
+
*/
|
|
15
|
+
async function hashPassword(password) {
|
|
16
|
+
const { hash } = await import("@node-rs/argon2");
|
|
17
|
+
return hash(password, {
|
|
18
|
+
memoryCost: 19456,
|
|
19
|
+
timeCost: 2,
|
|
20
|
+
parallelism: 1
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async function verifyPassword(stored, password) {
|
|
24
|
+
const { verify } = await import("@node-rs/argon2");
|
|
25
|
+
return verify(stored, password);
|
|
26
|
+
}
|
|
27
|
+
/** Matches better-auth's `minPasswordLength`, so a password set here signs in there. */
|
|
28
|
+
const MIN_PASSWORD_LENGTH = 12;
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/api-key.ts
|
|
31
|
+
const PREFIX = "mbx";
|
|
32
|
+
/**
|
|
33
|
+
* Takes a presented key apart. The secret is base64url and may itself contain `_`, so
|
|
34
|
+
* the key is not split on it: the prefix is a fixed twelve hex characters and the
|
|
35
|
+
* secret is whatever follows.
|
|
36
|
+
*/
|
|
37
|
+
function parseApiKey(presented) {
|
|
38
|
+
const match = /^([a-z]+)_([0-9a-f]{12})_([A-Za-z0-9_-]+)$/.exec(presented);
|
|
39
|
+
if (!match || match[1] !== PREFIX) return null;
|
|
40
|
+
return {
|
|
41
|
+
prefix: match[2],
|
|
42
|
+
secret: match[3]
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Long-lived credentials for headless consumers.
|
|
47
|
+
*
|
|
48
|
+
* Keys are stored as a SHA-256 digest, never in plaintext, and are looked up by an
|
|
49
|
+
* indexed non-secret prefix so verification is one indexed read plus one constant-time
|
|
50
|
+
* comparison — not a scan-and-compare over every row.
|
|
51
|
+
*/
|
|
52
|
+
var ApiKeyService = class {
|
|
53
|
+
db;
|
|
54
|
+
repos;
|
|
55
|
+
constructor(db, repos) {
|
|
56
|
+
this.db = db;
|
|
57
|
+
this.repos = repos;
|
|
58
|
+
}
|
|
59
|
+
async issue(userId, name, options = {}) {
|
|
60
|
+
const secret = randomBytes(32).toString("base64url");
|
|
61
|
+
const prefix = randomBytes(6).toString("hex");
|
|
62
|
+
const key = `${PREFIX}_${prefix}_${secret}`;
|
|
63
|
+
const [row] = await this.db.insert(schema.apikeys).values({
|
|
64
|
+
userId,
|
|
65
|
+
name,
|
|
66
|
+
prefix,
|
|
67
|
+
start: key.slice(0, 12),
|
|
68
|
+
key: digest(secret),
|
|
69
|
+
expiresAt: options.expiresAt ?? null,
|
|
70
|
+
spaceIds: options.spaceIds?.length ? options.spaceIds : null,
|
|
71
|
+
permissions: options.permissions ?? null
|
|
72
|
+
}).returning();
|
|
73
|
+
if (!row) throw new ManabloxError("apiKey.create.failed");
|
|
74
|
+
await this.repos.audit.record({
|
|
75
|
+
action: "apiKey.issue",
|
|
76
|
+
targetKind: "apiKey",
|
|
77
|
+
targetId: row.id,
|
|
78
|
+
targetLabel: name,
|
|
79
|
+
changes: [
|
|
80
|
+
{
|
|
81
|
+
path: "expiresAt",
|
|
82
|
+
from: null,
|
|
83
|
+
to: row.expiresAt
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
path: "spaceIds",
|
|
87
|
+
from: null,
|
|
88
|
+
to: row.spaceIds
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
path: "permissions",
|
|
92
|
+
from: null,
|
|
93
|
+
to: row.permissions
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
meta: { userId }
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
id: row.id,
|
|
100
|
+
name,
|
|
101
|
+
key,
|
|
102
|
+
prefix
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Deletes the row rather than clearing `enabled`: a revoked key is never listed again
|
|
107
|
+
* or re-enabled, so a disabled row is only a secret digest left lying around.
|
|
108
|
+
*/
|
|
109
|
+
async revoke(id) {
|
|
110
|
+
const [row] = await this.db.delete(schema.apikeys).where(eq(schema.apikeys.id, id)).returning({
|
|
111
|
+
id: schema.apikeys.id,
|
|
112
|
+
name: schema.apikeys.name,
|
|
113
|
+
userId: schema.apikeys.userId
|
|
114
|
+
});
|
|
115
|
+
if (!row) return;
|
|
116
|
+
await this.repos.audit.record({
|
|
117
|
+
action: "apiKey.revoke",
|
|
118
|
+
targetKind: "apiKey",
|
|
119
|
+
targetId: row.id,
|
|
120
|
+
targetLabel: row.name,
|
|
121
|
+
meta: { userId: row.userId }
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async list(userId) {
|
|
125
|
+
return this.db.select({
|
|
126
|
+
id: schema.apikeys.id,
|
|
127
|
+
name: schema.apikeys.name,
|
|
128
|
+
start: schema.apikeys.start,
|
|
129
|
+
enabled: schema.apikeys.enabled,
|
|
130
|
+
expiresAt: schema.apikeys.expiresAt,
|
|
131
|
+
lastRequest: schema.apikeys.lastRequest,
|
|
132
|
+
spaceIds: schema.apikeys.spaceIds,
|
|
133
|
+
permissions: schema.apikeys.permissions,
|
|
134
|
+
createdAt: schema.apikeys.createdAt
|
|
135
|
+
}).from(schema.apikeys).where(eq(schema.apikeys.userId, userId));
|
|
136
|
+
}
|
|
137
|
+
async resolve(presented) {
|
|
138
|
+
const parsed = parseApiKey(presented);
|
|
139
|
+
if (!parsed) return null;
|
|
140
|
+
const { prefix, secret } = parsed;
|
|
141
|
+
const row = (await this.db.select().from(schema.apikeys).where(and(eq(schema.apikeys.prefix, prefix), eq(schema.apikeys.enabled, true))).limit(1))[0];
|
|
142
|
+
if (!row) return null;
|
|
143
|
+
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return null;
|
|
144
|
+
const expected = Buffer.from(row.key, "hex");
|
|
145
|
+
const actual = Buffer.from(digest(secret), "hex");
|
|
146
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
|
|
147
|
+
this.db.update(schema.apikeys).set({ lastRequest: /* @__PURE__ */ new Date() }).where(eq(schema.apikeys.id, row.id)).catch(() => void 0);
|
|
148
|
+
const user = await this.repos.users.findById(row.userId);
|
|
149
|
+
if (!user || user.banned) return null;
|
|
150
|
+
const resolved = await this.repos.users.principal(user.id);
|
|
151
|
+
if (!resolved) return null;
|
|
152
|
+
const allowed = row.spaceIds?.length ? new Set(row.spaceIds) : null;
|
|
153
|
+
const spaces = {};
|
|
154
|
+
const permissions = {};
|
|
155
|
+
for (const [spaceId, role] of Object.entries(resolved.spaces)) {
|
|
156
|
+
if (allowed && !allowed.has(spaceId)) continue;
|
|
157
|
+
spaces[spaceId] = role;
|
|
158
|
+
const grants = resolved.permissions[spaceId];
|
|
159
|
+
if (grants) permissions[spaceId] = grants;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
userId: user.id,
|
|
163
|
+
email: user.email,
|
|
164
|
+
role: user.role,
|
|
165
|
+
spaces,
|
|
166
|
+
permissions,
|
|
167
|
+
viaApiKey: true,
|
|
168
|
+
apiKeyId: row.id,
|
|
169
|
+
apiKeyName: row.name,
|
|
170
|
+
allowedSpaceIds: allowed ? [...allowed] : null,
|
|
171
|
+
allowedGrants: row.permissions
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** Removes expired keys; scheduled by the jobs package. */
|
|
175
|
+
async pruneExpired() {
|
|
176
|
+
const deleted = await this.db.delete(schema.apikeys).where(sql`${schema.apikeys.expiresAt} is not null and ${schema.apikeys.expiresAt} < now()`).returning({
|
|
177
|
+
id: schema.apikeys.id,
|
|
178
|
+
name: schema.apikeys.name
|
|
179
|
+
});
|
|
180
|
+
if (deleted.length) await this.repos.audit.record({
|
|
181
|
+
action: "apiKey.prune",
|
|
182
|
+
targetKind: "apiKey",
|
|
183
|
+
meta: { removed: deleted.map((row) => ({
|
|
184
|
+
id: row.id,
|
|
185
|
+
name: row.name
|
|
186
|
+
})) }
|
|
187
|
+
});
|
|
188
|
+
return deleted.length;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const digest = (secret) => createHash("sha256").update(secret).digest("hex");
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/rbac.ts
|
|
194
|
+
/**
|
|
195
|
+
* The principal as the audit log records it: a user, or a user through an API key, with
|
|
196
|
+
* the request's client details beside it. Anonymous requests act as the system.
|
|
197
|
+
*/
|
|
198
|
+
function auditActorFor(principal, request) {
|
|
199
|
+
const detail = {};
|
|
200
|
+
if (request) {
|
|
201
|
+
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip");
|
|
202
|
+
if (ip) detail.ip = ip;
|
|
203
|
+
const userAgent = request.headers.get("user-agent");
|
|
204
|
+
if (userAgent) detail.userAgent = userAgent.slice(0, 300);
|
|
205
|
+
if (request.requestId) detail.requestId = request.requestId;
|
|
206
|
+
}
|
|
207
|
+
if (!principal) return {
|
|
208
|
+
kind: "system",
|
|
209
|
+
id: null,
|
|
210
|
+
label: "anonymous",
|
|
211
|
+
detail
|
|
212
|
+
};
|
|
213
|
+
if (principal.viaApiKey) {
|
|
214
|
+
detail.apiKeyId = principal.apiKeyId ?? null;
|
|
215
|
+
detail.apiKeyName = principal.apiKeyName ?? null;
|
|
216
|
+
return {
|
|
217
|
+
kind: "apikey",
|
|
218
|
+
id: principal.userId,
|
|
219
|
+
label: principal.email,
|
|
220
|
+
detail
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
kind: "user",
|
|
225
|
+
id: principal.userId,
|
|
226
|
+
label: principal.email,
|
|
227
|
+
detail
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** The grants a principal's role gives in a space, whatever kind of role it is. */
|
|
231
|
+
function grantsIn(principal, spaceId) {
|
|
232
|
+
const role = principal.spaces[spaceId];
|
|
233
|
+
if (!role) return [];
|
|
234
|
+
return principal.permissions?.[spaceId] ?? permissionsFor$1(role);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* What a principal can actually do in a space: the role's grants (everything, for a
|
|
238
|
+
* superadmin) narrowed by an API key's restriction, if the request came through one.
|
|
239
|
+
*/
|
|
240
|
+
function effectiveGrants(principal, spaceId) {
|
|
241
|
+
if (principal.allowedSpaceIds && !principal.allowedSpaceIds.includes(spaceId)) return [];
|
|
242
|
+
const held = principal.role === "superadmin" ? ALL_PERMISSIONS$1 : grantsIn(principal, spaceId);
|
|
243
|
+
return principal.allowedGrants ? intersectGrants$1(held, principal.allowedGrants) : held;
|
|
244
|
+
}
|
|
245
|
+
function can(principal, spaceId, permission, typeId) {
|
|
246
|
+
if (!principal) return false;
|
|
247
|
+
if (principal.allowedSpaceIds && (!spaceId || !principal.allowedSpaceIds.includes(spaceId))) return false;
|
|
248
|
+
if (principal.allowedGrants && !grantsCover$1(principal.allowedGrants, permission, typeId)) return false;
|
|
249
|
+
if (principal.role === "superadmin") return true;
|
|
250
|
+
if (!spaceId) return false;
|
|
251
|
+
return grantsCover$1(grantsIn(principal, spaceId), permission, typeId);
|
|
252
|
+
}
|
|
253
|
+
function assertCan(principal, spaceId, permission, typeId) {
|
|
254
|
+
if (can(principal, spaceId, permission, typeId)) return;
|
|
255
|
+
if (!principal) throw ManabloxError.unauthorized();
|
|
256
|
+
throw ManabloxError.forbidden("auth.forbidden", {
|
|
257
|
+
permission,
|
|
258
|
+
spaceId,
|
|
259
|
+
...typeId ? { typeId } : {}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The content types a principal may perform an action on in a space, or `null` for
|
|
264
|
+
* every type — what a listing narrows its filter to.
|
|
265
|
+
*/
|
|
266
|
+
function allowedTypeIds(principal, spaceId, permission) {
|
|
267
|
+
if (!principal) return [];
|
|
268
|
+
if (principal.role === "superadmin" && !principal.allowedGrants) return null;
|
|
269
|
+
return typesCoveredBy$1(effectiveGrants(principal, spaceId), permission);
|
|
270
|
+
}
|
|
271
|
+
/** Roles used by field-level `readRoles`/`writeRoles` checks. */
|
|
272
|
+
function actorRoles(principal, spaceId) {
|
|
273
|
+
if (!principal) return [];
|
|
274
|
+
const roles = [principal.role];
|
|
275
|
+
if (spaceId && principal.spaces[spaceId]) roles.push(principal.spaces[spaceId]);
|
|
276
|
+
return roles;
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region src/user.service.ts
|
|
280
|
+
/**
|
|
281
|
+
* `users_email_key` is enforced in the database, so a taken address arrives as a
|
|
282
|
+
* Postgres unique violation and would surface as an opaque 500.
|
|
283
|
+
*/
|
|
284
|
+
const emailConflict = (email) => (error) => rethrowUniqueViolation(error, {
|
|
285
|
+
constraint: "email",
|
|
286
|
+
key: "user.email.taken",
|
|
287
|
+
path: ["email"],
|
|
288
|
+
params: { email: email ?? "" },
|
|
289
|
+
errorKey: "user.validation.failed"
|
|
290
|
+
});
|
|
291
|
+
/**
|
|
292
|
+
* Instance-wide user administration: the accounts, their instance role, and whether they
|
|
293
|
+
* may sign in at all. Space membership stays with `SpaceService`, because it is a
|
|
294
|
+
* property of the space.
|
|
295
|
+
*
|
|
296
|
+
* Every rule here exists to keep the instance reachable: an administrator cannot lock
|
|
297
|
+
* themself out, and the instance always keeps at least one superadmin.
|
|
298
|
+
*/
|
|
299
|
+
var UserService = class {
|
|
300
|
+
repos;
|
|
301
|
+
constructor(repos) {
|
|
302
|
+
this.repos = repos;
|
|
303
|
+
}
|
|
304
|
+
async get(userId) {
|
|
305
|
+
const user = await this.repos.users.findById(userId);
|
|
306
|
+
if (!user) throw ManabloxError.notFound("user.notFound", { id: userId });
|
|
307
|
+
const memberships = await this.repos.users.membershipsWithSpaces(userId);
|
|
308
|
+
return {
|
|
309
|
+
...summary(user),
|
|
310
|
+
memberships: memberships.map((row) => ({
|
|
311
|
+
spaceId: row.spaceId,
|
|
312
|
+
role: row.role,
|
|
313
|
+
space: row.space
|
|
314
|
+
}))
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async list(pagination, search) {
|
|
318
|
+
const page = await this.repos.users.list(pagination, search);
|
|
319
|
+
return {
|
|
320
|
+
...page,
|
|
321
|
+
items: page.items.map(summary)
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
async create(input) {
|
|
325
|
+
const email = normaliseEmail(input.email);
|
|
326
|
+
const user = await this.repos.users.create({
|
|
327
|
+
name: input.name.trim(),
|
|
328
|
+
email,
|
|
329
|
+
role: input.role,
|
|
330
|
+
passwordHash: await hashPassword(input.password)
|
|
331
|
+
}).catch(emailConflict(email));
|
|
332
|
+
await this.audit("user.create", user, diffRecords(null, summary(user)));
|
|
333
|
+
return summary(user);
|
|
334
|
+
}
|
|
335
|
+
async update(userId, input) {
|
|
336
|
+
const before = await this.require(userId);
|
|
337
|
+
const data = {};
|
|
338
|
+
if (input.name !== void 0) data.name = input.name.trim();
|
|
339
|
+
if (input.email !== void 0) data.email = normaliseEmail(input.email);
|
|
340
|
+
const user = await this.repos.users.update(userId, data).catch(emailConflict(data.email));
|
|
341
|
+
await this.audit("user.update", user, diffRecords(summary(before), summary(user)));
|
|
342
|
+
return summary(user);
|
|
343
|
+
}
|
|
344
|
+
/** Changing the instance role; the last superadmin cannot step down. */
|
|
345
|
+
async setRole(userId, role) {
|
|
346
|
+
const before = await this.require(userId);
|
|
347
|
+
if (role !== "superadmin") await this.assertNotLastSuperadmin(userId);
|
|
348
|
+
const user = await this.repos.users.setRole(userId, role);
|
|
349
|
+
await this.audit("user.setRole", user, [{
|
|
350
|
+
path: "role",
|
|
351
|
+
from: before.role,
|
|
352
|
+
to: user.role
|
|
353
|
+
}]);
|
|
354
|
+
return summary(user);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* A new password, and every session gone with the old one: whoever held the account
|
|
358
|
+
* before the reset does not keep it afterwards.
|
|
359
|
+
*/
|
|
360
|
+
async setPassword(userId, password) {
|
|
361
|
+
const user = await this.require(userId);
|
|
362
|
+
await this.repos.users.setPasswordHash(userId, await hashPassword(password));
|
|
363
|
+
await this.repos.users.revokeSessions(userId);
|
|
364
|
+
await this.audit("user.setPassword", user, []);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The account holder changing their own password: the current one must be right, and
|
|
368
|
+
* unlike an administrator's reset the other sessions stay signed in, since the person
|
|
369
|
+
* holding the account is the one making the change.
|
|
370
|
+
*/
|
|
371
|
+
async changePassword(userId, currentPassword, password) {
|
|
372
|
+
const user = await this.require(userId);
|
|
373
|
+
const stored = await this.repos.users.passwordHash(userId);
|
|
374
|
+
if (!stored || !await verifyPassword(stored, currentPassword)) throw ManabloxError.validation([{
|
|
375
|
+
key: "user.password.incorrect",
|
|
376
|
+
path: ["currentPassword"]
|
|
377
|
+
}], "user.validation.failed");
|
|
378
|
+
if (currentPassword === password) throw ManabloxError.validation([{
|
|
379
|
+
key: "user.password.sameAsCurrent",
|
|
380
|
+
path: ["password"]
|
|
381
|
+
}], "user.validation.failed");
|
|
382
|
+
await this.repos.users.setPasswordHash(userId, await hashPassword(password));
|
|
383
|
+
await this.audit("user.changePassword", user, []);
|
|
384
|
+
}
|
|
385
|
+
/** A banned user is signed out everywhere and refused on the next request. */
|
|
386
|
+
async ban(actorId, userId, reason) {
|
|
387
|
+
this.assertNotSelf(actorId, userId);
|
|
388
|
+
await this.assertNotLastSuperadmin(userId);
|
|
389
|
+
const user = await this.repos.users.setBanned(userId, true, reason);
|
|
390
|
+
await this.repos.users.revokeSessions(userId);
|
|
391
|
+
await this.audit("user.ban", user, [{
|
|
392
|
+
path: "banned",
|
|
393
|
+
from: false,
|
|
394
|
+
to: true
|
|
395
|
+
}, {
|
|
396
|
+
path: "banReason",
|
|
397
|
+
from: null,
|
|
398
|
+
to: reason
|
|
399
|
+
}]);
|
|
400
|
+
return summary(user);
|
|
401
|
+
}
|
|
402
|
+
async unban(userId) {
|
|
403
|
+
const before = await this.require(userId);
|
|
404
|
+
const user = await this.repos.users.setBanned(userId, false, null);
|
|
405
|
+
await this.audit("user.unban", user, [{
|
|
406
|
+
path: "banned",
|
|
407
|
+
from: before.banned,
|
|
408
|
+
to: false
|
|
409
|
+
}, {
|
|
410
|
+
path: "banReason",
|
|
411
|
+
from: before.banReason,
|
|
412
|
+
to: null
|
|
413
|
+
}]);
|
|
414
|
+
return summary(user);
|
|
415
|
+
}
|
|
416
|
+
async delete(actorId, userId) {
|
|
417
|
+
this.assertNotSelf(actorId, userId);
|
|
418
|
+
const user = await this.require(userId);
|
|
419
|
+
await this.assertNotLastSuperadmin(userId);
|
|
420
|
+
await this.repos.users.delete(userId);
|
|
421
|
+
await this.audit("user.delete", user, diffRecords(summary(user), null));
|
|
422
|
+
}
|
|
423
|
+
/** Signs the user out of every device without touching the account. */
|
|
424
|
+
async revokeSessions(userId) {
|
|
425
|
+
const user = await this.require(userId);
|
|
426
|
+
await this.repos.users.revokeSessions(userId);
|
|
427
|
+
await this.audit("user.revokeSessions", user, []);
|
|
428
|
+
}
|
|
429
|
+
/** Accounts are instance-wide, so their entries carry no space. */
|
|
430
|
+
audit(action, user, changes) {
|
|
431
|
+
return this.repos.audit.record({
|
|
432
|
+
action,
|
|
433
|
+
targetKind: "user",
|
|
434
|
+
targetId: user.id,
|
|
435
|
+
targetLabel: user.email,
|
|
436
|
+
changes
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
async require(userId) {
|
|
440
|
+
const user = await this.repos.users.findById(userId);
|
|
441
|
+
if (!user) throw ManabloxError.notFound("user.notFound", { id: userId });
|
|
442
|
+
return user;
|
|
443
|
+
}
|
|
444
|
+
assertNotSelf(actorId, userId) {
|
|
445
|
+
if (actorId === userId) throw ManabloxError.badRequest("user.self.protected", { id: userId });
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Whatever happens to `userId`, one superadmin must remain — otherwise the instance
|
|
449
|
+
* has no one left who can create a space or manage users, and no way back.
|
|
450
|
+
*/
|
|
451
|
+
async assertNotLastSuperadmin(userId) {
|
|
452
|
+
if ((await this.require(userId)).role !== "superadmin") return;
|
|
453
|
+
if (await this.repos.users.countByRole("superadmin") <= 1) throw ManabloxError.badRequest("user.lastSuperadmin", { id: userId });
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
function summary(user) {
|
|
457
|
+
return {
|
|
458
|
+
id: user.id,
|
|
459
|
+
name: user.name,
|
|
460
|
+
email: user.email,
|
|
461
|
+
image: user.image,
|
|
462
|
+
role: user.role,
|
|
463
|
+
banned: user.banned,
|
|
464
|
+
banReason: user.banReason,
|
|
465
|
+
createdAt: user.createdAt,
|
|
466
|
+
updatedAt: user.updatedAt
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
/** Lower-cased and trimmed, as better-auth stores it, so two spellings cannot coexist. */
|
|
470
|
+
function normaliseEmail(email) {
|
|
471
|
+
return email.trim().toLowerCase();
|
|
472
|
+
}
|
|
473
|
+
//#endregion
|
|
474
|
+
//#region src/index.ts
|
|
475
|
+
function createAuth(config, db, callbacks = {}) {
|
|
476
|
+
return betterAuth({
|
|
477
|
+
secret: config.secret,
|
|
478
|
+
...config.baseUrl ? { baseURL: config.baseUrl } : {},
|
|
479
|
+
trustedOrigins: config.trustedOrigins ?? [],
|
|
480
|
+
database: drizzleAdapter(db, {
|
|
481
|
+
provider: "pg",
|
|
482
|
+
schema: {
|
|
483
|
+
user: schema.users,
|
|
484
|
+
session: schema.sessions,
|
|
485
|
+
account: schema.accounts,
|
|
486
|
+
verification: schema.verifications,
|
|
487
|
+
apikey: schema.apikeys
|
|
488
|
+
}
|
|
489
|
+
}),
|
|
490
|
+
emailAndPassword: {
|
|
491
|
+
enabled: config.emailAndPassword ?? true,
|
|
492
|
+
minPasswordLength: 12,
|
|
493
|
+
password: {
|
|
494
|
+
hash: hashPassword,
|
|
495
|
+
verify: ({ hash: stored, password }) => verifyPassword(stored, password)
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
session: {
|
|
499
|
+
expiresIn: config.sessionMaxAge ?? 604800,
|
|
500
|
+
updateAge: 86400,
|
|
501
|
+
cookieCache: {
|
|
502
|
+
enabled: true,
|
|
503
|
+
maxAge: 300
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
plugins: [bearer()],
|
|
507
|
+
databaseHooks: {
|
|
508
|
+
user: { create: {
|
|
509
|
+
before: async (user) => {
|
|
510
|
+
if (!callbacks.allowSignUp || await callbacks.allowSignUp()) return { data: user };
|
|
511
|
+
throw new APIError("FORBIDDEN", { message: "auth.signUp.closed" });
|
|
512
|
+
},
|
|
513
|
+
after: async (user) => {
|
|
514
|
+
await callbacks.onUserCreated?.(user.id);
|
|
515
|
+
}
|
|
516
|
+
} },
|
|
517
|
+
session: { create: { after: async (session) => {
|
|
518
|
+
await callbacks.onSessionCreated?.(session);
|
|
519
|
+
} } }
|
|
520
|
+
},
|
|
521
|
+
advanced: { database: { generateId: () => crypto.randomUUID() } }
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Resolves a request's session into a `Principal`, including its space memberships.
|
|
526
|
+
* Returns `null` for anonymous requests rather than throwing — route guards decide.
|
|
527
|
+
*/
|
|
528
|
+
async function resolvePrincipal(auth, repos, headers, apiKeys) {
|
|
529
|
+
const presented = headers.get("x-api-key");
|
|
530
|
+
if (presented && apiKeys) {
|
|
531
|
+
const principal = await apiKeys.resolve(presented);
|
|
532
|
+
if (principal) return principal;
|
|
533
|
+
}
|
|
534
|
+
const session = await auth.api.getSession({ headers });
|
|
535
|
+
if (!session?.user) return null;
|
|
536
|
+
const principal = await repos.users.principal(session.user.id);
|
|
537
|
+
if (!principal || principal.banned) return null;
|
|
538
|
+
return {
|
|
539
|
+
userId: session.user.id,
|
|
540
|
+
email: session.user.email,
|
|
541
|
+
role: principal.role,
|
|
542
|
+
spaces: principal.spaces,
|
|
543
|
+
permissions: principal.permissions
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Promotes the very first account to `superadmin` and grants it ownership of every
|
|
548
|
+
* existing space, so a fresh install is reachable.
|
|
549
|
+
*
|
|
550
|
+
* Called from better-auth's user-create hook rather than at startup, so it fires for an
|
|
551
|
+
* account created after the server is already running.
|
|
552
|
+
*/
|
|
553
|
+
async function promoteFirstUser(manablox, repos, userId) {
|
|
554
|
+
if (await repos.users.count() !== 1) return;
|
|
555
|
+
const user = await repos.users.findById(userId);
|
|
556
|
+
if (!user || user.role === "superadmin") return;
|
|
557
|
+
await repos.users.setRole(userId, "superadmin");
|
|
558
|
+
for (const space of await repos.spaces.all()) await repos.users.grant(userId, space.id, "owner");
|
|
559
|
+
await repos.audit.record({
|
|
560
|
+
actor: systemActor("first account"),
|
|
561
|
+
action: "user.promote",
|
|
562
|
+
targetKind: "user",
|
|
563
|
+
targetId: user.id,
|
|
564
|
+
targetLabel: user.email,
|
|
565
|
+
changes: [{
|
|
566
|
+
path: "role",
|
|
567
|
+
from: user.role,
|
|
568
|
+
to: "superadmin"
|
|
569
|
+
}]
|
|
570
|
+
});
|
|
571
|
+
manablox.logger.info({ email: user.email }, "first account promoted to superadmin");
|
|
572
|
+
}
|
|
573
|
+
/** Covers an instance whose first account predates this behaviour. */
|
|
574
|
+
function attachBootstrapOwner(manablox, repos) {
|
|
575
|
+
manablox.hooks.on("after:start", async () => {
|
|
576
|
+
const { items } = await repos.users.list({
|
|
577
|
+
limit: 1,
|
|
578
|
+
offset: 0
|
|
579
|
+
});
|
|
580
|
+
const first = items[0];
|
|
581
|
+
if (first && await repos.users.count() === 1) await promoteFirstUser(manablox, repos, first.id);
|
|
582
|
+
}, { source: "@manablox/auth" });
|
|
583
|
+
}
|
|
584
|
+
//#endregion
|
|
585
|
+
export { ALL_PERMISSIONS, ApiKeyService, BUILT_IN_ROLES, CONTENT_ACTIONS, MIN_PASSWORD_LENGTH, PERMISSION_GROUPS, UserService, actorRoles, allowedTypeIds, assertCan, attachBootstrapOwner, auditActorFor, can, createAuth, effectiveGrants, grantsCover, grantsIn, hashPassword, intersectGrants, isBuiltInRole, normaliseGrants, parseApiKey, parseGrant, permissionsFor, promoteFirstUser, resolvePrincipal, typesCoveredBy, verifyPassword };
|
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manablox/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
|
-
"types": "./
|
|
8
|
-
"default": "./
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
|
-
"main": "./
|
|
12
|
-
"types": "./
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@manablox/core": "0.
|
|
15
|
-
"@manablox/db": "0.
|
|
14
|
+
"@manablox/core": "0.4.0",
|
|
15
|
+
"@manablox/db": "0.4.0",
|
|
16
16
|
"better-auth": "^1.7.2",
|
|
17
17
|
"drizzle-orm": "^0.45.2",
|
|
18
18
|
"@node-rs/argon2": "^2.2.0"
|
|
@@ -20,10 +20,17 @@
|
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@manablox/config-typescript": "0.0.0",
|
|
22
22
|
"@types/node": "^26.4.1",
|
|
23
|
+
"tsdown": "^0.23.0",
|
|
23
24
|
"typescript": "^7.0.2",
|
|
24
25
|
"vitest": "^5.0.0"
|
|
25
26
|
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"!dist/**/*.map",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
26
32
|
"scripts": {
|
|
33
|
+
"build": "tsdown",
|
|
27
34
|
"typecheck": "tsc --noEmit",
|
|
28
35
|
"test": "vitest run"
|
|
29
36
|
}
|