@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.
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, e-mail & password 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,41 @@
2
2
  import {
3
3
  createTablesSql,
4
4
  upgradeTablesSql
5
- } from "./chunk-56MOVFO4.js";
5
+ } from "./chunk-OMIX3ERC.js";
6
6
  import {
7
7
  madauthSchema
8
- } from "./chunk-CD7Y4T66.js";
8
+ } from "./chunk-GA4TF7EI.js";
9
9
  import {
10
+ SECOND_FACTOR_POLICIES,
10
11
  SIGN_IN_METHODS,
11
12
  Settings,
12
13
  Users,
13
14
  anyOn,
15
+ canSignUp,
14
16
  checkPasswordPolicy,
15
17
  claimsFromJson,
16
18
  hashPassword,
19
+ initialMethods,
20
+ isConfigured,
17
21
  isOn,
18
22
  isValidEmail,
19
23
  loadConfig,
20
24
  loadStore,
21
25
  loadUserStoreConfig,
26
+ methodConfig,
22
27
  normalizeEmail,
23
28
  parseClaims,
24
29
  parseRoles,
25
- passwordAccountKey
26
- } from "./chunk-TI35AZ2U.js";
30
+ passwordAccountKey,
31
+ policyOf
32
+ } from "./chunk-ISJGOCF3.js";
27
33
  import {
28
34
  generateSigningKey
29
35
  } from "./chunk-JDQLSL36.js";
30
36
  import {
31
37
  DEV_WEBHOOK_RECEIVER_URL,
32
38
  generateWebhookSecret
33
- } from "./chunk-3QRKWLR7.js";
39
+ } from "./chunk-Y7ZSUROX.js";
34
40
 
35
41
  // src/cli.ts
36
42
  import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -43,8 +49,9 @@ Commands:
43
49
  init Ask a few questions, write the configuration to .env and print the next steps.
44
50
  An answer given as an option is not asked for: --app-url <url>,
45
51
  --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.
52
+ --no-password, --totp or --no-totp (sign-up with the authenticator app),
53
+ --database <url>, --webhook-url <url>. --yes takes the default for the
54
+ rest. --out <path> writes another file, --force overwrites.
48
55
  start Start the server on PORT (default ${PORT})
49
56
  generate-key Print a new private ES256 key to use as MADAUTH_SIGNING_KEY
50
57
  generate-webhook-secret Print a new secret to use as WEBHOOK_SECRET (madAuth and your receiver)
@@ -56,11 +63,17 @@ Commands:
56
63
  get-roles <email> Print the roles of a user.
57
64
  set-claims <email> <json> Replace the claims of a user, e.g. '{"roles":["admin"],"plan":"pro"}'.
58
65
  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).
66
+ set-methods [method[=policy]...]
67
+ Switch sign-in methods on and off while the server runs: the listed ones
68
+ are on, the others off. Methods: google, password, totp (the authenticator
69
+ app on its own). google and password take a policy for the authenticator
70
+ app as a second factor: none (default), optional or required, e.g.
71
+ set-methods google password=required totp
72
+ get-methods Print which sign-in methods are switched on, with their policies.
73
+ remove-totp <email> Remove a user's authenticator app and recovery codes: the last resort when
74
+ both are lost. With a required policy, the user sets it up again next time.
75
+ The claims commands and remove-totp use DATABASE_URL from the environment;
76
+ the methods commands need the server's whole configuration (e.g. --env-file .env).
64
77
  schema [--dialect <name>] [--from <version>]
65
78
  Print the SQL that creates madAuth's tables for a custom store adapter.
66
79
  Dialects: postgres (default), mysql, sqlite. With --from, print only the
@@ -157,6 +170,7 @@ function parseCliArgs(args) {
157
170
  "google-client-id": { type: "string" },
158
171
  "google-client-secret": { type: "string" },
159
172
  password: { type: "boolean" },
173
+ totp: { type: "boolean" },
160
174
  database: { type: "string" },
161
175
  "webhook-url": { type: "string" },
162
176
  yes: { type: "boolean" },
@@ -179,7 +193,15 @@ ${usage}`, exitCode: 1 };
179
193
  }
180
194
  const { values: options, positionals } = parsed;
181
195
  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 };
196
+ const argumentLimits = {
197
+ "set-roles": Infinity,
198
+ "set-methods": Infinity,
199
+ "set-claims": 2,
200
+ "create-user": 1,
201
+ "get-roles": 1,
202
+ "get-claims": 1,
203
+ "remove-totp": 1
204
+ };
183
205
  const maxArguments = command ? argumentLimits[command] ?? 0 : 0;
184
206
  if (rest.length > maxArguments) {
185
207
  return { output: `Unexpected argument: ${rest.at(-1)}
@@ -223,6 +245,9 @@ ${usage}`, exitCode: 1 };
223
245
  if (command === "set-methods" || command === "get-methods") {
224
246
  return await methods(command, rest, env);
225
247
  }
248
+ if (command === "remove-totp") {
249
+ return await removeTotp(rest[0], env);
250
+ }
226
251
  } catch (e) {
227
252
  return { output: e instanceof Error ? e.message : String(e), exitCode: 1 };
228
253
  }
@@ -256,17 +281,20 @@ async function init(options, io) {
256
281
  }
257
282
  const clientSecret = clientId ? (options["google-client-secret"] ?? await askClientSecret()).trim() : "";
258
283
  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.");
284
+ const totp = options.totp ?? /^y/i.test(await answer(void 0, "Sign-up with the authenticator app alone, without a password? (yes/no)", "no"));
285
+ const emails = password || totp;
286
+ if (!emails && options["webhook-url"] !== void 0) {
287
+ return fail("--webhook-url is for the e-mails of e-mail & password sign-in and of sign-up with the authenticator app, which are turned off.");
261
288
  }
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.");
289
+ if (!clientId && !emails) {
290
+ return fail("No sign-in method is chosen. Give a Google client ID, turn on e-mail & password sign-in or sign-up with the authenticator app.");
264
291
  }
265
292
  const database = await answer(options.database, "Database for the users", "sqlite:./madauth.db");
266
293
  if (!/^(sqlite|dynamodb):./.test(database)) {
267
294
  return fail(`The database must be sqlite:<path> or dynamodb:<table> but is "${database}".`);
268
295
  }
269
- const webhookUrl = password ? await answer(options["webhook-url"], "Webhook URL, where madAuth hands over its e-mails", "http://localhost:8790/webhook") : "";
296
+ const webhookUrl = emails ? await answer(options["webhook-url"], "Webhook URL, where madAuth hands over its e-mails", "http://localhost:8790/webhook") : "";
297
+ const events = password ? "email.verify,email.reset,email.already_registered,email.no_password" : "email.verify,email.already_registered";
270
298
  const variable = (name, value, comment) => `# ${comment}
271
299
  ${name}=${value}
272
300
  `;
@@ -289,13 +317,9 @@ ${name}=${value}
289
317
  clientId && variable("GOOGLE_CLIENT_ID", clientId, "Google sign-in: your OAuth client ID, of the type Web application."),
290
318
  clientSecret && variable("GOOGLE_CLIENT_SECRET", clientSecret, "Client secret of the same client. It enables the redirect flow."),
291
319
  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
- ),
320
+ emails && variable("WEBHOOK_URL", webhookUrl, "Your webhook receiver. madAuth hands its e-mails to it."),
321
+ emails && variable("WEBHOOK_SECRET", generateWebhookSecret(), "Signs every webhook call. Your receiver needs the same one."),
322
+ emails && variable("WEBHOOK_EVENTS", events, "The types your webhook receiver handles; only these are sent. Add e.g. signup.before or user.created."),
299
323
  variable("PORT", String(PORT), "Port the server listens on.")
300
324
  ].filter(Boolean).join("");
301
325
  await loadConfig({ ...parseEnv(content), DATABASE_URL: void 0 }, { store: {} });
@@ -306,21 +330,27 @@ ${name}=${value}
306
330
 
307
331
  Next steps:
308
332
 
309
- ${nextSteps(file, origin, google, password)}`, exitCode: 0 };
333
+ ${nextSteps(file, origin, google, password, totp)}`, exitCode: 0 };
310
334
  }
311
- function nextSteps(file, origin, google, password) {
335
+ function nextSteps(file, origin, google, password, totp = false) {
312
336
  const server = `http://localhost:${PORT}`;
313
337
  const envFile = /[^\w./-]/.test(file) ? `'${file.replaceAll("'", `'\\''`)}'` : file;
314
- const providers = [google, password && "Password"].filter(Boolean);
338
+ const providers = [google, password && "Password", totp && "Totp"].filter(Boolean);
339
+ const methodList = [google && "google", password && "password", totp && "totp"].filter(Boolean).join(" ");
315
340
  const googleOrigins = /* @__PURE__ */ new Set([origin, ...new URL(origin).hostname === "localhost" ? ["http://localhost"] : []]);
316
341
  const steps = [
317
342
  ["Start the madAuth server:", "", ` npx @madauth/server start --env-file ${envFile}`],
318
- password && [
343
+ (password || totp) && [
319
344
  "madAuth hands its e-mails to your webhook receiver. For development, start the development receiver,",
320
345
  `which prints them, with the same WEBHOOK_URL and WEBHOOK_SECRET as in ${file}:`,
321
346
  "",
322
347
  ` ${DEV_WEBHOOK_RECEIVER_URL}`
323
348
  ],
349
+ totp && (google || password) && [
350
+ "Signing in with the authenticator app alone is off until an admin lists it. Once the server runs:",
351
+ "",
352
+ ` npx @madauth/server set-methods ${methodList} --env-file ${envFile}`
353
+ ],
324
354
  [
325
355
  "Proxy /auth and /.well-known from your app to the server, so that its cookies are first-party.",
326
356
  "With Vite, in vite.config.ts:",
@@ -401,28 +431,60 @@ ${usage}`, exitCode: 1 };
401
431
  const roles = Array.isArray(current.roles) ? current.roles : [];
402
432
  return { output: roles.length ? `${address}: ${roles.join(" ")}` : `${address} has no roles.`, exitCode: 0 };
403
433
  }
404
- async function methods(command, names, env) {
434
+ async function methods(command, args, env) {
405
435
  const config = await loadConfig(env);
406
- const available = (method) => method === "google" ? !!config.google : !!config.password;
407
- const settings = new Settings(config.store);
436
+ const forMethods = methodConfig(config);
437
+ const configured = (method) => isConfigured(forMethods, method);
438
+ const signsUp = (method) => canSignUp(forMethods, method);
439
+ const settings = new Settings(config.store, initialMethods(forMethods));
408
440
  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
441
  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 };
442
+ for (const arg of args) {
443
+ const [name, policy, ...more] = arg.split("=");
444
+ if (!SIGN_IN_METHODS.includes(name) || more.length) {
445
+ return { output: `Unknown sign-in method "${arg}". Methods: ${SIGN_IN_METHODS.join(", ")}, e.g. set-methods google password=required totp.`, exitCode: 1 };
446
+ }
447
+ const method = name;
448
+ if (policy !== void 0 && (method === "totp" || !SECOND_FACTOR_POLICIES.includes(policy))) {
449
+ return {
450
+ output: `"${arg}": google and password take a policy for the authenticator app as a second factor: ${SECOND_FACTOR_POLICIES.join(", ")}.`,
451
+ exitCode: 1
452
+ };
453
+ }
454
+ next[method] = method === "totp" ? {} : { secondFactor: policy ?? "none" };
455
+ }
456
+ if (!anyOn(signsUp, next)) {
457
+ const usable = SIGN_IN_METHODS.filter(signsUp);
458
+ return {
459
+ output: `That would leave no sign-in method on that creates users, so nobody could sign up. On this server these do: ${usable.join(", ")}.`,
460
+ exitCode: 1
461
+ };
418
462
  }
419
463
  await settings.changeMethods(() => next, null);
420
464
  }
421
465
  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));
466
+ const describe = (method) => {
467
+ const policy = policyOf(method, current);
468
+ return method !== "totp" && policy !== "none" ? `${method} (authenticator app ${policy})` : method;
469
+ };
470
+ const on = SIGN_IN_METHODS.filter((method) => isOn(method, configured, current)).map(describe);
471
+ const off = SIGN_IN_METHODS.filter((method) => configured(method) && !isOn(method, configured, current));
424
472
  return { output: `Switched on: ${on.join(", ")}.${off.length ? ` Switched off: ${off.join(", ")}.` : ""}`, exitCode: 0 };
425
473
  }
474
+ async function removeTotp(email, env) {
475
+ if (!isValidEmail(email)) return { output: `"${email ?? ""}" is not an e-mail address.
476
+
477
+ ${usage}`, exitCode: 1 };
478
+ const users = new Users(await loadStore(env));
479
+ const address = normalizeEmail(email);
480
+ const user = await users.findByEmail(address);
481
+ if (!user) return { output: `No user has the e-mail address ${address}.`, exitCode: 1 };
482
+ const removed = await users.removeTotp(user.id);
483
+ return {
484
+ output: removed ? `Removed the authenticator app of ${address}.` : `${address} has no authenticator app.`,
485
+ exitCode: 0
486
+ };
487
+ }
426
488
  async function createUser(email, io, env) {
427
489
  if (!isValidEmail(email)) return { output: `"${email ?? ""}" is not an e-mail address.
428
490