@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.
@@ -13,7 +13,10 @@ import {
13
13
  } from "./chunk-A7UYZRBL.js";
14
14
  import {
15
15
  ADMIN_ROLE,
16
+ DEFAULT_PASSWORD_RULES,
17
+ MAX_MIN_LENGTH,
16
18
  MAX_PASSWORD_LENGTH,
19
+ METHODS,
17
20
  METHODS_WITH_SECOND_FACTOR,
18
21
  SIGN_IN_METHODS,
19
22
  Settings,
@@ -24,7 +27,10 @@ import {
24
27
  checkPasswordPolicy,
25
28
  claimsFromJson,
26
29
  deriveCodeKey,
30
+ emailLinkAccountKey,
31
+ githubAccountKey,
27
32
  googleAccountKey,
33
+ hasSecondFactor,
28
34
  hashCode,
29
35
  hashPassword,
30
36
  initialMethods,
@@ -37,16 +43,18 @@ import {
37
43
  normalizeEmail,
38
44
  parseClaims,
39
45
  parseMethodSettings,
46
+ parsePasswordRules,
40
47
  passwordAccountKey,
41
48
  policyOf,
42
49
  safeEqual,
43
50
  sameClaims,
44
51
  sendsVerificationEmails,
52
+ smsAccountKey,
45
53
  toMadauthUser,
46
54
  totpAccountKey,
47
55
  verifyAgainstDummy,
48
56
  verifyPassword
49
- } from "./chunk-ISJGOCF3.js";
57
+ } from "./chunk-NYIEFI4B.js";
50
58
  import {
51
59
  importSigningKeys
52
60
  } from "./chunk-JDQLSL36.js";
@@ -54,11 +62,503 @@ import {
54
62
  WEBHOOK_TIMEOUT_MS,
55
63
  createWebhookClient,
56
64
  localeOf
57
- } from "./chunk-Y7ZSUROX.js";
65
+ } from "./chunk-SISM7YUZ.js";
58
66
 
59
- // src/routes/google.ts
67
+ // src/routes/oauth.ts
60
68
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
61
69
  import { base64url } from "jose";
70
+ var REDIRECT_ERROR_PARAM = "madauth_error";
71
+ var REDIRECT_NEXT_PARAM = "madauth_next";
72
+ var REDIRECT_FACTORS_PARAM = "madauth_factors";
73
+ var REDIRECT_METHOD_PARAM = "madauth_method";
74
+ var OAUTH_COOKIE = "madauth_oauth";
75
+ var OAUTH_TYP = "madauth-oauth-state+jwt";
76
+ var OAUTH_TTL = 10 * 60;
77
+ async function pkceChallenge(verifier) {
78
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
79
+ return base64url.encode(new Uint8Array(digest));
80
+ }
81
+ async function startOAuth(c, ctx, cookiePath, returnTo) {
82
+ const oauth = {
83
+ state: randomString(),
84
+ nonce: randomString(),
85
+ verifier: randomString(),
86
+ returnTo: returnTo.href,
87
+ locale: localeOf(c.req.query("locale"))
88
+ };
89
+ const token = await signToken(await ctx.keys, ctx.config.issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
90
+ setCookie(c, OAUTH_COOKIE, token, {
91
+ path: cookiePath,
92
+ httpOnly: true,
93
+ secure: ctx.secure,
94
+ sameSite: "Lax",
95
+ maxAge: OAUTH_TTL
96
+ });
97
+ return oauth;
98
+ }
99
+ async function takeOAuthState(c, ctx, cookiePath) {
100
+ const oauth = await readToken(await ctx.keys, ctx.config.issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
101
+ if (!oauth) return null;
102
+ deleteCookie(c, OAUTH_COOKIE, { path: cookiePath, secure: ctx.secure });
103
+ return {
104
+ state: oauth.state,
105
+ nonce: oauth.nonce,
106
+ verifier: oauth.verifier,
107
+ returnTo: oauth.returnTo,
108
+ ...typeof oauth.locale === "string" ? { locale: oauth.locale } : {}
109
+ };
110
+ }
111
+ function redirectBack(c, returnTo, method) {
112
+ return (error2, next, factors = []) => {
113
+ const url = new URL(returnTo);
114
+ if (error2) url.hash = `${REDIRECT_ERROR_PARAM}=${error2}&${REDIRECT_METHOD_PARAM}=${method}`;
115
+ if (next) url.hash = `${REDIRECT_NEXT_PARAM}=${next}${factors.length ? `&${REDIRECT_FACTORS_PARAM}=${factors.join(",")}` : ""}&${REDIRECT_METHOD_PARAM}=${method}`;
116
+ return c.redirect(url.href, 302);
117
+ };
118
+ }
119
+ var METHOD_NAMES = {
120
+ google: "Google",
121
+ github: "GitHub",
122
+ password: "a password",
123
+ totp: "an authenticator app",
124
+ "email-link": "an e-mail link"
125
+ };
126
+ async function resolveProviderUser(ctx, provider, profile, locale) {
127
+ const { users } = ctx;
128
+ const { key } = profile;
129
+ const emailNormalized = normalizeEmail(profile.email);
130
+ const signedIn = (user) => ({ ok: true, user });
131
+ const account = await users.findAccountByKey(key);
132
+ if (account) {
133
+ const user = await users.findById(account.userId);
134
+ if (user) {
135
+ const patch = {
136
+ ...account.email !== profile.email ? { email: profile.email } : {},
137
+ ...profile.secret ? { secret: profile.secret } : {}
138
+ };
139
+ if (Object.keys(patch).length) await users.updateAccount(account.id, patch);
140
+ return signedIn(user);
141
+ }
142
+ await users.deleteAccount(account.id);
143
+ }
144
+ for (let attempt = 0; attempt < 2; attempt++) {
145
+ const existing = await users.findByEmail(emailNormalized);
146
+ if (existing) {
147
+ if (existing.emailVerified && await users.hasSignInMethod(existing.id)) {
148
+ const methods = await users.signInMethods(existing.id, await ctx.settings.methods());
149
+ const how = methods.map((method) => METHOD_NAMES[method]).join(" or ");
150
+ return {
151
+ ok: false,
152
+ error: "other_method",
153
+ message: `This e-mail address signs in with ${how || "another method"}. Use that instead of ${provider.name}.`,
154
+ methods
155
+ };
156
+ }
157
+ const taken = await users.reopenSignUp(existing);
158
+ await users.verifyByProvider(taken);
159
+ if (!await users.linkAccount(taken.id, { key, email: profile.email, secret: profile.secret })) {
160
+ const linked = await users.findAccountByKey(key);
161
+ const owner = linked && await users.findById(linked.userId);
162
+ if (!owner) return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
163
+ return signedIn(owner);
164
+ }
165
+ return signedIn({ ...taken, emailVerified: true });
166
+ }
167
+ if (attempt === 0) {
168
+ const check = await ctx.checkSignup({ email: profile.email, name: profile.name, locale, method: provider.method });
169
+ if (!check.ok) {
170
+ return check.reason === "rejected" ? { ok: false, error: "signup_rejected", message: check.message } : { ok: false, error: "temporarily_unavailable", message: "Signing up is not possible right now. Please try again later." };
171
+ }
172
+ }
173
+ const user = await users.createUser({
174
+ email: profile.email,
175
+ emailNormalized,
176
+ name: profile.name ?? null,
177
+ emailVerified: true,
178
+ account: { key, email: profile.email, secret: profile.secret }
179
+ });
180
+ if (user) return signedIn(user);
181
+ }
182
+ return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
183
+ }
184
+
185
+ // src/app.ts
186
+ import { Hono } from "hono";
187
+ import { deleteCookie as deleteCookie6, getCookie as getCookie6, setCookie as setCookie6 } from "hono/cookie";
188
+ import { cors } from "hono/cors";
189
+
190
+ // src/routes/helpers.ts
191
+ async function body(c) {
192
+ const data = await c.req.json().catch(() => null);
193
+ return data && typeof data === "object" ? data : {};
194
+ }
195
+ function error(c, status, code, message) {
196
+ return c.json({ error: code, message }, status);
197
+ }
198
+ var unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
199
+ var smsUnavailable = (c) => error(c, 503, "temporarily_unavailable", "The SMS can not be sent right now. Please try again later.");
200
+ function originHost(ctx, c) {
201
+ const origin = c.req.header("origin");
202
+ return URL.canParse(origin ?? "") ? new URL(origin).hostname : new URL(ctx.config.issuer).hostname;
203
+ }
204
+ var accepted = (c) => c.json({}, 202);
205
+ var noSession = (c) => error(c, 401, "no_session", "Please sign in first.");
206
+ function redirectTarget(ctx, c, value) {
207
+ const url = ctx.allowedUrl(value);
208
+ return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
209
+ }
210
+ var MAX_NAME_LENGTH = 100;
211
+ function nameOf(value) {
212
+ return typeof value === "string" && value.trim() ? value.trim().slice(0, MAX_NAME_LENGTH) : null;
213
+ }
214
+ function signInAnswer(c, step) {
215
+ if ("disabled" in step) return error(c, 403, "method_disabled", "This way of signing in is switched off.");
216
+ if ("next" in step) {
217
+ const rest = { method: step.method, factors: step.factors };
218
+ if (step.next === "totp") return c.json({ error: "totp_required", message: "Enter the code from your authenticator app to finish signing in.", ...rest }, 401);
219
+ if (step.next === "sms") return c.json({ error: "sms_required", message: "Enter the code we sent to your phone to finish signing in.", ...rest }, 401);
220
+ return c.json({ error: "totp_setup_required", message: "Set up an authenticator app to finish signing in.", ...rest }, 401);
221
+ }
222
+ return c.json({ user: step.user });
223
+ }
224
+
225
+ // src/routes/emails.ts
226
+ var MAIL_INTERVAL_MS = 60 * 1e3;
227
+ var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
228
+ var RESET_TTL_MS = 30 * 60 * 1e3;
229
+ var SIGNIN_TTL_MS = 15 * 60 * 1e3;
230
+ var VERIFY_LINK_PARAM = "madauth_verify";
231
+ var RESET_LINK_PARAM = "madauth_reset";
232
+ var SIGNIN_LINK_PARAM = "madauth_signin";
233
+ var LINKS = {
234
+ verify: { ttl: VERIFY_TTL_MS, param: VERIFY_LINK_PARAM, type: "email.send_verification" },
235
+ reset: { ttl: RESET_TTL_MS, param: RESET_LINK_PARAM, type: "email.send_password_reset" },
236
+ signin: { ttl: SIGNIN_TTL_MS, param: SIGNIN_LINK_PARAM, type: "email.send_signin_link" }
237
+ };
238
+ function createEmails(ctx, users, webhook) {
239
+ const sendEmail = async (user, type, data) => {
240
+ if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
241
+ const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
242
+ if (!result.ok) {
243
+ console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
244
+ return false;
245
+ }
246
+ await users.updateUser(user.id, { lastMailAt: Date.now() });
247
+ return true;
248
+ };
249
+ return {
250
+ sendEmail,
251
+ async sendLinkEmail(user, purpose, target, locale) {
252
+ if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
253
+ const { ttl, param, type } = LINKS[purpose];
254
+ const { token, code } = await users.issueVerification(user.id, purpose, ttl);
255
+ const link = new URL(target);
256
+ link.hash = `${param}=${token}`;
257
+ return sendEmail(user, type, { link: link.href, code, expiresAt: Date.now() + ttl, site: target.host, locale });
258
+ },
259
+ async sendAccountEmail(user, type, target, locale) {
260
+ if (!webhook.wants(type)) return true;
261
+ const methods = await users.signInMethods(user.id, await ctx.settings.methods());
262
+ return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
263
+ },
264
+ async consume(data, purpose) {
265
+ if (typeof data.token === "string" && data.token) {
266
+ return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
267
+ }
268
+ if (isValidEmail(data.email) && typeof data.code === "string") {
269
+ const user = await users.findByEmail(normalizeEmail(data.email));
270
+ if (!user) return { userId: null, via: "code" };
271
+ return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
272
+ }
273
+ return { userId: null, via: "code" };
274
+ },
275
+ invalidVerification(c, { via, locked }) {
276
+ if (locked) {
277
+ return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
278
+ }
279
+ return via === "link" ? error(c, 400, "link_invalid", "The link is invalid, expired or was already used.") : error(c, 400, "code_invalid", "The code is wrong or expired.");
280
+ }
281
+ };
282
+ }
283
+
284
+ // src/routes/email.ts
285
+ function emailRoutes(app, ctx, users, webhook) {
286
+ const emails = createEmails(ctx, users, webhook);
287
+ const signUpMethod = async (userId) => await users.passwordAccount(userId) ? "password" : "totp";
288
+ app.post("/auth/email/send-verification", async (c) => {
289
+ const data = await body(c);
290
+ const target = redirectTarget(ctx, c, data.redirectTo);
291
+ if (target instanceof Response) return target;
292
+ if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
293
+ const user = await users.findByEmail(normalizeEmail(data.email));
294
+ if (user && !user.emailVerified && await ctx.enabled(c, await signUpMethod(user.id))) {
295
+ if (!await emails.sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
296
+ }
297
+ return accepted(c);
298
+ });
299
+ const disabled = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
300
+ app.post("/auth/email/verify", async (c) => {
301
+ const data = await body(c);
302
+ const waiting = typeof data.token === "string" && data.token ? await users.peekLinkToken(data.token, "verify") : isValidEmail(data.email) ? (await users.findByEmail(normalizeEmail(data.email)))?.id ?? null : null;
303
+ if (waiting && !await ctx.enabled(c, await signUpMethod(waiting))) return disabled(c);
304
+ const consumed = await emails.consume(data, "verify");
305
+ const { userId, via } = consumed;
306
+ const user = userId ? await users.findById(userId) : null;
307
+ if (!user) return emails.invalidVerification(c, consumed);
308
+ const method = await signUpMethod(user.id);
309
+ if (!await ctx.enabled(c, method)) return disabled(c);
310
+ if (!user.emailVerified) await users.dropUnprovenAccounts(user.id, method === "password" ? "password" : void 0);
311
+ await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
312
+ const verified = { ...user.emailVerified ? user : await users.reopenSignUp(user), emailVerified: true, wrongCodes: 0 };
313
+ await ctx.emit("user.email_verified", { user: toMadauthUser(verified), method, via });
314
+ return signInAnswer(c, await ctx.secondStep(c, verified, method === "password" ? ["pwd"] : [], method));
315
+ });
316
+ }
317
+
318
+ // src/routes/email-link.ts
319
+ function emailLinkRoutes(app, ctx, users, webhook) {
320
+ const emails = createEmails(ctx, users, webhook);
321
+ app.use("/auth/email-link/*", async (c, next) => {
322
+ if (!await ctx.enabled(c, "email-link")) {
323
+ return error(c, 403, "method_disabled", "Signing in with an e-mail link is switched off.");
324
+ }
325
+ await next();
326
+ });
327
+ app.post("/auth/email-link/send", async (c) => {
328
+ const data = await body(c);
329
+ const target = redirectTarget(ctx, c, data.redirectTo);
330
+ if (target instanceof Response) return target;
331
+ if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
332
+ const email = data.email.trim();
333
+ const locale = localeOf(data.locale);
334
+ const send = async (user2) => await emails.sendLinkEmail(user2, "signin", target, locale) ? accepted(c) : unavailable(c);
335
+ const existing = await users.findByEmail(normalizeEmail(email));
336
+ if (existing && await users.emailLinkAccount(existing.id)) return send(existing);
337
+ if (existing?.emailVerified && await users.hasSignInMethod(existing.id)) {
338
+ return await emails.sendAccountEmail(existing, "email.send_already_registered", target, locale) ? accepted(c) : unavailable(c);
339
+ }
340
+ const check = await ctx.checkSignup({ email, locale, method: "email-link" });
341
+ if (!check.ok) return check.reason === "rejected" ? error(c, 403, "signup_rejected", check.message) : unavailable(c);
342
+ if (existing) {
343
+ if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
344
+ if (!existing.emailVerified) await users.dropUnprovenAccounts(existing.id, "email-link");
345
+ if (!await users.linkAccount(existing.id, { key: emailLinkAccountKey(existing.id) })) return accepted(c);
346
+ return send(await users.reopenSignUp(existing));
347
+ }
348
+ const user = await users.createUser({
349
+ email,
350
+ emailNormalized: normalizeEmail(email),
351
+ name: null,
352
+ emailVerified: false,
353
+ account: (userId) => ({ key: emailLinkAccountKey(userId) })
354
+ });
355
+ if (!user) return accepted(c);
356
+ return send(user);
357
+ });
358
+ app.post("/auth/email-link/verify", async (c) => {
359
+ const consumed = await emails.consume(await body(c), "signin");
360
+ const found = consumed.userId ? await users.findById(consumed.userId) : null;
361
+ if (!found || !await users.emailLinkAccount(found.id)) return emails.invalidVerification(c, consumed);
362
+ const user = found.emailVerified ? found : await users.reopenSignUp(found);
363
+ if (!user.emailVerified) {
364
+ await users.verifyByProvider(user, "email-link");
365
+ await ctx.emit("user.email_verified", { user: toMadauthUser({ ...user, emailVerified: true }), method: "email-link", via: consumed.via });
366
+ } else {
367
+ await users.updateUser(user.id, { wrongCodes: 0 });
368
+ }
369
+ const verified = { ...user, emailVerified: true, wrongCodes: 0 };
370
+ return signInAnswer(c, await ctx.secondStep(c, verified, ["email"], "email-link"));
371
+ });
372
+ }
373
+
374
+ // src/crypto.ts
375
+ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "crypto";
376
+ var ENCRYPTION_VERSION = "v1";
377
+ var IV_BYTES = 12;
378
+ var TAG_BYTES = 16;
379
+ function deriveKey(signingKeyD, info) {
380
+ return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", info, 32));
381
+ }
382
+ function encrypt(key, secret) {
383
+ const iv = randomBytes(IV_BYTES);
384
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
385
+ const encrypted = Buffer.concat([cipher.update(secret), cipher.final()]);
386
+ const parts = [iv, encrypted, cipher.getAuthTag()].map((part) => part.toString("base64url"));
387
+ return [ENCRYPTION_VERSION, ...parts].join(".");
388
+ }
389
+ function decrypt(key, stored) {
390
+ const [version, iv, encrypted, tag, ...rest] = stored.split(".");
391
+ if (version !== ENCRYPTION_VERSION || !iv || !encrypted || !tag || rest.length) return null;
392
+ const ivBytes = Buffer.from(iv, "base64url");
393
+ const tagBytes = Buffer.from(tag, "base64url");
394
+ if (ivBytes.length !== IV_BYTES || tagBytes.length !== TAG_BYTES) return null;
395
+ try {
396
+ const decipher = createDecipheriv("aes-256-gcm", key, ivBytes);
397
+ decipher.setAuthTag(tagBytes);
398
+ return Buffer.concat([decipher.update(Buffer.from(encrypted, "base64url")), decipher.final()]);
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ // src/github.ts
405
+ var TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token";
406
+ var API = "https://api.github.com";
407
+ var API_VERSION = "2022-11-28";
408
+ var USER_AGENT = "madauth";
409
+ var NOREPLY_DOMAIN = "@users.noreply.github.com";
410
+ var TIMEOUT_MS = 1e4;
411
+ var REVOKE_TIMEOUT_MS = 5e3;
412
+ function deriveTokenKey(signingKeyD) {
413
+ return deriveKey(signingKeyD, "madauth provider token");
414
+ }
415
+ var apiHeaders = (token) => ({
416
+ Authorization: `Bearer ${token}`,
417
+ Accept: "application/vnd.github+json",
418
+ "X-GitHub-Api-Version": API_VERSION,
419
+ "User-Agent": USER_AGENT
420
+ });
421
+ async function exchangeGitHubCode(app, code, redirectUri, verifier) {
422
+ try {
423
+ const res = await fetch(TOKEN_ENDPOINT, {
424
+ method: "POST",
425
+ headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": USER_AGENT },
426
+ body: new URLSearchParams({
427
+ client_id: app.clientId,
428
+ client_secret: app.clientSecret,
429
+ code,
430
+ redirect_uri: redirectUri,
431
+ code_verifier: verifier
432
+ }),
433
+ signal: AbortSignal.timeout(TIMEOUT_MS)
434
+ });
435
+ if (!res.ok) {
436
+ await res.body?.cancel();
437
+ return null;
438
+ }
439
+ const body2 = await res.json();
440
+ return typeof body2.access_token === "string" && body2.access_token ? body2.access_token : null;
441
+ } catch {
442
+ return null;
443
+ }
444
+ }
445
+ async function fetchGitHubAccount(token) {
446
+ const failed = (reason) => ({ ok: false, error: "verification_failed", reason });
447
+ let user;
448
+ let emails;
449
+ try {
450
+ const [userRes, emailsRes] = await Promise.all([
451
+ fetch(`${API}/user`, { headers: apiHeaders(token), signal: AbortSignal.timeout(TIMEOUT_MS) }),
452
+ fetch(`${API}/user/emails`, { headers: apiHeaders(token), signal: AbortSignal.timeout(TIMEOUT_MS) })
453
+ ]);
454
+ if (!userRes.ok || !emailsRes.ok) {
455
+ await Promise.all([userRes.body?.cancel(), emailsRes.body?.cancel()]);
456
+ return failed(!userRes.ok ? `GET /user answered ${userRes.status}` : `GET /user/emails answered ${emailsRes.status}`);
457
+ }
458
+ user = await userRes.json();
459
+ emails = await emailsRes.json();
460
+ } catch (e) {
461
+ return failed(e.message);
462
+ }
463
+ const id = typeof user.id === "number" ? String(user.id) : typeof user.id === "string" ? user.id : "";
464
+ if (!id || typeof user.login !== "string") return failed("GET /user did not return an account");
465
+ if (!Array.isArray(emails)) return failed("GET /user/emails did not return a list");
466
+ const verified = emails.filter((e) => typeof e.email === "string" && e.email && e.verified === true && !e.email.endsWith(NOREPLY_DOMAIN));
467
+ const address = verified.find((e) => e.primary === true) ?? verified[0];
468
+ if (!address) return { ok: false, error: "email_unverified", reason: "the GitHub account has no verified e-mail address" };
469
+ const account = {
470
+ id,
471
+ email: address.email,
472
+ name: typeof user.name === "string" && user.name.trim() ? user.name.trim() : user.login
473
+ };
474
+ if (typeof user.avatar_url === "string" && user.avatar_url) account.picture = user.avatar_url;
475
+ return { ok: true, account };
476
+ }
477
+ async function revokeGitHubGrant(app, token) {
478
+ try {
479
+ const res = await fetch(`${API}/applications/${encodeURIComponent(app.clientId)}/grant`, {
480
+ method: "DELETE",
481
+ headers: {
482
+ Authorization: `Basic ${Buffer.from(`${app.clientId}:${app.clientSecret}`).toString("base64")}`,
483
+ Accept: "application/vnd.github+json",
484
+ "Content-Type": "application/json",
485
+ "X-GitHub-Api-Version": API_VERSION,
486
+ "User-Agent": USER_AGENT
487
+ },
488
+ body: JSON.stringify({ access_token: token }),
489
+ signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS)
490
+ });
491
+ await res.body?.cancel();
492
+ return res.status;
493
+ } catch {
494
+ return null;
495
+ }
496
+ }
497
+
498
+ // src/routes/github.ts
499
+ var GITHUB_AUTH_ENDPOINT = "https://github.com/login/oauth/authorize";
500
+ var COOKIE_PATH = "/auth/github";
501
+ var GITHUB = { method: "github", name: "GitHub" };
502
+ function githubRoutes(app, ctx, github, tokenKey) {
503
+ const callbackUrl = `${ctx.config.issuer}/auth/github/callback`;
504
+ app.get("/auth/github/start", async (c) => {
505
+ const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
506
+ if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
507
+ if (!await ctx.enabled(c, "github")) return redirectBack(c, returnUrl.href, "github")("method_disabled");
508
+ const oauth = await startOAuth(c, ctx, COOKIE_PATH, returnUrl);
509
+ const url = new URL(GITHUB_AUTH_ENDPOINT);
510
+ url.search = new URLSearchParams({
511
+ client_id: github.clientId,
512
+ redirect_uri: callbackUrl,
513
+ // Only the addresses: the profile's id, name and picture come with any token.
514
+ scope: "user:email",
515
+ state: oauth.state,
516
+ code_challenge: await pkceChallenge(oauth.verifier),
517
+ code_challenge_method: "S256"
518
+ }).toString();
519
+ return c.redirect(url.href, 302);
520
+ });
521
+ app.get("/auth/github/callback", async (c) => {
522
+ const oauth = await takeOAuthState(c, ctx, COOKIE_PATH);
523
+ if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
524
+ const back = redirectBack(c, oauth.returnTo, "github");
525
+ if (!await ctx.enabled(c, "github")) return back("method_disabled");
526
+ if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
527
+ const code = c.req.query("code");
528
+ if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
529
+ const token = await exchangeGitHubCode(github, code, callbackUrl, oauth.verifier);
530
+ if (!token) return back("verification_failed");
531
+ const result = await fetchGitHubAccount(token);
532
+ if (!result.ok) return back(result.error);
533
+ const { account } = result;
534
+ const resolved = await resolveProviderUser(
535
+ ctx,
536
+ GITHUB,
537
+ { key: githubAccountKey(account.id), email: account.email, name: account.name, secret: encrypt(tokenKey, Buffer.from(token)) },
538
+ oauth.locale
539
+ );
540
+ if (!resolved.ok) return back(resolved.error);
541
+ const step = await ctx.secondStep(c, resolved.user, ["github"], "github", { name: account.name, picture: account.picture });
542
+ if ("disabled" in step) return back("method_disabled");
543
+ if ("next" in step) return back(void 0, step.next, step.factors);
544
+ return back();
545
+ });
546
+ }
547
+ async function revokeGrant(github, tokenKey, account) {
548
+ if (!account) return;
549
+ const token = account.secret ? decrypt(tokenKey, account.secret) : null;
550
+ if (!token) {
551
+ console.error(`[madauth] The GitHub token of user ${account.userId} can't be read (was MADAUTH_SIGNING_KEY changed?): the authorization stays at GitHub until the user removes it.`);
552
+ return;
553
+ }
554
+ const status = await revokeGitHubGrant(github, token.toString());
555
+ if (status !== 204 && status !== 404) {
556
+ console.error(`[madauth] GitHub did not revoke the authorization of user ${account.userId}${status ? ` (HTTP ${status})` : ""}: it stays until the user removes it.`);
557
+ }
558
+ }
559
+
560
+ // src/routes/google.ts
561
+ import { deleteCookie as deleteCookie2, getCookie as getCookie2, setCookie as setCookie2 } from "hono/cookie";
62
562
 
63
563
  // src/google.ts
64
564
  import { createRemoteJWKSet, jwtVerify } from "jose";
@@ -94,109 +594,26 @@ async function verifyGoogleIdToken(idToken, opts) {
94
594
  return { ok: true, profile };
95
595
  }
96
596
 
97
- // src/routes/helpers.ts
98
- async function body(c) {
99
- const data = await c.req.json().catch(() => null);
100
- return data && typeof data === "object" ? data : {};
101
- }
102
- function error(c, status, code, message) {
103
- return c.json({ error: code, message }, status);
104
- }
105
- var unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
106
- var accepted = (c) => c.json({}, 202);
107
- var noSession = (c) => error(c, 401, "no_session", "Please sign in first.");
108
- function redirectTarget(ctx, c, value) {
109
- const url = ctx.allowedUrl(value);
110
- return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
111
- }
112
- var MAX_NAME_LENGTH = 100;
113
- function nameOf(value) {
114
- return typeof value === "string" && value.trim() ? value.trim().slice(0, MAX_NAME_LENGTH) : null;
115
- }
116
- function signInAnswer(c, step) {
117
- if ("disabled" in step) return error(c, 403, "method_disabled", "This way of signing in is switched off.");
118
- if ("next" in step) {
119
- return step.next === "totp" ? c.json({ error: "totp_required", message: "Enter the code from your authenticator app to finish signing in.", method: step.method }, 401) : c.json({ error: "totp_setup_required", message: "Set up an authenticator app to finish signing in.", method: step.method }, 401);
120
- }
121
- return c.json({ user: step.user });
122
- }
123
-
124
597
  // src/routes/google.ts
125
598
  var NONCE_COOKIE = "madauth_nonce";
126
599
  var NONCE_TYP = "madauth-nonce+jwt";
127
600
  var NONCE_TTL = 5 * 60;
128
- var OAUTH_COOKIE = "madauth_oauth";
129
- var OAUTH_TYP = "madauth-oauth-state+jwt";
130
- var OAUTH_TTL = 10 * 60;
601
+ var COOKIE_PATH2 = "/auth/google";
131
602
  var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
132
603
  var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
133
- var REDIRECT_ERROR_PARAM = "madauth_error";
134
- var REDIRECT_NEXT_PARAM = "madauth_next";
135
- async function pkceChallenge(verifier) {
136
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
137
- return base64url.encode(new Uint8Array(digest));
138
- }
604
+ var GOOGLE = { method: "google", name: "Google" };
605
+ var toProfile = (profile) => ({ key: googleAccountKey(profile.sub), email: profile.email, name: profile.name });
139
606
  function googleRoutes(app, ctx, google) {
140
- const { config, keys, secure, users } = ctx;
607
+ const { config, keys, secure } = ctx;
141
608
  const { issuer } = config;
142
609
  const callbackUrl = `${issuer}/auth/google/callback`;
143
- const resolveUser = async (profile, locale) => {
144
- const key = googleAccountKey(profile.sub);
145
- const emailNormalized = normalizeEmail(profile.email);
146
- const signedIn = (user) => ({ ok: true, user });
147
- const account = await users.findAccountByKey(key);
148
- if (account) {
149
- const user = await users.findById(account.userId);
150
- if (user) {
151
- if (account.email !== profile.email) await users.updateAccount(account.id, { email: profile.email });
152
- return signedIn(user);
153
- }
154
- await users.deleteAccount(account.id);
155
- }
156
- for (let attempt = 0; attempt < 2; attempt++) {
157
- const existing = await users.findByEmail(emailNormalized);
158
- if (existing) {
159
- if (existing.emailVerified && await users.hasAccounts(existing.id)) {
160
- const methods = await users.signInMethods(existing.id, await ctx.settings.methods());
161
- const how = methods.map((method) => ({ password: "a password", totp: "an authenticator app", google: "Google" })[method]).join(" or ");
162
- return { ok: false, error: "other_method", message: `This e-mail address signs in with ${how || "another method"}. Use that instead of Google.`, methods };
163
- }
164
- await users.verifyByProvider(existing);
165
- if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
166
- const linked = await users.findAccountByKey(key);
167
- const owner = linked && await users.findById(linked.userId);
168
- if (!owner) return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
169
- return signedIn(owner);
170
- }
171
- return signedIn(existing);
172
- }
173
- if (attempt === 0) {
174
- const check = await ctx.checkSignup({ email: profile.email, name: profile.name, locale, method: "google" });
175
- if (!check.ok) {
176
- return check.reason === "rejected" ? { ok: false, error: "signup_rejected", message: check.message } : { ok: false, error: "temporarily_unavailable", message: "Signing up is not possible right now. Please try again later." };
177
- }
178
- }
179
- const user = await users.createUser({
180
- email: profile.email,
181
- emailNormalized,
182
- name: profile.name ?? null,
183
- emailVerified: true,
184
- account: { key, email: profile.email }
185
- });
186
- if (user) {
187
- await ctx.emit("user.created", { user: toMadauthUser(user), method: "google" });
188
- return signedIn(user);
189
- }
190
- }
191
- return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
192
- };
193
610
  const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
194
611
  app.post("/auth/google/nonce", async (c) => {
195
612
  if (!await ctx.enabled(c, "google")) return disabled(c);
196
613
  const nonce = randomString();
197
614
  const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
198
- setCookie(c, NONCE_COOKIE, token, {
199
- path: "/auth/google",
615
+ setCookie2(c, NONCE_COOKIE, token, {
616
+ path: COOKIE_PATH2,
200
617
  httpOnly: true,
201
618
  secure,
202
619
  sameSite: "Strict",
@@ -210,7 +627,7 @@ function googleRoutes(app, ctx, google) {
210
627
  if (typeof body2.credential !== "string" || !body2.credential) {
211
628
  return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
212
629
  }
213
- const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
630
+ const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie2(c, NONCE_COOKIE));
214
631
  if (!nonceClaims) {
215
632
  return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
216
633
  }
@@ -222,8 +639,8 @@ function googleRoutes(app, ctx, google) {
222
639
  if (!result.ok) {
223
640
  return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
224
641
  }
225
- deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
226
- const resolved = await resolveUser(result.profile, localeOf(body2.locale));
642
+ deleteCookie2(c, NONCE_COOKIE, { path: COOKIE_PATH2, secure });
643
+ const resolved = await resolveProviderUser(ctx, GOOGLE, toProfile(result.profile), localeOf(body2.locale));
227
644
  if (!resolved.ok) {
228
645
  if (resolved.error === "other_method") return c.json({ error: resolved.error, message: resolved.message, methods: resolved.methods }, 403);
229
646
  return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
@@ -232,27 +649,13 @@ function googleRoutes(app, ctx, google) {
232
649
  });
233
650
  const requireCodeFlow = async (c, next) => {
234
651
  if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
235
- if (!await ctx.enabled(c, "google")) return disabled(c);
236
652
  await next();
237
653
  };
238
654
  app.get("/auth/google/start", requireCodeFlow, async (c) => {
239
655
  const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
240
656
  if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
241
- const oauth = {
242
- state: randomString(),
243
- nonce: randomString(),
244
- verifier: randomString(),
245
- returnTo: returnUrl.href,
246
- locale: localeOf(c.req.query("locale"))
247
- };
248
- const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
249
- setCookie(c, OAUTH_COOKIE, token, {
250
- path: "/auth/google",
251
- httpOnly: true,
252
- secure,
253
- sameSite: "Lax",
254
- maxAge: OAUTH_TTL
255
- });
657
+ if (!await ctx.enabled(c, "google")) return redirectBack(c, returnUrl.href, "google")("method_disabled");
658
+ const oauth = await startOAuth(c, ctx, COOKIE_PATH2, returnUrl);
256
659
  const url = new URL(GOOGLE_AUTH_ENDPOINT);
257
660
  url.search = new URLSearchParams({
258
661
  client_id: google.clientId,
@@ -267,15 +670,10 @@ function googleRoutes(app, ctx, google) {
267
670
  return c.redirect(url.href, 302);
268
671
  });
269
672
  app.get("/auth/google/callback", requireCodeFlow, async (c) => {
270
- const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
673
+ const oauth = await takeOAuthState(c, ctx, COOKIE_PATH2);
271
674
  if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
272
- deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
273
- const back = (error2, next) => {
274
- const url = new URL(oauth.returnTo);
275
- if (error2) url.hash = `${REDIRECT_ERROR_PARAM}=${error2}`;
276
- if (next) url.hash = `${REDIRECT_NEXT_PARAM}=${next}`;
277
- return c.redirect(url.href, 302);
278
- };
675
+ const back = redirectBack(c, oauth.returnTo, "google");
676
+ if (!await ctx.enabled(c, "google")) return back("method_disabled");
279
677
  if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
280
678
  const code = c.req.query("code");
281
679
  if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
@@ -305,111 +703,15 @@ function googleRoutes(app, ctx, google) {
305
703
  keys: config.jwksResolver
306
704
  });
307
705
  if (!result.ok) return back(result.error);
308
- const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
706
+ const resolved = await resolveProviderUser(ctx, GOOGLE, toProfile(result.profile), oauth.locale);
309
707
  if (!resolved.ok) return back(resolved.error);
310
708
  const step = await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile);
311
709
  if ("disabled" in step) return back("method_disabled");
312
- if ("next" in step) return back(void 0, step.next);
710
+ if ("next" in step) return back(void 0, step.next, step.factors);
313
711
  return back();
314
712
  });
315
713
  }
316
714
 
317
- // src/app.ts
318
- import { Hono } from "hono";
319
- import { deleteCookie as deleteCookie3, getCookie as getCookie3, setCookie as setCookie3 } from "hono/cookie";
320
- import { cors } from "hono/cors";
321
-
322
- // src/routes/emails.ts
323
- var MAIL_INTERVAL_MS = 60 * 1e3;
324
- var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
325
- var RESET_TTL_MS = 30 * 60 * 1e3;
326
- var VERIFY_LINK_PARAM = "madauth_verify";
327
- var RESET_LINK_PARAM = "madauth_reset";
328
- function createEmails(ctx, users, webhook) {
329
- const sendEmail = async (user, type, data) => {
330
- if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
331
- const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
332
- if (!result.ok) {
333
- console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
334
- return false;
335
- }
336
- await users.updateUser(user.id, { lastMailAt: Date.now() });
337
- return true;
338
- };
339
- return {
340
- sendEmail,
341
- async sendLinkEmail(user, purpose, target, locale) {
342
- if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
343
- const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
344
- const { token, code } = await users.issueVerification(user.id, purpose, ttl);
345
- const link = new URL(target);
346
- link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
347
- return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
348
- link: link.href,
349
- code,
350
- expiresAt: Date.now() + ttl,
351
- site: target.host,
352
- locale
353
- });
354
- },
355
- async sendAccountEmail(user, type, target, locale) {
356
- if (!webhook.wants(type)) return true;
357
- const methods = await users.signInMethods(user.id, await ctx.settings.methods());
358
- return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
359
- },
360
- async consume(data, purpose) {
361
- if (typeof data.token === "string" && data.token) {
362
- return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
363
- }
364
- if (isValidEmail(data.email) && typeof data.code === "string") {
365
- const user = await users.findByEmail(normalizeEmail(data.email));
366
- if (!user) return { userId: null, via: "code" };
367
- return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
368
- }
369
- return { userId: null, via: "code" };
370
- },
371
- invalidVerification(c, { via, locked }) {
372
- if (locked) {
373
- return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
374
- }
375
- return via === "link" ? error(c, 400, "link_invalid", "The link is invalid, expired or was already used.") : error(c, 400, "code_invalid", "The code is wrong or expired.");
376
- }
377
- };
378
- }
379
-
380
- // src/routes/email.ts
381
- function emailRoutes(app, ctx, users, webhook) {
382
- const emails = createEmails(ctx, users, webhook);
383
- const signUpMethod = async (userId) => await users.passwordAccount(userId) ? "password" : "totp";
384
- app.post("/auth/email/send-verification", async (c) => {
385
- const data = await body(c);
386
- const target = redirectTarget(ctx, c, data.redirectTo);
387
- if (target instanceof Response) return target;
388
- if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
389
- const user = await users.findByEmail(normalizeEmail(data.email));
390
- if (user && !user.emailVerified && await ctx.enabled(c, await signUpMethod(user.id))) {
391
- if (!await emails.sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
392
- }
393
- return accepted(c);
394
- });
395
- const disabled = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
396
- app.post("/auth/email/verify", async (c) => {
397
- const data = await body(c);
398
- const waiting = typeof data.token === "string" && data.token ? await users.peekLinkToken(data.token, "verify") : isValidEmail(data.email) ? (await users.findByEmail(normalizeEmail(data.email)))?.id ?? null : null;
399
- if (waiting && !await ctx.enabled(c, await signUpMethod(waiting))) return disabled(c);
400
- const consumed = await emails.consume(data, "verify");
401
- const { userId, via } = consumed;
402
- const user = userId ? await users.findById(userId) : null;
403
- if (!user) return emails.invalidVerification(c, consumed);
404
- const method = await signUpMethod(user.id);
405
- if (!await ctx.enabled(c, method)) return disabled(c);
406
- await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
407
- const verified = { ...user, emailVerified: true, wrongCodes: 0 };
408
- await ctx.emit("email.verified", { user: toMadauthUser(verified), via });
409
- return signInAnswer(c, await ctx.secondStep(c, verified, method === "password" ? ["pwd"] : [], method));
410
- });
411
- }
412
-
413
715
  // src/routes/lockout.ts
414
716
  var FREE_ATTEMPTS = 5;
415
717
  var MAX_LOCK_MS = 15 * 60 * 1e3;
@@ -428,8 +730,16 @@ async function countSignInAttempt(c, users, account, now = Date.now()) {
428
730
  }
429
731
 
430
732
  // src/routes/password.ts
431
- function passwordRoutes(app, ctx, password, users, webhook) {
432
- const { minLength } = password;
733
+ function weakPassword(c, problem) {
734
+ const { message, ...details } = problem;
735
+ return c.json({ error: "weak_password", message, ...details }, 400);
736
+ }
737
+ function passwordRoutes(app, ctx, users, webhook) {
738
+ const refused = async (c, password, userInputs) => {
739
+ const words = userInputs.filter((word) => !!word);
740
+ const problem = await checkPasswordPolicy(password, await ctx.settings.passwordRules(), words);
741
+ return problem ? weakPassword(c, problem) : null;
742
+ };
433
743
  const emails = createEmails(ctx, users, webhook);
434
744
  app.use("/auth/password/*", async (c, next) => {
435
745
  if (!await ctx.enabled(c, "password")) return error(c, 403, "method_disabled", "E-mail & password sign-in is switched off.");
@@ -446,8 +756,8 @@ function passwordRoutes(app, ctx, password, users, webhook) {
446
756
  await verifyAgainstDummy(pw);
447
757
  return wrong();
448
758
  }
449
- const refused = await countSignInAttempt(c, users, account);
450
- if (refused) return refused;
759
+ const refused2 = await countSignInAttempt(c, users, account);
760
+ if (refused2) return refused2;
451
761
  const { ok, needsRehash } = await verifyPassword(pw, account.secret);
452
762
  if (!ok) return wrong();
453
763
  await users.updateAccount(account.id, {
@@ -465,23 +775,25 @@ function passwordRoutes(app, ctx, password, users, webhook) {
465
775
  const target = redirectTarget(ctx, c, data.redirectTo);
466
776
  if (target instanceof Response) return target;
467
777
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
468
- const policy = checkPasswordPolicy(data.password, minLength);
469
- if (policy) return error(c, 400, "weak_password", policy);
470
778
  const name = nameOf(data.name);
471
779
  const email = data.email.trim();
780
+ const weak = await refused(c, data.password, [email, name]);
781
+ if (weak) return weak;
472
782
  const locale = localeOf(data.locale);
473
783
  const check = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
474
784
  if (!check.ok) return check.reason === "rejected" ? error(c, 403, "signup_rejected", check.message) : unavailable(c);
475
785
  const passwordHash = await hashPassword(data.password);
476
786
  const existing = await users.findByEmail(normalizeEmail(email));
477
- if (existing?.emailVerified && await users.hasAccounts(existing.id)) {
478
- return await emails.sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
787
+ if (existing?.emailVerified && await users.hasSignInMethod(existing.id)) {
788
+ return await emails.sendAccountEmail(existing, "email.send_already_registered", target, locale) ? accepted(c) : unavailable(c);
479
789
  }
480
790
  if (existing) {
481
791
  if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
792
+ if (!existing.emailVerified) await users.dropUnprovenAccounts(existing.id, "password");
482
793
  const account = await users.passwordAccount(existing.id);
483
794
  if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
484
- else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
795
+ else if (!await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash })) return accepted(c);
796
+ await users.reopenSignUp(existing);
485
797
  await users.updateUser(existing.id, { name });
486
798
  return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
487
799
  }
@@ -493,7 +805,6 @@ function passwordRoutes(app, ctx, password, users, webhook) {
493
805
  account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
494
806
  });
495
807
  if (!user) return accepted(c);
496
- await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
497
808
  return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
498
809
  });
499
810
  app.post("/auth/password/send-reset", async (c) => {
@@ -506,14 +817,14 @@ function passwordRoutes(app, ctx, password, users, webhook) {
506
817
  if (user && await users.passwordAccount(user.id)) {
507
818
  if (!await emails.sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
508
819
  } else if (user) {
509
- if (!await emails.sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
820
+ if (!await emails.sendAccountEmail(user, "email.send_password_reset_no_password", target, locale)) return unavailable(c);
510
821
  }
511
822
  return accepted(c);
512
823
  });
513
824
  app.post("/auth/password/reset", async (c) => {
514
825
  const data = await body(c);
515
- const policy = checkPasswordPolicy(data.password, minLength);
516
- if (policy) return error(c, 400, "weak_password", policy);
826
+ const weak = await refused(c, data.password, [isValidEmail(data.email) ? data.email.trim() : null]);
827
+ if (weak) return weak;
517
828
  const consumed = await emails.consume(data, "reset");
518
829
  const user = consumed.userId ? await users.findById(consumed.userId) : null;
519
830
  const account = user ? await users.passwordAccount(user.id) : null;
@@ -523,17 +834,216 @@ function passwordRoutes(app, ctx, password, users, webhook) {
523
834
  const sessionVersion = user.sessionVersion + 1;
524
835
  await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
525
836
  await users.clearVerifications(user.id);
526
- const reset = { ...user, emailVerified: true, sessionVersion, wrongCodes: 0 };
527
- await ctx.emit("email.password_reset", { user: toMadauthUser(reset) });
837
+ const reset = { ...user.emailVerified ? user : await users.reopenSignUp(user), emailVerified: true, sessionVersion, wrongCodes: 0 };
838
+ await ctx.emit("user.password_reset", { user: toMadauthUser(reset) });
528
839
  return signInAnswer(c, await ctx.secondStep(c, reset, ["pwd"], "password"));
529
840
  });
530
841
  }
531
842
 
843
+ // src/routes/challenge.ts
844
+ import { deleteCookie as deleteCookie3, getCookie as getCookie3, setCookie as setCookie3 } from "hono/cookie";
845
+ var CHALLENGE_COOKIE = "madauth_challenge";
846
+ var CHALLENGE_TYP = "madauth-challenge+jwt";
847
+ var CHALLENGE_TTL = 10 * 60;
848
+ var COOKIE_PATH3 = "/auth";
849
+ var LEGACY_COOKIE_PATH = "/auth/totp";
850
+ var cookieOptions = (secure) => ({ path: COOKIE_PATH3, httpOnly: true, secure, sameSite: "Strict" });
851
+ async function setChallenge(c, ctx, challenge) {
852
+ const token = await signToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, { ...challenge }, CHALLENGE_TTL);
853
+ setCookie3(c, CHALLENGE_COOKIE, token, { ...cookieOptions(ctx.secure), maxAge: CHALLENGE_TTL });
854
+ }
855
+ function clearChallenge(c, secure) {
856
+ deleteCookie3(c, CHALLENGE_COOKIE, cookieOptions(secure));
857
+ deleteCookie3(c, CHALLENGE_COOKIE, { ...cookieOptions(secure), path: LEGACY_COOKIE_PATH });
858
+ }
859
+ async function readChallenge(c, ctx, step) {
860
+ const claims = await readToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, getCookie3(c, CHALLENGE_COOKIE));
861
+ if (!claims || typeof claims.sub !== "string" || !Array.isArray(claims.amr)) return null;
862
+ const factors = Array.isArray(claims.factors) ? claims.factors : [];
863
+ const fits = step === "totp-setup" ? claims.next === "totp-setup" : factors.includes(step);
864
+ return fits ? claims : null;
865
+ }
866
+ var methodStillOn = async (c, ctx, challenge) => isOn(challenge.method, ctx.configured, await ctx.methods(c));
867
+ var methodOff = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
868
+ async function pendingOrSignedIn(c, ctx) {
869
+ const challenge = await readChallenge(c, ctx, "totp-setup");
870
+ const waiting = challenge ? await ctx.users.findById(challenge.sub) : null;
871
+ if (challenge && waiting && waiting.sessionVersion === challenge.sv) return { user: waiting, challenge };
872
+ const session = await ctx.currentUser(c);
873
+ const user = session ? await ctx.users.findById(session.id) : null;
874
+ return user ? { user, challenge: null } : null;
875
+ }
876
+ async function finishSignIn(c, ctx, user, challenge, amr, via) {
877
+ const result = await ctx.startSession(c, toMadauthUser(user, challenge.profile), [...challenge.amr, amr], { method: challenge.method, signedUpAt: user.signedUpAt }, { sv: user.sessionVersion });
878
+ await ctx.emit("user.signed_in", { user: result, method: challenge.method, ...challenge.amr.length ? { secondFactor: via } : {} });
879
+ return result;
880
+ }
881
+
882
+ // src/routes/sms.ts
883
+ import { deleteCookie as deleteCookie4, getCookie as getCookie4, setCookie as setCookie4 } from "hono/cookie";
884
+ var SMS_TTL_MS = 10 * 60 * 1e3;
885
+ var SETUP_COOKIE = "madauth_sms_setup";
886
+ var SETUP_TYP = "madauth-sms-setup+jwt";
887
+ var SETUP_TTL = 10 * 60;
888
+ var ROUTE_PATH = "/auth/sms";
889
+ function normalizePhone(value) {
890
+ if (typeof value !== "string") return null;
891
+ const digits = value.replace(/[\s().-]/g, "");
892
+ return /^\+[1-9]\d{6,14}$/.test(digits) ? digits : null;
893
+ }
894
+ function maskPhone(phone) {
895
+ return `${phone.slice(0, 3)} ${"\u2022".repeat(Math.max(0, phone.length - 5))} ${phone.slice(-2)}`;
896
+ }
897
+ function smsRoutes(app, ctx, users, webhook) {
898
+ const { config, secure } = ctx;
899
+ const cookie = { path: ROUTE_PATH, httpOnly: true, secure, sameSite: "Strict" };
900
+ app.use(`${ROUTE_PATH}/*`, async (c, next) => {
901
+ if (!isOn("sms", ctx.configured, await ctx.methods(c))) return error(c, 403, "method_disabled", "SMS codes are switched off.");
902
+ await next();
903
+ });
904
+ const expired = (c) => error(c, 401, "challenge_expired", "The sign-in has expired. Please sign in again.");
905
+ const tooSoon = (c) => error(c, 429, "too_soon", "A code was sent to your phone less than a minute ago: enter that one, or wait a minute for a new one.");
906
+ const sendCode = async (c, user, phone, purpose, locale) => {
907
+ const { code } = await users.issueVerification(user.id, "sms", SMS_TTL_MS);
908
+ const data = { to: phone, code, purpose, expiresAt: Date.now() + SMS_TTL_MS, site: originHost(ctx, c), locale, user: { id: user.id, name: user.name ?? void 0 } };
909
+ const result = await webhook.call("sms.send_code", data, WEBHOOK_TIMEOUT_MS);
910
+ if (!result.ok) {
911
+ console.error(`[madauth] Webhook "sms.send_code" for user ${user.id} failed: ${result.reason}`);
912
+ return false;
913
+ }
914
+ await users.updateUser(user.id, { lastSmsAt: Date.now() });
915
+ return true;
916
+ };
917
+ const sentRecently = (user) => Date.now() - (user.lastSmsAt ?? 0) < MAIL_INTERVAL_MS;
918
+ const checkCode2 = async (c, user, account, data) => {
919
+ if (account) {
920
+ const refused = await countSignInAttempt(c, users, account);
921
+ if (refused) return refused;
922
+ }
923
+ const code = typeof data.code === "string" ? data.code.replace(/\s/g, "") : "";
924
+ const { userId } = code ? await users.consumeCode(user, "sms", code) : { userId: null };
925
+ if (!userId) return error(c, 401, "code_invalid", "The code is wrong or has expired.");
926
+ if (account) await users.updateAccount(account.id, { failedAttempts: 0, lockedUntil: 0 });
927
+ return null;
928
+ };
929
+ const whoSetsUp = async (c) => {
930
+ const who = await pendingOrSignedIn(c, ctx);
931
+ if (!who || who.challenge && !who.challenge.amr.length) return { answer: noSession(c) };
932
+ if (who.challenge && !await methodStillOn(c, ctx, who.challenge)) return { answer: methodOff(c) };
933
+ return who;
934
+ };
935
+ app.post(`${ROUTE_PATH}/setup`, async (c) => {
936
+ const who = await whoSetsUp(c);
937
+ if ("answer" in who) return who.answer;
938
+ const { user } = who;
939
+ const data = await body(c);
940
+ const phone = normalizePhone(data.phone);
941
+ if (!phone) return error(c, 400, "invalid_phone", "Enter a mobile number with the country code, e.g. +41 79 123 45 67.");
942
+ const existing = await users.smsAccount(user.id);
943
+ let approved;
944
+ let justApproved = false;
945
+ if (existing) {
946
+ const previous = await readToken(await ctx.keys, config.issuer, SETUP_TYP, getCookie4(c, SETUP_COOKIE));
947
+ const holds = previous?.sub === user.id && typeof previous.approved === "number" && Date.now() - previous.approved < SETUP_TTL * 1e3;
948
+ if (holds) {
949
+ approved = previous.approved;
950
+ } else {
951
+ if (data.code === void 0) return error(c, 401, "code_required", "Enter the code we sent to your current number to replace it.");
952
+ const wrong = await checkCode2(c, user, existing, data);
953
+ if (wrong) return wrong;
954
+ approved = Date.now();
955
+ justApproved = true;
956
+ }
957
+ }
958
+ if (!justApproved && sentRecently(user)) return tooSoon(c);
959
+ if (!await sendCode(c, user, phone, "setup", localeOf(data.locale))) return smsUnavailable(c);
960
+ const token = await signToken(await ctx.keys, config.issuer, SETUP_TYP, { sub: user.id, phone, ...approved ? { approved } : {} }, SETUP_TTL);
961
+ setCookie4(c, SETUP_COOKIE, token, { ...cookie, maxAge: SETUP_TTL });
962
+ return c.json({ phone: maskPhone(phone) }, 202);
963
+ });
964
+ app.post(`${ROUTE_PATH}/confirm`, async (c) => {
965
+ const who = await whoSetsUp(c);
966
+ if ("answer" in who) return who.answer;
967
+ const { user, challenge } = who;
968
+ const setup = await readToken(await ctx.keys, config.issuer, SETUP_TYP, getCookie4(c, SETUP_COOKIE));
969
+ if (!setup || setup.sub !== user.id || typeof setup.phone !== "string") {
970
+ return error(c, 400, "setup_expired", "The setup has expired or was started in another browser. Please start again.");
971
+ }
972
+ const existing = await users.smsAccount(user.id);
973
+ if (existing && typeof setup.approved !== "number") {
974
+ deleteCookie4(c, SETUP_COOKIE, cookie);
975
+ return error(c, 400, "setup_expired", "Your phone number was changed elsewhere meanwhile. Please start again.");
976
+ }
977
+ const wrong = await checkCode2(c, user, existing, await body(c));
978
+ if (wrong) return wrong;
979
+ const written = existing ? await users.updateAccount(existing.id, { phone: setup.phone, failedAttempts: 0, lockedUntil: 0 }) : !!await users.linkAccount(user.id, { key: smsAccountKey(user.id), phone: setup.phone });
980
+ deleteCookie4(c, SETUP_COOKIE, cookie);
981
+ if (!written) return error(c, 400, "setup_expired", "Your phone number was changed elsewhere meanwhile. Please start again.");
982
+ await ctx.emit("user.second_factor_enabled", { user: toMadauthUser(user), method: "sms", phone: setup.phone });
983
+ if (!challenge) return c.json({});
984
+ return c.json({ user: await finishSignIn(c, ctx, user, challenge, "sms", "sms") });
985
+ });
986
+ app.post(`${ROUTE_PATH}/send`, async (c) => {
987
+ const data = await body(c);
988
+ const challenge = await readChallenge(c, ctx, "sms");
989
+ let user;
990
+ if (challenge) {
991
+ if (!await methodStillOn(c, ctx, challenge)) return methodOff(c);
992
+ const waiting = await users.findById(challenge.sub);
993
+ user = waiting && waiting.sessionVersion === challenge.sv ? waiting : null;
994
+ if (!user) return expired(c);
995
+ } else {
996
+ const session = await ctx.currentUser(c);
997
+ user = session ? await users.findById(session.id) : null;
998
+ if (!user) return noSession(c);
999
+ }
1000
+ const account = await users.smsAccount(user.id);
1001
+ if (!account?.phone) return challenge ? expired(c) : error(c, 400, "no_phone", "No phone number is set up.");
1002
+ if (sentRecently(user)) return tooSoon(c);
1003
+ if (!await sendCode(c, user, account.phone, challenge ? "signin" : "approve", localeOf(data.locale))) return smsUnavailable(c);
1004
+ return c.json({ phone: maskPhone(account.phone) }, 202);
1005
+ });
1006
+ app.post(`${ROUTE_PATH}/verify`, async (c) => {
1007
+ const challenge = await readChallenge(c, ctx, "sms");
1008
+ if (!challenge) return expired(c);
1009
+ if (!await methodStillOn(c, ctx, challenge)) return methodOff(c);
1010
+ const user = await users.findById(challenge.sub);
1011
+ const account = user && user.sessionVersion === challenge.sv ? await users.smsAccount(user.id) : null;
1012
+ if (!user || !account) return expired(c);
1013
+ const wrong = await checkCode2(c, user, account, await body(c));
1014
+ if (wrong) return wrong;
1015
+ return c.json({ user: await finishSignIn(c, ctx, user, challenge, "sms", "sms") });
1016
+ });
1017
+ app.post(`${ROUTE_PATH}/remove`, async (c) => {
1018
+ const session = await ctx.currentUser(c);
1019
+ const user = session ? await users.findById(session.id) : null;
1020
+ if (!user) return noSession(c);
1021
+ const account = await users.smsAccount(user.id);
1022
+ if (!account) return error(c, 400, "no_phone", "No phone number is set up.");
1023
+ const methods = await ctx.methods(c);
1024
+ const required = METHODS_WITH_SECOND_FACTOR.some((method) => isOn(method, ctx.configured, methods) && policyOf(method, methods) === "required");
1025
+ if (required && !await users.totpAccount(user.id)) {
1026
+ return error(c, 403, "required_by_policy", "Signing in requires a second factor, so the phone number can't be removed without an authenticator app.");
1027
+ }
1028
+ const wrong = await checkCode2(c, user, account, await body(c));
1029
+ if (wrong) return wrong;
1030
+ await users.removeSms(user.id);
1031
+ await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "sms" });
1032
+ return c.json({});
1033
+ });
1034
+ app.get(`${ROUTE_PATH}/status`, async (c) => {
1035
+ const session = await ctx.currentUser(c);
1036
+ if (!session) return noSession(c);
1037
+ const account = await users.smsAccount(session.id);
1038
+ return c.json({ enabled: !!account?.phone, phone: account?.phone ? maskPhone(account.phone) : null });
1039
+ });
1040
+ }
1041
+
532
1042
  // src/routes/totp.ts
533
- import { deleteCookie as deleteCookie2, getCookie as getCookie2, setCookie as setCookie2 } from "hono/cookie";
1043
+ import { deleteCookie as deleteCookie5, getCookie as getCookie5, setCookie as setCookie5 } from "hono/cookie";
534
1044
 
535
1045
  // src/totp.ts
536
- import { createCipheriv, createDecipheriv, createHmac, hkdfSync, randomBytes, randomInt } from "crypto";
1046
+ import { createHmac, randomBytes as randomBytes2, randomInt } from "crypto";
537
1047
  var TOTP_STEP_SECONDS = 30;
538
1048
  var TOTP_DIGITS = 6;
539
1049
  var TOTP_WINDOW = 1;
@@ -542,9 +1052,6 @@ var SECRET_BYTES = 20;
542
1052
  var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
543
1053
  var RECOVERY_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
544
1054
  var RECOVERY_CODE_LENGTH = 10;
545
- var ENCRYPTION_VERSION = "v1";
546
- var IV_BYTES = 12;
547
- var TAG_BYTES = 16;
548
1055
  function base32Encode(bytes) {
549
1056
  let bits = 0;
550
1057
  let value = 0;
@@ -585,7 +1092,7 @@ function verifyTotp(secret, code, nowMs, window = TOTP_WINDOW) {
585
1092
  return found;
586
1093
  }
587
1094
  function generateTotpSecret() {
588
- return randomBytes(SECRET_BYTES);
1095
+ return randomBytes2(SECRET_BYTES);
589
1096
  }
590
1097
  function otpauthUri(options) {
591
1098
  const issuer = encodeURIComponent(options.issuer);
@@ -594,28 +1101,7 @@ function otpauthUri(options) {
594
1101
  return `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_STEP_SECONDS}`;
595
1102
  }
596
1103
  function deriveTotpKey(signingKeyD) {
597
- return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth totp secret", 32));
598
- }
599
- function encryptSecret(key, secret) {
600
- const iv = randomBytes(IV_BYTES);
601
- const cipher = createCipheriv("aes-256-gcm", key, iv);
602
- const encrypted = Buffer.concat([cipher.update(secret), cipher.final()]);
603
- const parts = [iv, encrypted, cipher.getAuthTag()].map((part) => part.toString("base64url"));
604
- return [ENCRYPTION_VERSION, ...parts].join(".");
605
- }
606
- function decryptSecret(key, stored) {
607
- const [version, iv, encrypted, tag, ...rest] = stored.split(".");
608
- if (version !== ENCRYPTION_VERSION || !iv || !encrypted || !tag || rest.length) return null;
609
- const ivBytes = Buffer.from(iv, "base64url");
610
- const tagBytes = Buffer.from(tag, "base64url");
611
- if (ivBytes.length !== IV_BYTES || tagBytes.length !== TAG_BYTES) return null;
612
- try {
613
- const decipher = createDecipheriv("aes-256-gcm", key, ivBytes);
614
- decipher.setAuthTag(tagBytes);
615
- return Buffer.concat([decipher.update(Buffer.from(encrypted, "base64url")), decipher.final()]);
616
- } catch {
617
- return null;
618
- }
1104
+ return deriveKey(signingKeyD, "madauth totp secret");
619
1105
  }
620
1106
  function generateRecoveryCodes() {
621
1107
  const codes = /* @__PURE__ */ new Set();
@@ -636,25 +1122,11 @@ function hashRecoveryCode(codeKey, code) {
636
1122
  }
637
1123
 
638
1124
  // src/routes/totp.ts
639
- var CHALLENGE_COOKIE = "madauth_challenge";
640
- var CHALLENGE_TYP = "madauth-challenge+jwt";
641
- var CHALLENGE_TTL = 10 * 60;
642
- var SETUP_COOKIE = "madauth_totp_setup";
643
- var SETUP_TYP = "madauth-totp-setup+jwt";
644
- var SETUP_TTL = 10 * 60;
645
- var COOKIE_PATH = "/auth/totp";
646
- var cookieOptions = (secure) => ({ path: COOKIE_PATH, httpOnly: true, secure, sameSite: "Strict" });
647
- async function setChallenge(c, ctx, challenge) {
648
- const token = await signToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, { ...challenge }, CHALLENGE_TTL);
649
- setCookie2(c, CHALLENGE_COOKIE, token, { ...cookieOptions(ctx.secure), maxAge: CHALLENGE_TTL });
650
- }
651
- function clearChallenge(c, secure) {
652
- deleteCookie2(c, CHALLENGE_COOKIE, cookieOptions(secure));
653
- }
654
- async function readChallenge(c, ctx, next) {
655
- const claims = await readToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, getCookie2(c, CHALLENGE_COOKIE));
656
- return claims && claims.next === next && typeof claims.sub === "string" && Array.isArray(claims.amr) ? claims : null;
657
- }
1125
+ var SETUP_COOKIE2 = "madauth_totp_setup";
1126
+ var SETUP_TYP2 = "madauth-totp-setup+jwt";
1127
+ var SETUP_TTL2 = 10 * 60;
1128
+ var COOKIE_PATH4 = "/auth/totp";
1129
+ var cookieOptions2 = (secure) => ({ path: COOKIE_PATH4, httpOnly: true, secure, sameSite: "Strict" });
658
1130
  async function checkCode(c, ctx, keys, user, account, data, wrong = () => error(c, 401, "code_invalid", "The code is wrong or was already used.")) {
659
1131
  const { users } = ctx;
660
1132
  const refused = await countSignInAttempt(c, users, account);
@@ -664,11 +1136,11 @@ async function checkCode(c, ctx, keys, user, account, data, wrong = () => error(
664
1136
  if (!code || !await users.consumeRecoveryCode(account, hashRecoveryCode(keys.codeKey, code))) return wrong();
665
1137
  await users.updateAccount(account.id, { failedAttempts: 0, lockedUntil: 0 });
666
1138
  const remaining = await users.countRecoveryCodes(account);
667
- await ctx.emit("totp.recovery_code_used", { user: toMadauthUser(user), remaining });
1139
+ await ctx.emit("user.recovery_code_used", { user: toMadauthUser(user), remaining });
668
1140
  return "recovery_code";
669
1141
  }
670
1142
  if (typeof data.code !== "string" || !account.secret) return wrong();
671
- const secret = decryptSecret(keys.totpKey, account.secret);
1143
+ const secret = decrypt(keys.totpKey, account.secret);
672
1144
  if (!secret) {
673
1145
  console.error(`[madauth] The authenticator secret of user ${user.id} can't be read: was MADAUTH_SIGNING_KEY changed?`);
674
1146
  return wrong();
@@ -680,9 +1152,9 @@ async function checkCode(c, ctx, keys, user, account, data, wrong = () => error(
680
1152
  function totpRoutes(app, ctx, keys) {
681
1153
  const { config, secure, users } = ctx;
682
1154
  const { issuer } = config;
683
- const cookie = cookieOptions(secure);
1155
+ const cookie = cookieOptions2(secure);
684
1156
  const methodsNow = (c) => ctx.methods(c);
685
- app.use(`${COOKIE_PATH}/*`, async (c, next) => {
1157
+ app.use(`${COOKIE_PATH4}/*`, async (c, next) => {
686
1158
  if (!authenticatorOn(ctx.configured, await methodsNow(c))) {
687
1159
  return error(c, 403, "method_disabled", "The authenticator app is switched off.");
688
1160
  }
@@ -690,42 +1162,25 @@ function totpRoutes(app, ctx, keys) {
690
1162
  });
691
1163
  const standalone = async (c) => isOn("totp", ctx.configured, await methodsNow(c));
692
1164
  const notStandalone = (c) => error(c, 403, "method_disabled", "Signing in with the authenticator app alone is switched off.");
693
- const methodStillOn = async (c, challenge) => isOn(challenge.method, ctx.configured, await methodsNow(c));
694
- const methodOff = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
695
1165
  const signedIn = async (c) => {
696
1166
  const user = await ctx.currentUser(c);
697
1167
  return user ? users.findById(user.id) : null;
698
1168
  };
699
- const whoSetsUp = async (c) => {
700
- const challenge = await readChallenge(c, ctx, "totp-setup");
701
- const waiting = challenge ? await users.findById(challenge.sub) : null;
702
- if (challenge && waiting && waiting.sessionVersion === challenge.sv) return { user: waiting, challenge };
703
- const user = await signedIn(c);
704
- return user ? { user, challenge: null } : null;
705
- };
1169
+ const whoSetsUp = (c) => pendingOrSignedIn(c, ctx);
706
1170
  const newRecoveryCodes = () => {
707
1171
  const recoveryCodes = generateRecoveryCodes();
708
1172
  return { recoveryCodes, hashes: recoveryCodes.map((code) => hashRecoveryCode(keys.codeKey, normalizeRecoveryCode(code))) };
709
1173
  };
710
- const issuerLabel = (c) => {
711
- if (config.totpIssuer) return config.totpIssuer;
712
- const origin = c.req.header("origin");
713
- return URL.canParse(origin ?? "") ? new URL(origin).hostname : new URL(issuer).hostname;
714
- };
715
- const finishSignIn = async (c, user, challenge, via) => {
716
- const result = await ctx.startSession(c, toMadauthUser(user, challenge.profile), [...challenge.amr, "otp"], { sv: user.sessionVersion });
717
- await ctx.emit("user.signed_in", { user: result, method: challenge.method, ...challenge.amr.length ? { secondFactor: via } : {} });
718
- return result;
719
- };
720
- app.post(`${COOKIE_PATH}/setup`, async (c) => {
1174
+ const issuerLabel = (c) => config.totpIssuer || originHost(ctx, c);
1175
+ app.post(`${COOKIE_PATH4}/setup`, async (c) => {
721
1176
  const who = await whoSetsUp(c);
722
1177
  if (!who) return noSession(c);
723
- if (who.challenge && !await methodStillOn(c, who.challenge)) return methodOff(c);
1178
+ if (who.challenge && !await methodStillOn(c, ctx, who.challenge)) return methodOff(c);
724
1179
  const existing = await users.totpAccount(who.user.id);
725
1180
  let approved;
726
1181
  if (existing) {
727
- const previous = await readToken(await ctx.keys, issuer, SETUP_TYP, getCookie2(c, SETUP_COOKIE));
728
- const holds = previous?.sub === who.user.id && typeof previous.approved === "number" && Date.now() - previous.approved < SETUP_TTL * 1e3;
1182
+ const previous = await readToken(await ctx.keys, issuer, SETUP_TYP2, getCookie5(c, SETUP_COOKIE2));
1183
+ const holds = previous?.sub === who.user.id && typeof previous.approved === "number" && Date.now() - previous.approved < SETUP_TTL2 * 1e3;
729
1184
  if (holds) {
730
1185
  approved = previous.approved;
731
1186
  } else {
@@ -742,89 +1197,96 @@ function totpRoutes(app, ctx, keys) {
742
1197
  const claims = {
743
1198
  sub: who.user.id,
744
1199
  setup: newId("tsu"),
745
- secret: encryptSecret(keys.totpKey, secret),
1200
+ secret: encrypt(keys.totpKey, secret),
746
1201
  ...existing ? { replaces: existing.setupId } : {},
747
1202
  ...approved ? { approved } : {}
748
1203
  };
749
- const token = await signToken(await ctx.keys, issuer, SETUP_TYP, claims, SETUP_TTL);
750
- setCookie2(c, SETUP_COOKIE, token, { ...cookie, maxAge: SETUP_TTL });
1204
+ const token = await signToken(await ctx.keys, issuer, SETUP_TYP2, claims, SETUP_TTL2);
1205
+ setCookie5(c, SETUP_COOKIE2, token, { ...cookie, maxAge: SETUP_TTL2 });
751
1206
  return c.json({ secret: base32Encode(secret), uri: otpauthUri({ issuer: issuerLabel(c), account: who.user.email, secret }) });
752
1207
  });
753
- app.post(`${COOKIE_PATH}/confirm`, async (c) => {
1208
+ app.post(`${COOKIE_PATH4}/confirm`, async (c) => {
754
1209
  const who = await whoSetsUp(c);
755
1210
  if (!who) return noSession(c);
756
1211
  const { user, challenge } = who;
757
- if (challenge && !await methodStillOn(c, challenge)) return methodOff(c);
1212
+ if (challenge && !await methodStillOn(c, ctx, challenge)) return methodOff(c);
758
1213
  const data = await body(c);
759
- const setup = await readToken(await ctx.keys, issuer, SETUP_TYP, getCookie2(c, SETUP_COOKIE));
760
- const secret = setup && setup.sub === user.id && typeof setup.secret === "string" && typeof setup.setup === "string" ? decryptSecret(keys.totpKey, setup.secret) : null;
1214
+ const setup = await readToken(await ctx.keys, issuer, SETUP_TYP2, getCookie5(c, SETUP_COOKIE2));
1215
+ const secret = setup && setup.sub === user.id && typeof setup.secret === "string" && typeof setup.setup === "string" ? decrypt(keys.totpKey, setup.secret) : null;
761
1216
  if (!setup || !secret) return error(c, 400, "setup_expired", "The setup has expired or was started in another browser. Please start again.");
762
1217
  const step = typeof data.code === "string" ? verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW) : null;
763
1218
  if (step === null) return error(c, 400, "code_invalid", "The code is wrong. Enter the current code from the app.");
1219
+ const signUp = challenge?.method === "totp";
764
1220
  const { recoveryCodes, hashes } = newRecoveryCodes();
765
1221
  await users.addRecoveryCodes(user.id, setup.setup, hashes);
766
1222
  const patch = { secret: setup.secret, setupId: setup.setup, failedAttempts: 0, lockedUntil: 0, lastUsedStep: step };
767
- const current = setup.replaces === void 0 ? null : await users.totpAccount(user.id);
768
- const written = setup.replaces === void 0 ? !!await users.linkAccount(user.id, { key: totpAccountKey(user.id), ...patch }) : !!current && await users.confirmTotpSetup(current.id, setup.replaces, patch);
1223
+ const replaced = setup.replaces === void 0 ? null : await users.totpApp(user.id);
1224
+ const current = replaced?.account ?? null;
1225
+ const written = setup.replaces === void 0 ? !!await users.linkAccount(user.id, { key: totpAccountKey(user.id), primary: signUp, ...patch }) : !!current && await users.confirmTotpSetup(current.id, setup.replaces, patch);
769
1226
  if (!written) {
770
1227
  await users.clearRecoveryCodes(user.id, { only: setup.setup });
771
- deleteCookie2(c, SETUP_COOKIE, cookie);
1228
+ deleteCookie5(c, SETUP_COOKIE2, cookie);
772
1229
  return error(c, 400, "setup_expired", "Your authenticator app was set up or removed elsewhere meanwhile. Please start again.");
773
1230
  }
774
1231
  await users.clearRecoveryCodes(user.id, { except: setup.setup });
775
- deleteCookie2(c, SETUP_COOKIE, cookie);
776
- await ctx.emit("totp.enabled", { user: toMadauthUser(user) });
1232
+ deleteCookie5(c, SETUP_COOKIE2, cookie);
1233
+ const secondFactor = replaced ? !replaced.signsIn : !signUp;
1234
+ if (secondFactor) await ctx.emit("user.second_factor_enabled", { user: toMadauthUser(user), method: "totp" });
777
1235
  if (!challenge) return c.json({ recoveryCodes });
778
- const result = await finishSignIn(c, user, challenge, "totp");
1236
+ const result = await finishSignIn(c, ctx, user, challenge, "otp", "totp");
779
1237
  return c.json({ user: result, recoveryCodes });
780
1238
  });
781
- app.post(`${COOKIE_PATH}/verify`, async (c) => {
1239
+ app.post(`${COOKIE_PATH4}/verify`, async (c) => {
782
1240
  const expired = () => error(c, 401, "challenge_expired", "The sign-in has expired. Please sign in again.");
783
1241
  const challenge = await readChallenge(c, ctx, "totp");
784
1242
  if (!challenge) return expired();
785
- if (!await methodStillOn(c, challenge)) return methodOff(c);
1243
+ if (!await methodStillOn(c, ctx, challenge)) return methodOff(c);
786
1244
  const user = await users.findById(challenge.sub);
787
1245
  const account = user && user.sessionVersion === challenge.sv ? await users.totpAccount(user.id) : null;
788
1246
  if (!user || !account) return expired();
789
1247
  const via = await checkCode(c, ctx, keys, user, account, await body(c));
790
1248
  if (via instanceof Response) return via;
791
- return c.json({ user: await finishSignIn(c, user, challenge, via) });
1249
+ return c.json({ user: await finishSignIn(c, ctx, user, challenge, "otp", via) });
792
1250
  });
793
- app.post(`${COOKIE_PATH}/signin`, async (c) => {
1251
+ app.post(`${COOKIE_PATH4}/signin`, async (c) => {
794
1252
  if (!await standalone(c)) return notStandalone(c);
795
1253
  const data = await body(c);
796
1254
  const wrong = () => error(c, 401, "invalid_credentials", "E-mail or code is wrong.");
797
1255
  if (!isValidEmail(data.email)) return wrong();
798
1256
  const user = await users.findByEmail(normalizeEmail(data.email));
799
- const account = user ? await users.totpAccount(user.id) : null;
800
- if (!user || !user.emailVerified || !account?.secret) return wrong();
1257
+ const app2 = user ? await users.totpApp(user.id) : null;
1258
+ const account = app2?.account;
1259
+ if (!user || !user.emailVerified || !app2?.signsIn || !account?.secret) return wrong();
801
1260
  const via = await checkCode(c, ctx, keys, user, account, data, wrong);
802
1261
  if (via instanceof Response) return via;
803
- const result = await ctx.startSession(c, toMadauthUser(user), ["otp"], { sv: user.sessionVersion });
1262
+ const result = await ctx.startSession(c, toMadauthUser(user), ["otp"], { method: "totp", signedUpAt: user.signedUpAt }, { sv: user.sessionVersion });
804
1263
  await ctx.emit("user.signed_in", { user: result, method: "totp" });
805
1264
  return c.json({ user: result });
806
1265
  });
807
- app.post(`${COOKIE_PATH}/remove`, async (c) => {
1266
+ app.post(`${COOKIE_PATH4}/remove`, async (c) => {
808
1267
  const user = await signedIn(c);
809
1268
  if (!user) return noSession(c);
810
1269
  const methods = await methodsNow(c);
811
1270
  const required = METHODS_WITH_SECOND_FACTOR.filter((method) => isOn(method, ctx.configured, methods) && policyOf(method, methods) === "required");
812
- if (required.length) {
813
- const names = required.map((method) => method === "google" ? "Google" : "e-mail & password");
814
- return error(c, 403, "required_by_policy", `Signing in with ${names.join(" and ")} requires the authenticator app, so it can't be removed.`);
1271
+ const smsOn = isOn("sms", ctx.configured, methods);
1272
+ if (required.length && !(smsOn && await users.smsAccount(user.id))) {
1273
+ const names = required.map((method) => ({ google: "Google", github: "GitHub", password: "e-mail & password", "email-link": "the e-mail link" })[method]);
1274
+ const alternative = smsOn ? " Add a phone number for SMS codes first, or" : " To stop using it,";
1275
+ return error(c, 403, "required_by_policy", `Signing in with ${names.join(" and ")} requires a second factor, so the authenticator app can't be removed.${alternative} delete your account.`);
815
1276
  }
816
- const account = await users.totpAccount(user.id);
817
- if (!account) return c.json({});
818
- if (!(await users.signInMethods(user.id, methods)).some((method) => method !== "totp" && isOn(method, ctx.configured, methods))) {
1277
+ const app2 = await users.totpApp(user.id);
1278
+ if (!app2) return c.json({});
1279
+ const { account } = app2;
1280
+ if (app2.signsIn) {
819
1281
  return error(c, 403, "only_method", "The authenticator app is the only way you sign in, so it can't be removed. Delete your account instead.");
820
1282
  }
821
1283
  const via = await checkCode(c, ctx, keys, user, account, await body(c));
822
1284
  if (via instanceof Response) return via;
823
1285
  await users.removeTotp(user.id);
824
- await ctx.emit("totp.disabled", { user: toMadauthUser(user) });
1286
+ await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "totp" });
825
1287
  return c.json({});
826
1288
  });
827
- app.post(`${COOKIE_PATH}/recovery-codes`, async (c) => {
1289
+ app.post(`${COOKIE_PATH4}/recovery-codes`, async (c) => {
828
1290
  const user = await signedIn(c);
829
1291
  if (!user) return noSession(c);
830
1292
  const account = await users.totpAccount(user.id);
@@ -835,7 +1297,7 @@ function totpRoutes(app, ctx, keys) {
835
1297
  await users.replaceRecoveryCodes(account, hashes);
836
1298
  return c.json({ recoveryCodes });
837
1299
  });
838
- app.get(`${COOKIE_PATH}/status`, async (c) => {
1300
+ app.get(`${COOKIE_PATH4}/status`, async (c) => {
839
1301
  const user = await signedIn(c);
840
1302
  if (!user) return noSession(c);
841
1303
  const account = await users.totpAccount(user.id);
@@ -843,7 +1305,7 @@ function totpRoutes(app, ctx, keys) {
843
1305
  });
844
1306
  if (ctx.webhook && ctx.emailVerification) {
845
1307
  const emails = createEmails(ctx, users, ctx.webhook);
846
- app.post(`${COOKIE_PATH}/signup`, async (c) => {
1308
+ app.post(`${COOKIE_PATH4}/signup`, async (c) => {
847
1309
  if (!await standalone(c)) return notStandalone(c);
848
1310
  const data = await body(c);
849
1311
  const target = redirectTarget(ctx, c, data.redirectTo);
@@ -855,18 +1317,18 @@ function totpRoutes(app, ctx, keys) {
855
1317
  const check = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "totp" });
856
1318
  if (!check.ok) return check.reason === "rejected" ? error(c, 403, "signup_rejected", check.message) : unavailable(c);
857
1319
  const existing = await users.findByEmail(normalizeEmail(email));
858
- if (existing?.emailVerified && await users.hasAccounts(existing.id)) {
859
- return await emails.sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
1320
+ if (existing?.emailVerified && await users.hasSignInMethod(existing.id)) {
1321
+ return await emails.sendAccountEmail(existing, "email.send_already_registered", target, locale) ? accepted(c) : unavailable(c);
860
1322
  }
861
1323
  if (existing) {
862
1324
  if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
863
- if (!existing.emailVerified) await users.unlinkAccount(passwordAccountKey(existing.id));
1325
+ if (!existing.emailVerified) await users.dropUnprovenAccounts(existing.id);
1326
+ await users.reopenSignUp(existing);
864
1327
  await users.updateUser(existing.id, { name });
865
1328
  return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
866
1329
  }
867
1330
  const user = await users.createUser({ email, emailNormalized: normalizeEmail(email), name, emailVerified: false });
868
1331
  if (!user) return accepted(c);
869
- await ctx.emit("user.created", { user: toMadauthUser(user), method: "totp" });
870
1332
  return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
871
1333
  });
872
1334
  }
@@ -887,13 +1349,14 @@ function createApp(config) {
887
1349
  const signsUp = (method) => canSignUp(methods, method);
888
1350
  const emailVerification = sendsVerificationEmails(config);
889
1351
  const totpKeys = { totpKey: deriveTotpKey(config.signingKey.d), codeKey };
1352
+ const tokenKey = deriveTokenKey(config.signingKey.d);
890
1353
  const issueSession = async (c, user, amr, extra) => {
891
1354
  const signingKeys = await keys;
892
1355
  const [sessionToken, renewalToken] = await Promise.all([
893
1356
  signSession(signingKeys, issuer, sessionTtlSeconds, user, amr, extra),
894
1357
  signRenewal(signingKeys, issuer, renewalTtlSeconds, user, amr, extra)
895
1358
  ]);
896
- setCookie3(c, SESSION_COOKIE, sessionToken, {
1359
+ setCookie6(c, SESSION_COOKIE, sessionToken, {
897
1360
  path: "/",
898
1361
  domain: cookieDomain,
899
1362
  httpOnly: true,
@@ -901,14 +1364,14 @@ function createApp(config) {
901
1364
  sameSite: "Lax",
902
1365
  maxAge: sessionTtlSeconds
903
1366
  });
904
- setCookie3(c, RENEWAL_COOKIE, renewalToken, {
1367
+ setCookie6(c, RENEWAL_COOKIE, renewalToken, {
905
1368
  path: "/auth",
906
1369
  httpOnly: true,
907
1370
  secure,
908
1371
  sameSite: "Lax",
909
1372
  maxAge: renewalTtlSeconds
910
1373
  });
911
- setCookie3(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
1374
+ setCookie6(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
912
1375
  path: "/",
913
1376
  domain: cookieDomain,
914
1377
  secure,
@@ -917,11 +1380,11 @@ function createApp(config) {
917
1380
  });
918
1381
  };
919
1382
  const clearSession = (c) => {
920
- deleteCookie3(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
921
- deleteCookie3(c, RENEWAL_COOKIE, { path: "/auth", secure });
922
- deleteCookie3(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
1383
+ deleteCookie6(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
1384
+ deleteCookie6(c, RENEWAL_COOKIE, { path: "/auth", secure });
1385
+ deleteCookie6(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
923
1386
  };
924
- const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie3(c, cookie));
1387
+ const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie6(c, cookie));
925
1388
  const checked = async (claims) => {
926
1389
  const stored = await users.findById(claims.sub);
927
1390
  if (!stored || stored.sessionVersion !== claims.sv) return null;
@@ -960,24 +1423,35 @@ function createApp(config) {
960
1423
  url.hash = "";
961
1424
  return url;
962
1425
  },
963
- async startSession(c, user, amr, extra) {
1426
+ async startSession(c, user, amr, { method, signedUpAt }, extra) {
1427
+ const firstSession = signedUpAt === 0 && await users.markSignedUp(user.id);
964
1428
  clearChallenge(c, secure);
965
1429
  await issueSession(c, user, amr, extra);
966
- return { ...user, amr };
1430
+ const result = { ...user, amr };
1431
+ if (firstSession) await this.emit("user.signed_up", { user: result, method });
1432
+ return result;
967
1433
  },
968
1434
  async secondStep(c, user, amr, method, profile) {
969
1435
  const methods2 = await this.methods(c);
970
1436
  if (!isOn(method, configured, methods2)) return { disabled: true };
971
- const enrolled = await users.totpAccount(user.id);
972
1437
  const policy = method === "totp" ? "required" : policyOf(method, methods2);
973
- const next = policy === "none" ? null : enrolled ? "totp" : policy === "required" ? "totp-setup" : null;
1438
+ let next = null;
1439
+ let factors = [];
1440
+ if (policy !== "none") {
1441
+ const [app2, phone] = await Promise.all([
1442
+ users.totpAccount(user.id),
1443
+ method !== "totp" && isOn("sms", configured, methods2) ? users.smsAccount(user.id) : null
1444
+ ]);
1445
+ factors = [...app2 ? ["totp"] : [], ...phone ? ["sms"] : []];
1446
+ next = app2 ? "totp" : phone ? "sms" : policy === "required" ? "totp-setup" : null;
1447
+ }
974
1448
  if (next) {
975
1449
  clearSession(c);
976
1450
  const known = { ...profile?.name ? { name: profile.name } : {}, ...profile?.picture ? { picture: profile.picture } : {} };
977
- await setChallenge(c, this, { sub: user.id, sv: user.sessionVersion, amr, method, next, ...Object.keys(known).length ? { profile: known } : {} });
978
- return { next, method };
1451
+ await setChallenge(c, this, { sub: user.id, sv: user.sessionVersion, amr, method, next, factors, ...Object.keys(known).length ? { profile: known } : {} });
1452
+ return { next, method, factors };
979
1453
  }
980
- const result = await this.startSession(c, toMadauthUser(user, profile), amr, { sv: user.sessionVersion });
1454
+ const result = await this.startSession(c, toMadauthUser(user, profile), amr, { method, signedUpAt: user.signedUpAt }, { sv: user.sessionVersion });
981
1455
  await this.emit("user.signed_in", { user: result, method });
982
1456
  return { user: result };
983
1457
  },
@@ -999,10 +1473,10 @@ function createApp(config) {
999
1473
  return isOn(method, configured, await this.methods(c));
1000
1474
  },
1001
1475
  async checkSignup(data) {
1002
- if (!this.webhook?.wants("signup.before")) return { ok: true };
1003
- const check = await this.webhook.call("signup.before", data, WEBHOOK_TIMEOUT_MS);
1476
+ if (!this.webhook?.wants("user.signing_up")) return { ok: true };
1477
+ const check = await this.webhook.call("user.signing_up", data, WEBHOOK_TIMEOUT_MS);
1004
1478
  if (!check.ok) {
1005
- console.error(`[madauth] Webhook "signup.before" failed: ${check.reason}`);
1479
+ console.error(`[madauth] Webhook "user.signing_up" failed: ${check.reason}`);
1006
1480
  return { ok: false, reason: "unavailable" };
1007
1481
  }
1008
1482
  const answer = check.body;
@@ -1046,17 +1520,25 @@ function createApp(config) {
1046
1520
  app.get("/auth/config", async (c) => {
1047
1521
  const current = await ctx.methods(c);
1048
1522
  const on = (method) => isOn(method, configured, current);
1523
+ const rules = on("password") ? await settings.passwordRules() : null;
1049
1524
  return c.json({
1050
1525
  google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret, secondFactor: policyOf("google", current) } : null,
1051
- password: on("password") ? { minLength: config.password.minLength, secondFactor: policyOf("password", current) } : null,
1526
+ github: on("github") ? { secondFactor: policyOf("github", current) } : null,
1527
+ password: rules ? { ...rules, secondFactor: policyOf("password", current) } : null,
1052
1528
  // The app can be set up when it is in use at all; `signIn` says whether it signs in on its own.
1053
1529
  totp: authenticatorOn(configured, current) ? { signIn: on("totp"), signUp: on("totp") && emailVerification } : null,
1054
- email: { verification: emailVerification }
1530
+ email: { verification: emailVerification },
1531
+ emailLink: on("email-link") ? { secondFactor: policyOf("email-link", current) } : null,
1532
+ // SMS codes as a second factor: a signed-in user can add a phone, and the dialog offers it in the second step.
1533
+ sms: on("sms")
1055
1534
  });
1056
1535
  });
1057
1536
  if (emailVerification && ctx.webhook) emailRoutes(app, ctx, users, ctx.webhook);
1537
+ if (configured("email-link") && ctx.webhook) emailLinkRoutes(app, ctx, users, ctx.webhook);
1538
+ if (configured("sms") && ctx.webhook) smsRoutes(app, ctx, users, ctx.webhook);
1058
1539
  if (config.google) googleRoutes(app, ctx, config.google);
1059
- if (config.password && ctx.webhook) passwordRoutes(app, ctx, config.password, users, ctx.webhook);
1540
+ if (config.github) githubRoutes(app, ctx, config.github, tokenKey);
1541
+ if (config.password && ctx.webhook) passwordRoutes(app, ctx, users, ctx.webhook);
1060
1542
  totpRoutes(app, ctx, totpKeys);
1061
1543
  app.get("/auth/session", async (c) => {
1062
1544
  const user = await currentSession(c);
@@ -1085,10 +1567,13 @@ function createApp(config) {
1085
1567
  const via = await checkCode(c, ctx, totpKeys, stored, authenticator, data);
1086
1568
  if (via instanceof Response) return via;
1087
1569
  }
1088
- await users.deleteById(user.id);
1570
+ const googleSub = await users.googleSub(user.id);
1571
+ const githubAccount = config.github ? await users.githubAccount(user.id) : null;
1572
+ const deleted = await users.deleteById(user.id);
1573
+ if (deleted && config.github) await revokeGrant(config.github, tokenKey, githubAccount);
1089
1574
  clearSession(c);
1090
1575
  await ctx.emit("user.deleted", { user });
1091
- return c.body(null, 204);
1576
+ return googleSub ? c.json({ googleSub }) : c.body(null, 204);
1092
1577
  });
1093
1578
  const admin = async (c) => {
1094
1579
  const user = await currentSession(c);
@@ -1128,19 +1613,21 @@ function createApp(config) {
1128
1613
  }
1129
1614
  const user = await users.findByEmail(normalizeEmail(data.email));
1130
1615
  if (!user) return noUser(c);
1131
- await users.updateUser(user.id, { claims: Object.keys(claims).length ? JSON.stringify(claims) : null });
1616
+ const stored = Object.keys(claims).length ? JSON.stringify(claims) : null;
1617
+ await users.updateUser(user.id, { claims: stored });
1132
1618
  const email = user.emailNormalized;
1133
- await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
1619
+ await ctx.emit("user.claims_changed", { user: toMadauthUser({ ...user, claims: stored }), claims, by: caller.email });
1134
1620
  return c.json({ email, userId: user.id, claims });
1135
1621
  });
1136
- const settingsAnswer = (methods2) => ({
1622
+ const settingsAnswer = (methods2, password) => ({
1623
+ password,
1137
1624
  methods: Object.fromEntries(
1138
- SIGN_IN_METHODS.map((method) => [
1625
+ METHODS.map((method) => [
1139
1626
  method,
1140
1627
  {
1141
1628
  configured: configured(method),
1142
1629
  enabled: isOn(method, configured, methods2),
1143
- ...method === "totp" ? {} : { secondFactor: policyOf(method, methods2) }
1630
+ ...hasSecondFactor(method) ? { secondFactor: policyOf(method, methods2) } : {}
1144
1631
  }
1145
1632
  ])
1146
1633
  )
@@ -1148,22 +1635,43 @@ function createApp(config) {
1148
1635
  app.post("/auth/admin/settings/get", async (c) => {
1149
1636
  const caller = await admin(c);
1150
1637
  if ("answer" in caller) return caller.answer;
1151
- return c.json(settingsAnswer(await settings.methods()));
1638
+ const [methods2, password] = await Promise.all([settings.methods(), settings.passwordRules()]);
1639
+ return c.json(settingsAnswer(methods2, password));
1152
1640
  });
1153
1641
  app.post("/auth/admin/settings/set", async (c) => {
1154
1642
  const caller = await admin(c);
1155
1643
  if ("answer" in caller) return caller.answer;
1156
1644
  const data = await body(c);
1157
1645
  const invalid = (message) => c.json({ error: "invalid_settings", message }, 400);
1158
- const next = parseMethodSettings(data.methods, { booleans: true });
1159
- if (!next) {
1646
+ if (data.methods === void 0 && data.password === void 0) return invalid("Pass methods, password or both.");
1647
+ const next = data.methods === void 0 ? void 0 : parseMethodSettings(data.methods, { booleans: true });
1648
+ if (next === null) {
1160
1649
  return invalid(
1161
- `methods must list the sign-in methods that are on (${SIGN_IN_METHODS.join(", ")}), each as true, {} or { "secondFactor": "none" | "optional" | "required" } (google and password), e.g. { "google": {}, "password": { "secondFactor": "optional" } }. A method that is not listed is off.`
1650
+ `methods must list the sign-in methods that are on (${SIGN_IN_METHODS.join(", ")}; sms for SMS codes as a second factor), each as true, {} or { "secondFactor": "none" | "optional" | "required" } (google, github, password and email-link), e.g. { "google": {}, "password": { "secondFactor": "optional" }, "sms": {} }. A method that is not listed is off.`
1162
1651
  );
1163
1652
  }
1164
- const methods2 = await settings.changeMethods(() => anyOn(signsUp, next) ? next : null, caller.email);
1165
- if (!methods2) return invalid("At least one sign-in method that creates users must stay on: Google, e-mail & password, or the authenticator app with sign-up by e-mail.");
1166
- return c.json(settingsAnswer(methods2));
1653
+ const rules = data.password === void 0 ? void 0 : parsePasswordRules(data.password);
1654
+ if (rules === null) {
1655
+ return invalid(
1656
+ `password must be { "minLength": 1 to ${MAX_MIN_LENGTH}, "minScore": 0 to 4 }, both optional, e.g. { "minLength": 10, "minScore": 3 }. What is left out takes its default (${DEFAULT_PASSWORD_RULES.minLength}, ${DEFAULT_PASSWORD_RULES.minScore}).`
1657
+ );
1658
+ }
1659
+ if (next && !anyOn(signsUp, next)) {
1660
+ return invalid("At least one sign-in method that creates users must stay on: Google, GitHub, e-mail & password, the e-mail link, or the authenticator app with sign-up by e-mail.");
1661
+ }
1662
+ const previousRules = rules && next ? await settings.passwordRules() : void 0;
1663
+ const password = rules ? await settings.setPasswordRules(rules, caller.email) : await settings.passwordRules();
1664
+ if (next) {
1665
+ try {
1666
+ await settings.changeMethods(() => next, caller.email);
1667
+ } catch (e) {
1668
+ if (previousRules) await settings.setPasswordRules(previousRules, caller.email).catch(() => {
1669
+ });
1670
+ throw e;
1671
+ }
1672
+ }
1673
+ const methods2 = next ?? await settings.methods();
1674
+ return c.json(settingsAnswer(methods2, password));
1167
1675
  });
1168
1676
  app.post("/auth/admin/totp/remove", async (c) => {
1169
1677
  const caller = await admin(c);
@@ -1172,8 +1680,21 @@ function createApp(config) {
1172
1680
  if (!isValidEmail(data.email)) return invalidEmail(c);
1173
1681
  const user = await users.findByEmail(normalizeEmail(data.email));
1174
1682
  if (!user) return noUser(c);
1683
+ const app2 = await users.totpApp(user.id);
1684
+ const secondFactor = !!app2 && !app2.signsIn;
1175
1685
  const removed = await users.removeTotp(user.id);
1176
- if (removed) await ctx.emit("totp.disabled", { user: toMadauthUser(user), by: caller.email });
1686
+ if (removed && secondFactor) await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "totp", by: caller.email });
1687
+ return c.json({ email: user.emailNormalized, userId: user.id, enabled: false });
1688
+ });
1689
+ app.post("/auth/admin/sms/remove", async (c) => {
1690
+ const caller = await admin(c);
1691
+ if ("answer" in caller) return caller.answer;
1692
+ const data = await body(c);
1693
+ if (!isValidEmail(data.email)) return invalidEmail(c);
1694
+ const user = await users.findByEmail(normalizeEmail(data.email));
1695
+ if (!user) return noUser(c);
1696
+ const removed = await users.removeSms(user.id);
1697
+ if (removed) await ctx.emit("user.second_factor_disabled", { user: toMadauthUser(user), method: "sms", by: caller.email });
1177
1698
  return c.json({ email: user.emailNormalized, userId: user.id, enabled: false });
1178
1699
  });
1179
1700
  return app;