@madauth/server 0.3.0-beta.1 → 0.4.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/bin.js +104 -42
- package/dist/chunk-3PACOYTG.js +1185 -0
- package/dist/{chunk-DCDTO5LG.js → chunk-A7UYZRBL.js} +1 -0
- package/dist/{chunk-CD7Y4T66.js → chunk-GA4TF7EI.js} +24 -2
- package/dist/{chunk-ODITZLMM.js → chunk-ISJGOCF3.js} +258 -84
- package/dist/{chunk-56MOVFO4.js → chunk-OMIX3ERC.js} +11 -2
- package/dist/{chunk-NPNM26J3.js → chunk-Y7ZSUROX.js} +5 -1
- package/dist/{config-DEBYi8hb.d.ts → config-DkE_2D3-.d.ts} +5 -2
- package/dist/dynamodb.js +1 -1
- package/dist/entry/azure.d.ts +3 -3
- package/dist/entry/azure.js +4 -4
- package/dist/entry/lambda.d.ts +3 -3
- package/dist/entry/lambda.js +4 -4
- package/dist/entry/node.d.ts +3 -3
- package/dist/entry/node.js +4 -4
- package/dist/index.d.ts +29 -14
- package/dist/index.js +10 -6
- package/dist/{schema-3yhCc2jH.d.ts → schema-DTeiSuQB.d.ts} +39 -2
- package/dist/sqlite.js +2 -2
- package/dist/standalone/azure.mjs +1331 -676
- package/dist/standalone/lambda.mjs +1319 -664
- package/dist/standalone/main.mjs +1191 -536
- package/dist/store/dynamodb.d.ts +1 -1
- package/dist/store/sqlite.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +2 -0
- package/dist/{user-BMWEWREH.d.ts → user-BZs84sui.d.ts} +5 -0
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +1 -1
- package/dist/{webhook-DCDP6SaE.d.ts → webhook-B6P50ocX.d.ts} +1 -1
- package/dist/webhook.d.ts +1 -1
- package/dist/webhook.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-ZSBKNV5W.js +0 -713
|
@@ -57,6 +57,7 @@ function userFromClaims(claims) {
|
|
|
57
57
|
if (claims.name) user.name = claims.name;
|
|
58
58
|
if (claims.picture) user.picture = claims.picture;
|
|
59
59
|
if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
|
|
60
|
+
if (Array.isArray(claims.amr)) user.amr = claims.amr.filter((value) => typeof value === "string");
|
|
60
61
|
return user;
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/store/schema.ts
|
|
2
2
|
var madauthSchema = {
|
|
3
|
-
version:
|
|
3
|
+
version: 5,
|
|
4
4
|
models: {
|
|
5
5
|
/** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
|
|
6
6
|
user: {
|
|
@@ -19,7 +19,10 @@ var madauthSchema = {
|
|
|
19
19
|
createdAt: { type: "number" }
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* How a user signs in. `key` is `password:<userId>`, `google:<sub>` or `totp:<userId>` (the authenticator
|
|
24
|
+
* app, whose `secret` is encrypted).
|
|
25
|
+
*/
|
|
23
26
|
account: {
|
|
24
27
|
fields: {
|
|
25
28
|
id: { type: "string", primaryKey: true },
|
|
@@ -30,6 +33,25 @@ var madauthSchema = {
|
|
|
30
33
|
lockedUntil: { type: "number" },
|
|
31
34
|
/** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
|
|
32
35
|
email: { type: "string", nullable: true },
|
|
36
|
+
/** The time step of the last authenticator code that was accepted, so a code works once; 0 before (since version 5). */
|
|
37
|
+
lastUsedStep: { type: "number" },
|
|
38
|
+
/**
|
|
39
|
+
* The setup an authenticator secret came from; its recovery codes carry the same id, so the app and
|
|
40
|
+
* its codes always belong together. Empty for other accounts (since version 5).
|
|
41
|
+
*/
|
|
42
|
+
setupId: { type: "string", nullable: true },
|
|
43
|
+
createdAt: { type: "number" }
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
/**
|
|
47
|
+
* A recovery code of a user's authenticator app (since version 5). `id` is the code's keyed hash; the
|
|
48
|
+
* record goes when the code is used. It works only while the account's `setupId` is its own.
|
|
49
|
+
*/
|
|
50
|
+
recoveryCode: {
|
|
51
|
+
fields: {
|
|
52
|
+
id: { type: "string", primaryKey: true },
|
|
53
|
+
userId: { type: "string", index: true },
|
|
54
|
+
setupId: { type: "string" },
|
|
33
55
|
createdAt: { type: "number" }
|
|
34
56
|
}
|
|
35
57
|
},
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
WEBHOOK_TYPES,
|
|
9
9
|
checkWebhookSecret,
|
|
10
10
|
generateWebhookSecret
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-Y7ZSUROX.js";
|
|
12
12
|
|
|
13
13
|
// src/claims.ts
|
|
14
14
|
var ADMIN_ROLE = "admin";
|
|
@@ -67,75 +67,13 @@ function isAdmin(user) {
|
|
|
67
67
|
return rolesOf(user).includes(ADMIN_ROLE);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// src/
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
function parseMethodSettings(input) {
|
|
74
|
-
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
|
|
75
|
-
const settings = {};
|
|
76
|
-
for (const [key, value] of Object.entries(input)) {
|
|
77
|
-
if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
|
|
78
|
-
settings[key] = value;
|
|
79
|
-
}
|
|
80
|
-
return settings;
|
|
81
|
-
}
|
|
82
|
-
var MAX_TRIES = 3;
|
|
83
|
-
var Settings = class {
|
|
84
|
-
constructor(store) {
|
|
85
|
-
this.store = store;
|
|
86
|
-
}
|
|
87
|
-
/** The setting's value, or undefined if it was never set. */
|
|
88
|
-
async get(id) {
|
|
89
|
-
return (await this.read(id)).value;
|
|
90
|
-
}
|
|
91
|
-
/** The stored JSON and what it holds; both undefined if the setting was never set. */
|
|
92
|
-
async read(id) {
|
|
93
|
-
const record = await this.store.findOne("setting", { id });
|
|
94
|
-
if (typeof record?.value !== "string") return {};
|
|
95
|
-
try {
|
|
96
|
-
return { json: record.value, value: JSON.parse(record.value) };
|
|
97
|
-
} catch {
|
|
98
|
-
return { json: record.value };
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
|
|
103
|
-
* change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
|
|
104
|
-
* again with the newer value. Resolves to the new value, or null if `apply` refused.
|
|
105
|
-
* `updatedBy` is the address of the admin, or null for the command line.
|
|
106
|
-
*/
|
|
107
|
-
async change(id, apply, updatedBy) {
|
|
108
|
-
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
|
109
|
-
const { json, value } = await this.read(id);
|
|
110
|
-
const next = apply(value);
|
|
111
|
-
if (next === null) return null;
|
|
112
|
-
const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
|
|
113
|
-
const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
|
|
114
|
-
if (written) return next;
|
|
115
|
-
}
|
|
116
|
-
throw new Error(`Setting "${id}" kept changing; try again.`);
|
|
117
|
-
}
|
|
118
|
-
/** Replaces the setting. */
|
|
119
|
-
async set(id, value, updatedBy) {
|
|
120
|
-
await this.change(id, () => value, updatedBy);
|
|
121
|
-
}
|
|
122
|
-
/** Which sign-in methods are on; a method that was never switched off is on. */
|
|
123
|
-
async methods() {
|
|
124
|
-
return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
|
|
125
|
-
}
|
|
126
|
-
/** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
|
|
127
|
-
async changeMethods(apply, updatedBy) {
|
|
128
|
-
return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
function isOn(method, available, methods) {
|
|
132
|
-
return available(method) && methods[method] !== false;
|
|
70
|
+
// src/config.ts
|
|
71
|
+
function sendsVerificationEmails(config) {
|
|
72
|
+
return !!config.webhook?.events.has("email.verify");
|
|
133
73
|
}
|
|
134
|
-
function
|
|
135
|
-
return
|
|
74
|
+
function methodConfig(config) {
|
|
75
|
+
return { google: config.google, password: config.password, emailVerification: sendsVerificationEmails(config) };
|
|
136
76
|
}
|
|
137
|
-
|
|
138
|
-
// src/config.ts
|
|
139
77
|
var envVars = {
|
|
140
78
|
MADAUTH_ISSUER: true,
|
|
141
79
|
MADAUTH_SIGNING_KEY: true,
|
|
@@ -144,6 +82,7 @@ var envVars = {
|
|
|
144
82
|
GOOGLE_CLIENT_SECRET: false,
|
|
145
83
|
DATABASE_URL: true,
|
|
146
84
|
PASSWORD_MIN_LENGTH: false,
|
|
85
|
+
TOTP_ISSUER: false,
|
|
147
86
|
WEBHOOK_URL: false,
|
|
148
87
|
WEBHOOK_SECRET: false,
|
|
149
88
|
WEBHOOK_EVENTS: false,
|
|
@@ -214,9 +153,10 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
|
214
153
|
const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
|
|
215
154
|
const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
|
|
216
155
|
const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
|
|
217
|
-
|
|
156
|
+
const emailVerification = !!webhook && webhook.events.has("email.verify");
|
|
157
|
+
if (!clientId && !password && !emailVerification) {
|
|
218
158
|
throw new ConfigError(
|
|
219
|
-
`No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails
|
|
159
|
+
`No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails: e-mail & password sign-in needs email.verify and email.reset, sign-up with the authenticator app needs email.verify. 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,email.no_password. See docs/server.md.`
|
|
220
160
|
);
|
|
221
161
|
}
|
|
222
162
|
const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
|
|
@@ -244,6 +184,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
|
244
184
|
sessionTtlSeconds,
|
|
245
185
|
renewalTtlSeconds,
|
|
246
186
|
cookieDomain: read("COOKIE_DOMAIN"),
|
|
187
|
+
totpIssuer: read("TOTP_ISSUER"),
|
|
247
188
|
google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
|
|
248
189
|
store,
|
|
249
190
|
password,
|
|
@@ -333,7 +274,7 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
|
|
|
333
274
|
throw new ConfigError(
|
|
334
275
|
`WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
|
|
335
276
|
|
|
336
|
-
WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
|
|
277
|
+
WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
|
|
337
278
|
|
|
338
279
|
For one that only wants to know who signed in:
|
|
339
280
|
|
|
@@ -352,6 +293,125 @@ WEBHOOK_EVENTS=user.signed_in
|
|
|
352
293
|
return { url: parsed.href, secret, events: selected };
|
|
353
294
|
}
|
|
354
295
|
|
|
296
|
+
// src/settings.ts
|
|
297
|
+
var SIGN_IN_METHODS = ["google", "password", "totp"];
|
|
298
|
+
var METHODS_WITH_SECOND_FACTOR = ["google", "password"];
|
|
299
|
+
var SECOND_FACTOR_POLICIES = ["none", "optional", "required"];
|
|
300
|
+
var METHODS_SETTING = "methods";
|
|
301
|
+
var isSignInMethod = (value) => SIGN_IN_METHODS.includes(value);
|
|
302
|
+
var isPolicy = (value) => SECOND_FACTOR_POLICIES.includes(value);
|
|
303
|
+
function parseMethodSettings(input, options = {}) {
|
|
304
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
|
|
305
|
+
const settings = {};
|
|
306
|
+
for (const [method, value] of Object.entries(input)) {
|
|
307
|
+
if (!isSignInMethod(method)) return null;
|
|
308
|
+
if (typeof value === "boolean") {
|
|
309
|
+
if (!options.booleans) return null;
|
|
310
|
+
if (value) settings[method] = {};
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
314
|
+
const setting = {};
|
|
315
|
+
for (const [field, fieldValue] of Object.entries(value)) {
|
|
316
|
+
if (field !== "secondFactor" || method === "totp" || !isPolicy(fieldValue)) return null;
|
|
317
|
+
setting.secondFactor = fieldValue;
|
|
318
|
+
}
|
|
319
|
+
settings[method] = setting;
|
|
320
|
+
}
|
|
321
|
+
return settings;
|
|
322
|
+
}
|
|
323
|
+
function isConfigured(config, method) {
|
|
324
|
+
if (method === "google") return !!config.google;
|
|
325
|
+
if (method === "password") return !!config.password;
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
function canSignUp(config, method) {
|
|
329
|
+
return method === "totp" ? !!config.emailVerification : isConfigured(config, method);
|
|
330
|
+
}
|
|
331
|
+
function initialMethods(config) {
|
|
332
|
+
const methods = {};
|
|
333
|
+
if (config.google) methods.google = { secondFactor: "none" };
|
|
334
|
+
if (config.password) methods.password = { secondFactor: "none" };
|
|
335
|
+
if (!config.google && !config.password && config.emailVerification) methods.totp = {};
|
|
336
|
+
return methods;
|
|
337
|
+
}
|
|
338
|
+
var MAX_TRIES = 3;
|
|
339
|
+
var Settings = class {
|
|
340
|
+
/** `initial` is what counts until the methods are set, see {@link initialMethods}. */
|
|
341
|
+
constructor(store, initial = {}) {
|
|
342
|
+
this.store = store;
|
|
343
|
+
this.initial = initial;
|
|
344
|
+
this.warned = false;
|
|
345
|
+
}
|
|
346
|
+
/** The setting's value, or undefined if it was never set. */
|
|
347
|
+
async get(id) {
|
|
348
|
+
return (await this.read(id)).value;
|
|
349
|
+
}
|
|
350
|
+
/** The stored JSON and what it holds; both undefined if the setting was never set. */
|
|
351
|
+
async read(id) {
|
|
352
|
+
const record = await this.store.findOne("setting", { id });
|
|
353
|
+
if (typeof record?.value !== "string") return {};
|
|
354
|
+
try {
|
|
355
|
+
return { json: record.value, value: JSON.parse(record.value) };
|
|
356
|
+
} catch {
|
|
357
|
+
return { json: record.value };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
|
|
362
|
+
* change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
|
|
363
|
+
* again with the newer value. Resolves to the new value, or null if `apply` refused.
|
|
364
|
+
* `updatedBy` is the address of the admin, or null for the command line.
|
|
365
|
+
*/
|
|
366
|
+
async change(id, apply, updatedBy) {
|
|
367
|
+
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
|
368
|
+
const { json, value } = await this.read(id);
|
|
369
|
+
const next = apply(value);
|
|
370
|
+
if (next === null) return null;
|
|
371
|
+
const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
|
|
372
|
+
const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
|
|
373
|
+
if (written) return next;
|
|
374
|
+
}
|
|
375
|
+
throw new Error(`Setting "${id}" kept changing; try again.`);
|
|
376
|
+
}
|
|
377
|
+
/** Replaces the setting. */
|
|
378
|
+
async set(id, value, updatedBy) {
|
|
379
|
+
await this.change(id, () => value, updatedBy);
|
|
380
|
+
}
|
|
381
|
+
/** Which sign-in methods are on, with their settings; the initial ones until an admin set them. */
|
|
382
|
+
async methods() {
|
|
383
|
+
return this.current(await this.get(METHODS_SETTING));
|
|
384
|
+
}
|
|
385
|
+
/** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
|
|
386
|
+
async changeMethods(apply, updatedBy) {
|
|
387
|
+
return this.change(METHODS_SETTING, (current) => apply(this.current(current)), updatedBy);
|
|
388
|
+
}
|
|
389
|
+
/** The stored methods, or the initial ones if nothing (usable) is stored. */
|
|
390
|
+
current(stored) {
|
|
391
|
+
if (stored === void 0) return this.initial;
|
|
392
|
+
const parsed = parseMethodSettings(stored);
|
|
393
|
+
if (parsed) return parsed;
|
|
394
|
+
if (!this.warned) {
|
|
395
|
+
this.warned = true;
|
|
396
|
+
console.warn("[madauth] The stored sign-in methods are not in the form this version expects (they were set by an older one?). Set them again with set-methods; until then the initial methods count.");
|
|
397
|
+
}
|
|
398
|
+
return this.initial;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
function isOn(method, configured, methods) {
|
|
402
|
+
return configured(method) && methods[method] !== void 0;
|
|
403
|
+
}
|
|
404
|
+
function policyOf(method, methods) {
|
|
405
|
+
return methods[method]?.secondFactor ?? "none";
|
|
406
|
+
}
|
|
407
|
+
function anyOn(signsUp, methods) {
|
|
408
|
+
return SIGN_IN_METHODS.some((method) => signsUp(method) && methods[method] !== void 0);
|
|
409
|
+
}
|
|
410
|
+
function authenticatorOn(configured, methods) {
|
|
411
|
+
if (isOn("totp", configured, methods)) return true;
|
|
412
|
+
return METHODS_WITH_SECOND_FACTOR.some((method) => isOn(method, configured, methods) && policyOf(method, methods) !== "none");
|
|
413
|
+
}
|
|
414
|
+
|
|
355
415
|
// src/password.ts
|
|
356
416
|
import { createHash, createHmac, hkdfSync, randomBytes, randomInt, scrypt, timingSafeEqual } from "crypto";
|
|
357
417
|
var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
|
|
@@ -438,6 +498,9 @@ function passwordAccountKey(userId) {
|
|
|
438
498
|
function googleAccountKey(sub) {
|
|
439
499
|
return `google:${sub}`;
|
|
440
500
|
}
|
|
501
|
+
function totpAccountKey(userId) {
|
|
502
|
+
return `totp:${userId}`;
|
|
503
|
+
}
|
|
441
504
|
function toMadauthUser(user, profile = {}) {
|
|
442
505
|
const result = { id: user.id, email: user.email };
|
|
443
506
|
const name = user.name ?? profile.name;
|
|
@@ -466,12 +529,32 @@ var Users = class {
|
|
|
466
529
|
async passwordAccount(userId) {
|
|
467
530
|
return this.findAccountByKey(passwordAccountKey(userId));
|
|
468
531
|
}
|
|
532
|
+
/** The user's authenticator app, if they set one up. */
|
|
533
|
+
async totpAccount(userId) {
|
|
534
|
+
return this.findAccountByKey(totpAccountKey(userId));
|
|
535
|
+
}
|
|
536
|
+
/** Whether the user has any way to sign in; a sign-up with the authenticator app has none until it is set up. */
|
|
537
|
+
async hasAccounts(userId) {
|
|
538
|
+
return (await this.store.findMany("account", { userId }, { limit: 1 })).length > 0;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The ways the user can sign in, from their accounts: e.g. `['google']` for a user without a password.
|
|
542
|
+
* The authenticator app counts only when it signs in on its own (`methods` lists it); as a second
|
|
543
|
+
* factor it is a step of another method, not a way in.
|
|
544
|
+
*/
|
|
545
|
+
async signInMethods(userId, methods) {
|
|
546
|
+
const accounts = await this.store.findMany("account", { userId });
|
|
547
|
+
const keys = accounts.map((account) => String(account.key).split(":")[0]);
|
|
548
|
+
return SIGN_IN_METHODS.filter((method) => keys.includes(method) && (method !== "totp" || methods.totp !== void 0));
|
|
549
|
+
}
|
|
469
550
|
async findAccountByKey(key) {
|
|
470
551
|
return await this.store.findOne("account", { key });
|
|
471
552
|
}
|
|
472
553
|
/**
|
|
473
|
-
* Creates a user with their first account. Resolves to null if the e-mail address or the
|
|
474
|
-
* taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or
|
|
554
|
+
* Creates a user, with their first account if given. Resolves to null if the e-mail address or the
|
|
555
|
+
* account key is taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or
|
|
556
|
+
* e.g. `google:<sub>`. A sign-up with the authenticator app creates the user without an account: the app
|
|
557
|
+
* is set up once the address is confirmed.
|
|
475
558
|
*/
|
|
476
559
|
async createUser(data) {
|
|
477
560
|
const user = {
|
|
@@ -487,6 +570,7 @@ var Users = class {
|
|
|
487
570
|
createdAt: Date.now()
|
|
488
571
|
};
|
|
489
572
|
if (!await this.store.create("user", user)) return null;
|
|
573
|
+
if (!data.account) return user;
|
|
490
574
|
const account = typeof data.account === "function" ? data.account(user.id) : data.account;
|
|
491
575
|
if (!await this.linkAccount(user.id, account)) {
|
|
492
576
|
await this.store.delete("user", { id: user.id });
|
|
@@ -508,6 +592,16 @@ var Users = class {
|
|
|
508
592
|
async deleteAccount(id) {
|
|
509
593
|
await this.store.delete("account", { id });
|
|
510
594
|
}
|
|
595
|
+
/** Removes a sign-in method by its key. Resolves to false if there was none. */
|
|
596
|
+
async unlinkAccount(key) {
|
|
597
|
+
return await this.store.delete("account", { key }) === 1;
|
|
598
|
+
}
|
|
599
|
+
/** Removes the user's authenticator app and recovery codes. Resolves to false if they had no app. */
|
|
600
|
+
async removeTotp(userId) {
|
|
601
|
+
const removed = await this.unlinkAccount(totpAccountKey(userId));
|
|
602
|
+
await this.clearRecoveryCodes(userId);
|
|
603
|
+
return removed;
|
|
604
|
+
}
|
|
511
605
|
/** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
|
|
512
606
|
async linkAccount(userId, account) {
|
|
513
607
|
const record = {
|
|
@@ -518,10 +612,70 @@ var Users = class {
|
|
|
518
612
|
failedAttempts: 0,
|
|
519
613
|
lockedUntil: 0,
|
|
520
614
|
email: account.email ?? null,
|
|
615
|
+
lastUsedStep: account.lastUsedStep ?? 0,
|
|
616
|
+
setupId: account.setupId ?? null,
|
|
521
617
|
createdAt: Date.now()
|
|
522
618
|
};
|
|
523
619
|
return await this.store.create("account", record) ? record : null;
|
|
524
620
|
}
|
|
621
|
+
/**
|
|
622
|
+
* Writes the secret of a confirmed setup over the authenticator account, only if the account still has the
|
|
623
|
+
* setup it had when this setup started (`replaces`): the write count decides, so of two setups confirmed
|
|
624
|
+
* at once exactly one wins, and the other is told to start again.
|
|
625
|
+
*/
|
|
626
|
+
async confirmTotpSetup(accountId, replaces, patch) {
|
|
627
|
+
return await this.store.update("account", { id: accountId, setupId: replaces }, patch) === 1;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Marks the time step of an accepted authenticator code as used and resets the failed attempts, only if
|
|
631
|
+
* no other request accepted a code meanwhile: the write count decides, so the same code sent twice signs
|
|
632
|
+
* in once.
|
|
633
|
+
*/
|
|
634
|
+
async useTotpStep(account, step) {
|
|
635
|
+
const changed = await this.store.update(
|
|
636
|
+
"account",
|
|
637
|
+
{ id: account.id, lastUsedStep: account.lastUsedStep ?? null },
|
|
638
|
+
{ lastUsedStep: step, failedAttempts: 0, lockedUntil: 0 }
|
|
639
|
+
);
|
|
640
|
+
return changed === 1;
|
|
641
|
+
}
|
|
642
|
+
/** Replaces the user's recovery codes with new ones, given as their hashes. */
|
|
643
|
+
/** Stores the recovery codes of a setup (their hashes), next to whatever codes the user has. */
|
|
644
|
+
async addRecoveryCodes(userId, setupId, hashes) {
|
|
645
|
+
const createdAt = Date.now();
|
|
646
|
+
for (const id of hashes) await this.store.create("recoveryCode", { id, userId, setupId, createdAt });
|
|
647
|
+
}
|
|
648
|
+
/** New recovery codes for the user's current app: the codes they had before stop working. */
|
|
649
|
+
async replaceRecoveryCodes(account, hashes) {
|
|
650
|
+
const before = await this.store.findMany("recoveryCode", { userId: account.userId });
|
|
651
|
+
await this.addRecoveryCodes(account.userId, account.setupId ?? "", hashes);
|
|
652
|
+
for (const old of before) await this.store.delete("recoveryCode", { id: old.id });
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Uses up the recovery code with this hash for the user's authenticator account. Resolves to false if it
|
|
656
|
+
* is not theirs, belongs to another setup than the account's, or was used already.
|
|
657
|
+
*/
|
|
658
|
+
async consumeRecoveryCode(account, hash) {
|
|
659
|
+
const record = await this.store.findOne("recoveryCode", { id: hash });
|
|
660
|
+
if (!record || record.userId !== account.userId || record.setupId !== (account.setupId ?? "")) return false;
|
|
661
|
+
return await this.store.delete("recoveryCode", { id: hash }) === 1;
|
|
662
|
+
}
|
|
663
|
+
/** How many recovery codes of the account's setup are left. */
|
|
664
|
+
async countRecoveryCodes(account) {
|
|
665
|
+
const codes = await this.store.findMany("recoveryCode", { userId: account.userId });
|
|
666
|
+
return codes.filter((code) => code.setupId === (account.setupId ?? "")).length;
|
|
667
|
+
}
|
|
668
|
+
/** Deletes the user's recovery codes: all of them, or only those of one setup, or all but one setup's. */
|
|
669
|
+
async clearRecoveryCodes(userId, which = {}) {
|
|
670
|
+
if (which.only === void 0 && which.except === void 0) {
|
|
671
|
+
await this.store.delete("recoveryCode", { userId });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
for (const code of await this.store.findMany("recoveryCode", { userId })) {
|
|
675
|
+
const match = which.only !== void 0 ? code.setupId === which.only : code.setupId !== which.except;
|
|
676
|
+
if (match) await this.store.delete("recoveryCode", { id: code.id });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
525
679
|
/**
|
|
526
680
|
* Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
|
|
527
681
|
*/
|
|
@@ -532,6 +686,7 @@ var Users = class {
|
|
|
532
686
|
try {
|
|
533
687
|
await this.store.delete("account", { userId: user.id });
|
|
534
688
|
await this.store.delete("verification", { userId: user.id });
|
|
689
|
+
await this.store.delete("recoveryCode", { userId: user.id });
|
|
535
690
|
} catch (e) {
|
|
536
691
|
console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
|
|
537
692
|
}
|
|
@@ -540,8 +695,9 @@ var Users = class {
|
|
|
540
695
|
async updateUser(id, patch) {
|
|
541
696
|
await this.store.update("user", { id }, patch);
|
|
542
697
|
}
|
|
698
|
+
/** Changes an account. Resolves to false if it is gone meanwhile. */
|
|
543
699
|
async updateAccount(id, patch) {
|
|
544
|
-
await this.store.update("account", { id }, patch);
|
|
700
|
+
return await this.store.update("account", { id }, patch) === 1;
|
|
545
701
|
}
|
|
546
702
|
/**
|
|
547
703
|
* Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
|
|
@@ -574,6 +730,11 @@ var Users = class {
|
|
|
574
730
|
await this.store.create("verification", record);
|
|
575
731
|
return { token, code };
|
|
576
732
|
}
|
|
733
|
+
/** The user ID a link token belongs to, without using the verification up; null if invalid, expired or used. */
|
|
734
|
+
async peekLinkToken(token, purpose) {
|
|
735
|
+
const record = await this.store.findOne("verification", { id: hashLinkToken(token), purpose });
|
|
736
|
+
return record && record.expiresAt > Date.now() ? record.userId : null;
|
|
737
|
+
}
|
|
577
738
|
/** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
|
|
578
739
|
async consumeLinkToken(token, purpose) {
|
|
579
740
|
const id = hashLinkToken(token);
|
|
@@ -627,6 +788,14 @@ export {
|
|
|
627
788
|
sameClaims,
|
|
628
789
|
claimsFromJson,
|
|
629
790
|
isAdmin,
|
|
791
|
+
sendsVerificationEmails,
|
|
792
|
+
methodConfig,
|
|
793
|
+
envVars,
|
|
794
|
+
resolveEnv,
|
|
795
|
+
ConfigError,
|
|
796
|
+
loadConfig,
|
|
797
|
+
loadUserStoreConfig,
|
|
798
|
+
loadStore,
|
|
630
799
|
MAX_PASSWORD_LENGTH,
|
|
631
800
|
hashPassword,
|
|
632
801
|
verifyPassword,
|
|
@@ -635,19 +804,24 @@ export {
|
|
|
635
804
|
normalizeEmail,
|
|
636
805
|
isValidEmail,
|
|
637
806
|
deriveCodeKey,
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
toMadauthUser,
|
|
641
|
-
Users,
|
|
807
|
+
hashCode,
|
|
808
|
+
safeEqual,
|
|
642
809
|
SIGN_IN_METHODS,
|
|
810
|
+
METHODS_WITH_SECOND_FACTOR,
|
|
811
|
+
SECOND_FACTOR_POLICIES,
|
|
643
812
|
parseMethodSettings,
|
|
813
|
+
isConfigured,
|
|
814
|
+
canSignUp,
|
|
815
|
+
initialMethods,
|
|
644
816
|
Settings,
|
|
645
817
|
isOn,
|
|
818
|
+
policyOf,
|
|
646
819
|
anyOn,
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
820
|
+
authenticatorOn,
|
|
821
|
+
newId,
|
|
822
|
+
passwordAccountKey,
|
|
823
|
+
googleAccountKey,
|
|
824
|
+
totpAccountKey,
|
|
825
|
+
toMadauthUser,
|
|
826
|
+
Users
|
|
653
827
|
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
madauthSchema
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-GA4TF7EI.js";
|
|
4
4
|
|
|
5
5
|
// src/store/sql.ts
|
|
6
6
|
function tableName(model) {
|
|
7
|
-
return `madauth_${model}`;
|
|
7
|
+
return `madauth_${columnName(model)}`;
|
|
8
8
|
}
|
|
9
9
|
function columnName(field) {
|
|
10
10
|
return field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
@@ -45,6 +45,15 @@ var upgrades = [
|
|
|
45
45
|
`DROP TABLE ${tableName("role")};`,
|
|
46
46
|
createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
|
|
47
47
|
].join("\n")
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
// The authenticator app: which code was used last, and the recovery codes.
|
|
51
|
+
version: 5,
|
|
52
|
+
sql: (dialect) => [
|
|
53
|
+
`ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("lastUsedStep")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`,
|
|
54
|
+
`ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("setupId")} ${columnTypes[dialect].string};`,
|
|
55
|
+
createTablesSql(dialect, { version: 5, models: { recoveryCode: madauthSchema.models.recoveryCode } })
|
|
56
|
+
].join("\n")
|
|
48
57
|
}
|
|
49
58
|
];
|
|
50
59
|
function upgradeTablesSql(dialect, fromVersion) {
|
|
@@ -4,13 +4,17 @@ var WEBHOOK_TYPES = [
|
|
|
4
4
|
"email.verify",
|
|
5
5
|
"email.reset",
|
|
6
6
|
"email.already_registered",
|
|
7
|
+
"email.no_password",
|
|
7
8
|
"signup.before",
|
|
8
9
|
"user.created",
|
|
9
10
|
"email.verified",
|
|
10
11
|
"email.password_reset",
|
|
11
12
|
"user.signed_in",
|
|
12
13
|
"user.deleted",
|
|
13
|
-
"user.claims_changed"
|
|
14
|
+
"user.claims_changed",
|
|
15
|
+
"totp.enabled",
|
|
16
|
+
"totp.disabled",
|
|
17
|
+
"totp.recovery_code_used"
|
|
14
18
|
];
|
|
15
19
|
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
16
20
|
var MAX_LOCALE_LENGTH = 35;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { JWK, JWTVerifyGetKey } from 'jose';
|
|
2
|
-
import { S as StoreAdapter } from './schema-
|
|
3
|
-
import { W as WebhookSettings } from './webhook-
|
|
2
|
+
import { S as StoreAdapter } from './schema-DTeiSuQB.js';
|
|
3
|
+
import { W as WebhookSettings } from './webhook-B6P50ocX.js';
|
|
4
4
|
|
|
5
5
|
interface MadauthConfig {
|
|
6
6
|
/** Public base URL of the server (MADAUTH_ISSUER). */
|
|
@@ -15,6 +15,8 @@ interface MadauthConfig {
|
|
|
15
15
|
renewalTtlSeconds: number;
|
|
16
16
|
/** Cookie domain, e.g. `.example.com` (COOKIE_DOMAIN). */
|
|
17
17
|
cookieDomain?: string;
|
|
18
|
+
/** The name authenticator apps show next to the account (TOTP_ISSUER). Default: the host of the app that asked for the setup. */
|
|
19
|
+
totpIssuer?: string;
|
|
18
20
|
/** Google sign-in; off when GOOGLE_CLIENT_ID is not set. */
|
|
19
21
|
google?: {
|
|
20
22
|
/** OAuth web client ID (GOOGLE_CLIENT_ID). */
|
|
@@ -45,6 +47,7 @@ declare const envVars: {
|
|
|
45
47
|
readonly GOOGLE_CLIENT_SECRET: false;
|
|
46
48
|
readonly DATABASE_URL: true;
|
|
47
49
|
readonly PASSWORD_MIN_LENGTH: false;
|
|
50
|
+
readonly TOTP_ISSUER: false;
|
|
48
51
|
readonly WEBHOOK_URL: false;
|
|
49
52
|
readonly WEBHOOK_SECRET: false;
|
|
50
53
|
readonly WEBHOOK_EVENTS: false;
|
package/dist/dynamodb.js
CHANGED
package/dist/entry/azure.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { E as EntryOptions } from '../config-
|
|
1
|
+
import { E as EntryOptions } from '../config-DkE_2D3-.js';
|
|
2
2
|
import 'jose';
|
|
3
|
-
import '../schema-
|
|
4
|
-
import '../webhook-
|
|
3
|
+
import '../schema-DTeiSuQB.js';
|
|
4
|
+
import '../webhook-B6P50ocX.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Registers madAuth as an Azure Functions v4 HTTP function that handles every route. Pass `store` to use your
|
package/dist/entry/azure.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createApp
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-3PACOYTG.js";
|
|
4
|
+
import "../chunk-A7UYZRBL.js";
|
|
5
5
|
import {
|
|
6
6
|
loadConfig,
|
|
7
7
|
resolveEnv
|
|
8
|
-
} from "../chunk-
|
|
8
|
+
} from "../chunk-ISJGOCF3.js";
|
|
9
9
|
import "../chunk-JDQLSL36.js";
|
|
10
|
-
import "../chunk-
|
|
10
|
+
import "../chunk-Y7ZSUROX.js";
|
|
11
11
|
|
|
12
12
|
// src/entry/azure.ts
|
|
13
13
|
import { app } from "@azure/functions";
|
package/dist/entry/lambda.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { LambdaEvent, LambdaContext, handle } from 'hono/aws-lambda';
|
|
2
|
-
import { E as EntryOptions } from '../config-
|
|
2
|
+
import { E as EntryOptions } from '../config-DkE_2D3-.js';
|
|
3
3
|
import 'jose';
|
|
4
|
-
import '../schema-
|
|
5
|
-
import '../webhook-
|
|
4
|
+
import '../schema-DTeiSuQB.js';
|
|
5
|
+
import '../webhook-B6P50ocX.js';
|
|
6
6
|
|
|
7
7
|
type LambdaHandler = (event: LambdaEvent, context: LambdaContext) => ReturnType<ReturnType<typeof handle>>;
|
|
8
8
|
/**
|
package/dist/entry/lambda.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createApp
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-3PACOYTG.js";
|
|
4
|
+
import "../chunk-A7UYZRBL.js";
|
|
5
5
|
import {
|
|
6
6
|
loadConfig,
|
|
7
7
|
resolveEnv
|
|
8
|
-
} from "../chunk-
|
|
8
|
+
} from "../chunk-ISJGOCF3.js";
|
|
9
9
|
import "../chunk-JDQLSL36.js";
|
|
10
|
-
import "../chunk-
|
|
10
|
+
import "../chunk-Y7ZSUROX.js";
|
|
11
11
|
|
|
12
12
|
// src/entry/lambda.ts
|
|
13
13
|
import { handle } from "hono/aws-lambda";
|