@madauth/server 0.3.0-beta.2 → 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 +3 -2
- package/dist/bin.js +202 -50
- package/dist/chunk-7DHKDRGY.js +1706 -0
- package/dist/{chunk-DCDTO5LG.js → chunk-A7UYZRBL.js} +1 -0
- package/dist/chunk-NP75Y3IV.js +107 -0
- package/dist/chunk-NYIEFI4B.js +1040 -0
- package/dist/{chunk-56MOVFO4.js → chunk-O2URPWA6.js} +37 -4
- package/dist/{chunk-3QRKWLR7.js → chunk-SISM7YUZ.js} +15 -10
- package/dist/{config-Dg2fZuWR.d.ts → config-CcGlHVAN.d.ts} +19 -8
- 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 +37 -15
- package/dist/index.js +12 -6
- package/dist/{schema-3yhCc2jH.d.ts → schema-BTgDj1Xn.d.ts} +79 -3
- package/dist/sqlite.js +2 -2
- package/dist/standalone/azure.mjs +5511 -922
- package/dist/standalone/lambda.mjs +5461 -872
- package/dist/standalone/main.mjs +5487 -898
- 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 +21 -0
- package/dist/{user-BMWEWREH.d.ts → user-yoGjrw4k.d.ts} +5 -0
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +1 -1
- package/dist/{webhook-j5u9Kv9l.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 +4 -2
- package/dist/chunk-CD7Y4T66.js +0 -62
- package/dist/chunk-DVVBIFIV.js +0 -718
- package/dist/chunk-TI35AZ2U.js +0 -660
package/dist/chunk-TI35AZ2U.js
DELETED
|
@@ -1,660 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
assertPrivateSigningJwk,
|
|
3
|
-
generateSigningKey
|
|
4
|
-
} from "./chunk-JDQLSL36.js";
|
|
5
|
-
import {
|
|
6
|
-
DEV_WEBHOOK_RECEIVER_URL,
|
|
7
|
-
REQUIRED_EMAIL_TYPES,
|
|
8
|
-
WEBHOOK_TYPES,
|
|
9
|
-
checkWebhookSecret,
|
|
10
|
-
generateWebhookSecret
|
|
11
|
-
} from "./chunk-3QRKWLR7.js";
|
|
12
|
-
|
|
13
|
-
// src/claims.ts
|
|
14
|
-
var ADMIN_ROLE = "admin";
|
|
15
|
-
var MAX_ROLES = 20;
|
|
16
|
-
var MAX_CLAIMS_LENGTH = 2048;
|
|
17
|
-
var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
18
|
-
var CLAIM_NAME = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
|
|
19
|
-
function parseRoles(input) {
|
|
20
|
-
if (!Array.isArray(input)) return null;
|
|
21
|
-
if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
|
|
22
|
-
const roles = [...new Set(input)].sort();
|
|
23
|
-
return roles.length <= MAX_ROLES ? roles : null;
|
|
24
|
-
}
|
|
25
|
-
function parseClaims(input) {
|
|
26
|
-
if (!isJsonObject(input)) return null;
|
|
27
|
-
const claims = {};
|
|
28
|
-
for (const key of Object.keys(input).sort()) {
|
|
29
|
-
const value = input[key];
|
|
30
|
-
if (!CLAIM_NAME.test(key) || !isJsonValue(value)) return null;
|
|
31
|
-
if (key === "roles") {
|
|
32
|
-
const roles = parseRoles(value);
|
|
33
|
-
if (!roles) return null;
|
|
34
|
-
claims[key] = roles;
|
|
35
|
-
} else {
|
|
36
|
-
claims[key] = value;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return JSON.stringify(claims).length <= MAX_CLAIMS_LENGTH ? claims : null;
|
|
40
|
-
}
|
|
41
|
-
function isJsonObject(value) {
|
|
42
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
43
|
-
}
|
|
44
|
-
function isJsonValue(value) {
|
|
45
|
-
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
46
|
-
if (typeof value === "number") return Number.isFinite(value);
|
|
47
|
-
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
48
|
-
return isJsonObject(value) && Object.values(value).every(isJsonValue);
|
|
49
|
-
}
|
|
50
|
-
function sameClaims(a, b) {
|
|
51
|
-
return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
|
|
52
|
-
}
|
|
53
|
-
function claimsFromJson(json) {
|
|
54
|
-
if (!json) return {};
|
|
55
|
-
try {
|
|
56
|
-
const parsed = JSON.parse(json);
|
|
57
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
58
|
-
} catch {
|
|
59
|
-
return {};
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
function rolesOf(user) {
|
|
63
|
-
const roles = user.claims?.roles;
|
|
64
|
-
return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
|
|
65
|
-
}
|
|
66
|
-
function isAdmin(user) {
|
|
67
|
-
return rolesOf(user).includes(ADMIN_ROLE);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// src/settings.ts
|
|
71
|
-
var SIGN_IN_METHODS = ["google", "password"];
|
|
72
|
-
var METHODS_SETTING = "methods";
|
|
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;
|
|
133
|
-
}
|
|
134
|
-
function anyOn(available, methods) {
|
|
135
|
-
return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// src/config.ts
|
|
139
|
-
var envVars = {
|
|
140
|
-
MADAUTH_ISSUER: true,
|
|
141
|
-
MADAUTH_SIGNING_KEY: true,
|
|
142
|
-
ALLOWED_ORIGINS: true,
|
|
143
|
-
GOOGLE_CLIENT_ID: false,
|
|
144
|
-
GOOGLE_CLIENT_SECRET: false,
|
|
145
|
-
DATABASE_URL: true,
|
|
146
|
-
PASSWORD_MIN_LENGTH: false,
|
|
147
|
-
WEBHOOK_URL: false,
|
|
148
|
-
WEBHOOK_SECRET: false,
|
|
149
|
-
WEBHOOK_EVENTS: false,
|
|
150
|
-
SESSION_TTL: false,
|
|
151
|
-
SESSION_RENEWAL_TTL: false,
|
|
152
|
-
COOKIE_DOMAIN: false
|
|
153
|
-
};
|
|
154
|
-
async function resolveEnv(source = process.env) {
|
|
155
|
-
return typeof source === "function" ? source() : source;
|
|
156
|
-
}
|
|
157
|
-
var DEFAULT_SESSION_TTL = 8 * 60 * 60;
|
|
158
|
-
var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
|
|
159
|
-
var DEFAULT_PASSWORD_MIN_LENGTH = 8;
|
|
160
|
-
var ConfigError = class extends Error {
|
|
161
|
-
constructor() {
|
|
162
|
-
super(...arguments);
|
|
163
|
-
this.name = "ConfigError";
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
function parseOrigin(name, value) {
|
|
167
|
-
let url;
|
|
168
|
-
try {
|
|
169
|
-
url = new URL(value);
|
|
170
|
-
} catch {
|
|
171
|
-
throw new ConfigError(`${name}: "${value}" is not a URL.`);
|
|
172
|
-
}
|
|
173
|
-
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
174
|
-
throw new ConfigError(`${name}: "${value}" must be an http(s) URL.`);
|
|
175
|
-
}
|
|
176
|
-
return url.origin;
|
|
177
|
-
}
|
|
178
|
-
function readEnv(env, name) {
|
|
179
|
-
return env[name]?.trim().replace(/^(['"])(.*)\1$/s, "$2").trim() || void 0;
|
|
180
|
-
}
|
|
181
|
-
async function loadConfig(env, overrides = {}) {
|
|
182
|
-
const read = (name) => readEnv(env, name);
|
|
183
|
-
const require2 = (name) => {
|
|
184
|
-
const value = read(name);
|
|
185
|
-
if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
|
|
186
|
-
return value;
|
|
187
|
-
};
|
|
188
|
-
const issuerValue = require2("MADAUTH_ISSUER");
|
|
189
|
-
parseOrigin("MADAUTH_ISSUER", issuerValue);
|
|
190
|
-
const issuer = issuerValue.replace(/\/+$/, "");
|
|
191
|
-
const keyValue = read("MADAUTH_SIGNING_KEY");
|
|
192
|
-
if (!keyValue) {
|
|
193
|
-
const key = await generateSigningKey();
|
|
194
|
-
throw new ConfigError(
|
|
195
|
-
`MADAUTH_SIGNING_KEY is not set. Here is a newly generated key you can use (keep it secret and use the same key on every instance):
|
|
196
|
-
|
|
197
|
-
MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
198
|
-
`
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
let signingKey;
|
|
202
|
-
try {
|
|
203
|
-
signingKey = JSON.parse(keyValue);
|
|
204
|
-
assertPrivateSigningJwk(signingKey);
|
|
205
|
-
} catch (e) {
|
|
206
|
-
const reason = e instanceof SyntaxError ? "is not valid JSON" : e.message;
|
|
207
|
-
throw new ConfigError(`MADAUTH_SIGNING_KEY ${reason}. Generate one with: npx @madauth/server generate-key`);
|
|
208
|
-
}
|
|
209
|
-
const allowedOrigins = require2("ALLOWED_ORIGINS").split(",").map((o) => o.trim()).filter(Boolean).map((o) => parseOrigin("ALLOWED_ORIGINS", o));
|
|
210
|
-
const clientId = read("GOOGLE_CLIENT_ID");
|
|
211
|
-
if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
|
|
212
|
-
throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
|
|
213
|
-
}
|
|
214
|
-
const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
|
|
215
|
-
const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
|
|
216
|
-
const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
|
|
217
|
-
if (!clientId && !password) {
|
|
218
|
-
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 for e-mail & password sign-in: 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
|
-
);
|
|
221
|
-
}
|
|
222
|
-
const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
|
|
223
|
-
if (!store) {
|
|
224
|
-
throw new ConfigError(
|
|
225
|
-
"DATABASE_URL is not set. madAuth stores every user, whichever way they sign in: set e.g. DATABASE_URL=sqlite:./madauth.db or DATABASE_URL=dynamodb:<table>. See docs/server.md."
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
const ttlValue = read("SESSION_TTL");
|
|
229
|
-
const sessionTtlSeconds = ttlValue ? Number(ttlValue) : DEFAULT_SESSION_TTL;
|
|
230
|
-
if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
|
|
231
|
-
throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
|
|
232
|
-
}
|
|
233
|
-
const renewalValue = read("SESSION_RENEWAL_TTL");
|
|
234
|
-
const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
|
|
235
|
-
if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
|
|
236
|
-
throw new ConfigError(
|
|
237
|
-
`SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
return {
|
|
241
|
-
issuer,
|
|
242
|
-
signingKey,
|
|
243
|
-
allowedOrigins,
|
|
244
|
-
sessionTtlSeconds,
|
|
245
|
-
renewalTtlSeconds,
|
|
246
|
-
cookieDomain: read("COOKIE_DOMAIN"),
|
|
247
|
-
google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
|
|
248
|
-
store,
|
|
249
|
-
password,
|
|
250
|
-
webhook
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
async function loadUserStoreConfig(env) {
|
|
254
|
-
return { store: await loadStore(env), minLength: passwordMinLength(readEnv(env, "PASSWORD_MIN_LENGTH")) };
|
|
255
|
-
}
|
|
256
|
-
async function loadStore(env) {
|
|
257
|
-
const store = await storeFromDatabaseUrl(readEnv(env, "DATABASE_URL"));
|
|
258
|
-
if (!store) throw new ConfigError("Set DATABASE_URL to manage users.");
|
|
259
|
-
return store;
|
|
260
|
-
}
|
|
261
|
-
function passwordMinLength(value) {
|
|
262
|
-
const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
|
|
263
|
-
if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
|
|
264
|
-
throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
|
|
265
|
-
}
|
|
266
|
-
return minLength;
|
|
267
|
-
}
|
|
268
|
-
async function storeFromDatabaseUrl(url) {
|
|
269
|
-
if (!url) return void 0;
|
|
270
|
-
if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
|
|
271
|
-
if (!url.startsWith("sqlite:")) {
|
|
272
|
-
throw new ConfigError(
|
|
273
|
-
`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).`
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
const path = url.slice("sqlite:".length);
|
|
277
|
-
if (!path) throw new ConfigError("DATABASE_URL needs a file path, e.g. sqlite:/data/madauth.db");
|
|
278
|
-
const { createSqliteAdapter } = await import("./sqlite.js");
|
|
279
|
-
try {
|
|
280
|
-
return createSqliteAdapter(path);
|
|
281
|
-
} catch (e) {
|
|
282
|
-
throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
async function dynamoDbStore(tableName) {
|
|
286
|
-
if (!tableName) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
|
|
287
|
-
if (!/^[\w.-]{3,255}$/.test(tableName)) {
|
|
288
|
-
throw new ConfigError(
|
|
289
|
-
`DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName}"`
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
try {
|
|
293
|
-
await import("@aws-sdk/client-dynamodb");
|
|
294
|
-
} catch (e) {
|
|
295
|
-
throw new ConfigError(
|
|
296
|
-
`DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
|
-
const { createDynamoDbAdapter } = await import("./dynamodb.js");
|
|
300
|
-
return createDynamoDbAdapter({ tableName });
|
|
301
|
-
}
|
|
302
|
-
var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
|
|
303
|
-
var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
|
|
304
|
-
function webhookFromEnv(url, secret, events) {
|
|
305
|
-
if (!url) {
|
|
306
|
-
if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
|
|
307
|
-
return void 0;
|
|
308
|
-
}
|
|
309
|
-
let parsed;
|
|
310
|
-
try {
|
|
311
|
-
parsed = new URL(url);
|
|
312
|
-
} catch {
|
|
313
|
-
throw new ConfigError(`WEBHOOK_URL: "${url}" is not a URL.`);
|
|
314
|
-
}
|
|
315
|
-
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname))) {
|
|
316
|
-
throw new ConfigError(`WEBHOOK_URL must be an https URL (http only for localhost) but is "${url}".`);
|
|
317
|
-
}
|
|
318
|
-
if (!secret) {
|
|
319
|
-
throw new ConfigError(
|
|
320
|
-
`WEBHOOK_SECRET is not set. Here is a newly generated secret you can use (keep it secret; your webhook receiver needs the same one):
|
|
321
|
-
|
|
322
|
-
WEBHOOK_SECRET=${generateWebhookSecret()}
|
|
323
|
-
`
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
const problem = checkWebhookSecret(secret);
|
|
327
|
-
if (problem) {
|
|
328
|
-
throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
|
|
329
|
-
}
|
|
330
|
-
const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
|
|
331
|
-
const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
|
|
332
|
-
if (!selected.size) {
|
|
333
|
-
throw new ConfigError(
|
|
334
|
-
`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
|
-
|
|
336
|
-
WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
|
|
337
|
-
|
|
338
|
-
For one that only wants to know who signed in:
|
|
339
|
-
|
|
340
|
-
WEBHOOK_EVENTS=user.signed_in
|
|
341
|
-
|
|
342
|
-
` + allTypes
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
|
|
346
|
-
if (unknown.length) {
|
|
347
|
-
const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
|
|
348
|
-
throw new ConfigError(
|
|
349
|
-
`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
return { url: parsed.href, secret, events: selected };
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// src/password.ts
|
|
356
|
-
import { createHash, createHmac, hkdfSync, randomBytes, randomInt, scrypt, timingSafeEqual } from "crypto";
|
|
357
|
-
var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
|
|
358
|
-
var KEY_LENGTH = 64;
|
|
359
|
-
var SALT_LENGTH = 16;
|
|
360
|
-
var MAX_PASSWORD_LENGTH = 256;
|
|
361
|
-
var MAX_EMAIL_LENGTH = 254;
|
|
362
|
-
function derive(password, salt, { logN, r, p }) {
|
|
363
|
-
const N = 2 ** logN;
|
|
364
|
-
return new Promise(
|
|
365
|
-
(resolve, reject) => scrypt(
|
|
366
|
-
password.normalize("NFC"),
|
|
367
|
-
salt,
|
|
368
|
-
KEY_LENGTH,
|
|
369
|
-
{ N, r, p, maxmem: 256 * N * r + 1024 * 1024 },
|
|
370
|
-
(err, key) => err ? reject(err) : resolve(key)
|
|
371
|
-
)
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
async function hashPassword(password, params = HASH_PARAMS) {
|
|
375
|
-
const salt = randomBytes(SALT_LENGTH);
|
|
376
|
-
const key = await derive(password, salt, params);
|
|
377
|
-
return ["scrypt", params.logN, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
|
|
378
|
-
}
|
|
379
|
-
async function verifyPassword(password, stored) {
|
|
380
|
-
const [scheme, logN, r, p, salt, hash] = stored.split("$");
|
|
381
|
-
const params = { logN: Number(logN), r: Number(r), p: Number(p) };
|
|
382
|
-
if (scheme !== "scrypt" || !salt || !hash || !Object.values(params).every((n) => Number.isInteger(n) && n > 0)) {
|
|
383
|
-
return { ok: false, needsRehash: false };
|
|
384
|
-
}
|
|
385
|
-
const expected = Buffer.from(hash, "base64url");
|
|
386
|
-
const key = await derive(password, Buffer.from(salt, "base64url"), params);
|
|
387
|
-
const ok = key.length === expected.length && timingSafeEqual(key, expected);
|
|
388
|
-
const needsRehash = params.logN !== HASH_PARAMS.logN || params.r !== HASH_PARAMS.r || params.p !== HASH_PARAMS.p;
|
|
389
|
-
return { ok, needsRehash };
|
|
390
|
-
}
|
|
391
|
-
var dummyHash;
|
|
392
|
-
async function verifyAgainstDummy(password) {
|
|
393
|
-
dummyHash ??= hashPassword(randomBytes(16).toString("hex"));
|
|
394
|
-
await verifyPassword(password, await dummyHash);
|
|
395
|
-
}
|
|
396
|
-
function checkPasswordPolicy(password, minLength) {
|
|
397
|
-
if (typeof password !== "string") return "password is missing";
|
|
398
|
-
const length = [...password].length;
|
|
399
|
-
if (length < minLength) return `The password must have at least ${minLength} characters.`;
|
|
400
|
-
if (length > MAX_PASSWORD_LENGTH) return `The password must have at most ${MAX_PASSWORD_LENGTH} characters.`;
|
|
401
|
-
return null;
|
|
402
|
-
}
|
|
403
|
-
function normalizeEmail(email) {
|
|
404
|
-
return email.trim().toLowerCase();
|
|
405
|
-
}
|
|
406
|
-
function isValidEmail(email) {
|
|
407
|
-
return typeof email === "string" && email.trim().length <= MAX_EMAIL_LENGTH && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
|
|
408
|
-
}
|
|
409
|
-
function generateLinkToken() {
|
|
410
|
-
return randomBytes(32).toString("base64url");
|
|
411
|
-
}
|
|
412
|
-
function hashLinkToken(token) {
|
|
413
|
-
return createHash("sha256").update(token).digest("base64url");
|
|
414
|
-
}
|
|
415
|
-
function generateCode() {
|
|
416
|
-
return randomInt(0, 1e6).toString().padStart(6, "0");
|
|
417
|
-
}
|
|
418
|
-
function deriveCodeKey(signingKeyD) {
|
|
419
|
-
return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth e-mail code", 32));
|
|
420
|
-
}
|
|
421
|
-
function hashCode(key, code) {
|
|
422
|
-
return createHmac("sha256", key).update(code).digest("base64url");
|
|
423
|
-
}
|
|
424
|
-
function safeEqual(a, b) {
|
|
425
|
-
const x = Buffer.from(a);
|
|
426
|
-
const y = Buffer.from(b);
|
|
427
|
-
return x.length === y.length && timingSafeEqual(x, y);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// src/users.ts
|
|
431
|
-
import { randomBytes as randomBytes2 } from "crypto";
|
|
432
|
-
var MAX_CODE_ATTEMPTS = 5;
|
|
433
|
-
var MAX_WRONG_CODES = 10;
|
|
434
|
-
var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
|
|
435
|
-
function passwordAccountKey(userId) {
|
|
436
|
-
return `password:${userId}`;
|
|
437
|
-
}
|
|
438
|
-
function googleAccountKey(sub) {
|
|
439
|
-
return `google:${sub}`;
|
|
440
|
-
}
|
|
441
|
-
function toMadauthUser(user, profile = {}) {
|
|
442
|
-
const result = { id: user.id, email: user.email };
|
|
443
|
-
const name = user.name ?? profile.name;
|
|
444
|
-
if (name) result.name = name;
|
|
445
|
-
if (profile.picture) result.picture = profile.picture;
|
|
446
|
-
const claims = claimsFromJson(user.claims);
|
|
447
|
-
if (Object.keys(claims).length) result.claims = claims;
|
|
448
|
-
return result;
|
|
449
|
-
}
|
|
450
|
-
var Users = class {
|
|
451
|
-
/** `codeKey` keys the e-mail codes (see deriveCodeKey); only needed to issue and check them. */
|
|
452
|
-
constructor(store, codeKey) {
|
|
453
|
-
this.store = store;
|
|
454
|
-
this.codeKey = codeKey;
|
|
455
|
-
}
|
|
456
|
-
get key() {
|
|
457
|
-
if (!this.codeKey) throw new Error("Users needs a code key to issue or check e-mail codes.");
|
|
458
|
-
return this.codeKey;
|
|
459
|
-
}
|
|
460
|
-
async findByEmail(emailNormalized) {
|
|
461
|
-
return await this.store.findOne("user", { emailNormalized });
|
|
462
|
-
}
|
|
463
|
-
async findById(id) {
|
|
464
|
-
return await this.store.findOne("user", { id });
|
|
465
|
-
}
|
|
466
|
-
async passwordAccount(userId) {
|
|
467
|
-
return this.findAccountByKey(passwordAccountKey(userId));
|
|
468
|
-
}
|
|
469
|
-
/** How the user signs in, from their accounts: e.g. `['google']` for a user without a password. */
|
|
470
|
-
async signInMethods(userId) {
|
|
471
|
-
const accounts = await this.store.findMany("account", { userId });
|
|
472
|
-
const methods = accounts.map((account) => String(account.key).split(":")[0]);
|
|
473
|
-
return SIGN_IN_METHODS.filter((method) => methods.includes(method));
|
|
474
|
-
}
|
|
475
|
-
async findAccountByKey(key) {
|
|
476
|
-
return await this.store.findOne("account", { key });
|
|
477
|
-
}
|
|
478
|
-
/**
|
|
479
|
-
* Creates a user with their first account. Resolves to null if the e-mail address or the account key is
|
|
480
|
-
* taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or e.g. `google:<sub>`.
|
|
481
|
-
*/
|
|
482
|
-
async createUser(data) {
|
|
483
|
-
const user = {
|
|
484
|
-
id: newId("usr"),
|
|
485
|
-
email: data.email,
|
|
486
|
-
emailNormalized: data.emailNormalized,
|
|
487
|
-
emailVerified: data.emailVerified,
|
|
488
|
-
name: data.name,
|
|
489
|
-
sessionVersion: 0,
|
|
490
|
-
lastMailAt: 0,
|
|
491
|
-
wrongCodes: 0,
|
|
492
|
-
claims: null,
|
|
493
|
-
createdAt: Date.now()
|
|
494
|
-
};
|
|
495
|
-
if (!await this.store.create("user", user)) return null;
|
|
496
|
-
const account = typeof data.account === "function" ? data.account(user.id) : data.account;
|
|
497
|
-
if (!await this.linkAccount(user.id, account)) {
|
|
498
|
-
await this.store.delete("user", { id: user.id });
|
|
499
|
-
return null;
|
|
500
|
-
}
|
|
501
|
-
return user;
|
|
502
|
-
}
|
|
503
|
-
/**
|
|
504
|
-
* A provider verified the user's address: an unconfirmed password sign-up with it, whose password nobody
|
|
505
|
-
* has proven, is dropped (the account and its pending links and codes), and the user counts as verified.
|
|
506
|
-
*/
|
|
507
|
-
async verifyByProvider(user) {
|
|
508
|
-
if (user.emailVerified) return;
|
|
509
|
-
await this.store.delete("account", { key: passwordAccountKey(user.id) });
|
|
510
|
-
await this.clearVerifications(user.id);
|
|
511
|
-
await this.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
|
|
512
|
-
}
|
|
513
|
-
/** Removes an account record, e.g. one left behind by a deletion that did not finish. */
|
|
514
|
-
async deleteAccount(id) {
|
|
515
|
-
await this.store.delete("account", { id });
|
|
516
|
-
}
|
|
517
|
-
/** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
|
|
518
|
-
async linkAccount(userId, account) {
|
|
519
|
-
const record = {
|
|
520
|
-
id: newId("acc"),
|
|
521
|
-
userId,
|
|
522
|
-
key: account.key,
|
|
523
|
-
secret: account.secret ?? null,
|
|
524
|
-
failedAttempts: 0,
|
|
525
|
-
lockedUntil: 0,
|
|
526
|
-
email: account.email ?? null,
|
|
527
|
-
createdAt: Date.now()
|
|
528
|
-
};
|
|
529
|
-
return await this.store.create("account", record) ? record : null;
|
|
530
|
-
}
|
|
531
|
-
/**
|
|
532
|
-
* Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
|
|
533
|
-
*/
|
|
534
|
-
async deleteById(id) {
|
|
535
|
-
const user = await this.findById(id);
|
|
536
|
-
if (!user) return false;
|
|
537
|
-
if (await this.store.delete("user", { id: user.id }) !== 1) return false;
|
|
538
|
-
try {
|
|
539
|
-
await this.store.delete("account", { userId: user.id });
|
|
540
|
-
await this.store.delete("verification", { userId: user.id });
|
|
541
|
-
} catch (e) {
|
|
542
|
-
console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
|
|
543
|
-
}
|
|
544
|
-
return true;
|
|
545
|
-
}
|
|
546
|
-
async updateUser(id, patch) {
|
|
547
|
-
await this.store.update("user", { id }, patch);
|
|
548
|
-
}
|
|
549
|
-
/** Changes an account. Resolves to false if it is gone meanwhile. */
|
|
550
|
-
async updateAccount(id, patch) {
|
|
551
|
-
return await this.store.update("account", { id }, patch) === 1;
|
|
552
|
-
}
|
|
553
|
-
/**
|
|
554
|
-
* Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
|
|
555
|
-
* was read. Resolves to false if another request counted an attempt meanwhile.
|
|
556
|
-
*/
|
|
557
|
-
async countAttempt(account, patch) {
|
|
558
|
-
const changed = await this.store.update("account", { id: account.id, failedAttempts: account.failedAttempts }, patch);
|
|
559
|
-
return changed === 1;
|
|
560
|
-
}
|
|
561
|
-
/** Deletes the user's pending verifications and password resets; their links and codes stop working. */
|
|
562
|
-
async clearVerifications(userId) {
|
|
563
|
-
await this.store.delete("verification", { userId });
|
|
564
|
-
}
|
|
565
|
-
/**
|
|
566
|
-
* Starts a verification or password reset: replaces the user's earlier one for the same purpose and
|
|
567
|
-
* returns the link token and the code for the e-mail. Only their hashes are stored.
|
|
568
|
-
*/
|
|
569
|
-
async issueVerification(userId, purpose, ttlMs) {
|
|
570
|
-
await this.store.delete("verification", { userId, purpose });
|
|
571
|
-
const token = generateLinkToken();
|
|
572
|
-
const code = generateCode();
|
|
573
|
-
const record = {
|
|
574
|
-
id: hashLinkToken(token),
|
|
575
|
-
userId,
|
|
576
|
-
purpose,
|
|
577
|
-
codeHash: hashCode(this.key, code),
|
|
578
|
-
attempts: 0,
|
|
579
|
-
expiresAt: Date.now() + ttlMs
|
|
580
|
-
};
|
|
581
|
-
await this.store.create("verification", record);
|
|
582
|
-
return { token, code };
|
|
583
|
-
}
|
|
584
|
-
/** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
|
|
585
|
-
async consumeLinkToken(token, purpose) {
|
|
586
|
-
const id = hashLinkToken(token);
|
|
587
|
-
const record = await this.store.findOne("verification", { id, purpose });
|
|
588
|
-
return record ? this.consume(record) : null;
|
|
589
|
-
}
|
|
590
|
-
/**
|
|
591
|
-
* Uses up the user's verification if `code` matches. Too many wrong codes delete it, and after
|
|
592
|
-
* MAX_WRONG_CODES wrong codes in a row the user's codes stop working until an e-mail link is used.
|
|
593
|
-
*/
|
|
594
|
-
async consumeCode(user, purpose, code) {
|
|
595
|
-
if ((user.wrongCodes ?? 0) >= MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
596
|
-
const record = await this.store.findOne("verification", { userId: user.id, purpose });
|
|
597
|
-
if (!record) return { userId: null, locked: false };
|
|
598
|
-
const attempts = record.attempts + 1;
|
|
599
|
-
if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) {
|
|
600
|
-
return { userId: null, locked: false };
|
|
601
|
-
}
|
|
602
|
-
const wrongCodes = await this.countWrongCode(user);
|
|
603
|
-
if (wrongCodes === null) return { userId: null, locked: false };
|
|
604
|
-
if (wrongCodes > MAX_WRONG_CODES) return { userId: null, locked: true };
|
|
605
|
-
if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
|
|
606
|
-
if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
|
|
607
|
-
return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
|
|
608
|
-
}
|
|
609
|
-
return { userId: await this.consume(record), locked: false };
|
|
610
|
-
}
|
|
611
|
-
/** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
|
|
612
|
-
async countWrongCode(user) {
|
|
613
|
-
let current = user;
|
|
614
|
-
for (let attempt = 0; attempt < 3 && current; attempt++) {
|
|
615
|
-
const wrongCodes = (current.wrongCodes ?? 0) + 1;
|
|
616
|
-
if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
|
|
617
|
-
return wrongCodes;
|
|
618
|
-
}
|
|
619
|
-
current = await this.findById(user.id);
|
|
620
|
-
}
|
|
621
|
-
return null;
|
|
622
|
-
}
|
|
623
|
-
async consume(record) {
|
|
624
|
-
const deleted = await this.store.delete("verification", { id: record.id });
|
|
625
|
-
if (deleted !== 1 || record.expiresAt <= Date.now()) return null;
|
|
626
|
-
return record.userId;
|
|
627
|
-
}
|
|
628
|
-
};
|
|
629
|
-
|
|
630
|
-
export {
|
|
631
|
-
ADMIN_ROLE,
|
|
632
|
-
parseRoles,
|
|
633
|
-
parseClaims,
|
|
634
|
-
sameClaims,
|
|
635
|
-
claimsFromJson,
|
|
636
|
-
isAdmin,
|
|
637
|
-
MAX_PASSWORD_LENGTH,
|
|
638
|
-
hashPassword,
|
|
639
|
-
verifyPassword,
|
|
640
|
-
verifyAgainstDummy,
|
|
641
|
-
checkPasswordPolicy,
|
|
642
|
-
normalizeEmail,
|
|
643
|
-
isValidEmail,
|
|
644
|
-
deriveCodeKey,
|
|
645
|
-
SIGN_IN_METHODS,
|
|
646
|
-
parseMethodSettings,
|
|
647
|
-
Settings,
|
|
648
|
-
isOn,
|
|
649
|
-
anyOn,
|
|
650
|
-
passwordAccountKey,
|
|
651
|
-
googleAccountKey,
|
|
652
|
-
toMadauthUser,
|
|
653
|
-
Users,
|
|
654
|
-
envVars,
|
|
655
|
-
resolveEnv,
|
|
656
|
-
ConfigError,
|
|
657
|
-
loadConfig,
|
|
658
|
-
loadUserStoreConfig,
|
|
659
|
-
loadStore
|
|
660
|
-
};
|