@madauth/server 0.4.0-beta.1 → 0.5.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 +1 -1
- package/dist/bin.js +134 -44
- package/dist/{chunk-3PACOYTG.js → chunk-7DHKDRGY.js} +897 -376
- package/dist/{chunk-GA4TF7EI.js → chunk-NP75Y3IV.js} +27 -4
- package/dist/{chunk-ISJGOCF3.js → chunk-NYIEFI4B.js} +268 -55
- package/dist/{chunk-OMIX3ERC.js → chunk-O2URPWA6.js} +27 -3
- package/dist/{chunk-Y7ZSUROX.js → chunk-SISM7YUZ.js} +14 -12
- package/dist/{config-DkE_2D3-.d.ts → config-CcGlHVAN.d.ts} +16 -8
- package/dist/dynamodb.js +1 -1
- package/dist/entry/azure.d.ts +3 -3
- package/dist/entry/azure.js +3 -3
- package/dist/entry/lambda.d.ts +3 -3
- package/dist/entry/lambda.js +3 -3
- package/dist/entry/node.d.ts +3 -3
- package/dist/entry/node.js +3 -3
- package/dist/index.d.ts +22 -15
- package/dist/index.js +7 -5
- package/dist/{schema-DTeiSuQB.d.ts → schema-BTgDj1Xn.d.ts} +43 -4
- package/dist/sqlite.js +2 -2
- package/dist/standalone/azure.mjs +4373 -424
- package/dist/standalone/lambda.mjs +4372 -423
- package/dist/standalone/main.mjs +4310 -361
- 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 +19 -0
- package/dist/{user-BZs84sui.d.ts → user-yoGjrw4k.d.ts} +2 -2
- package/dist/verify.d.ts +1 -1
- package/dist/{webhook-B6P50ocX.d.ts → webhook-DlmfG_kS.d.ts} +5 -2
- package/dist/webhook.d.ts +1 -1
- package/dist/webhook.js +1 -1
- package/package.json +3 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/store/schema.ts
|
|
2
2
|
var madauthSchema = {
|
|
3
|
-
version:
|
|
3
|
+
version: 7,
|
|
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: {
|
|
@@ -11,17 +11,27 @@ var madauthSchema = {
|
|
|
11
11
|
emailVerified: { type: "boolean" },
|
|
12
12
|
name: { type: "string", nullable: true },
|
|
13
13
|
sessionVersion: { type: "number" },
|
|
14
|
+
/** When the last e-mail was sent to the user: at most one per minute. */
|
|
14
15
|
lastMailAt: { type: "number" },
|
|
16
|
+
/** When the last SMS was sent to the user: at most one per minute, counted apart from the e-mails (since version 6). */
|
|
17
|
+
lastSmsAt: { type: "number", nullable: true },
|
|
15
18
|
/** Wrong e-mail codes in a row, counted across e-mails (since version 2). */
|
|
16
19
|
wrongCodes: { type: "number" },
|
|
17
20
|
/** What admins attached to the user, as JSON, e.g. `{"roles":["admin"]}` (since version 4). */
|
|
18
21
|
claims: { type: "string", nullable: true },
|
|
22
|
+
/**
|
|
23
|
+
* When the user's first session started: the sign-up was complete, with every second factor the
|
|
24
|
+
* policy asked for. 0 until then. Empty in records from before version 7, whose sign-up counts as
|
|
25
|
+
* complete (since version 7).
|
|
26
|
+
*/
|
|
27
|
+
signedUpAt: { type: "number", nullable: true },
|
|
19
28
|
createdAt: { type: "number" }
|
|
20
29
|
}
|
|
21
30
|
},
|
|
22
31
|
/**
|
|
23
|
-
* How a user signs in. `key` is `password:<userId>`, `google:<sub
|
|
24
|
-
* app, whose `secret` is encrypted)
|
|
32
|
+
* How a user signs in. `key` is `password:<userId>`, `google:<sub>`, `totp:<userId>` (the authenticator
|
|
33
|
+
* app, whose `secret` is encrypted), `email-link:<userId>` or `sms:<userId>` (the phone that gets the
|
|
34
|
+
* codes of the second factor).
|
|
25
35
|
*/
|
|
26
36
|
account: {
|
|
27
37
|
fields: {
|
|
@@ -40,6 +50,19 @@ var madauthSchema = {
|
|
|
40
50
|
* its codes always belong together. Empty for other accounts (since version 5).
|
|
41
51
|
*/
|
|
42
52
|
setupId: { type: "string", nullable: true },
|
|
53
|
+
/** The phone number of an `sms:` account, in E.164 form (`+41791234567`). Empty for other accounts (since version 6). */
|
|
54
|
+
phone: { type: "string", nullable: true },
|
|
55
|
+
/**
|
|
56
|
+
* The user's id when the account is how they sign in (their one primary method), empty for a second
|
|
57
|
+
* factor. Unique, so a user has one primary method at most (since version 7; empty in older records).
|
|
58
|
+
*/
|
|
59
|
+
primaryUserId: { type: "string", nullable: true, unique: true },
|
|
60
|
+
/**
|
|
61
|
+
* Whether the account is a second factor (an authenticator app that is a step of the user's sign-in
|
|
62
|
+
* method, or a phone for SMS codes) rather than a sign-in method. Empty in records from before version 7,
|
|
63
|
+
* which madAuth tells apart by their key (since version 7).
|
|
64
|
+
*/
|
|
65
|
+
secondFactor: { type: "boolean", nullable: true },
|
|
43
66
|
createdAt: { type: "number" }
|
|
44
67
|
}
|
|
45
68
|
},
|
|
@@ -55,7 +78,7 @@ var madauthSchema = {
|
|
|
55
78
|
createdAt: { type: "number" }
|
|
56
79
|
}
|
|
57
80
|
},
|
|
58
|
-
/** A pending e-mail verification or
|
|
81
|
+
/** A pending e-mail verification, password reset, sign-in link or SMS code. `id` is the SHA-256 of the link token. */
|
|
59
82
|
verification: {
|
|
60
83
|
fields: {
|
|
61
84
|
id: { type: "string", primaryKey: true },
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
WEBHOOK_TYPES,
|
|
9
9
|
checkWebhookSecret,
|
|
10
10
|
generateWebhookSecret
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-SISM7YUZ.js";
|
|
12
12
|
|
|
13
13
|
// src/claims.ts
|
|
14
14
|
var ADMIN_ROLE = "admin";
|
|
@@ -69,10 +69,23 @@ function isAdmin(user) {
|
|
|
69
69
|
|
|
70
70
|
// src/config.ts
|
|
71
71
|
function sendsVerificationEmails(config) {
|
|
72
|
-
return !!config.webhook?.events.has("email.
|
|
72
|
+
return !!config.webhook?.events.has("email.send_verification");
|
|
73
|
+
}
|
|
74
|
+
function sendsSignInEmails(config) {
|
|
75
|
+
return !!config.webhook?.events.has("email.send_signin_link");
|
|
76
|
+
}
|
|
77
|
+
function sendsSms(config) {
|
|
78
|
+
return !!config.webhook?.events.has("sms.send_code");
|
|
73
79
|
}
|
|
74
80
|
function methodConfig(config) {
|
|
75
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
google: config.google,
|
|
83
|
+
github: config.github,
|
|
84
|
+
password: config.password,
|
|
85
|
+
emailVerification: sendsVerificationEmails(config),
|
|
86
|
+
emailLink: sendsSignInEmails(config),
|
|
87
|
+
sms: sendsSms(config)
|
|
88
|
+
};
|
|
76
89
|
}
|
|
77
90
|
var envVars = {
|
|
78
91
|
MADAUTH_ISSUER: true,
|
|
@@ -80,8 +93,9 @@ var envVars = {
|
|
|
80
93
|
ALLOWED_ORIGINS: true,
|
|
81
94
|
GOOGLE_CLIENT_ID: false,
|
|
82
95
|
GOOGLE_CLIENT_SECRET: false,
|
|
96
|
+
GITHUB_CLIENT_ID: false,
|
|
97
|
+
GITHUB_CLIENT_SECRET: false,
|
|
83
98
|
DATABASE_URL: true,
|
|
84
|
-
PASSWORD_MIN_LENGTH: false,
|
|
85
99
|
TOTP_ISSUER: false,
|
|
86
100
|
WEBHOOK_URL: false,
|
|
87
101
|
WEBHOOK_SECRET: false,
|
|
@@ -95,7 +109,6 @@ async function resolveEnv(source = process.env) {
|
|
|
95
109
|
}
|
|
96
110
|
var DEFAULT_SESSION_TTL = 8 * 60 * 60;
|
|
97
111
|
var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
|
|
98
|
-
var DEFAULT_PASSWORD_MIN_LENGTH = 8;
|
|
99
112
|
var ConfigError = class extends Error {
|
|
100
113
|
constructor() {
|
|
101
114
|
super(...arguments);
|
|
@@ -124,6 +137,11 @@ async function loadConfig(env, overrides = {}) {
|
|
|
124
137
|
if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
|
|
125
138
|
return value;
|
|
126
139
|
};
|
|
140
|
+
if (env.PASSWORD_MIN_LENGTH?.trim()) {
|
|
141
|
+
throw new ConfigError(
|
|
142
|
+
`PASSWORD_MIN_LENGTH was removed: the password rules are a setting now (default: at least 8 characters). Remove the variable and set the rules with \`npx @madauth/server set-password-rules min-length=${env.PASSWORD_MIN_LENGTH.trim()}\` or Madauth.admin.setSettings. See docs/upgrading.md.`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
127
145
|
const issuerValue = require2("MADAUTH_ISSUER");
|
|
128
146
|
parseOrigin("MADAUTH_ISSUER", issuerValue);
|
|
129
147
|
const issuer = issuerValue.replace(/\/+$/, "");
|
|
@@ -150,13 +168,21 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
|
150
168
|
if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
|
|
151
169
|
throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
|
|
152
170
|
}
|
|
171
|
+
const githubClientId = read("GITHUB_CLIENT_ID");
|
|
172
|
+
const githubClientSecret = read("GITHUB_CLIENT_SECRET");
|
|
173
|
+
if (githubClientId && !githubClientSecret) {
|
|
174
|
+
throw new ConfigError(
|
|
175
|
+
"GITHUB_CLIENT_ID needs GITHUB_CLIENT_SECRET: GitHub only has the redirect flow, in which the server exchanges the code. Set both from your OAuth App at https://github.com/settings/developers."
|
|
176
|
+
);
|
|
177
|
+
}
|
|
153
178
|
const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
|
|
154
179
|
const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
|
|
155
|
-
const password = sendsEmails ? {
|
|
156
|
-
const emailVerification = !!webhook && webhook.events.has("email.
|
|
157
|
-
|
|
180
|
+
const password = sendsEmails ? {} : void 0;
|
|
181
|
+
const emailVerification = !!webhook && webhook.events.has("email.send_verification");
|
|
182
|
+
const emailLink = !!webhook && webhook.events.has("email.send_signin_link");
|
|
183
|
+
if (!clientId && !githubClientId && !password && !emailVerification && !emailLink) {
|
|
158
184
|
throw new ConfigError(
|
|
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.
|
|
185
|
+
`No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET for GitHub sign-in, and/or a webhook that sends the e-mails: e-mail & password sign-in needs email.send_verification and email.send_password_reset, the passwordless e-mail link needs email.send_signin_link, sign-up with the authenticator app needs email.send_verification. 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.send_signin_link,email.send_already_registered,email.send_password_reset_no_password. See docs/server.md.`
|
|
160
186
|
);
|
|
161
187
|
}
|
|
162
188
|
const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
|
|
@@ -186,26 +212,17 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
|
186
212
|
cookieDomain: read("COOKIE_DOMAIN"),
|
|
187
213
|
totpIssuer: read("TOTP_ISSUER"),
|
|
188
214
|
google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
|
|
215
|
+
github: githubClientId && githubClientSecret ? { clientId: githubClientId, clientSecret: githubClientSecret } : void 0,
|
|
189
216
|
store,
|
|
190
217
|
password,
|
|
191
218
|
webhook
|
|
192
219
|
};
|
|
193
220
|
}
|
|
194
|
-
async function loadUserStoreConfig(env) {
|
|
195
|
-
return { store: await loadStore(env), minLength: passwordMinLength(readEnv(env, "PASSWORD_MIN_LENGTH")) };
|
|
196
|
-
}
|
|
197
221
|
async function loadStore(env) {
|
|
198
222
|
const store = await storeFromDatabaseUrl(readEnv(env, "DATABASE_URL"));
|
|
199
223
|
if (!store) throw new ConfigError("Set DATABASE_URL to manage users.");
|
|
200
224
|
return store;
|
|
201
225
|
}
|
|
202
|
-
function passwordMinLength(value) {
|
|
203
|
-
const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
|
|
204
|
-
if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
|
|
205
|
-
throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
|
|
206
|
-
}
|
|
207
|
-
return minLength;
|
|
208
|
-
}
|
|
209
226
|
async function storeFromDatabaseUrl(url) {
|
|
210
227
|
if (!url) return void 0;
|
|
211
228
|
if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
|
|
@@ -240,7 +257,27 @@ async function dynamoDbStore(tableName) {
|
|
|
240
257
|
const { createDynamoDbAdapter } = await import("./dynamodb.js");
|
|
241
258
|
return createDynamoDbAdapter({ tableName });
|
|
242
259
|
}
|
|
243
|
-
var RENAMED_TYPES = {
|
|
260
|
+
var RENAMED_TYPES = {
|
|
261
|
+
"password.reset": "user.password_reset",
|
|
262
|
+
"roles.changed": "user.claims_changed",
|
|
263
|
+
"email.verify": "email.send_verification",
|
|
264
|
+
"email.reset": "email.send_password_reset",
|
|
265
|
+
"email.signin": "email.send_signin_link",
|
|
266
|
+
"email.already_registered": "email.send_already_registered",
|
|
267
|
+
"email.no_password": "email.send_password_reset_no_password",
|
|
268
|
+
"sms.code": "sms.send_code",
|
|
269
|
+
"signup.before": "user.signing_up",
|
|
270
|
+
"email.verified": "user.email_verified",
|
|
271
|
+
"email.password_reset": "user.password_reset",
|
|
272
|
+
"totp.enabled": 'user.second_factor_enabled (with method "totp")',
|
|
273
|
+
"totp.disabled": 'user.second_factor_disabled (with method "totp")',
|
|
274
|
+
"sms.enabled": 'user.second_factor_enabled (with method "sms")',
|
|
275
|
+
"sms.disabled": 'user.second_factor_disabled (with method "sms")',
|
|
276
|
+
"totp.recovery_code_used": "user.recovery_code_used"
|
|
277
|
+
};
|
|
278
|
+
var REMOVED_TYPES = {
|
|
279
|
+
"user.created": "user.created is gone: user.signed_up tells when a user can sign in (an unconfirmed sign-up is no user yet)"
|
|
280
|
+
};
|
|
244
281
|
var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
|
|
245
282
|
function webhookFromEnv(url, secret, events) {
|
|
246
283
|
if (!url) {
|
|
@@ -272,11 +309,11 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
|
|
|
272
309
|
const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
|
|
273
310
|
if (!selected.size) {
|
|
274
311
|
throw new ConfigError(
|
|
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:
|
|
312
|
+
`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 ")}, the passwordless e-mail link when they include email.send_signin_link. For a receiver that sends the e-mails:
|
|
276
313
|
|
|
277
|
-
WEBHOOK_EVENTS=email.
|
|
314
|
+
WEBHOOK_EVENTS=email.send_verification,email.send_password_reset,email.send_signin_link,email.send_already_registered,email.send_password_reset_no_password
|
|
278
315
|
|
|
279
|
-
For one that only wants to know who signed in:
|
|
316
|
+
SMS codes as a second factor need a receiver that sends the SMS: add sms.send_code,user.second_factor_enabled,user.second_factor_disabled. For one that only wants to know who signed in:
|
|
280
317
|
|
|
281
318
|
WEBHOOK_EVENTS=user.signed_in
|
|
282
319
|
|
|
@@ -285,7 +322,7 @@ WEBHOOK_EVENTS=user.signed_in
|
|
|
285
322
|
}
|
|
286
323
|
const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
|
|
287
324
|
if (unknown.length) {
|
|
288
|
-
const renamed = unknown.
|
|
325
|
+
const renamed = unknown.flatMap((e) => e in RENAMED_TYPES ? [`${e} is now ${RENAMED_TYPES[e]}`] : e in REMOVED_TYPES ? [REMOVED_TYPES[e]] : []);
|
|
289
326
|
throw new ConfigError(
|
|
290
327
|
`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
|
|
291
328
|
);
|
|
@@ -294,17 +331,31 @@ WEBHOOK_EVENTS=user.signed_in
|
|
|
294
331
|
}
|
|
295
332
|
|
|
296
333
|
// src/settings.ts
|
|
297
|
-
var SIGN_IN_METHODS = ["google", "password", "totp"];
|
|
298
|
-
var
|
|
334
|
+
var SIGN_IN_METHODS = ["google", "github", "password", "totp", "email-link"];
|
|
335
|
+
var METHODS = [...SIGN_IN_METHODS, "sms"];
|
|
336
|
+
var METHODS_WITH_SECOND_FACTOR = ["google", "github", "password", "email-link"];
|
|
299
337
|
var SECOND_FACTOR_POLICIES = ["none", "optional", "required"];
|
|
300
338
|
var METHODS_SETTING = "methods";
|
|
301
|
-
var
|
|
339
|
+
var PASSWORD_SETTING = "password";
|
|
340
|
+
var DEFAULT_PASSWORD_RULES = { minLength: 8, minScore: 2 };
|
|
341
|
+
var MAX_MIN_LENGTH = 128;
|
|
342
|
+
var isPasswordScore = (value) => Number.isInteger(value) && value >= 0 && value <= 4;
|
|
343
|
+
function parsePasswordRules(input) {
|
|
344
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
|
|
345
|
+
const { minLength = DEFAULT_PASSWORD_RULES.minLength, minScore = DEFAULT_PASSWORD_RULES.minScore, ...rest } = input;
|
|
346
|
+
if (Object.keys(rest).length) return null;
|
|
347
|
+
if (!Number.isInteger(minLength) || minLength < 1 || minLength > MAX_MIN_LENGTH) return null;
|
|
348
|
+
if (!isPasswordScore(minScore)) return null;
|
|
349
|
+
return { minLength, minScore };
|
|
350
|
+
}
|
|
351
|
+
var isMethodId = (value) => METHODS.includes(value);
|
|
352
|
+
var hasSecondFactor = (method) => METHODS_WITH_SECOND_FACTOR.includes(method);
|
|
302
353
|
var isPolicy = (value) => SECOND_FACTOR_POLICIES.includes(value);
|
|
303
354
|
function parseMethodSettings(input, options = {}) {
|
|
304
355
|
if (!input || typeof input !== "object" || Array.isArray(input)) return null;
|
|
305
356
|
const settings = {};
|
|
306
357
|
for (const [method, value] of Object.entries(input)) {
|
|
307
|
-
if (!
|
|
358
|
+
if (!isMethodId(method)) return null;
|
|
308
359
|
if (typeof value === "boolean") {
|
|
309
360
|
if (!options.booleans) return null;
|
|
310
361
|
if (value) settings[method] = {};
|
|
@@ -313,7 +364,7 @@ function parseMethodSettings(input, options = {}) {
|
|
|
313
364
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
314
365
|
const setting = {};
|
|
315
366
|
for (const [field, fieldValue] of Object.entries(value)) {
|
|
316
|
-
if (field !== "secondFactor" || method
|
|
367
|
+
if (field !== "secondFactor" || !hasSecondFactor(method) || !isPolicy(fieldValue)) return null;
|
|
317
368
|
setting.secondFactor = fieldValue;
|
|
318
369
|
}
|
|
319
370
|
settings[method] = setting;
|
|
@@ -322,7 +373,10 @@ function parseMethodSettings(input, options = {}) {
|
|
|
322
373
|
}
|
|
323
374
|
function isConfigured(config, method) {
|
|
324
375
|
if (method === "google") return !!config.google;
|
|
376
|
+
if (method === "github") return !!config.github;
|
|
325
377
|
if (method === "password") return !!config.password;
|
|
378
|
+
if (method === "email-link") return !!config.emailLink;
|
|
379
|
+
if (method === "sms") return !!config.sms;
|
|
326
380
|
return true;
|
|
327
381
|
}
|
|
328
382
|
function canSignUp(config, method) {
|
|
@@ -331,8 +385,11 @@ function canSignUp(config, method) {
|
|
|
331
385
|
function initialMethods(config) {
|
|
332
386
|
const methods = {};
|
|
333
387
|
if (config.google) methods.google = { secondFactor: "none" };
|
|
388
|
+
if (config.github) methods.github = { secondFactor: "none" };
|
|
334
389
|
if (config.password) methods.password = { secondFactor: "none" };
|
|
335
|
-
if (
|
|
390
|
+
if (config.emailLink) methods["email-link"] = { secondFactor: "none" };
|
|
391
|
+
if (!config.google && !config.github && !config.password && !config.emailLink && config.emailVerification) methods.totp = {};
|
|
392
|
+
if (config.sms) methods.sms = {};
|
|
336
393
|
return methods;
|
|
337
394
|
}
|
|
338
395
|
var MAX_TRIES = 3;
|
|
@@ -342,6 +399,7 @@ var Settings = class {
|
|
|
342
399
|
this.store = store;
|
|
343
400
|
this.initial = initial;
|
|
344
401
|
this.warned = false;
|
|
402
|
+
this.warnedRules = false;
|
|
345
403
|
}
|
|
346
404
|
/** The setting's value, or undefined if it was never set. */
|
|
347
405
|
async get(id) {
|
|
@@ -386,6 +444,23 @@ var Settings = class {
|
|
|
386
444
|
async changeMethods(apply, updatedBy) {
|
|
387
445
|
return this.change(METHODS_SETTING, (current) => apply(this.current(current)), updatedBy);
|
|
388
446
|
}
|
|
447
|
+
/** The rules for new passwords; the defaults until an admin set them. */
|
|
448
|
+
async passwordRules() {
|
|
449
|
+
const stored = await this.get(PASSWORD_SETTING);
|
|
450
|
+
if (stored === void 0) return DEFAULT_PASSWORD_RULES;
|
|
451
|
+
const parsed = parsePasswordRules(stored);
|
|
452
|
+
if (parsed) return parsed;
|
|
453
|
+
if (!this.warnedRules) {
|
|
454
|
+
this.warnedRules = true;
|
|
455
|
+
console.warn("[madauth] The stored password rules are not in the form this version expects. Set them again with set-password-rules; until then the defaults count.");
|
|
456
|
+
}
|
|
457
|
+
return DEFAULT_PASSWORD_RULES;
|
|
458
|
+
}
|
|
459
|
+
/** Replaces the rules for new passwords. */
|
|
460
|
+
async setPasswordRules(rules, updatedBy) {
|
|
461
|
+
await this.set(PASSWORD_SETTING, rules, updatedBy);
|
|
462
|
+
return rules;
|
|
463
|
+
}
|
|
389
464
|
/** The stored methods, or the initial ones if nothing (usable) is stored. */
|
|
390
465
|
current(stored) {
|
|
391
466
|
if (stored === void 0) return this.initial;
|
|
@@ -414,6 +489,23 @@ function authenticatorOn(configured, methods) {
|
|
|
414
489
|
|
|
415
490
|
// src/password.ts
|
|
416
491
|
import { createHash, createHmac, hkdfSync, randomBytes, randomInt, scrypt, timingSafeEqual } from "crypto";
|
|
492
|
+
|
|
493
|
+
// src/strength.ts
|
|
494
|
+
var RATED_LENGTH = 64;
|
|
495
|
+
var loading;
|
|
496
|
+
async function passwordScore(password, userInputs = []) {
|
|
497
|
+
loading ??= Promise.all([import("@zxcvbn-ts/core"), import("@zxcvbn-ts/language-common")]).then(
|
|
498
|
+
([core, common]) => new core.ZxcvbnFactory({
|
|
499
|
+
dictionary: common.dictionary,
|
|
500
|
+
graphs: common.adjacencyGraphs,
|
|
501
|
+
useLevenshteinDistance: true,
|
|
502
|
+
maxLength: RATED_LENGTH
|
|
503
|
+
})
|
|
504
|
+
);
|
|
505
|
+
return (await loading).check(password, userInputs).score;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/password.ts
|
|
417
509
|
var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
|
|
418
510
|
var KEY_LENGTH = 64;
|
|
419
511
|
var SALT_LENGTH = 16;
|
|
@@ -453,11 +545,17 @@ async function verifyAgainstDummy(password) {
|
|
|
453
545
|
dummyHash ??= hashPassword(randomBytes(16).toString("hex"));
|
|
454
546
|
await verifyPassword(password, await dummyHash);
|
|
455
547
|
}
|
|
456
|
-
function checkPasswordPolicy(password,
|
|
457
|
-
if (typeof password !== "string") return "password is missing";
|
|
548
|
+
async function checkPasswordPolicy(password, rules, userInputs = []) {
|
|
549
|
+
if (typeof password !== "string") return { message: "password is missing" };
|
|
458
550
|
const length = [...password].length;
|
|
459
|
-
if (length
|
|
460
|
-
if (length
|
|
551
|
+
if (length > MAX_PASSWORD_LENGTH) return { message: `The password must have at most ${MAX_PASSWORD_LENGTH} characters.` };
|
|
552
|
+
if (length < rules.minLength) return { message: `The password must have at least ${rules.minLength} characters.`, minLength: rules.minLength };
|
|
553
|
+
if (rules.minScore > 0 && await passwordScore(password, userInputs) < rules.minScore) {
|
|
554
|
+
return {
|
|
555
|
+
message: "This password is too easy to guess. Add another word or two, or choose less common ones.",
|
|
556
|
+
minScore: rules.minScore
|
|
557
|
+
};
|
|
558
|
+
}
|
|
461
559
|
return null;
|
|
462
560
|
}
|
|
463
561
|
function normalizeEmail(email) {
|
|
@@ -498,9 +596,27 @@ function passwordAccountKey(userId) {
|
|
|
498
596
|
function googleAccountKey(sub) {
|
|
499
597
|
return `google:${sub}`;
|
|
500
598
|
}
|
|
599
|
+
function githubAccountKey(id) {
|
|
600
|
+
return `github:${id}`;
|
|
601
|
+
}
|
|
501
602
|
function totpAccountKey(userId) {
|
|
502
603
|
return `totp:${userId}`;
|
|
503
604
|
}
|
|
605
|
+
function emailLinkAccountKey(userId) {
|
|
606
|
+
return `email-link:${userId}`;
|
|
607
|
+
}
|
|
608
|
+
function smsAccountKey(userId) {
|
|
609
|
+
return `sms:${userId}`;
|
|
610
|
+
}
|
|
611
|
+
var methodOf = (account) => String(account.key).split(":")[0];
|
|
612
|
+
var ALWAYS_PRIMARY = ["password", "google", "github", "email-link"];
|
|
613
|
+
function isPrimary(account, accounts) {
|
|
614
|
+
if (account.primaryUserId) return true;
|
|
615
|
+
if (account.secondFactor === true) return false;
|
|
616
|
+
const method = methodOf(account);
|
|
617
|
+
if (method !== "totp") return ALWAYS_PRIMARY.includes(method);
|
|
618
|
+
return !accounts.some((other) => ALWAYS_PRIMARY.includes(methodOf(other)));
|
|
619
|
+
}
|
|
504
620
|
function toMadauthUser(user, profile = {}) {
|
|
505
621
|
const result = { id: user.id, email: user.email };
|
|
506
622
|
const name = user.name ?? profile.name;
|
|
@@ -533,19 +649,75 @@ var Users = class {
|
|
|
533
649
|
async totpAccount(userId) {
|
|
534
650
|
return this.findAccountByKey(totpAccountKey(userId));
|
|
535
651
|
}
|
|
536
|
-
/**
|
|
537
|
-
async
|
|
538
|
-
return
|
|
652
|
+
/** The user's e-mail link account, if they sign in that way. */
|
|
653
|
+
async emailLinkAccount(userId) {
|
|
654
|
+
return this.findAccountByKey(emailLinkAccountKey(userId));
|
|
655
|
+
}
|
|
656
|
+
/** The user's phone number for SMS codes, if they added one. */
|
|
657
|
+
async smsAccount(userId) {
|
|
658
|
+
return this.findAccountByKey(smsAccountKey(userId));
|
|
659
|
+
}
|
|
660
|
+
/** The user's accounts: their sign-in method and their second factors. */
|
|
661
|
+
async accounts(userId) {
|
|
662
|
+
return await this.store.findMany("account", { userId });
|
|
663
|
+
}
|
|
664
|
+
/** The account the user signs in with (they have one at most), or null; a sign-up with the authenticator app has none until it is set up. */
|
|
665
|
+
async primaryAccount(userId) {
|
|
666
|
+
const accounts = await this.accounts(userId);
|
|
667
|
+
return accounts.find((account) => isPrimary(account, accounts)) ?? null;
|
|
539
668
|
}
|
|
540
669
|
/**
|
|
541
|
-
* The
|
|
542
|
-
*
|
|
543
|
-
|
|
670
|
+
* The user's authenticator app, if they set one up, and whether it is how they sign in (they signed up with
|
|
671
|
+
* it) as opposed to a second factor. One read: the app and the user's other accounts together.
|
|
672
|
+
*/
|
|
673
|
+
async totpApp(userId) {
|
|
674
|
+
const accounts = await this.accounts(userId);
|
|
675
|
+
const account = accounts.find((a) => a.key === totpAccountKey(userId));
|
|
676
|
+
return account ? { account, signsIn: isPrimary(account, accounts) } : null;
|
|
677
|
+
}
|
|
678
|
+
/** Whether someone can sign in as the user: they have a sign-in method. */
|
|
679
|
+
async hasSignInMethod(userId) {
|
|
680
|
+
return await this.primaryAccount(userId) !== null;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* How the user signs in, e.g. `['google']`; empty while nobody can. A user has one sign-in method (only
|
|
684
|
+
* users from before madAuth 0.4 may have a password and Google); a second factor (the authenticator app of
|
|
685
|
+
* a password user, SMS) is a step of it, not a way in. The app of a user who signed up with it counts only
|
|
686
|
+
* while it signs in on its own (`methods` lists it).
|
|
544
687
|
*/
|
|
545
688
|
async signInMethods(userId, methods) {
|
|
689
|
+
const accounts = await this.accounts(userId);
|
|
690
|
+
const signIn = accounts.filter((account) => isPrimary(account, accounts)).map(methodOf);
|
|
691
|
+
return SIGN_IN_METHODS.filter((m) => signIn.includes(m) && (m !== "totp" || methods.totp !== void 0));
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Notes that the user's first session started: their sign-up is complete. Resolves to true only for the
|
|
695
|
+
* request that wrote it, so `user.signed_up` is sent once; false for a later session, and for a user from
|
|
696
|
+
* before schema version 7.
|
|
697
|
+
*/
|
|
698
|
+
async markSignedUp(userId) {
|
|
699
|
+
return await this.store.update("user", { id: userId, signedUpAt: 0 }, { signedUpAt: Date.now() }) === 1;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* For a user whose sign-up is not complete (no confirmed address, or no sign-in method) and who is about to
|
|
703
|
+
* finish it or sign up again: one from before schema version 7 says nothing (`signedUpAt` empty, which
|
|
704
|
+
* counts as complete), so it is set to 0 and `user.signed_up` comes at their first session like for anyone.
|
|
705
|
+
*/
|
|
706
|
+
async reopenSignUp(user) {
|
|
707
|
+
if (user.signedUpAt !== null && user.signedUpAt !== void 0) return user;
|
|
708
|
+
await this.store.update("user", { id: user.id, signedUpAt: null }, { signedUpAt: 0 });
|
|
709
|
+
return { ...user, signedUpAt: 0 };
|
|
710
|
+
}
|
|
711
|
+
/** The `sub` of the user's linked Google account, if they have one. */
|
|
712
|
+
async googleSub(userId) {
|
|
546
713
|
const accounts = await this.store.findMany("account", { userId });
|
|
547
|
-
const
|
|
548
|
-
return
|
|
714
|
+
const key = accounts.map((account) => String(account.key)).find((k) => k.startsWith("google:"));
|
|
715
|
+
return key ? key.slice("google:".length) : null;
|
|
716
|
+
}
|
|
717
|
+
/** The user's GitHub account, if linked: its key holds GitHub's user id, its secret the encrypted access token of the last sign-in. */
|
|
718
|
+
async githubAccount(userId) {
|
|
719
|
+
const accounts = await this.accounts(userId);
|
|
720
|
+
return accounts.find((account) => account.key.startsWith("github:")) ?? null;
|
|
549
721
|
}
|
|
550
722
|
async findAccountByKey(key) {
|
|
551
723
|
return await this.store.findOne("account", { key });
|
|
@@ -565,8 +737,10 @@ var Users = class {
|
|
|
565
737
|
name: data.name,
|
|
566
738
|
sessionVersion: 0,
|
|
567
739
|
lastMailAt: 0,
|
|
740
|
+
lastSmsAt: 0,
|
|
568
741
|
wrongCodes: 0,
|
|
569
742
|
claims: null,
|
|
743
|
+
signedUpAt: 0,
|
|
570
744
|
createdAt: Date.now()
|
|
571
745
|
};
|
|
572
746
|
if (!await this.store.create("user", user)) return null;
|
|
@@ -579,12 +753,24 @@ var Users = class {
|
|
|
579
753
|
return user;
|
|
580
754
|
}
|
|
581
755
|
/**
|
|
582
|
-
*
|
|
583
|
-
*
|
|
756
|
+
* Removes what an unconfirmed sign-up set without proving it: the password, and the e-mail link account of
|
|
757
|
+
* a first link nobody used. Only `except`'s own account stays: the method that proves the address, or
|
|
758
|
+
* signs it up anew, is the one the user ends up with.
|
|
584
759
|
*/
|
|
585
|
-
async
|
|
760
|
+
async dropUnprovenAccounts(userId, except) {
|
|
761
|
+
const keys = { password: passwordAccountKey(userId), "email-link": emailLinkAccountKey(userId) };
|
|
762
|
+
for (const [method, key] of Object.entries(keys)) {
|
|
763
|
+
if (method !== except) await this.store.delete("account", { key });
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* A provider verified the user's address: an unconfirmed sign-up with it, whose password or e-mail link
|
|
768
|
+
* nobody has proven, is dropped (its accounts, except `proven`'s own, and its pending links and codes),
|
|
769
|
+
* and the user counts as verified.
|
|
770
|
+
*/
|
|
771
|
+
async verifyByProvider(user, proven) {
|
|
586
772
|
if (user.emailVerified) return;
|
|
587
|
-
await this.
|
|
773
|
+
await this.dropUnprovenAccounts(user.id, proven);
|
|
588
774
|
await this.clearVerifications(user.id);
|
|
589
775
|
await this.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
590
776
|
}
|
|
@@ -602,8 +788,16 @@ var Users = class {
|
|
|
602
788
|
await this.clearRecoveryCodes(userId);
|
|
603
789
|
return removed;
|
|
604
790
|
}
|
|
605
|
-
/**
|
|
791
|
+
/** Removes the user's phone number for SMS codes. Resolves to false if they had none. */
|
|
792
|
+
async removeSms(userId) {
|
|
793
|
+
return this.unlinkAccount(smsAccountKey(userId));
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Adds a sign-in method or a second factor to a user. Resolves to null if the account key is taken, or if
|
|
797
|
+
* it is a sign-in method and the user has one already.
|
|
798
|
+
*/
|
|
606
799
|
async linkAccount(userId, account) {
|
|
800
|
+
const primary = account.primary ?? ALWAYS_PRIMARY.includes(methodOf(account));
|
|
607
801
|
const record = {
|
|
608
802
|
id: newId("acc"),
|
|
609
803
|
userId,
|
|
@@ -614,6 +808,9 @@ var Users = class {
|
|
|
614
808
|
email: account.email ?? null,
|
|
615
809
|
lastUsedStep: account.lastUsedStep ?? 0,
|
|
616
810
|
setupId: account.setupId ?? null,
|
|
811
|
+
phone: account.phone ?? null,
|
|
812
|
+
primaryUserId: primary ? userId : null,
|
|
813
|
+
secondFactor: !primary,
|
|
617
814
|
createdAt: Date.now()
|
|
618
815
|
};
|
|
619
816
|
return await this.store.create("account", record) ? record : null;
|
|
@@ -712,7 +909,7 @@ var Users = class {
|
|
|
712
909
|
await this.store.delete("verification", { userId });
|
|
713
910
|
}
|
|
714
911
|
/**
|
|
715
|
-
* Starts a verification or
|
|
912
|
+
* Starts a verification, password reset or e-mail link sign-in: replaces the user's earlier one for the same purpose and
|
|
716
913
|
* returns the link token and the code for the e-mail. Only their hashes are stored.
|
|
717
914
|
*/
|
|
718
915
|
async issueVerification(userId, purpose, ttlMs) {
|
|
@@ -743,22 +940,30 @@ var Users = class {
|
|
|
743
940
|
}
|
|
744
941
|
/**
|
|
745
942
|
* Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
|
|
746
|
-
* MAX_WRONG_CODES wrong codes in a row the user's codes stop working until an e-mail link is used.
|
|
943
|
+
* MAX_WRONG_CODES wrong e-mail codes in a row the user's codes stop working until an e-mail link is used.
|
|
944
|
+
* SMS codes are not counted that way: that lock is only lifted by an e-mail link or a password reset, which
|
|
945
|
+
* a Google user with a phone never has. Their record's attempts, one SMS per minute and the lock on the
|
|
946
|
+
* sms account (routes/lockout.ts) are their limits.
|
|
747
947
|
*/
|
|
748
948
|
async consumeCode(user, purpose, code) {
|
|
749
|
-
|
|
949
|
+
const counted = purpose !== "sms";
|
|
950
|
+
if (counted && (user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
750
951
|
const record = await this.store.findOne("verification", { userId: user.id, purpose });
|
|
751
952
|
if (!record) return { userId: null, locked: false };
|
|
752
953
|
const attempts = record.attempts + 1;
|
|
753
954
|
if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
|
|
754
955
|
return { userId: null, locked: false };
|
|
755
956
|
}
|
|
756
|
-
|
|
757
|
-
if (
|
|
758
|
-
|
|
957
|
+
let wrongCodes = 0;
|
|
958
|
+
if (counted) {
|
|
959
|
+
const count = await this.countWrongCode(user);
|
|
960
|
+
if (count === null) return { userId: null, locked: false };
|
|
961
|
+
if (count > MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
962
|
+
wrongCodes = count;
|
|
963
|
+
}
|
|
759
964
|
if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
|
|
760
965
|
if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
|
|
761
|
-
return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
|
|
966
|
+
return { userId: null, locked: counted && wrongCodes >= MAX_WRONG_CODES };
|
|
762
967
|
}
|
|
763
968
|
return { userId: await this.consume(record), locked: false };
|
|
764
969
|
}
|
|
@@ -794,7 +999,6 @@ export {
|
|
|
794
999
|
resolveEnv,
|
|
795
1000
|
ConfigError,
|
|
796
1001
|
loadConfig,
|
|
797
|
-
loadUserStoreConfig,
|
|
798
1002
|
loadStore,
|
|
799
1003
|
MAX_PASSWORD_LENGTH,
|
|
800
1004
|
hashPassword,
|
|
@@ -807,8 +1011,14 @@ export {
|
|
|
807
1011
|
hashCode,
|
|
808
1012
|
safeEqual,
|
|
809
1013
|
SIGN_IN_METHODS,
|
|
1014
|
+
METHODS,
|
|
810
1015
|
METHODS_WITH_SECOND_FACTOR,
|
|
811
1016
|
SECOND_FACTOR_POLICIES,
|
|
1017
|
+
DEFAULT_PASSWORD_RULES,
|
|
1018
|
+
MAX_MIN_LENGTH,
|
|
1019
|
+
isPasswordScore,
|
|
1020
|
+
parsePasswordRules,
|
|
1021
|
+
hasSecondFactor,
|
|
812
1022
|
parseMethodSettings,
|
|
813
1023
|
isConfigured,
|
|
814
1024
|
canSignUp,
|
|
@@ -821,7 +1031,10 @@ export {
|
|
|
821
1031
|
newId,
|
|
822
1032
|
passwordAccountKey,
|
|
823
1033
|
googleAccountKey,
|
|
1034
|
+
githubAccountKey,
|
|
824
1035
|
totpAccountKey,
|
|
1036
|
+
emailLinkAccountKey,
|
|
1037
|
+
smsAccountKey,
|
|
825
1038
|
toMadauthUser,
|
|
826
1039
|
Users
|
|
827
1040
|
};
|