@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.
- package/README.md +17 -7
- package/dist/bin.js +284 -22
- package/dist/{chunk-V2XKGETK.js → chunk-5CKEMC6M.js} +163 -28
- package/dist/{chunk-AVZE6FFR.js → chunk-6EJDVZCD.js} +8 -3
- package/dist/{chunk-PRRKLEW7.js → chunk-7CTAB3QV.js} +21 -0
- package/dist/{chunk-3WIWWKRI.js → chunk-CNL562LZ.js} +17 -42
- package/dist/chunk-XNBWZZHT.js +60 -0
- package/dist/{chunk-TEMZRMFF.js → chunk-YALS7OMV.js} +162 -42
- package/dist/{config-KQvE2imU.d.ts → config-BkxADgtp.d.ts} +16 -4
- package/dist/dynamodb.js +244 -0
- package/dist/entry/azure.d.ts +5 -5
- package/dist/entry/azure.js +9 -7
- package/dist/entry/lambda.d.ts +5 -5
- package/dist/entry/lambda.js +9 -7
- package/dist/entry/node.d.ts +5 -8
- package/dist/entry/node.js +8 -6
- package/dist/index.d.ts +12 -7
- package/dist/index.js +12 -8
- package/dist/{schema-Dotc6LU7.d.ts → schema-BSgYVRpd.d.ts} +28 -1
- package/dist/sqlite.js +7 -4
- package/dist/standalone/azure.mjs +611 -78
- package/dist/standalone/lambda.mjs +611 -78
- package/dist/standalone/main.mjs +29508 -592
- package/dist/store/dynamodb.d.ts +19 -0
- package/dist/store/sqlite.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +65 -2
- package/dist/{user-3DDyggFV.d.ts → user-C1A3BfOo.d.ts} +2 -0
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +1 -1
- package/dist/{webhook-CT80emTQ.d.ts → webhook-ltwulgcB.d.ts} +3 -3
- package/dist/webhook.d.ts +1 -1
- package/dist/webhook.js +1 -1
- package/package.json +12 -2
|
@@ -4107,7 +4107,7 @@ var init_schema = __esm({
|
|
|
4107
4107
|
"src/store/schema.ts"() {
|
|
4108
4108
|
"use strict";
|
|
4109
4109
|
madauthSchema = {
|
|
4110
|
-
version:
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
7587
|
-
return c.json({ 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
|
|
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
|
-
|
|
7728
|
-
return {
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
/**
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
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)
|
|
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
|
|
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) =>
|
|
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
|
|
8080
|
-
|
|
8081
|
-
|
|
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
|
|
8131
|
-
if (!
|
|
8132
|
-
|
|
8133
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|