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