@madauth/server 0.3.0-beta.2 → 0.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +3 -2
  2. package/dist/bin.js +202 -50
  3. package/dist/chunk-7DHKDRGY.js +1706 -0
  4. package/dist/{chunk-DCDTO5LG.js → chunk-A7UYZRBL.js} +1 -0
  5. package/dist/chunk-NP75Y3IV.js +107 -0
  6. package/dist/chunk-NYIEFI4B.js +1040 -0
  7. package/dist/{chunk-56MOVFO4.js → chunk-O2URPWA6.js} +37 -4
  8. package/dist/{chunk-3QRKWLR7.js → chunk-SISM7YUZ.js} +15 -10
  9. package/dist/{config-Dg2fZuWR.d.ts → config-CcGlHVAN.d.ts} +19 -8
  10. package/dist/dynamodb.js +1 -1
  11. package/dist/entry/azure.d.ts +3 -3
  12. package/dist/entry/azure.js +4 -4
  13. package/dist/entry/lambda.d.ts +3 -3
  14. package/dist/entry/lambda.js +4 -4
  15. package/dist/entry/node.d.ts +3 -3
  16. package/dist/entry/node.js +4 -4
  17. package/dist/index.d.ts +37 -15
  18. package/dist/index.js +12 -6
  19. package/dist/{schema-3yhCc2jH.d.ts → schema-BTgDj1Xn.d.ts} +79 -3
  20. package/dist/sqlite.js +2 -2
  21. package/dist/standalone/azure.mjs +5511 -922
  22. package/dist/standalone/lambda.mjs +5461 -872
  23. package/dist/standalone/main.mjs +5487 -898
  24. package/dist/store/dynamodb.d.ts +1 -1
  25. package/dist/store/sqlite.d.ts +1 -1
  26. package/dist/testing.d.ts +1 -1
  27. package/dist/testing.js +21 -0
  28. package/dist/{user-BMWEWREH.d.ts → user-yoGjrw4k.d.ts} +5 -0
  29. package/dist/verify.d.ts +1 -1
  30. package/dist/verify.js +1 -1
  31. package/dist/{webhook-j5u9Kv9l.d.ts → webhook-DlmfG_kS.d.ts} +5 -2
  32. package/dist/webhook.d.ts +1 -1
  33. package/dist/webhook.js +1 -1
  34. package/package.json +4 -2
  35. package/dist/chunk-CD7Y4T66.js +0 -62
  36. package/dist/chunk-DVVBIFIV.js +0 -718
  37. package/dist/chunk-TI35AZ2U.js +0 -660
@@ -0,0 +1,1040 @@
1
+ import {
2
+ assertPrivateSigningJwk,
3
+ generateSigningKey
4
+ } from "./chunk-JDQLSL36.js";
5
+ import {
6
+ DEV_WEBHOOK_RECEIVER_URL,
7
+ REQUIRED_EMAIL_TYPES,
8
+ WEBHOOK_TYPES,
9
+ checkWebhookSecret,
10
+ generateWebhookSecret
11
+ } from "./chunk-SISM7YUZ.js";
12
+
13
+ // src/claims.ts
14
+ var ADMIN_ROLE = "admin";
15
+ var MAX_ROLES = 20;
16
+ var MAX_CLAIMS_LENGTH = 2048;
17
+ var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
18
+ var CLAIM_NAME = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
19
+ function parseRoles(input) {
20
+ if (!Array.isArray(input)) return null;
21
+ if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
22
+ const roles = [...new Set(input)].sort();
23
+ return roles.length <= MAX_ROLES ? roles : null;
24
+ }
25
+ function parseClaims(input) {
26
+ if (!isJsonObject(input)) return null;
27
+ const claims = {};
28
+ for (const key of Object.keys(input).sort()) {
29
+ const value = input[key];
30
+ if (!CLAIM_NAME.test(key) || !isJsonValue(value)) return null;
31
+ if (key === "roles") {
32
+ const roles = parseRoles(value);
33
+ if (!roles) return null;
34
+ claims[key] = roles;
35
+ } else {
36
+ claims[key] = value;
37
+ }
38
+ }
39
+ return JSON.stringify(claims).length <= MAX_CLAIMS_LENGTH ? claims : null;
40
+ }
41
+ function isJsonObject(value) {
42
+ return !!value && typeof value === "object" && !Array.isArray(value);
43
+ }
44
+ function isJsonValue(value) {
45
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
46
+ if (typeof value === "number") return Number.isFinite(value);
47
+ if (Array.isArray(value)) return value.every(isJsonValue);
48
+ return isJsonObject(value) && Object.values(value).every(isJsonValue);
49
+ }
50
+ function sameClaims(a, b) {
51
+ return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
52
+ }
53
+ function claimsFromJson(json) {
54
+ if (!json) return {};
55
+ try {
56
+ const parsed = JSON.parse(json);
57
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
58
+ } catch {
59
+ return {};
60
+ }
61
+ }
62
+ function rolesOf(user) {
63
+ const roles = user.claims?.roles;
64
+ return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
65
+ }
66
+ function isAdmin(user) {
67
+ return rolesOf(user).includes(ADMIN_ROLE);
68
+ }
69
+
70
+ // src/config.ts
71
+ function sendsVerificationEmails(config) {
72
+ return !!config.webhook?.events.has("email.send_verification");
73
+ }
74
+ function sendsSignInEmails(config) {
75
+ return !!config.webhook?.events.has("email.send_signin_link");
76
+ }
77
+ function sendsSms(config) {
78
+ return !!config.webhook?.events.has("sms.send_code");
79
+ }
80
+ function methodConfig(config) {
81
+ return {
82
+ google: config.google,
83
+ github: config.github,
84
+ password: config.password,
85
+ emailVerification: sendsVerificationEmails(config),
86
+ emailLink: sendsSignInEmails(config),
87
+ sms: sendsSms(config)
88
+ };
89
+ }
90
+ var envVars = {
91
+ MADAUTH_ISSUER: true,
92
+ MADAUTH_SIGNING_KEY: true,
93
+ ALLOWED_ORIGINS: true,
94
+ GOOGLE_CLIENT_ID: false,
95
+ GOOGLE_CLIENT_SECRET: false,
96
+ GITHUB_CLIENT_ID: false,
97
+ GITHUB_CLIENT_SECRET: false,
98
+ DATABASE_URL: true,
99
+ TOTP_ISSUER: false,
100
+ WEBHOOK_URL: false,
101
+ WEBHOOK_SECRET: false,
102
+ WEBHOOK_EVENTS: false,
103
+ SESSION_TTL: false,
104
+ SESSION_RENEWAL_TTL: false,
105
+ COOKIE_DOMAIN: false
106
+ };
107
+ async function resolveEnv(source = process.env) {
108
+ return typeof source === "function" ? source() : source;
109
+ }
110
+ var DEFAULT_SESSION_TTL = 8 * 60 * 60;
111
+ var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
112
+ var ConfigError = class extends Error {
113
+ constructor() {
114
+ super(...arguments);
115
+ this.name = "ConfigError";
116
+ }
117
+ };
118
+ function parseOrigin(name, value) {
119
+ let url;
120
+ try {
121
+ url = new URL(value);
122
+ } catch {
123
+ throw new ConfigError(`${name}: "${value}" is not a URL.`);
124
+ }
125
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
126
+ throw new ConfigError(`${name}: "${value}" must be an http(s) URL.`);
127
+ }
128
+ return url.origin;
129
+ }
130
+ function readEnv(env, name) {
131
+ return env[name]?.trim().replace(/^(['"])(.*)\1$/s, "$2").trim() || void 0;
132
+ }
133
+ async function loadConfig(env, overrides = {}) {
134
+ const read = (name) => readEnv(env, name);
135
+ const require2 = (name) => {
136
+ const value = read(name);
137
+ if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
138
+ return value;
139
+ };
140
+ if (env.PASSWORD_MIN_LENGTH?.trim()) {
141
+ throw new ConfigError(
142
+ `PASSWORD_MIN_LENGTH was removed: the password rules are a setting now (default: at least 8 characters). Remove the variable and set the rules with \`npx @madauth/server set-password-rules min-length=${env.PASSWORD_MIN_LENGTH.trim()}\` or Madauth.admin.setSettings. See docs/upgrading.md.`
143
+ );
144
+ }
145
+ const issuerValue = require2("MADAUTH_ISSUER");
146
+ parseOrigin("MADAUTH_ISSUER", issuerValue);
147
+ const issuer = issuerValue.replace(/\/+$/, "");
148
+ const keyValue = read("MADAUTH_SIGNING_KEY");
149
+ if (!keyValue) {
150
+ const key = await generateSigningKey();
151
+ throw new ConfigError(
152
+ `MADAUTH_SIGNING_KEY is not set. Here is a newly generated key you can use (keep it secret and use the same key on every instance):
153
+
154
+ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
155
+ `
156
+ );
157
+ }
158
+ let signingKey;
159
+ try {
160
+ signingKey = JSON.parse(keyValue);
161
+ assertPrivateSigningJwk(signingKey);
162
+ } catch (e) {
163
+ const reason = e instanceof SyntaxError ? "is not valid JSON" : e.message;
164
+ throw new ConfigError(`MADAUTH_SIGNING_KEY ${reason}. Generate one with: npx @madauth/server generate-key`);
165
+ }
166
+ const allowedOrigins = require2("ALLOWED_ORIGINS").split(",").map((o) => o.trim()).filter(Boolean).map((o) => parseOrigin("ALLOWED_ORIGINS", o));
167
+ const clientId = read("GOOGLE_CLIENT_ID");
168
+ if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
169
+ throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
170
+ }
171
+ const githubClientId = read("GITHUB_CLIENT_ID");
172
+ const githubClientSecret = read("GITHUB_CLIENT_SECRET");
173
+ if (githubClientId && !githubClientSecret) {
174
+ throw new ConfigError(
175
+ "GITHUB_CLIENT_ID needs GITHUB_CLIENT_SECRET: GitHub only has the redirect flow, in which the server exchanges the code. Set both from your OAuth App at https://github.com/settings/developers."
176
+ );
177
+ }
178
+ const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
179
+ const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
180
+ const password = sendsEmails ? {} : void 0;
181
+ const emailVerification = !!webhook && webhook.events.has("email.send_verification");
182
+ const emailLink = !!webhook && webhook.events.has("email.send_signin_link");
183
+ if (!clientId && !githubClientId && !password && !emailVerification && !emailLink) {
184
+ throw new ConfigError(
185
+ `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET for GitHub sign-in, and/or a webhook that sends the e-mails: e-mail & password sign-in needs email.send_verification and email.send_password_reset, the passwordless e-mail link needs email.send_signin_link, sign-up with the authenticator app needs email.send_verification. For development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.send_signin_link,email.send_already_registered,email.send_password_reset_no_password. See docs/server.md.`
186
+ );
187
+ }
188
+ const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
189
+ if (!store) {
190
+ throw new ConfigError(
191
+ "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."
192
+ );
193
+ }
194
+ const ttlValue = read("SESSION_TTL");
195
+ const sessionTtlSeconds = ttlValue ? Number(ttlValue) : DEFAULT_SESSION_TTL;
196
+ if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
197
+ throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
198
+ }
199
+ const renewalValue = read("SESSION_RENEWAL_TTL");
200
+ const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
201
+ if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
202
+ throw new ConfigError(
203
+ `SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
204
+ );
205
+ }
206
+ return {
207
+ issuer,
208
+ signingKey,
209
+ allowedOrigins,
210
+ sessionTtlSeconds,
211
+ renewalTtlSeconds,
212
+ cookieDomain: read("COOKIE_DOMAIN"),
213
+ totpIssuer: read("TOTP_ISSUER"),
214
+ google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
215
+ github: githubClientId && githubClientSecret ? { clientId: githubClientId, clientSecret: githubClientSecret } : void 0,
216
+ store,
217
+ password,
218
+ webhook
219
+ };
220
+ }
221
+ async function loadStore(env) {
222
+ const store = await storeFromDatabaseUrl(readEnv(env, "DATABASE_URL"));
223
+ if (!store) throw new ConfigError("Set DATABASE_URL to manage users.");
224
+ return store;
225
+ }
226
+ async function storeFromDatabaseUrl(url) {
227
+ if (!url) return void 0;
228
+ if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
229
+ if (!url.startsWith("sqlite:")) {
230
+ throw new ConfigError(
231
+ `DATABASE_URL must start with "sqlite:" (e.g. sqlite:/data/madauth.db) or "dynamodb:" (e.g. dynamodb:madauth) but is "${url}". For other databases, pass your own store adapter (see "Custom store adapter" in docs/server.md).`
232
+ );
233
+ }
234
+ const path = url.slice("sqlite:".length);
235
+ if (!path) throw new ConfigError("DATABASE_URL needs a file path, e.g. sqlite:/data/madauth.db");
236
+ const { createSqliteAdapter } = await import("./sqlite.js");
237
+ try {
238
+ return createSqliteAdapter(path);
239
+ } catch (e) {
240
+ throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
241
+ }
242
+ }
243
+ async function dynamoDbStore(tableName) {
244
+ if (!tableName) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
245
+ if (!/^[\w.-]{3,255}$/.test(tableName)) {
246
+ throw new ConfigError(
247
+ `DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName}"`
248
+ );
249
+ }
250
+ try {
251
+ await import("@aws-sdk/client-dynamodb");
252
+ } catch (e) {
253
+ throw new ConfigError(
254
+ `DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
255
+ );
256
+ }
257
+ const { createDynamoDbAdapter } = await import("./dynamodb.js");
258
+ return createDynamoDbAdapter({ tableName });
259
+ }
260
+ var RENAMED_TYPES = {
261
+ "password.reset": "user.password_reset",
262
+ "roles.changed": "user.claims_changed",
263
+ "email.verify": "email.send_verification",
264
+ "email.reset": "email.send_password_reset",
265
+ "email.signin": "email.send_signin_link",
266
+ "email.already_registered": "email.send_already_registered",
267
+ "email.no_password": "email.send_password_reset_no_password",
268
+ "sms.code": "sms.send_code",
269
+ "signup.before": "user.signing_up",
270
+ "email.verified": "user.email_verified",
271
+ "email.password_reset": "user.password_reset",
272
+ "totp.enabled": 'user.second_factor_enabled (with method "totp")',
273
+ "totp.disabled": 'user.second_factor_disabled (with method "totp")',
274
+ "sms.enabled": 'user.second_factor_enabled (with method "sms")',
275
+ "sms.disabled": 'user.second_factor_disabled (with method "sms")',
276
+ "totp.recovery_code_used": "user.recovery_code_used"
277
+ };
278
+ var REMOVED_TYPES = {
279
+ "user.created": "user.created is gone: user.signed_up tells when a user can sign in (an unconfirmed sign-up is no user yet)"
280
+ };
281
+ var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
282
+ function webhookFromEnv(url, secret, events) {
283
+ if (!url) {
284
+ if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
285
+ return void 0;
286
+ }
287
+ let parsed;
288
+ try {
289
+ parsed = new URL(url);
290
+ } catch {
291
+ throw new ConfigError(`WEBHOOK_URL: "${url}" is not a URL.`);
292
+ }
293
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname))) {
294
+ throw new ConfigError(`WEBHOOK_URL must be an https URL (http only for localhost) but is "${url}".`);
295
+ }
296
+ if (!secret) {
297
+ throw new ConfigError(
298
+ `WEBHOOK_SECRET is not set. Here is a newly generated secret you can use (keep it secret; your webhook receiver needs the same one):
299
+
300
+ WEBHOOK_SECRET=${generateWebhookSecret()}
301
+ `
302
+ );
303
+ }
304
+ const problem = checkWebhookSecret(secret);
305
+ if (problem) {
306
+ throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
307
+ }
308
+ const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
309
+ const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
310
+ if (!selected.size) {
311
+ throw new ConfigError(
312
+ `WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}, the passwordless e-mail link when they include email.send_signin_link. For a receiver that sends the e-mails:
313
+
314
+ WEBHOOK_EVENTS=email.send_verification,email.send_password_reset,email.send_signin_link,email.send_already_registered,email.send_password_reset_no_password
315
+
316
+ SMS codes as a second factor need a receiver that sends the SMS: add sms.send_code,user.second_factor_enabled,user.second_factor_disabled. For one that only wants to know who signed in:
317
+
318
+ WEBHOOK_EVENTS=user.signed_in
319
+
320
+ ` + allTypes
321
+ );
322
+ }
323
+ const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
324
+ if (unknown.length) {
325
+ const renamed = unknown.flatMap((e) => e in RENAMED_TYPES ? [`${e} is now ${RENAMED_TYPES[e]}`] : e in REMOVED_TYPES ? [REMOVED_TYPES[e]] : []);
326
+ throw new ConfigError(
327
+ `WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
328
+ );
329
+ }
330
+ return { url: parsed.href, secret, events: selected };
331
+ }
332
+
333
+ // src/settings.ts
334
+ var SIGN_IN_METHODS = ["google", "github", "password", "totp", "email-link"];
335
+ var METHODS = [...SIGN_IN_METHODS, "sms"];
336
+ var METHODS_WITH_SECOND_FACTOR = ["google", "github", "password", "email-link"];
337
+ var SECOND_FACTOR_POLICIES = ["none", "optional", "required"];
338
+ var METHODS_SETTING = "methods";
339
+ var PASSWORD_SETTING = "password";
340
+ var DEFAULT_PASSWORD_RULES = { minLength: 8, minScore: 2 };
341
+ var MAX_MIN_LENGTH = 128;
342
+ var isPasswordScore = (value) => Number.isInteger(value) && value >= 0 && value <= 4;
343
+ function parsePasswordRules(input) {
344
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
345
+ const { minLength = DEFAULT_PASSWORD_RULES.minLength, minScore = DEFAULT_PASSWORD_RULES.minScore, ...rest } = input;
346
+ if (Object.keys(rest).length) return null;
347
+ if (!Number.isInteger(minLength) || minLength < 1 || minLength > MAX_MIN_LENGTH) return null;
348
+ if (!isPasswordScore(minScore)) return null;
349
+ return { minLength, minScore };
350
+ }
351
+ var isMethodId = (value) => METHODS.includes(value);
352
+ var hasSecondFactor = (method) => METHODS_WITH_SECOND_FACTOR.includes(method);
353
+ var isPolicy = (value) => SECOND_FACTOR_POLICIES.includes(value);
354
+ function parseMethodSettings(input, options = {}) {
355
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
356
+ const settings = {};
357
+ for (const [method, value] of Object.entries(input)) {
358
+ if (!isMethodId(method)) return null;
359
+ if (typeof value === "boolean") {
360
+ if (!options.booleans) return null;
361
+ if (value) settings[method] = {};
362
+ continue;
363
+ }
364
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
365
+ const setting = {};
366
+ for (const [field, fieldValue] of Object.entries(value)) {
367
+ if (field !== "secondFactor" || !hasSecondFactor(method) || !isPolicy(fieldValue)) return null;
368
+ setting.secondFactor = fieldValue;
369
+ }
370
+ settings[method] = setting;
371
+ }
372
+ return settings;
373
+ }
374
+ function isConfigured(config, method) {
375
+ if (method === "google") return !!config.google;
376
+ if (method === "github") return !!config.github;
377
+ if (method === "password") return !!config.password;
378
+ if (method === "email-link") return !!config.emailLink;
379
+ if (method === "sms") return !!config.sms;
380
+ return true;
381
+ }
382
+ function canSignUp(config, method) {
383
+ return method === "totp" ? !!config.emailVerification : isConfigured(config, method);
384
+ }
385
+ function initialMethods(config) {
386
+ const methods = {};
387
+ if (config.google) methods.google = { secondFactor: "none" };
388
+ if (config.github) methods.github = { secondFactor: "none" };
389
+ if (config.password) methods.password = { secondFactor: "none" };
390
+ if (config.emailLink) methods["email-link"] = { secondFactor: "none" };
391
+ if (!config.google && !config.github && !config.password && !config.emailLink && config.emailVerification) methods.totp = {};
392
+ if (config.sms) methods.sms = {};
393
+ return methods;
394
+ }
395
+ var MAX_TRIES = 3;
396
+ var Settings = class {
397
+ /** `initial` is what counts until the methods are set, see {@link initialMethods}. */
398
+ constructor(store, initial = {}) {
399
+ this.store = store;
400
+ this.initial = initial;
401
+ this.warned = false;
402
+ this.warnedRules = false;
403
+ }
404
+ /** The setting's value, or undefined if it was never set. */
405
+ async get(id) {
406
+ return (await this.read(id)).value;
407
+ }
408
+ /** The stored JSON and what it holds; both undefined if the setting was never set. */
409
+ async read(id) {
410
+ const record = await this.store.findOne("setting", { id });
411
+ if (typeof record?.value !== "string") return {};
412
+ try {
413
+ return { json: record.value, value: JSON.parse(record.value) };
414
+ } catch {
415
+ return { json: record.value };
416
+ }
417
+ }
418
+ /**
419
+ * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
420
+ * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
421
+ * again with the newer value. Resolves to the new value, or null if `apply` refused.
422
+ * `updatedBy` is the address of the admin, or null for the command line.
423
+ */
424
+ async change(id, apply, updatedBy) {
425
+ for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
426
+ const { json, value } = await this.read(id);
427
+ const next = apply(value);
428
+ if (next === null) return null;
429
+ const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
430
+ const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
431
+ if (written) return next;
432
+ }
433
+ throw new Error(`Setting "${id}" kept changing; try again.`);
434
+ }
435
+ /** Replaces the setting. */
436
+ async set(id, value, updatedBy) {
437
+ await this.change(id, () => value, updatedBy);
438
+ }
439
+ /** Which sign-in methods are on, with their settings; the initial ones until an admin set them. */
440
+ async methods() {
441
+ return this.current(await this.get(METHODS_SETTING));
442
+ }
443
+ /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
444
+ async changeMethods(apply, updatedBy) {
445
+ return this.change(METHODS_SETTING, (current) => apply(this.current(current)), updatedBy);
446
+ }
447
+ /** The rules for new passwords; the defaults until an admin set them. */
448
+ async passwordRules() {
449
+ const stored = await this.get(PASSWORD_SETTING);
450
+ if (stored === void 0) return DEFAULT_PASSWORD_RULES;
451
+ const parsed = parsePasswordRules(stored);
452
+ if (parsed) return parsed;
453
+ if (!this.warnedRules) {
454
+ this.warnedRules = true;
455
+ console.warn("[madauth] The stored password rules are not in the form this version expects. Set them again with set-password-rules; until then the defaults count.");
456
+ }
457
+ return DEFAULT_PASSWORD_RULES;
458
+ }
459
+ /** Replaces the rules for new passwords. */
460
+ async setPasswordRules(rules, updatedBy) {
461
+ await this.set(PASSWORD_SETTING, rules, updatedBy);
462
+ return rules;
463
+ }
464
+ /** The stored methods, or the initial ones if nothing (usable) is stored. */
465
+ current(stored) {
466
+ if (stored === void 0) return this.initial;
467
+ const parsed = parseMethodSettings(stored);
468
+ if (parsed) return parsed;
469
+ if (!this.warned) {
470
+ this.warned = true;
471
+ console.warn("[madauth] The stored sign-in methods are not in the form this version expects (they were set by an older one?). Set them again with set-methods; until then the initial methods count.");
472
+ }
473
+ return this.initial;
474
+ }
475
+ };
476
+ function isOn(method, configured, methods) {
477
+ return configured(method) && methods[method] !== void 0;
478
+ }
479
+ function policyOf(method, methods) {
480
+ return methods[method]?.secondFactor ?? "none";
481
+ }
482
+ function anyOn(signsUp, methods) {
483
+ return SIGN_IN_METHODS.some((method) => signsUp(method) && methods[method] !== void 0);
484
+ }
485
+ function authenticatorOn(configured, methods) {
486
+ if (isOn("totp", configured, methods)) return true;
487
+ return METHODS_WITH_SECOND_FACTOR.some((method) => isOn(method, configured, methods) && policyOf(method, methods) !== "none");
488
+ }
489
+
490
+ // src/password.ts
491
+ import { createHash, createHmac, hkdfSync, randomBytes, randomInt, scrypt, timingSafeEqual } from "crypto";
492
+
493
+ // src/strength.ts
494
+ var RATED_LENGTH = 64;
495
+ var loading;
496
+ async function passwordScore(password, userInputs = []) {
497
+ loading ??= Promise.all([import("@zxcvbn-ts/core"), import("@zxcvbn-ts/language-common")]).then(
498
+ ([core, common]) => new core.ZxcvbnFactory({
499
+ dictionary: common.dictionary,
500
+ graphs: common.adjacencyGraphs,
501
+ useLevenshteinDistance: true,
502
+ maxLength: RATED_LENGTH
503
+ })
504
+ );
505
+ return (await loading).check(password, userInputs).score;
506
+ }
507
+
508
+ // src/password.ts
509
+ var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
510
+ var KEY_LENGTH = 64;
511
+ var SALT_LENGTH = 16;
512
+ var MAX_PASSWORD_LENGTH = 256;
513
+ var MAX_EMAIL_LENGTH = 254;
514
+ function derive(password, salt, { logN, r, p }) {
515
+ const N = 2 ** logN;
516
+ return new Promise(
517
+ (resolve, reject) => scrypt(
518
+ password.normalize("NFC"),
519
+ salt,
520
+ KEY_LENGTH,
521
+ { N, r, p, maxmem: 256 * N * r + 1024 * 1024 },
522
+ (err, key) => err ? reject(err) : resolve(key)
523
+ )
524
+ );
525
+ }
526
+ async function hashPassword(password, params = HASH_PARAMS) {
527
+ const salt = randomBytes(SALT_LENGTH);
528
+ const key = await derive(password, salt, params);
529
+ return ["scrypt", params.logN, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
530
+ }
531
+ async function verifyPassword(password, stored) {
532
+ const [scheme, logN, r, p, salt, hash] = stored.split("$");
533
+ const params = { logN: Number(logN), r: Number(r), p: Number(p) };
534
+ if (scheme !== "scrypt" || !salt || !hash || !Object.values(params).every((n) => Number.isInteger(n) && n > 0)) {
535
+ return { ok: false, needsRehash: false };
536
+ }
537
+ const expected = Buffer.from(hash, "base64url");
538
+ const key = await derive(password, Buffer.from(salt, "base64url"), params);
539
+ const ok = key.length === expected.length && timingSafeEqual(key, expected);
540
+ const needsRehash = params.logN !== HASH_PARAMS.logN || params.r !== HASH_PARAMS.r || params.p !== HASH_PARAMS.p;
541
+ return { ok, needsRehash };
542
+ }
543
+ var dummyHash;
544
+ async function verifyAgainstDummy(password) {
545
+ dummyHash ??= hashPassword(randomBytes(16).toString("hex"));
546
+ await verifyPassword(password, await dummyHash);
547
+ }
548
+ async function checkPasswordPolicy(password, rules, userInputs = []) {
549
+ if (typeof password !== "string") return { message: "password is missing" };
550
+ const length = [...password].length;
551
+ if (length > MAX_PASSWORD_LENGTH) return { message: `The password must have at most ${MAX_PASSWORD_LENGTH} characters.` };
552
+ if (length < rules.minLength) return { message: `The password must have at least ${rules.minLength} characters.`, minLength: rules.minLength };
553
+ if (rules.minScore > 0 && await passwordScore(password, userInputs) < rules.minScore) {
554
+ return {
555
+ message: "This password is too easy to guess. Add another word or two, or choose less common ones.",
556
+ minScore: rules.minScore
557
+ };
558
+ }
559
+ return null;
560
+ }
561
+ function normalizeEmail(email) {
562
+ return email.trim().toLowerCase();
563
+ }
564
+ function isValidEmail(email) {
565
+ return typeof email === "string" && email.trim().length <= MAX_EMAIL_LENGTH && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
566
+ }
567
+ function generateLinkToken() {
568
+ return randomBytes(32).toString("base64url");
569
+ }
570
+ function hashLinkToken(token) {
571
+ return createHash("sha256").update(token).digest("base64url");
572
+ }
573
+ function generateCode() {
574
+ return randomInt(0, 1e6).toString().padStart(6, "0");
575
+ }
576
+ function deriveCodeKey(signingKeyD) {
577
+ return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth e-mail code", 32));
578
+ }
579
+ function hashCode(key, code) {
580
+ return createHmac("sha256", key).update(code).digest("base64url");
581
+ }
582
+ function safeEqual(a, b) {
583
+ const x = Buffer.from(a);
584
+ const y = Buffer.from(b);
585
+ return x.length === y.length && timingSafeEqual(x, y);
586
+ }
587
+
588
+ // src/users.ts
589
+ import { randomBytes as randomBytes2 } from "crypto";
590
+ var MAX_CODE_ATTEMPTS = 5;
591
+ var MAX_WRONG_CODES = 10;
592
+ var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
593
+ function passwordAccountKey(userId) {
594
+ return `password:${userId}`;
595
+ }
596
+ function googleAccountKey(sub) {
597
+ return `google:${sub}`;
598
+ }
599
+ function githubAccountKey(id) {
600
+ return `github:${id}`;
601
+ }
602
+ function totpAccountKey(userId) {
603
+ return `totp:${userId}`;
604
+ }
605
+ function emailLinkAccountKey(userId) {
606
+ return `email-link:${userId}`;
607
+ }
608
+ function smsAccountKey(userId) {
609
+ return `sms:${userId}`;
610
+ }
611
+ var methodOf = (account) => String(account.key).split(":")[0];
612
+ var ALWAYS_PRIMARY = ["password", "google", "github", "email-link"];
613
+ function isPrimary(account, accounts) {
614
+ if (account.primaryUserId) return true;
615
+ if (account.secondFactor === true) return false;
616
+ const method = methodOf(account);
617
+ if (method !== "totp") return ALWAYS_PRIMARY.includes(method);
618
+ return !accounts.some((other) => ALWAYS_PRIMARY.includes(methodOf(other)));
619
+ }
620
+ function toMadauthUser(user, profile = {}) {
621
+ const result = { id: user.id, email: user.email };
622
+ const name = user.name ?? profile.name;
623
+ if (name) result.name = name;
624
+ if (profile.picture) result.picture = profile.picture;
625
+ const claims = claimsFromJson(user.claims);
626
+ if (Object.keys(claims).length) result.claims = claims;
627
+ return result;
628
+ }
629
+ var Users = class {
630
+ /** `codeKey` keys the e-mail codes (see deriveCodeKey); only needed to issue and check them. */
631
+ constructor(store, codeKey) {
632
+ this.store = store;
633
+ this.codeKey = codeKey;
634
+ }
635
+ get key() {
636
+ if (!this.codeKey) throw new Error("Users needs a code key to issue or check e-mail codes.");
637
+ return this.codeKey;
638
+ }
639
+ async findByEmail(emailNormalized) {
640
+ return await this.store.findOne("user", { emailNormalized });
641
+ }
642
+ async findById(id) {
643
+ return await this.store.findOne("user", { id });
644
+ }
645
+ async passwordAccount(userId) {
646
+ return this.findAccountByKey(passwordAccountKey(userId));
647
+ }
648
+ /** The user's authenticator app, if they set one up. */
649
+ async totpAccount(userId) {
650
+ return this.findAccountByKey(totpAccountKey(userId));
651
+ }
652
+ /** The user's e-mail link account, if they sign in that way. */
653
+ async emailLinkAccount(userId) {
654
+ return this.findAccountByKey(emailLinkAccountKey(userId));
655
+ }
656
+ /** The user's phone number for SMS codes, if they added one. */
657
+ async smsAccount(userId) {
658
+ return this.findAccountByKey(smsAccountKey(userId));
659
+ }
660
+ /** The user's accounts: their sign-in method and their second factors. */
661
+ async accounts(userId) {
662
+ return await this.store.findMany("account", { userId });
663
+ }
664
+ /** The account the user signs in with (they have one at most), or null; a sign-up with the authenticator app has none until it is set up. */
665
+ async primaryAccount(userId) {
666
+ const accounts = await this.accounts(userId);
667
+ return accounts.find((account) => isPrimary(account, accounts)) ?? null;
668
+ }
669
+ /**
670
+ * The user's authenticator app, if they set one up, and whether it is how they sign in (they signed up with
671
+ * it) as opposed to a second factor. One read: the app and the user's other accounts together.
672
+ */
673
+ async totpApp(userId) {
674
+ const accounts = await this.accounts(userId);
675
+ const account = accounts.find((a) => a.key === totpAccountKey(userId));
676
+ return account ? { account, signsIn: isPrimary(account, accounts) } : null;
677
+ }
678
+ /** Whether someone can sign in as the user: they have a sign-in method. */
679
+ async hasSignInMethod(userId) {
680
+ return await this.primaryAccount(userId) !== null;
681
+ }
682
+ /**
683
+ * How the user signs in, e.g. `['google']`; empty while nobody can. A user has one sign-in method (only
684
+ * users from before madAuth 0.4 may have a password and Google); a second factor (the authenticator app of
685
+ * a password user, SMS) is a step of it, not a way in. The app of a user who signed up with it counts only
686
+ * while it signs in on its own (`methods` lists it).
687
+ */
688
+ async signInMethods(userId, methods) {
689
+ const accounts = await this.accounts(userId);
690
+ const signIn = accounts.filter((account) => isPrimary(account, accounts)).map(methodOf);
691
+ return SIGN_IN_METHODS.filter((m) => signIn.includes(m) && (m !== "totp" || methods.totp !== void 0));
692
+ }
693
+ /**
694
+ * Notes that the user's first session started: their sign-up is complete. Resolves to true only for the
695
+ * request that wrote it, so `user.signed_up` is sent once; false for a later session, and for a user from
696
+ * before schema version 7.
697
+ */
698
+ async markSignedUp(userId) {
699
+ return await this.store.update("user", { id: userId, signedUpAt: 0 }, { signedUpAt: Date.now() }) === 1;
700
+ }
701
+ /**
702
+ * For a user whose sign-up is not complete (no confirmed address, or no sign-in method) and who is about to
703
+ * finish it or sign up again: one from before schema version 7 says nothing (`signedUpAt` empty, which
704
+ * counts as complete), so it is set to 0 and `user.signed_up` comes at their first session like for anyone.
705
+ */
706
+ async reopenSignUp(user) {
707
+ if (user.signedUpAt !== null && user.signedUpAt !== void 0) return user;
708
+ await this.store.update("user", { id: user.id, signedUpAt: null }, { signedUpAt: 0 });
709
+ return { ...user, signedUpAt: 0 };
710
+ }
711
+ /** The `sub` of the user's linked Google account, if they have one. */
712
+ async googleSub(userId) {
713
+ const accounts = await this.store.findMany("account", { userId });
714
+ const key = accounts.map((account) => String(account.key)).find((k) => k.startsWith("google:"));
715
+ return key ? key.slice("google:".length) : null;
716
+ }
717
+ /** The user's GitHub account, if linked: its key holds GitHub's user id, its secret the encrypted access token of the last sign-in. */
718
+ async githubAccount(userId) {
719
+ const accounts = await this.accounts(userId);
720
+ return accounts.find((account) => account.key.startsWith("github:")) ?? null;
721
+ }
722
+ async findAccountByKey(key) {
723
+ return await this.store.findOne("account", { key });
724
+ }
725
+ /**
726
+ * Creates a user, with their first account if given. Resolves to null if the e-mail address or the
727
+ * account key is taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or
728
+ * e.g. `google:<sub>`. A sign-up with the authenticator app creates the user without an account: the app
729
+ * is set up once the address is confirmed.
730
+ */
731
+ async createUser(data) {
732
+ const user = {
733
+ id: newId("usr"),
734
+ email: data.email,
735
+ emailNormalized: data.emailNormalized,
736
+ emailVerified: data.emailVerified,
737
+ name: data.name,
738
+ sessionVersion: 0,
739
+ lastMailAt: 0,
740
+ lastSmsAt: 0,
741
+ wrongCodes: 0,
742
+ claims: null,
743
+ signedUpAt: 0,
744
+ createdAt: Date.now()
745
+ };
746
+ if (!await this.store.create("user", user)) return null;
747
+ if (!data.account) return user;
748
+ const account = typeof data.account === "function" ? data.account(user.id) : data.account;
749
+ if (!await this.linkAccount(user.id, account)) {
750
+ await this.store.delete("user", { id: user.id });
751
+ return null;
752
+ }
753
+ return user;
754
+ }
755
+ /**
756
+ * Removes what an unconfirmed sign-up set without proving it: the password, and the e-mail link account of
757
+ * a first link nobody used. Only `except`'s own account stays: the method that proves the address, or
758
+ * signs it up anew, is the one the user ends up with.
759
+ */
760
+ async dropUnprovenAccounts(userId, except) {
761
+ const keys = { password: passwordAccountKey(userId), "email-link": emailLinkAccountKey(userId) };
762
+ for (const [method, key] of Object.entries(keys)) {
763
+ if (method !== except) await this.store.delete("account", { key });
764
+ }
765
+ }
766
+ /**
767
+ * A provider verified the user's address: an unconfirmed sign-up with it, whose password or e-mail link
768
+ * nobody has proven, is dropped (its accounts, except `proven`'s own, and its pending links and codes),
769
+ * and the user counts as verified.
770
+ */
771
+ async verifyByProvider(user, proven) {
772
+ if (user.emailVerified) return;
773
+ await this.dropUnprovenAccounts(user.id, proven);
774
+ await this.clearVerifications(user.id);
775
+ await this.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
776
+ }
777
+ /** Removes an account record, e.g. one left behind by a deletion that did not finish. */
778
+ async deleteAccount(id) {
779
+ await this.store.delete("account", { id });
780
+ }
781
+ /** Removes a sign-in method by its key. Resolves to false if there was none. */
782
+ async unlinkAccount(key) {
783
+ return await this.store.delete("account", { key }) === 1;
784
+ }
785
+ /** Removes the user's authenticator app and recovery codes. Resolves to false if they had no app. */
786
+ async removeTotp(userId) {
787
+ const removed = await this.unlinkAccount(totpAccountKey(userId));
788
+ await this.clearRecoveryCodes(userId);
789
+ return removed;
790
+ }
791
+ /** Removes the user's phone number for SMS codes. Resolves to false if they had none. */
792
+ async removeSms(userId) {
793
+ return this.unlinkAccount(smsAccountKey(userId));
794
+ }
795
+ /**
796
+ * Adds a sign-in method or a second factor to a user. Resolves to null if the account key is taken, or if
797
+ * it is a sign-in method and the user has one already.
798
+ */
799
+ async linkAccount(userId, account) {
800
+ const primary = account.primary ?? ALWAYS_PRIMARY.includes(methodOf(account));
801
+ const record = {
802
+ id: newId("acc"),
803
+ userId,
804
+ key: account.key,
805
+ secret: account.secret ?? null,
806
+ failedAttempts: 0,
807
+ lockedUntil: 0,
808
+ email: account.email ?? null,
809
+ lastUsedStep: account.lastUsedStep ?? 0,
810
+ setupId: account.setupId ?? null,
811
+ phone: account.phone ?? null,
812
+ primaryUserId: primary ? userId : null,
813
+ secondFactor: !primary,
814
+ createdAt: Date.now()
815
+ };
816
+ return await this.store.create("account", record) ? record : null;
817
+ }
818
+ /**
819
+ * Writes the secret of a confirmed setup over the authenticator account, only if the account still has the
820
+ * setup it had when this setup started (`replaces`): the write count decides, so of two setups confirmed
821
+ * at once exactly one wins, and the other is told to start again.
822
+ */
823
+ async confirmTotpSetup(accountId, replaces, patch) {
824
+ return await this.store.update("account", { id: accountId, setupId: replaces }, patch) === 1;
825
+ }
826
+ /**
827
+ * Marks the time step of an accepted authenticator code as used and resets the failed attempts, only if
828
+ * no other request accepted a code meanwhile: the write count decides, so the same code sent twice signs
829
+ * in once.
830
+ */
831
+ async useTotpStep(account, step) {
832
+ const changed = await this.store.update(
833
+ "account",
834
+ { id: account.id, lastUsedStep: account.lastUsedStep ?? null },
835
+ { lastUsedStep: step, failedAttempts: 0, lockedUntil: 0 }
836
+ );
837
+ return changed === 1;
838
+ }
839
+ /** Replaces the user's recovery codes with new ones, given as their hashes. */
840
+ /** Stores the recovery codes of a setup (their hashes), next to whatever codes the user has. */
841
+ async addRecoveryCodes(userId, setupId, hashes) {
842
+ const createdAt = Date.now();
843
+ for (const id of hashes) await this.store.create("recoveryCode", { id, userId, setupId, createdAt });
844
+ }
845
+ /** New recovery codes for the user's current app: the codes they had before stop working. */
846
+ async replaceRecoveryCodes(account, hashes) {
847
+ const before = await this.store.findMany("recoveryCode", { userId: account.userId });
848
+ await this.addRecoveryCodes(account.userId, account.setupId ?? "", hashes);
849
+ for (const old of before) await this.store.delete("recoveryCode", { id: old.id });
850
+ }
851
+ /**
852
+ * Uses up the recovery code with this hash for the user's authenticator account. Resolves to false if it
853
+ * is not theirs, belongs to another setup than the account's, or was used already.
854
+ */
855
+ async consumeRecoveryCode(account, hash) {
856
+ const record = await this.store.findOne("recoveryCode", { id: hash });
857
+ if (!record || record.userId !== account.userId || record.setupId !== (account.setupId ?? "")) return false;
858
+ return await this.store.delete("recoveryCode", { id: hash }) === 1;
859
+ }
860
+ /** How many recovery codes of the account's setup are left. */
861
+ async countRecoveryCodes(account) {
862
+ const codes = await this.store.findMany("recoveryCode", { userId: account.userId });
863
+ return codes.filter((code) => code.setupId === (account.setupId ?? "")).length;
864
+ }
865
+ /** Deletes the user's recovery codes: all of them, or only those of one setup, or all but one setup's. */
866
+ async clearRecoveryCodes(userId, which = {}) {
867
+ if (which.only === void 0 && which.except === void 0) {
868
+ await this.store.delete("recoveryCode", { userId });
869
+ return;
870
+ }
871
+ for (const code of await this.store.findMany("recoveryCode", { userId })) {
872
+ const match = which.only !== void 0 ? code.setupId === which.only : code.setupId !== which.except;
873
+ if (match) await this.store.delete("recoveryCode", { id: code.id });
874
+ }
875
+ }
876
+ /**
877
+ * Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
878
+ */
879
+ async deleteById(id) {
880
+ const user = await this.findById(id);
881
+ if (!user) return false;
882
+ if (await this.store.delete("user", { id: user.id }) !== 1) return false;
883
+ try {
884
+ await this.store.delete("account", { userId: user.id });
885
+ await this.store.delete("verification", { userId: user.id });
886
+ await this.store.delete("recoveryCode", { userId: user.id });
887
+ } catch (e) {
888
+ console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
889
+ }
890
+ return true;
891
+ }
892
+ async updateUser(id, patch) {
893
+ await this.store.update("user", { id }, patch);
894
+ }
895
+ /** Changes an account. Resolves to false if it is gone meanwhile. */
896
+ async updateAccount(id, patch) {
897
+ return await this.store.update("account", { id }, patch) === 1;
898
+ }
899
+ /**
900
+ * Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
901
+ * was read. Resolves to false if another request counted an attempt meanwhile.
902
+ */
903
+ async countAttempt(account, patch) {
904
+ const changed = await this.store.update("account", { id: account.id, failedAttempts: account.failedAttempts }, patch);
905
+ return changed === 1;
906
+ }
907
+ /** Deletes the user's pending verifications and password resets; their links and codes stop working. */
908
+ async clearVerifications(userId) {
909
+ await this.store.delete("verification", { userId });
910
+ }
911
+ /**
912
+ * Starts a verification, password reset or e-mail link sign-in: replaces the user's earlier one for the same purpose and
913
+ * returns the link token and the code for the e-mail. Only their hashes are stored.
914
+ */
915
+ async issueVerification(userId, purpose, ttlMs) {
916
+ await this.store.delete("verification", { userId, purpose });
917
+ const token = generateLinkToken();
918
+ const code = generateCode();
919
+ const record = {
920
+ id: hashLinkToken(token),
921
+ userId,
922
+ purpose,
923
+ codeHash: hashCode(this.key, code),
924
+ attempts: 0,
925
+ expiresAt: Date.now() + ttlMs
926
+ };
927
+ await this.store.create("verification", record);
928
+ return { token, code };
929
+ }
930
+ /** The user ID a link token belongs to, without using the verification up; null if invalid, expired or used. */
931
+ async peekLinkToken(token, purpose) {
932
+ const record = await this.store.findOne("verification", { id: hashLinkToken(token), purpose });
933
+ return record && record.expiresAt > Date.now() ? record.userId : null;
934
+ }
935
+ /** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
936
+ async consumeLinkToken(token, purpose) {
937
+ const id = hashLinkToken(token);
938
+ const record = await this.store.findOne("verification", { id, purpose });
939
+ return record ? this.consume(record) : null;
940
+ }
941
+ /**
942
+ * Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
943
+ * MAX_WRONG_CODES wrong e-mail codes in a row the user's codes stop working until an e-mail link is used.
944
+ * SMS codes are not counted that way: that lock is only lifted by an e-mail link or a password reset, which
945
+ * a Google user with a phone never has. Their record's attempts, one SMS per minute and the lock on the
946
+ * sms account (routes/lockout.ts) are their limits.
947
+ */
948
+ async consumeCode(user, purpose, code) {
949
+ const counted = purpose !== "sms";
950
+ if (counted && (user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
951
+ const record = await this.store.findOne("verification", { userId: user.id, purpose });
952
+ if (!record) return { userId: null, locked: false };
953
+ const attempts = record.attempts + 1;
954
+ if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
955
+ return { userId: null, locked: false };
956
+ }
957
+ let wrongCodes = 0;
958
+ if (counted) {
959
+ const count = await this.countWrongCode(user);
960
+ if (count === null) return { userId: null, locked: false };
961
+ if (count > MAX_WRONG_CODES) return { userId: null, locked: true };
962
+ wrongCodes = count;
963
+ }
964
+ if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
965
+ if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
966
+ return { userId: null, locked: counted && wrongCodes >= MAX_WRONG_CODES };
967
+ }
968
+ return { userId: await this.consume(record), locked: false };
969
+ }
970
+ /** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
971
+ async countWrongCode(user) {
972
+ let current = user;
973
+ for (let attempt = 0; attempt < 3 && current; attempt++) {
974
+ const wrongCodes = (current.wrongCodes ?? 0) + 1;
975
+ if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
976
+ return wrongCodes;
977
+ }
978
+ current = await this.findById(user.id);
979
+ }
980
+ return null;
981
+ }
982
+ async consume(record) {
983
+ const deleted = await this.store.delete("verification", { id: record.id });
984
+ if (deleted !== 1 || record.expiresAt <= Date.now()) return null;
985
+ return record.userId;
986
+ }
987
+ };
988
+
989
+ export {
990
+ ADMIN_ROLE,
991
+ parseRoles,
992
+ parseClaims,
993
+ sameClaims,
994
+ claimsFromJson,
995
+ isAdmin,
996
+ sendsVerificationEmails,
997
+ methodConfig,
998
+ envVars,
999
+ resolveEnv,
1000
+ ConfigError,
1001
+ loadConfig,
1002
+ loadStore,
1003
+ MAX_PASSWORD_LENGTH,
1004
+ hashPassword,
1005
+ verifyPassword,
1006
+ verifyAgainstDummy,
1007
+ checkPasswordPolicy,
1008
+ normalizeEmail,
1009
+ isValidEmail,
1010
+ deriveCodeKey,
1011
+ hashCode,
1012
+ safeEqual,
1013
+ SIGN_IN_METHODS,
1014
+ METHODS,
1015
+ METHODS_WITH_SECOND_FACTOR,
1016
+ SECOND_FACTOR_POLICIES,
1017
+ DEFAULT_PASSWORD_RULES,
1018
+ MAX_MIN_LENGTH,
1019
+ isPasswordScore,
1020
+ parsePasswordRules,
1021
+ hasSecondFactor,
1022
+ parseMethodSettings,
1023
+ isConfigured,
1024
+ canSignUp,
1025
+ initialMethods,
1026
+ Settings,
1027
+ isOn,
1028
+ policyOf,
1029
+ anyOn,
1030
+ authenticatorOn,
1031
+ newId,
1032
+ passwordAccountKey,
1033
+ googleAccountKey,
1034
+ githubAccountKey,
1035
+ totpAccountKey,
1036
+ emailLinkAccountKey,
1037
+ smsAccountKey,
1038
+ toMadauthUser,
1039
+ Users
1040
+ };