@madauth/server 0.1.0
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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/bin.js +121 -0
- package/dist/chunk-3WIWWKRI.js +81 -0
- package/dist/chunk-AVZE6FFR.js +99 -0
- package/dist/chunk-JDQLSL36.js +32 -0
- package/dist/chunk-PRRKLEW7.js +54 -0
- package/dist/chunk-TEMZRMFF.js +498 -0
- package/dist/chunk-V2XKGETK.js +386 -0
- package/dist/config-KQvE2imU.d.ts +65 -0
- package/dist/entry/azure.d.ts +13 -0
- package/dist/entry/azure.js +84 -0
- package/dist/entry/lambda.d.ts +17 -0
- package/dist/entry/lambda.js +25 -0
- package/dist/entry/node.d.ts +16 -0
- package/dist/entry/node.js +22 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +32 -0
- package/dist/schema-Dotc6LU7.d.ts +137 -0
- package/dist/sqlite.js +100 -0
- package/dist/standalone/azure.mjs +8377 -0
- package/dist/standalone/lambda.mjs +4462 -0
- package/dist/standalone/main.mjs +5548 -0
- package/dist/store/sqlite.d.ts +9 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +99 -0
- package/dist/user-3DDyggFV.d.ts +10 -0
- package/dist/verify.d.ts +24 -0
- package/dist/verify.js +48 -0
- package/dist/webhook-CT80emTQ.d.ts +31 -0
- package/dist/webhook.d.ts +1 -0
- package/dist/webhook.js +14 -0
- package/package.json +109 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertPrivateSigningJwk,
|
|
3
|
+
generateSigningKey
|
|
4
|
+
} from "./chunk-JDQLSL36.js";
|
|
5
|
+
import {
|
|
6
|
+
WEBHOOK_TYPES,
|
|
7
|
+
checkWebhookSecret,
|
|
8
|
+
generateWebhookSecret
|
|
9
|
+
} from "./chunk-AVZE6FFR.js";
|
|
10
|
+
|
|
11
|
+
// src/config.ts
|
|
12
|
+
var envVars = {
|
|
13
|
+
MADAUTH_ISSUER: true,
|
|
14
|
+
MADAUTH_SIGNING_KEY: true,
|
|
15
|
+
ALLOWED_ORIGINS: true,
|
|
16
|
+
GOOGLE_CLIENT_ID: false,
|
|
17
|
+
GOOGLE_CLIENT_SECRET: false,
|
|
18
|
+
DATABASE_URL: false,
|
|
19
|
+
PASSWORD_MIN_LENGTH: false,
|
|
20
|
+
WEBHOOK_URL: false,
|
|
21
|
+
WEBHOOK_SECRET: false,
|
|
22
|
+
WEBHOOK_EVENTS: false,
|
|
23
|
+
SESSION_TTL: false,
|
|
24
|
+
COOKIE_DOMAIN: false
|
|
25
|
+
};
|
|
26
|
+
var DEFAULT_SESSION_TTL = 8 * 60 * 60;
|
|
27
|
+
var DEFAULT_PASSWORD_MIN_LENGTH = 8;
|
|
28
|
+
var ConfigError = class extends Error {
|
|
29
|
+
constructor() {
|
|
30
|
+
super(...arguments);
|
|
31
|
+
this.name = "ConfigError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
function parseOrigin(name, value) {
|
|
35
|
+
let url;
|
|
36
|
+
try {
|
|
37
|
+
url = new URL(value);
|
|
38
|
+
} catch {
|
|
39
|
+
throw new ConfigError(`${name}: "${value}" is not a URL.`);
|
|
40
|
+
}
|
|
41
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
42
|
+
throw new ConfigError(`${name}: "${value}" must be an http(s) URL.`);
|
|
43
|
+
}
|
|
44
|
+
return url.origin;
|
|
45
|
+
}
|
|
46
|
+
function readEnv(env, name) {
|
|
47
|
+
return env[name]?.trim().replace(/^(['"])(.*)\1$/s, "$2").trim() || void 0;
|
|
48
|
+
}
|
|
49
|
+
async function loadConfig(env, overrides = {}) {
|
|
50
|
+
const read = (name) => readEnv(env, name);
|
|
51
|
+
const require2 = (name) => {
|
|
52
|
+
const value = read(name);
|
|
53
|
+
if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
|
|
54
|
+
return value;
|
|
55
|
+
};
|
|
56
|
+
const issuerValue = require2("MADAUTH_ISSUER");
|
|
57
|
+
parseOrigin("MADAUTH_ISSUER", issuerValue);
|
|
58
|
+
const issuer = issuerValue.replace(/\/+$/, "");
|
|
59
|
+
const keyValue = read("MADAUTH_SIGNING_KEY");
|
|
60
|
+
if (!keyValue) {
|
|
61
|
+
const key = await generateSigningKey();
|
|
62
|
+
throw new ConfigError(
|
|
63
|
+
`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):
|
|
64
|
+
|
|
65
|
+
MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
|
|
66
|
+
`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
let signingKey;
|
|
70
|
+
try {
|
|
71
|
+
signingKey = JSON.parse(keyValue);
|
|
72
|
+
assertPrivateSigningJwk(signingKey);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
const reason = e instanceof SyntaxError ? "is not valid JSON" : e.message;
|
|
75
|
+
throw new ConfigError(`MADAUTH_SIGNING_KEY ${reason}. Generate one with: npx @madauth/server generate-key`);
|
|
76
|
+
}
|
|
77
|
+
const allowedOrigins = require2("ALLOWED_ORIGINS").split(",").map((o) => o.trim()).filter(Boolean).map((o) => parseOrigin("ALLOWED_ORIGINS", o));
|
|
78
|
+
const clientId = read("GOOGLE_CLIENT_ID");
|
|
79
|
+
if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
|
|
80
|
+
throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
|
|
81
|
+
}
|
|
82
|
+
const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
|
|
83
|
+
const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
|
|
84
|
+
let password;
|
|
85
|
+
if (store) {
|
|
86
|
+
if (!webhook) {
|
|
87
|
+
throw new ConfigError(
|
|
88
|
+
'WEBHOOK_URL is not set. E-mail & password sign-in sends its e-mails through your webhook. For development, run the example receiver (npm run dev:webhooks) and set WEBHOOK_URL=http://localhost:8790/webhook. See "Webhooks" in docs/server.md.'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
password = { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")), store };
|
|
92
|
+
}
|
|
93
|
+
if (!clientId && !password) {
|
|
94
|
+
throw new ConfigError(
|
|
95
|
+
"No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in and/or DATABASE_URL (e.g. sqlite:./madauth.db) for e-mail & password sign-in. See docs/server.md."
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const ttlValue = read("SESSION_TTL");
|
|
99
|
+
const sessionTtlSeconds = ttlValue ? Number(ttlValue) : DEFAULT_SESSION_TTL;
|
|
100
|
+
if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
|
|
101
|
+
throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
issuer,
|
|
105
|
+
signingKey,
|
|
106
|
+
allowedOrigins,
|
|
107
|
+
sessionTtlSeconds,
|
|
108
|
+
cookieDomain: read("COOKIE_DOMAIN"),
|
|
109
|
+
google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
|
|
110
|
+
password,
|
|
111
|
+
webhook
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
async function loadUserStoreConfig(env) {
|
|
115
|
+
const store = await storeFromDatabaseUrl(readEnv(env, "DATABASE_URL"));
|
|
116
|
+
if (!store) throw new ConfigError("Set DATABASE_URL to manage users.");
|
|
117
|
+
return { store, minLength: passwordMinLength(readEnv(env, "PASSWORD_MIN_LENGTH")) };
|
|
118
|
+
}
|
|
119
|
+
function passwordMinLength(value) {
|
|
120
|
+
const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
|
|
121
|
+
if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
|
|
122
|
+
throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
|
|
123
|
+
}
|
|
124
|
+
return minLength;
|
|
125
|
+
}
|
|
126
|
+
async function storeFromDatabaseUrl(url) {
|
|
127
|
+
if (!url) return void 0;
|
|
128
|
+
if (!url.startsWith("sqlite:")) {
|
|
129
|
+
throw new ConfigError(
|
|
130
|
+
`DATABASE_URL must start with "sqlite:" (e.g. sqlite:/data/madauth.db) but is "${url}". For other databases, pass your own store adapter (see "Custom store adapter" in docs/server.md).`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const path = url.slice("sqlite:".length);
|
|
134
|
+
if (!path) throw new ConfigError("DATABASE_URL needs a file path, e.g. sqlite:/data/madauth.db");
|
|
135
|
+
const { createSqliteAdapter } = await import("./sqlite.js");
|
|
136
|
+
try {
|
|
137
|
+
return createSqliteAdapter(path);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
|
|
143
|
+
function webhookFromEnv(url, secret, events) {
|
|
144
|
+
if (!url) {
|
|
145
|
+
if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
|
|
146
|
+
return void 0;
|
|
147
|
+
}
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = new URL(url);
|
|
151
|
+
} catch {
|
|
152
|
+
throw new ConfigError(`WEBHOOK_URL: "${url}" is not a URL.`);
|
|
153
|
+
}
|
|
154
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname))) {
|
|
155
|
+
throw new ConfigError(`WEBHOOK_URL must be an https URL (http only for localhost) but is "${url}".`);
|
|
156
|
+
}
|
|
157
|
+
if (!secret) {
|
|
158
|
+
throw new ConfigError(
|
|
159
|
+
`WEBHOOK_SECRET is not set. Here is a newly generated secret you can use (keep it secret; your webhook receiver needs the same one):
|
|
160
|
+
|
|
161
|
+
WEBHOOK_SECRET=${generateWebhookSecret()}
|
|
162
|
+
`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const problem = checkWebhookSecret(secret);
|
|
166
|
+
if (problem) {
|
|
167
|
+
throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
|
|
168
|
+
}
|
|
169
|
+
let selected = null;
|
|
170
|
+
if (events) {
|
|
171
|
+
selected = new Set(events.split(",").map((e) => e.trim()).filter(Boolean));
|
|
172
|
+
const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
|
|
173
|
+
if (unknown.length) {
|
|
174
|
+
throw new ConfigError(`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. Known types: ${WEBHOOK_TYPES.join(", ")}.`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { url: parsed.href, secret, events: selected };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/password.ts
|
|
181
|
+
import { createHash, createHmac, hkdfSync, randomBytes, randomInt, scrypt, timingSafeEqual } from "crypto";
|
|
182
|
+
var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
|
|
183
|
+
var KEY_LENGTH = 64;
|
|
184
|
+
var SALT_LENGTH = 16;
|
|
185
|
+
var MAX_PASSWORD_LENGTH = 256;
|
|
186
|
+
var MAX_EMAIL_LENGTH = 254;
|
|
187
|
+
function derive(password, salt, { logN, r, p }) {
|
|
188
|
+
const N = 2 ** logN;
|
|
189
|
+
return new Promise(
|
|
190
|
+
(resolve, reject) => scrypt(
|
|
191
|
+
password.normalize("NFC"),
|
|
192
|
+
salt,
|
|
193
|
+
KEY_LENGTH,
|
|
194
|
+
{ N, r, p, maxmem: 256 * N * r + 1024 * 1024 },
|
|
195
|
+
(err, key) => err ? reject(err) : resolve(key)
|
|
196
|
+
)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
async function hashPassword(password, params = HASH_PARAMS) {
|
|
200
|
+
const salt = randomBytes(SALT_LENGTH);
|
|
201
|
+
const key = await derive(password, salt, params);
|
|
202
|
+
return ["scrypt", params.logN, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
|
|
203
|
+
}
|
|
204
|
+
async function verifyPassword(password, stored) {
|
|
205
|
+
const [scheme, logN, r, p, salt, hash] = stored.split("$");
|
|
206
|
+
const params = { logN: Number(logN), r: Number(r), p: Number(p) };
|
|
207
|
+
if (scheme !== "scrypt" || !salt || !hash || !Object.values(params).every((n) => Number.isInteger(n) && n > 0)) {
|
|
208
|
+
return { ok: false, needsRehash: false };
|
|
209
|
+
}
|
|
210
|
+
const expected = Buffer.from(hash, "base64url");
|
|
211
|
+
const key = await derive(password, Buffer.from(salt, "base64url"), params);
|
|
212
|
+
const ok = key.length === expected.length && timingSafeEqual(key, expected);
|
|
213
|
+
const needsRehash = params.logN !== HASH_PARAMS.logN || params.r !== HASH_PARAMS.r || params.p !== HASH_PARAMS.p;
|
|
214
|
+
return { ok, needsRehash };
|
|
215
|
+
}
|
|
216
|
+
var dummyHash;
|
|
217
|
+
async function verifyAgainstDummy(password) {
|
|
218
|
+
dummyHash ??= hashPassword(randomBytes(16).toString("hex"));
|
|
219
|
+
await verifyPassword(password, await dummyHash);
|
|
220
|
+
}
|
|
221
|
+
function checkPasswordPolicy(password, minLength) {
|
|
222
|
+
if (typeof password !== "string") return "password is missing";
|
|
223
|
+
const length = [...password].length;
|
|
224
|
+
if (length < minLength) return `The password must have at least ${minLength} characters.`;
|
|
225
|
+
if (length > MAX_PASSWORD_LENGTH) return `The password must have at most ${MAX_PASSWORD_LENGTH} characters.`;
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
function normalizeEmail(email) {
|
|
229
|
+
return email.trim().toLowerCase();
|
|
230
|
+
}
|
|
231
|
+
function isValidEmail(email) {
|
|
232
|
+
return typeof email === "string" && email.trim().length <= MAX_EMAIL_LENGTH && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
|
|
233
|
+
}
|
|
234
|
+
function generateLinkToken() {
|
|
235
|
+
return randomBytes(32).toString("base64url");
|
|
236
|
+
}
|
|
237
|
+
function hashLinkToken(token) {
|
|
238
|
+
return createHash("sha256").update(token).digest("base64url");
|
|
239
|
+
}
|
|
240
|
+
function generateCode() {
|
|
241
|
+
return randomInt(0, 1e6).toString().padStart(6, "0");
|
|
242
|
+
}
|
|
243
|
+
function deriveCodeKey(signingKeyD) {
|
|
244
|
+
return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth e-mail code", 32));
|
|
245
|
+
}
|
|
246
|
+
function hashCode(key, code) {
|
|
247
|
+
return createHmac("sha256", key).update(code).digest("base64url");
|
|
248
|
+
}
|
|
249
|
+
function safeEqual(a, b) {
|
|
250
|
+
const x = Buffer.from(a);
|
|
251
|
+
const y = Buffer.from(b);
|
|
252
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/users.ts
|
|
256
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
257
|
+
var MAX_CODE_ATTEMPTS = 5;
|
|
258
|
+
var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
|
|
259
|
+
function passwordAccountKey(userId) {
|
|
260
|
+
return `password:${userId}`;
|
|
261
|
+
}
|
|
262
|
+
var Users = class {
|
|
263
|
+
/** `codeKey` keys the e-mail codes (see deriveCodeKey); only needed to issue and check them. */
|
|
264
|
+
constructor(store, codeKey) {
|
|
265
|
+
this.store = store;
|
|
266
|
+
this.codeKey = codeKey;
|
|
267
|
+
}
|
|
268
|
+
get key() {
|
|
269
|
+
if (!this.codeKey) throw new Error("Users needs a code key to issue or check e-mail codes.");
|
|
270
|
+
return this.codeKey;
|
|
271
|
+
}
|
|
272
|
+
async findByEmail(emailNormalized) {
|
|
273
|
+
return await this.store.findOne("user", { emailNormalized });
|
|
274
|
+
}
|
|
275
|
+
async findById(id) {
|
|
276
|
+
return await this.store.findOne("user", { id });
|
|
277
|
+
}
|
|
278
|
+
async passwordAccount(userId) {
|
|
279
|
+
return await this.store.findOne("account", { key: passwordAccountKey(userId) });
|
|
280
|
+
}
|
|
281
|
+
/** Creates a user with a password. Resolves to null if the e-mail address is taken. */
|
|
282
|
+
async createPasswordUser(data) {
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
const user = {
|
|
285
|
+
id: newId("usr"),
|
|
286
|
+
email: data.email,
|
|
287
|
+
emailNormalized: data.emailNormalized,
|
|
288
|
+
emailVerified: data.emailVerified,
|
|
289
|
+
name: data.name,
|
|
290
|
+
sessionVersion: 0,
|
|
291
|
+
lastMailAt: 0,
|
|
292
|
+
createdAt: now
|
|
293
|
+
};
|
|
294
|
+
if (!await this.store.create("user", user)) return null;
|
|
295
|
+
const account = {
|
|
296
|
+
id: newId("acc"),
|
|
297
|
+
userId: user.id,
|
|
298
|
+
key: passwordAccountKey(user.id),
|
|
299
|
+
secret: data.passwordHash,
|
|
300
|
+
failedAttempts: 0,
|
|
301
|
+
lockedUntil: 0,
|
|
302
|
+
createdAt: now
|
|
303
|
+
};
|
|
304
|
+
if (!await this.store.create("account", account)) {
|
|
305
|
+
await this.store.delete("user", { id: user.id });
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
return user;
|
|
309
|
+
}
|
|
310
|
+
async updateUser(id, patch) {
|
|
311
|
+
await this.store.update("user", { id }, patch);
|
|
312
|
+
}
|
|
313
|
+
async updateAccount(id, patch) {
|
|
314
|
+
await this.store.update("account", { id }, patch);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
|
|
318
|
+
* was read. Resolves to false if another request counted an attempt meanwhile.
|
|
319
|
+
*/
|
|
320
|
+
async countAttempt(account, patch) {
|
|
321
|
+
const changed = await this.store.update("account", { id: account.id, failedAttempts: account.failedAttempts }, patch);
|
|
322
|
+
return changed === 1;
|
|
323
|
+
}
|
|
324
|
+
/** Deletes the user's pending verifications and password resets; their links and codes stop working. */
|
|
325
|
+
async clearVerifications(userId) {
|
|
326
|
+
await this.store.delete("verification", { userId });
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Starts a verification or password reset: replaces the user's earlier one for the same purpose and
|
|
330
|
+
* returns the link token and the code for the e-mail. Only their hashes are stored.
|
|
331
|
+
*/
|
|
332
|
+
async issueVerification(userId, purpose, ttlMs) {
|
|
333
|
+
await this.store.delete("verification", { userId, purpose });
|
|
334
|
+
const token = generateLinkToken();
|
|
335
|
+
const code = generateCode();
|
|
336
|
+
const record = {
|
|
337
|
+
id: hashLinkToken(token),
|
|
338
|
+
userId,
|
|
339
|
+
purpose,
|
|
340
|
+
codeHash: hashCode(this.key, code),
|
|
341
|
+
attempts: 0,
|
|
342
|
+
expiresAt: Date.now() + ttlMs
|
|
343
|
+
};
|
|
344
|
+
await this.store.create("verification", record);
|
|
345
|
+
return { token, code };
|
|
346
|
+
}
|
|
347
|
+
/** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
|
|
348
|
+
async consumeLinkToken(token, purpose) {
|
|
349
|
+
const id = hashLinkToken(token);
|
|
350
|
+
const record = await this.store.findOne("verification", { id, purpose });
|
|
351
|
+
return record ? this.consume(record) : null;
|
|
352
|
+
}
|
|
353
|
+
/** Uses up the user's verification if `code` matches. Too many wrong codes delete it. */
|
|
354
|
+
async consumeCode(userId, purpose, code) {
|
|
355
|
+
const record = await this.store.findOne("verification", { userId, purpose });
|
|
356
|
+
if (!record) return null;
|
|
357
|
+
const attempts = record.attempts + 1;
|
|
358
|
+
if (await this.store.update("verification", { id: record.id, attempts: record.attempts }, { attempts }) !== 1) return null;
|
|
359
|
+
if (!safeEqual(hashCode(this.key, code), record.codeHash)) {
|
|
360
|
+
if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
return this.consume(record);
|
|
364
|
+
}
|
|
365
|
+
async consume(record) {
|
|
366
|
+
const deleted = await this.store.delete("verification", { id: record.id });
|
|
367
|
+
if (deleted !== 1 || record.expiresAt <= Date.now()) return null;
|
|
368
|
+
return record.userId;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export {
|
|
373
|
+
MAX_PASSWORD_LENGTH,
|
|
374
|
+
hashPassword,
|
|
375
|
+
verifyPassword,
|
|
376
|
+
verifyAgainstDummy,
|
|
377
|
+
checkPasswordPolicy,
|
|
378
|
+
normalizeEmail,
|
|
379
|
+
isValidEmail,
|
|
380
|
+
deriveCodeKey,
|
|
381
|
+
Users,
|
|
382
|
+
envVars,
|
|
383
|
+
ConfigError,
|
|
384
|
+
loadConfig,
|
|
385
|
+
loadUserStoreConfig
|
|
386
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { JWK, JWTVerifyGetKey } from 'jose';
|
|
2
|
+
import { S as StoreAdapter } from './schema-Dotc6LU7.js';
|
|
3
|
+
import { W as WebhookSettings } from './webhook-CT80emTQ.js';
|
|
4
|
+
|
|
5
|
+
interface MadauthConfig {
|
|
6
|
+
/** Public base URL of the server (MADAUTH_ISSUER). */
|
|
7
|
+
issuer: string;
|
|
8
|
+
/** Private ES256 JWK (MADAUTH_SIGNING_KEY). */
|
|
9
|
+
signingKey: JWK;
|
|
10
|
+
/** App origins allowed to call the server (ALLOWED_ORIGINS). */
|
|
11
|
+
allowedOrigins: string[];
|
|
12
|
+
/** Session lifetime in seconds (SESSION_TTL). */
|
|
13
|
+
sessionTtlSeconds: number;
|
|
14
|
+
/** Cookie domain, e.g. `.example.com` (COOKIE_DOMAIN). */
|
|
15
|
+
cookieDomain?: string;
|
|
16
|
+
/** Google sign-in; off when GOOGLE_CLIENT_ID is not set. */
|
|
17
|
+
google?: {
|
|
18
|
+
/** OAuth web client ID (GOOGLE_CLIENT_ID). */
|
|
19
|
+
clientId: string;
|
|
20
|
+
/** Enables the server-side code flow (GOOGLE_CLIENT_SECRET). */
|
|
21
|
+
clientSecret?: string;
|
|
22
|
+
};
|
|
23
|
+
/** E-mail & password sign-in; off without a store (DATABASE_URL or the `store` option). */
|
|
24
|
+
password?: {
|
|
25
|
+
/** PASSWORD_MIN_LENGTH. */
|
|
26
|
+
minLength: number;
|
|
27
|
+
store: StoreAdapter;
|
|
28
|
+
};
|
|
29
|
+
/** Where madAuth sends e-mails, sign-up checks and events (WEBHOOK_URL); required for password sign-in. */
|
|
30
|
+
webhook?: WebhookSettings;
|
|
31
|
+
/** Test hook: resolves Google's signing keys. Defaults to Google's published JWKS. */
|
|
32
|
+
jwksResolver?: JWTVerifyGetKey;
|
|
33
|
+
/** Test hook: the fetch used for webhook calls. */
|
|
34
|
+
webhookFetch?: typeof fetch;
|
|
35
|
+
}
|
|
36
|
+
/** Every environment variable read by {@link loadConfig}, with whether it is required. */
|
|
37
|
+
declare const envVars: {
|
|
38
|
+
readonly MADAUTH_ISSUER: true;
|
|
39
|
+
readonly MADAUTH_SIGNING_KEY: true;
|
|
40
|
+
readonly ALLOWED_ORIGINS: true;
|
|
41
|
+
readonly GOOGLE_CLIENT_ID: false;
|
|
42
|
+
readonly GOOGLE_CLIENT_SECRET: false;
|
|
43
|
+
readonly DATABASE_URL: false;
|
|
44
|
+
readonly PASSWORD_MIN_LENGTH: false;
|
|
45
|
+
readonly WEBHOOK_URL: false;
|
|
46
|
+
readonly WEBHOOK_SECRET: false;
|
|
47
|
+
readonly WEBHOOK_EVENTS: false;
|
|
48
|
+
readonly SESSION_TTL: false;
|
|
49
|
+
readonly COOKIE_DOMAIN: false;
|
|
50
|
+
};
|
|
51
|
+
/** Instead of environment variables: your own store adapter. */
|
|
52
|
+
interface ConfigOverrides {
|
|
53
|
+
/** Stores users; replaces DATABASE_URL. */
|
|
54
|
+
store?: StoreAdapter;
|
|
55
|
+
}
|
|
56
|
+
declare class ConfigError extends Error {
|
|
57
|
+
name: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Reads and validates the madAuth configuration from environment variables.
|
|
61
|
+
* Throws a {@link ConfigError} naming the variable that is missing or invalid.
|
|
62
|
+
*/
|
|
63
|
+
declare function loadConfig(env: Record<string, string | undefined>, overrides?: ConfigOverrides): Promise<MadauthConfig>;
|
|
64
|
+
|
|
65
|
+
export { type ConfigOverrides as C, type MadauthConfig as M, ConfigError as a, envVars as e, loadConfig as l };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { C as ConfigOverrides } from '../config-KQvE2imU.js';
|
|
2
|
+
import 'jose';
|
|
3
|
+
import '../schema-Dotc6LU7.js';
|
|
4
|
+
import '../webhook-CT80emTQ.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Registers madAuth as an Azure Functions v4 HTTP function that handles every route. Pass `store` to use your
|
|
8
|
+
* own store adapter.
|
|
9
|
+
* Set `"routePrefix": ""` in host.json so the routes are served at /auth/... (see docs/server.md).
|
|
10
|
+
*/
|
|
11
|
+
declare function register(overrides?: ConfigOverrides): void;
|
|
12
|
+
|
|
13
|
+
export { register };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createApp
|
|
3
|
+
} from "../chunk-TEMZRMFF.js";
|
|
4
|
+
import "../chunk-PRRKLEW7.js";
|
|
5
|
+
import {
|
|
6
|
+
loadConfig
|
|
7
|
+
} from "../chunk-V2XKGETK.js";
|
|
8
|
+
import "../chunk-JDQLSL36.js";
|
|
9
|
+
import "../chunk-AVZE6FFR.js";
|
|
10
|
+
|
|
11
|
+
// src/entry/azure.ts
|
|
12
|
+
import { app } from "@azure/functions";
|
|
13
|
+
|
|
14
|
+
// src/entry/azure-handler.ts
|
|
15
|
+
var sameSiteValues = { strict: "Strict", lax: "Lax", none: "None" };
|
|
16
|
+
function parseSetCookie(header) {
|
|
17
|
+
const [pair, ...attributes] = header.split(";").map((s) => s.trim());
|
|
18
|
+
const eq = pair.indexOf("=");
|
|
19
|
+
const cookie = { name: pair.slice(0, eq), value: pair.slice(eq + 1) };
|
|
20
|
+
for (const attribute of attributes) {
|
|
21
|
+
const [key, ...rest] = attribute.split("=");
|
|
22
|
+
const value = rest.join("=");
|
|
23
|
+
switch (key.toLowerCase()) {
|
|
24
|
+
case "domain":
|
|
25
|
+
cookie.domain = value;
|
|
26
|
+
break;
|
|
27
|
+
case "path":
|
|
28
|
+
cookie.path = value;
|
|
29
|
+
break;
|
|
30
|
+
case "expires":
|
|
31
|
+
cookie.expires = new Date(value);
|
|
32
|
+
break;
|
|
33
|
+
case "max-age":
|
|
34
|
+
cookie.maxAge = Number(value);
|
|
35
|
+
break;
|
|
36
|
+
case "secure":
|
|
37
|
+
cookie.secure = true;
|
|
38
|
+
break;
|
|
39
|
+
case "httponly":
|
|
40
|
+
cookie.httpOnly = true;
|
|
41
|
+
break;
|
|
42
|
+
case "samesite":
|
|
43
|
+
cookie.sameSite = sameSiteValues[value.toLowerCase()];
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return cookie;
|
|
48
|
+
}
|
|
49
|
+
async function handleAzureRequest(app2, request) {
|
|
50
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
51
|
+
const response = await app2.fetch(
|
|
52
|
+
new Request(request.url, {
|
|
53
|
+
method: request.method,
|
|
54
|
+
headers: request.headers,
|
|
55
|
+
body: hasBody ? await request.arrayBuffer() : void 0
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
const headers = new Headers(response.headers);
|
|
59
|
+
headers.delete("set-cookie");
|
|
60
|
+
return {
|
|
61
|
+
status: response.status,
|
|
62
|
+
headers,
|
|
63
|
+
cookies: response.headers.getSetCookie().map(parseSetCookie),
|
|
64
|
+
body: response.body ? new Uint8Array(await response.arrayBuffer()) : void 0
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/entry/azure.ts
|
|
69
|
+
function register(overrides = {}) {
|
|
70
|
+
let appPromise;
|
|
71
|
+
app.http("madauth", {
|
|
72
|
+
route: "{*path}",
|
|
73
|
+
methods: ["GET", "POST", "OPTIONS"],
|
|
74
|
+
authLevel: "anonymous",
|
|
75
|
+
handler: async (request) => {
|
|
76
|
+
appPromise ??= loadConfig(process.env, overrides).then(createApp);
|
|
77
|
+
appPromise.catch(() => appPromise = void 0);
|
|
78
|
+
return handleAzureRequest(await appPromise, request);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export {
|
|
83
|
+
register
|
|
84
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { LambdaEvent, LambdaContext, handle } from 'hono/aws-lambda';
|
|
2
|
+
import { C as ConfigOverrides } from '../config-KQvE2imU.js';
|
|
3
|
+
import 'jose';
|
|
4
|
+
import '../schema-Dotc6LU7.js';
|
|
5
|
+
import '../webhook-CT80emTQ.js';
|
|
6
|
+
|
|
7
|
+
type LambdaHandler = (event: LambdaEvent, context: LambdaContext) => ReturnType<ReturnType<typeof handle>>;
|
|
8
|
+
/**
|
|
9
|
+
* Creates an AWS Lambda handler (Function URL, API Gateway v1/v2 or ALB). It reads the configuration from
|
|
10
|
+
* the function's environment variables on the first invocation. Pass `store` to use your own store
|
|
11
|
+
* adapter.
|
|
12
|
+
*/
|
|
13
|
+
declare function createHandler(overrides?: ConfigOverrides): LambdaHandler;
|
|
14
|
+
/** The handler configured only by environment variables (`lambda.handler` in the standalone bundle). */
|
|
15
|
+
declare const handler: LambdaHandler;
|
|
16
|
+
|
|
17
|
+
export { type LambdaHandler, createHandler, handler };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createApp
|
|
3
|
+
} from "../chunk-TEMZRMFF.js";
|
|
4
|
+
import "../chunk-PRRKLEW7.js";
|
|
5
|
+
import {
|
|
6
|
+
loadConfig
|
|
7
|
+
} from "../chunk-V2XKGETK.js";
|
|
8
|
+
import "../chunk-JDQLSL36.js";
|
|
9
|
+
import "../chunk-AVZE6FFR.js";
|
|
10
|
+
|
|
11
|
+
// src/entry/lambda.ts
|
|
12
|
+
import { handle } from "hono/aws-lambda";
|
|
13
|
+
function createHandler(overrides = {}) {
|
|
14
|
+
let handlerPromise;
|
|
15
|
+
return async (event, context) => {
|
|
16
|
+
handlerPromise ??= loadConfig(process.env, overrides).then((config) => handle(createApp(config)));
|
|
17
|
+
handlerPromise.catch(() => handlerPromise = void 0);
|
|
18
|
+
return (await handlerPromise)(event, context);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
var handler = createHandler();
|
|
22
|
+
export {
|
|
23
|
+
createHandler,
|
|
24
|
+
handler
|
|
25
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { C as ConfigOverrides } from '../config-KQvE2imU.js';
|
|
2
|
+
import 'jose';
|
|
3
|
+
import '../schema-Dotc6LU7.js';
|
|
4
|
+
import '../webhook-CT80emTQ.js';
|
|
5
|
+
|
|
6
|
+
interface StartOptions extends ConfigOverrides {
|
|
7
|
+
/** Environment variables to read. Default: `process.env`. */
|
|
8
|
+
env?: Record<string, string | undefined>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Starts the madAuth server on Node, listening on PORT (default 8787). Used by the Docker image.
|
|
12
|
+
* Pass `store` to use your own store adapter.
|
|
13
|
+
*/
|
|
14
|
+
declare function start(opts?: StartOptions): Promise<void>;
|
|
15
|
+
|
|
16
|
+
export { type StartOptions, start };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createApp
|
|
3
|
+
} from "../chunk-TEMZRMFF.js";
|
|
4
|
+
import "../chunk-PRRKLEW7.js";
|
|
5
|
+
import {
|
|
6
|
+
loadConfig
|
|
7
|
+
} from "../chunk-V2XKGETK.js";
|
|
8
|
+
import "../chunk-JDQLSL36.js";
|
|
9
|
+
import "../chunk-AVZE6FFR.js";
|
|
10
|
+
|
|
11
|
+
// src/entry/node.ts
|
|
12
|
+
import { serve } from "@hono/node-server";
|
|
13
|
+
async function start(opts = {}) {
|
|
14
|
+
const { env = process.env, ...overrides } = opts;
|
|
15
|
+
const config = await loadConfig(env, overrides);
|
|
16
|
+
const port = Number(env.PORT?.trim() || 8787);
|
|
17
|
+
serve({ fetch: createApp(config).fetch, port });
|
|
18
|
+
console.log(`madAuth server listening on port ${port} (issuer ${config.issuer})`);
|
|
19
|
+
}
|
|
20
|
+
export {
|
|
21
|
+
start
|
|
22
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { M as MadauthConfig } from './config-KQvE2imU.js';
|
|
3
|
+
export { a as ConfigError, C as ConfigOverrides, e as envVars, l as loadConfig } from './config-KQvE2imU.js';
|
|
4
|
+
import { JWK } from 'jose';
|
|
5
|
+
import { a as Schema } from './schema-Dotc6LU7.js';
|
|
6
|
+
export { F as FieldDef, b as FieldType, R as Row, S as StoreAdapter, V as Value, W as Where, m as madauthSchema } from './schema-Dotc6LU7.js';
|
|
7
|
+
export { M as MadauthUser } from './user-3DDyggFV.js';
|
|
8
|
+
import './webhook-CT80emTQ.js';
|
|
9
|
+
|
|
10
|
+
/** Creates a new private ES256 JWK for MADAUTH_SIGNING_KEY. */
|
|
11
|
+
declare function generateSigningKey(): Promise<JWK>;
|
|
12
|
+
|
|
13
|
+
/** Hash parameter that carries a sign-in error back to the app after the code flow. */
|
|
14
|
+
declare const REDIRECT_ERROR_PARAM = "madauth_error";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates the madAuth HTTP app. It is built on Hono's Web Standard Request/Response, so the same app runs
|
|
18
|
+
* on Node (Docker), AWS Lambda and Azure Functions (see `src/entry/`).
|
|
19
|
+
*/
|
|
20
|
+
declare function createApp(config: MadauthConfig): Hono;
|
|
21
|
+
|
|
22
|
+
/** The SQL table that holds a model, e.g. `madauth_user`. */
|
|
23
|
+
declare function tableName(model: string): string;
|
|
24
|
+
/** The SQL column that holds a field, e.g. `email_normalized` for `emailNormalized`. */
|
|
25
|
+
declare function columnName(field: string): string;
|
|
26
|
+
type SqlDialect = 'sqlite' | 'postgres' | 'mysql';
|
|
27
|
+
/** The SQL that creates madAuth's tables and indexes in an empty database. */
|
|
28
|
+
declare function createTablesSql(dialect: SqlDialect, schema?: Schema): string;
|
|
29
|
+
|
|
30
|
+
export { MadauthConfig, REDIRECT_ERROR_PARAM, Schema, type SqlDialect, columnName, createApp, createTablesSql, generateSigningKey, tableName };
|