@madauth/server 0.1.0 → 0.2.0-beta.2

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.
@@ -4107,7 +4107,7 @@ var init_schema = __esm({
4107
4107
  "src/store/schema.ts"() {
4108
4108
  "use strict";
4109
4109
  madauthSchema = {
4110
- version: 1,
4110
+ version: 3,
4111
4111
  models: {
4112
4112
  user: {
4113
4113
  fields: {
@@ -4118,6 +4118,8 @@ var init_schema = __esm({
4118
4118
  name: { type: "string", nullable: true },
4119
4119
  sessionVersion: { type: "number" },
4120
4120
  lastMailAt: { type: "number" },
4121
+ /** Wrong e-mail codes in a row, counted across e-mails (since version 2). */
4122
+ wrongCodes: { type: "number" },
4121
4123
  createdAt: { type: "number" }
4122
4124
  }
4123
4125
  },
@@ -4143,6 +4145,19 @@ var init_schema = __esm({
4143
4145
  attempts: { type: "number" },
4144
4146
  expiresAt: { type: "number" }
4145
4147
  }
4148
+ },
4149
+ /**
4150
+ * The roles of an e-mail address (since version 3). `id` is the normalized address, so the roles
4151
+ * apply whichever way its owner signs in. `roles` holds the names separated by spaces.
4152
+ */
4153
+ role: {
4154
+ fields: {
4155
+ id: { type: "string", primaryKey: true },
4156
+ roles: { type: "string" },
4157
+ updatedAt: { type: "number" },
4158
+ /** The address of the admin who set them; empty when set from the command line. */
4159
+ updatedBy: { type: "string", nullable: true }
4160
+ }
4146
4161
  }
4147
4162
  }
4148
4163
  };
@@ -4156,6 +4171,9 @@ function tableName(model) {
4156
4171
  function columnName(field) {
4157
4172
  return field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
4158
4173
  }
4174
+ function upgradeTablesSql(dialect, fromVersion) {
4175
+ return upgrades.filter((step) => step.version > fromVersion).map((step) => step.sql(dialect)).join("\n");
4176
+ }
4159
4177
  function createTablesSql(dialect, schema = madauthSchema) {
4160
4178
  const statements = [];
4161
4179
  for (const [model, { fields }] of Object.entries(schema.models)) {
@@ -4175,7 +4193,7 @@ ${columns.join(",\n")}
4175
4193
  }
4176
4194
  return statements.join("\n");
4177
4195
  }
4178
- var columnTypes;
4196
+ var columnTypes, upgrades;
4179
4197
  var init_sql = __esm({
4180
4198
  "src/store/sql.ts"() {
4181
4199
  "use strict";
@@ -4186,6 +4204,16 @@ var init_sql = __esm({
4186
4204
  postgres: { string: "TEXT", number: "DOUBLE PRECISION", boolean: "BOOLEAN" },
4187
4205
  mysql: { string: "TEXT", number: "DOUBLE", boolean: "BOOLEAN" }
4188
4206
  };
4207
+ upgrades = [
4208
+ {
4209
+ version: 2,
4210
+ sql: (dialect) => `ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("wrongCodes")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`
4211
+ },
4212
+ {
4213
+ version: 3,
4214
+ sql: (dialect) => createTablesSql(dialect, { version: 3, models: { role: madauthSchema.models.role } })
4215
+ }
4216
+ ];
4189
4217
  }
4190
4218
  });
4191
4219
 
@@ -4200,7 +4228,7 @@ function createSqliteAdapter(path) {
4200
4228
  db.exec("PRAGMA busy_timeout = 5000");
4201
4229
  if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL");
4202
4230
  migrate(db);
4203
- const fieldsOf = (model) => {
4231
+ const fieldsOf2 = (model) => {
4204
4232
  const fields = madauthSchema.models[model]?.fields;
4205
4233
  if (!fields) throw new Error(`Unknown model "${model}"`);
4206
4234
  return fields;
@@ -4233,25 +4261,25 @@ function createSqliteAdapter(path) {
4233
4261
  };
4234
4262
  return {
4235
4263
  async create(model, data) {
4236
- const fields = fieldsOf(model);
4264
+ const fields = fieldsOf2(model);
4237
4265
  const names = Object.keys(data);
4238
4266
  const sql = `INSERT INTO ${tableName(model)} (${names.map((f) => column(fields, f)).join(", ")}) VALUES (${names.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`;
4239
4267
  return Number(db.prepare(sql).run(...names.map((f) => toSql(data[f]))).changes) === 1;
4240
4268
  },
4241
4269
  async findOne(model, where) {
4242
- const fields = fieldsOf(model);
4270
+ const fields = fieldsOf2(model);
4243
4271
  const w = whereSql(fields, where);
4244
4272
  const raw2 = db.prepare(`SELECT * FROM ${tableName(model)}${w.sql} LIMIT 1`).get(...w.params);
4245
4273
  return raw2 ? fromSql(fields, raw2) : null;
4246
4274
  },
4247
4275
  async findMany(model, where, opts = {}) {
4248
- const fields = fieldsOf(model);
4276
+ const fields = fieldsOf2(model);
4249
4277
  const w = whereSql(fields, where);
4250
4278
  const limit = opts.limit === void 0 ? "" : ` LIMIT ${Math.max(0, Math.floor(opts.limit))}`;
4251
4279
  return db.prepare(`SELECT * FROM ${tableName(model)}${w.sql}${limit}`).all(...w.params).map((raw2) => fromSql(fields, raw2));
4252
4280
  },
4253
4281
  async update(model, where, patch) {
4254
- const fields = fieldsOf(model);
4282
+ const fields = fieldsOf2(model);
4255
4283
  const names = Object.keys(patch);
4256
4284
  if (!names.length) return 0;
4257
4285
  const w = whereSql(fields, where);
@@ -4259,7 +4287,7 @@ function createSqliteAdapter(path) {
4259
4287
  return Number(db.prepare(sql).run(...names.map((f) => toSql(patch[f])), ...w.params).changes);
4260
4288
  },
4261
4289
  async delete(model, where) {
4262
- const fields = fieldsOf(model);
4290
+ const fields = fieldsOf2(model);
4263
4291
  const w = whereSql(fields, where);
4264
4292
  return Number(db.prepare(`DELETE FROM ${tableName(model)}${w.sql}`).run(...w.params).changes);
4265
4293
  }
@@ -4275,7 +4303,7 @@ function migrate(db) {
4275
4303
  if (version === madauthSchema.version) return;
4276
4304
  db.exec("BEGIN");
4277
4305
  try {
4278
- db.exec(createTablesSql("sqlite"));
4306
+ db.exec(version === 0 ? createTablesSql("sqlite") : upgradeTablesSql("sqlite", version));
4279
4307
  db.exec(`PRAGMA user_version = ${madauthSchema.version}`);
4280
4308
  db.exec("COMMIT");
4281
4309
  } catch (e) {
@@ -4291,6 +4319,255 @@ var init_sqlite = __esm({
4291
4319
  }
4292
4320
  });
4293
4321
 
4322
+ // src/store/dynamodb.ts
4323
+ var dynamodb_exports = {};
4324
+ __export(dynamodb_exports, {
4325
+ createDynamoDbAdapter: () => createDynamoDbAdapter
4326
+ });
4327
+ function toAttribute(value) {
4328
+ if (value === null) return { NULL: true };
4329
+ if (typeof value === "string") return { S: value };
4330
+ if (typeof value === "number") return { N: String(value) };
4331
+ return { BOOL: value };
4332
+ }
4333
+ function fromAttribute(attribute) {
4334
+ if (attribute?.S !== void 0) return attribute.S;
4335
+ if (attribute?.N !== void 0) return Number(attribute.N);
4336
+ if (attribute?.BOOL !== void 0) return attribute.BOOL;
4337
+ return null;
4338
+ }
4339
+ function fieldsOf(model) {
4340
+ const fields = madauthSchema.models[model]?.fields;
4341
+ if (!fields) throw new Error(`Unknown model "${model}"`);
4342
+ return fields;
4343
+ }
4344
+ function checkFields(fields, names) {
4345
+ for (const name of names) if (!fields[name]) throw new Error(`Unknown field "${name}"`);
4346
+ }
4347
+ function primaryKeyOf(fields) {
4348
+ return Object.keys(fields).find((name) => fields[name].primaryKey);
4349
+ }
4350
+ function toRow(fields, item) {
4351
+ return Object.fromEntries(Object.keys(fields).map((name) => [name, fromAttribute(item[name])]));
4352
+ }
4353
+ function matches(row, where) {
4354
+ return Object.entries(where).every(([field, value]) => row[field] === value);
4355
+ }
4356
+ function stillMatches(where) {
4357
+ const names = {};
4358
+ const values = {};
4359
+ const parts = ["attribute_exists(pk)"];
4360
+ Object.entries(where).forEach(([field, value], i) => {
4361
+ names[`#w${i}`] = field;
4362
+ values[`:w${i}`] = toAttribute(value);
4363
+ parts.push(value === null ? `(attribute_not_exists(#w${i}) OR #w${i} = :w${i})` : `#w${i} = :w${i}`);
4364
+ });
4365
+ return { text: parts.join(" AND "), names, values };
4366
+ }
4367
+ function failedWrite(error) {
4368
+ const { name, CancellationReasons } = error;
4369
+ if (name === "ConditionalCheckFailedException") return "refused";
4370
+ if (name && RETRYABLE_ERRORS.includes(name)) return { retry: error };
4371
+ if (name === "TransactionCanceledException") {
4372
+ const reasons = (CancellationReasons ?? []).map((r) => r.Code ?? "None").filter((code) => code !== "None");
4373
+ if (reasons.includes("ConditionalCheckFailed")) return "refused";
4374
+ if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry: error };
4375
+ }
4376
+ throw error;
4377
+ }
4378
+ function createDynamoDbAdapter(options) {
4379
+ const { tableName: tableName2 } = options;
4380
+ let loading;
4381
+ const aws = () => loading ??= import("@aws-sdk/client-dynamodb").then((sdk) => ({
4382
+ sdk,
4383
+ client: options.client ?? new sdk.DynamoDBClient({})
4384
+ }));
4385
+ const write = async (send) => {
4386
+ for (let attempt = 0; ; attempt++) {
4387
+ const outcome = await send().then(() => "written", failedWrite);
4388
+ if (outcome === "written") return true;
4389
+ if (outcome === "refused") return false;
4390
+ if (attempt === MAX_RETRIES) throw outcome.retry;
4391
+ await pause(attempt);
4392
+ }
4393
+ };
4394
+ const transact = async (items) => {
4395
+ const { sdk, client } = await aws();
4396
+ return write(() => client.send(new sdk.TransactWriteItemsCommand({ TransactItems: items })));
4397
+ };
4398
+ const getItem = async (key) => {
4399
+ const { sdk, client } = await aws();
4400
+ return (await client.send(new sdk.GetItemCommand({ TableName: tableName2, Key: key, ConsistentRead: true }))).Item;
4401
+ };
4402
+ const getRecord = async (model, fields, id) => {
4403
+ const item = await getItem(recordKey(model, id));
4404
+ return item ? toRow(fields, item) : null;
4405
+ };
4406
+ const idsOfIndex = async (model, field, value) => {
4407
+ const { sdk, client } = await aws();
4408
+ const ids = [];
4409
+ let next;
4410
+ do {
4411
+ const page = await client.send(
4412
+ new sdk.QueryCommand({
4413
+ TableName: tableName2,
4414
+ KeyConditionExpression: "pk = :pk",
4415
+ ExpressionAttributeValues: { ":pk": { S: indexPartition(model, field, value) } },
4416
+ ProjectionExpression: "sk",
4417
+ ConsistentRead: true,
4418
+ ExclusiveStartKey: next
4419
+ })
4420
+ );
4421
+ for (const item of page.Items ?? []) if (item.sk?.S) ids.push(item.sk.S);
4422
+ next = page.LastEvaluatedKey;
4423
+ } while (next);
4424
+ return ids;
4425
+ };
4426
+ const find = async (model, where) => {
4427
+ const fields = fieldsOf(model);
4428
+ checkFields(fields, Object.keys(where));
4429
+ const given = (name) => where[name] !== void 0 && where[name] !== null;
4430
+ const primaryKey = primaryKeyOf(fields);
4431
+ const unique = Object.keys(fields).find((name) => fields[name].unique && given(name));
4432
+ const indexed = Object.keys(fields).find((name) => fields[name].index && given(name));
4433
+ let ids;
4434
+ if (given(primaryKey)) {
4435
+ ids = [String(where[primaryKey])];
4436
+ } else if (unique) {
4437
+ const ref = (await getItem(uniqueKey(model, unique, where[unique])))?.ref?.S;
4438
+ ids = ref ? [ref] : [];
4439
+ } else if (indexed) {
4440
+ ids = await idsOfIndex(model, indexed, where[indexed]);
4441
+ } else {
4442
+ throw new Error(
4443
+ `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.`
4444
+ );
4445
+ }
4446
+ const rows = await Promise.all(ids.map((id) => getRecord(model, fields, id)));
4447
+ return rows.filter((row) => row !== null && matches(row, where));
4448
+ };
4449
+ const derivedKeys = (model, fields, row, id) => Object.keys(fields).flatMap((name) => {
4450
+ const value = row[name];
4451
+ if (value === void 0 || value === null) return [];
4452
+ return [
4453
+ ...fields[name].unique ? [uniqueKey(model, name, value)] : [],
4454
+ ...fields[name].index ? [indexKey(model, name, value, id)] : []
4455
+ ];
4456
+ });
4457
+ return {
4458
+ async create(model, data) {
4459
+ const fields = fieldsOf(model);
4460
+ checkFields(fields, Object.keys(data));
4461
+ const primaryKey = primaryKeyOf(fields);
4462
+ const id = data[primaryKey];
4463
+ if (typeof id !== "string" || !id) throw new Error(`A "${model}" record needs a "${primaryKey}"`);
4464
+ const record = { ...recordKey(model, id) };
4465
+ for (const [field, value] of Object.entries(data)) record[field] = toAttribute(value);
4466
+ const unused = "attribute_not_exists(pk)";
4467
+ return transact([
4468
+ { Put: { TableName: tableName2, Item: record, ConditionExpression: unused } },
4469
+ ...derivedKeys(model, fields, data, id).map(
4470
+ (key) => (
4471
+ // A unique value refers to its record and may not be taken yet; an index entry is only a key.
4472
+ key.pk.S.startsWith("u|") ? { Put: { TableName: tableName2, Item: { ...key, ref: { S: id } }, ConditionExpression: unused } } : { Put: { TableName: tableName2, Item: key } }
4473
+ )
4474
+ )
4475
+ ]);
4476
+ },
4477
+ async findOne(model, where) {
4478
+ return (await find(model, where))[0] ?? null;
4479
+ },
4480
+ async findMany(model, where, opts = {}) {
4481
+ const rows = await find(model, where);
4482
+ return opts.limit === void 0 ? rows : rows.slice(0, Math.max(0, Math.floor(opts.limit)));
4483
+ },
4484
+ async update(model, where, patch) {
4485
+ const fields = fieldsOf(model);
4486
+ const changes = Object.entries(patch);
4487
+ checkFields(fields, [...Object.keys(where), ...Object.keys(patch)]);
4488
+ for (const [field] of changes) {
4489
+ if (fields[field].primaryKey || fields[field].unique || fields[field].index) {
4490
+ throw new Error(`The DynamoDB adapter can't change "${field}" of a "${model}" record: it is a key.`);
4491
+ }
4492
+ }
4493
+ if (!changes.length) return 0;
4494
+ const primaryKey = primaryKeyOf(fields);
4495
+ const condition = stillMatches(where);
4496
+ const names = { ...condition.names };
4497
+ const values = { ...condition.values };
4498
+ const assignments = changes.map(([field, value], i) => {
4499
+ names[`#p${i}`] = field;
4500
+ values[`:p${i}`] = toAttribute(value);
4501
+ return `#p${i} = :p${i}`;
4502
+ });
4503
+ const byId = where[primaryKey];
4504
+ const ids = byId !== void 0 && byId !== null ? [String(byId)] : (await find(model, where)).map((row) => String(row[primaryKey]));
4505
+ const written = await Promise.all(
4506
+ ids.map(
4507
+ (id) => transact([
4508
+ {
4509
+ Update: {
4510
+ TableName: tableName2,
4511
+ Key: recordKey(model, id),
4512
+ UpdateExpression: `SET ${assignments.join(", ")}`,
4513
+ ConditionExpression: condition.text,
4514
+ ExpressionAttributeNames: names,
4515
+ ExpressionAttributeValues: values
4516
+ }
4517
+ }
4518
+ ])
4519
+ )
4520
+ );
4521
+ return written.filter(Boolean).length;
4522
+ },
4523
+ async delete(model, where) {
4524
+ const fields = fieldsOf(model);
4525
+ const primaryKey = primaryKeyOf(fields);
4526
+ const condition = stillMatches(where);
4527
+ const deleted = await Promise.all(
4528
+ (await find(model, where)).map((row) => {
4529
+ const id = String(row[primaryKey]);
4530
+ return transact([
4531
+ {
4532
+ Delete: {
4533
+ TableName: tableName2,
4534
+ Key: recordKey(model, id),
4535
+ ConditionExpression: condition.text,
4536
+ ExpressionAttributeNames: condition.names,
4537
+ ExpressionAttributeValues: condition.values
4538
+ }
4539
+ },
4540
+ ...derivedKeys(model, fields, row, id).map((key) => ({ Delete: { TableName: tableName2, Key: key } }))
4541
+ ]);
4542
+ })
4543
+ );
4544
+ return deleted.filter(Boolean).length;
4545
+ }
4546
+ };
4547
+ }
4548
+ var SORT_KEY, MAX_RETRIES, RETRYABLE_REASONS, RETRYABLE_ERRORS, recordKey, uniqueKey, indexPartition, indexKey, pause;
4549
+ var init_dynamodb = __esm({
4550
+ "src/store/dynamodb.ts"() {
4551
+ "use strict";
4552
+ init_schema();
4553
+ SORT_KEY = "#";
4554
+ MAX_RETRIES = 8;
4555
+ RETRYABLE_REASONS = ["TransactionConflict", "ThrottlingError", "ProvisionedThroughputExceeded"];
4556
+ RETRYABLE_ERRORS = ["TransactionConflictException", "TransactionInProgressException"];
4557
+ recordKey = (model, id) => ({ pk: { S: `r|${model}|${id}` }, sk: { S: SORT_KEY } });
4558
+ uniqueKey = (model, field, value) => ({
4559
+ pk: { S: `u|${model}|${field}|${String(value)}` },
4560
+ sk: { S: SORT_KEY }
4561
+ });
4562
+ indexPartition = (model, field, value) => `i|${model}|${field}|${String(value)}`;
4563
+ indexKey = (model, field, value, id) => ({
4564
+ pk: { S: indexPartition(model, field, value) },
4565
+ sk: { S: id }
4566
+ });
4567
+ pause = (attempt) => new Promise((resolve) => setTimeout(resolve, Math.random() * Math.min(25 * 2 ** attempt, 400)));
4568
+ }
4569
+ });
4570
+
4294
4571
  // src/entry/azure.ts
4295
4572
  var import_functions = __toESM(require_azure_functions(), 1);
4296
4573
 
@@ -7460,6 +7737,45 @@ function safeEqual(a, b) {
7460
7737
  return x.length === y.length && timingSafeEqual(x, y);
7461
7738
  }
7462
7739
 
7740
+ // src/roles.ts
7741
+ var ADMIN_ROLE = "admin";
7742
+ var MAX_ROLES = 20;
7743
+ var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
7744
+ function parseRoles(input) {
7745
+ if (!Array.isArray(input)) return null;
7746
+ if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
7747
+ const roles = [...new Set(input)].sort();
7748
+ return roles.length <= MAX_ROLES ? roles : null;
7749
+ }
7750
+ function sameRoles(a, b) {
7751
+ return a.length === b.length && a.every((role, i) => role === b[i]);
7752
+ }
7753
+ var Roles = class {
7754
+ constructor(store) {
7755
+ this.store = store;
7756
+ }
7757
+ /** The address's roles, sorted; empty if it has none. */
7758
+ async get(emailNormalized) {
7759
+ const record = await this.store.findOne("role", { id: emailNormalized });
7760
+ return typeof record?.roles === "string" && record.roles ? record.roles.split(" ").filter(Boolean) : [];
7761
+ }
7762
+ /** Replaces the address's roles. `updatedBy` is the address of the admin, or null for the command line. */
7763
+ async set(emailNormalized, roles, updatedBy) {
7764
+ if (!roles.length) {
7765
+ await this.remove(emailNormalized);
7766
+ return;
7767
+ }
7768
+ const data = { roles: roles.join(" "), updatedAt: Date.now(), updatedBy };
7769
+ if (await this.store.update("role", { id: emailNormalized }, data) === 1) return;
7770
+ if (!await this.store.create("role", { id: emailNormalized, ...data })) {
7771
+ await this.store.update("role", { id: emailNormalized }, data);
7772
+ }
7773
+ }
7774
+ async remove(emailNormalized) {
7775
+ await this.store.delete("role", { id: emailNormalized });
7776
+ }
7777
+ };
7778
+
7463
7779
  // src/google.ts
7464
7780
  var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
7465
7781
  var googleJwks;
@@ -7497,6 +7813,9 @@ async function verifyGoogleIdToken(idToken, opts) {
7497
7813
  // src/user.ts
7498
7814
  var SESSION_COOKIE = "madauth_session";
7499
7815
  var SESSION_TYP = "madauth-session+jwt";
7816
+ var RENEWAL_COOKIE = "madauth_renewal";
7817
+ var RENEWAL_TYP = "madauth-renewal+jwt";
7818
+ var EXPIRY_COOKIE = "madauth_session_expires";
7500
7819
 
7501
7820
  // src/tokens.ts
7502
7821
  function randomString(bytes = 32) {
@@ -7522,15 +7841,29 @@ function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
7522
7841
  email_verified: user.email ? true : void 0,
7523
7842
  name: user.name,
7524
7843
  picture: user.picture,
7844
+ roles: user.roles?.length ? user.roles : void 0,
7525
7845
  amr
7526
7846
  };
7527
7847
  return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
7528
7848
  }
7849
+ function signRenewal(keys, issuer, ttlSeconds, user, amr, extra = {}) {
7850
+ const claims = {
7851
+ ...extra,
7852
+ sub: user.id,
7853
+ email: user.email,
7854
+ email_verified: user.email ? true : void 0,
7855
+ name: user.name,
7856
+ picture: user.picture,
7857
+ amr
7858
+ };
7859
+ return signToken(keys, issuer, RENEWAL_TYP, claims, ttlSeconds);
7860
+ }
7529
7861
  function userFromClaims(claims) {
7530
7862
  const user = { id: claims.sub };
7531
7863
  if (claims.email) user.email = claims.email;
7532
7864
  if (claims.name) user.name = claims.name;
7533
7865
  if (claims.picture) user.picture = claims.picture;
7866
+ if (Array.isArray(claims.roles) && claims.roles.length) user.roles = claims.roles;
7534
7867
  return user;
7535
7868
  }
7536
7869
 
@@ -7582,9 +7915,9 @@ function googleRoutes(app2, ctx, google) {
7582
7915
  return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
7583
7916
  }
7584
7917
  deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
7585
- await ctx.startSession(c, result.user, ["google"]);
7586
- await ctx.emit("user.signed_in", { user: result.user, method: "google" });
7587
- return c.json({ user: result.user });
7918
+ const user = await ctx.startSession(c, result.user, ["google"]);
7919
+ await ctx.emit("user.signed_in", { user, method: "google" });
7920
+ return c.json({ user });
7588
7921
  });
7589
7922
  const requireCodeFlow = async (c, next) => {
7590
7923
  if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
@@ -7658,8 +7991,8 @@ function googleRoutes(app2, ctx, google) {
7658
7991
  keys: config.jwksResolver
7659
7992
  });
7660
7993
  if (!result.ok) return back(result.error);
7661
- await ctx.startSession(c, result.user, ["google"]);
7662
- await ctx.emit("user.signed_in", { user: result.user, method: "google" });
7994
+ const user = await ctx.startSession(c, result.user, ["google"]);
7995
+ await ctx.emit("user.signed_in", { user, method: "google" });
7663
7996
  return back();
7664
7997
  });
7665
7998
  }
@@ -7724,12 +8057,17 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
7724
8057
  }
7725
8058
  if (isValidEmail(data.email) && typeof data.code === "string") {
7726
8059
  const user = await users.findByEmail(normalizeEmail(data.email));
7727
- const code = data.code.replace(/\s/g, "");
7728
- return { userId: user ? await users.consumeCode(user.id, purpose, code) : null, via: "code" };
8060
+ if (!user) return { userId: null, via: "code" };
8061
+ return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
7729
8062
  }
7730
8063
  return { userId: null, via: "code" };
7731
8064
  };
7732
- 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.");
8065
+ const invalidVerification = (c, { via, locked }) => {
8066
+ if (locked) {
8067
+ return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
8068
+ }
8069
+ 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.");
8070
+ };
7733
8071
  app2.post("/auth/password/signin", async (c) => {
7734
8072
  const data = await body(c);
7735
8073
  const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
@@ -7761,8 +8099,7 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
7761
8099
  if (!user.emailVerified) {
7762
8100
  return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
7763
8101
  }
7764
- const result = toMadauthUser(user);
7765
- await ctx.startSession(c, result, ["pwd"], { sv: user.sessionVersion });
8102
+ const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
7766
8103
  await ctx.emit("user.signed_in", { user: result, method: "password" });
7767
8104
  return c.json({ user: result });
7768
8105
  });
@@ -7791,6 +8128,7 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
7791
8128
  const passwordHash = await hashPassword(data.password);
7792
8129
  const existing = await users.findByEmail(normalizeEmail(email));
7793
8130
  if (existing?.emailVerified) {
8131
+ if (!webhook.wants("email.already_registered")) return accepted(c);
7794
8132
  const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
7795
8133
  return sent ? accepted(c) : unavailable(c);
7796
8134
  }
@@ -7824,12 +8162,12 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
7824
8162
  return accepted(c);
7825
8163
  });
7826
8164
  app2.post("/auth/password/verify-email", async (c) => {
7827
- const { userId, via } = await consume(await body(c), "verify");
8165
+ const consumed = await consume(await body(c), "verify");
8166
+ const { userId, via } = consumed;
7828
8167
  const user = userId ? await users.findById(userId) : null;
7829
- if (!user) return invalidVerification(c, via);
7830
- await users.updateUser(user.id, { emailVerified: true });
7831
- const result = toMadauthUser(user);
7832
- await ctx.startSession(c, result, ["pwd"], { sv: user.sessionVersion });
8168
+ if (!user) return invalidVerification(c, consumed);
8169
+ await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
8170
+ const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
7833
8171
  await ctx.emit("email.verified", { user: result, via });
7834
8172
  await ctx.emit("user.signed_in", { user: result, method: "password" });
7835
8173
  return c.json({ user: result });
@@ -7849,16 +8187,15 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
7849
8187
  const data = await body(c);
7850
8188
  const policy = checkPasswordPolicy(data.password, minLength);
7851
8189
  if (policy) return error(c, 400, "weak_password", policy);
7852
- const { userId, via } = await consume(data, "reset");
7853
- const user = userId ? await users.findById(userId) : null;
8190
+ const consumed = await consume(data, "reset");
8191
+ const user = consumed.userId ? await users.findById(consumed.userId) : null;
7854
8192
  const account = user ? await users.passwordAccount(user.id) : null;
7855
- if (!user || !account) return invalidVerification(c, via);
8193
+ if (!user || !account) return invalidVerification(c, consumed);
7856
8194
  await users.updateAccount(account.id, { secret: await hashPassword(data.password), failedAttempts: 0, lockedUntil: 0 });
7857
8195
  const sessionVersion = user.sessionVersion + 1;
7858
- await users.updateUser(user.id, { emailVerified: true, sessionVersion });
8196
+ await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
7859
8197
  await users.clearVerifications(user.id);
7860
- const result = toMadauthUser(user);
7861
- await ctx.startSession(c, result, ["pwd"], { sv: sessionVersion });
8198
+ const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
7862
8199
  await ctx.emit("password.reset", { user: result });
7863
8200
  await ctx.emit("user.signed_in", { user: result, method: "password" });
7864
8201
  return c.json({ user: result });
@@ -7868,6 +8205,7 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
7868
8205
  // src/users.ts
7869
8206
  import { randomBytes as randomBytes2 } from "crypto";
7870
8207
  var MAX_CODE_ATTEMPTS = 5;
8208
+ var MAX_WRONG_CODES = 10;
7871
8209
  var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
7872
8210
  function passwordAccountKey(userId) {
7873
8211
  return `password:${userId}`;
@@ -7902,6 +8240,7 @@ var Users = class {
7902
8240
  name: data.name,
7903
8241
  sessionVersion: 0,
7904
8242
  lastMailAt: 0,
8243
+ wrongCodes: 0,
7905
8244
  createdAt: now
7906
8245
  };
7907
8246
  if (!await this.store.create("user", user)) return null;
@@ -7920,6 +8259,22 @@ var Users = class {
7920
8259
  }
7921
8260
  return user;
7922
8261
  }
8262
+ /**
8263
+ * Deletes the user with this e-mail address and everything stored for them. Resolves to the deleted
8264
+ * user's ID, or null if there is no such user (any more).
8265
+ */
8266
+ async deleteByEmail(emailNormalized) {
8267
+ const user = await this.findByEmail(emailNormalized);
8268
+ if (!user) return null;
8269
+ if (await this.store.delete("user", { id: user.id }) !== 1) return null;
8270
+ try {
8271
+ await this.store.delete("account", { userId: user.id });
8272
+ await this.store.delete("verification", { userId: user.id });
8273
+ } catch (e) {
8274
+ console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
8275
+ }
8276
+ return user.id;
8277
+ }
7923
8278
  async updateUser(id, patch) {
7924
8279
  await this.store.update("user", { id }, patch);
7925
8280
  }
@@ -7963,17 +8318,38 @@ var Users = class {
7963
8318
  const record = await this.store.findOne("verification", { id, purpose });
7964
8319
  return record ? this.consume(record) : null;
7965
8320
  }
7966
- /** Uses up the user's verification if `code` matches. Too many wrong codes delete it. */
7967
- async consumeCode(userId, purpose, code) {
7968
- const record = await this.store.findOne("verification", { userId, purpose });
7969
- if (!record) return null;
8321
+ /**
8322
+ * Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
8323
+ * MAX_WRONG_CODES wrong codes in a row the user's codes stop working until an e-mail link is used.
8324
+ */
8325
+ async consumeCode(user, purpose, code) {
8326
+ if ((user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
8327
+ const record = await this.store.findOne("verification", { userId: user.id, purpose });
8328
+ if (!record) return { userId: null, locked: false };
7970
8329
  const attempts = record.attempts + 1;
7971
- if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) return null;
8330
+ if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
8331
+ return { userId: null, locked: false };
8332
+ }
8333
+ const wrongCodes = await this.countWrongCode(user);
8334
+ if (wrongCodes === null) return { userId: null, locked: false };
8335
+ if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
7972
8336
  if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
7973
8337
  if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
7974
- return null;
8338
+ return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
7975
8339
  }
7976
- return this.consume(record);
8340
+ return { userId: await this.consume(record), locked: false };
8341
+ }
8342
+ /** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
8343
+ async countWrongCode(user) {
8344
+ let current = user;
8345
+ for (let attempt = 0; attempt < 3 && current; attempt++) {
8346
+ const wrongCodes = (current.wrongCodes ?? 0) + 1;
8347
+ if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
8348
+ return wrongCodes;
8349
+ }
8350
+ current = await this.findById(user.id);
8351
+ }
8352
+ return null;
7977
8353
  }
7978
8354
  async consume(record) {
7979
8355
  const deleted = await this.store.delete("verification", { id: record.id });
@@ -7992,10 +8368,13 @@ var WEBHOOK_TYPES = [
7992
8368
  "user.created",
7993
8369
  "email.verified",
7994
8370
  "password.reset",
7995
- "user.signed_in"
8371
+ "user.signed_in",
8372
+ "user.deleted",
8373
+ "roles.changed"
7996
8374
  ];
7997
- var isEmailType = (type) => type.startsWith("email.") && type !== "email.verified";
8375
+ var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
7998
8376
  var SECRET_PREFIX = "whsec_";
8377
+ var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
7999
8378
  var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
8000
8379
  function generateWebhookSecret() {
8001
8380
  return `${SECRET_PREFIX}${randomBytes3(32).toString("base64")}`;
@@ -8014,7 +8393,7 @@ function signWebhook(secret, id, timestamp, body) {
8014
8393
  return `v1,${hmac2(secret, `${id}.${timestamp}.${body}`)}`;
8015
8394
  }
8016
8395
  function createWebhookClient(settings, fetchImpl = fetch) {
8017
- const wants = (type) => isEmailType(type) || !settings.events || settings.events.has(type);
8396
+ const wants = (type) => settings.events.has(type);
8018
8397
  return {
8019
8398
  wants,
8020
8399
  async call(type, data, timeoutMs) {
@@ -8055,10 +8434,50 @@ function createWebhookClient(settings, fetchImpl = fetch) {
8055
8434
  // src/app.ts
8056
8435
  var EVENT_TIMEOUT_MS = 5e3;
8057
8436
  function createApp(config) {
8058
- const { issuer, allowedOrigins, sessionTtlSeconds, cookieDomain } = config;
8437
+ const { issuer, allowedOrigins, sessionTtlSeconds, renewalTtlSeconds, cookieDomain } = config;
8059
8438
  const keys = importSigningKeys(config.signingKey);
8060
8439
  const secure = issuer.startsWith("https:");
8061
8440
  const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
8441
+ const roles = config.password ? new Roles(config.password.store) : void 0;
8442
+ const withRoles = async (user) => {
8443
+ const { roles: _, ...rest } = user;
8444
+ const current = roles && user.email ? await roles.get(normalizeEmail(user.email)) : [];
8445
+ return current.length ? { ...rest, roles: current } : rest;
8446
+ };
8447
+ const issueSession = async (c, user, amr, extra) => {
8448
+ const signingKeys = await keys;
8449
+ const [sessionToken, renewalToken] = await Promise.all([
8450
+ signSession(signingKeys, issuer, sessionTtlSeconds, user, amr, extra),
8451
+ signRenewal(signingKeys, issuer, renewalTtlSeconds, user, amr, extra)
8452
+ ]);
8453
+ setCookie(c, SESSION_COOKIE, sessionToken, {
8454
+ path: "/",
8455
+ domain: cookieDomain,
8456
+ httpOnly: true,
8457
+ secure,
8458
+ sameSite: "Lax",
8459
+ maxAge: sessionTtlSeconds
8460
+ });
8461
+ setCookie(c, RENEWAL_COOKIE, renewalToken, {
8462
+ path: "/auth",
8463
+ httpOnly: true,
8464
+ secure,
8465
+ sameSite: "Lax",
8466
+ maxAge: renewalTtlSeconds
8467
+ });
8468
+ setCookie(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
8469
+ path: "/",
8470
+ domain: cookieDomain,
8471
+ secure,
8472
+ sameSite: "Lax",
8473
+ maxAge: renewalTtlSeconds
8474
+ });
8475
+ };
8476
+ const clearSession = (c) => {
8477
+ deleteCookie(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
8478
+ deleteCookie(c, RENEWAL_COOKIE, { path: "/auth", secure });
8479
+ deleteCookie(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
8480
+ };
8062
8481
  const ctx = {
8063
8482
  config,
8064
8483
  keys,
@@ -8076,15 +8495,9 @@ function createApp(config) {
8076
8495
  return url;
8077
8496
  },
8078
8497
  async startSession(c, user, amr, extra) {
8079
- const token = await signSession(await keys, issuer, sessionTtlSeconds, user, amr, extra);
8080
- setCookie(c, SESSION_COOKIE, token, {
8081
- path: "/",
8082
- domain: cookieDomain,
8083
- httpOnly: true,
8084
- secure,
8085
- sameSite: "Lax",
8086
- maxAge: sessionTtlSeconds
8087
- });
8498
+ const signedIn = await withRoles(user);
8499
+ await issueSession(c, signedIn, amr, extra);
8500
+ return signedIn;
8088
8501
  },
8089
8502
  users: config.password ? new Users(config.password.store, deriveCodeKey(config.signingKey.d)) : void 0,
8090
8503
  webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
@@ -8126,26 +8539,104 @@ function createApp(config) {
8126
8539
  );
8127
8540
  if (config.google) googleRoutes(app2, ctx, config.google);
8128
8541
  if (config.password && ctx.users && ctx.webhook) passwordRoutes(app2, ctx, config.password, ctx.users, ctx.webhook);
8542
+ const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c, cookie));
8543
+ const hasEnded = async (claims) => {
8544
+ if (!claims.sub.startsWith("usr_")) return false;
8545
+ const stored = await ctx.users?.findById(claims.sub);
8546
+ return !stored || stored.sessionVersion !== claims.sv;
8547
+ };
8548
+ const checked = async (claims) => {
8549
+ const [ended, user] = await Promise.all([hasEnded(claims), withRoles(userFromClaims(claims))]);
8550
+ return ended ? null : user;
8551
+ };
8552
+ const currentSession = async (c) => {
8553
+ const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
8554
+ const user = session ? await checked(session) : null;
8555
+ const upToDate = session && user && sameRoles(user.roles ?? [], session.roles ?? []) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
8556
+ if (upToDate) return user;
8557
+ const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
8558
+ const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
8559
+ const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
8560
+ if (renewal && renewed) {
8561
+ await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
8562
+ return renewed;
8563
+ }
8564
+ return user;
8565
+ };
8129
8566
  app2.get("/auth/session", async (c) => {
8130
- const claims = await readToken(await keys, issuer, SESSION_TYP, getCookie(c, SESSION_COOKIE));
8131
- if (!claims) return c.json({ error: "no_session" }, 401);
8132
- if (claims.sub.startsWith("usr_")) {
8133
- const stored = await ctx.users?.findById(claims.sub);
8134
- if (!stored || stored.sessionVersion !== claims.sv) return c.json({ error: "no_session" }, 401);
8567
+ const user = await currentSession(c);
8568
+ if (!user) {
8569
+ clearSession(c);
8570
+ return c.json({ error: "no_session" }, 401);
8135
8571
  }
8136
- const user = userFromClaims(claims);
8137
- if (Date.now() / 1e3 - claims.iat > sessionTtlSeconds / 2) await ctx.startSession(c, user, claims.amr, { sv: claims.sv });
8138
8572
  return c.json({ user });
8139
8573
  });
8140
8574
  app2.post("/auth/logout", (c) => {
8141
- deleteCookie(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
8575
+ clearSession(c);
8576
+ return c.body(null, 204);
8577
+ });
8578
+ app2.post("/auth/account/delete", async (c) => {
8579
+ const user = await currentSession(c);
8580
+ if (!user) return c.json({ error: "no_session" }, 401);
8581
+ if (roles && user.email) await roles.remove(normalizeEmail(user.email));
8582
+ const passwordUserId = ctx.users && user.email ? await ctx.users.deleteByEmail(normalizeEmail(user.email)) : null;
8583
+ clearSession(c);
8584
+ await ctx.emit("user.deleted", { user, passwordUserId: passwordUserId ?? void 0 });
8142
8585
  return c.body(null, 204);
8143
8586
  });
8587
+ if (roles) {
8588
+ const admin = async (c) => {
8589
+ const user = await currentSession(c);
8590
+ if (!user) return { answer: c.json({ error: "no_session" }, 401) };
8591
+ const email = user.email ? normalizeEmail(user.email) : void 0;
8592
+ if (!email || !user.roles?.includes(ADMIN_ROLE)) {
8593
+ return { answer: c.json({ error: "forbidden", message: `Only users with the role "${ADMIN_ROLE}" can manage roles.` }, 403) };
8594
+ }
8595
+ return { email };
8596
+ };
8597
+ const body = async (c) => {
8598
+ const data = await c.req.json().catch(() => null);
8599
+ return data && typeof data === "object" ? data : {};
8600
+ };
8601
+ const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
8602
+ app2.post("/auth/admin/roles/get", async (c) => {
8603
+ const caller = await admin(c);
8604
+ if ("answer" in caller) return caller.answer;
8605
+ const data = await body(c);
8606
+ if (!isValidEmail(data.email)) return invalidEmail(c);
8607
+ const email = normalizeEmail(data.email);
8608
+ return c.json({ email, roles: await roles.get(email) });
8609
+ });
8610
+ app2.post("/auth/admin/roles/set", async (c) => {
8611
+ const caller = await admin(c);
8612
+ if ("answer" in caller) return caller.answer;
8613
+ const data = await body(c);
8614
+ if (!isValidEmail(data.email)) return invalidEmail(c);
8615
+ const names = parseRoles(data.roles);
8616
+ if (!names) {
8617
+ return c.json(
8618
+ {
8619
+ error: "invalid_roles",
8620
+ message: 'roles must be a list of names: lower-case letters, digits, "-" and "_", starting with a letter.'
8621
+ },
8622
+ 400
8623
+ );
8624
+ }
8625
+ const email = normalizeEmail(data.email);
8626
+ await roles.set(email, names, caller.email);
8627
+ await ctx.emit("roles.changed", { email, roles: names, by: caller.email });
8628
+ return c.json({ email, roles: names });
8629
+ });
8630
+ }
8144
8631
  return app2;
8145
8632
  }
8146
8633
 
8147
8634
  // src/config.ts
8635
+ async function resolveEnv(source = process.env) {
8636
+ return typeof source === "function" ? source() : source;
8637
+ }
8148
8638
  var DEFAULT_SESSION_TTL = 8 * 60 * 60;
8639
+ var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
8149
8640
  var DEFAULT_PASSWORD_MIN_LENGTH = 8;
8150
8641
  var ConfigError = class extends Error {
8151
8642
  constructor() {
@@ -8201,17 +8692,16 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
8201
8692
  if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
8202
8693
  throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
8203
8694
  }
8204
- const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
8695
+ const sendsEmails = !!(overrides.store || read("DATABASE_URL"));
8696
+ const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"), sendsEmails);
8697
+ if (sendsEmails && !webhook) {
8698
+ throw new ConfigError(
8699
+ `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.`
8700
+ );
8701
+ }
8205
8702
  const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
8206
8703
  let password;
8207
- if (store) {
8208
- if (!webhook) {
8209
- throw new ConfigError(
8210
- '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.'
8211
- );
8212
- }
8213
- password = { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")), store };
8214
- }
8704
+ if (store) password = { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")), store };
8215
8705
  if (!clientId && !password) {
8216
8706
  throw new ConfigError(
8217
8707
  "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."
@@ -8222,11 +8712,19 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
8222
8712
  if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
8223
8713
  throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
8224
8714
  }
8715
+ const renewalValue = read("SESSION_RENEWAL_TTL");
8716
+ const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
8717
+ if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
8718
+ throw new ConfigError(
8719
+ `SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
8720
+ );
8721
+ }
8225
8722
  return {
8226
8723
  issuer,
8227
8724
  signingKey,
8228
8725
  allowedOrigins,
8229
8726
  sessionTtlSeconds,
8727
+ renewalTtlSeconds,
8230
8728
  cookieDomain: read("COOKIE_DOMAIN"),
8231
8729
  google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
8232
8730
  password,
@@ -8242,9 +8740,10 @@ function passwordMinLength(value) {
8242
8740
  }
8243
8741
  async function storeFromDatabaseUrl(url) {
8244
8742
  if (!url) return void 0;
8743
+ if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
8245
8744
  if (!url.startsWith("sqlite:")) {
8246
8745
  throw new ConfigError(
8247
- `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).`
8746
+ `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).`
8248
8747
  );
8249
8748
  }
8250
8749
  const path = url.slice("sqlite:".length);
@@ -8256,8 +8755,25 @@ async function storeFromDatabaseUrl(url) {
8256
8755
  throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
8257
8756
  }
8258
8757
  }
8758
+ async function dynamoDbStore(tableName2) {
8759
+ if (!tableName2) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
8760
+ if (!/^[\w.-]{3,255}$/.test(tableName2)) {
8761
+ throw new ConfigError(
8762
+ `DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName2}"`
8763
+ );
8764
+ }
8765
+ try {
8766
+ await import("@aws-sdk/client-dynamodb");
8767
+ } catch (e) {
8768
+ throw new ConfigError(
8769
+ `DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
8770
+ );
8771
+ }
8772
+ const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
8773
+ return createDynamoDbAdapter2({ tableName: tableName2 });
8774
+ }
8259
8775
  var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
8260
- function webhookFromEnv(url, secret, events) {
8776
+ function webhookFromEnv(url, secret, events, sendsEmails) {
8261
8777
  if (!url) {
8262
8778
  if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
8263
8779
  return void 0;
@@ -8283,13 +8799,29 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
8283
8799
  if (problem) {
8284
8800
  throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
8285
8801
  }
8286
- let selected = null;
8287
- if (events) {
8288
- selected = new Set(events.split(",").map((e) => e.trim()).filter(Boolean));
8289
- const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
8290
- if (unknown.length) {
8291
- throw new ConfigError(`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. Known types: ${WEBHOOK_TYPES.join(", ")}.`);
8292
- }
8802
+ const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
8803
+ const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
8804
+ if (!selected.size) {
8805
+ throw new ConfigError(
8806
+ "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:
8807
+
8808
+ WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
8809
+
8810
+ ` : "For example:\n\nWEBHOOK_EVENTS=user.signed_in\n\n") + allTypes
8811
+ );
8812
+ }
8813
+ const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
8814
+ if (unknown.length) {
8815
+ throw new ConfigError(`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. Known types: ${WEBHOOK_TYPES.join(", ")}.`);
8816
+ }
8817
+ const missing = sendsEmails ? REQUIRED_EMAIL_TYPES.filter((type) => !selected.has(type)) : [];
8818
+ if (missing.length) {
8819
+ throw new ConfigError(
8820
+ `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:
8821
+
8822
+ WEBHOOK_EVENTS=${[...selected, ...missing].join(",")}
8823
+ `
8824
+ );
8293
8825
  }
8294
8826
  return { url: parsed.href, secret, events: selected };
8295
8827
  }
@@ -8349,14 +8881,15 @@ async function handleAzureRequest(app2, request) {
8349
8881
  }
8350
8882
 
8351
8883
  // src/entry/azure.ts
8352
- function register(overrides = {}) {
8884
+ function register(options = {}) {
8885
+ const { env, ...overrides } = options;
8353
8886
  let appPromise;
8354
8887
  import_functions.app.http("madauth", {
8355
8888
  route: "{*path}",
8356
8889
  methods: ["GET", "POST", "OPTIONS"],
8357
8890
  authLevel: "anonymous",
8358
8891
  handler: async (request) => {
8359
- appPromise ??= loadConfig(process.env, overrides).then(createApp);
8892
+ appPromise ??= resolveEnv(env).then((loaded) => loadConfig(loaded, overrides)).then(createApp);
8360
8893
  appPromise.catch(() => appPromise = void 0);
8361
8894
  return handleAzureRequest(await appPromise, request);
8362
8895
  }