@madauth/server 0.3.0-beta.1 → 0.4.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.
@@ -57,6 +57,7 @@ function userFromClaims(claims) {
57
57
  if (claims.name) user.name = claims.name;
58
58
  if (claims.picture) user.picture = claims.picture;
59
59
  if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
60
+ if (Array.isArray(claims.amr)) user.amr = claims.amr.filter((value) => typeof value === "string");
60
61
  return user;
61
62
  }
62
63
 
@@ -1,6 +1,6 @@
1
1
  // src/store/schema.ts
2
2
  var madauthSchema = {
3
- version: 4,
3
+ version: 5,
4
4
  models: {
5
5
  /** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
6
6
  user: {
@@ -19,7 +19,10 @@ var madauthSchema = {
19
19
  createdAt: { type: "number" }
20
20
  }
21
21
  },
22
- /** How a user signs in. `key` is `password:<userId>` or `google:<sub>`; later e.g. `totp:<userId>`. */
22
+ /**
23
+ * How a user signs in. `key` is `password:<userId>`, `google:<sub>` or `totp:<userId>` (the authenticator
24
+ * app, whose `secret` is encrypted).
25
+ */
23
26
  account: {
24
27
  fields: {
25
28
  id: { type: "string", primaryKey: true },
@@ -30,6 +33,25 @@ var madauthSchema = {
30
33
  lockedUntil: { type: "number" },
31
34
  /** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
32
35
  email: { type: "string", nullable: true },
36
+ /** The time step of the last authenticator code that was accepted, so a code works once; 0 before (since version 5). */
37
+ lastUsedStep: { type: "number" },
38
+ /**
39
+ * The setup an authenticator secret came from; its recovery codes carry the same id, so the app and
40
+ * its codes always belong together. Empty for other accounts (since version 5).
41
+ */
42
+ setupId: { type: "string", nullable: true },
43
+ createdAt: { type: "number" }
44
+ }
45
+ },
46
+ /**
47
+ * A recovery code of a user's authenticator app (since version 5). `id` is the code's keyed hash; the
48
+ * record goes when the code is used. It works only while the account's `setupId` is its own.
49
+ */
50
+ recoveryCode: {
51
+ fields: {
52
+ id: { type: "string", primaryKey: true },
53
+ userId: { type: "string", index: true },
54
+ setupId: { type: "string" },
33
55
  createdAt: { type: "number" }
34
56
  }
35
57
  },
@@ -8,7 +8,7 @@ import {
8
8
  WEBHOOK_TYPES,
9
9
  checkWebhookSecret,
10
10
  generateWebhookSecret
11
- } from "./chunk-NPNM26J3.js";
11
+ } from "./chunk-Y7ZSUROX.js";
12
12
 
13
13
  // src/claims.ts
14
14
  var ADMIN_ROLE = "admin";
@@ -67,75 +67,13 @@ function isAdmin(user) {
67
67
  return rolesOf(user).includes(ADMIN_ROLE);
68
68
  }
69
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;
70
+ // src/config.ts
71
+ function sendsVerificationEmails(config) {
72
+ return !!config.webhook?.events.has("email.verify");
133
73
  }
134
- function anyOn(available, methods) {
135
- return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
74
+ function methodConfig(config) {
75
+ return { google: config.google, password: config.password, emailVerification: sendsVerificationEmails(config) };
136
76
  }
137
-
138
- // src/config.ts
139
77
  var envVars = {
140
78
  MADAUTH_ISSUER: true,
141
79
  MADAUTH_SIGNING_KEY: true,
@@ -144,6 +82,7 @@ var envVars = {
144
82
  GOOGLE_CLIENT_SECRET: false,
145
83
  DATABASE_URL: true,
146
84
  PASSWORD_MIN_LENGTH: false,
85
+ TOTP_ISSUER: false,
147
86
  WEBHOOK_URL: false,
148
87
  WEBHOOK_SECRET: false,
149
88
  WEBHOOK_EVENTS: false,
@@ -214,9 +153,10 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
214
153
  const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
215
154
  const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
216
155
  const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
217
- if (!clientId && !password) {
156
+ const emailVerification = !!webhook && webhook.events.has("email.verify");
157
+ if (!clientId && !password && !emailVerification) {
218
158
  throw new ConfigError(
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.`
159
+ `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails: e-mail & password sign-in needs email.verify and email.reset, sign-up with the authenticator app needs email.verify. 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,email.no_password. See docs/server.md.`
220
160
  );
221
161
  }
222
162
  const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
@@ -244,6 +184,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
244
184
  sessionTtlSeconds,
245
185
  renewalTtlSeconds,
246
186
  cookieDomain: read("COOKIE_DOMAIN"),
187
+ totpIssuer: read("TOTP_ISSUER"),
247
188
  google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
248
189
  store,
249
190
  password,
@@ -333,7 +274,7 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
333
274
  throw new ConfigError(
334
275
  `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:
335
276
 
336
- WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
277
+ WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
337
278
 
338
279
  For one that only wants to know who signed in:
339
280
 
@@ -352,6 +293,125 @@ WEBHOOK_EVENTS=user.signed_in
352
293
  return { url: parsed.href, secret, events: selected };
353
294
  }
354
295
 
296
+ // src/settings.ts
297
+ var SIGN_IN_METHODS = ["google", "password", "totp"];
298
+ var METHODS_WITH_SECOND_FACTOR = ["google", "password"];
299
+ var SECOND_FACTOR_POLICIES = ["none", "optional", "required"];
300
+ var METHODS_SETTING = "methods";
301
+ var isSignInMethod = (value) => SIGN_IN_METHODS.includes(value);
302
+ var isPolicy = (value) => SECOND_FACTOR_POLICIES.includes(value);
303
+ function parseMethodSettings(input, options = {}) {
304
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
305
+ const settings = {};
306
+ for (const [method, value] of Object.entries(input)) {
307
+ if (!isSignInMethod(method)) return null;
308
+ if (typeof value === "boolean") {
309
+ if (!options.booleans) return null;
310
+ if (value) settings[method] = {};
311
+ continue;
312
+ }
313
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
314
+ const setting = {};
315
+ for (const [field, fieldValue] of Object.entries(value)) {
316
+ if (field !== "secondFactor" || method === "totp" || !isPolicy(fieldValue)) return null;
317
+ setting.secondFactor = fieldValue;
318
+ }
319
+ settings[method] = setting;
320
+ }
321
+ return settings;
322
+ }
323
+ function isConfigured(config, method) {
324
+ if (method === "google") return !!config.google;
325
+ if (method === "password") return !!config.password;
326
+ return true;
327
+ }
328
+ function canSignUp(config, method) {
329
+ return method === "totp" ? !!config.emailVerification : isConfigured(config, method);
330
+ }
331
+ function initialMethods(config) {
332
+ const methods = {};
333
+ if (config.google) methods.google = { secondFactor: "none" };
334
+ if (config.password) methods.password = { secondFactor: "none" };
335
+ if (!config.google && !config.password && config.emailVerification) methods.totp = {};
336
+ return methods;
337
+ }
338
+ var MAX_TRIES = 3;
339
+ var Settings = class {
340
+ /** `initial` is what counts until the methods are set, see {@link initialMethods}. */
341
+ constructor(store, initial = {}) {
342
+ this.store = store;
343
+ this.initial = initial;
344
+ this.warned = false;
345
+ }
346
+ /** The setting's value, or undefined if it was never set. */
347
+ async get(id) {
348
+ return (await this.read(id)).value;
349
+ }
350
+ /** The stored JSON and what it holds; both undefined if the setting was never set. */
351
+ async read(id) {
352
+ const record = await this.store.findOne("setting", { id });
353
+ if (typeof record?.value !== "string") return {};
354
+ try {
355
+ return { json: record.value, value: JSON.parse(record.value) };
356
+ } catch {
357
+ return { json: record.value };
358
+ }
359
+ }
360
+ /**
361
+ * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
362
+ * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
363
+ * again with the newer value. Resolves to the new value, or null if `apply` refused.
364
+ * `updatedBy` is the address of the admin, or null for the command line.
365
+ */
366
+ async change(id, apply, updatedBy) {
367
+ for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
368
+ const { json, value } = await this.read(id);
369
+ const next = apply(value);
370
+ if (next === null) return null;
371
+ const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
372
+ const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
373
+ if (written) return next;
374
+ }
375
+ throw new Error(`Setting "${id}" kept changing; try again.`);
376
+ }
377
+ /** Replaces the setting. */
378
+ async set(id, value, updatedBy) {
379
+ await this.change(id, () => value, updatedBy);
380
+ }
381
+ /** Which sign-in methods are on, with their settings; the initial ones until an admin set them. */
382
+ async methods() {
383
+ return this.current(await this.get(METHODS_SETTING));
384
+ }
385
+ /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
386
+ async changeMethods(apply, updatedBy) {
387
+ return this.change(METHODS_SETTING, (current) => apply(this.current(current)), updatedBy);
388
+ }
389
+ /** The stored methods, or the initial ones if nothing (usable) is stored. */
390
+ current(stored) {
391
+ if (stored === void 0) return this.initial;
392
+ const parsed = parseMethodSettings(stored);
393
+ if (parsed) return parsed;
394
+ if (!this.warned) {
395
+ this.warned = true;
396
+ console.warn("[madauth] The stored sign-in methods are not in the form this version expects (they were set by an older one?). Set them again with set-methods; until then the initial methods count.");
397
+ }
398
+ return this.initial;
399
+ }
400
+ };
401
+ function isOn(method, configured, methods) {
402
+ return configured(method) && methods[method] !== void 0;
403
+ }
404
+ function policyOf(method, methods) {
405
+ return methods[method]?.secondFactor ?? "none";
406
+ }
407
+ function anyOn(signsUp, methods) {
408
+ return SIGN_IN_METHODS.some((method) => signsUp(method) && methods[method] !== void 0);
409
+ }
410
+ function authenticatorOn(configured, methods) {
411
+ if (isOn("totp", configured, methods)) return true;
412
+ return METHODS_WITH_SECOND_FACTOR.some((method) => isOn(method, configured, methods) && policyOf(method, methods) !== "none");
413
+ }
414
+
355
415
  // src/password.ts
356
416
  import { createHash, createHmac, hkdfSync, randomBytes, randomInt, scrypt, timingSafeEqual } from "crypto";
357
417
  var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
@@ -438,6 +498,9 @@ function passwordAccountKey(userId) {
438
498
  function googleAccountKey(sub) {
439
499
  return `google:${sub}`;
440
500
  }
501
+ function totpAccountKey(userId) {
502
+ return `totp:${userId}`;
503
+ }
441
504
  function toMadauthUser(user, profile = {}) {
442
505
  const result = { id: user.id, email: user.email };
443
506
  const name = user.name ?? profile.name;
@@ -466,12 +529,32 @@ var Users = class {
466
529
  async passwordAccount(userId) {
467
530
  return this.findAccountByKey(passwordAccountKey(userId));
468
531
  }
532
+ /** The user's authenticator app, if they set one up. */
533
+ async totpAccount(userId) {
534
+ return this.findAccountByKey(totpAccountKey(userId));
535
+ }
536
+ /** Whether the user has any way to sign in; a sign-up with the authenticator app has none until it is set up. */
537
+ async hasAccounts(userId) {
538
+ return (await this.store.findMany("account", { userId }, { limit: 1 })).length > 0;
539
+ }
540
+ /**
541
+ * The ways the user can sign in, from their accounts: e.g. `['google']` for a user without a password.
542
+ * The authenticator app counts only when it signs in on its own (`methods` lists it); as a second
543
+ * factor it is a step of another method, not a way in.
544
+ */
545
+ async signInMethods(userId, methods) {
546
+ const accounts = await this.store.findMany("account", { userId });
547
+ const keys = accounts.map((account) => String(account.key).split(":")[0]);
548
+ return SIGN_IN_METHODS.filter((method) => keys.includes(method) && (method !== "totp" || methods.totp !== void 0));
549
+ }
469
550
  async findAccountByKey(key) {
470
551
  return await this.store.findOne("account", { key });
471
552
  }
472
553
  /**
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>`.
554
+ * Creates a user, with their first account if given. Resolves to null if the e-mail address or the
555
+ * account key is taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or
556
+ * e.g. `google:<sub>`. A sign-up with the authenticator app creates the user without an account: the app
557
+ * is set up once the address is confirmed.
475
558
  */
476
559
  async createUser(data) {
477
560
  const user = {
@@ -487,6 +570,7 @@ var Users = class {
487
570
  createdAt: Date.now()
488
571
  };
489
572
  if (!await this.store.create("user", user)) return null;
573
+ if (!data.account) return user;
490
574
  const account = typeof data.account === "function" ? data.account(user.id) : data.account;
491
575
  if (!await this.linkAccount(user.id, account)) {
492
576
  await this.store.delete("user", { id: user.id });
@@ -508,6 +592,16 @@ var Users = class {
508
592
  async deleteAccount(id) {
509
593
  await this.store.delete("account", { id });
510
594
  }
595
+ /** Removes a sign-in method by its key. Resolves to false if there was none. */
596
+ async unlinkAccount(key) {
597
+ return await this.store.delete("account", { key }) === 1;
598
+ }
599
+ /** Removes the user's authenticator app and recovery codes. Resolves to false if they had no app. */
600
+ async removeTotp(userId) {
601
+ const removed = await this.unlinkAccount(totpAccountKey(userId));
602
+ await this.clearRecoveryCodes(userId);
603
+ return removed;
604
+ }
511
605
  /** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
512
606
  async linkAccount(userId, account) {
513
607
  const record = {
@@ -518,10 +612,70 @@ var Users = class {
518
612
  failedAttempts: 0,
519
613
  lockedUntil: 0,
520
614
  email: account.email ?? null,
615
+ lastUsedStep: account.lastUsedStep ?? 0,
616
+ setupId: account.setupId ?? null,
521
617
  createdAt: Date.now()
522
618
  };
523
619
  return await this.store.create("account", record) ? record : null;
524
620
  }
621
+ /**
622
+ * Writes the secret of a confirmed setup over the authenticator account, only if the account still has the
623
+ * setup it had when this setup started (`replaces`): the write count decides, so of two setups confirmed
624
+ * at once exactly one wins, and the other is told to start again.
625
+ */
626
+ async confirmTotpSetup(accountId, replaces, patch) {
627
+ return await this.store.update("account", { id: accountId, setupId: replaces }, patch) === 1;
628
+ }
629
+ /**
630
+ * Marks the time step of an accepted authenticator code as used and resets the failed attempts, only if
631
+ * no other request accepted a code meanwhile: the write count decides, so the same code sent twice signs
632
+ * in once.
633
+ */
634
+ async useTotpStep(account, step) {
635
+ const changed = await this.store.update(
636
+ "account",
637
+ { id: account.id, lastUsedStep: account.lastUsedStep ?? null },
638
+ { lastUsedStep: step, failedAttempts: 0, lockedUntil: 0 }
639
+ );
640
+ return changed === 1;
641
+ }
642
+ /** Replaces the user's recovery codes with new ones, given as their hashes. */
643
+ /** Stores the recovery codes of a setup (their hashes), next to whatever codes the user has. */
644
+ async addRecoveryCodes(userId, setupId, hashes) {
645
+ const createdAt = Date.now();
646
+ for (const id of hashes) await this.store.create("recoveryCode", { id, userId, setupId, createdAt });
647
+ }
648
+ /** New recovery codes for the user's current app: the codes they had before stop working. */
649
+ async replaceRecoveryCodes(account, hashes) {
650
+ const before = await this.store.findMany("recoveryCode", { userId: account.userId });
651
+ await this.addRecoveryCodes(account.userId, account.setupId ?? "", hashes);
652
+ for (const old of before) await this.store.delete("recoveryCode", { id: old.id });
653
+ }
654
+ /**
655
+ * Uses up the recovery code with this hash for the user's authenticator account. Resolves to false if it
656
+ * is not theirs, belongs to another setup than the account's, or was used already.
657
+ */
658
+ async consumeRecoveryCode(account, hash) {
659
+ const record = await this.store.findOne("recoveryCode", { id: hash });
660
+ if (!record || record.userId !== account.userId || record.setupId !== (account.setupId ?? "")) return false;
661
+ return await this.store.delete("recoveryCode", { id: hash }) === 1;
662
+ }
663
+ /** How many recovery codes of the account's setup are left. */
664
+ async countRecoveryCodes(account) {
665
+ const codes = await this.store.findMany("recoveryCode", { userId: account.userId });
666
+ return codes.filter((code) => code.setupId === (account.setupId ?? "")).length;
667
+ }
668
+ /** Deletes the user's recovery codes: all of them, or only those of one setup, or all but one setup's. */
669
+ async clearRecoveryCodes(userId, which = {}) {
670
+ if (which.only === void 0 && which.except === void 0) {
671
+ await this.store.delete("recoveryCode", { userId });
672
+ return;
673
+ }
674
+ for (const code of await this.store.findMany("recoveryCode", { userId })) {
675
+ const match = which.only !== void 0 ? code.setupId === which.only : code.setupId !== which.except;
676
+ if (match) await this.store.delete("recoveryCode", { id: code.id });
677
+ }
678
+ }
525
679
  /**
526
680
  * Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
527
681
  */
@@ -532,6 +686,7 @@ var Users = class {
532
686
  try {
533
687
  await this.store.delete("account", { userId: user.id });
534
688
  await this.store.delete("verification", { userId: user.id });
689
+ await this.store.delete("recoveryCode", { userId: user.id });
535
690
  } catch (e) {
536
691
  console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
537
692
  }
@@ -540,8 +695,9 @@ var Users = class {
540
695
  async updateUser(id, patch) {
541
696
  await this.store.update("user", { id }, patch);
542
697
  }
698
+ /** Changes an account. Resolves to false if it is gone meanwhile. */
543
699
  async updateAccount(id, patch) {
544
- await this.store.update("account", { id }, patch);
700
+ return await this.store.update("account", { id }, patch) === 1;
545
701
  }
546
702
  /**
547
703
  * Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
@@ -574,6 +730,11 @@ var Users = class {
574
730
  await this.store.create("verification", record);
575
731
  return { token, code };
576
732
  }
733
+ /** The user ID a link token belongs to, without using the verification up; null if invalid, expired or used. */
734
+ async peekLinkToken(token, purpose) {
735
+ const record = await this.store.findOne("verification", { id: hashLinkToken(token), purpose });
736
+ return record && record.expiresAt > Date.now() ? record.userId : null;
737
+ }
577
738
  /** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
578
739
  async consumeLinkToken(token, purpose) {
579
740
  const id = hashLinkToken(token);
@@ -627,6 +788,14 @@ export {
627
788
  sameClaims,
628
789
  claimsFromJson,
629
790
  isAdmin,
791
+ sendsVerificationEmails,
792
+ methodConfig,
793
+ envVars,
794
+ resolveEnv,
795
+ ConfigError,
796
+ loadConfig,
797
+ loadUserStoreConfig,
798
+ loadStore,
630
799
  MAX_PASSWORD_LENGTH,
631
800
  hashPassword,
632
801
  verifyPassword,
@@ -635,19 +804,24 @@ export {
635
804
  normalizeEmail,
636
805
  isValidEmail,
637
806
  deriveCodeKey,
638
- passwordAccountKey,
639
- googleAccountKey,
640
- toMadauthUser,
641
- Users,
807
+ hashCode,
808
+ safeEqual,
642
809
  SIGN_IN_METHODS,
810
+ METHODS_WITH_SECOND_FACTOR,
811
+ SECOND_FACTOR_POLICIES,
643
812
  parseMethodSettings,
813
+ isConfigured,
814
+ canSignUp,
815
+ initialMethods,
644
816
  Settings,
645
817
  isOn,
818
+ policyOf,
646
819
  anyOn,
647
- envVars,
648
- resolveEnv,
649
- ConfigError,
650
- loadConfig,
651
- loadUserStoreConfig,
652
- loadStore
820
+ authenticatorOn,
821
+ newId,
822
+ passwordAccountKey,
823
+ googleAccountKey,
824
+ totpAccountKey,
825
+ toMadauthUser,
826
+ Users
653
827
  };
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  madauthSchema
3
- } from "./chunk-CD7Y4T66.js";
3
+ } from "./chunk-GA4TF7EI.js";
4
4
 
5
5
  // src/store/sql.ts
6
6
  function tableName(model) {
7
- return `madauth_${model}`;
7
+ return `madauth_${columnName(model)}`;
8
8
  }
9
9
  function columnName(field) {
10
10
  return field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
@@ -45,6 +45,15 @@ var upgrades = [
45
45
  `DROP TABLE ${tableName("role")};`,
46
46
  createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
47
47
  ].join("\n")
48
+ },
49
+ {
50
+ // The authenticator app: which code was used last, and the recovery codes.
51
+ version: 5,
52
+ sql: (dialect) => [
53
+ `ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("lastUsedStep")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`,
54
+ `ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("setupId")} ${columnTypes[dialect].string};`,
55
+ createTablesSql(dialect, { version: 5, models: { recoveryCode: madauthSchema.models.recoveryCode } })
56
+ ].join("\n")
48
57
  }
49
58
  ];
50
59
  function upgradeTablesSql(dialect, fromVersion) {
@@ -4,13 +4,17 @@ var WEBHOOK_TYPES = [
4
4
  "email.verify",
5
5
  "email.reset",
6
6
  "email.already_registered",
7
+ "email.no_password",
7
8
  "signup.before",
8
9
  "user.created",
9
10
  "email.verified",
10
11
  "email.password_reset",
11
12
  "user.signed_in",
12
13
  "user.deleted",
13
- "user.claims_changed"
14
+ "user.claims_changed",
15
+ "totp.enabled",
16
+ "totp.disabled",
17
+ "totp.recovery_code_used"
14
18
  ];
15
19
  var WEBHOOK_TIMEOUT_MS = 1e4;
16
20
  var MAX_LOCALE_LENGTH = 35;
@@ -1,6 +1,6 @@
1
1
  import { JWK, JWTVerifyGetKey } from 'jose';
2
- import { S as StoreAdapter } from './schema-3yhCc2jH.js';
3
- import { W as WebhookSettings } from './webhook-DCDP6SaE.js';
2
+ import { S as StoreAdapter } from './schema-DTeiSuQB.js';
3
+ import { W as WebhookSettings } from './webhook-B6P50ocX.js';
4
4
 
5
5
  interface MadauthConfig {
6
6
  /** Public base URL of the server (MADAUTH_ISSUER). */
@@ -15,6 +15,8 @@ interface MadauthConfig {
15
15
  renewalTtlSeconds: number;
16
16
  /** Cookie domain, e.g. `.example.com` (COOKIE_DOMAIN). */
17
17
  cookieDomain?: string;
18
+ /** The name authenticator apps show next to the account (TOTP_ISSUER). Default: the host of the app that asked for the setup. */
19
+ totpIssuer?: string;
18
20
  /** Google sign-in; off when GOOGLE_CLIENT_ID is not set. */
19
21
  google?: {
20
22
  /** OAuth web client ID (GOOGLE_CLIENT_ID). */
@@ -45,6 +47,7 @@ declare const envVars: {
45
47
  readonly GOOGLE_CLIENT_SECRET: false;
46
48
  readonly DATABASE_URL: true;
47
49
  readonly PASSWORD_MIN_LENGTH: false;
50
+ readonly TOTP_ISSUER: false;
48
51
  readonly WEBHOOK_URL: false;
49
52
  readonly WEBHOOK_SECRET: false;
50
53
  readonly WEBHOOK_EVENTS: false;
package/dist/dynamodb.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  madauthSchema
3
- } from "./chunk-CD7Y4T66.js";
3
+ } from "./chunk-GA4TF7EI.js";
4
4
 
5
5
  // src/store/dynamodb.ts
6
6
  var SORT_KEY = "#";
@@ -1,7 +1,7 @@
1
- import { E as EntryOptions } from '../config-DEBYi8hb.js';
1
+ import { E as EntryOptions } from '../config-DkE_2D3-.js';
2
2
  import 'jose';
3
- import '../schema-3yhCc2jH.js';
4
- import '../webhook-DCDP6SaE.js';
3
+ import '../schema-DTeiSuQB.js';
4
+ import '../webhook-B6P50ocX.js';
5
5
 
6
6
  /**
7
7
  * Registers madAuth as an Azure Functions v4 HTTP function that handles every route. Pass `store` to use your
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  createApp
3
- } from "../chunk-ZSBKNV5W.js";
4
- import "../chunk-DCDTO5LG.js";
3
+ } from "../chunk-3PACOYTG.js";
4
+ import "../chunk-A7UYZRBL.js";
5
5
  import {
6
6
  loadConfig,
7
7
  resolveEnv
8
- } from "../chunk-ODITZLMM.js";
8
+ } from "../chunk-ISJGOCF3.js";
9
9
  import "../chunk-JDQLSL36.js";
10
- import "../chunk-NPNM26J3.js";
10
+ import "../chunk-Y7ZSUROX.js";
11
11
 
12
12
  // src/entry/azure.ts
13
13
  import { app } from "@azure/functions";
@@ -1,8 +1,8 @@
1
1
  import { LambdaEvent, LambdaContext, handle } from 'hono/aws-lambda';
2
- import { E as EntryOptions } from '../config-DEBYi8hb.js';
2
+ import { E as EntryOptions } from '../config-DkE_2D3-.js';
3
3
  import 'jose';
4
- import '../schema-3yhCc2jH.js';
5
- import '../webhook-DCDP6SaE.js';
4
+ import '../schema-DTeiSuQB.js';
5
+ import '../webhook-B6P50ocX.js';
6
6
 
7
7
  type LambdaHandler = (event: LambdaEvent, context: LambdaContext) => ReturnType<ReturnType<typeof handle>>;
8
8
  /**
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  createApp
3
- } from "../chunk-ZSBKNV5W.js";
4
- import "../chunk-DCDTO5LG.js";
3
+ } from "../chunk-3PACOYTG.js";
4
+ import "../chunk-A7UYZRBL.js";
5
5
  import {
6
6
  loadConfig,
7
7
  resolveEnv
8
- } from "../chunk-ODITZLMM.js";
8
+ } from "../chunk-ISJGOCF3.js";
9
9
  import "../chunk-JDQLSL36.js";
10
- import "../chunk-NPNM26J3.js";
10
+ import "../chunk-Y7ZSUROX.js";
11
11
 
12
12
  // src/entry/lambda.ts
13
13
  import { handle } from "hono/aws-lambda";