@madauth/server 0.2.0-beta.2 → 0.3.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/dist/bin.js CHANGED
@@ -2,29 +2,35 @@
2
2
  import {
3
3
  createTablesSql,
4
4
  upgradeTablesSql
5
- } from "./chunk-CNL562LZ.js";
5
+ } from "./chunk-56MOVFO4.js";
6
6
  import {
7
7
  madauthSchema
8
- } from "./chunk-XNBWZZHT.js";
8
+ } from "./chunk-CD7Y4T66.js";
9
9
  import {
10
- Roles,
10
+ SIGN_IN_METHODS,
11
+ Settings,
11
12
  Users,
13
+ anyOn,
12
14
  checkPasswordPolicy,
15
+ claimsFromJson,
13
16
  hashPassword,
17
+ isOn,
14
18
  isValidEmail,
15
19
  loadConfig,
16
20
  loadStore,
17
21
  loadUserStoreConfig,
18
22
  normalizeEmail,
19
- parseRoles
20
- } from "./chunk-5CKEMC6M.js";
23
+ parseClaims,
24
+ parseRoles,
25
+ passwordAccountKey
26
+ } from "./chunk-ODITZLMM.js";
21
27
  import {
22
28
  generateSigningKey
23
29
  } from "./chunk-JDQLSL36.js";
24
30
  import {
25
31
  DEV_WEBHOOK_RECEIVER_URL,
26
32
  generateWebhookSecret
27
- } from "./chunk-6EJDVZCD.js";
33
+ } from "./chunk-NPNM26J3.js";
28
34
 
29
35
  // src/cli.ts
30
36
  import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -44,10 +50,17 @@ Commands:
44
50
  generate-webhook-secret Print a new secret to use as WEBHOOK_SECRET (madAuth and your receiver)
45
51
  create-user <email> Create a user with a password (asks for it), already verified.
46
52
  Uses DATABASE_URL and PASSWORD_MIN_LENGTH from the environment.
47
- set-roles <email> [role...] Set the roles of an e-mail address, e.g. to make the first admin:
53
+ set-roles <email> [role...] Set the roles of a user (the claim "roles"), e.g. to make the first admin:
48
54
  set-roles you@example.com admin. Without roles, removes them all.
49
- Uses DATABASE_URL from the environment.
50
- get-roles <email> Print the roles of an e-mail address.
55
+ The user must exist: signed in once, or created with create-user.
56
+ get-roles <email> Print the roles of a user.
57
+ set-claims <email> <json> Replace the claims of a user, e.g. '{"roles":["admin"],"plan":"pro"}'.
58
+ 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).
51
64
  schema [--dialect <name>] [--from <version>]
52
65
  Print the SQL that creates madAuth's tables for a custom store adapter.
53
66
  Dialects: postgres (default), mysql, sqlite. With --from, print only the
@@ -166,7 +179,8 @@ ${usage}`, exitCode: 1 };
166
179
  }
167
180
  const { values: options, positionals } = parsed;
168
181
  const [command, ...rest] = positionals;
169
- const maxArguments = command === "set-roles" ? Infinity : command === "create-user" || command === "get-roles" ? 1 : 0;
182
+ const argumentLimits = { "set-roles": Infinity, "set-methods": Infinity, "set-claims": 2, "create-user": 1, "get-roles": 1, "get-claims": 1 };
183
+ const maxArguments = command ? argumentLimits[command] ?? 0 : 0;
170
184
  if (rest.length > maxArguments) {
171
185
  return { output: `Unexpected argument: ${rest.at(-1)}
172
186
 
@@ -203,8 +217,11 @@ ${usage}`, exitCode: 1 };
203
217
  if (command === "create-user") {
204
218
  return await createUser(rest[0], io, env);
205
219
  }
206
- if (command === "set-roles" || command === "get-roles") {
207
- return await roles(command, rest[0], rest.slice(1), env);
220
+ if (command === "set-roles" || command === "get-roles" || command === "set-claims" || command === "get-claims") {
221
+ return await claims(command, rest[0], rest.slice(1), env);
222
+ }
223
+ if (command === "set-methods" || command === "get-methods") {
224
+ return await methods(command, rest, env);
208
225
  }
209
226
  } catch (e) {
210
227
  return { output: e instanceof Error ? e.message : String(e), exitCode: 1 };
@@ -239,14 +256,14 @@ async function init(options, io) {
239
256
  }
240
257
  const clientSecret = clientId ? (options["google-client-secret"] ?? await askClientSecret()).trim() : "";
241
258
  const password = options.password ?? /^y/i.test(await answer(void 0, "E-mail & password sign-in? (yes/no)", "yes"));
242
- if (!password && (options.database !== void 0 || options["webhook-url"] !== void 0)) {
243
- return fail("--database and --webhook-url are for e-mail & password sign-in, which is turned off.");
259
+ if (!password && options["webhook-url"] !== void 0) {
260
+ return fail("--webhook-url is for e-mail & password sign-in, which is turned off.");
244
261
  }
245
262
  if (!clientId && !password) {
246
263
  return fail("No sign-in method is chosen. Give a Google client ID, turn on e-mail & password sign-in, or both.");
247
264
  }
248
- const database = password ? await answer(options.database, "Database for the users", "sqlite:./madauth.db") : "";
249
- if (password && !/^(sqlite|dynamodb):./.test(database)) {
265
+ const database = await answer(options.database, "Database for the users", "sqlite:./madauth.db");
266
+ if (!/^(sqlite|dynamodb):./.test(database)) {
250
267
  return fail(`The database must be sqlite:<path> or dynamodb:<table> but is "${database}".`);
251
268
  }
252
269
  const webhookUrl = password ? await answer(options["webhook-url"], "Webhook URL, where madAuth hands over its e-mails", "http://localhost:8790/webhook") : "";
@@ -271,7 +288,7 @@ ${name}=${value}
271
288
  variable("ALLOWED_ORIGINS", origin, "Origins of your apps, comma-separated. Only these may call the server."),
272
289
  clientId && variable("GOOGLE_CLIENT_ID", clientId, "Google sign-in: your OAuth client ID, of the type Web application."),
273
290
  clientSecret && variable("GOOGLE_CLIENT_SECRET", clientSecret, "Client secret of the same client. It enables the redirect flow."),
274
- password && variable("DATABASE_URL", database, "E-mail & password sign-in: where the users are stored."),
291
+ variable("DATABASE_URL", database, "Where the users are stored, whichever way they sign in."),
275
292
  password && variable("WEBHOOK_URL", webhookUrl, "Your webhook receiver. madAuth hands its e-mails to it."),
276
293
  password && variable("WEBHOOK_SECRET", generateWebhookSecret(), "Signs every webhook call. Your receiver needs the same one."),
277
294
  password && variable(
@@ -281,7 +298,7 @@ ${name}=${value}
281
298
  ),
282
299
  variable("PORT", String(PORT), "Port the server listens on.")
283
300
  ].filter(Boolean).join("");
284
- await loadConfig({ ...parseEnv(content), DATABASE_URL: void 0 }, password ? { store: {} } : {});
301
+ await loadConfig({ ...parseEnv(content), DATABASE_URL: void 0 }, { store: {} });
285
302
  if (options.force) rmSync(file, { force: true });
286
303
  writeFileSync(file, content, { mode: 384, flag: "wx" });
287
304
  const google = clientSecret ? "GoogleRedirect" : clientId ? "GoogleFedcm" : void 0;
@@ -337,25 +354,74 @@ function nextSteps(file, origin, google, password) {
337
354
  ].filter((step) => !!step);
338
355
  return steps.map((lines, i) => `${i + 1}. ${lines.map((line, j) => j && line ? ` ${line}` : line).join("\n")}`).join("\n\n");
339
356
  }
340
- async function roles(command, email, names, env) {
357
+ async function claims(command, email, args, env) {
341
358
  if (!isValidEmail(email)) return { output: `"${email ?? ""}" is not an e-mail address.
342
359
 
343
360
  ${usage}`, exitCode: 1 };
344
- const store = await loadStore(env);
361
+ const users = new Users(await loadStore(env));
345
362
  const address = normalizeEmail(email);
346
- const assignments = new Roles(store);
347
- if (command === "set-roles") {
348
- const parsed = parseRoles(names);
349
- if (!parsed) {
363
+ const user = await users.findByEmail(address);
364
+ if (!user) return { output: `No user has the e-mail address ${address}. Users exist once they signed in, or with create-user.`, exitCode: 1 };
365
+ let current = claimsFromJson(user.claims);
366
+ if (command === "set-roles" || command === "set-claims") {
367
+ let next;
368
+ if (command === "set-roles") {
369
+ const roles2 = parseRoles(args);
370
+ if (!roles2) {
371
+ return {
372
+ output: 'Role names consist of lower-case letters, digits, "-" and "_", and start with a letter, e.g. admin.',
373
+ exitCode: 1
374
+ };
375
+ }
376
+ const { roles: _, ...rest } = current;
377
+ next = parseClaims(roles2.length ? { ...rest, roles: roles2 } : rest);
378
+ } else {
379
+ let parsed;
380
+ try {
381
+ parsed = JSON.parse(args[0] ?? "");
382
+ } catch {
383
+ return { output: `The claims must be JSON, e.g. '{"roles":["admin"]}'.
384
+
385
+ ${usage}`, exitCode: 1 };
386
+ }
387
+ next = parseClaims(parsed);
388
+ }
389
+ if (!next) {
350
390
  return {
351
- output: 'Role names consist of lower-case letters, digits, "-" and "_", and start with a letter, e.g. admin.',
391
+ output: 'Claims are a JSON object of at most 2048 characters whose keys are names (letters, digits and "_", starting with a letter); "roles" is a list of role names.',
352
392
  exitCode: 1
353
393
  };
354
394
  }
355
- await assignments.set(address, parsed, null);
395
+ await users.updateUser(user.id, { claims: Object.keys(next).length ? JSON.stringify(next) : null });
396
+ current = next;
397
+ }
398
+ if (command === "set-claims" || command === "get-claims") {
399
+ return { output: `${address}: ${JSON.stringify(current)}`, exitCode: 0 };
400
+ }
401
+ const roles = Array.isArray(current.roles) ? current.roles : [];
402
+ return { output: roles.length ? `${address}: ${roles.join(" ")}` : `${address} has no roles.`, exitCode: 0 };
403
+ }
404
+ async function methods(command, names, env) {
405
+ const config = await loadConfig(env);
406
+ const available = (method) => method === "google" ? !!config.google : !!config.password;
407
+ const settings = new Settings(config.store);
408
+ 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
+ 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 };
418
+ }
419
+ await settings.changeMethods(() => next, null);
356
420
  }
357
- const current = await assignments.get(address);
358
- return { output: current.length ? `${address}: ${current.join(" ")}` : `${address} has no roles.`, exitCode: 0 };
421
+ 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));
424
+ return { output: `Switched on: ${on.join(", ")}.${off.length ? ` Switched off: ${off.join(", ")}.` : ""}`, exitCode: 0 };
359
425
  }
360
426
  async function createUser(email, io, env) {
361
427
  if (!isValidEmail(email)) return { output: `"${email ?? ""}" is not an e-mail address.
@@ -366,12 +432,13 @@ ${usage}`, exitCode: 1 };
366
432
  const policy = checkPasswordPolicy(password, minLength);
367
433
  if (policy) return { output: policy, exitCode: 1 };
368
434
  const users = new Users(store);
369
- const user = await users.createPasswordUser({
435
+ const passwordHash = await hashPassword(password);
436
+ const user = await users.createUser({
370
437
  email: email.trim(),
371
438
  emailNormalized: normalizeEmail(email),
372
439
  name: null,
373
- passwordHash: await hashPassword(password),
374
- emailVerified: true
440
+ emailVerified: true,
441
+ account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
375
442
  });
376
443
  if (!user) return { output: `A user with the e-mail address ${email} already exists.`, exitCode: 1 };
377
444
  return { output: `Created user ${user.id} (${user.email}).`, exitCode: 0 };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  madauthSchema
3
- } from "./chunk-XNBWZZHT.js";
3
+ } from "./chunk-CD7Y4T66.js";
4
4
 
5
5
  // src/store/sql.ts
6
6
  function tableName(model) {
@@ -21,8 +21,30 @@ var upgrades = [
21
21
  sql: (dialect) => `ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("wrongCodes")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`
22
22
  },
23
23
  {
24
+ // Roles were per e-mail address in their own table; since version 4 they are one of the user's claims.
24
25
  version: 3,
25
- sql: (dialect) => createTablesSql(dialect, { version: 3, models: { role: madauthSchema.models.role } })
26
+ sql: (dialect) => createTablesSql(dialect, {
27
+ version: 3,
28
+ models: {
29
+ role: {
30
+ fields: {
31
+ id: { type: "string", primaryKey: true },
32
+ roles: { type: "string" },
33
+ updatedAt: { type: "number" },
34
+ updatedBy: { type: "string", nullable: true }
35
+ }
36
+ }
37
+ }
38
+ })
39
+ },
40
+ {
41
+ version: 4,
42
+ sql: (dialect) => [
43
+ `ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("claims")} ${columnTypes[dialect].string};`,
44
+ `ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("email")} ${columnTypes[dialect].string};`,
45
+ `DROP TABLE ${tableName("role")};`,
46
+ createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
47
+ ].join("\n")
26
48
  }
27
49
  ];
28
50
  function upgradeTablesSql(dialect, fromVersion) {
@@ -1,7 +1,8 @@
1
1
  // src/store/schema.ts
2
2
  var madauthSchema = {
3
- version: 3,
3
+ version: 4,
4
4
  models: {
5
+ /** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
5
6
  user: {
6
7
  fields: {
7
8
  id: { type: "string", primaryKey: true },
@@ -13,10 +14,12 @@ var madauthSchema = {
13
14
  lastMailAt: { type: "number" },
14
15
  /** Wrong e-mail codes in a row, counted across e-mails (since version 2). */
15
16
  wrongCodes: { type: "number" },
17
+ /** What admins attached to the user, as JSON, e.g. `{"roles":["admin"]}` (since version 4). */
18
+ claims: { type: "string", nullable: true },
16
19
  createdAt: { type: "number" }
17
20
  }
18
21
  },
19
- /** How a user signs in. `key` is e.g. `password:<userId>`; later `google:<sub>` or `totp:<userId>`. */
22
+ /** How a user signs in. `key` is `password:<userId>` or `google:<sub>`; later e.g. `totp:<userId>`. */
20
23
  account: {
21
24
  fields: {
22
25
  id: { type: "string", primaryKey: true },
@@ -25,6 +28,8 @@ var madauthSchema = {
25
28
  secret: { type: "string", nullable: true },
26
29
  failedAttempts: { type: "number" },
27
30
  lockedUntil: { type: "number" },
31
+ /** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
32
+ email: { type: "string", nullable: true },
28
33
  createdAt: { type: "number" }
29
34
  }
30
35
  },
@@ -39,16 +44,13 @@ var madauthSchema = {
39
44
  expiresAt: { type: "number" }
40
45
  }
41
46
  },
42
- /**
43
- * The roles of an e-mail address (since version 3). `id` is the normalized address, so the roles
44
- * apply whichever way its owner signs in. `roles` holds the names separated by spaces.
45
- */
46
- role: {
47
+ /** A setting admins change at runtime, as JSON (since version 4), e.g. `methods`: which sign-in methods are on. */
48
+ setting: {
47
49
  fields: {
48
50
  id: { type: "string", primaryKey: true },
49
- roles: { type: "string" },
51
+ value: { type: "string" },
50
52
  updatedAt: { type: "number" },
51
- /** The address of the admin who set them; empty when set from the command line. */
53
+ /** The address of the admin who set it; empty when set from the command line. */
52
54
  updatedBy: { type: "string", nullable: true }
53
55
  }
54
56
  }
@@ -34,7 +34,7 @@ function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
34
34
  email_verified: user.email ? true : void 0,
35
35
  name: user.name,
36
36
  picture: user.picture,
37
- roles: user.roles?.length ? user.roles : void 0,
37
+ claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
38
38
  amr
39
39
  };
40
40
  return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
@@ -56,7 +56,7 @@ function userFromClaims(claims) {
56
56
  if (claims.email) user.email = claims.email;
57
57
  if (claims.name) user.name = claims.name;
58
58
  if (claims.picture) user.picture = claims.picture;
59
- if (Array.isArray(claims.roles) && claims.roles.length) user.roles = claims.roles;
59
+ if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
60
60
  return user;
61
61
  }
62
62
 
@@ -7,11 +7,16 @@ var WEBHOOK_TYPES = [
7
7
  "signup.before",
8
8
  "user.created",
9
9
  "email.verified",
10
- "password.reset",
10
+ "email.password_reset",
11
11
  "user.signed_in",
12
12
  "user.deleted",
13
- "roles.changed"
13
+ "user.claims_changed"
14
14
  ];
15
+ var WEBHOOK_TIMEOUT_MS = 1e4;
16
+ var MAX_LOCALE_LENGTH = 35;
17
+ function localeOf(value) {
18
+ return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
19
+ }
15
20
  var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
16
21
  var SECRET_PREFIX = "whsec_";
17
22
  var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
@@ -93,6 +98,8 @@ function createWebhookClient(settings, fetchImpl = fetch) {
93
98
 
94
99
  export {
95
100
  WEBHOOK_TYPES,
101
+ WEBHOOK_TIMEOUT_MS,
102
+ localeOf,
96
103
  REQUIRED_EMAIL_TYPES,
97
104
  DEV_WEBHOOK_RECEIVER_URL,
98
105
  WEBHOOK_TOLERANCE_SECONDS,