@madauth/server 0.4.0-beta.1 → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  // src/store/schema.ts
2
2
  var madauthSchema = {
3
- version: 5,
3
+ version: 7,
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: {
@@ -11,17 +11,27 @@ var madauthSchema = {
11
11
  emailVerified: { type: "boolean" },
12
12
  name: { type: "string", nullable: true },
13
13
  sessionVersion: { type: "number" },
14
+ /** When the last e-mail was sent to the user: at most one per minute. */
14
15
  lastMailAt: { type: "number" },
16
+ /** When the last SMS was sent to the user: at most one per minute, counted apart from the e-mails (since version 6). */
17
+ lastSmsAt: { type: "number", nullable: true },
15
18
  /** Wrong e-mail codes in a row, counted across e-mails (since version 2). */
16
19
  wrongCodes: { type: "number" },
17
20
  /** What admins attached to the user, as JSON, e.g. `{"roles":["admin"]}` (since version 4). */
18
21
  claims: { type: "string", nullable: true },
22
+ /**
23
+ * When the user's first session started: the sign-up was complete, with every second factor the
24
+ * policy asked for. 0 until then. Empty in records from before version 7, whose sign-up counts as
25
+ * complete (since version 7).
26
+ */
27
+ signedUpAt: { type: "number", nullable: true },
19
28
  createdAt: { type: "number" }
20
29
  }
21
30
  },
22
31
  /**
23
- * How a user signs in. `key` is `password:<userId>`, `google:<sub>` or `totp:<userId>` (the authenticator
24
- * app, whose `secret` is encrypted).
32
+ * How a user signs in. `key` is `password:<userId>`, `google:<sub>`, `totp:<userId>` (the authenticator
33
+ * app, whose `secret` is encrypted), `email-link:<userId>` or `sms:<userId>` (the phone that gets the
34
+ * codes of the second factor).
25
35
  */
26
36
  account: {
27
37
  fields: {
@@ -40,6 +50,19 @@ var madauthSchema = {
40
50
  * its codes always belong together. Empty for other accounts (since version 5).
41
51
  */
42
52
  setupId: { type: "string", nullable: true },
53
+ /** The phone number of an `sms:` account, in E.164 form (`+41791234567`). Empty for other accounts (since version 6). */
54
+ phone: { type: "string", nullable: true },
55
+ /**
56
+ * The user's id when the account is how they sign in (their one primary method), empty for a second
57
+ * factor. Unique, so a user has one primary method at most (since version 7; empty in older records).
58
+ */
59
+ primaryUserId: { type: "string", nullable: true, unique: true },
60
+ /**
61
+ * Whether the account is a second factor (an authenticator app that is a step of the user's sign-in
62
+ * method, or a phone for SMS codes) rather than a sign-in method. Empty in records from before version 7,
63
+ * which madAuth tells apart by their key (since version 7).
64
+ */
65
+ secondFactor: { type: "boolean", nullable: true },
43
66
  createdAt: { type: "number" }
44
67
  }
45
68
  },
@@ -55,7 +78,7 @@ var madauthSchema = {
55
78
  createdAt: { type: "number" }
56
79
  }
57
80
  },
58
- /** A pending e-mail verification or password reset. `id` is the SHA-256 of the link token. */
81
+ /** A pending e-mail verification, password reset, sign-in link or SMS code. `id` is the SHA-256 of the link token. */
59
82
  verification: {
60
83
  fields: {
61
84
  id: { type: "string", primaryKey: true },
@@ -8,7 +8,7 @@ import {
8
8
  WEBHOOK_TYPES,
9
9
  checkWebhookSecret,
10
10
  generateWebhookSecret
11
- } from "./chunk-Y7ZSUROX.js";
11
+ } from "./chunk-SISM7YUZ.js";
12
12
 
13
13
  // src/claims.ts
14
14
  var ADMIN_ROLE = "admin";
@@ -69,10 +69,23 @@ function isAdmin(user) {
69
69
 
70
70
  // src/config.ts
71
71
  function sendsVerificationEmails(config) {
72
- return !!config.webhook?.events.has("email.verify");
72
+ return !!config.webhook?.events.has("email.send_verification");
73
+ }
74
+ function sendsSignInEmails(config) {
75
+ return !!config.webhook?.events.has("email.send_signin_link");
76
+ }
77
+ function sendsSms(config) {
78
+ return !!config.webhook?.events.has("sms.send_code");
73
79
  }
74
80
  function methodConfig(config) {
75
- return { google: config.google, password: config.password, emailVerification: sendsVerificationEmails(config) };
81
+ return {
82
+ google: config.google,
83
+ github: config.github,
84
+ password: config.password,
85
+ emailVerification: sendsVerificationEmails(config),
86
+ emailLink: sendsSignInEmails(config),
87
+ sms: sendsSms(config)
88
+ };
76
89
  }
77
90
  var envVars = {
78
91
  MADAUTH_ISSUER: true,
@@ -80,8 +93,9 @@ var envVars = {
80
93
  ALLOWED_ORIGINS: true,
81
94
  GOOGLE_CLIENT_ID: false,
82
95
  GOOGLE_CLIENT_SECRET: false,
96
+ GITHUB_CLIENT_ID: false,
97
+ GITHUB_CLIENT_SECRET: false,
83
98
  DATABASE_URL: true,
84
- PASSWORD_MIN_LENGTH: false,
85
99
  TOTP_ISSUER: false,
86
100
  WEBHOOK_URL: false,
87
101
  WEBHOOK_SECRET: false,
@@ -95,7 +109,6 @@ async function resolveEnv(source = process.env) {
95
109
  }
96
110
  var DEFAULT_SESSION_TTL = 8 * 60 * 60;
97
111
  var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
98
- var DEFAULT_PASSWORD_MIN_LENGTH = 8;
99
112
  var ConfigError = class extends Error {
100
113
  constructor() {
101
114
  super(...arguments);
@@ -124,6 +137,11 @@ async function loadConfig(env, overrides = {}) {
124
137
  if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
125
138
  return value;
126
139
  };
140
+ if (env.PASSWORD_MIN_LENGTH?.trim()) {
141
+ throw new ConfigError(
142
+ `PASSWORD_MIN_LENGTH was removed: the password rules are a setting now (default: at least 8 characters). Remove the variable and set the rules with \`npx @madauth/server set-password-rules min-length=${env.PASSWORD_MIN_LENGTH.trim()}\` or Madauth.admin.setSettings. See docs/upgrading.md.`
143
+ );
144
+ }
127
145
  const issuerValue = require2("MADAUTH_ISSUER");
128
146
  parseOrigin("MADAUTH_ISSUER", issuerValue);
129
147
  const issuer = issuerValue.replace(/\/+$/, "");
@@ -150,13 +168,21 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
150
168
  if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
151
169
  throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
152
170
  }
171
+ const githubClientId = read("GITHUB_CLIENT_ID");
172
+ const githubClientSecret = read("GITHUB_CLIENT_SECRET");
173
+ if (githubClientId && !githubClientSecret) {
174
+ throw new ConfigError(
175
+ "GITHUB_CLIENT_ID needs GITHUB_CLIENT_SECRET: GitHub only has the redirect flow, in which the server exchanges the code. Set both from your OAuth App at https://github.com/settings/developers."
176
+ );
177
+ }
153
178
  const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
154
179
  const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
155
- const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
156
- const emailVerification = !!webhook && webhook.events.has("email.verify");
157
- if (!clientId && !password && !emailVerification) {
180
+ const password = sendsEmails ? {} : void 0;
181
+ const emailVerification = !!webhook && webhook.events.has("email.send_verification");
182
+ const emailLink = !!webhook && webhook.events.has("email.send_signin_link");
183
+ if (!clientId && !githubClientId && !password && !emailVerification && !emailLink) {
158
184
  throw new ConfigError(
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.`
185
+ `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET for GitHub sign-in, and/or a webhook that sends the e-mails: e-mail & password sign-in needs email.send_verification and email.send_password_reset, the passwordless e-mail link needs email.send_signin_link, sign-up with the authenticator app needs email.send_verification. 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.send_signin_link,email.send_already_registered,email.send_password_reset_no_password. See docs/server.md.`
160
186
  );
161
187
  }
162
188
  const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
@@ -186,26 +212,17 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
186
212
  cookieDomain: read("COOKIE_DOMAIN"),
187
213
  totpIssuer: read("TOTP_ISSUER"),
188
214
  google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
215
+ github: githubClientId && githubClientSecret ? { clientId: githubClientId, clientSecret: githubClientSecret } : void 0,
189
216
  store,
190
217
  password,
191
218
  webhook
192
219
  };
193
220
  }
194
- async function loadUserStoreConfig(env) {
195
- return { store: await loadStore(env), minLength: passwordMinLength(readEnv(env, "PASSWORD_MIN_LENGTH")) };
196
- }
197
221
  async function loadStore(env) {
198
222
  const store = await storeFromDatabaseUrl(readEnv(env, "DATABASE_URL"));
199
223
  if (!store) throw new ConfigError("Set DATABASE_URL to manage users.");
200
224
  return store;
201
225
  }
202
- function passwordMinLength(value) {
203
- const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
204
- if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
205
- throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
206
- }
207
- return minLength;
208
- }
209
226
  async function storeFromDatabaseUrl(url) {
210
227
  if (!url) return void 0;
211
228
  if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
@@ -240,7 +257,27 @@ async function dynamoDbStore(tableName) {
240
257
  const { createDynamoDbAdapter } = await import("./dynamodb.js");
241
258
  return createDynamoDbAdapter({ tableName });
242
259
  }
243
- var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
260
+ var RENAMED_TYPES = {
261
+ "password.reset": "user.password_reset",
262
+ "roles.changed": "user.claims_changed",
263
+ "email.verify": "email.send_verification",
264
+ "email.reset": "email.send_password_reset",
265
+ "email.signin": "email.send_signin_link",
266
+ "email.already_registered": "email.send_already_registered",
267
+ "email.no_password": "email.send_password_reset_no_password",
268
+ "sms.code": "sms.send_code",
269
+ "signup.before": "user.signing_up",
270
+ "email.verified": "user.email_verified",
271
+ "email.password_reset": "user.password_reset",
272
+ "totp.enabled": 'user.second_factor_enabled (with method "totp")',
273
+ "totp.disabled": 'user.second_factor_disabled (with method "totp")',
274
+ "sms.enabled": 'user.second_factor_enabled (with method "sms")',
275
+ "sms.disabled": 'user.second_factor_disabled (with method "sms")',
276
+ "totp.recovery_code_used": "user.recovery_code_used"
277
+ };
278
+ var REMOVED_TYPES = {
279
+ "user.created": "user.created is gone: user.signed_up tells when a user can sign in (an unconfirmed sign-up is no user yet)"
280
+ };
244
281
  var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
245
282
  function webhookFromEnv(url, secret, events) {
246
283
  if (!url) {
@@ -272,11 +309,11 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
272
309
  const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
273
310
  if (!selected.size) {
274
311
  throw new ConfigError(
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:
312
+ `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 ")}, the passwordless e-mail link when they include email.send_signin_link. For a receiver that sends the e-mails:
276
313
 
277
- WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
314
+ WEBHOOK_EVENTS=email.send_verification,email.send_password_reset,email.send_signin_link,email.send_already_registered,email.send_password_reset_no_password
278
315
 
279
- For one that only wants to know who signed in:
316
+ SMS codes as a second factor need a receiver that sends the SMS: add sms.send_code,user.second_factor_enabled,user.second_factor_disabled. For one that only wants to know who signed in:
280
317
 
281
318
  WEBHOOK_EVENTS=user.signed_in
282
319
 
@@ -285,7 +322,7 @@ WEBHOOK_EVENTS=user.signed_in
285
322
  }
286
323
  const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
287
324
  if (unknown.length) {
288
- const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
325
+ const renamed = unknown.flatMap((e) => e in RENAMED_TYPES ? [`${e} is now ${RENAMED_TYPES[e]}`] : e in REMOVED_TYPES ? [REMOVED_TYPES[e]] : []);
289
326
  throw new ConfigError(
290
327
  `WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
291
328
  );
@@ -294,17 +331,31 @@ WEBHOOK_EVENTS=user.signed_in
294
331
  }
295
332
 
296
333
  // src/settings.ts
297
- var SIGN_IN_METHODS = ["google", "password", "totp"];
298
- var METHODS_WITH_SECOND_FACTOR = ["google", "password"];
334
+ var SIGN_IN_METHODS = ["google", "github", "password", "totp", "email-link"];
335
+ var METHODS = [...SIGN_IN_METHODS, "sms"];
336
+ var METHODS_WITH_SECOND_FACTOR = ["google", "github", "password", "email-link"];
299
337
  var SECOND_FACTOR_POLICIES = ["none", "optional", "required"];
300
338
  var METHODS_SETTING = "methods";
301
- var isSignInMethod = (value) => SIGN_IN_METHODS.includes(value);
339
+ var PASSWORD_SETTING = "password";
340
+ var DEFAULT_PASSWORD_RULES = { minLength: 8, minScore: 2 };
341
+ var MAX_MIN_LENGTH = 128;
342
+ var isPasswordScore = (value) => Number.isInteger(value) && value >= 0 && value <= 4;
343
+ function parsePasswordRules(input) {
344
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
345
+ const { minLength = DEFAULT_PASSWORD_RULES.minLength, minScore = DEFAULT_PASSWORD_RULES.minScore, ...rest } = input;
346
+ if (Object.keys(rest).length) return null;
347
+ if (!Number.isInteger(minLength) || minLength < 1 || minLength > MAX_MIN_LENGTH) return null;
348
+ if (!isPasswordScore(minScore)) return null;
349
+ return { minLength, minScore };
350
+ }
351
+ var isMethodId = (value) => METHODS.includes(value);
352
+ var hasSecondFactor = (method) => METHODS_WITH_SECOND_FACTOR.includes(method);
302
353
  var isPolicy = (value) => SECOND_FACTOR_POLICIES.includes(value);
303
354
  function parseMethodSettings(input, options = {}) {
304
355
  if (!input || typeof input !== "object" || Array.isArray(input)) return null;
305
356
  const settings = {};
306
357
  for (const [method, value] of Object.entries(input)) {
307
- if (!isSignInMethod(method)) return null;
358
+ if (!isMethodId(method)) return null;
308
359
  if (typeof value === "boolean") {
309
360
  if (!options.booleans) return null;
310
361
  if (value) settings[method] = {};
@@ -313,7 +364,7 @@ function parseMethodSettings(input, options = {}) {
313
364
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
314
365
  const setting = {};
315
366
  for (const [field, fieldValue] of Object.entries(value)) {
316
- if (field !== "secondFactor" || method === "totp" || !isPolicy(fieldValue)) return null;
367
+ if (field !== "secondFactor" || !hasSecondFactor(method) || !isPolicy(fieldValue)) return null;
317
368
  setting.secondFactor = fieldValue;
318
369
  }
319
370
  settings[method] = setting;
@@ -322,7 +373,10 @@ function parseMethodSettings(input, options = {}) {
322
373
  }
323
374
  function isConfigured(config, method) {
324
375
  if (method === "google") return !!config.google;
376
+ if (method === "github") return !!config.github;
325
377
  if (method === "password") return !!config.password;
378
+ if (method === "email-link") return !!config.emailLink;
379
+ if (method === "sms") return !!config.sms;
326
380
  return true;
327
381
  }
328
382
  function canSignUp(config, method) {
@@ -331,8 +385,11 @@ function canSignUp(config, method) {
331
385
  function initialMethods(config) {
332
386
  const methods = {};
333
387
  if (config.google) methods.google = { secondFactor: "none" };
388
+ if (config.github) methods.github = { secondFactor: "none" };
334
389
  if (config.password) methods.password = { secondFactor: "none" };
335
- if (!config.google && !config.password && config.emailVerification) methods.totp = {};
390
+ if (config.emailLink) methods["email-link"] = { secondFactor: "none" };
391
+ if (!config.google && !config.github && !config.password && !config.emailLink && config.emailVerification) methods.totp = {};
392
+ if (config.sms) methods.sms = {};
336
393
  return methods;
337
394
  }
338
395
  var MAX_TRIES = 3;
@@ -342,6 +399,7 @@ var Settings = class {
342
399
  this.store = store;
343
400
  this.initial = initial;
344
401
  this.warned = false;
402
+ this.warnedRules = false;
345
403
  }
346
404
  /** The setting's value, or undefined if it was never set. */
347
405
  async get(id) {
@@ -386,6 +444,23 @@ var Settings = class {
386
444
  async changeMethods(apply, updatedBy) {
387
445
  return this.change(METHODS_SETTING, (current) => apply(this.current(current)), updatedBy);
388
446
  }
447
+ /** The rules for new passwords; the defaults until an admin set them. */
448
+ async passwordRules() {
449
+ const stored = await this.get(PASSWORD_SETTING);
450
+ if (stored === void 0) return DEFAULT_PASSWORD_RULES;
451
+ const parsed = parsePasswordRules(stored);
452
+ if (parsed) return parsed;
453
+ if (!this.warnedRules) {
454
+ this.warnedRules = true;
455
+ console.warn("[madauth] The stored password rules are not in the form this version expects. Set them again with set-password-rules; until then the defaults count.");
456
+ }
457
+ return DEFAULT_PASSWORD_RULES;
458
+ }
459
+ /** Replaces the rules for new passwords. */
460
+ async setPasswordRules(rules, updatedBy) {
461
+ await this.set(PASSWORD_SETTING, rules, updatedBy);
462
+ return rules;
463
+ }
389
464
  /** The stored methods, or the initial ones if nothing (usable) is stored. */
390
465
  current(stored) {
391
466
  if (stored === void 0) return this.initial;
@@ -414,6 +489,23 @@ function authenticatorOn(configured, methods) {
414
489
 
415
490
  // src/password.ts
416
491
  import { createHash, createHmac, hkdfSync, randomBytes, randomInt, scrypt, timingSafeEqual } from "crypto";
492
+
493
+ // src/strength.ts
494
+ var RATED_LENGTH = 64;
495
+ var loading;
496
+ async function passwordScore(password, userInputs = []) {
497
+ loading ??= Promise.all([import("@zxcvbn-ts/core"), import("@zxcvbn-ts/language-common")]).then(
498
+ ([core, common]) => new core.ZxcvbnFactory({
499
+ dictionary: common.dictionary,
500
+ graphs: common.adjacencyGraphs,
501
+ useLevenshteinDistance: true,
502
+ maxLength: RATED_LENGTH
503
+ })
504
+ );
505
+ return (await loading).check(password, userInputs).score;
506
+ }
507
+
508
+ // src/password.ts
417
509
  var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
418
510
  var KEY_LENGTH = 64;
419
511
  var SALT_LENGTH = 16;
@@ -453,11 +545,17 @@ async function verifyAgainstDummy(password) {
453
545
  dummyHash ??= hashPassword(randomBytes(16).toString("hex"));
454
546
  await verifyPassword(password, await dummyHash);
455
547
  }
456
- function checkPasswordPolicy(password, minLength) {
457
- if (typeof password !== "string") return "password is missing";
548
+ async function checkPasswordPolicy(password, rules, userInputs = []) {
549
+ if (typeof password !== "string") return { message: "password is missing" };
458
550
  const length = [...password].length;
459
- if (length < minLength) return `The password must have at least ${minLength} characters.`;
460
- if (length > MAX_PASSWORD_LENGTH) return `The password must have at most ${MAX_PASSWORD_LENGTH} characters.`;
551
+ if (length > MAX_PASSWORD_LENGTH) return { message: `The password must have at most ${MAX_PASSWORD_LENGTH} characters.` };
552
+ if (length < rules.minLength) return { message: `The password must have at least ${rules.minLength} characters.`, minLength: rules.minLength };
553
+ if (rules.minScore > 0 && await passwordScore(password, userInputs) < rules.minScore) {
554
+ return {
555
+ message: "This password is too easy to guess. Add another word or two, or choose less common ones.",
556
+ minScore: rules.minScore
557
+ };
558
+ }
461
559
  return null;
462
560
  }
463
561
  function normalizeEmail(email) {
@@ -498,9 +596,27 @@ function passwordAccountKey(userId) {
498
596
  function googleAccountKey(sub) {
499
597
  return `google:${sub}`;
500
598
  }
599
+ function githubAccountKey(id) {
600
+ return `github:${id}`;
601
+ }
501
602
  function totpAccountKey(userId) {
502
603
  return `totp:${userId}`;
503
604
  }
605
+ function emailLinkAccountKey(userId) {
606
+ return `email-link:${userId}`;
607
+ }
608
+ function smsAccountKey(userId) {
609
+ return `sms:${userId}`;
610
+ }
611
+ var methodOf = (account) => String(account.key).split(":")[0];
612
+ var ALWAYS_PRIMARY = ["password", "google", "github", "email-link"];
613
+ function isPrimary(account, accounts) {
614
+ if (account.primaryUserId) return true;
615
+ if (account.secondFactor === true) return false;
616
+ const method = methodOf(account);
617
+ if (method !== "totp") return ALWAYS_PRIMARY.includes(method);
618
+ return !accounts.some((other) => ALWAYS_PRIMARY.includes(methodOf(other)));
619
+ }
504
620
  function toMadauthUser(user, profile = {}) {
505
621
  const result = { id: user.id, email: user.email };
506
622
  const name = user.name ?? profile.name;
@@ -533,19 +649,75 @@ var Users = class {
533
649
  async totpAccount(userId) {
534
650
  return this.findAccountByKey(totpAccountKey(userId));
535
651
  }
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;
652
+ /** The user's e-mail link account, if they sign in that way. */
653
+ async emailLinkAccount(userId) {
654
+ return this.findAccountByKey(emailLinkAccountKey(userId));
655
+ }
656
+ /** The user's phone number for SMS codes, if they added one. */
657
+ async smsAccount(userId) {
658
+ return this.findAccountByKey(smsAccountKey(userId));
659
+ }
660
+ /** The user's accounts: their sign-in method and their second factors. */
661
+ async accounts(userId) {
662
+ return await this.store.findMany("account", { userId });
663
+ }
664
+ /** The account the user signs in with (they have one at most), or null; a sign-up with the authenticator app has none until it is set up. */
665
+ async primaryAccount(userId) {
666
+ const accounts = await this.accounts(userId);
667
+ return accounts.find((account) => isPrimary(account, accounts)) ?? null;
539
668
  }
540
669
  /**
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.
670
+ * The user's authenticator app, if they set one up, and whether it is how they sign in (they signed up with
671
+ * it) as opposed to a second factor. One read: the app and the user's other accounts together.
672
+ */
673
+ async totpApp(userId) {
674
+ const accounts = await this.accounts(userId);
675
+ const account = accounts.find((a) => a.key === totpAccountKey(userId));
676
+ return account ? { account, signsIn: isPrimary(account, accounts) } : null;
677
+ }
678
+ /** Whether someone can sign in as the user: they have a sign-in method. */
679
+ async hasSignInMethod(userId) {
680
+ return await this.primaryAccount(userId) !== null;
681
+ }
682
+ /**
683
+ * How the user signs in, e.g. `['google']`; empty while nobody can. A user has one sign-in method (only
684
+ * users from before madAuth 0.4 may have a password and Google); a second factor (the authenticator app of
685
+ * a password user, SMS) is a step of it, not a way in. The app of a user who signed up with it counts only
686
+ * while it signs in on its own (`methods` lists it).
544
687
  */
545
688
  async signInMethods(userId, methods) {
689
+ const accounts = await this.accounts(userId);
690
+ const signIn = accounts.filter((account) => isPrimary(account, accounts)).map(methodOf);
691
+ return SIGN_IN_METHODS.filter((m) => signIn.includes(m) && (m !== "totp" || methods.totp !== void 0));
692
+ }
693
+ /**
694
+ * Notes that the user's first session started: their sign-up is complete. Resolves to true only for the
695
+ * request that wrote it, so `user.signed_up` is sent once; false for a later session, and for a user from
696
+ * before schema version 7.
697
+ */
698
+ async markSignedUp(userId) {
699
+ return await this.store.update("user", { id: userId, signedUpAt: 0 }, { signedUpAt: Date.now() }) === 1;
700
+ }
701
+ /**
702
+ * For a user whose sign-up is not complete (no confirmed address, or no sign-in method) and who is about to
703
+ * finish it or sign up again: one from before schema version 7 says nothing (`signedUpAt` empty, which
704
+ * counts as complete), so it is set to 0 and `user.signed_up` comes at their first session like for anyone.
705
+ */
706
+ async reopenSignUp(user) {
707
+ if (user.signedUpAt !== null && user.signedUpAt !== void 0) return user;
708
+ await this.store.update("user", { id: user.id, signedUpAt: null }, { signedUpAt: 0 });
709
+ return { ...user, signedUpAt: 0 };
710
+ }
711
+ /** The `sub` of the user's linked Google account, if they have one. */
712
+ async googleSub(userId) {
546
713
  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));
714
+ const key = accounts.map((account) => String(account.key)).find((k) => k.startsWith("google:"));
715
+ return key ? key.slice("google:".length) : null;
716
+ }
717
+ /** The user's GitHub account, if linked: its key holds GitHub's user id, its secret the encrypted access token of the last sign-in. */
718
+ async githubAccount(userId) {
719
+ const accounts = await this.accounts(userId);
720
+ return accounts.find((account) => account.key.startsWith("github:")) ?? null;
549
721
  }
550
722
  async findAccountByKey(key) {
551
723
  return await this.store.findOne("account", { key });
@@ -565,8 +737,10 @@ var Users = class {
565
737
  name: data.name,
566
738
  sessionVersion: 0,
567
739
  lastMailAt: 0,
740
+ lastSmsAt: 0,
568
741
  wrongCodes: 0,
569
742
  claims: null,
743
+ signedUpAt: 0,
570
744
  createdAt: Date.now()
571
745
  };
572
746
  if (!await this.store.create("user", user)) return null;
@@ -579,12 +753,24 @@ var Users = class {
579
753
  return user;
580
754
  }
581
755
  /**
582
- * A provider verified the user's address: an unconfirmed password sign-up with it, whose password nobody
583
- * has proven, is dropped (the account and its pending links and codes), and the user counts as verified.
756
+ * Removes what an unconfirmed sign-up set without proving it: the password, and the e-mail link account of
757
+ * a first link nobody used. Only `except`'s own account stays: the method that proves the address, or
758
+ * signs it up anew, is the one the user ends up with.
584
759
  */
585
- async verifyByProvider(user) {
760
+ async dropUnprovenAccounts(userId, except) {
761
+ const keys = { password: passwordAccountKey(userId), "email-link": emailLinkAccountKey(userId) };
762
+ for (const [method, key] of Object.entries(keys)) {
763
+ if (method !== except) await this.store.delete("account", { key });
764
+ }
765
+ }
766
+ /**
767
+ * A provider verified the user's address: an unconfirmed sign-up with it, whose password or e-mail link
768
+ * nobody has proven, is dropped (its accounts, except `proven`'s own, and its pending links and codes),
769
+ * and the user counts as verified.
770
+ */
771
+ async verifyByProvider(user, proven) {
586
772
  if (user.emailVerified) return;
587
- await this.store.delete("account", { key: passwordAccountKey(user.id) });
773
+ await this.dropUnprovenAccounts(user.id, proven);
588
774
  await this.clearVerifications(user.id);
589
775
  await this.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
590
776
  }
@@ -602,8 +788,16 @@ var Users = class {
602
788
  await this.clearRecoveryCodes(userId);
603
789
  return removed;
604
790
  }
605
- /** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
791
+ /** Removes the user's phone number for SMS codes. Resolves to false if they had none. */
792
+ async removeSms(userId) {
793
+ return this.unlinkAccount(smsAccountKey(userId));
794
+ }
795
+ /**
796
+ * Adds a sign-in method or a second factor to a user. Resolves to null if the account key is taken, or if
797
+ * it is a sign-in method and the user has one already.
798
+ */
606
799
  async linkAccount(userId, account) {
800
+ const primary = account.primary ?? ALWAYS_PRIMARY.includes(methodOf(account));
607
801
  const record = {
608
802
  id: newId("acc"),
609
803
  userId,
@@ -614,6 +808,9 @@ var Users = class {
614
808
  email: account.email ?? null,
615
809
  lastUsedStep: account.lastUsedStep ?? 0,
616
810
  setupId: account.setupId ?? null,
811
+ phone: account.phone ?? null,
812
+ primaryUserId: primary ? userId : null,
813
+ secondFactor: !primary,
617
814
  createdAt: Date.now()
618
815
  };
619
816
  return await this.store.create("account", record) ? record : null;
@@ -712,7 +909,7 @@ var Users = class {
712
909
  await this.store.delete("verification", { userId });
713
910
  }
714
911
  /**
715
- * Starts a verification or password reset: replaces the user's earlier one for the same purpose and
912
+ * Starts a verification, password reset or e-mail link sign-in: replaces the user's earlier one for the same purpose and
716
913
  * returns the link token and the code for the e-mail. Only their hashes are stored.
717
914
  */
718
915
  async issueVerification(userId, purpose, ttlMs) {
@@ -743,22 +940,30 @@ var Users = class {
743
940
  }
744
941
  /**
745
942
  * Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
746
- * MAX_WRONG_CODES wrong codes in a row the user's codes stop working until an e-mail link is used.
943
+ * MAX_WRONG_CODES wrong e-mail codes in a row the user's codes stop working until an e-mail link is used.
944
+ * SMS codes are not counted that way: that lock is only lifted by an e-mail link or a password reset, which
945
+ * a Google user with a phone never has. Their record's attempts, one SMS per minute and the lock on the
946
+ * sms account (routes/lockout.ts) are their limits.
747
947
  */
748
948
  async consumeCode(user, purpose, code) {
749
- if ((user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
949
+ const counted = purpose !== "sms";
950
+ if (counted && (user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
750
951
  const record = await this.store.findOne("verification", { userId: user.id, purpose });
751
952
  if (!record) return { userId: null, locked: false };
752
953
  const attempts = record.attempts + 1;
753
954
  if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
754
955
  return { userId: null, locked: false };
755
956
  }
756
- const wrongCodes = await this.countWrongCode(user);
757
- if (wrongCodes === null) return { userId: null, locked: false };
758
- if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
957
+ let wrongCodes = 0;
958
+ if (counted) {
959
+ const count = await this.countWrongCode(user);
960
+ if (count === null) return { userId: null, locked: false };
961
+ if (count > MAX_WRONG_CODES) return { userId: null, locked: true };
962
+ wrongCodes = count;
963
+ }
759
964
  if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
760
965
  if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
761
- return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
966
+ return { userId: null, locked: counted && wrongCodes >= MAX_WRONG_CODES };
762
967
  }
763
968
  return { userId: await this.consume(record), locked: false };
764
969
  }
@@ -794,7 +999,6 @@ export {
794
999
  resolveEnv,
795
1000
  ConfigError,
796
1001
  loadConfig,
797
- loadUserStoreConfig,
798
1002
  loadStore,
799
1003
  MAX_PASSWORD_LENGTH,
800
1004
  hashPassword,
@@ -807,8 +1011,14 @@ export {
807
1011
  hashCode,
808
1012
  safeEqual,
809
1013
  SIGN_IN_METHODS,
1014
+ METHODS,
810
1015
  METHODS_WITH_SECOND_FACTOR,
811
1016
  SECOND_FACTOR_POLICIES,
1017
+ DEFAULT_PASSWORD_RULES,
1018
+ MAX_MIN_LENGTH,
1019
+ isPasswordScore,
1020
+ parsePasswordRules,
1021
+ hasSecondFactor,
812
1022
  parseMethodSettings,
813
1023
  isConfigured,
814
1024
  canSignUp,
@@ -821,7 +1031,10 @@ export {
821
1031
  newId,
822
1032
  passwordAccountKey,
823
1033
  googleAccountKey,
1034
+ githubAccountKey,
824
1035
  totpAccountKey,
1036
+ emailLinkAccountKey,
1037
+ smsAccountKey,
825
1038
  toMadauthUser,
826
1039
  Users
827
1040
  };