@madauth/server 0.3.0-beta.2 → 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.
Files changed (37) hide show
  1. package/README.md +3 -2
  2. package/dist/bin.js +202 -50
  3. package/dist/chunk-7DHKDRGY.js +1706 -0
  4. package/dist/{chunk-DCDTO5LG.js → chunk-A7UYZRBL.js} +1 -0
  5. package/dist/chunk-NP75Y3IV.js +107 -0
  6. package/dist/chunk-NYIEFI4B.js +1040 -0
  7. package/dist/{chunk-56MOVFO4.js → chunk-O2URPWA6.js} +37 -4
  8. package/dist/{chunk-3QRKWLR7.js → chunk-SISM7YUZ.js} +15 -10
  9. package/dist/{config-Dg2fZuWR.d.ts → config-CcGlHVAN.d.ts} +19 -8
  10. package/dist/dynamodb.js +1 -1
  11. package/dist/entry/azure.d.ts +3 -3
  12. package/dist/entry/azure.js +4 -4
  13. package/dist/entry/lambda.d.ts +3 -3
  14. package/dist/entry/lambda.js +4 -4
  15. package/dist/entry/node.d.ts +3 -3
  16. package/dist/entry/node.js +4 -4
  17. package/dist/index.d.ts +37 -15
  18. package/dist/index.js +12 -6
  19. package/dist/{schema-3yhCc2jH.d.ts → schema-BTgDj1Xn.d.ts} +79 -3
  20. package/dist/sqlite.js +2 -2
  21. package/dist/standalone/azure.mjs +5511 -922
  22. package/dist/standalone/lambda.mjs +5461 -872
  23. package/dist/standalone/main.mjs +5487 -898
  24. package/dist/store/dynamodb.d.ts +1 -1
  25. package/dist/store/sqlite.d.ts +1 -1
  26. package/dist/testing.d.ts +1 -1
  27. package/dist/testing.js +21 -0
  28. package/dist/{user-BMWEWREH.d.ts → user-yoGjrw4k.d.ts} +5 -0
  29. package/dist/verify.d.ts +1 -1
  30. package/dist/verify.js +1 -1
  31. package/dist/{webhook-j5u9Kv9l.d.ts → webhook-DlmfG_kS.d.ts} +5 -2
  32. package/dist/webhook.d.ts +1 -1
  33. package/dist/webhook.js +1 -1
  34. package/package.json +4 -2
  35. package/dist/chunk-CD7Y4T66.js +0 -62
  36. package/dist/chunk-DVVBIFIV.js +0 -718
  37. package/dist/chunk-TI35AZ2U.js +0 -660
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @madauth/server
2
2
 
3
- The server of [madAuth](https://github.com/inouiw/madAuth), a self-hostable alternative to Cognito / Auth0: Google and e-mail & password sign-in with madAuth sessions. It runs with Node, in Docker, on AWS Lambda, on Azure Functions or inside your own Node server.
3
+ The server of [madAuth](https://github.com/inouiw/madAuth), a self-hostable alternative to Cognito / Auth0: Google, GitHub, e-mail & password, e-mail link and authenticator-app (TOTP) sign-in with madAuth sessions. It runs with Node, in Docker, on AWS Lambda, on Azure Functions or inside your own Node server.
4
4
 
5
5
  The browser side is [`@madauth/web`](https://www.npmjs.com/package/@madauth/web).
6
6
 
@@ -53,7 +53,8 @@ The package also contains self-contained bundles for AWS Lambda (`dist/standalon
53
53
  ## Documentation
54
54
 
55
55
  - [Getting started](https://github.com/inouiw/madAuth/blob/main/docs/getting-started.md): set up madAuth on your machine, step by step.
56
- - [Running the madAuth server](https://github.com/inouiw/madAuth/blob/main/docs/server.md): the configuration, Google Cloud Console setup, webhooks, hosting and custom store adapters.
56
+ - [Running the madAuth server](https://github.com/inouiw/madAuth/blob/main/docs/server.md): the configuration, Google Cloud Console setup, the sign-in methods and their policies, the authenticator app, webhooks, hosting and custom store adapters.
57
+ - [Password security](https://github.com/inouiw/madAuth/blob/main/docs/password-security.md) and [Authenticator app security](https://github.com/inouiw/madAuth/blob/main/docs/totp-security.md): what the server does to protect accounts.
57
58
 
58
59
  ## License
59
60
 
package/dist/bin.js CHANGED
@@ -2,35 +2,45 @@
2
2
  import {
3
3
  createTablesSql,
4
4
  upgradeTablesSql
5
- } from "./chunk-56MOVFO4.js";
5
+ } from "./chunk-O2URPWA6.js";
6
6
  import {
7
7
  madauthSchema
8
- } from "./chunk-CD7Y4T66.js";
8
+ } from "./chunk-NP75Y3IV.js";
9
9
  import {
10
+ DEFAULT_PASSWORD_RULES,
11
+ MAX_MIN_LENGTH,
12
+ METHODS,
13
+ SECOND_FACTOR_POLICIES,
10
14
  SIGN_IN_METHODS,
11
15
  Settings,
12
16
  Users,
13
17
  anyOn,
18
+ canSignUp,
14
19
  checkPasswordPolicy,
15
20
  claimsFromJson,
21
+ hasSecondFactor,
16
22
  hashPassword,
23
+ initialMethods,
24
+ isConfigured,
17
25
  isOn,
26
+ isPasswordScore,
18
27
  isValidEmail,
19
28
  loadConfig,
20
29
  loadStore,
21
- loadUserStoreConfig,
30
+ methodConfig,
22
31
  normalizeEmail,
23
32
  parseClaims,
24
33
  parseRoles,
25
- passwordAccountKey
26
- } from "./chunk-TI35AZ2U.js";
34
+ passwordAccountKey,
35
+ policyOf
36
+ } from "./chunk-NYIEFI4B.js";
27
37
  import {
28
38
  generateSigningKey
29
39
  } from "./chunk-JDQLSL36.js";
30
40
  import {
31
41
  DEV_WEBHOOK_RECEIVER_URL,
32
42
  generateWebhookSecret
33
- } from "./chunk-3QRKWLR7.js";
43
+ } from "./chunk-SISM7YUZ.js";
34
44
 
35
45
  // src/cli.ts
36
46
  import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -42,25 +52,44 @@ var usage = `Usage: npx @madauth/server <command> [--env-file <path>]
42
52
  Commands:
43
53
  init Ask a few questions, write the configuration to .env and print the next steps.
44
54
  An answer given as an option is not asked for: --app-url <url>,
45
- --google-client-id <id>, --google-client-secret <secret>, --password or
46
- --no-password, --database <url>, --webhook-url <url>. --yes takes the
47
- default for the rest. --out <path> writes another file, --force overwrites.
55
+ --google-client-id <id>, --google-client-secret <secret>,
56
+ --github-client-id <id>, --github-client-secret <secret>, --password or
57
+ --no-password, --totp or --no-totp (sign-up with the authenticator app),
58
+ --email-link or --no-email-link (passwordless sign-in by e-mail),
59
+ --sms or --no-sms (SMS codes as a second factor),
60
+ --database <url>, --webhook-url <url>. --yes takes the default for the
61
+ rest. --out <path> writes another file, --force overwrites.
48
62
  start Start the server on PORT (default ${PORT})
49
63
  generate-key Print a new private ES256 key to use as MADAUTH_SIGNING_KEY
50
64
  generate-webhook-secret Print a new secret to use as WEBHOOK_SECRET (madAuth and your receiver)
51
- create-user <email> Create a user with a password (asks for it), already verified.
52
- Uses DATABASE_URL and PASSWORD_MIN_LENGTH from the environment.
65
+ create-user <email> Create a user with a password (asks for it), already verified. The password
66
+ must follow the password rules. Uses DATABASE_URL from the environment.
53
67
  set-roles <email> [role...] Set the roles of a user (the claim "roles"), e.g. to make the first admin:
54
68
  set-roles you@example.com admin. Without roles, removes them all.
55
69
  The user must exist: signed in once, or created with create-user.
56
70
  get-roles <email> Print the roles of a user.
57
71
  set-claims <email> <json> Replace the claims of a user, e.g. '{"roles":["admin"],"plan":"pro"}'.
58
72
  get-claims <email> Print the claims of a user as JSON.
59
- set-methods [method...] Switch sign-in methods on and off while the server runs: the listed ones
60
- (google, password) are on, the others off. Without any, all are on.
61
- get-methods Print which sign-in methods are switched on.
62
- The claims commands use DATABASE_URL from the environment; the methods
63
- commands need the server's whole configuration (e.g. --env-file .env).
73
+ set-methods [method[=policy]...]
74
+ Switch sign-in methods on and off while the server runs: the listed ones
75
+ are on, the others off. Methods: google, github, password, totp (the
76
+ authenticator app on its own), email-link (passwordless sign-in by e-mail),
77
+ and sms (SMS codes as a second factor, next to the app). google, github,
78
+ password and email-link take a policy for the second factor: none (default),
79
+ optional or required, e.g. set-methods google password=required totp email-link=optional sms
80
+ get-methods Print which sign-in methods are switched on, with their policies.
81
+ set-password-rules [min-length=<n>] [min-score=<0-4>]
82
+ Set the rules for new passwords: the minimum length (default 8, at most 128)
83
+ and how hard to guess they must be, 0 (anything) to 4 (default 2: no common
84
+ passwords or simple patterns). What is left out takes its default, e.g.
85
+ set-password-rules min-length=10 min-score=3. Uses DATABASE_URL.
86
+ get-password-rules Print the rules for new passwords.
87
+ remove-totp <email> Remove a user's authenticator app and recovery codes: the last resort when
88
+ both are lost. With a required policy, the user sets it up again next time.
89
+ remove-sms <email> Remove a user's phone number for SMS codes, when they lost the phone.
90
+ The claims commands, remove-totp and remove-sms use DATABASE_URL from the
91
+ environment; the methods commands need the server's whole configuration
92
+ (e.g. --env-file .env).
64
93
  schema [--dialect <name>] [--from <version>]
65
94
  Print the SQL that creates madAuth's tables for a custom store adapter.
66
95
  Dialects: postgres (default), mysql, sqlite. With --from, print only the
@@ -156,7 +185,12 @@ function parseCliArgs(args) {
156
185
  "app-url": { type: "string" },
157
186
  "google-client-id": { type: "string" },
158
187
  "google-client-secret": { type: "string" },
188
+ "github-client-id": { type: "string" },
189
+ "github-client-secret": { type: "string" },
159
190
  password: { type: "boolean" },
191
+ totp: { type: "boolean" },
192
+ "email-link": { type: "boolean" },
193
+ sms: { type: "boolean" },
160
194
  database: { type: "string" },
161
195
  "webhook-url": { type: "string" },
162
196
  yes: { type: "boolean" },
@@ -179,7 +213,17 @@ ${usage}`, exitCode: 1 };
179
213
  }
180
214
  const { values: options, positionals } = parsed;
181
215
  const [command, ...rest] = positionals;
182
- const argumentLimits = { "set-roles": Infinity, "set-methods": Infinity, "set-claims": 2, "create-user": 1, "get-roles": 1, "get-claims": 1 };
216
+ const argumentLimits = {
217
+ "set-roles": Infinity,
218
+ "set-methods": Infinity,
219
+ "set-password-rules": Infinity,
220
+ "set-claims": 2,
221
+ "create-user": 1,
222
+ "get-roles": 1,
223
+ "get-claims": 1,
224
+ "remove-totp": 1,
225
+ "remove-sms": 1
226
+ };
183
227
  const maxArguments = command ? argumentLimits[command] ?? 0 : 0;
184
228
  if (rest.length > maxArguments) {
185
229
  return { output: `Unexpected argument: ${rest.at(-1)}
@@ -223,6 +267,15 @@ ${usage}`, exitCode: 1 };
223
267
  if (command === "set-methods" || command === "get-methods") {
224
268
  return await methods(command, rest, env);
225
269
  }
270
+ if (command === "set-password-rules" || command === "get-password-rules") {
271
+ return await passwordRules(command, rest, env);
272
+ }
273
+ if (command === "remove-totp") {
274
+ return await removeTotp(rest[0], env);
275
+ }
276
+ if (command === "remove-sms") {
277
+ return await removeSms(rest[0], env);
278
+ }
226
279
  } catch (e) {
227
280
  return { output: e instanceof Error ? e.message : String(e), exitCode: 1 };
228
281
  }
@@ -255,18 +308,37 @@ async function init(options, io) {
255
308
  return fail("--google-client-secret needs a Google client ID.");
256
309
  }
257
310
  const clientSecret = clientId ? (options["google-client-secret"] ?? await askClientSecret()).trim() : "";
311
+ const githubClientId = await answer(options["github-client-id"], "GitHub client ID (empty: no GitHub sign-in)");
312
+ if (!githubClientId && options["github-client-secret"] !== void 0) {
313
+ return fail("--github-client-secret needs a GitHub client ID.");
314
+ }
315
+ const githubClientSecret = githubClientId ? (options["github-client-secret"] ?? (options.yes ? "" : await io.askSecret("GitHub client secret: "))).trim() : "";
316
+ if (githubClientId && !githubClientSecret) {
317
+ return fail("GitHub sign-in needs the client secret of the OAuth App too (--github-client-secret).");
318
+ }
258
319
  const password = options.password ?? /^y/i.test(await answer(void 0, "E-mail & password sign-in? (yes/no)", "yes"));
259
- if (!password && options["webhook-url"] !== void 0) {
260
- return fail("--webhook-url is for e-mail & password sign-in, which is turned off.");
320
+ const totp = options.totp ?? /^y/i.test(await answer(void 0, "Sign-up with the authenticator app alone, without a password? (yes/no)", "no"));
321
+ const emailLink = options["email-link"] ?? /^y/i.test(await answer(void 0, "Passwordless sign-in with an e-mail link? (yes/no)", "no"));
322
+ const sms = options.sms ?? /^y/i.test(await answer(void 0, "SMS codes as a second factor, sent by your webhook receiver? (yes/no)", "no"));
323
+ const emails = password || totp || emailLink;
324
+ const webhook = emails || sms;
325
+ if (!webhook && options["webhook-url"] !== void 0) {
326
+ return fail("--webhook-url is for the e-mails of e-mail & password sign-in, of the e-mail link and of sign-up with the authenticator app, and for SMS codes, which are turned off.");
261
327
  }
262
- if (!clientId && !password) {
263
- return fail("No sign-in method is chosen. Give a Google client ID, turn on e-mail & password sign-in, or both.");
328
+ if (!clientId && !githubClientId && !emails) {
329
+ return fail("No sign-in method is chosen. Give a Google or GitHub client ID, turn on e-mail & password sign-in, the e-mail link or sign-up with the authenticator app.");
264
330
  }
265
331
  const database = await answer(options.database, "Database for the users", "sqlite:./madauth.db");
266
332
  if (!/^(sqlite|dynamodb):./.test(database)) {
267
333
  return fail(`The database must be sqlite:<path> or dynamodb:<table> but is "${database}".`);
268
334
  }
269
- const webhookUrl = password ? await answer(options["webhook-url"], "Webhook URL, where madAuth hands over its e-mails", "http://localhost:8790/webhook") : "";
335
+ const webhookUrl = webhook ? await answer(options["webhook-url"], `Webhook URL, where madAuth hands over its ${emails ? sms ? "e-mails and SMS" : "e-mails" : "SMS"}`, "http://localhost:8790/webhook") : "";
336
+ const events = [
337
+ ...password ? ["email.send_verification", "email.send_password_reset", "email.send_already_registered", "email.send_password_reset_no_password"] : [],
338
+ ...totp ? ["email.send_verification", "email.send_already_registered"] : [],
339
+ ...emailLink ? ["email.send_signin_link", "email.send_already_registered"] : [],
340
+ ...sms ? ["sms.send_code", "user.second_factor_enabled", "user.second_factor_disabled"] : []
341
+ ].filter((type, i, all) => all.indexOf(type) === i).join(",");
270
342
  const variable = (name, value, comment) => `# ${comment}
271
343
  ${name}=${value}
272
344
  `;
@@ -288,14 +360,12 @@ ${name}=${value}
288
360
  variable("ALLOWED_ORIGINS", origin, "Origins of your apps, comma-separated. Only these may call the server."),
289
361
  clientId && variable("GOOGLE_CLIENT_ID", clientId, "Google sign-in: your OAuth client ID, of the type Web application."),
290
362
  clientSecret && variable("GOOGLE_CLIENT_SECRET", clientSecret, "Client secret of the same client. It enables the redirect flow."),
363
+ githubClientId && variable("GITHUB_CLIENT_ID", githubClientId, "GitHub sign-in: the client ID of your OAuth App."),
364
+ githubClientSecret && variable("GITHUB_CLIENT_SECRET", githubClientSecret, "Client secret of the same OAuth App."),
291
365
  variable("DATABASE_URL", database, "Where the users are stored, whichever way they sign in."),
292
- password && variable("WEBHOOK_URL", webhookUrl, "Your webhook receiver. madAuth hands its e-mails to it."),
293
- password && variable("WEBHOOK_SECRET", generateWebhookSecret(), "Signs every webhook call. Your receiver needs the same one."),
294
- password && variable(
295
- "WEBHOOK_EVENTS",
296
- "email.verify,email.reset,email.already_registered,email.no_password",
297
- "The types your webhook receiver handles; only these are sent. Add e.g. signup.before or user.created."
298
- ),
366
+ webhook && variable("WEBHOOK_URL", webhookUrl, `Your webhook receiver. madAuth hands its ${sms ? "e-mails and SMS" : "e-mails"} to it.`),
367
+ webhook && variable("WEBHOOK_SECRET", generateWebhookSecret(), "Signs every webhook call. Your receiver needs the same one."),
368
+ webhook && variable("WEBHOOK_EVENTS", events, "The types your webhook receiver handles; only these are sent. Add e.g. user.signing_up or user.signed_up."),
299
369
  variable("PORT", String(PORT), "Port the server listens on.")
300
370
  ].filter(Boolean).join("");
301
371
  await loadConfig({ ...parseEnv(content), DATABASE_URL: void 0 }, { store: {} });
@@ -306,21 +376,27 @@ ${name}=${value}
306
376
 
307
377
  Next steps:
308
378
 
309
- ${nextSteps(file, origin, google, password)}`, exitCode: 0 };
379
+ ${nextSteps(file, origin, google, !!githubClientId, password, totp, emailLink, sms)}`, exitCode: 0 };
310
380
  }
311
- function nextSteps(file, origin, google, password) {
381
+ function nextSteps(file, origin, google, github, password, totp = false, emailLink = false, sms = false) {
312
382
  const server = `http://localhost:${PORT}`;
313
383
  const envFile = /[^\w./-]/.test(file) ? `'${file.replaceAll("'", `'\\''`)}'` : file;
314
- const providers = [google, password && "Password"].filter(Boolean);
384
+ const providers = [google, github && "GitHub", password && "Password", totp && "Totp", emailLink && "EmailLink"].filter(Boolean);
385
+ const methodList = [google && "google", github && "github", password && "password", totp && "totp", emailLink && "email-link"].filter(Boolean).join(" ");
315
386
  const googleOrigins = /* @__PURE__ */ new Set([origin, ...new URL(origin).hostname === "localhost" ? ["http://localhost"] : []]);
316
387
  const steps = [
317
388
  ["Start the madAuth server:", "", ` npx @madauth/server start --env-file ${envFile}`],
318
- password && [
319
- "madAuth hands its e-mails to your webhook receiver. For development, start the development receiver,",
389
+ (password || totp || emailLink || sms) && [
390
+ `madAuth hands its ${sms ? "e-mails and SMS" : "e-mails"} to your webhook receiver. For development, start the development receiver,`,
320
391
  `which prints them, with the same WEBHOOK_URL and WEBHOOK_SECRET as in ${file}:`,
321
392
  "",
322
393
  ` ${DEV_WEBHOOK_RECEIVER_URL}`
323
394
  ],
395
+ totp && (google || github || password) && [
396
+ "Signing in with the authenticator app alone is off until an admin lists it. Once the server runs:",
397
+ "",
398
+ ` npx @madauth/server set-methods ${methodList} --env-file ${envFile}`
399
+ ],
324
400
  [
325
401
  "Proxy /auth and /.well-known from your app to the server, so that its cookies are first-party.",
326
402
  "With Vite, in vite.config.ts:",
@@ -347,6 +423,11 @@ function nextSteps(file, origin, google, password) {
347
423
  ` Authorized JavaScript origins: ${[...googleOrigins].join(" and ")}`,
348
424
  ...google === "GoogleRedirect" ? [` Authorized redirect URIs: ${origin}/auth/google/callback`] : []
349
425
  ],
426
+ github && [
427
+ "In your GitHub OAuth App (https://github.com/settings/developers), set:",
428
+ "",
429
+ ` Authorization callback URL: ${origin}/auth/github/callback`
430
+ ],
350
431
  [
351
432
  `Never commit ${file}: it holds the signing key and your secrets. Production needs an https app URL`,
352
433
  "and a signing key of its own: run init again for it."
@@ -401,36 +482,107 @@ ${usage}`, exitCode: 1 };
401
482
  const roles = Array.isArray(current.roles) ? current.roles : [];
402
483
  return { output: roles.length ? `${address}: ${roles.join(" ")}` : `${address} has no roles.`, exitCode: 0 };
403
484
  }
404
- async function methods(command, names, env) {
485
+ async function methods(command, args, env) {
405
486
  const config = await loadConfig(env);
406
- const available = (method) => method === "google" ? !!config.google : !!config.password;
407
- const settings = new Settings(config.store);
487
+ const forMethods = methodConfig(config);
488
+ const configured = (method) => isConfigured(forMethods, method);
489
+ const signsUp = (method) => canSignUp(forMethods, method);
490
+ const settings = new Settings(config.store, initialMethods(forMethods));
408
491
  if (command === "set-methods") {
409
- const unknown = names.filter((name) => !SIGN_IN_METHODS.includes(name));
410
- if (unknown.length) {
411
- return { output: `Unknown sign-in method ${unknown.join(", ")}. Methods: ${SIGN_IN_METHODS.join(", ")}.`, exitCode: 1 };
412
- }
413
492
  const next = {};
414
- for (const method of SIGN_IN_METHODS) next[method] = !names.length || names.includes(method);
415
- if (!anyOn(available, next)) {
416
- const configured = SIGN_IN_METHODS.filter(available);
417
- return { output: `That would switch off every sign-in method. The server is configured for: ${configured.join(", ")}.`, exitCode: 1 };
493
+ for (const arg of args) {
494
+ const [name, policy, ...more] = arg.split("=");
495
+ if (!METHODS.includes(name) || more.length) {
496
+ return { output: `Unknown sign-in method "${arg}". Methods: ${METHODS.join(", ")}, e.g. set-methods google password=required totp sms.`, exitCode: 1 };
497
+ }
498
+ const method = name;
499
+ if (policy !== void 0 && (!hasSecondFactor(method) || !SECOND_FACTOR_POLICIES.includes(policy))) {
500
+ return {
501
+ output: `"${arg}": google, github, password and email-link take a policy for the second factor: ${SECOND_FACTOR_POLICIES.join(", ")}.`,
502
+ exitCode: 1
503
+ };
504
+ }
505
+ next[method] = hasSecondFactor(method) ? { secondFactor: policy ?? "none" } : {};
506
+ }
507
+ if (!anyOn(signsUp, next)) {
508
+ const usable = SIGN_IN_METHODS.filter(signsUp);
509
+ return {
510
+ output: `That would leave no sign-in method on that creates users, so nobody could sign up. On this server these do: ${usable.join(", ")}.`,
511
+ exitCode: 1
512
+ };
418
513
  }
419
514
  await settings.changeMethods(() => next, null);
420
515
  }
421
516
  const current = await settings.methods();
422
- const on = SIGN_IN_METHODS.filter((method) => isOn(method, available, current));
423
- const off = SIGN_IN_METHODS.filter((method) => available(method) && !isOn(method, available, current));
517
+ const describe = (method) => {
518
+ const policy = hasSecondFactor(method) ? policyOf(method, current) : "none";
519
+ return policy !== "none" ? `${method} (second factor ${policy})` : method;
520
+ };
521
+ const on = METHODS.filter((method) => isOn(method, configured, current)).map(describe);
522
+ const off = METHODS.filter((method) => configured(method) && !isOn(method, configured, current));
424
523
  return { output: `Switched on: ${on.join(", ")}.${off.length ? ` Switched off: ${off.join(", ")}.` : ""}`, exitCode: 0 };
425
524
  }
525
+ function describeRules(rules) {
526
+ return `New passwords: at least ${rules.minLength} characters, and a strength score of at least ${rules.minScore} (0 to 4).`;
527
+ }
528
+ async function passwordRules(command, args, env) {
529
+ const settings = new Settings(await loadStore(env));
530
+ if (command === "set-password-rules") {
531
+ const fail = (output2) => ({ output: output2, exitCode: 1 });
532
+ const rules = { ...DEFAULT_PASSWORD_RULES };
533
+ for (const arg of args) {
534
+ const [name, value, ...more] = arg.split("=");
535
+ const number = value !== void 0 && /^\d+$/.test(value) && !more.length ? Number(value) : NaN;
536
+ if (name === "min-length") {
537
+ if (!(number >= 1 && number <= MAX_MIN_LENGTH)) return fail(`min-length must be a whole number from 1 to ${MAX_MIN_LENGTH} but is "${value ?? ""}".`);
538
+ rules.minLength = number;
539
+ } else if (name === "min-score") {
540
+ if (!isPasswordScore(number)) return fail(`min-score must be 0, 1, 2, 3 or 4 but is "${value ?? ""}".`);
541
+ rules.minScore = number;
542
+ } else {
543
+ return fail(`Unknown password rule "${arg}". Rules: min-length=<n>, min-score=<0-4>, e.g. set-password-rules min-length=10 min-score=3.`);
544
+ }
545
+ }
546
+ await settings.setPasswordRules(rules, null);
547
+ }
548
+ return { output: describeRules(await settings.passwordRules()), exitCode: 0 };
549
+ }
550
+ async function removeTotp(email, env) {
551
+ if (!isValidEmail(email)) return { output: `"${email ?? ""}" is not an e-mail address.
552
+
553
+ ${usage}`, exitCode: 1 };
554
+ const users = new Users(await loadStore(env));
555
+ const address = normalizeEmail(email);
556
+ const user = await users.findByEmail(address);
557
+ if (!user) return { output: `No user has the e-mail address ${address}.`, exitCode: 1 };
558
+ const removed = await users.removeTotp(user.id);
559
+ return {
560
+ output: removed ? `Removed the authenticator app of ${address}.` : `${address} has no authenticator app.`,
561
+ exitCode: 0
562
+ };
563
+ }
564
+ async function removeSms(email, env) {
565
+ if (!isValidEmail(email)) return { output: `"${email ?? ""}" is not an e-mail address.
566
+
567
+ ${usage}`, exitCode: 1 };
568
+ const users = new Users(await loadStore(env));
569
+ const address = normalizeEmail(email);
570
+ const user = await users.findByEmail(address);
571
+ if (!user) return { output: `No user has the e-mail address ${address}.`, exitCode: 1 };
572
+ const removed = await users.removeSms(user.id);
573
+ return {
574
+ output: removed ? `Removed the phone number of ${address}.` : `${address} has no phone number for SMS codes.`,
575
+ exitCode: 0
576
+ };
577
+ }
426
578
  async function createUser(email, io, env) {
427
579
  if (!isValidEmail(email)) return { output: `"${email ?? ""}" is not an e-mail address.
428
580
 
429
581
  ${usage}`, exitCode: 1 };
430
- const { store, minLength } = await loadUserStoreConfig(env);
582
+ const store = await loadStore(env);
431
583
  const password = await io.askSecret(`Password for ${email}: `);
432
- const policy = checkPasswordPolicy(password, minLength);
433
- if (policy) return { output: policy, exitCode: 1 };
584
+ const problem = await checkPasswordPolicy(password, await new Settings(store).passwordRules(), [email]);
585
+ if (problem) return { output: problem.message, exitCode: 1 };
434
586
  const users = new Users(store);
435
587
  const passwordHash = await hashPassword(password);
436
588
  const user = await users.createUser({