@madauth/server 0.3.0-beta.2 → 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-3QRKWLR7.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,email.no_password. 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,
@@ -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,18 +529,32 @@ var Users = class {
466
529
  async passwordAccount(userId) {
467
530
  return this.findAccountByKey(passwordAccountKey(userId));
468
531
  }
469
- /** How the user signs in, from their accounts: e.g. `['google']` for a user without a password. */
470
- async signInMethods(userId) {
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) {
471
546
  const accounts = await this.store.findMany("account", { userId });
472
- const methods = accounts.map((account) => String(account.key).split(":")[0]);
473
- return SIGN_IN_METHODS.filter((method) => methods.includes(method));
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));
474
549
  }
475
550
  async findAccountByKey(key) {
476
551
  return await this.store.findOne("account", { key });
477
552
  }
478
553
  /**
479
- * Creates a user with their first account. Resolves to null if the e-mail address or the account key is
480
- * 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.
481
558
  */
482
559
  async createUser(data) {
483
560
  const user = {
@@ -493,6 +570,7 @@ var Users = class {
493
570
  createdAt: Date.now()
494
571
  };
495
572
  if (!await this.store.create("user", user)) return null;
573
+ if (!data.account) return user;
496
574
  const account = typeof data.account === "function" ? data.account(user.id) : data.account;
497
575
  if (!await this.linkAccount(user.id, account)) {
498
576
  await this.store.delete("user", { id: user.id });
@@ -514,6 +592,16 @@ var Users = class {
514
592
  async deleteAccount(id) {
515
593
  await this.store.delete("account", { id });
516
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
+ }
517
605
  /** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
518
606
  async linkAccount(userId, account) {
519
607
  const record = {
@@ -524,10 +612,70 @@ var Users = class {
524
612
  failedAttempts: 0,
525
613
  lockedUntil: 0,
526
614
  email: account.email ?? null,
615
+ lastUsedStep: account.lastUsedStep ?? 0,
616
+ setupId: account.setupId ?? null,
527
617
  createdAt: Date.now()
528
618
  };
529
619
  return await this.store.create("account", record) ? record : null;
530
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
+ }
531
679
  /**
532
680
  * Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
533
681
  */
@@ -538,6 +686,7 @@ var Users = class {
538
686
  try {
539
687
  await this.store.delete("account", { userId: user.id });
540
688
  await this.store.delete("verification", { userId: user.id });
689
+ await this.store.delete("recoveryCode", { userId: user.id });
541
690
  } catch (e) {
542
691
  console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
543
692
  }
@@ -581,6 +730,11 @@ var Users = class {
581
730
  await this.store.create("verification", record);
582
731
  return { token, code };
583
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
+ }
584
738
  /** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
585
739
  async consumeLinkToken(token, purpose) {
586
740
  const id = hashLinkToken(token);
@@ -634,6 +788,14 @@ export {
634
788
  sameClaims,
635
789
  claimsFromJson,
636
790
  isAdmin,
791
+ sendsVerificationEmails,
792
+ methodConfig,
793
+ envVars,
794
+ resolveEnv,
795
+ ConfigError,
796
+ loadConfig,
797
+ loadUserStoreConfig,
798
+ loadStore,
637
799
  MAX_PASSWORD_LENGTH,
638
800
  hashPassword,
639
801
  verifyPassword,
@@ -642,19 +804,24 @@ export {
642
804
  normalizeEmail,
643
805
  isValidEmail,
644
806
  deriveCodeKey,
807
+ hashCode,
808
+ safeEqual,
645
809
  SIGN_IN_METHODS,
810
+ METHODS_WITH_SECOND_FACTOR,
811
+ SECOND_FACTOR_POLICIES,
646
812
  parseMethodSettings,
813
+ isConfigured,
814
+ canSignUp,
815
+ initialMethods,
647
816
  Settings,
648
817
  isOn,
818
+ policyOf,
649
819
  anyOn,
820
+ authenticatorOn,
821
+ newId,
650
822
  passwordAccountKey,
651
823
  googleAccountKey,
824
+ totpAccountKey,
652
825
  toMadauthUser,
653
- Users,
654
- envVars,
655
- resolveEnv,
656
- ConfigError,
657
- loadConfig,
658
- loadUserStoreConfig,
659
- loadStore
826
+ Users
660
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) {
@@ -11,7 +11,10 @@ var WEBHOOK_TYPES = [
11
11
  "email.password_reset",
12
12
  "user.signed_in",
13
13
  "user.deleted",
14
- "user.claims_changed"
14
+ "user.claims_changed",
15
+ "totp.enabled",
16
+ "totp.disabled",
17
+ "totp.recovery_code_used"
15
18
  ];
16
19
  var WEBHOOK_TIMEOUT_MS = 1e4;
17
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-j5u9Kv9l.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-Dg2fZuWR.js';
1
+ import { E as EntryOptions } from '../config-DkE_2D3-.js';
2
2
  import 'jose';
3
- import '../schema-3yhCc2jH.js';
4
- import '../webhook-j5u9Kv9l.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-DVVBIFIV.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-TI35AZ2U.js";
8
+ } from "../chunk-ISJGOCF3.js";
9
9
  import "../chunk-JDQLSL36.js";
10
- import "../chunk-3QRKWLR7.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-Dg2fZuWR.js';
2
+ import { E as EntryOptions } from '../config-DkE_2D3-.js';
3
3
  import 'jose';
4
- import '../schema-3yhCc2jH.js';
5
- import '../webhook-j5u9Kv9l.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-DVVBIFIV.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-TI35AZ2U.js";
8
+ } from "../chunk-ISJGOCF3.js";
9
9
  import "../chunk-JDQLSL36.js";
10
- import "../chunk-3QRKWLR7.js";
10
+ import "../chunk-Y7ZSUROX.js";
11
11
 
12
12
  // src/entry/lambda.ts
13
13
  import { handle } from "hono/aws-lambda";
@@ -1,7 +1,7 @@
1
- import { E as EntryOptions } from '../config-Dg2fZuWR.js';
1
+ import { E as EntryOptions } from '../config-DkE_2D3-.js';
2
2
  import 'jose';
3
- import '../schema-3yhCc2jH.js';
4
- import '../webhook-j5u9Kv9l.js';
3
+ import '../schema-DTeiSuQB.js';
4
+ import '../webhook-B6P50ocX.js';
5
5
 
6
6
  type StartOptions = EntryOptions;
7
7
  /**