@pramen/auth 0.0.8 → 0.0.10
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 +70 -5
- package/dist/index.js +131 -104
- package/package.json +2 -2
- package/src/index.ts +142 -109
package/dist/index.d.ts
CHANGED
|
@@ -118,8 +118,63 @@ export declare function createMagicLinkAuth(opts: MagicLinkOptions): {
|
|
|
118
118
|
};
|
|
119
119
|
}>;
|
|
120
120
|
};
|
|
121
|
-
/**
|
|
122
|
-
*
|
|
121
|
+
/** Build admin + self-service handlers over a users table (default `auth_users`).
|
|
122
|
+
* Pass `table` to operate over your OWN authSchema-shaped table — e.g. one with an
|
|
123
|
+
* extra `tenants` column — without renaming it: the handlers manage username/roles/
|
|
124
|
+
* email/active/delete and ignore any extra columns. Spread the result into your
|
|
125
|
+
* handler map and gate it with the matching `authPolicies({ table })`. The table must
|
|
126
|
+
* have a `username` primary key and (for changeEmail/changePassword) `email`/
|
|
127
|
+
* `passwordHash` columns. */
|
|
128
|
+
export declare function createUserHandlers(opts?: {
|
|
129
|
+
table?: string;
|
|
130
|
+
}): {
|
|
131
|
+
/** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
|
|
132
|
+
* only the self policy sees just their own row; ungranted callers get a 403. */
|
|
133
|
+
listUsers: import("@pramen/server").Handler<{
|
|
134
|
+
limit?: number;
|
|
135
|
+
offset?: number;
|
|
136
|
+
}, (import("@pramen/server").InferRow<import("@pramen/server").EntityFields> & Partial<{
|
|
137
|
+
[x: string]: import("@pramen/server").InferRow<import("@pramen/server").EntityFields> | import("@pramen/server").InferRow<import("@pramen/server").EntityFields>[] | null;
|
|
138
|
+
}>)[]>;
|
|
139
|
+
/** Admin: replace a user's roles. The ACL admin update policy permits writing
|
|
140
|
+
* `roles`; a self-only caller can't (so this is admin-gated declaratively). */
|
|
141
|
+
setUserRoles: import("@pramen/server").Handler<{
|
|
142
|
+
username: string;
|
|
143
|
+
roles: string[];
|
|
144
|
+
}, Record<string, unknown>>;
|
|
145
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins (and
|
|
146
|
+
* token refresh); existing tokens still expire naturally within the TTL. */
|
|
147
|
+
setUserActive: import("@pramen/server").Handler<{
|
|
148
|
+
username: string;
|
|
149
|
+
active: boolean;
|
|
150
|
+
}, Record<string, unknown>>;
|
|
151
|
+
/** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
|
|
152
|
+
* cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
|
|
153
|
+
deleteUser: import("@pramen/server").Handler<{
|
|
154
|
+
username: string;
|
|
155
|
+
}, {
|
|
156
|
+
ok: boolean;
|
|
157
|
+
}>;
|
|
158
|
+
/** Self-service: change the caller's contact email. The ACL self policy scopes the
|
|
159
|
+
* write to the caller's own row and permits only the `email` field. Email is unique,
|
|
160
|
+
* so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
|
|
161
|
+
changeEmail: import("@pramen/server").Handler<{
|
|
162
|
+
email: string;
|
|
163
|
+
}, Record<string, unknown>>;
|
|
164
|
+
/** Self-service: change the caller's password. A credential op — it reads the
|
|
165
|
+
* caller's OWN hash (passwordHash is never ACL-readable) to verify the current
|
|
166
|
+
* password, then writes the new one. Self-scoped by the verified identity, so it
|
|
167
|
+
* never touches another row; passwordless (magic-link) users have no current
|
|
168
|
+
* password and are rejected. */
|
|
169
|
+
changePassword: import("@pramen/server").Handler<{
|
|
170
|
+
currentPassword: string;
|
|
171
|
+
newPassword: string;
|
|
172
|
+
}, {
|
|
173
|
+
ok: boolean;
|
|
174
|
+
}>;
|
|
175
|
+
};
|
|
176
|
+
/** The default user-management handlers over `auth_users`. Equivalent to
|
|
177
|
+
* `createUserHandlers()`; spread alongside `authHandlers`. */
|
|
123
178
|
export declare const userHandlers: {
|
|
124
179
|
/** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
|
|
125
180
|
* only the self policy sees just their own row; ungranted callers get a 403. */
|
|
@@ -166,18 +221,28 @@ export declare const userHandlers: {
|
|
|
166
221
|
ok: boolean;
|
|
167
222
|
}>;
|
|
168
223
|
};
|
|
169
|
-
/** ACL policy fragments that turn on
|
|
170
|
-
* role and `self` into your authenticated-user role:
|
|
224
|
+
/** ACL policy fragments that turn on the user-management handlers. Spread `admin`
|
|
225
|
+
* into your admin role and `self` into your authenticated-user role:
|
|
171
226
|
*
|
|
172
227
|
* role("admin", [...authPolicies().admin, ...yourAdminPolicies])
|
|
173
228
|
* role("user", [...authPolicies().self, ...yourUserPolicies])
|
|
174
229
|
*
|
|
175
230
|
* `admin` grants read (projected) + update of roles/email/active on every user.
|
|
176
231
|
* `self` grants each user read + email-update of ONLY their own row (matched on the
|
|
177
|
-
* `userId` identity claim). passwordHash is in no policy, so it is never exposed.
|
|
232
|
+
* `userId` identity claim). passwordHash is in no policy, so it is never exposed.
|
|
233
|
+
*
|
|
234
|
+
* For a custom table, pass the same `table` you gave `createUserHandlers`, a unique
|
|
235
|
+
* `prefix` (policy names must be unique across roles when you wire more than one
|
|
236
|
+
* instance), and `adminReadFields`/`adminWriteFields` to expose/permit extra columns
|
|
237
|
+
* (e.g. a `tenants` column managed by your own setUserTenants handler). */
|
|
178
238
|
export declare function authPolicies(opts?: {
|
|
179
239
|
table?: string;
|
|
180
240
|
identityPath?: string;
|
|
241
|
+
prefix?: string;
|
|
242
|
+
adminReadFields?: string[];
|
|
243
|
+
adminWriteFields?: string[];
|
|
244
|
+
selfReadFields?: string[];
|
|
245
|
+
selfWriteFields?: string[];
|
|
181
246
|
}): {
|
|
182
247
|
admin: Policy[];
|
|
183
248
|
self: Policy[];
|
package/dist/index.js
CHANGED
|
@@ -206,27 +206,26 @@ export function createMagicLinkAuth(opts) {
|
|
|
206
206
|
// Single-use: consume before issuing the session.
|
|
207
207
|
await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
|
|
208
208
|
const email = String(link.email);
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
209
|
+
// Key on the USERNAME (the immutable identity = the JWT sub), NOT the mutable
|
|
210
|
+
// `email` column: a magic-link user's username IS their email address, so this
|
|
211
|
+
// both matches existing users and avoids resolving login by a mutable, unverified
|
|
212
|
+
// field (which would let a changeEmail squat another address — and would miss
|
|
213
|
+
// pre-`email`-column users on upgrade, colliding on the username PK).
|
|
214
|
+
const existing = await ctx.db.exec("SELECT roles, active FROM auth_users WHERE username = ? LIMIT 1", email);
|
|
214
215
|
let roles;
|
|
215
216
|
if (existing.length > 0) {
|
|
216
217
|
if (!isActive(existing[0].active))
|
|
217
218
|
throw new Unauthorized("account is deactivated");
|
|
218
|
-
username = String(existing[0].username);
|
|
219
219
|
roles = JSON.parse(String(existing[0].roles));
|
|
220
220
|
}
|
|
221
221
|
else {
|
|
222
|
-
username = email;
|
|
223
222
|
roles = defaultRoles;
|
|
224
|
-
|
|
225
|
-
//
|
|
226
|
-
"INSERT INTO auth_users (username, passwordHash, roles,
|
|
223
|
+
// No email column set: it stays a pure contact attribute (set via changeEmail),
|
|
224
|
+
// so a new passwordless user can never collide with a password user's contact email.
|
|
225
|
+
await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)", email, "", JSON.stringify(roles), Date.now());
|
|
227
226
|
}
|
|
228
|
-
const token = await signToken({ sub:
|
|
229
|
-
return { token, user: { username, roles } };
|
|
227
|
+
const token = await signToken({ sub: email, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
|
|
228
|
+
return { token, user: { username: email, roles } };
|
|
230
229
|
}, { input: parseLinkToken }),
|
|
231
230
|
};
|
|
232
231
|
}
|
|
@@ -255,115 +254,143 @@ function requireUserId(ctx) {
|
|
|
255
254
|
return id;
|
|
256
255
|
}
|
|
257
256
|
const usersDb = (ctx) => ctx.db;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
257
|
+
// The users table must be a valid SQL identifier (it's interpolated into the raw exec
|
|
258
|
+
// strings below). It's app config, never request input, but guard it anyway.
|
|
259
|
+
function assertIdentifier(table) {
|
|
260
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table))
|
|
261
|
+
throw new Error(`@pramen/auth: invalid table name ${JSON.stringify(table)}`);
|
|
262
|
+
return table;
|
|
263
|
+
}
|
|
264
|
+
/** Build admin + self-service handlers over a users table (default `auth_users`).
|
|
265
|
+
* Pass `table` to operate over your OWN authSchema-shaped table — e.g. one with an
|
|
266
|
+
* extra `tenants` column — without renaming it: the handlers manage username/roles/
|
|
267
|
+
* email/active/delete and ignore any extra columns. Spread the result into your
|
|
268
|
+
* handler map and gate it with the matching `authPolicies({ table })`. The table must
|
|
269
|
+
* have a `username` primary key and (for changeEmail/changePassword) `email`/
|
|
270
|
+
* `passwordHash` columns. */
|
|
271
|
+
export function createUserHandlers(opts = {}) {
|
|
272
|
+
const table = assertIdentifier(opts.table ?? "auth_users");
|
|
273
|
+
return {
|
|
274
|
+
/** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
|
|
275
|
+
* only the self policy sees just their own row; ungranted callers get a 403. */
|
|
276
|
+
listUsers: query(async (ctx, input) => {
|
|
277
|
+
const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
|
|
278
|
+
const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
|
|
279
|
+
return ctx.db.find({ from: table, orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
|
|
280
|
+
}),
|
|
281
|
+
/** Admin: replace a user's roles. The ACL admin update policy permits writing
|
|
282
|
+
* `roles`; a self-only caller can't (so this is admin-gated declaratively). */
|
|
283
|
+
setUserRoles: mutation(async (ctx, input) => {
|
|
284
|
+
if (typeof input?.username !== "string" || input.username.length === 0)
|
|
285
|
+
throw new BadRequest("username is required");
|
|
286
|
+
if (!Array.isArray(input.roles) || !input.roles.every((r) => typeof r === "string" && r.length > 0)) {
|
|
287
|
+
throw new BadRequest("roles must be a non-empty string[]");
|
|
288
|
+
}
|
|
289
|
+
const updated = await usersDb(ctx).update(table, input.username, { roles: input.roles });
|
|
290
|
+
if (!updated)
|
|
291
|
+
throw new BadRequest("user not found"); // (or out of the caller's update scope)
|
|
292
|
+
return updated;
|
|
293
|
+
}),
|
|
294
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins (and
|
|
295
|
+
* token refresh); existing tokens still expire naturally within the TTL. */
|
|
296
|
+
setUserActive: mutation(async (ctx, input) => {
|
|
297
|
+
if (typeof input?.username !== "string" || input.username.length === 0)
|
|
298
|
+
throw new BadRequest("username is required");
|
|
299
|
+
if (typeof input?.active !== "boolean")
|
|
300
|
+
throw new BadRequest("active must be a boolean");
|
|
301
|
+
if (input.active === false && input.username === ctx.identity?.userId) {
|
|
302
|
+
throw new BadRequest("cannot deactivate your own account");
|
|
303
|
+
}
|
|
304
|
+
const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
|
|
305
|
+
if (!updated)
|
|
306
|
+
throw new BadRequest("user not found");
|
|
307
|
+
return updated;
|
|
308
|
+
}),
|
|
309
|
+
/** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
|
|
310
|
+
* cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
|
|
311
|
+
deleteUser: mutation(async (ctx, input) => {
|
|
312
|
+
if (typeof input?.username !== "string" || input.username.length === 0)
|
|
313
|
+
throw new BadRequest("username is required");
|
|
314
|
+
if (input.username === ctx.identity?.userId)
|
|
315
|
+
throw new BadRequest("cannot delete your own account");
|
|
316
|
+
const deleted = await usersDb(ctx).delete(table, input.username);
|
|
317
|
+
if (!deleted)
|
|
318
|
+
throw new BadRequest("user not found");
|
|
319
|
+
return { ok: true };
|
|
320
|
+
}),
|
|
321
|
+
/** Self-service: change the caller's contact email. The ACL self policy scopes the
|
|
322
|
+
* write to the caller's own row and permits only the `email` field. Email is unique,
|
|
323
|
+
* so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
|
|
324
|
+
changeEmail: mutation(async (ctx, input) => {
|
|
325
|
+
const userId = requireUserId(ctx);
|
|
326
|
+
const { email } = parseEmail(input); // validates + normalizes; 400 on a bad address
|
|
327
|
+
const taken = await ctx.db.exec(`SELECT 1 FROM ${table} WHERE email = ? AND username != ? LIMIT 1`, email, userId);
|
|
328
|
+
if (taken.length > 0)
|
|
329
|
+
throw new BadRequest("email already in use");
|
|
330
|
+
const updated = await usersDb(ctx).update(table, userId, { email });
|
|
331
|
+
if (!updated)
|
|
332
|
+
throw new Unauthorized("authentication required");
|
|
333
|
+
return updated;
|
|
334
|
+
}),
|
|
335
|
+
/** Self-service: change the caller's password. A credential op — it reads the
|
|
336
|
+
* caller's OWN hash (passwordHash is never ACL-readable) to verify the current
|
|
337
|
+
* password, then writes the new one. Self-scoped by the verified identity, so it
|
|
338
|
+
* never touches another row; passwordless (magic-link) users have no current
|
|
339
|
+
* password and are rejected. */
|
|
340
|
+
changePassword: mutation(async (ctx, input) => {
|
|
341
|
+
const userId = requireUserId(ctx);
|
|
342
|
+
const current = typeof input?.currentPassword === "string" ? input.currentPassword : "";
|
|
343
|
+
const next = typeof input?.newPassword === "string" ? input.newPassword : "";
|
|
344
|
+
if (next.length < 8)
|
|
345
|
+
throw new BadRequest("newPassword must be at least 8 characters");
|
|
346
|
+
const rows = await ctx.db.exec(`SELECT passwordHash FROM ${table} WHERE username = ? LIMIT 1`, userId);
|
|
347
|
+
const stored = rows[0] ? String(rows[0].passwordHash ?? "") : "";
|
|
348
|
+
if (stored === "" || !(await verifyPassword(current, stored))) {
|
|
349
|
+
throw new Unauthorized("current password is incorrect");
|
|
350
|
+
}
|
|
351
|
+
await ctx.db.exec(`UPDATE ${table} SET passwordHash = ? WHERE username = ?`, await hashPassword(next), userId);
|
|
352
|
+
return { ok: true };
|
|
353
|
+
}),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/** The default user-management handlers over `auth_users`. Equivalent to
|
|
357
|
+
* `createUserHandlers()`; spread alongside `authHandlers`. */
|
|
358
|
+
export const userHandlers = createUserHandlers();
|
|
342
359
|
// Fields a self-service caller may see of their own row (never passwordHash/roles).
|
|
343
360
|
const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
|
|
344
361
|
// Fields an admin may see of any user (never passwordHash).
|
|
345
362
|
const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
|
|
346
|
-
/** ACL policy fragments that turn on
|
|
347
|
-
* role and `self` into your authenticated-user role:
|
|
363
|
+
/** ACL policy fragments that turn on the user-management handlers. Spread `admin`
|
|
364
|
+
* into your admin role and `self` into your authenticated-user role:
|
|
348
365
|
*
|
|
349
366
|
* role("admin", [...authPolicies().admin, ...yourAdminPolicies])
|
|
350
367
|
* role("user", [...authPolicies().self, ...yourUserPolicies])
|
|
351
368
|
*
|
|
352
369
|
* `admin` grants read (projected) + update of roles/email/active on every user.
|
|
353
370
|
* `self` grants each user read + email-update of ONLY their own row (matched on the
|
|
354
|
-
* `userId` identity claim). passwordHash is in no policy, so it is never exposed.
|
|
371
|
+
* `userId` identity claim). passwordHash is in no policy, so it is never exposed.
|
|
372
|
+
*
|
|
373
|
+
* For a custom table, pass the same `table` you gave `createUserHandlers`, a unique
|
|
374
|
+
* `prefix` (policy names must be unique across roles when you wire more than one
|
|
375
|
+
* instance), and `adminReadFields`/`adminWriteFields` to expose/permit extra columns
|
|
376
|
+
* (e.g. a `tenants` column managed by your own setUserTenants handler). */
|
|
355
377
|
export function authPolicies(opts = {}) {
|
|
356
378
|
const table = opts.table ?? "auth_users";
|
|
357
379
|
const idPath = opts.identityPath ?? "userId";
|
|
380
|
+
const p = opts.prefix ?? "auth";
|
|
381
|
+
const adminRead = opts.adminReadFields ?? ADMIN_READ_FIELDS;
|
|
382
|
+
const adminWrite = opts.adminWriteFields ?? ["roles", "email", "active"];
|
|
383
|
+
const selfRead = opts.selfReadFields ?? SELF_READ_FIELDS;
|
|
384
|
+
const selfWrite = opts.selfWriteFields ?? ["email"];
|
|
358
385
|
return {
|
|
359
386
|
admin: [
|
|
360
|
-
policy(
|
|
361
|
-
policy(
|
|
362
|
-
policy(
|
|
387
|
+
policy(`${p}:admin:read`, table, "read", { fields: adminRead }),
|
|
388
|
+
policy(`${p}:admin:update`, table, "update", { fields: adminWrite }),
|
|
389
|
+
policy(`${p}:admin:delete`, table, "delete", allow()),
|
|
363
390
|
],
|
|
364
391
|
self: [
|
|
365
|
-
policy(
|
|
366
|
-
policy(
|
|
392
|
+
policy(`${p}:self:read`, table, "read", { where: { username: $identity(idPath) }, fields: selfRead }),
|
|
393
|
+
policy(`${p}:self:update`, table, "update", { where: { username: $identity(idPath) }, fields: selfWrite }),
|
|
367
394
|
],
|
|
368
395
|
};
|
|
369
396
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/auth",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "Optional credential→JWT login for pramen — signup/login/me + PBKDF2 hashing, issuing HS256 tokens the pramen verifier accepts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@pramen/server": "0.0.
|
|
37
|
+
"@pramen/server": "0.0.10"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -282,34 +282,30 @@ export function createMagicLinkAuth(opts: MagicLinkOptions) {
|
|
|
282
282
|
await ctx.db.exec("UPDATE auth_magic_links SET consumedAt = ? WHERE tokenHash = ?", Date.now(), tokenHash);
|
|
283
283
|
|
|
284
284
|
const email = String(link.email);
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
);
|
|
292
|
-
let username: string;
|
|
285
|
+
// Key on the USERNAME (the immutable identity = the JWT sub), NOT the mutable
|
|
286
|
+
// `email` column: a magic-link user's username IS their email address, so this
|
|
287
|
+
// both matches existing users and avoids resolving login by a mutable, unverified
|
|
288
|
+
// field (which would let a changeEmail squat another address — and would miss
|
|
289
|
+
// pre-`email`-column users on upgrade, colliding on the username PK).
|
|
290
|
+
const existing = await ctx.db.exec("SELECT roles, active FROM auth_users WHERE username = ? LIMIT 1", email);
|
|
293
291
|
let roles: string[];
|
|
294
292
|
if (existing.length > 0) {
|
|
295
293
|
if (!isActive(existing[0].active)) throw new Unauthorized("account is deactivated");
|
|
296
|
-
username = String(existing[0].username);
|
|
297
294
|
roles = JSON.parse(String(existing[0].roles)) as string[];
|
|
298
295
|
} else {
|
|
299
|
-
username = email;
|
|
300
296
|
roles = defaultRoles;
|
|
297
|
+
// No email column set: it stays a pure contact attribute (set via changeEmail),
|
|
298
|
+
// so a new passwordless user can never collide with a password user's contact email.
|
|
301
299
|
await ctx.db.exec(
|
|
302
|
-
|
|
303
|
-
"INSERT INTO auth_users (username, passwordHash, roles, email, createdAt) VALUES (?, ?, ?, ?, ?)",
|
|
300
|
+
"INSERT INTO auth_users (username, passwordHash, roles, createdAt) VALUES (?, ?, ?, ?)",
|
|
304
301
|
email,
|
|
305
302
|
"",
|
|
306
303
|
JSON.stringify(roles),
|
|
307
|
-
email,
|
|
308
304
|
Date.now(),
|
|
309
305
|
);
|
|
310
306
|
}
|
|
311
|
-
const token = await signToken({ sub:
|
|
312
|
-
return { token, user: { username, roles } };
|
|
307
|
+
const token = await signToken({ sub: email, roles }, secretOf(ctx), { ttlSeconds: sessionTtl });
|
|
308
|
+
return { token, user: { username: email, roles } };
|
|
313
309
|
},
|
|
314
310
|
{ input: parseLinkToken },
|
|
315
311
|
),
|
|
@@ -343,119 +339,156 @@ function requireUserId(ctx: HandlerContext): string {
|
|
|
343
339
|
}
|
|
344
340
|
|
|
345
341
|
// `ctx.db` is schema-typed against the *app's* composed schema, which this package
|
|
346
|
-
// can't import — so address
|
|
347
|
-
// Db. This is the same ctx.db at runtime: row-scope + field projection still apply.
|
|
348
|
-
interface
|
|
349
|
-
update(table:
|
|
350
|
-
delete(table:
|
|
342
|
+
// can't import — so address the users table through a minimal structural view of the
|
|
343
|
+
// ACL'd Db. This is the same ctx.db at runtime: row-scope + field projection still apply.
|
|
344
|
+
interface UsersDb {
|
|
345
|
+
update(table: string, id: string, patch: Record<string, unknown>): Promise<Record<string, unknown> | undefined>;
|
|
346
|
+
delete(table: string, id: string): Promise<boolean>;
|
|
351
347
|
}
|
|
352
|
-
const usersDb = (ctx: HandlerContext):
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
348
|
+
const usersDb = (ctx: HandlerContext): UsersDb => ctx.db as unknown as UsersDb;
|
|
349
|
+
|
|
350
|
+
// The users table must be a valid SQL identifier (it's interpolated into the raw exec
|
|
351
|
+
// strings below). It's app config, never request input, but guard it anyway.
|
|
352
|
+
function assertIdentifier(table: string): string {
|
|
353
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error(`@pramen/auth: invalid table name ${JSON.stringify(table)}`);
|
|
354
|
+
return table;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Build admin + self-service handlers over a users table (default `auth_users`).
|
|
358
|
+
* Pass `table` to operate over your OWN authSchema-shaped table — e.g. one with an
|
|
359
|
+
* extra `tenants` column — without renaming it: the handlers manage username/roles/
|
|
360
|
+
* email/active/delete and ignore any extra columns. Spread the result into your
|
|
361
|
+
* handler map and gate it with the matching `authPolicies({ table })`. The table must
|
|
362
|
+
* have a `username` primary key and (for changeEmail/changePassword) `email`/
|
|
363
|
+
* `passwordHash` columns. */
|
|
364
|
+
export function createUserHandlers(opts: { table?: string } = {}) {
|
|
365
|
+
const table = assertIdentifier(opts.table ?? "auth_users");
|
|
366
|
+
return {
|
|
367
|
+
/** Admin: list users (ACL projects out passwordHash). A non-admin caller granted
|
|
368
|
+
* only the self policy sees just their own row; ungranted callers get a 403. */
|
|
369
|
+
listUsers: query(async (ctx, input: { limit?: number; offset?: number }) => {
|
|
370
|
+
const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
|
|
371
|
+
const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
|
|
372
|
+
return ctx.db.find({ from: table, orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
|
|
373
|
+
}),
|
|
374
|
+
|
|
375
|
+
/** Admin: replace a user's roles. The ACL admin update policy permits writing
|
|
376
|
+
* `roles`; a self-only caller can't (so this is admin-gated declaratively). */
|
|
377
|
+
setUserRoles: mutation(async (ctx, input: { username: string; roles: string[] }) => {
|
|
378
|
+
if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
|
|
379
|
+
if (!Array.isArray(input.roles) || !input.roles.every((r) => typeof r === "string" && r.length > 0)) {
|
|
380
|
+
throw new BadRequest("roles must be a non-empty string[]");
|
|
381
|
+
}
|
|
382
|
+
const updated = await usersDb(ctx).update(table, input.username, { roles: input.roles });
|
|
383
|
+
if (!updated) throw new BadRequest("user not found"); // (or out of the caller's update scope)
|
|
384
|
+
return updated;
|
|
385
|
+
}),
|
|
386
|
+
|
|
387
|
+
/** Admin: activate / deactivate a user. Deactivating blocks future logins (and
|
|
388
|
+
* token refresh); existing tokens still expire naturally within the TTL. */
|
|
389
|
+
setUserActive: mutation(async (ctx, input: { username: string; active: boolean }) => {
|
|
390
|
+
if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
|
|
391
|
+
if (typeof input?.active !== "boolean") throw new BadRequest("active must be a boolean");
|
|
392
|
+
if (input.active === false && input.username === ctx.identity?.userId) {
|
|
393
|
+
throw new BadRequest("cannot deactivate your own account");
|
|
394
|
+
}
|
|
395
|
+
const updated = await usersDb(ctx).update(table, input.username, { active: input.active });
|
|
396
|
+
if (!updated) throw new BadRequest("user not found");
|
|
397
|
+
return updated;
|
|
398
|
+
}),
|
|
399
|
+
|
|
400
|
+
/** Admin: permanently delete a user. ACL-gated by the admin delete policy; a caller
|
|
401
|
+
* cannot delete their own account. Deactivation (setUserActive) is usually preferable. */
|
|
402
|
+
deleteUser: mutation(async (ctx, input: { username: string }) => {
|
|
403
|
+
if (typeof input?.username !== "string" || input.username.length === 0) throw new BadRequest("username is required");
|
|
404
|
+
if (input.username === ctx.identity?.userId) throw new BadRequest("cannot delete your own account");
|
|
405
|
+
const deleted = await usersDb(ctx).delete(table, input.username);
|
|
406
|
+
if (!deleted) throw new BadRequest("user not found");
|
|
407
|
+
return { ok: true };
|
|
408
|
+
}),
|
|
409
|
+
|
|
410
|
+
/** Self-service: change the caller's contact email. The ACL self policy scopes the
|
|
411
|
+
* write to the caller's own row and permits only the `email` field. Email is unique,
|
|
412
|
+
* so a clash is reported as a clean 400 rather than surfacing the DB constraint as a 500. */
|
|
413
|
+
changeEmail: mutation(async (ctx, input: { email: string }) => {
|
|
414
|
+
const userId = requireUserId(ctx);
|
|
415
|
+
const { email } = parseEmail(input); // validates + normalizes; 400 on a bad address
|
|
416
|
+
const taken = await ctx.db.exec(`SELECT 1 FROM ${table} WHERE email = ? AND username != ? LIMIT 1`, email, userId);
|
|
417
|
+
if (taken.length > 0) throw new BadRequest("email already in use");
|
|
418
|
+
const updated = await usersDb(ctx).update(table, userId, { email });
|
|
419
|
+
if (!updated) throw new Unauthorized("authentication required");
|
|
420
|
+
return updated;
|
|
421
|
+
}),
|
|
422
|
+
|
|
423
|
+
/** Self-service: change the caller's password. A credential op — it reads the
|
|
424
|
+
* caller's OWN hash (passwordHash is never ACL-readable) to verify the current
|
|
425
|
+
* password, then writes the new one. Self-scoped by the verified identity, so it
|
|
426
|
+
* never touches another row; passwordless (magic-link) users have no current
|
|
427
|
+
* password and are rejected. */
|
|
428
|
+
changePassword: mutation(async (ctx, input: { currentPassword: string; newPassword: string }) => {
|
|
429
|
+
const userId = requireUserId(ctx);
|
|
430
|
+
const current = typeof input?.currentPassword === "string" ? input.currentPassword : "";
|
|
431
|
+
const next = typeof input?.newPassword === "string" ? input.newPassword : "";
|
|
432
|
+
if (next.length < 8) throw new BadRequest("newPassword must be at least 8 characters");
|
|
433
|
+
const rows = await ctx.db.exec(`SELECT passwordHash FROM ${table} WHERE username = ? LIMIT 1`, userId);
|
|
434
|
+
const stored = rows[0] ? String(rows[0].passwordHash ?? "") : "";
|
|
435
|
+
if (stored === "" || !(await verifyPassword(current, stored))) {
|
|
436
|
+
throw new Unauthorized("current password is incorrect");
|
|
437
|
+
}
|
|
438
|
+
await ctx.db.exec(`UPDATE ${table} SET passwordHash = ? WHERE username = ?`, await hashPassword(next), userId);
|
|
439
|
+
return { ok: true };
|
|
440
|
+
}),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** The default user-management handlers over `auth_users`. Equivalent to
|
|
445
|
+
* `createUserHandlers()`; spread alongside `authHandlers`. */
|
|
446
|
+
export const userHandlers = createUserHandlers();
|
|
432
447
|
|
|
433
448
|
// Fields a self-service caller may see of their own row (never passwordHash/roles).
|
|
434
449
|
const SELF_READ_FIELDS = ["username", "email", "active", "createdAt"];
|
|
435
450
|
// Fields an admin may see of any user (never passwordHash).
|
|
436
451
|
const ADMIN_READ_FIELDS = ["username", "roles", "email", "active", "createdAt"];
|
|
437
452
|
|
|
438
|
-
/** ACL policy fragments that turn on
|
|
439
|
-
* role and `self` into your authenticated-user role:
|
|
453
|
+
/** ACL policy fragments that turn on the user-management handlers. Spread `admin`
|
|
454
|
+
* into your admin role and `self` into your authenticated-user role:
|
|
440
455
|
*
|
|
441
456
|
* role("admin", [...authPolicies().admin, ...yourAdminPolicies])
|
|
442
457
|
* role("user", [...authPolicies().self, ...yourUserPolicies])
|
|
443
458
|
*
|
|
444
459
|
* `admin` grants read (projected) + update of roles/email/active on every user.
|
|
445
460
|
* `self` grants each user read + email-update of ONLY their own row (matched on the
|
|
446
|
-
* `userId` identity claim). passwordHash is in no policy, so it is never exposed.
|
|
447
|
-
|
|
461
|
+
* `userId` identity claim). passwordHash is in no policy, so it is never exposed.
|
|
462
|
+
*
|
|
463
|
+
* For a custom table, pass the same `table` you gave `createUserHandlers`, a unique
|
|
464
|
+
* `prefix` (policy names must be unique across roles when you wire more than one
|
|
465
|
+
* instance), and `adminReadFields`/`adminWriteFields` to expose/permit extra columns
|
|
466
|
+
* (e.g. a `tenants` column managed by your own setUserTenants handler). */
|
|
467
|
+
export function authPolicies(opts: {
|
|
468
|
+
table?: string;
|
|
469
|
+
identityPath?: string;
|
|
470
|
+
prefix?: string;
|
|
471
|
+
adminReadFields?: string[];
|
|
472
|
+
adminWriteFields?: string[];
|
|
473
|
+
selfReadFields?: string[];
|
|
474
|
+
selfWriteFields?: string[];
|
|
475
|
+
} = {}): { admin: Policy[]; self: Policy[] } {
|
|
448
476
|
const table = opts.table ?? "auth_users";
|
|
449
477
|
const idPath = opts.identityPath ?? "userId";
|
|
478
|
+
const p = opts.prefix ?? "auth";
|
|
479
|
+
const adminRead = opts.adminReadFields ?? ADMIN_READ_FIELDS;
|
|
480
|
+
const adminWrite = opts.adminWriteFields ?? ["roles", "email", "active"];
|
|
481
|
+
const selfRead = opts.selfReadFields ?? SELF_READ_FIELDS;
|
|
482
|
+
const selfWrite = opts.selfWriteFields ?? ["email"];
|
|
450
483
|
return {
|
|
451
484
|
admin: [
|
|
452
|
-
policy(
|
|
453
|
-
policy(
|
|
454
|
-
policy(
|
|
485
|
+
policy(`${p}:admin:read`, table, "read", { fields: adminRead }),
|
|
486
|
+
policy(`${p}:admin:update`, table, "update", { fields: adminWrite }),
|
|
487
|
+
policy(`${p}:admin:delete`, table, "delete", allow()),
|
|
455
488
|
],
|
|
456
489
|
self: [
|
|
457
|
-
policy(
|
|
458
|
-
policy(
|
|
490
|
+
policy(`${p}:self:read`, table, "read", { where: { username: $identity(idPath) }, fields: selfRead }),
|
|
491
|
+
policy(`${p}:self:update`, table, "update", { where: { username: $identity(idPath) }, fields: selfWrite }),
|
|
459
492
|
],
|
|
460
493
|
};
|
|
461
494
|
}
|