@madauth/server 0.2.0-beta.1 → 0.3.0-beta.1

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.
@@ -8,7 +8,132 @@ import {
8
8
  WEBHOOK_TYPES,
9
9
  checkWebhookSecret,
10
10
  generateWebhookSecret
11
- } from "./chunk-6EJDVZCD.js";
11
+ } from "./chunk-NPNM26J3.js";
12
+
13
+ // src/claims.ts
14
+ var ADMIN_ROLE = "admin";
15
+ var MAX_ROLES = 20;
16
+ var MAX_CLAIMS_LENGTH = 2048;
17
+ var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
18
+ var CLAIM_NAME = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
19
+ function parseRoles(input) {
20
+ if (!Array.isArray(input)) return null;
21
+ if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
22
+ const roles = [...new Set(input)].sort();
23
+ return roles.length <= MAX_ROLES ? roles : null;
24
+ }
25
+ function parseClaims(input) {
26
+ if (!isJsonObject(input)) return null;
27
+ const claims = {};
28
+ for (const key of Object.keys(input).sort()) {
29
+ const value = input[key];
30
+ if (!CLAIM_NAME.test(key) || !isJsonValue(value)) return null;
31
+ if (key === "roles") {
32
+ const roles = parseRoles(value);
33
+ if (!roles) return null;
34
+ claims[key] = roles;
35
+ } else {
36
+ claims[key] = value;
37
+ }
38
+ }
39
+ return JSON.stringify(claims).length <= MAX_CLAIMS_LENGTH ? claims : null;
40
+ }
41
+ function isJsonObject(value) {
42
+ return !!value && typeof value === "object" && !Array.isArray(value);
43
+ }
44
+ function isJsonValue(value) {
45
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
46
+ if (typeof value === "number") return Number.isFinite(value);
47
+ if (Array.isArray(value)) return value.every(isJsonValue);
48
+ return isJsonObject(value) && Object.values(value).every(isJsonValue);
49
+ }
50
+ function sameClaims(a, b) {
51
+ return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
52
+ }
53
+ function claimsFromJson(json) {
54
+ if (!json) return {};
55
+ try {
56
+ const parsed = JSON.parse(json);
57
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
58
+ } catch {
59
+ return {};
60
+ }
61
+ }
62
+ function rolesOf(user) {
63
+ const roles = user.claims?.roles;
64
+ return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
65
+ }
66
+ function isAdmin(user) {
67
+ return rolesOf(user).includes(ADMIN_ROLE);
68
+ }
69
+
70
+ // src/settings.ts
71
+ var SIGN_IN_METHODS = ["google", "password"];
72
+ var METHODS_SETTING = "methods";
73
+ function parseMethodSettings(input) {
74
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
75
+ const settings = {};
76
+ for (const [key, value] of Object.entries(input)) {
77
+ if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
78
+ settings[key] = value;
79
+ }
80
+ return settings;
81
+ }
82
+ var MAX_TRIES = 3;
83
+ var Settings = class {
84
+ constructor(store) {
85
+ this.store = store;
86
+ }
87
+ /** The setting's value, or undefined if it was never set. */
88
+ async get(id) {
89
+ return (await this.read(id)).value;
90
+ }
91
+ /** The stored JSON and what it holds; both undefined if the setting was never set. */
92
+ async read(id) {
93
+ const record = await this.store.findOne("setting", { id });
94
+ if (typeof record?.value !== "string") return {};
95
+ try {
96
+ return { json: record.value, value: JSON.parse(record.value) };
97
+ } catch {
98
+ return { json: record.value };
99
+ }
100
+ }
101
+ /**
102
+ * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
103
+ * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
104
+ * again with the newer value. Resolves to the new value, or null if `apply` refused.
105
+ * `updatedBy` is the address of the admin, or null for the command line.
106
+ */
107
+ async change(id, apply, updatedBy) {
108
+ for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
109
+ const { json, value } = await this.read(id);
110
+ const next = apply(value);
111
+ if (next === null) return null;
112
+ const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
113
+ const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
114
+ if (written) return next;
115
+ }
116
+ throw new Error(`Setting "${id}" kept changing; try again.`);
117
+ }
118
+ /** Replaces the setting. */
119
+ async set(id, value, updatedBy) {
120
+ await this.change(id, () => value, updatedBy);
121
+ }
122
+ /** Which sign-in methods are on; a method that was never switched off is on. */
123
+ async methods() {
124
+ return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
125
+ }
126
+ /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
127
+ async changeMethods(apply, updatedBy) {
128
+ return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
129
+ }
130
+ };
131
+ function isOn(method, available, methods) {
132
+ return available(method) && methods[method] !== false;
133
+ }
134
+ function anyOn(available, methods) {
135
+ return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
136
+ }
12
137
 
13
138
  // src/config.ts
14
139
  var envVars = {
@@ -17,7 +142,7 @@ var envVars = {
17
142
  ALLOWED_ORIGINS: true,
18
143
  GOOGLE_CLIENT_ID: false,
19
144
  GOOGLE_CLIENT_SECRET: false,
20
- DATABASE_URL: false,
145
+ DATABASE_URL: true,
21
146
  PASSWORD_MIN_LENGTH: false,
22
147
  WEBHOOK_URL: false,
23
148
  WEBHOOK_SECRET: false,
@@ -86,19 +211,18 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
86
211
  if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
87
212
  throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
88
213
  }
89
- const sendsEmails = !!(overrides.store || read("DATABASE_URL"));
90
- const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"), sendsEmails);
91
- if (sendsEmails && !webhook) {
214
+ const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
215
+ const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
216
+ const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
217
+ if (!clientId && !password) {
92
218
  throw new ConfigError(
93
- `WEBHOOK_URL is not set. E-mail & password sign-in sends its e-mails through your webhook. For development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered. See "Webhooks" in docs/server.md.`
219
+ `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered. See docs/server.md.`
94
220
  );
95
221
  }
96
222
  const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
97
- let password;
98
- if (store) password = { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")), store };
99
- if (!clientId && !password) {
223
+ if (!store) {
100
224
  throw new ConfigError(
101
- "No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in and/or DATABASE_URL (e.g. sqlite:./madauth.db) for e-mail & password sign-in. See docs/server.md."
225
+ "DATABASE_URL is not set. madAuth stores every user, whichever way they sign in: set e.g. DATABASE_URL=sqlite:./madauth.db or DATABASE_URL=dynamodb:<table>. See docs/server.md."
102
226
  );
103
227
  }
104
228
  const ttlValue = read("SESSION_TTL");
@@ -121,6 +245,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
121
245
  renewalTtlSeconds,
122
246
  cookieDomain: read("COOKIE_DOMAIN"),
123
247
  google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
248
+ store,
124
249
  password,
125
250
  webhook
126
251
  };
@@ -174,8 +299,9 @@ async function dynamoDbStore(tableName) {
174
299
  const { createDynamoDbAdapter } = await import("./dynamodb.js");
175
300
  return createDynamoDbAdapter({ tableName });
176
301
  }
302
+ var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
177
303
  var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
178
- function webhookFromEnv(url, secret, events, sendsEmails) {
304
+ function webhookFromEnv(url, secret, events) {
179
305
  if (!url) {
180
306
  if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
181
307
  return void 0;
@@ -205,24 +331,22 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
205
331
  const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
206
332
  if (!selected.size) {
207
333
  throw new ConfigError(
208
- "WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. " + (sendsEmails ? `E-mail & password sign-in needs ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
334
+ `WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
209
335
 
210
336
  WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
211
337
 
212
- ` : "For example:\n\nWEBHOOK_EVENTS=user.signed_in\n\n") + allTypes
338
+ For one that only wants to know who signed in:
339
+
340
+ WEBHOOK_EVENTS=user.signed_in
341
+
342
+ ` + allTypes
213
343
  );
214
344
  }
215
345
  const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
216
346
  if (unknown.length) {
217
- throw new ConfigError(`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. Known types: ${WEBHOOK_TYPES.join(", ")}.`);
218
- }
219
- const missing = sendsEmails ? REQUIRED_EMAIL_TYPES.filter((type) => !selected.has(type)) : [];
220
- if (missing.length) {
347
+ const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
221
348
  throw new ConfigError(
222
- `WEBHOOK_EVENTS lacks ${missing.join(" and ")}. E-mail & password sign-in can't work without these e-mails, so your webhook receiver must send them:
223
-
224
- WEBHOOK_EVENTS=${[...selected, ...missing].join(",")}
225
- `
349
+ `WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
226
350
  );
227
351
  }
228
352
  return { url: parsed.href, secret, events: selected };
@@ -303,45 +427,6 @@ function safeEqual(a, b) {
303
427
  return x.length === y.length && timingSafeEqual(x, y);
304
428
  }
305
429
 
306
- // src/roles.ts
307
- var ADMIN_ROLE = "admin";
308
- var MAX_ROLES = 20;
309
- var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
310
- function parseRoles(input) {
311
- if (!Array.isArray(input)) return null;
312
- if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
313
- const roles = [...new Set(input)].sort();
314
- return roles.length <= MAX_ROLES ? roles : null;
315
- }
316
- function sameRoles(a, b) {
317
- return a.length === b.length && a.every((role, i) => role === b[i]);
318
- }
319
- var Roles = class {
320
- constructor(store) {
321
- this.store = store;
322
- }
323
- /** The address's roles, sorted; empty if it has none. */
324
- async get(emailNormalized) {
325
- const record = await this.store.findOne("role", { id: emailNormalized });
326
- return typeof record?.roles === "string" && record.roles ? record.roles.split(" ").filter(Boolean) : [];
327
- }
328
- /** Replaces the address's roles. `updatedBy` is the address of the admin, or null for the command line. */
329
- async set(emailNormalized, roles, updatedBy) {
330
- if (!roles.length) {
331
- await this.remove(emailNormalized);
332
- return;
333
- }
334
- const data = { roles: roles.join(" "), updatedAt: Date.now(), updatedBy };
335
- if (await this.store.update("role", { id: emailNormalized }, data) === 1) return;
336
- if (!await this.store.create("role", { id: emailNormalized, ...data })) {
337
- await this.store.update("role", { id: emailNormalized }, data);
338
- }
339
- }
340
- async remove(emailNormalized) {
341
- await this.store.delete("role", { id: emailNormalized });
342
- }
343
- };
344
-
345
430
  // src/users.ts
346
431
  import { randomBytes as randomBytes2 } from "crypto";
347
432
  var MAX_CODE_ATTEMPTS = 5;
@@ -350,6 +435,18 @@ var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
350
435
  function passwordAccountKey(userId) {
351
436
  return `password:${userId}`;
352
437
  }
438
+ function googleAccountKey(sub) {
439
+ return `google:${sub}`;
440
+ }
441
+ function toMadauthUser(user, profile = {}) {
442
+ const result = { id: user.id, email: user.email };
443
+ const name = user.name ?? profile.name;
444
+ if (name) result.name = name;
445
+ if (profile.picture) result.picture = profile.picture;
446
+ const claims = claimsFromJson(user.claims);
447
+ if (Object.keys(claims).length) result.claims = claims;
448
+ return result;
449
+ }
353
450
  var Users = class {
354
451
  /** `codeKey` keys the e-mail codes (see deriveCodeKey); only needed to issue and check them. */
355
452
  constructor(store, codeKey) {
@@ -367,11 +464,16 @@ var Users = class {
367
464
  return await this.store.findOne("user", { id });
368
465
  }
369
466
  async passwordAccount(userId) {
370
- return await this.store.findOne("account", { key: passwordAccountKey(userId) });
467
+ return this.findAccountByKey(passwordAccountKey(userId));
468
+ }
469
+ async findAccountByKey(key) {
470
+ return await this.store.findOne("account", { key });
371
471
  }
372
- /** Creates a user with a password. Resolves to null if the e-mail address is taken. */
373
- async createPasswordUser(data) {
374
- const now = Date.now();
472
+ /**
473
+ * Creates a user with their first account. Resolves to null if the e-mail address or the account key is
474
+ * taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or e.g. `google:<sub>`.
475
+ */
476
+ async createUser(data) {
375
477
  const user = {
376
478
  id: newId("usr"),
377
479
  email: data.email,
@@ -381,39 +483,59 @@ var Users = class {
381
483
  sessionVersion: 0,
382
484
  lastMailAt: 0,
383
485
  wrongCodes: 0,
384
- createdAt: now
486
+ claims: null,
487
+ createdAt: Date.now()
385
488
  };
386
489
  if (!await this.store.create("user", user)) return null;
387
- const account = {
388
- id: newId("acc"),
389
- userId: user.id,
390
- key: passwordAccountKey(user.id),
391
- secret: data.passwordHash,
392
- failedAttempts: 0,
393
- lockedUntil: 0,
394
- createdAt: now
395
- };
396
- if (!await this.store.create("account", account)) {
490
+ const account = typeof data.account === "function" ? data.account(user.id) : data.account;
491
+ if (!await this.linkAccount(user.id, account)) {
397
492
  await this.store.delete("user", { id: user.id });
398
493
  return null;
399
494
  }
400
495
  return user;
401
496
  }
402
497
  /**
403
- * Deletes the user with this e-mail address and everything stored for them. Resolves to the deleted
404
- * user's ID, or null if there is no such user (any more).
498
+ * A provider verified the user's address: an unconfirmed password sign-up with it, whose password nobody
499
+ * has proven, is dropped (the account and its pending links and codes), and the user counts as verified.
500
+ */
501
+ async verifyByProvider(user) {
502
+ if (user.emailVerified) return;
503
+ await this.store.delete("account", { key: passwordAccountKey(user.id) });
504
+ await this.clearVerifications(user.id);
505
+ await this.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
506
+ }
507
+ /** Removes an account record, e.g. one left behind by a deletion that did not finish. */
508
+ async deleteAccount(id) {
509
+ await this.store.delete("account", { id });
510
+ }
511
+ /** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
512
+ async linkAccount(userId, account) {
513
+ const record = {
514
+ id: newId("acc"),
515
+ userId,
516
+ key: account.key,
517
+ secret: account.secret ?? null,
518
+ failedAttempts: 0,
519
+ lockedUntil: 0,
520
+ email: account.email ?? null,
521
+ createdAt: Date.now()
522
+ };
523
+ return await this.store.create("account", record) ? record : null;
524
+ }
525
+ /**
526
+ * Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
405
527
  */
406
- async deleteByEmail(emailNormalized) {
407
- const user = await this.findByEmail(emailNormalized);
408
- if (!user) return null;
409
- if (await this.store.delete("user", { id: user.id }) !== 1) return null;
528
+ async deleteById(id) {
529
+ const user = await this.findById(id);
530
+ if (!user) return false;
531
+ if (await this.store.delete("user", { id: user.id }) !== 1) return false;
410
532
  try {
411
533
  await this.store.delete("account", { userId: user.id });
412
534
  await this.store.delete("verification", { userId: user.id });
413
535
  } catch (e) {
414
536
  console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
415
537
  }
416
- return user.id;
538
+ return true;
417
539
  }
418
540
  async updateUser(id, patch) {
419
541
  await this.store.update("user", { id }, patch);
@@ -499,6 +621,12 @@ var Users = class {
499
621
  };
500
622
 
501
623
  export {
624
+ ADMIN_ROLE,
625
+ parseRoles,
626
+ parseClaims,
627
+ sameClaims,
628
+ claimsFromJson,
629
+ isAdmin,
502
630
  MAX_PASSWORD_LENGTH,
503
631
  hashPassword,
504
632
  verifyPassword,
@@ -507,11 +635,15 @@ export {
507
635
  normalizeEmail,
508
636
  isValidEmail,
509
637
  deriveCodeKey,
510
- ADMIN_ROLE,
511
- parseRoles,
512
- sameRoles,
513
- Roles,
638
+ passwordAccountKey,
639
+ googleAccountKey,
640
+ toMadauthUser,
514
641
  Users,
642
+ SIGN_IN_METHODS,
643
+ parseMethodSettings,
644
+ Settings,
645
+ isOn,
646
+ anyOn,
515
647
  envVars,
516
648
  resolveEnv,
517
649
  ConfigError,