@madauth/server 0.2.0-beta.1 → 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.
@@ -38,8 +38,9 @@ var init_schema = __esm({
38
38
  "src/store/schema.ts"() {
39
39
  "use strict";
40
40
  madauthSchema = {
41
- version: 3,
41
+ version: 4,
42
42
  models: {
43
+ /** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
43
44
  user: {
44
45
  fields: {
45
46
  id: { type: "string", primaryKey: true },
@@ -51,10 +52,12 @@ var init_schema = __esm({
51
52
  lastMailAt: { type: "number" },
52
53
  /** Wrong e-mail codes in a row, counted across e-mails (since version 2). */
53
54
  wrongCodes: { type: "number" },
55
+ /** What admins attached to the user, as JSON, e.g. `{"roles":["admin"]}` (since version 4). */
56
+ claims: { type: "string", nullable: true },
54
57
  createdAt: { type: "number" }
55
58
  }
56
59
  },
57
- /** How a user signs in. `key` is e.g. `password:<userId>`; later `google:<sub>` or `totp:<userId>`. */
60
+ /** How a user signs in. `key` is `password:<userId>` or `google:<sub>`; later e.g. `totp:<userId>`. */
58
61
  account: {
59
62
  fields: {
60
63
  id: { type: "string", primaryKey: true },
@@ -63,6 +66,8 @@ var init_schema = __esm({
63
66
  secret: { type: "string", nullable: true },
64
67
  failedAttempts: { type: "number" },
65
68
  lockedUntil: { type: "number" },
69
+ /** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
70
+ email: { type: "string", nullable: true },
66
71
  createdAt: { type: "number" }
67
72
  }
68
73
  },
@@ -77,16 +82,13 @@ var init_schema = __esm({
77
82
  expiresAt: { type: "number" }
78
83
  }
79
84
  },
80
- /**
81
- * The roles of an e-mail address (since version 3). `id` is the normalized address, so the roles
82
- * apply whichever way its owner signs in. `roles` holds the names separated by spaces.
83
- */
84
- role: {
85
+ /** A setting admins change at runtime, as JSON (since version 4), e.g. `methods`: which sign-in methods are on. */
86
+ setting: {
85
87
  fields: {
86
88
  id: { type: "string", primaryKey: true },
87
- roles: { type: "string" },
89
+ value: { type: "string" },
88
90
  updatedAt: { type: "number" },
89
- /** The address of the admin who set them; empty when set from the command line. */
91
+ /** The address of the admin who set it; empty when set from the command line. */
90
92
  updatedBy: { type: "string", nullable: true }
91
93
  }
92
94
  }
@@ -141,8 +143,30 @@ var init_sql = __esm({
141
143
  sql: (dialect) => `ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("wrongCodes")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`
142
144
  },
143
145
  {
146
+ // Roles were per e-mail address in their own table; since version 4 they are one of the user's claims.
144
147
  version: 3,
145
- sql: (dialect) => createTablesSql(dialect, { version: 3, models: { role: madauthSchema.models.role } })
148
+ sql: (dialect) => createTablesSql(dialect, {
149
+ version: 3,
150
+ models: {
151
+ role: {
152
+ fields: {
153
+ id: { type: "string", primaryKey: true },
154
+ roles: { type: "string" },
155
+ updatedAt: { type: "number" },
156
+ updatedBy: { type: "string", nullable: true }
157
+ }
158
+ }
159
+ }
160
+ })
161
+ },
162
+ {
163
+ version: 4,
164
+ sql: (dialect) => [
165
+ `ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("claims")} ${columnTypes[dialect].string};`,
166
+ `ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("email")} ${columnTypes[dialect].string};`,
167
+ `DROP TABLE ${tableName("role")};`,
168
+ createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
169
+ ].join("\n")
146
170
  }
147
171
  ];
148
172
  }
@@ -29999,11 +30023,16 @@ var WEBHOOK_TYPES = [
29999
30023
  "signup.before",
30000
30024
  "user.created",
30001
30025
  "email.verified",
30002
- "password.reset",
30026
+ "email.password_reset",
30003
30027
  "user.signed_in",
30004
30028
  "user.deleted",
30005
- "roles.changed"
30029
+ "user.claims_changed"
30006
30030
  ];
30031
+ var WEBHOOK_TIMEOUT_MS = 1e4;
30032
+ var MAX_LOCALE_LENGTH = 35;
30033
+ function localeOf(value) {
30034
+ return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
30035
+ }
30007
30036
  var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
30008
30037
  var SECRET_PREFIX = "whsec_";
30009
30038
  var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
@@ -30124,19 +30153,18 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
30124
30153
  if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
30125
30154
  throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
30126
30155
  }
30127
- const sendsEmails = !!(overrides.store || read("DATABASE_URL"));
30128
- const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"), sendsEmails);
30129
- if (sendsEmails && !webhook) {
30156
+ const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
30157
+ const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
30158
+ const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
30159
+ if (!clientId && !password) {
30130
30160
  throw new ConfigError(
30131
- `WEBHOOK_URL is not set. E-mail & password sign-in sends its e-mails through your webhook. 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. See "Webhooks" in docs/server.md.`
30161
+ `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered. See docs/server.md.`
30132
30162
  );
30133
30163
  }
30134
30164
  const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
30135
- let password;
30136
- if (store) password = { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")), store };
30137
- if (!clientId && !password) {
30165
+ if (!store) {
30138
30166
  throw new ConfigError(
30139
- "No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in and/or DATABASE_URL (e.g. sqlite:./madauth.db) for e-mail & password sign-in. See docs/server.md."
30167
+ "DATABASE_URL is not set. madAuth stores every user, whichever way they sign in: set e.g. DATABASE_URL=sqlite:./madauth.db or DATABASE_URL=dynamodb:<table>. See docs/server.md."
30140
30168
  );
30141
30169
  }
30142
30170
  const ttlValue = read("SESSION_TTL");
@@ -30159,6 +30187,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
30159
30187
  renewalTtlSeconds,
30160
30188
  cookieDomain: read("COOKIE_DOMAIN"),
30161
30189
  google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
30190
+ store,
30162
30191
  password,
30163
30192
  webhook
30164
30193
  };
@@ -30204,8 +30233,9 @@ async function dynamoDbStore(tableName2) {
30204
30233
  const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
30205
30234
  return createDynamoDbAdapter2({ tableName: tableName2 });
30206
30235
  }
30236
+ var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
30207
30237
  var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
30208
- function webhookFromEnv(url, secret, events, sendsEmails) {
30238
+ function webhookFromEnv(url, secret, events) {
30209
30239
  if (!url) {
30210
30240
  if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
30211
30241
  return void 0;
@@ -30235,24 +30265,22 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
30235
30265
  const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
30236
30266
  if (!selected.size) {
30237
30267
  throw new ConfigError(
30238
- "WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. " + (sendsEmails ? `E-mail & password sign-in needs ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
30268
+ `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:
30239
30269
 
30240
30270
  WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
30241
30271
 
30242
- ` : "For example:\n\nWEBHOOK_EVENTS=user.signed_in\n\n") + allTypes
30272
+ For one that only wants to know who signed in:
30273
+
30274
+ WEBHOOK_EVENTS=user.signed_in
30275
+
30276
+ ` + allTypes
30243
30277
  );
30244
30278
  }
30245
30279
  const unknown = [...selected].filter((e6) => !WEBHOOK_TYPES.includes(e6));
30246
30280
  if (unknown.length) {
30247
- throw new ConfigError(`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. Known types: ${WEBHOOK_TYPES.join(", ")}.`);
30248
- }
30249
- const missing = sendsEmails ? REQUIRED_EMAIL_TYPES.filter((type) => !selected.has(type)) : [];
30250
- if (missing.length) {
30281
+ const renamed = unknown.filter((e6) => e6 in RENAMED_TYPES).map((e6) => `${e6} is now ${RENAMED_TYPES[e6]}`);
30251
30282
  throw new ConfigError(
30252
- `WEBHOOK_EVENTS lacks ${missing.join(" and ")}. E-mail & password sign-in can't work without these e-mails, so your webhook receiver must send them:
30253
-
30254
- WEBHOOK_EVENTS=${[...selected, ...missing].join(",")}
30255
- `
30283
+ `WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
30256
30284
  );
30257
30285
  }
30258
30286
  return { url: parsed.href, secret, events: selected };
@@ -33551,6 +33579,63 @@ var cors = (options) => {
33551
33579
  };
33552
33580
  };
33553
33581
 
33582
+ // src/claims.ts
33583
+ var ADMIN_ROLE = "admin";
33584
+ var MAX_ROLES = 20;
33585
+ var MAX_CLAIMS_LENGTH = 2048;
33586
+ var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
33587
+ var CLAIM_NAME = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
33588
+ function parseRoles(input) {
33589
+ if (!Array.isArray(input)) return null;
33590
+ if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
33591
+ const roles = [...new Set(input)].sort();
33592
+ return roles.length <= MAX_ROLES ? roles : null;
33593
+ }
33594
+ function parseClaims(input) {
33595
+ if (!isJsonObject(input)) return null;
33596
+ const claims = {};
33597
+ for (const key of Object.keys(input).sort()) {
33598
+ const value = input[key];
33599
+ if (!CLAIM_NAME.test(key) || !isJsonValue(value)) return null;
33600
+ if (key === "roles") {
33601
+ const roles = parseRoles(value);
33602
+ if (!roles) return null;
33603
+ claims[key] = roles;
33604
+ } else {
33605
+ claims[key] = value;
33606
+ }
33607
+ }
33608
+ return JSON.stringify(claims).length <= MAX_CLAIMS_LENGTH ? claims : null;
33609
+ }
33610
+ function isJsonObject(value) {
33611
+ return !!value && typeof value === "object" && !Array.isArray(value);
33612
+ }
33613
+ function isJsonValue(value) {
33614
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
33615
+ if (typeof value === "number") return Number.isFinite(value);
33616
+ if (Array.isArray(value)) return value.every(isJsonValue);
33617
+ return isJsonObject(value) && Object.values(value).every(isJsonValue);
33618
+ }
33619
+ function sameClaims(a6, b6) {
33620
+ return JSON.stringify(a6 ?? {}) === JSON.stringify(b6 ?? {});
33621
+ }
33622
+ function claimsFromJson(json2) {
33623
+ if (!json2) return {};
33624
+ try {
33625
+ const parsed = JSON.parse(json2);
33626
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
33627
+ } catch {
33628
+ return {};
33629
+ }
33630
+ }
33631
+ function rolesOf(user) {
33632
+ const roles = user.claims?.roles;
33633
+ return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
33634
+ }
33635
+ function isAdmin(user) {
33636
+ return rolesOf(user).includes(ADMIN_ROLE);
33637
+ }
33638
+
33554
33639
  // src/password.ts
33555
33640
  import { createHash as createHash4, createHmac as createHmac3, hkdfSync, randomBytes as randomBytes2, randomInt, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
33556
33641
  var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
@@ -33626,45 +33711,6 @@ function safeEqual(a6, b6) {
33626
33711
  return x2.length === y2.length && timingSafeEqual2(x2, y2);
33627
33712
  }
33628
33713
 
33629
- // src/roles.ts
33630
- var ADMIN_ROLE = "admin";
33631
- var MAX_ROLES = 20;
33632
- var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
33633
- function parseRoles(input) {
33634
- if (!Array.isArray(input)) return null;
33635
- if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
33636
- const roles = [...new Set(input)].sort();
33637
- return roles.length <= MAX_ROLES ? roles : null;
33638
- }
33639
- function sameRoles(a6, b6) {
33640
- return a6.length === b6.length && a6.every((role, i6) => role === b6[i6]);
33641
- }
33642
- var Roles = class {
33643
- constructor(store) {
33644
- this.store = store;
33645
- }
33646
- /** The address's roles, sorted; empty if it has none. */
33647
- async get(emailNormalized) {
33648
- const record = await this.store.findOne("role", { id: emailNormalized });
33649
- return typeof record?.roles === "string" && record.roles ? record.roles.split(" ").filter(Boolean) : [];
33650
- }
33651
- /** Replaces the address's roles. `updatedBy` is the address of the admin, or null for the command line. */
33652
- async set(emailNormalized, roles, updatedBy) {
33653
- if (!roles.length) {
33654
- await this.remove(emailNormalized);
33655
- return;
33656
- }
33657
- const data = { roles: roles.join(" "), updatedAt: Date.now(), updatedBy };
33658
- if (await this.store.update("role", { id: emailNormalized }, data) === 1) return;
33659
- if (!await this.store.create("role", { id: emailNormalized, ...data })) {
33660
- await this.store.update("role", { id: emailNormalized }, data);
33661
- }
33662
- }
33663
- async remove(emailNormalized) {
33664
- await this.store.delete("role", { id: emailNormalized });
33665
- }
33666
- };
33667
-
33668
33714
  // src/google.ts
33669
33715
  var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
33670
33716
  var googleJwks;
@@ -33689,14 +33735,13 @@ async function verifyGoogleIdToken(idToken, opts) {
33689
33735
  if (payload.nonce !== opts.nonce) {
33690
33736
  return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
33691
33737
  }
33692
- if (payload.email_verified !== true) {
33738
+ if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
33693
33739
  return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
33694
33740
  }
33695
- const user = { id: `google:${payload.sub}` };
33696
- if (typeof payload.email === "string") user.email = payload.email;
33697
- if (typeof payload.name === "string") user.name = payload.name;
33698
- if (typeof payload.picture === "string") user.picture = payload.picture;
33699
- return { ok: true, user };
33741
+ const profile = { sub: payload.sub, email: payload.email };
33742
+ if (typeof payload.name === "string") profile.name = payload.name;
33743
+ if (typeof payload.picture === "string") profile.picture = payload.picture;
33744
+ return { ok: true, profile };
33700
33745
  }
33701
33746
 
33702
33747
  // src/user.ts
@@ -33730,7 +33775,7 @@ function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
33730
33775
  email_verified: user.email ? true : void 0,
33731
33776
  name: user.name,
33732
33777
  picture: user.picture,
33733
- roles: user.roles?.length ? user.roles : void 0,
33778
+ claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
33734
33779
  amr
33735
33780
  };
33736
33781
  return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
@@ -33752,10 +33797,203 @@ function userFromClaims(claims) {
33752
33797
  if (claims.email) user.email = claims.email;
33753
33798
  if (claims.name) user.name = claims.name;
33754
33799
  if (claims.picture) user.picture = claims.picture;
33755
- if (Array.isArray(claims.roles) && claims.roles.length) user.roles = claims.roles;
33800
+ if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
33756
33801
  return user;
33757
33802
  }
33758
33803
 
33804
+ // src/users.ts
33805
+ import { randomBytes as randomBytes3 } from "crypto";
33806
+ var MAX_CODE_ATTEMPTS = 5;
33807
+ var MAX_WRONG_CODES = 10;
33808
+ var newId = (prefix) => `${prefix}_${randomBytes3(16).toString("base64url")}`;
33809
+ function passwordAccountKey(userId) {
33810
+ return `password:${userId}`;
33811
+ }
33812
+ function googleAccountKey(sub) {
33813
+ return `google:${sub}`;
33814
+ }
33815
+ function toMadauthUser(user, profile = {}) {
33816
+ const result = { id: user.id, email: user.email };
33817
+ const name = user.name ?? profile.name;
33818
+ if (name) result.name = name;
33819
+ if (profile.picture) result.picture = profile.picture;
33820
+ const claims = claimsFromJson(user.claims);
33821
+ if (Object.keys(claims).length) result.claims = claims;
33822
+ return result;
33823
+ }
33824
+ var Users = class {
33825
+ /** `codeKey` keys the e-mail codes (see deriveCodeKey); only needed to issue and check them. */
33826
+ constructor(store, codeKey) {
33827
+ this.store = store;
33828
+ this.codeKey = codeKey;
33829
+ }
33830
+ get key() {
33831
+ if (!this.codeKey) throw new Error("Users needs a code key to issue or check e-mail codes.");
33832
+ return this.codeKey;
33833
+ }
33834
+ async findByEmail(emailNormalized) {
33835
+ return await this.store.findOne("user", { emailNormalized });
33836
+ }
33837
+ async findById(id) {
33838
+ return await this.store.findOne("user", { id });
33839
+ }
33840
+ async passwordAccount(userId) {
33841
+ return this.findAccountByKey(passwordAccountKey(userId));
33842
+ }
33843
+ async findAccountByKey(key) {
33844
+ return await this.store.findOne("account", { key });
33845
+ }
33846
+ /**
33847
+ * Creates a user with their first account. Resolves to null if the e-mail address or the account key is
33848
+ * taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or e.g. `google:<sub>`.
33849
+ */
33850
+ async createUser(data) {
33851
+ const user = {
33852
+ id: newId("usr"),
33853
+ email: data.email,
33854
+ emailNormalized: data.emailNormalized,
33855
+ emailVerified: data.emailVerified,
33856
+ name: data.name,
33857
+ sessionVersion: 0,
33858
+ lastMailAt: 0,
33859
+ wrongCodes: 0,
33860
+ claims: null,
33861
+ createdAt: Date.now()
33862
+ };
33863
+ if (!await this.store.create("user", user)) return null;
33864
+ const account = typeof data.account === "function" ? data.account(user.id) : data.account;
33865
+ if (!await this.linkAccount(user.id, account)) {
33866
+ await this.store.delete("user", { id: user.id });
33867
+ return null;
33868
+ }
33869
+ return user;
33870
+ }
33871
+ /**
33872
+ * A provider verified the user's address: an unconfirmed password sign-up with it, whose password nobody
33873
+ * has proven, is dropped (the account and its pending links and codes), and the user counts as verified.
33874
+ */
33875
+ async verifyByProvider(user) {
33876
+ if (user.emailVerified) return;
33877
+ await this.store.delete("account", { key: passwordAccountKey(user.id) });
33878
+ await this.clearVerifications(user.id);
33879
+ await this.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
33880
+ }
33881
+ /** Removes an account record, e.g. one left behind by a deletion that did not finish. */
33882
+ async deleteAccount(id) {
33883
+ await this.store.delete("account", { id });
33884
+ }
33885
+ /** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
33886
+ async linkAccount(userId, account) {
33887
+ const record = {
33888
+ id: newId("acc"),
33889
+ userId,
33890
+ key: account.key,
33891
+ secret: account.secret ?? null,
33892
+ failedAttempts: 0,
33893
+ lockedUntil: 0,
33894
+ email: account.email ?? null,
33895
+ createdAt: Date.now()
33896
+ };
33897
+ return await this.store.create("account", record) ? record : null;
33898
+ }
33899
+ /**
33900
+ * Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
33901
+ */
33902
+ async deleteById(id) {
33903
+ const user = await this.findById(id);
33904
+ if (!user) return false;
33905
+ if (await this.store.delete("user", { id: user.id }) !== 1) return false;
33906
+ try {
33907
+ await this.store.delete("account", { userId: user.id });
33908
+ await this.store.delete("verification", { userId: user.id });
33909
+ } catch (e6) {
33910
+ console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e6.message}`);
33911
+ }
33912
+ return true;
33913
+ }
33914
+ async updateUser(id, patch) {
33915
+ await this.store.update("user", { id }, patch);
33916
+ }
33917
+ async updateAccount(id, patch) {
33918
+ await this.store.update("account", { id }, patch);
33919
+ }
33920
+ /**
33921
+ * Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
33922
+ * was read. Resolves to false if another request counted an attempt meanwhile.
33923
+ */
33924
+ async countAttempt(account, patch) {
33925
+ const changed = await this.store.update("account", { id: account.id, failedAttempts: account.failedAttempts }, patch);
33926
+ return changed === 1;
33927
+ }
33928
+ /** Deletes the user's pending verifications and password resets; their links and codes stop working. */
33929
+ async clearVerifications(userId) {
33930
+ await this.store.delete("verification", { userId });
33931
+ }
33932
+ /**
33933
+ * Starts a verification or password reset: replaces the user's earlier one for the same purpose and
33934
+ * returns the link token and the code for the e-mail. Only their hashes are stored.
33935
+ */
33936
+ async issueVerification(userId, purpose, ttlMs) {
33937
+ await this.store.delete("verification", { userId, purpose });
33938
+ const token = generateLinkToken();
33939
+ const code = generateCode();
33940
+ const record = {
33941
+ id: hashLinkToken(token),
33942
+ userId,
33943
+ purpose,
33944
+ codeHash: hashCode(this.key, code),
33945
+ attempts: 0,
33946
+ expiresAt: Date.now() + ttlMs
33947
+ };
33948
+ await this.store.create("verification", record);
33949
+ return { token, code };
33950
+ }
33951
+ /** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
33952
+ async consumeLinkToken(token, purpose) {
33953
+ const id = hashLinkToken(token);
33954
+ const record = await this.store.findOne("verification", { id, purpose });
33955
+ return record ? this.consume(record) : null;
33956
+ }
33957
+ /**
33958
+ * Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
33959
+ * MAX_WRONG_CODES wrong codes in a row the user's codes stop working until an e-mail link is used.
33960
+ */
33961
+ async consumeCode(user, purpose, code) {
33962
+ if ((user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
33963
+ const record = await this.store.findOne("verification", { userId: user.id, purpose });
33964
+ if (!record) return { userId: null, locked: false };
33965
+ const attempts = record.attempts + 1;
33966
+ if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
33967
+ return { userId: null, locked: false };
33968
+ }
33969
+ const wrongCodes = await this.countWrongCode(user);
33970
+ if (wrongCodes === null) return { userId: null, locked: false };
33971
+ if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
33972
+ if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
33973
+ if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
33974
+ return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
33975
+ }
33976
+ return { userId: await this.consume(record), locked: false };
33977
+ }
33978
+ /** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
33979
+ async countWrongCode(user) {
33980
+ let current = user;
33981
+ for (let attempt = 0; attempt < 3 && current; attempt++) {
33982
+ const wrongCodes = (current.wrongCodes ?? 0) + 1;
33983
+ if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
33984
+ return wrongCodes;
33985
+ }
33986
+ current = await this.findById(user.id);
33987
+ }
33988
+ return null;
33989
+ }
33990
+ async consume(record) {
33991
+ const deleted = await this.store.delete("verification", { id: record.id });
33992
+ if (deleted !== 1 || record.expiresAt <= Date.now()) return null;
33993
+ return record.userId;
33994
+ }
33995
+ };
33996
+
33759
33997
  // src/routes/google.ts
33760
33998
  var NONCE_COOKIE = "madauth_nonce";
33761
33999
  var NONCE_TYP = "madauth-nonce+jwt";
@@ -33771,10 +34009,57 @@ async function pkceChallenge(verifier) {
33771
34009
  return base64url_exports.encode(new Uint8Array(digest2));
33772
34010
  }
33773
34011
  function googleRoutes(app, ctx, google) {
33774
- const { config, keys, secure } = ctx;
34012
+ const { config, keys, secure, users } = ctx;
33775
34013
  const { issuer } = config;
33776
34014
  const callbackUrl = `${issuer}/auth/google/callback`;
34015
+ const resolveUser = async (profile, locale) => {
34016
+ const key = googleAccountKey(profile.sub);
34017
+ const emailNormalized = normalizeEmail(profile.email);
34018
+ const signedIn = (user) => ({ ok: true, user: toMadauthUser(user, profile), sv: user.sessionVersion });
34019
+ const account = await users.findAccountByKey(key);
34020
+ if (account) {
34021
+ const user = await users.findById(account.userId);
34022
+ if (user) {
34023
+ if (account.email !== profile.email) await users.updateAccount(account.id, { email: profile.email });
34024
+ return signedIn(user);
34025
+ }
34026
+ await users.deleteAccount(account.id);
34027
+ }
34028
+ for (let attempt = 0; attempt < 2; attempt++) {
34029
+ const existing = await users.findByEmail(emailNormalized);
34030
+ if (existing) {
34031
+ await users.verifyByProvider(existing);
34032
+ if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
34033
+ const linked = await users.findAccountByKey(key);
34034
+ const owner = linked && await users.findById(linked.userId);
34035
+ if (!owner) return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
34036
+ return signedIn(owner);
34037
+ }
34038
+ return signedIn(existing);
34039
+ }
34040
+ if (attempt === 0) {
34041
+ const check2 = await ctx.checkSignup({ email: profile.email, name: profile.name, locale, method: "google" });
34042
+ if (!check2.ok) {
34043
+ return check2.reason === "rejected" ? { ok: false, error: "signup_rejected", message: check2.message } : { ok: false, error: "temporarily_unavailable", message: "Signing up is not possible right now. Please try again later." };
34044
+ }
34045
+ }
34046
+ const user = await users.createUser({
34047
+ email: profile.email,
34048
+ emailNormalized,
34049
+ name: profile.name ?? null,
34050
+ emailVerified: true,
34051
+ account: { key, email: profile.email }
34052
+ });
34053
+ if (user) {
34054
+ await ctx.emit("user.created", { user: toMadauthUser(user), method: "google" });
34055
+ return signedIn(user);
34056
+ }
34057
+ }
34058
+ return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
34059
+ };
34060
+ const disabled = (c6) => c6.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
33777
34061
  app.post("/auth/google/nonce", async (c6) => {
34062
+ if (!await ctx.enabled("google")) return disabled(c6);
33778
34063
  const nonce = randomString();
33779
34064
  const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
33780
34065
  setCookie(c6, NONCE_COOKIE, token, {
@@ -33787,6 +34072,7 @@ function googleRoutes(app, ctx, google) {
33787
34072
  return c6.json({ nonce });
33788
34073
  });
33789
34074
  app.post("/auth/google/verify", async (c6) => {
34075
+ if (!await ctx.enabled("google")) return disabled(c6);
33790
34076
  const body = await c6.req.json().catch(() => ({}));
33791
34077
  if (typeof body.credential !== "string" || !body.credential) {
33792
34078
  return c6.json({ error: "verification_failed", message: "credential is missing" }, 400);
@@ -33804,12 +34090,17 @@ function googleRoutes(app, ctx, google) {
33804
34090
  return c6.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
33805
34091
  }
33806
34092
  deleteCookie(c6, NONCE_COOKIE, { path: "/auth/google", secure });
33807
- const user = await ctx.startSession(c6, result.user, ["google"]);
34093
+ const resolved = await resolveUser(result.profile, localeOf(body.locale));
34094
+ if (!resolved.ok) {
34095
+ return c6.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
34096
+ }
34097
+ const user = await ctx.startSession(c6, resolved.user, ["google"], { sv: resolved.sv });
33808
34098
  await ctx.emit("user.signed_in", { user, method: "google" });
33809
34099
  return c6.json({ user });
33810
34100
  });
33811
34101
  const requireCodeFlow = async (c6, next) => {
33812
34102
  if (!google.clientSecret) return c6.json({ error: "not_found" }, 404);
34103
+ if (!await ctx.enabled("google")) return disabled(c6);
33813
34104
  await next();
33814
34105
  };
33815
34106
  app.get("/auth/google/start", requireCodeFlow, async (c6) => {
@@ -33819,7 +34110,8 @@ function googleRoutes(app, ctx, google) {
33819
34110
  state: randomString(),
33820
34111
  nonce: randomString(),
33821
34112
  verifier: randomString(),
33822
- returnTo: returnUrl.href
34113
+ returnTo: returnUrl.href,
34114
+ locale: localeOf(c6.req.query("locale"))
33823
34115
  };
33824
34116
  const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
33825
34117
  setCookie(c6, OAUTH_COOKIE, token, {
@@ -33880,7 +34172,9 @@ function googleRoutes(app, ctx, google) {
33880
34172
  keys: config.jwksResolver
33881
34173
  });
33882
34174
  if (!result.ok) return back(result.error);
33883
- const user = await ctx.startSession(c6, result.user, ["google"]);
34175
+ const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
34176
+ if (!resolved.ok) return back(resolved.error);
34177
+ const user = await ctx.startSession(c6, resolved.user, ["google"], { sv: resolved.sv });
33884
34178
  await ctx.emit("user.signed_in", { user, method: "google" });
33885
34179
  return back();
33886
34180
  });
@@ -33892,18 +34186,16 @@ var MAX_LOCK_MS = 15 * 60 * 1e3;
33892
34186
  var MAIL_INTERVAL_MS = 60 * 1e3;
33893
34187
  var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
33894
34188
  var RESET_TTL_MS = 30 * 60 * 1e3;
33895
- var WEBHOOK_TIMEOUT_MS = 1e4;
33896
34189
  var VERIFY_LINK_PARAM = "madauth_verify";
33897
34190
  var RESET_LINK_PARAM = "madauth_reset";
33898
34191
  var MAX_NAME_LENGTH = 100;
33899
- var MAX_LOCALE_LENGTH = 35;
33900
- function toMadauthUser(user) {
33901
- const result = { id: user.id, email: user.email };
33902
- if (user.name) result.name = user.name;
33903
- return result;
33904
- }
33905
34192
  function passwordRoutes(app, ctx, password, users, webhook) {
33906
34193
  const { minLength } = password;
34194
+ const disabled = (c6) => c6.json({ error: "method_disabled", message: "E-mail & password sign-in is switched off." }, 403);
34195
+ app.use("/auth/password/*", async (c6, next) => {
34196
+ if (!await ctx.enabled("password")) return disabled(c6);
34197
+ await next();
34198
+ });
33907
34199
  const body = async (c6) => {
33908
34200
  const data = await c6.req.json().catch(() => null);
33909
34201
  return data && typeof data === "object" ? data : {};
@@ -33939,7 +34231,6 @@ function passwordRoutes(app, ctx, password, users, webhook) {
33939
34231
  locale
33940
34232
  });
33941
34233
  };
33942
- const localeOf = (data) => typeof data.locale === "string" && data.locale.length <= MAX_LOCALE_LENGTH ? data.locale : void 0;
33943
34234
  const consume = async (data, purpose) => {
33944
34235
  if (typeof data.token === "string" && data.token) {
33945
34236
  return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
@@ -34001,19 +34292,9 @@ function passwordRoutes(app, ctx, password, users, webhook) {
34001
34292
  if (policy) return error(c6, 400, "weak_password", policy);
34002
34293
  const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
34003
34294
  const email = data.email.trim();
34004
- const locale = localeOf(data);
34005
- if (webhook.wants("signup.before")) {
34006
- const check2 = await webhook.call("signup.before", { email, name: name ?? void 0, locale }, WEBHOOK_TIMEOUT_MS);
34007
- if (!check2.ok) {
34008
- console.error(`[madauth] Webhook "signup.before" failed: ${check2.reason}`);
34009
- return unavailable(c6);
34010
- }
34011
- const answer = check2.body;
34012
- if (answer?.allow === false) {
34013
- const message2 = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
34014
- return error(c6, 403, "signup_rejected", message2);
34015
- }
34016
- }
34295
+ const locale = localeOf(data.locale);
34296
+ const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
34297
+ if (!check2.ok) return check2.reason === "rejected" ? error(c6, 403, "signup_rejected", check2.message) : unavailable(c6);
34017
34298
  const passwordHash = await hashPassword(data.password);
34018
34299
  const existing = await users.findByEmail(normalizeEmail(email));
34019
34300
  if (existing?.emailVerified) {
@@ -34025,18 +34306,19 @@ function passwordRoutes(app, ctx, password, users, webhook) {
34025
34306
  if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c6);
34026
34307
  const account = await users.passwordAccount(existing.id);
34027
34308
  if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
34309
+ else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
34028
34310
  await users.updateUser(existing.id, { name });
34029
34311
  return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c6) : unavailable(c6);
34030
34312
  }
34031
- const user = await users.createPasswordUser({
34313
+ const user = await users.createUser({
34032
34314
  email,
34033
34315
  emailNormalized: normalizeEmail(email),
34034
34316
  name,
34035
- passwordHash,
34036
- emailVerified: false
34317
+ emailVerified: false,
34318
+ account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
34037
34319
  });
34038
34320
  if (!user) return accepted(c6);
34039
- await ctx.emit("user.created", { user: toMadauthUser(user) });
34321
+ await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
34040
34322
  return await sendLinkEmail(user, "verify", target, locale) ? accepted(c6) : unavailable(c6);
34041
34323
  });
34042
34324
  app.post("/auth/password/send-verification", async (c6) => {
@@ -34046,7 +34328,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
34046
34328
  if (!isValidEmail(data.email)) return error(c6, 400, "invalid_email", "This is not a valid e-mail address.");
34047
34329
  const user = await users.findByEmail(normalizeEmail(data.email));
34048
34330
  if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
34049
- if (!await sendLinkEmail(user, "verify", target, localeOf(data))) return unavailable(c6);
34331
+ if (!await sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c6);
34050
34332
  }
34051
34333
  return accepted(c6);
34052
34334
  });
@@ -34067,8 +34349,8 @@ function passwordRoutes(app, ctx, password, users, webhook) {
34067
34349
  if (target instanceof Response) return target;
34068
34350
  if (!isValidEmail(data.email)) return error(c6, 400, "invalid_email", "This is not a valid e-mail address.");
34069
34351
  const user = await users.findByEmail(normalizeEmail(data.email));
34070
- if (user && await users.passwordAccount(user.id)) {
34071
- if (!await sendLinkEmail(user, "reset", target, localeOf(data))) return unavailable(c6);
34352
+ if (user && (user.emailVerified || await users.passwordAccount(user.id))) {
34353
+ if (!await sendLinkEmail(user, "reset", target, localeOf(data.locale))) return unavailable(c6);
34072
34354
  }
34073
34355
  return accepted(c6);
34074
34356
  });
@@ -34078,174 +34360,88 @@ function passwordRoutes(app, ctx, password, users, webhook) {
34078
34360
  if (policy) return error(c6, 400, "weak_password", policy);
34079
34361
  const consumed = await consume(data, "reset");
34080
34362
  const user = consumed.userId ? await users.findById(consumed.userId) : null;
34081
- const account = user ? await users.passwordAccount(user.id) : null;
34082
- if (!user || !account) return invalidVerification(c6, consumed);
34083
- await users.updateAccount(account.id, { secret: await hashPassword(data.password), failedAttempts: 0, lockedUntil: 0 });
34363
+ if (!user) return invalidVerification(c6, consumed);
34364
+ const secret = await hashPassword(data.password);
34365
+ const account = await users.passwordAccount(user.id);
34366
+ if (account) await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 });
34367
+ else await users.linkAccount(user.id, { key: passwordAccountKey(user.id), secret });
34084
34368
  const sessionVersion = user.sessionVersion + 1;
34085
34369
  await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
34086
34370
  await users.clearVerifications(user.id);
34087
34371
  const result = await ctx.startSession(c6, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
34088
- await ctx.emit("password.reset", { user: result });
34372
+ await ctx.emit("email.password_reset", { user: result });
34089
34373
  await ctx.emit("user.signed_in", { user: result, method: "password" });
34090
34374
  return c6.json({ user: result });
34091
34375
  });
34092
34376
  }
34093
34377
 
34094
- // src/users.ts
34095
- import { randomBytes as randomBytes3 } from "crypto";
34096
- var MAX_CODE_ATTEMPTS = 5;
34097
- var MAX_WRONG_CODES = 10;
34098
- var newId = (prefix) => `${prefix}_${randomBytes3(16).toString("base64url")}`;
34099
- function passwordAccountKey(userId) {
34100
- return `password:${userId}`;
34378
+ // src/settings.ts
34379
+ var SIGN_IN_METHODS = ["google", "password"];
34380
+ var METHODS_SETTING = "methods";
34381
+ function parseMethodSettings(input) {
34382
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
34383
+ const settings = {};
34384
+ for (const [key, value] of Object.entries(input)) {
34385
+ if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
34386
+ settings[key] = value;
34387
+ }
34388
+ return settings;
34101
34389
  }
34102
- var Users = class {
34103
- /** `codeKey` keys the e-mail codes (see deriveCodeKey); only needed to issue and check them. */
34104
- constructor(store, codeKey) {
34390
+ var MAX_TRIES = 3;
34391
+ var Settings = class {
34392
+ constructor(store) {
34105
34393
  this.store = store;
34106
- this.codeKey = codeKey;
34107
- }
34108
- get key() {
34109
- if (!this.codeKey) throw new Error("Users needs a code key to issue or check e-mail codes.");
34110
- return this.codeKey;
34111
- }
34112
- async findByEmail(emailNormalized) {
34113
- return await this.store.findOne("user", { emailNormalized });
34114
- }
34115
- async findById(id) {
34116
- return await this.store.findOne("user", { id });
34117
- }
34118
- async passwordAccount(userId) {
34119
- return await this.store.findOne("account", { key: passwordAccountKey(userId) });
34120
34394
  }
34121
- /** Creates a user with a password. Resolves to null if the e-mail address is taken. */
34122
- async createPasswordUser(data) {
34123
- const now = Date.now();
34124
- const user = {
34125
- id: newId("usr"),
34126
- email: data.email,
34127
- emailNormalized: data.emailNormalized,
34128
- emailVerified: data.emailVerified,
34129
- name: data.name,
34130
- sessionVersion: 0,
34131
- lastMailAt: 0,
34132
- wrongCodes: 0,
34133
- createdAt: now
34134
- };
34135
- if (!await this.store.create("user", user)) return null;
34136
- const account = {
34137
- id: newId("acc"),
34138
- userId: user.id,
34139
- key: passwordAccountKey(user.id),
34140
- secret: data.passwordHash,
34141
- failedAttempts: 0,
34142
- lockedUntil: 0,
34143
- createdAt: now
34144
- };
34145
- if (!await this.store.create("account", account)) {
34146
- await this.store.delete("user", { id: user.id });
34147
- return null;
34148
- }
34149
- return user;
34395
+ /** The setting's value, or undefined if it was never set. */
34396
+ async get(id) {
34397
+ return (await this.read(id)).value;
34150
34398
  }
34151
- /**
34152
- * Deletes the user with this e-mail address and everything stored for them. Resolves to the deleted
34153
- * user's ID, or null if there is no such user (any more).
34154
- */
34155
- async deleteByEmail(emailNormalized) {
34156
- const user = await this.findByEmail(emailNormalized);
34157
- if (!user) return null;
34158
- if (await this.store.delete("user", { id: user.id }) !== 1) return null;
34399
+ /** The stored JSON and what it holds; both undefined if the setting was never set. */
34400
+ async read(id) {
34401
+ const record = await this.store.findOne("setting", { id });
34402
+ if (typeof record?.value !== "string") return {};
34159
34403
  try {
34160
- await this.store.delete("account", { userId: user.id });
34161
- await this.store.delete("verification", { userId: user.id });
34162
- } catch (e6) {
34163
- console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e6.message}`);
34404
+ return { json: record.value, value: JSON.parse(record.value) };
34405
+ } catch {
34406
+ return { json: record.value };
34164
34407
  }
34165
- return user.id;
34166
- }
34167
- async updateUser(id, patch) {
34168
- await this.store.update("user", { id }, patch);
34169
- }
34170
- async updateAccount(id, patch) {
34171
- await this.store.update("account", { id }, patch);
34172
34408
  }
34173
34409
  /**
34174
- * Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
34175
- * was read. Resolves to false if another request counted an attempt meanwhile.
34176
- */
34177
- async countAttempt(account, patch) {
34178
- const changed = await this.store.update("account", { id: account.id, failedAttempts: account.failedAttempts }, patch);
34179
- return changed === 1;
34180
- }
34181
- /** Deletes the user's pending verifications and password resets; their links and codes stop working. */
34182
- async clearVerifications(userId) {
34183
- await this.store.delete("verification", { userId });
34184
- }
34185
- /**
34186
- * Starts a verification or password reset: replaces the user's earlier one for the same purpose and
34187
- * returns the link token and the code for the e-mail. Only their hashes are stored.
34410
+ * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
34411
+ * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
34412
+ * again with the newer value. Resolves to the new value, or null if `apply` refused.
34413
+ * `updatedBy` is the address of the admin, or null for the command line.
34188
34414
  */
34189
- async issueVerification(userId, purpose, ttlMs) {
34190
- await this.store.delete("verification", { userId, purpose });
34191
- const token = generateLinkToken();
34192
- const code = generateCode();
34193
- const record = {
34194
- id: hashLinkToken(token),
34195
- userId,
34196
- purpose,
34197
- codeHash: hashCode(this.key, code),
34198
- attempts: 0,
34199
- expiresAt: Date.now() + ttlMs
34200
- };
34201
- await this.store.create("verification", record);
34202
- return { token, code };
34203
- }
34204
- /** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
34205
- async consumeLinkToken(token, purpose) {
34206
- const id = hashLinkToken(token);
34207
- const record = await this.store.findOne("verification", { id, purpose });
34208
- return record ? this.consume(record) : null;
34209
- }
34210
- /**
34211
- * Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
34212
- * MAX_WRONG_CODES wrong codes in a row the user's codes stop working until an e-mail link is used.
34213
- */
34214
- async consumeCode(user, purpose, code) {
34215
- if ((user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
34216
- const record = await this.store.findOne("verification", { userId: user.id, purpose });
34217
- if (!record) return { userId: null, locked: false };
34218
- const attempts = record.attempts + 1;
34219
- if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
34220
- return { userId: null, locked: false };
34221
- }
34222
- const wrongCodes = await this.countWrongCode(user);
34223
- if (wrongCodes === null) return { userId: null, locked: false };
34224
- if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
34225
- if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
34226
- if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
34227
- return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
34415
+ async change(id, apply, updatedBy) {
34416
+ for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
34417
+ const { json: json2, value } = await this.read(id);
34418
+ const next = apply(value);
34419
+ if (next === null) return null;
34420
+ const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
34421
+ const written = json2 === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json2 }, patch) === 1;
34422
+ if (written) return next;
34228
34423
  }
34229
- return { userId: await this.consume(record), locked: false };
34424
+ throw new Error(`Setting "${id}" kept changing; try again.`);
34230
34425
  }
34231
- /** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
34232
- async countWrongCode(user) {
34233
- let current = user;
34234
- for (let attempt = 0; attempt < 3 && current; attempt++) {
34235
- const wrongCodes = (current.wrongCodes ?? 0) + 1;
34236
- if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
34237
- return wrongCodes;
34238
- }
34239
- current = await this.findById(user.id);
34240
- }
34241
- return null;
34426
+ /** Replaces the setting. */
34427
+ async set(id, value, updatedBy) {
34428
+ await this.change(id, () => value, updatedBy);
34242
34429
  }
34243
- async consume(record) {
34244
- const deleted = await this.store.delete("verification", { id: record.id });
34245
- if (deleted !== 1 || record.expiresAt <= Date.now()) return null;
34246
- return record.userId;
34430
+ /** Which sign-in methods are on; a method that was never switched off is on. */
34431
+ async methods() {
34432
+ return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
34433
+ }
34434
+ /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
34435
+ async changeMethods(apply, updatedBy) {
34436
+ return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
34247
34437
  }
34248
34438
  };
34439
+ function isOn(method, available, methods) {
34440
+ return available(method) && methods[method] !== false;
34441
+ }
34442
+ function anyOn(available, methods) {
34443
+ return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
34444
+ }
34249
34445
 
34250
34446
  // src/app.ts
34251
34447
  var EVENT_TIMEOUT_MS = 5e3;
@@ -34254,12 +34450,9 @@ function createApp(config) {
34254
34450
  const keys = importSigningKeys(config.signingKey);
34255
34451
  const secure = issuer.startsWith("https:");
34256
34452
  const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
34257
- const roles = config.password ? new Roles(config.password.store) : void 0;
34258
- const withRoles = async (user) => {
34259
- const { roles: _, ...rest } = user;
34260
- const current = roles && user.email ? await roles.get(normalizeEmail(user.email)) : [];
34261
- return current.length ? { ...rest, roles: current } : rest;
34262
- };
34453
+ const users = new Users(config.store, deriveCodeKey(config.signingKey.d));
34454
+ const settings = new Settings(config.store);
34455
+ const available = (method) => method === "google" ? !!config.google : !!config.password;
34263
34456
  const issueSession = async (c6, user, amr, extra) => {
34264
34457
  const signingKeys = await keys;
34265
34458
  const [sessionToken, renewalToken] = await Promise.all([
@@ -34311,12 +34504,29 @@ function createApp(config) {
34311
34504
  return url;
34312
34505
  },
34313
34506
  async startSession(c6, user, amr, extra) {
34314
- const signedIn = await withRoles(user);
34315
- await issueSession(c6, signedIn, amr, extra);
34316
- return signedIn;
34507
+ await issueSession(c6, user, amr, extra);
34508
+ return user;
34317
34509
  },
34318
- users: config.password ? new Users(config.password.store, deriveCodeKey(config.signingKey.d)) : void 0,
34510
+ users,
34511
+ settings,
34319
34512
  webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
34513
+ async enabled(method) {
34514
+ return isOn(method, available, await settings.methods());
34515
+ },
34516
+ async checkSignup(data) {
34517
+ if (!this.webhook?.wants("signup.before")) return { ok: true };
34518
+ const check2 = await this.webhook.call("signup.before", data, WEBHOOK_TIMEOUT_MS);
34519
+ if (!check2.ok) {
34520
+ console.error(`[madauth] Webhook "signup.before" failed: ${check2.reason}`);
34521
+ return { ok: false, reason: "unavailable" };
34522
+ }
34523
+ const answer = check2.body;
34524
+ if (answer?.allow === false) {
34525
+ const message2 = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
34526
+ return { ok: false, reason: "rejected", message: message2 };
34527
+ }
34528
+ return { ok: true };
34529
+ },
34320
34530
  async emit(type, data) {
34321
34531
  if (!this.webhook?.wants(type)) return;
34322
34532
  const result = await this.webhook.call(type, data, EVENT_TIMEOUT_MS);
@@ -34346,29 +34556,28 @@ function createApp(config) {
34346
34556
  c6.header("Cache-Control", "public, max-age=3600");
34347
34557
  return c6.json({ keys: [publicJwk] });
34348
34558
  });
34349
- app.get(
34350
- "/auth/config",
34351
- (c6) => c6.json({
34352
- google: config.google ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
34353
- password: config.password ? { minLength: config.password.minLength } : null
34354
- })
34355
- );
34559
+ app.get("/auth/config", async (c6) => {
34560
+ const methods = await settings.methods();
34561
+ const on = (method) => isOn(method, available, methods);
34562
+ return c6.json({
34563
+ google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
34564
+ password: on("password") ? { minLength: config.password.minLength } : null
34565
+ });
34566
+ });
34356
34567
  if (config.google) googleRoutes(app, ctx, config.google);
34357
- if (config.password && ctx.users && ctx.webhook) passwordRoutes(app, ctx, config.password, ctx.users, ctx.webhook);
34568
+ if (config.password && ctx.webhook) passwordRoutes(app, ctx, config.password, users, ctx.webhook);
34358
34569
  const tokenClaims = async (c6, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c6, cookie));
34359
- const hasEnded = async (claims) => {
34360
- if (!claims.sub.startsWith("usr_")) return false;
34361
- const stored = await ctx.users?.findById(claims.sub);
34362
- return !stored || stored.sessionVersion !== claims.sv;
34363
- };
34364
34570
  const checked = async (claims) => {
34365
- const [ended, user] = await Promise.all([hasEnded(claims), withRoles(userFromClaims(claims))]);
34366
- return ended ? null : user;
34571
+ const stored = await users.findById(claims.sub);
34572
+ if (!stored || stored.sessionVersion !== claims.sv) return null;
34573
+ const { claims: _, ...user } = userFromClaims(claims);
34574
+ const current = claimsFromJson(stored.claims);
34575
+ return Object.keys(current).length ? { ...user, claims: current } : user;
34367
34576
  };
34368
34577
  const currentSession = async (c6) => {
34369
34578
  const session = await tokenClaims(c6, SESSION_COOKIE, SESSION_TYP);
34370
34579
  const user = session ? await checked(session) : null;
34371
- const upToDate = session && user && sameRoles(user.roles ?? [], session.roles ?? []) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
34580
+ const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
34372
34581
  if (upToDate) return user;
34373
34582
  const renewal = await tokenClaims(c6, RENEWAL_COOKIE, RENEWAL_TYP);
34374
34583
  const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
@@ -34394,56 +34603,82 @@ function createApp(config) {
34394
34603
  app.post("/auth/account/delete", async (c6) => {
34395
34604
  const user = await currentSession(c6);
34396
34605
  if (!user) return c6.json({ error: "no_session" }, 401);
34397
- if (roles && user.email) await roles.remove(normalizeEmail(user.email));
34398
- const passwordUserId = ctx.users && user.email ? await ctx.users.deleteByEmail(normalizeEmail(user.email)) : null;
34606
+ await users.deleteById(user.id);
34399
34607
  clearSession(c6);
34400
- await ctx.emit("user.deleted", { user, passwordUserId: passwordUserId ?? void 0 });
34608
+ await ctx.emit("user.deleted", { user });
34401
34609
  return c6.body(null, 204);
34402
34610
  });
34403
- if (roles) {
34404
- const admin = async (c6) => {
34405
- const user = await currentSession(c6);
34406
- if (!user) return { answer: c6.json({ error: "no_session" }, 401) };
34407
- const email = user.email ? normalizeEmail(user.email) : void 0;
34408
- if (!email || !user.roles?.includes(ADMIN_ROLE)) {
34409
- return { answer: c6.json({ error: "forbidden", message: `Only users with the role "${ADMIN_ROLE}" can manage roles.` }, 403) };
34410
- }
34411
- return { email };
34412
- };
34413
- const body = async (c6) => {
34414
- const data = await c6.req.json().catch(() => null);
34415
- return data && typeof data === "object" ? data : {};
34416
- };
34417
- const invalidEmail = (c6) => c6.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
34418
- app.post("/auth/admin/roles/get", async (c6) => {
34419
- const caller = await admin(c6);
34420
- if ("answer" in caller) return caller.answer;
34421
- const data = await body(c6);
34422
- if (!isValidEmail(data.email)) return invalidEmail(c6);
34423
- const email = normalizeEmail(data.email);
34424
- return c6.json({ email, roles: await roles.get(email) });
34425
- });
34426
- app.post("/auth/admin/roles/set", async (c6) => {
34427
- const caller = await admin(c6);
34428
- if ("answer" in caller) return caller.answer;
34429
- const data = await body(c6);
34430
- if (!isValidEmail(data.email)) return invalidEmail(c6);
34431
- const names = parseRoles(data.roles);
34432
- if (!names) {
34433
- return c6.json(
34434
- {
34435
- error: "invalid_roles",
34436
- message: 'roles must be a list of names: lower-case letters, digits, "-" and "_", starting with a letter.'
34437
- },
34438
- 400
34439
- );
34440
- }
34441
- const email = normalizeEmail(data.email);
34442
- await roles.set(email, names, caller.email);
34443
- await ctx.emit("roles.changed", { email, roles: names, by: caller.email });
34444
- return c6.json({ email, roles: names });
34445
- });
34446
- }
34611
+ const admin = async (c6) => {
34612
+ const user = await currentSession(c6);
34613
+ if (!user) return { answer: c6.json({ error: "no_session" }, 401) };
34614
+ const email = user.email ? normalizeEmail(user.email) : void 0;
34615
+ if (!email || !isAdmin(user)) {
34616
+ const message2 = `Only users with the role "${ADMIN_ROLE}" can manage claims and settings.`;
34617
+ return { answer: c6.json({ error: "forbidden", message: message2 }, 403) };
34618
+ }
34619
+ return { email };
34620
+ };
34621
+ const body = async (c6) => {
34622
+ const data = await c6.req.json().catch(() => null);
34623
+ return data && typeof data === "object" ? data : {};
34624
+ };
34625
+ const invalidEmail = (c6) => c6.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
34626
+ const noUser = (c6) => c6.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
34627
+ app.post("/auth/admin/claims/get", async (c6) => {
34628
+ const caller = await admin(c6);
34629
+ if ("answer" in caller) return caller.answer;
34630
+ const data = await body(c6);
34631
+ if (!isValidEmail(data.email)) return invalidEmail(c6);
34632
+ const user = await users.findByEmail(normalizeEmail(data.email));
34633
+ if (!user) return noUser(c6);
34634
+ return c6.json({ email: user.emailNormalized, userId: user.id, claims: claimsFromJson(user.claims) });
34635
+ });
34636
+ app.post("/auth/admin/claims/set", async (c6) => {
34637
+ const caller = await admin(c6);
34638
+ if ("answer" in caller) return caller.answer;
34639
+ const data = await body(c6);
34640
+ if (!isValidEmail(data.email)) return invalidEmail(c6);
34641
+ const claims = parseClaims(data.claims);
34642
+ if (!claims) {
34643
+ return c6.json(
34644
+ {
34645
+ error: "invalid_claims",
34646
+ message: 'claims must be a JSON object of at most 2048 characters whose keys are names (letters, digits and "_", starting with a letter). "roles" must be a list of names of lower-case letters, digits, "-" and "_".'
34647
+ },
34648
+ 400
34649
+ );
34650
+ }
34651
+ const user = await users.findByEmail(normalizeEmail(data.email));
34652
+ if (!user) return noUser(c6);
34653
+ await users.updateUser(user.id, { claims: Object.keys(claims).length ? JSON.stringify(claims) : null });
34654
+ const email = user.emailNormalized;
34655
+ await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
34656
+ return c6.json({ email, userId: user.id, claims });
34657
+ });
34658
+ const settingsAnswer = (methods) => ({
34659
+ methods: Object.fromEntries(
34660
+ SIGN_IN_METHODS.map((method) => [method, { available: available(method), enabled: isOn(method, available, methods) }])
34661
+ )
34662
+ });
34663
+ app.post("/auth/admin/settings/get", async (c6) => {
34664
+ const caller = await admin(c6);
34665
+ if ("answer" in caller) return caller.answer;
34666
+ return c6.json(settingsAnswer(await settings.methods()));
34667
+ });
34668
+ app.post("/auth/admin/settings/set", async (c6) => {
34669
+ const caller = await admin(c6);
34670
+ if ("answer" in caller) return caller.answer;
34671
+ const data = await body(c6);
34672
+ const invalid2 = (message2) => c6.json({ error: "invalid_settings", message: message2 }, 400);
34673
+ const changes = parseMethodSettings(data.methods);
34674
+ if (!changes) return invalid2(`methods must be an object with true or false for ${SIGN_IN_METHODS.join(" and/or ")}.`);
34675
+ const methods = await settings.changeMethods((current) => {
34676
+ const next = { ...current, ...changes };
34677
+ return anyOn(available, next) ? next : null;
34678
+ }, caller.email);
34679
+ if (!methods) return invalid2("At least one sign-in method must stay on.");
34680
+ return c6.json(settingsAnswer(methods));
34681
+ });
34447
34682
  return app;
34448
34683
  }
34449
34684