@madauth/server 0.1.0 → 0.2.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.
@@ -15,7 +15,7 @@ var init_schema = __esm({
15
15
  "src/store/schema.ts"() {
16
16
  "use strict";
17
17
  madauthSchema = {
18
- version: 1,
18
+ version: 3,
19
19
  models: {
20
20
  user: {
21
21
  fields: {
@@ -26,6 +26,8 @@ var init_schema = __esm({
26
26
  name: { type: "string", nullable: true },
27
27
  sessionVersion: { type: "number" },
28
28
  lastMailAt: { type: "number" },
29
+ /** Wrong e-mail codes in a row, counted across e-mails (since version 2). */
30
+ wrongCodes: { type: "number" },
29
31
  createdAt: { type: "number" }
30
32
  }
31
33
  },
@@ -51,6 +53,19 @@ var init_schema = __esm({
51
53
  attempts: { type: "number" },
52
54
  expiresAt: { type: "number" }
53
55
  }
56
+ },
57
+ /**
58
+ * The roles of an e-mail address (since version 3). `id` is the normalized address, so the roles
59
+ * apply whichever way its owner signs in. `roles` holds the names separated by spaces.
60
+ */
61
+ role: {
62
+ fields: {
63
+ id: { type: "string", primaryKey: true },
64
+ roles: { type: "string" },
65
+ updatedAt: { type: "number" },
66
+ /** The address of the admin who set them; empty when set from the command line. */
67
+ updatedBy: { type: "string", nullable: true }
68
+ }
54
69
  }
55
70
  }
56
71
  };
@@ -64,6 +79,9 @@ function tableName(model) {
64
79
  function columnName(field) {
65
80
  return field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
66
81
  }
82
+ function upgradeTablesSql(dialect, fromVersion) {
83
+ return upgrades.filter((step) => step.version > fromVersion).map((step) => step.sql(dialect)).join("\n");
84
+ }
67
85
  function createTablesSql(dialect, schema = madauthSchema) {
68
86
  const statements = [];
69
87
  for (const [model, { fields }] of Object.entries(schema.models)) {
@@ -83,7 +101,7 @@ ${columns.join(",\n")}
83
101
  }
84
102
  return statements.join("\n");
85
103
  }
86
- var columnTypes;
104
+ var columnTypes, upgrades;
87
105
  var init_sql = __esm({
88
106
  "src/store/sql.ts"() {
89
107
  "use strict";
@@ -94,6 +112,16 @@ var init_sql = __esm({
94
112
  postgres: { string: "TEXT", number: "DOUBLE PRECISION", boolean: "BOOLEAN" },
95
113
  mysql: { string: "TEXT", number: "DOUBLE", boolean: "BOOLEAN" }
96
114
  };
115
+ upgrades = [
116
+ {
117
+ version: 2,
118
+ sql: (dialect) => `ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("wrongCodes")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`
119
+ },
120
+ {
121
+ version: 3,
122
+ sql: (dialect) => createTablesSql(dialect, { version: 3, models: { role: madauthSchema.models.role } })
123
+ }
124
+ ];
97
125
  }
98
126
  });
99
127
 
@@ -108,7 +136,7 @@ function createSqliteAdapter(path) {
108
136
  db.exec("PRAGMA busy_timeout = 5000");
109
137
  if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL");
110
138
  migrate(db);
111
- const fieldsOf = (model) => {
139
+ const fieldsOf2 = (model) => {
112
140
  const fields = madauthSchema.models[model]?.fields;
113
141
  if (!fields) throw new Error(`Unknown model "${model}"`);
114
142
  return fields;
@@ -141,25 +169,25 @@ function createSqliteAdapter(path) {
141
169
  };
142
170
  return {
143
171
  async create(model, data) {
144
- const fields = fieldsOf(model);
172
+ const fields = fieldsOf2(model);
145
173
  const names = Object.keys(data);
146
174
  const sql = `INSERT INTO ${tableName(model)} (${names.map((f) => column(fields, f)).join(", ")}) VALUES (${names.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`;
147
175
  return Number(db.prepare(sql).run(...names.map((f) => toSql(data[f]))).changes) === 1;
148
176
  },
149
177
  async findOne(model, where) {
150
- const fields = fieldsOf(model);
178
+ const fields = fieldsOf2(model);
151
179
  const w = whereSql(fields, where);
152
180
  const raw2 = db.prepare(`SELECT * FROM ${tableName(model)}${w.sql} LIMIT 1`).get(...w.params);
153
181
  return raw2 ? fromSql(fields, raw2) : null;
154
182
  },
155
183
  async findMany(model, where, opts = {}) {
156
- const fields = fieldsOf(model);
184
+ const fields = fieldsOf2(model);
157
185
  const w = whereSql(fields, where);
158
186
  const limit = opts.limit === void 0 ? "" : ` LIMIT ${Math.max(0, Math.floor(opts.limit))}`;
159
187
  return db.prepare(`SELECT * FROM ${tableName(model)}${w.sql}${limit}`).all(...w.params).map((raw2) => fromSql(fields, raw2));
160
188
  },
161
189
  async update(model, where, patch) {
162
- const fields = fieldsOf(model);
190
+ const fields = fieldsOf2(model);
163
191
  const names = Object.keys(patch);
164
192
  if (!names.length) return 0;
165
193
  const w = whereSql(fields, where);
@@ -167,7 +195,7 @@ function createSqliteAdapter(path) {
167
195
  return Number(db.prepare(sql).run(...names.map((f) => toSql(patch[f])), ...w.params).changes);
168
196
  },
169
197
  async delete(model, where) {
170
- const fields = fieldsOf(model);
198
+ const fields = fieldsOf2(model);
171
199
  const w = whereSql(fields, where);
172
200
  return Number(db.prepare(`DELETE FROM ${tableName(model)}${w.sql}`).run(...w.params).changes);
173
201
  }
@@ -183,7 +211,7 @@ function migrate(db) {
183
211
  if (version === madauthSchema.version) return;
184
212
  db.exec("BEGIN");
185
213
  try {
186
- db.exec(createTablesSql("sqlite"));
214
+ db.exec(version === 0 ? createTablesSql("sqlite") : upgradeTablesSql("sqlite", version));
187
215
  db.exec(`PRAGMA user_version = ${madauthSchema.version}`);
188
216
  db.exec("COMMIT");
189
217
  } catch (e) {
@@ -199,6 +227,255 @@ var init_sqlite = __esm({
199
227
  }
200
228
  });
201
229
 
230
+ // src/store/dynamodb.ts
231
+ var dynamodb_exports = {};
232
+ __export(dynamodb_exports, {
233
+ createDynamoDbAdapter: () => createDynamoDbAdapter
234
+ });
235
+ function toAttribute(value) {
236
+ if (value === null) return { NULL: true };
237
+ if (typeof value === "string") return { S: value };
238
+ if (typeof value === "number") return { N: String(value) };
239
+ return { BOOL: value };
240
+ }
241
+ function fromAttribute(attribute) {
242
+ if (attribute?.S !== void 0) return attribute.S;
243
+ if (attribute?.N !== void 0) return Number(attribute.N);
244
+ if (attribute?.BOOL !== void 0) return attribute.BOOL;
245
+ return null;
246
+ }
247
+ function fieldsOf(model) {
248
+ const fields = madauthSchema.models[model]?.fields;
249
+ if (!fields) throw new Error(`Unknown model "${model}"`);
250
+ return fields;
251
+ }
252
+ function checkFields(fields, names) {
253
+ for (const name of names) if (!fields[name]) throw new Error(`Unknown field "${name}"`);
254
+ }
255
+ function primaryKeyOf(fields) {
256
+ return Object.keys(fields).find((name) => fields[name].primaryKey);
257
+ }
258
+ function toRow(fields, item) {
259
+ return Object.fromEntries(Object.keys(fields).map((name) => [name, fromAttribute(item[name])]));
260
+ }
261
+ function matches(row, where) {
262
+ return Object.entries(where).every(([field, value]) => row[field] === value);
263
+ }
264
+ function stillMatches(where) {
265
+ const names = {};
266
+ const values = {};
267
+ const parts = ["attribute_exists(pk)"];
268
+ Object.entries(where).forEach(([field, value], i) => {
269
+ names[`#w${i}`] = field;
270
+ values[`:w${i}`] = toAttribute(value);
271
+ parts.push(value === null ? `(attribute_not_exists(#w${i}) OR #w${i} = :w${i})` : `#w${i} = :w${i}`);
272
+ });
273
+ return { text: parts.join(" AND "), names, values };
274
+ }
275
+ function failedWrite(error) {
276
+ const { name, CancellationReasons } = error;
277
+ if (name === "ConditionalCheckFailedException") return "refused";
278
+ if (name && RETRYABLE_ERRORS.includes(name)) return { retry: error };
279
+ if (name === "TransactionCanceledException") {
280
+ const reasons = (CancellationReasons ?? []).map((r) => r.Code ?? "None").filter((code) => code !== "None");
281
+ if (reasons.includes("ConditionalCheckFailed")) return "refused";
282
+ if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry: error };
283
+ }
284
+ throw error;
285
+ }
286
+ function createDynamoDbAdapter(options) {
287
+ const { tableName: tableName2 } = options;
288
+ let loading;
289
+ const aws = () => loading ??= import("@aws-sdk/client-dynamodb").then((sdk) => ({
290
+ sdk,
291
+ client: options.client ?? new sdk.DynamoDBClient({})
292
+ }));
293
+ const write = async (send) => {
294
+ for (let attempt = 0; ; attempt++) {
295
+ const outcome = await send().then(() => "written", failedWrite);
296
+ if (outcome === "written") return true;
297
+ if (outcome === "refused") return false;
298
+ if (attempt === MAX_RETRIES) throw outcome.retry;
299
+ await pause(attempt);
300
+ }
301
+ };
302
+ const transact = async (items) => {
303
+ const { sdk, client } = await aws();
304
+ return write(() => client.send(new sdk.TransactWriteItemsCommand({ TransactItems: items })));
305
+ };
306
+ const getItem = async (key) => {
307
+ const { sdk, client } = await aws();
308
+ return (await client.send(new sdk.GetItemCommand({ TableName: tableName2, Key: key, ConsistentRead: true }))).Item;
309
+ };
310
+ const getRecord = async (model, fields, id) => {
311
+ const item = await getItem(recordKey(model, id));
312
+ return item ? toRow(fields, item) : null;
313
+ };
314
+ const idsOfIndex = async (model, field, value) => {
315
+ const { sdk, client } = await aws();
316
+ const ids = [];
317
+ let next;
318
+ do {
319
+ const page = await client.send(
320
+ new sdk.QueryCommand({
321
+ TableName: tableName2,
322
+ KeyConditionExpression: "pk = :pk",
323
+ ExpressionAttributeValues: { ":pk": { S: indexPartition(model, field, value) } },
324
+ ProjectionExpression: "sk",
325
+ ConsistentRead: true,
326
+ ExclusiveStartKey: next
327
+ })
328
+ );
329
+ for (const item of page.Items ?? []) if (item.sk?.S) ids.push(item.sk.S);
330
+ next = page.LastEvaluatedKey;
331
+ } while (next);
332
+ return ids;
333
+ };
334
+ const find = async (model, where) => {
335
+ const fields = fieldsOf(model);
336
+ checkFields(fields, Object.keys(where));
337
+ const given = (name) => where[name] !== void 0 && where[name] !== null;
338
+ const primaryKey = primaryKeyOf(fields);
339
+ const unique = Object.keys(fields).find((name) => fields[name].unique && given(name));
340
+ const indexed = Object.keys(fields).find((name) => fields[name].index && given(name));
341
+ let ids;
342
+ if (given(primaryKey)) {
343
+ ids = [String(where[primaryKey])];
344
+ } else if (unique) {
345
+ const ref = (await getItem(uniqueKey(model, unique, where[unique])))?.ref?.S;
346
+ ids = ref ? [ref] : [];
347
+ } else if (indexed) {
348
+ ids = await idsOfIndex(model, indexed, where[indexed]);
349
+ } else {
350
+ throw new Error(
351
+ `The DynamoDB adapter can't find "${model}" records by ${Object.keys(where).join(", ") || "nothing"}: the filter needs the id, a unique field or an indexed field.`
352
+ );
353
+ }
354
+ const rows = await Promise.all(ids.map((id) => getRecord(model, fields, id)));
355
+ return rows.filter((row) => row !== null && matches(row, where));
356
+ };
357
+ const derivedKeys = (model, fields, row, id) => Object.keys(fields).flatMap((name) => {
358
+ const value = row[name];
359
+ if (value === void 0 || value === null) return [];
360
+ return [
361
+ ...fields[name].unique ? [uniqueKey(model, name, value)] : [],
362
+ ...fields[name].index ? [indexKey(model, name, value, id)] : []
363
+ ];
364
+ });
365
+ return {
366
+ async create(model, data) {
367
+ const fields = fieldsOf(model);
368
+ checkFields(fields, Object.keys(data));
369
+ const primaryKey = primaryKeyOf(fields);
370
+ const id = data[primaryKey];
371
+ if (typeof id !== "string" || !id) throw new Error(`A "${model}" record needs a "${primaryKey}"`);
372
+ const record = { ...recordKey(model, id) };
373
+ for (const [field, value] of Object.entries(data)) record[field] = toAttribute(value);
374
+ const unused = "attribute_not_exists(pk)";
375
+ return transact([
376
+ { Put: { TableName: tableName2, Item: record, ConditionExpression: unused } },
377
+ ...derivedKeys(model, fields, data, id).map(
378
+ (key) => (
379
+ // A unique value refers to its record and may not be taken yet; an index entry is only a key.
380
+ key.pk.S.startsWith("u|") ? { Put: { TableName: tableName2, Item: { ...key, ref: { S: id } }, ConditionExpression: unused } } : { Put: { TableName: tableName2, Item: key } }
381
+ )
382
+ )
383
+ ]);
384
+ },
385
+ async findOne(model, where) {
386
+ return (await find(model, where))[0] ?? null;
387
+ },
388
+ async findMany(model, where, opts = {}) {
389
+ const rows = await find(model, where);
390
+ return opts.limit === void 0 ? rows : rows.slice(0, Math.max(0, Math.floor(opts.limit)));
391
+ },
392
+ async update(model, where, patch) {
393
+ const fields = fieldsOf(model);
394
+ const changes = Object.entries(patch);
395
+ checkFields(fields, [...Object.keys(where), ...Object.keys(patch)]);
396
+ for (const [field] of changes) {
397
+ if (fields[field].primaryKey || fields[field].unique || fields[field].index) {
398
+ throw new Error(`The DynamoDB adapter can't change "${field}" of a "${model}" record: it is a key.`);
399
+ }
400
+ }
401
+ if (!changes.length) return 0;
402
+ const primaryKey = primaryKeyOf(fields);
403
+ const condition = stillMatches(where);
404
+ const names = { ...condition.names };
405
+ const values = { ...condition.values };
406
+ const assignments = changes.map(([field, value], i) => {
407
+ names[`#p${i}`] = field;
408
+ values[`:p${i}`] = toAttribute(value);
409
+ return `#p${i} = :p${i}`;
410
+ });
411
+ const byId = where[primaryKey];
412
+ const ids = byId !== void 0 && byId !== null ? [String(byId)] : (await find(model, where)).map((row) => String(row[primaryKey]));
413
+ const written = await Promise.all(
414
+ ids.map(
415
+ (id) => transact([
416
+ {
417
+ Update: {
418
+ TableName: tableName2,
419
+ Key: recordKey(model, id),
420
+ UpdateExpression: `SET ${assignments.join(", ")}`,
421
+ ConditionExpression: condition.text,
422
+ ExpressionAttributeNames: names,
423
+ ExpressionAttributeValues: values
424
+ }
425
+ }
426
+ ])
427
+ )
428
+ );
429
+ return written.filter(Boolean).length;
430
+ },
431
+ async delete(model, where) {
432
+ const fields = fieldsOf(model);
433
+ const primaryKey = primaryKeyOf(fields);
434
+ const condition = stillMatches(where);
435
+ const deleted = await Promise.all(
436
+ (await find(model, where)).map((row) => {
437
+ const id = String(row[primaryKey]);
438
+ return transact([
439
+ {
440
+ Delete: {
441
+ TableName: tableName2,
442
+ Key: recordKey(model, id),
443
+ ConditionExpression: condition.text,
444
+ ExpressionAttributeNames: condition.names,
445
+ ExpressionAttributeValues: condition.values
446
+ }
447
+ },
448
+ ...derivedKeys(model, fields, row, id).map((key) => ({ Delete: { TableName: tableName2, Key: key } }))
449
+ ]);
450
+ })
451
+ );
452
+ return deleted.filter(Boolean).length;
453
+ }
454
+ };
455
+ }
456
+ var SORT_KEY, MAX_RETRIES, RETRYABLE_REASONS, RETRYABLE_ERRORS, recordKey, uniqueKey, indexPartition, indexKey, pause;
457
+ var init_dynamodb = __esm({
458
+ "src/store/dynamodb.ts"() {
459
+ "use strict";
460
+ init_schema();
461
+ SORT_KEY = "#";
462
+ MAX_RETRIES = 8;
463
+ RETRYABLE_REASONS = ["TransactionConflict", "ThrottlingError", "ProvisionedThroughputExceeded"];
464
+ RETRYABLE_ERRORS = ["TransactionConflictException", "TransactionInProgressException"];
465
+ recordKey = (model, id) => ({ pk: { S: `r|${model}|${id}` }, sk: { S: SORT_KEY } });
466
+ uniqueKey = (model, field, value) => ({
467
+ pk: { S: `u|${model}|${field}|${String(value)}` },
468
+ sk: { S: SORT_KEY }
469
+ });
470
+ indexPartition = (model, field, value) => `i|${model}|${field}|${String(value)}`;
471
+ indexKey = (model, field, value, id) => ({
472
+ pk: { S: indexPartition(model, field, value) },
473
+ sk: { S: id }
474
+ });
475
+ pause = (attempt) => new Promise((resolve) => setTimeout(resolve, Math.random() * Math.min(25 * 2 ** attempt, 400)));
476
+ }
477
+ });
478
+
202
479
  // ../../node_modules/hono/dist/utils/encode.js
203
480
  var encodeBase64 = (buf) => {
204
481
  let binary = "";
@@ -3612,6 +3889,45 @@ function safeEqual(a, b) {
3612
3889
  return x.length === y.length && timingSafeEqual(x, y);
3613
3890
  }
3614
3891
 
3892
+ // src/roles.ts
3893
+ var ADMIN_ROLE = "admin";
3894
+ var MAX_ROLES = 20;
3895
+ var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
3896
+ function parseRoles(input) {
3897
+ if (!Array.isArray(input)) return null;
3898
+ if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
3899
+ const roles = [...new Set(input)].sort();
3900
+ return roles.length <= MAX_ROLES ? roles : null;
3901
+ }
3902
+ function sameRoles(a, b) {
3903
+ return a.length === b.length && a.every((role, i) => role === b[i]);
3904
+ }
3905
+ var Roles = class {
3906
+ constructor(store) {
3907
+ this.store = store;
3908
+ }
3909
+ /** The address's roles, sorted; empty if it has none. */
3910
+ async get(emailNormalized) {
3911
+ const record = await this.store.findOne("role", { id: emailNormalized });
3912
+ return typeof record?.roles === "string" && record.roles ? record.roles.split(" ").filter(Boolean) : [];
3913
+ }
3914
+ /** Replaces the address's roles. `updatedBy` is the address of the admin, or null for the command line. */
3915
+ async set(emailNormalized, roles, updatedBy) {
3916
+ if (!roles.length) {
3917
+ await this.remove(emailNormalized);
3918
+ return;
3919
+ }
3920
+ const data = { roles: roles.join(" "), updatedAt: Date.now(), updatedBy };
3921
+ if (await this.store.update("role", { id: emailNormalized }, data) === 1) return;
3922
+ if (!await this.store.create("role", { id: emailNormalized, ...data })) {
3923
+ await this.store.update("role", { id: emailNormalized }, data);
3924
+ }
3925
+ }
3926
+ async remove(emailNormalized) {
3927
+ await this.store.delete("role", { id: emailNormalized });
3928
+ }
3929
+ };
3930
+
3615
3931
  // src/google.ts
3616
3932
  var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
3617
3933
  var googleJwks;
@@ -3649,6 +3965,9 @@ async function verifyGoogleIdToken(idToken, opts) {
3649
3965
  // src/user.ts
3650
3966
  var SESSION_COOKIE = "madauth_session";
3651
3967
  var SESSION_TYP = "madauth-session+jwt";
3968
+ var RENEWAL_COOKIE = "madauth_renewal";
3969
+ var RENEWAL_TYP = "madauth-renewal+jwt";
3970
+ var EXPIRY_COOKIE = "madauth_session_expires";
3652
3971
 
3653
3972
  // src/tokens.ts
3654
3973
  function randomString(bytes = 32) {
@@ -3674,15 +3993,29 @@ function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
3674
3993
  email_verified: user.email ? true : void 0,
3675
3994
  name: user.name,
3676
3995
  picture: user.picture,
3996
+ roles: user.roles?.length ? user.roles : void 0,
3677
3997
  amr
3678
3998
  };
3679
3999
  return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
3680
4000
  }
4001
+ function signRenewal(keys, issuer, ttlSeconds, user, amr, extra = {}) {
4002
+ const claims = {
4003
+ ...extra,
4004
+ sub: user.id,
4005
+ email: user.email,
4006
+ email_verified: user.email ? true : void 0,
4007
+ name: user.name,
4008
+ picture: user.picture,
4009
+ amr
4010
+ };
4011
+ return signToken(keys, issuer, RENEWAL_TYP, claims, ttlSeconds);
4012
+ }
3681
4013
  function userFromClaims(claims) {
3682
4014
  const user = { id: claims.sub };
3683
4015
  if (claims.email) user.email = claims.email;
3684
4016
  if (claims.name) user.name = claims.name;
3685
4017
  if (claims.picture) user.picture = claims.picture;
4018
+ if (Array.isArray(claims.roles) && claims.roles.length) user.roles = claims.roles;
3686
4019
  return user;
3687
4020
  }
3688
4021
 
@@ -3734,9 +4067,9 @@ function googleRoutes(app, ctx, google) {
3734
4067
  return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
3735
4068
  }
3736
4069
  deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
3737
- await ctx.startSession(c, result.user, ["google"]);
3738
- await ctx.emit("user.signed_in", { user: result.user, method: "google" });
3739
- return c.json({ user: result.user });
4070
+ const user = await ctx.startSession(c, result.user, ["google"]);
4071
+ await ctx.emit("user.signed_in", { user, method: "google" });
4072
+ return c.json({ user });
3740
4073
  });
3741
4074
  const requireCodeFlow = async (c, next) => {
3742
4075
  if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
@@ -3810,8 +4143,8 @@ function googleRoutes(app, ctx, google) {
3810
4143
  keys: config.jwksResolver
3811
4144
  });
3812
4145
  if (!result.ok) return back(result.error);
3813
- await ctx.startSession(c, result.user, ["google"]);
3814
- await ctx.emit("user.signed_in", { user: result.user, method: "google" });
4146
+ const user = await ctx.startSession(c, result.user, ["google"]);
4147
+ await ctx.emit("user.signed_in", { user, method: "google" });
3815
4148
  return back();
3816
4149
  });
3817
4150
  }
@@ -3876,12 +4209,17 @@ function passwordRoutes(app, ctx, password, users, webhook) {
3876
4209
  }
3877
4210
  if (isValidEmail(data.email) && typeof data.code === "string") {
3878
4211
  const user = await users.findByEmail(normalizeEmail(data.email));
3879
- const code = data.code.replace(/\s/g, "");
3880
- return { userId: user ? await users.consumeCode(user.id, purpose, code) : null, via: "code" };
4212
+ if (!user) return { userId: null, via: "code" };
4213
+ return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
3881
4214
  }
3882
4215
  return { userId: null, via: "code" };
3883
4216
  };
3884
- const invalidVerification = (c, via) => via === "link" ? error(c, 400, "link_invalid", "The link is invalid, expired or was already used.") : error(c, 400, "code_invalid", "The code is wrong or expired.");
4217
+ const invalidVerification = (c, { via, locked }) => {
4218
+ if (locked) {
4219
+ return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
4220
+ }
4221
+ return via === "link" ? error(c, 400, "link_invalid", "The link is invalid, expired or was already used.") : error(c, 400, "code_invalid", "The code is wrong or expired.");
4222
+ };
3885
4223
  app.post("/auth/password/signin", async (c) => {
3886
4224
  const data = await body(c);
3887
4225
  const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
@@ -3913,8 +4251,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
3913
4251
  if (!user.emailVerified) {
3914
4252
  return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
3915
4253
  }
3916
- const result = toMadauthUser(user);
3917
- await ctx.startSession(c, result, ["pwd"], { sv: user.sessionVersion });
4254
+ const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
3918
4255
  await ctx.emit("user.signed_in", { user: result, method: "password" });
3919
4256
  return c.json({ user: result });
3920
4257
  });
@@ -3943,6 +4280,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
3943
4280
  const passwordHash = await hashPassword(data.password);
3944
4281
  const existing = await users.findByEmail(normalizeEmail(email));
3945
4282
  if (existing?.emailVerified) {
4283
+ if (!webhook.wants("email.already_registered")) return accepted(c);
3946
4284
  const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
3947
4285
  return sent ? accepted(c) : unavailable(c);
3948
4286
  }
@@ -3976,12 +4314,12 @@ function passwordRoutes(app, ctx, password, users, webhook) {
3976
4314
  return accepted(c);
3977
4315
  });
3978
4316
  app.post("/auth/password/verify-email", async (c) => {
3979
- const { userId, via } = await consume(await body(c), "verify");
4317
+ const consumed = await consume(await body(c), "verify");
4318
+ const { userId, via } = consumed;
3980
4319
  const user = userId ? await users.findById(userId) : null;
3981
- if (!user) return invalidVerification(c, via);
3982
- await users.updateUser(user.id, { emailVerified: true });
3983
- const result = toMadauthUser(user);
3984
- await ctx.startSession(c, result, ["pwd"], { sv: user.sessionVersion });
4320
+ if (!user) return invalidVerification(c, consumed);
4321
+ await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
4322
+ const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
3985
4323
  await ctx.emit("email.verified", { user: result, via });
3986
4324
  await ctx.emit("user.signed_in", { user: result, method: "password" });
3987
4325
  return c.json({ user: result });
@@ -4001,16 +4339,15 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4001
4339
  const data = await body(c);
4002
4340
  const policy = checkPasswordPolicy(data.password, minLength);
4003
4341
  if (policy) return error(c, 400, "weak_password", policy);
4004
- const { userId, via } = await consume(data, "reset");
4005
- const user = userId ? await users.findById(userId) : null;
4342
+ const consumed = await consume(data, "reset");
4343
+ const user = consumed.userId ? await users.findById(consumed.userId) : null;
4006
4344
  const account = user ? await users.passwordAccount(user.id) : null;
4007
- if (!user || !account) return invalidVerification(c, via);
4345
+ if (!user || !account) return invalidVerification(c, consumed);
4008
4346
  await users.updateAccount(account.id, { secret: await hashPassword(data.password), failedAttempts: 0, lockedUntil: 0 });
4009
4347
  const sessionVersion = user.sessionVersion + 1;
4010
- await users.updateUser(user.id, { emailVerified: true, sessionVersion });
4348
+ await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
4011
4349
  await users.clearVerifications(user.id);
4012
- const result = toMadauthUser(user);
4013
- await ctx.startSession(c, result, ["pwd"], { sv: sessionVersion });
4350
+ const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
4014
4351
  await ctx.emit("password.reset", { user: result });
4015
4352
  await ctx.emit("user.signed_in", { user: result, method: "password" });
4016
4353
  return c.json({ user: result });
@@ -4020,6 +4357,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4020
4357
  // src/users.ts
4021
4358
  import { randomBytes as randomBytes2 } from "crypto";
4022
4359
  var MAX_CODE_ATTEMPTS = 5;
4360
+ var MAX_WRONG_CODES = 10;
4023
4361
  var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
4024
4362
  function passwordAccountKey(userId) {
4025
4363
  return `password:${userId}`;
@@ -4054,6 +4392,7 @@ var Users = class {
4054
4392
  name: data.name,
4055
4393
  sessionVersion: 0,
4056
4394
  lastMailAt: 0,
4395
+ wrongCodes: 0,
4057
4396
  createdAt: now
4058
4397
  };
4059
4398
  if (!await this.store.create("user", user)) return null;
@@ -4072,6 +4411,22 @@ var Users = class {
4072
4411
  }
4073
4412
  return user;
4074
4413
  }
4414
+ /**
4415
+ * Deletes the user with this e-mail address and everything stored for them. Resolves to the deleted
4416
+ * user's ID, or null if there is no such user (any more).
4417
+ */
4418
+ async deleteByEmail(emailNormalized) {
4419
+ const user = await this.findByEmail(emailNormalized);
4420
+ if (!user) return null;
4421
+ if (await this.store.delete("user", { id: user.id }) !== 1) return null;
4422
+ try {
4423
+ await this.store.delete("account", { userId: user.id });
4424
+ await this.store.delete("verification", { userId: user.id });
4425
+ } catch (e) {
4426
+ console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
4427
+ }
4428
+ return user.id;
4429
+ }
4075
4430
  async updateUser(id, patch) {
4076
4431
  await this.store.update("user", { id }, patch);
4077
4432
  }
@@ -4115,17 +4470,38 @@ var Users = class {
4115
4470
  const record = await this.store.findOne("verification", { id, purpose });
4116
4471
  return record ? this.consume(record) : null;
4117
4472
  }
4118
- /** Uses up the user's verification if `code` matches. Too many wrong codes delete it. */
4119
- async consumeCode(userId, purpose, code) {
4120
- const record = await this.store.findOne("verification", { userId, purpose });
4121
- if (!record) return null;
4473
+ /**
4474
+ * Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
4475
+ * MAX_WRONG_CODES wrong codes in a row the user's codes stop working until an e-mail link is used.
4476
+ */
4477
+ async consumeCode(user, purpose, code) {
4478
+ if ((user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
4479
+ const record = await this.store.findOne("verification", { userId: user.id, purpose });
4480
+ if (!record) return { userId: null, locked: false };
4122
4481
  const attempts = record.attempts + 1;
4123
- if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) return null;
4482
+ if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
4483
+ return { userId: null, locked: false };
4484
+ }
4485
+ const wrongCodes = await this.countWrongCode(user);
4486
+ if (wrongCodes === null) return { userId: null, locked: false };
4487
+ if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
4124
4488
  if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
4125
4489
  if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
4126
- return null;
4490
+ return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
4491
+ }
4492
+ return { userId: await this.consume(record), locked: false };
4493
+ }
4494
+ /** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
4495
+ async countWrongCode(user) {
4496
+ let current = user;
4497
+ for (let attempt = 0; attempt < 3 && current; attempt++) {
4498
+ const wrongCodes = (current.wrongCodes ?? 0) + 1;
4499
+ if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
4500
+ return wrongCodes;
4501
+ }
4502
+ current = await this.findById(user.id);
4127
4503
  }
4128
- return this.consume(record);
4504
+ return null;
4129
4505
  }
4130
4506
  async consume(record) {
4131
4507
  const deleted = await this.store.delete("verification", { id: record.id });
@@ -4144,10 +4520,13 @@ var WEBHOOK_TYPES = [
4144
4520
  "user.created",
4145
4521
  "email.verified",
4146
4522
  "password.reset",
4147
- "user.signed_in"
4523
+ "user.signed_in",
4524
+ "user.deleted",
4525
+ "roles.changed"
4148
4526
  ];
4149
- var isEmailType = (type) => type.startsWith("email.") && type !== "email.verified";
4527
+ var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
4150
4528
  var SECRET_PREFIX = "whsec_";
4529
+ var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
4151
4530
  var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
4152
4531
  function generateWebhookSecret() {
4153
4532
  return `${SECRET_PREFIX}${randomBytes3(32).toString("base64")}`;
@@ -4166,7 +4545,7 @@ function signWebhook(secret, id, timestamp, body) {
4166
4545
  return `v1,${hmac2(secret, `${id}.${timestamp}.${body}`)}`;
4167
4546
  }
4168
4547
  function createWebhookClient(settings, fetchImpl = fetch) {
4169
- const wants = (type) => isEmailType(type) || !settings.events || settings.events.has(type);
4548
+ const wants = (type) => settings.events.has(type);
4170
4549
  return {
4171
4550
  wants,
4172
4551
  async call(type, data, timeoutMs) {
@@ -4207,10 +4586,50 @@ function createWebhookClient(settings, fetchImpl = fetch) {
4207
4586
  // src/app.ts
4208
4587
  var EVENT_TIMEOUT_MS = 5e3;
4209
4588
  function createApp(config) {
4210
- const { issuer, allowedOrigins, sessionTtlSeconds, cookieDomain } = config;
4589
+ const { issuer, allowedOrigins, sessionTtlSeconds, renewalTtlSeconds, cookieDomain } = config;
4211
4590
  const keys = importSigningKeys(config.signingKey);
4212
4591
  const secure = issuer.startsWith("https:");
4213
4592
  const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
4593
+ const roles = config.password ? new Roles(config.password.store) : void 0;
4594
+ const withRoles = async (user) => {
4595
+ const { roles: _, ...rest } = user;
4596
+ const current = roles && user.email ? await roles.get(normalizeEmail(user.email)) : [];
4597
+ return current.length ? { ...rest, roles: current } : rest;
4598
+ };
4599
+ const issueSession = async (c, user, amr, extra) => {
4600
+ const signingKeys = await keys;
4601
+ const [sessionToken, renewalToken] = await Promise.all([
4602
+ signSession(signingKeys, issuer, sessionTtlSeconds, user, amr, extra),
4603
+ signRenewal(signingKeys, issuer, renewalTtlSeconds, user, amr, extra)
4604
+ ]);
4605
+ setCookie(c, SESSION_COOKIE, sessionToken, {
4606
+ path: "/",
4607
+ domain: cookieDomain,
4608
+ httpOnly: true,
4609
+ secure,
4610
+ sameSite: "Lax",
4611
+ maxAge: sessionTtlSeconds
4612
+ });
4613
+ setCookie(c, RENEWAL_COOKIE, renewalToken, {
4614
+ path: "/auth",
4615
+ httpOnly: true,
4616
+ secure,
4617
+ sameSite: "Lax",
4618
+ maxAge: renewalTtlSeconds
4619
+ });
4620
+ setCookie(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
4621
+ path: "/",
4622
+ domain: cookieDomain,
4623
+ secure,
4624
+ sameSite: "Lax",
4625
+ maxAge: renewalTtlSeconds
4626
+ });
4627
+ };
4628
+ const clearSession = (c) => {
4629
+ deleteCookie(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
4630
+ deleteCookie(c, RENEWAL_COOKIE, { path: "/auth", secure });
4631
+ deleteCookie(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
4632
+ };
4214
4633
  const ctx = {
4215
4634
  config,
4216
4635
  keys,
@@ -4228,15 +4647,9 @@ function createApp(config) {
4228
4647
  return url;
4229
4648
  },
4230
4649
  async startSession(c, user, amr, extra) {
4231
- const token = await signSession(await keys, issuer, sessionTtlSeconds, user, amr, extra);
4232
- setCookie(c, SESSION_COOKIE, token, {
4233
- path: "/",
4234
- domain: cookieDomain,
4235
- httpOnly: true,
4236
- secure,
4237
- sameSite: "Lax",
4238
- maxAge: sessionTtlSeconds
4239
- });
4650
+ const signedIn = await withRoles(user);
4651
+ await issueSession(c, signedIn, amr, extra);
4652
+ return signedIn;
4240
4653
  },
4241
4654
  users: config.password ? new Users(config.password.store, deriveCodeKey(config.signingKey.d)) : void 0,
4242
4655
  webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
@@ -4278,26 +4691,104 @@ function createApp(config) {
4278
4691
  );
4279
4692
  if (config.google) googleRoutes(app, ctx, config.google);
4280
4693
  if (config.password && ctx.users && ctx.webhook) passwordRoutes(app, ctx, config.password, ctx.users, ctx.webhook);
4694
+ const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c, cookie));
4695
+ const hasEnded = async (claims) => {
4696
+ if (!claims.sub.startsWith("usr_")) return false;
4697
+ const stored = await ctx.users?.findById(claims.sub);
4698
+ return !stored || stored.sessionVersion !== claims.sv;
4699
+ };
4700
+ const checked = async (claims) => {
4701
+ const [ended, user] = await Promise.all([hasEnded(claims), withRoles(userFromClaims(claims))]);
4702
+ return ended ? null : user;
4703
+ };
4704
+ const currentSession = async (c) => {
4705
+ const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
4706
+ const user = session ? await checked(session) : null;
4707
+ const upToDate = session && user && sameRoles(user.roles ?? [], session.roles ?? []) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
4708
+ if (upToDate) return user;
4709
+ const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
4710
+ const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
4711
+ const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
4712
+ if (renewal && renewed) {
4713
+ await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
4714
+ return renewed;
4715
+ }
4716
+ return user;
4717
+ };
4281
4718
  app.get("/auth/session", async (c) => {
4282
- const claims = await readToken(await keys, issuer, SESSION_TYP, getCookie(c, SESSION_COOKIE));
4283
- if (!claims) return c.json({ error: "no_session" }, 401);
4284
- if (claims.sub.startsWith("usr_")) {
4285
- const stored = await ctx.users?.findById(claims.sub);
4286
- if (!stored || stored.sessionVersion !== claims.sv) return c.json({ error: "no_session" }, 401);
4719
+ const user = await currentSession(c);
4720
+ if (!user) {
4721
+ clearSession(c);
4722
+ return c.json({ error: "no_session" }, 401);
4287
4723
  }
4288
- const user = userFromClaims(claims);
4289
- if (Date.now() / 1e3 - claims.iat > sessionTtlSeconds / 2) await ctx.startSession(c, user, claims.amr, { sv: claims.sv });
4290
4724
  return c.json({ user });
4291
4725
  });
4292
4726
  app.post("/auth/logout", (c) => {
4293
- deleteCookie(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
4727
+ clearSession(c);
4728
+ return c.body(null, 204);
4729
+ });
4730
+ app.post("/auth/account/delete", async (c) => {
4731
+ const user = await currentSession(c);
4732
+ if (!user) return c.json({ error: "no_session" }, 401);
4733
+ if (roles && user.email) await roles.remove(normalizeEmail(user.email));
4734
+ const passwordUserId = ctx.users && user.email ? await ctx.users.deleteByEmail(normalizeEmail(user.email)) : null;
4735
+ clearSession(c);
4736
+ await ctx.emit("user.deleted", { user, passwordUserId: passwordUserId ?? void 0 });
4294
4737
  return c.body(null, 204);
4295
4738
  });
4739
+ if (roles) {
4740
+ const admin = async (c) => {
4741
+ const user = await currentSession(c);
4742
+ if (!user) return { answer: c.json({ error: "no_session" }, 401) };
4743
+ const email = user.email ? normalizeEmail(user.email) : void 0;
4744
+ if (!email || !user.roles?.includes(ADMIN_ROLE)) {
4745
+ return { answer: c.json({ error: "forbidden", message: `Only users with the role "${ADMIN_ROLE}" can manage roles.` }, 403) };
4746
+ }
4747
+ return { email };
4748
+ };
4749
+ const body = async (c) => {
4750
+ const data = await c.req.json().catch(() => null);
4751
+ return data && typeof data === "object" ? data : {};
4752
+ };
4753
+ const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
4754
+ app.post("/auth/admin/roles/get", async (c) => {
4755
+ const caller = await admin(c);
4756
+ if ("answer" in caller) return caller.answer;
4757
+ const data = await body(c);
4758
+ if (!isValidEmail(data.email)) return invalidEmail(c);
4759
+ const email = normalizeEmail(data.email);
4760
+ return c.json({ email, roles: await roles.get(email) });
4761
+ });
4762
+ app.post("/auth/admin/roles/set", async (c) => {
4763
+ const caller = await admin(c);
4764
+ if ("answer" in caller) return caller.answer;
4765
+ const data = await body(c);
4766
+ if (!isValidEmail(data.email)) return invalidEmail(c);
4767
+ const names = parseRoles(data.roles);
4768
+ if (!names) {
4769
+ return c.json(
4770
+ {
4771
+ error: "invalid_roles",
4772
+ message: 'roles must be a list of names: lower-case letters, digits, "-" and "_", starting with a letter.'
4773
+ },
4774
+ 400
4775
+ );
4776
+ }
4777
+ const email = normalizeEmail(data.email);
4778
+ await roles.set(email, names, caller.email);
4779
+ await ctx.emit("roles.changed", { email, roles: names, by: caller.email });
4780
+ return c.json({ email, roles: names });
4781
+ });
4782
+ }
4296
4783
  return app;
4297
4784
  }
4298
4785
 
4299
4786
  // src/config.ts
4787
+ async function resolveEnv(source = process.env) {
4788
+ return typeof source === "function" ? source() : source;
4789
+ }
4300
4790
  var DEFAULT_SESSION_TTL = 8 * 60 * 60;
4791
+ var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
4301
4792
  var DEFAULT_PASSWORD_MIN_LENGTH = 8;
4302
4793
  var ConfigError = class extends Error {
4303
4794
  constructor() {
@@ -4353,17 +4844,16 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
4353
4844
  if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
4354
4845
  throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
4355
4846
  }
4356
- const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
4847
+ const sendsEmails = !!(overrides.store || read("DATABASE_URL"));
4848
+ const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"), sendsEmails);
4849
+ if (sendsEmails && !webhook) {
4850
+ throw new ConfigError(
4851
+ `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.`
4852
+ );
4853
+ }
4357
4854
  const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
4358
4855
  let password;
4359
- if (store) {
4360
- if (!webhook) {
4361
- throw new ConfigError(
4362
- 'WEBHOOK_URL is not set. E-mail & password sign-in sends its e-mails through your webhook. For development, run the example receiver (npm run dev:webhooks) and set WEBHOOK_URL=http://localhost:8790/webhook. See "Webhooks" in docs/server.md.'
4363
- );
4364
- }
4365
- password = { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")), store };
4366
- }
4856
+ if (store) password = { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")), store };
4367
4857
  if (!clientId && !password) {
4368
4858
  throw new ConfigError(
4369
4859
  "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."
@@ -4374,11 +4864,19 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
4374
4864
  if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
4375
4865
  throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
4376
4866
  }
4867
+ const renewalValue = read("SESSION_RENEWAL_TTL");
4868
+ const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
4869
+ if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
4870
+ throw new ConfigError(
4871
+ `SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
4872
+ );
4873
+ }
4377
4874
  return {
4378
4875
  issuer,
4379
4876
  signingKey,
4380
4877
  allowedOrigins,
4381
4878
  sessionTtlSeconds,
4879
+ renewalTtlSeconds,
4382
4880
  cookieDomain: read("COOKIE_DOMAIN"),
4383
4881
  google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
4384
4882
  password,
@@ -4394,9 +4892,10 @@ function passwordMinLength(value) {
4394
4892
  }
4395
4893
  async function storeFromDatabaseUrl(url) {
4396
4894
  if (!url) return void 0;
4895
+ if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
4397
4896
  if (!url.startsWith("sqlite:")) {
4398
4897
  throw new ConfigError(
4399
- `DATABASE_URL must start with "sqlite:" (e.g. sqlite:/data/madauth.db) but is "${url}". For other databases, pass your own store adapter (see "Custom store adapter" in docs/server.md).`
4898
+ `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).`
4400
4899
  );
4401
4900
  }
4402
4901
  const path = url.slice("sqlite:".length);
@@ -4408,8 +4907,25 @@ async function storeFromDatabaseUrl(url) {
4408
4907
  throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
4409
4908
  }
4410
4909
  }
4910
+ async function dynamoDbStore(tableName2) {
4911
+ if (!tableName2) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
4912
+ if (!/^[\w.-]{3,255}$/.test(tableName2)) {
4913
+ throw new ConfigError(
4914
+ `DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName2}"`
4915
+ );
4916
+ }
4917
+ try {
4918
+ await import("@aws-sdk/client-dynamodb");
4919
+ } catch (e) {
4920
+ throw new ConfigError(
4921
+ `DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
4922
+ );
4923
+ }
4924
+ const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
4925
+ return createDynamoDbAdapter2({ tableName: tableName2 });
4926
+ }
4411
4927
  var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
4412
- function webhookFromEnv(url, secret, events) {
4928
+ function webhookFromEnv(url, secret, events, sendsEmails) {
4413
4929
  if (!url) {
4414
4930
  if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
4415
4931
  return void 0;
@@ -4435,22 +4951,39 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
4435
4951
  if (problem) {
4436
4952
  throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
4437
4953
  }
4438
- let selected = null;
4439
- if (events) {
4440
- selected = new Set(events.split(",").map((e) => e.trim()).filter(Boolean));
4441
- const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
4442
- if (unknown.length) {
4443
- throw new ConfigError(`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. Known types: ${WEBHOOK_TYPES.join(", ")}.`);
4444
- }
4954
+ const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
4955
+ const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
4956
+ if (!selected.size) {
4957
+ throw new ConfigError(
4958
+ "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:
4959
+
4960
+ WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
4961
+
4962
+ ` : "For example:\n\nWEBHOOK_EVENTS=user.signed_in\n\n") + allTypes
4963
+ );
4964
+ }
4965
+ const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
4966
+ if (unknown.length) {
4967
+ throw new ConfigError(`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. Known types: ${WEBHOOK_TYPES.join(", ")}.`);
4968
+ }
4969
+ const missing = sendsEmails ? REQUIRED_EMAIL_TYPES.filter((type) => !selected.has(type)) : [];
4970
+ if (missing.length) {
4971
+ throw new ConfigError(
4972
+ `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:
4973
+
4974
+ WEBHOOK_EVENTS=${[...selected, ...missing].join(",")}
4975
+ `
4976
+ );
4445
4977
  }
4446
4978
  return { url: parsed.href, secret, events: selected };
4447
4979
  }
4448
4980
 
4449
4981
  // src/entry/lambda.ts
4450
- function createHandler(overrides = {}) {
4982
+ function createHandler(options = {}) {
4983
+ const { env, ...overrides } = options;
4451
4984
  let handlerPromise;
4452
4985
  return async (event, context) => {
4453
- handlerPromise ??= loadConfig(process.env, overrides).then((config) => handle(createApp(config)));
4986
+ handlerPromise ??= resolveEnv(env).then((loaded) => loadConfig(loaded, overrides)).then((config) => handle(createApp(config)));
4454
4987
  handlerPromise.catch(() => handlerPromise = void 0);
4455
4988
  return (await handlerPromise)(event, context);
4456
4989
  };