@madauth/server 0.3.0-beta.1 → 0.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@ var init_schema = __esm({
15
15
  "src/store/schema.ts"() {
16
16
  "use strict";
17
17
  madauthSchema = {
18
- version: 4,
18
+ version: 5,
19
19
  models: {
20
20
  /** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
21
21
  user: {
@@ -34,7 +34,10 @@ var init_schema = __esm({
34
34
  createdAt: { type: "number" }
35
35
  }
36
36
  },
37
- /** How a user signs in. `key` is `password:<userId>` or `google:<sub>`; later e.g. `totp:<userId>`. */
37
+ /**
38
+ * How a user signs in. `key` is `password:<userId>`, `google:<sub>` or `totp:<userId>` (the authenticator
39
+ * app, whose `secret` is encrypted).
40
+ */
38
41
  account: {
39
42
  fields: {
40
43
  id: { type: "string", primaryKey: true },
@@ -45,6 +48,25 @@ var init_schema = __esm({
45
48
  lockedUntil: { type: "number" },
46
49
  /** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
47
50
  email: { type: "string", nullable: true },
51
+ /** The time step of the last authenticator code that was accepted, so a code works once; 0 before (since version 5). */
52
+ lastUsedStep: { type: "number" },
53
+ /**
54
+ * The setup an authenticator secret came from; its recovery codes carry the same id, so the app and
55
+ * its codes always belong together. Empty for other accounts (since version 5).
56
+ */
57
+ setupId: { type: "string", nullable: true },
58
+ createdAt: { type: "number" }
59
+ }
60
+ },
61
+ /**
62
+ * A recovery code of a user's authenticator app (since version 5). `id` is the code's keyed hash; the
63
+ * record goes when the code is used. It works only while the account's `setupId` is its own.
64
+ */
65
+ recoveryCode: {
66
+ fields: {
67
+ id: { type: "string", primaryKey: true },
68
+ userId: { type: "string", index: true },
69
+ setupId: { type: "string" },
48
70
  createdAt: { type: "number" }
49
71
  }
50
72
  },
@@ -76,7 +98,7 @@ var init_schema = __esm({
76
98
 
77
99
  // src/store/sql.ts
78
100
  function tableName(model) {
79
- return `madauth_${model}`;
101
+ return `madauth_${columnName(model)}`;
80
102
  }
81
103
  function columnName(field) {
82
104
  return field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
@@ -144,6 +166,15 @@ var init_sql = __esm({
144
166
  `DROP TABLE ${tableName("role")};`,
145
167
  createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
146
168
  ].join("\n")
169
+ },
170
+ {
171
+ // The authenticator app: which code was used last, and the recovery codes.
172
+ version: 5,
173
+ sql: (dialect) => [
174
+ `ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("lastUsedStep")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`,
175
+ `ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("setupId")} ${columnTypes[dialect].string};`,
176
+ createTablesSql(dialect, { version: 5, models: { recoveryCode: madauthSchema.models.recoveryCode } })
177
+ ].join("\n")
147
178
  }
148
179
  ];
149
180
  }
@@ -296,16 +327,16 @@ function stillMatches(where) {
296
327
  });
297
328
  return { text: parts.join(" AND "), names, values };
298
329
  }
299
- function failedWrite(error) {
300
- const { name, CancellationReasons } = error;
330
+ function failedWrite(error2) {
331
+ const { name, CancellationReasons } = error2;
301
332
  if (name === "ConditionalCheckFailedException") return "refused";
302
- if (name && RETRYABLE_ERRORS.includes(name)) return { retry: error };
333
+ if (name && RETRYABLE_ERRORS.includes(name)) return { retry: error2 };
303
334
  if (name === "TransactionCanceledException") {
304
335
  const reasons = (CancellationReasons ?? []).map((r) => r.Code ?? "None").filter((code) => code !== "None");
305
336
  if (reasons.includes("ConditionalCheckFailed")) return "refused";
306
- if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry: error };
337
+ if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry: error2 };
307
338
  }
308
- throw error;
339
+ throw error2;
309
340
  }
310
341
  function createDynamoDbAdapter(options) {
311
342
  const { tableName: tableName2 } = options;
@@ -534,9 +565,9 @@ var handle = (app, { isContentTypeBinary } = { isContentTypeBinary: void 0 }) =>
534
565
  try {
535
566
  req = processor.createRequest(event);
536
567
  requestContext = getRequestContext(event);
537
- } catch (error) {
538
- console.error("Error processing request:", error);
539
- const errorResponse = error instanceof TypeError ? new Response("Invalid request", { status: 400 }) : new Response("Internal Server Error", { status: 500 });
568
+ } catch (error2) {
569
+ console.error("Error processing request:", error2);
570
+ const errorResponse = error2 instanceof TypeError ? new Response("Invalid request", { status: 400 }) : new Response("Internal Server Error", { status: 500 });
540
571
  return processor.createResult(event, errorResponse, { isContentTypeBinary });
541
572
  }
542
573
  const res = await app.fetch(req, {
@@ -568,9 +599,9 @@ var EventProcessor = class {
568
599
  method: this.getMethod(event)
569
600
  };
570
601
  if (event.body) {
571
- const body = event.isBase64Encoded ? decodeBase64(event.body) : new TextEncoder().encode(event.body);
572
- requestInit.body = body;
573
- headers.set("content-length", body.length.toString());
602
+ const body2 = event.isBase64Encoded ? decodeBase64(event.body) : new TextEncoder().encode(event.body);
603
+ requestInit.body = body2;
604
+ headers.set("content-length", body2.length.toString());
574
605
  }
575
606
  return new Request(url, requestInit);
576
607
  }
@@ -1070,10 +1101,10 @@ var HonoRequest = class {
1070
1101
  const { bodyCache, raw: raw2 } = this;
1071
1102
  const cachedBody = bodyCache[key];
1072
1103
  if (cachedBody) return cachedBody;
1073
- for (const anyCachedKey in bodyCache) return bodyCache[anyCachedKey].then((body) => {
1074
- if (anyCachedKey === "json") body = JSON.stringify(body);
1104
+ for (const anyCachedKey in bodyCache) return bodyCache[anyCachedKey].then((body2) => {
1105
+ if (anyCachedKey === "json") body2 = JSON.stringify(body2);
1075
1106
  const contentType = anyCachedKey === "formData" ? void 0 : raw2.headers.get("content-type");
1076
- return new Response(body, { headers: contentType ? { "Content-Type": contentType } : void 0 })[key]();
1107
+ return new Response(body2, { headers: contentType ? { "Content-Type": contentType } : void 0 })[key]();
1077
1108
  });
1078
1109
  return bodyCache[key] = raw2[key]();
1079
1110
  };
@@ -1298,7 +1329,7 @@ var setDefaultContentType = (contentType, headers) => {
1298
1329
  ...headers
1299
1330
  };
1300
1331
  };
1301
- var createResponseInstance = (body, init) => new Response(body, init);
1332
+ var createResponseInstance = (body2, init) => new Response(body2, init);
1302
1333
  var Context = class {
1303
1334
  #rawRequest;
1304
1335
  #req;
@@ -3674,8 +3705,8 @@ async function importWithAlgCache(cache2, jwk, entry) {
3674
3705
  if (key.type !== "public")
3675
3706
  throw new JWKSInvalid("JSON Web Key Set members must be public keys");
3676
3707
  return cached[alg] = key, key;
3677
- }).catch((error) => {
3678
- throw cached[alg] === pending && delete cached[alg], error;
3708
+ }).catch((error2) => {
3709
+ throw cached[alg] === pending && delete cached[alg], error2;
3679
3710
  });
3680
3711
  cached[alg] = pending;
3681
3712
  }
@@ -3701,14 +3732,14 @@ function createLocalJWKSet(jwks) {
3701
3732
  if (!length)
3702
3733
  throw new JWKSNoMatchingKey();
3703
3734
  if (length !== 1) {
3704
- const error = new JWKSMultipleMatchingKeys();
3705
- throw error[Symbol.asyncIterator] = async function* () {
3735
+ const error2 = new JWKSMultipleMatchingKeys();
3736
+ throw error2[Symbol.asyncIterator] = async function* () {
3706
3737
  for (const jwk2 of candidates)
3707
3738
  try {
3708
3739
  yield await importWithAlgCache(cached, jwk2, entry);
3709
3740
  } catch {
3710
3741
  }
3711
- }, error;
3742
+ }, error2;
3712
3743
  }
3713
3744
  return importWithAlgCache(cached, jwk, entry);
3714
3745
  }, "jwks", {
@@ -3895,182 +3926,500 @@ async function importSigningKeys(jwk) {
3895
3926
  };
3896
3927
  }
3897
3928
 
3898
- // src/password.ts
3899
- import { createHash, createHmac, hkdfSync, randomBytes, randomInt, scrypt, timingSafeEqual } from "crypto";
3900
- var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
3901
- var KEY_LENGTH = 64;
3902
- var SALT_LENGTH = 16;
3903
- var MAX_PASSWORD_LENGTH = 256;
3904
- var MAX_EMAIL_LENGTH = 254;
3905
- function derive2(password, salt, { logN, r, p }) {
3906
- const N = 2 ** logN;
3907
- return new Promise(
3908
- (resolve, reject) => scrypt(
3909
- password.normalize("NFC"),
3910
- salt,
3911
- KEY_LENGTH,
3912
- { N, r, p, maxmem: 256 * N * r + 1024 * 1024 },
3913
- (err, key) => err ? reject(err) : resolve(key)
3914
- )
3915
- );
3916
- }
3917
- async function hashPassword(password, params = HASH_PARAMS) {
3918
- const salt = randomBytes(SALT_LENGTH);
3919
- const key = await derive2(password, salt, params);
3920
- return ["scrypt", params.logN, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
3921
- }
3922
- async function verifyPassword(password, stored) {
3923
- const [scheme, logN, r, p, salt, hash] = stored.split("$");
3924
- const params = { logN: Number(logN), r: Number(r), p: Number(p) };
3925
- if (scheme !== "scrypt" || !salt || !hash || !Object.values(params).every((n) => Number.isInteger(n) && n > 0)) {
3926
- return { ok: false, needsRehash: false };
3927
- }
3928
- const expected = Buffer.from(hash, "base64url");
3929
- const key = await derive2(password, Buffer.from(salt, "base64url"), params);
3930
- const ok = key.length === expected.length && timingSafeEqual(key, expected);
3931
- const needsRehash = params.logN !== HASH_PARAMS.logN || params.r !== HASH_PARAMS.r || params.p !== HASH_PARAMS.p;
3932
- return { ok, needsRehash };
3929
+ // src/webhooks.ts
3930
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
3931
+ var WEBHOOK_TYPES = [
3932
+ "email.verify",
3933
+ "email.reset",
3934
+ "email.already_registered",
3935
+ "email.no_password",
3936
+ "signup.before",
3937
+ "user.created",
3938
+ "email.verified",
3939
+ "email.password_reset",
3940
+ "user.signed_in",
3941
+ "user.deleted",
3942
+ "user.claims_changed",
3943
+ "totp.enabled",
3944
+ "totp.disabled",
3945
+ "totp.recovery_code_used"
3946
+ ];
3947
+ var WEBHOOK_TIMEOUT_MS = 1e4;
3948
+ var MAX_LOCALE_LENGTH = 35;
3949
+ function localeOf(value) {
3950
+ return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
3933
3951
  }
3934
- var dummyHash;
3935
- async function verifyAgainstDummy(password) {
3936
- dummyHash ??= hashPassword(randomBytes(16).toString("hex"));
3937
- await verifyPassword(password, await dummyHash);
3952
+ var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
3953
+ var SECRET_PREFIX = "whsec_";
3954
+ var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
3955
+ var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
3956
+ function generateWebhookSecret() {
3957
+ return `${SECRET_PREFIX}${randomBytes(32).toString("base64")}`;
3938
3958
  }
3939
- function checkPasswordPolicy(password, minLength) {
3940
- if (typeof password !== "string") return "password is missing";
3941
- const length = [...password].length;
3942
- if (length < minLength) return `The password must have at least ${minLength} characters.`;
3943
- if (length > MAX_PASSWORD_LENGTH) return `The password must have at most ${MAX_PASSWORD_LENGTH} characters.`;
3959
+ function checkWebhookSecret(secret) {
3960
+ if (!secret.startsWith(SECRET_PREFIX)) return `must start with "${SECRET_PREFIX}"`;
3961
+ const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
3962
+ if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
3944
3963
  return null;
3945
3964
  }
3946
- function normalizeEmail(email) {
3947
- return email.trim().toLowerCase();
3965
+ function hmac2(secret, content) {
3966
+ const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
3967
+ return createHmac("sha256", key).update(content).digest("base64");
3948
3968
  }
3949
- function isValidEmail(email) {
3950
- return typeof email === "string" && email.trim().length <= MAX_EMAIL_LENGTH && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
3969
+ function signWebhook(secret, id, timestamp, body2) {
3970
+ return `v1,${hmac2(secret, `${id}.${timestamp}.${body2}`)}`;
3951
3971
  }
3952
- function generateLinkToken() {
3953
- return randomBytes(32).toString("base64url");
3972
+ function createWebhookClient(settings, fetchImpl = fetch) {
3973
+ const wants = (type) => settings.events.has(type);
3974
+ return {
3975
+ wants,
3976
+ async call(type, data, timeoutMs) {
3977
+ if (!wants(type)) return { ok: true, body: void 0 };
3978
+ const id = `msg_${randomUUID()}`;
3979
+ const timestamp = Math.floor(Date.now() / 1e3);
3980
+ const body2 = JSON.stringify({ type, data });
3981
+ let response;
3982
+ try {
3983
+ response = await fetchImpl(settings.url, {
3984
+ method: "POST",
3985
+ headers: {
3986
+ "Content-Type": "application/json",
3987
+ "webhook-id": id,
3988
+ "webhook-timestamp": String(timestamp),
3989
+ "webhook-signature": signWebhook(settings.secret, id, timestamp, body2)
3990
+ },
3991
+ body: body2,
3992
+ signal: AbortSignal.timeout(timeoutMs)
3993
+ });
3994
+ } catch (e) {
3995
+ const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
3996
+ return { ok: false, reason };
3997
+ }
3998
+ const text = await response.text().catch(() => "");
3999
+ if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
4000
+ let parsed;
4001
+ try {
4002
+ parsed = text ? JSON.parse(text) : void 0;
4003
+ } catch {
4004
+ parsed = void 0;
4005
+ }
4006
+ return { ok: true, body: parsed };
4007
+ }
4008
+ };
3954
4009
  }
3955
- function hashLinkToken(token) {
3956
- return createHash("sha256").update(token).digest("base64url");
4010
+
4011
+ // src/config.ts
4012
+ function sendsVerificationEmails(config) {
4013
+ return !!config.webhook?.events.has("email.verify");
3957
4014
  }
3958
- function generateCode() {
3959
- return randomInt(0, 1e6).toString().padStart(6, "0");
4015
+ function methodConfig(config) {
4016
+ return { google: config.google, password: config.password, emailVerification: sendsVerificationEmails(config) };
3960
4017
  }
3961
- function deriveCodeKey(signingKeyD) {
3962
- return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth e-mail code", 32));
4018
+ async function resolveEnv(source = process.env) {
4019
+ return typeof source === "function" ? source() : source;
3963
4020
  }
3964
- function hashCode(key, code) {
3965
- return createHmac("sha256", key).update(code).digest("base64url");
4021
+ var DEFAULT_SESSION_TTL = 8 * 60 * 60;
4022
+ var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
4023
+ var DEFAULT_PASSWORD_MIN_LENGTH = 8;
4024
+ var ConfigError = class extends Error {
4025
+ constructor() {
4026
+ super(...arguments);
4027
+ this.name = "ConfigError";
4028
+ }
4029
+ };
4030
+ function parseOrigin(name, value) {
4031
+ let url;
4032
+ try {
4033
+ url = new URL(value);
4034
+ } catch {
4035
+ throw new ConfigError(`${name}: "${value}" is not a URL.`);
4036
+ }
4037
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
4038
+ throw new ConfigError(`${name}: "${value}" must be an http(s) URL.`);
4039
+ }
4040
+ return url.origin;
3966
4041
  }
3967
- function safeEqual(a, b) {
3968
- const x = Buffer.from(a);
3969
- const y = Buffer.from(b);
3970
- return x.length === y.length && timingSafeEqual(x, y);
4042
+ function readEnv(env, name) {
4043
+ return env[name]?.trim().replace(/^(['"])(.*)\1$/s, "$2").trim() || void 0;
3971
4044
  }
4045
+ async function loadConfig(env, overrides = {}) {
4046
+ const read = (name) => readEnv(env, name);
4047
+ const require2 = (name) => {
4048
+ const value = read(name);
4049
+ if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
4050
+ return value;
4051
+ };
4052
+ const issuerValue = require2("MADAUTH_ISSUER");
4053
+ parseOrigin("MADAUTH_ISSUER", issuerValue);
4054
+ const issuer = issuerValue.replace(/\/+$/, "");
4055
+ const keyValue = read("MADAUTH_SIGNING_KEY");
4056
+ if (!keyValue) {
4057
+ const key = await generateSigningKey();
4058
+ throw new ConfigError(
4059
+ `MADAUTH_SIGNING_KEY is not set. Here is a newly generated key you can use (keep it secret and use the same key on every instance):
3972
4060
 
3973
- // src/google.ts
3974
- var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
3975
- var googleJwks;
3976
- function googleKeys() {
3977
- googleJwks ??= createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs"));
3978
- return googleJwks;
3979
- }
3980
- async function verifyGoogleIdToken(idToken, opts) {
3981
- let payload;
4061
+ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
4062
+ `
4063
+ );
4064
+ }
4065
+ let signingKey;
3982
4066
  try {
3983
- ({ payload } = await jwtVerify(idToken, opts.keys ?? googleKeys(), {
3984
- issuer: GOOGLE_ISSUERS,
3985
- audience: opts.clientId,
3986
- algorithms: ["RS256", "ES256"]
3987
- }));
4067
+ signingKey = JSON.parse(keyValue);
4068
+ assertPrivateSigningJwk(signingKey);
3988
4069
  } catch (e) {
3989
- return { ok: false, error: "verification_failed", reason: e.message };
4070
+ const reason = e instanceof SyntaxError ? "is not valid JSON" : e.message;
4071
+ throw new ConfigError(`MADAUTH_SIGNING_KEY ${reason}. Generate one with: npx @madauth/server generate-key`);
3990
4072
  }
3991
- if (typeof payload.sub !== "string" || !payload.sub) {
3992
- return { ok: false, error: "verification_failed", reason: "missing sub" };
4073
+ const allowedOrigins = require2("ALLOWED_ORIGINS").split(",").map((o) => o.trim()).filter(Boolean).map((o) => parseOrigin("ALLOWED_ORIGINS", o));
4074
+ const clientId = read("GOOGLE_CLIENT_ID");
4075
+ if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
4076
+ throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
3993
4077
  }
3994
- if (payload.nonce !== opts.nonce) {
3995
- return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
4078
+ const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
4079
+ const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
4080
+ const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
4081
+ const emailVerification = !!webhook && webhook.events.has("email.verify");
4082
+ if (!clientId && !password && !emailVerification) {
4083
+ throw new ConfigError(
4084
+ `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails: e-mail & password sign-in needs email.verify and email.reset, sign-up with the authenticator app needs email.verify. For development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered,email.no_password. See docs/server.md.`
4085
+ );
3996
4086
  }
3997
- if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
3998
- return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
4087
+ const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
4088
+ if (!store) {
4089
+ throw new ConfigError(
4090
+ "DATABASE_URL is not set. madAuth stores every user, whichever way they sign in: set e.g. DATABASE_URL=sqlite:./madauth.db or DATABASE_URL=dynamodb:<table>. See docs/server.md."
4091
+ );
3999
4092
  }
4000
- const profile = { sub: payload.sub, email: payload.email };
4001
- if (typeof payload.name === "string") profile.name = payload.name;
4002
- if (typeof payload.picture === "string") profile.picture = payload.picture;
4003
- return { ok: true, profile };
4093
+ const ttlValue = read("SESSION_TTL");
4094
+ const sessionTtlSeconds = ttlValue ? Number(ttlValue) : DEFAULT_SESSION_TTL;
4095
+ if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
4096
+ throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
4097
+ }
4098
+ const renewalValue = read("SESSION_RENEWAL_TTL");
4099
+ const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
4100
+ if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
4101
+ throw new ConfigError(
4102
+ `SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
4103
+ );
4104
+ }
4105
+ return {
4106
+ issuer,
4107
+ signingKey,
4108
+ allowedOrigins,
4109
+ sessionTtlSeconds,
4110
+ renewalTtlSeconds,
4111
+ cookieDomain: read("COOKIE_DOMAIN"),
4112
+ totpIssuer: read("TOTP_ISSUER"),
4113
+ google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
4114
+ store,
4115
+ password,
4116
+ webhook
4117
+ };
4004
4118
  }
4005
-
4006
- // src/user.ts
4007
- var SESSION_COOKIE = "madauth_session";
4008
- var SESSION_TYP = "madauth-session+jwt";
4009
- var RENEWAL_COOKIE = "madauth_renewal";
4010
- var RENEWAL_TYP = "madauth-renewal+jwt";
4011
- var EXPIRY_COOKIE = "madauth_session_expires";
4012
-
4013
- // src/tokens.ts
4014
- function randomString(bytes = 32) {
4015
- return base64url_exports.encode(crypto.getRandomValues(new Uint8Array(bytes)));
4119
+ function passwordMinLength(value) {
4120
+ const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
4121
+ if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
4122
+ throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
4123
+ }
4124
+ return minLength;
4016
4125
  }
4017
- async function signToken(keys, issuer, typ, claims, ttlSeconds) {
4018
- return new SignJWT(claims).setProtectedHeader({ alg: SIGNING_ALG, kid: keys.kid, typ }).setIssuer(issuer).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).sign(keys.privateKey);
4126
+ async function storeFromDatabaseUrl(url) {
4127
+ if (!url) return void 0;
4128
+ if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
4129
+ if (!url.startsWith("sqlite:")) {
4130
+ throw new ConfigError(
4131
+ `DATABASE_URL must start with "sqlite:" (e.g. sqlite:/data/madauth.db) or "dynamodb:" (e.g. dynamodb:madauth) but is "${url}". For other databases, pass your own store adapter (see "Custom store adapter" in docs/server.md).`
4132
+ );
4133
+ }
4134
+ const path = url.slice("sqlite:".length);
4135
+ if (!path) throw new ConfigError("DATABASE_URL needs a file path, e.g. sqlite:/data/madauth.db");
4136
+ const { createSqliteAdapter: createSqliteAdapter2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
4137
+ try {
4138
+ return createSqliteAdapter2(path);
4139
+ } catch (e) {
4140
+ throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
4141
+ }
4019
4142
  }
4020
- async function readToken(keys, issuer, typ, token) {
4021
- if (!token) return null;
4143
+ async function dynamoDbStore(tableName2) {
4144
+ if (!tableName2) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
4145
+ if (!/^[\w.-]{3,255}$/.test(tableName2)) {
4146
+ throw new ConfigError(
4147
+ `DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName2}"`
4148
+ );
4149
+ }
4022
4150
  try {
4023
- const { payload } = await jwtVerify(token, keys.publicKey, { issuer, typ, algorithms: [SIGNING_ALG] });
4024
- return payload;
4151
+ await import("@aws-sdk/client-dynamodb");
4152
+ } catch (e) {
4153
+ throw new ConfigError(
4154
+ `DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
4155
+ );
4156
+ }
4157
+ const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
4158
+ return createDynamoDbAdapter2({ tableName: tableName2 });
4159
+ }
4160
+ var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
4161
+ var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
4162
+ function webhookFromEnv(url, secret, events) {
4163
+ if (!url) {
4164
+ if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
4165
+ return void 0;
4166
+ }
4167
+ let parsed;
4168
+ try {
4169
+ parsed = new URL(url);
4025
4170
  } catch {
4026
- return null;
4171
+ throw new ConfigError(`WEBHOOK_URL: "${url}" is not a URL.`);
4172
+ }
4173
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname))) {
4174
+ throw new ConfigError(`WEBHOOK_URL must be an https URL (http only for localhost) but is "${url}".`);
4175
+ }
4176
+ if (!secret) {
4177
+ throw new ConfigError(
4178
+ `WEBHOOK_SECRET is not set. Here is a newly generated secret you can use (keep it secret; your webhook receiver needs the same one):
4179
+
4180
+ WEBHOOK_SECRET=${generateWebhookSecret()}
4181
+ `
4182
+ );
4183
+ }
4184
+ const problem = checkWebhookSecret(secret);
4185
+ if (problem) {
4186
+ throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
4187
+ }
4188
+ const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
4189
+ const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
4190
+ if (!selected.size) {
4191
+ throw new ConfigError(
4192
+ `WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
4193
+
4194
+ WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
4195
+
4196
+ For one that only wants to know who signed in:
4197
+
4198
+ WEBHOOK_EVENTS=user.signed_in
4199
+
4200
+ ` + allTypes
4201
+ );
4027
4202
  }
4203
+ const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
4204
+ if (unknown.length) {
4205
+ const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
4206
+ throw new ConfigError(
4207
+ `WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
4208
+ );
4209
+ }
4210
+ return { url: parsed.href, secret, events: selected };
4028
4211
  }
4029
- function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
4030
- const claims = {
4031
- ...extra,
4032
- sub: user.id,
4033
- email: user.email,
4034
- email_verified: user.email ? true : void 0,
4035
- name: user.name,
4036
- picture: user.picture,
4037
- claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
4038
- amr
4039
- };
4040
- return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
4212
+
4213
+ // src/password.ts
4214
+ import { createHash, createHmac as createHmac2, hkdfSync, randomBytes as randomBytes2, randomInt, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
4215
+ var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
4216
+ var KEY_LENGTH = 64;
4217
+ var SALT_LENGTH = 16;
4218
+ var MAX_PASSWORD_LENGTH = 256;
4219
+ var MAX_EMAIL_LENGTH = 254;
4220
+ function derive2(password, salt, { logN, r, p }) {
4221
+ const N = 2 ** logN;
4222
+ return new Promise(
4223
+ (resolve, reject) => scrypt(
4224
+ password.normalize("NFC"),
4225
+ salt,
4226
+ KEY_LENGTH,
4227
+ { N, r, p, maxmem: 256 * N * r + 1024 * 1024 },
4228
+ (err, key) => err ? reject(err) : resolve(key)
4229
+ )
4230
+ );
4041
4231
  }
4042
- function signRenewal(keys, issuer, ttlSeconds, user, amr, extra = {}) {
4043
- const claims = {
4044
- ...extra,
4045
- sub: user.id,
4046
- email: user.email,
4047
- email_verified: user.email ? true : void 0,
4048
- name: user.name,
4049
- picture: user.picture,
4050
- amr
4051
- };
4052
- return signToken(keys, issuer, RENEWAL_TYP, claims, ttlSeconds);
4232
+ async function hashPassword(password, params = HASH_PARAMS) {
4233
+ const salt = randomBytes2(SALT_LENGTH);
4234
+ const key = await derive2(password, salt, params);
4235
+ return ["scrypt", params.logN, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
4053
4236
  }
4054
- function userFromClaims(claims) {
4055
- const user = { id: claims.sub };
4056
- if (claims.email) user.email = claims.email;
4057
- if (claims.name) user.name = claims.name;
4058
- if (claims.picture) user.picture = claims.picture;
4059
- if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
4060
- return user;
4237
+ async function verifyPassword(password, stored) {
4238
+ const [scheme, logN, r, p, salt, hash] = stored.split("$");
4239
+ const params = { logN: Number(logN), r: Number(r), p: Number(p) };
4240
+ if (scheme !== "scrypt" || !salt || !hash || !Object.values(params).every((n) => Number.isInteger(n) && n > 0)) {
4241
+ return { ok: false, needsRehash: false };
4242
+ }
4243
+ const expected = Buffer.from(hash, "base64url");
4244
+ const key = await derive2(password, Buffer.from(salt, "base64url"), params);
4245
+ const ok = key.length === expected.length && timingSafeEqual2(key, expected);
4246
+ const needsRehash = params.logN !== HASH_PARAMS.logN || params.r !== HASH_PARAMS.r || params.p !== HASH_PARAMS.p;
4247
+ return { ok, needsRehash };
4248
+ }
4249
+ var dummyHash;
4250
+ async function verifyAgainstDummy(password) {
4251
+ dummyHash ??= hashPassword(randomBytes2(16).toString("hex"));
4252
+ await verifyPassword(password, await dummyHash);
4253
+ }
4254
+ function checkPasswordPolicy(password, minLength) {
4255
+ if (typeof password !== "string") return "password is missing";
4256
+ const length = [...password].length;
4257
+ if (length < minLength) return `The password must have at least ${minLength} characters.`;
4258
+ if (length > MAX_PASSWORD_LENGTH) return `The password must have at most ${MAX_PASSWORD_LENGTH} characters.`;
4259
+ return null;
4260
+ }
4261
+ function normalizeEmail(email) {
4262
+ return email.trim().toLowerCase();
4263
+ }
4264
+ function isValidEmail(email) {
4265
+ return typeof email === "string" && email.trim().length <= MAX_EMAIL_LENGTH && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
4266
+ }
4267
+ function generateLinkToken() {
4268
+ return randomBytes2(32).toString("base64url");
4269
+ }
4270
+ function hashLinkToken(token) {
4271
+ return createHash("sha256").update(token).digest("base64url");
4272
+ }
4273
+ function generateCode() {
4274
+ return randomInt(0, 1e6).toString().padStart(6, "0");
4275
+ }
4276
+ function deriveCodeKey(signingKeyD) {
4277
+ return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth e-mail code", 32));
4278
+ }
4279
+ function hashCode(key, code) {
4280
+ return createHmac2("sha256", key).update(code).digest("base64url");
4281
+ }
4282
+ function safeEqual(a, b) {
4283
+ const x = Buffer.from(a);
4284
+ const y = Buffer.from(b);
4285
+ return x.length === y.length && timingSafeEqual2(x, y);
4286
+ }
4287
+
4288
+ // src/users.ts
4289
+ import { randomBytes as randomBytes3 } from "crypto";
4290
+
4291
+ // src/settings.ts
4292
+ var SIGN_IN_METHODS = ["google", "password", "totp"];
4293
+ var METHODS_WITH_SECOND_FACTOR = ["google", "password"];
4294
+ var SECOND_FACTOR_POLICIES = ["none", "optional", "required"];
4295
+ var METHODS_SETTING = "methods";
4296
+ var isSignInMethod = (value) => SIGN_IN_METHODS.includes(value);
4297
+ var isPolicy = (value) => SECOND_FACTOR_POLICIES.includes(value);
4298
+ function parseMethodSettings(input, options = {}) {
4299
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
4300
+ const settings = {};
4301
+ for (const [method, value] of Object.entries(input)) {
4302
+ if (!isSignInMethod(method)) return null;
4303
+ if (typeof value === "boolean") {
4304
+ if (!options.booleans) return null;
4305
+ if (value) settings[method] = {};
4306
+ continue;
4307
+ }
4308
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
4309
+ const setting = {};
4310
+ for (const [field, fieldValue] of Object.entries(value)) {
4311
+ if (field !== "secondFactor" || method === "totp" || !isPolicy(fieldValue)) return null;
4312
+ setting.secondFactor = fieldValue;
4313
+ }
4314
+ settings[method] = setting;
4315
+ }
4316
+ return settings;
4317
+ }
4318
+ function isConfigured(config, method) {
4319
+ if (method === "google") return !!config.google;
4320
+ if (method === "password") return !!config.password;
4321
+ return true;
4322
+ }
4323
+ function canSignUp(config, method) {
4324
+ return method === "totp" ? !!config.emailVerification : isConfigured(config, method);
4325
+ }
4326
+ function initialMethods(config) {
4327
+ const methods = {};
4328
+ if (config.google) methods.google = { secondFactor: "none" };
4329
+ if (config.password) methods.password = { secondFactor: "none" };
4330
+ if (!config.google && !config.password && config.emailVerification) methods.totp = {};
4331
+ return methods;
4332
+ }
4333
+ var MAX_TRIES = 3;
4334
+ var Settings = class {
4335
+ /** `initial` is what counts until the methods are set, see {@link initialMethods}. */
4336
+ constructor(store, initial = {}) {
4337
+ this.store = store;
4338
+ this.initial = initial;
4339
+ this.warned = false;
4340
+ }
4341
+ /** The setting's value, or undefined if it was never set. */
4342
+ async get(id) {
4343
+ return (await this.read(id)).value;
4344
+ }
4345
+ /** The stored JSON and what it holds; both undefined if the setting was never set. */
4346
+ async read(id) {
4347
+ const record = await this.store.findOne("setting", { id });
4348
+ if (typeof record?.value !== "string") return {};
4349
+ try {
4350
+ return { json: record.value, value: JSON.parse(record.value) };
4351
+ } catch {
4352
+ return { json: record.value };
4353
+ }
4354
+ }
4355
+ /**
4356
+ * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
4357
+ * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
4358
+ * again with the newer value. Resolves to the new value, or null if `apply` refused.
4359
+ * `updatedBy` is the address of the admin, or null for the command line.
4360
+ */
4361
+ async change(id, apply, updatedBy) {
4362
+ for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
4363
+ const { json, value } = await this.read(id);
4364
+ const next = apply(value);
4365
+ if (next === null) return null;
4366
+ const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
4367
+ const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
4368
+ if (written) return next;
4369
+ }
4370
+ throw new Error(`Setting "${id}" kept changing; try again.`);
4371
+ }
4372
+ /** Replaces the setting. */
4373
+ async set(id, value, updatedBy) {
4374
+ await this.change(id, () => value, updatedBy);
4375
+ }
4376
+ /** Which sign-in methods are on, with their settings; the initial ones until an admin set them. */
4377
+ async methods() {
4378
+ return this.current(await this.get(METHODS_SETTING));
4379
+ }
4380
+ /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
4381
+ async changeMethods(apply, updatedBy) {
4382
+ return this.change(METHODS_SETTING, (current) => apply(this.current(current)), updatedBy);
4383
+ }
4384
+ /** The stored methods, or the initial ones if nothing (usable) is stored. */
4385
+ current(stored) {
4386
+ if (stored === void 0) return this.initial;
4387
+ const parsed = parseMethodSettings(stored);
4388
+ if (parsed) return parsed;
4389
+ if (!this.warned) {
4390
+ this.warned = true;
4391
+ console.warn("[madauth] The stored sign-in methods are not in the form this version expects (they were set by an older one?). Set them again with set-methods; until then the initial methods count.");
4392
+ }
4393
+ return this.initial;
4394
+ }
4395
+ };
4396
+ function isOn(method, configured, methods) {
4397
+ return configured(method) && methods[method] !== void 0;
4398
+ }
4399
+ function policyOf(method, methods) {
4400
+ return methods[method]?.secondFactor ?? "none";
4401
+ }
4402
+ function anyOn(signsUp, methods) {
4403
+ return SIGN_IN_METHODS.some((method) => signsUp(method) && methods[method] !== void 0);
4404
+ }
4405
+ function authenticatorOn(configured, methods) {
4406
+ if (isOn("totp", configured, methods)) return true;
4407
+ return METHODS_WITH_SECOND_FACTOR.some((method) => isOn(method, configured, methods) && policyOf(method, methods) !== "none");
4061
4408
  }
4062
4409
 
4063
4410
  // src/users.ts
4064
- import { randomBytes as randomBytes2 } from "crypto";
4065
4411
  var MAX_CODE_ATTEMPTS = 5;
4066
4412
  var MAX_WRONG_CODES = 10;
4067
- var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
4413
+ var newId = (prefix) => `${prefix}_${randomBytes3(16).toString("base64url")}`;
4068
4414
  function passwordAccountKey(userId) {
4069
4415
  return `password:${userId}`;
4070
4416
  }
4071
4417
  function googleAccountKey(sub) {
4072
4418
  return `google:${sub}`;
4073
4419
  }
4420
+ function totpAccountKey(userId) {
4421
+ return `totp:${userId}`;
4422
+ }
4074
4423
  function toMadauthUser(user, profile = {}) {
4075
4424
  const result = { id: user.id, email: user.email };
4076
4425
  const name = user.name ?? profile.name;
@@ -4099,12 +4448,32 @@ var Users = class {
4099
4448
  async passwordAccount(userId) {
4100
4449
  return this.findAccountByKey(passwordAccountKey(userId));
4101
4450
  }
4451
+ /** The user's authenticator app, if they set one up. */
4452
+ async totpAccount(userId) {
4453
+ return this.findAccountByKey(totpAccountKey(userId));
4454
+ }
4455
+ /** Whether the user has any way to sign in; a sign-up with the authenticator app has none until it is set up. */
4456
+ async hasAccounts(userId) {
4457
+ return (await this.store.findMany("account", { userId }, { limit: 1 })).length > 0;
4458
+ }
4459
+ /**
4460
+ * The ways the user can sign in, from their accounts: e.g. `['google']` for a user without a password.
4461
+ * The authenticator app counts only when it signs in on its own (`methods` lists it); as a second
4462
+ * factor it is a step of another method, not a way in.
4463
+ */
4464
+ async signInMethods(userId, methods) {
4465
+ const accounts = await this.store.findMany("account", { userId });
4466
+ const keys = accounts.map((account) => String(account.key).split(":")[0]);
4467
+ return SIGN_IN_METHODS.filter((method) => keys.includes(method) && (method !== "totp" || methods.totp !== void 0));
4468
+ }
4102
4469
  async findAccountByKey(key) {
4103
4470
  return await this.store.findOne("account", { key });
4104
4471
  }
4105
4472
  /**
4106
- * Creates a user with their first account. Resolves to null if the e-mail address or the account key is
4107
- * taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or e.g. `google:<sub>`.
4473
+ * Creates a user, with their first account if given. Resolves to null if the e-mail address or the
4474
+ * account key is taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or
4475
+ * e.g. `google:<sub>`. A sign-up with the authenticator app creates the user without an account: the app
4476
+ * is set up once the address is confirmed.
4108
4477
  */
4109
4478
  async createUser(data) {
4110
4479
  const user = {
@@ -4120,6 +4489,7 @@ var Users = class {
4120
4489
  createdAt: Date.now()
4121
4490
  };
4122
4491
  if (!await this.store.create("user", user)) return null;
4492
+ if (!data.account) return user;
4123
4493
  const account = typeof data.account === "function" ? data.account(user.id) : data.account;
4124
4494
  if (!await this.linkAccount(user.id, account)) {
4125
4495
  await this.store.delete("user", { id: user.id });
@@ -4141,6 +4511,16 @@ var Users = class {
4141
4511
  async deleteAccount(id) {
4142
4512
  await this.store.delete("account", { id });
4143
4513
  }
4514
+ /** Removes a sign-in method by its key. Resolves to false if there was none. */
4515
+ async unlinkAccount(key) {
4516
+ return await this.store.delete("account", { key }) === 1;
4517
+ }
4518
+ /** Removes the user's authenticator app and recovery codes. Resolves to false if they had no app. */
4519
+ async removeTotp(userId) {
4520
+ const removed = await this.unlinkAccount(totpAccountKey(userId));
4521
+ await this.clearRecoveryCodes(userId);
4522
+ return removed;
4523
+ }
4144
4524
  /** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
4145
4525
  async linkAccount(userId, account) {
4146
4526
  const record = {
@@ -4151,10 +4531,70 @@ var Users = class {
4151
4531
  failedAttempts: 0,
4152
4532
  lockedUntil: 0,
4153
4533
  email: account.email ?? null,
4534
+ lastUsedStep: account.lastUsedStep ?? 0,
4535
+ setupId: account.setupId ?? null,
4154
4536
  createdAt: Date.now()
4155
4537
  };
4156
4538
  return await this.store.create("account", record) ? record : null;
4157
4539
  }
4540
+ /**
4541
+ * Writes the secret of a confirmed setup over the authenticator account, only if the account still has the
4542
+ * setup it had when this setup started (`replaces`): the write count decides, so of two setups confirmed
4543
+ * at once exactly one wins, and the other is told to start again.
4544
+ */
4545
+ async confirmTotpSetup(accountId, replaces, patch) {
4546
+ return await this.store.update("account", { id: accountId, setupId: replaces }, patch) === 1;
4547
+ }
4548
+ /**
4549
+ * Marks the time step of an accepted authenticator code as used and resets the failed attempts, only if
4550
+ * no other request accepted a code meanwhile: the write count decides, so the same code sent twice signs
4551
+ * in once.
4552
+ */
4553
+ async useTotpStep(account, step) {
4554
+ const changed = await this.store.update(
4555
+ "account",
4556
+ { id: account.id, lastUsedStep: account.lastUsedStep ?? null },
4557
+ { lastUsedStep: step, failedAttempts: 0, lockedUntil: 0 }
4558
+ );
4559
+ return changed === 1;
4560
+ }
4561
+ /** Replaces the user's recovery codes with new ones, given as their hashes. */
4562
+ /** Stores the recovery codes of a setup (their hashes), next to whatever codes the user has. */
4563
+ async addRecoveryCodes(userId, setupId, hashes) {
4564
+ const createdAt = Date.now();
4565
+ for (const id of hashes) await this.store.create("recoveryCode", { id, userId, setupId, createdAt });
4566
+ }
4567
+ /** New recovery codes for the user's current app: the codes they had before stop working. */
4568
+ async replaceRecoveryCodes(account, hashes) {
4569
+ const before = await this.store.findMany("recoveryCode", { userId: account.userId });
4570
+ await this.addRecoveryCodes(account.userId, account.setupId ?? "", hashes);
4571
+ for (const old of before) await this.store.delete("recoveryCode", { id: old.id });
4572
+ }
4573
+ /**
4574
+ * Uses up the recovery code with this hash for the user's authenticator account. Resolves to false if it
4575
+ * is not theirs, belongs to another setup than the account's, or was used already.
4576
+ */
4577
+ async consumeRecoveryCode(account, hash) {
4578
+ const record = await this.store.findOne("recoveryCode", { id: hash });
4579
+ if (!record || record.userId !== account.userId || record.setupId !== (account.setupId ?? "")) return false;
4580
+ return await this.store.delete("recoveryCode", { id: hash }) === 1;
4581
+ }
4582
+ /** How many recovery codes of the account's setup are left. */
4583
+ async countRecoveryCodes(account) {
4584
+ const codes = await this.store.findMany("recoveryCode", { userId: account.userId });
4585
+ return codes.filter((code) => code.setupId === (account.setupId ?? "")).length;
4586
+ }
4587
+ /** Deletes the user's recovery codes: all of them, or only those of one setup, or all but one setup's. */
4588
+ async clearRecoveryCodes(userId, which = {}) {
4589
+ if (which.only === void 0 && which.except === void 0) {
4590
+ await this.store.delete("recoveryCode", { userId });
4591
+ return;
4592
+ }
4593
+ for (const code of await this.store.findMany("recoveryCode", { userId })) {
4594
+ const match2 = which.only !== void 0 ? code.setupId === which.only : code.setupId !== which.except;
4595
+ if (match2) await this.store.delete("recoveryCode", { id: code.id });
4596
+ }
4597
+ }
4158
4598
  /**
4159
4599
  * Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
4160
4600
  */
@@ -4165,6 +4605,7 @@ var Users = class {
4165
4605
  try {
4166
4606
  await this.store.delete("account", { userId: user.id });
4167
4607
  await this.store.delete("verification", { userId: user.id });
4608
+ await this.store.delete("recoveryCode", { userId: user.id });
4168
4609
  } catch (e) {
4169
4610
  console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
4170
4611
  }
@@ -4173,8 +4614,9 @@ var Users = class {
4173
4614
  async updateUser(id, patch) {
4174
4615
  await this.store.update("user", { id }, patch);
4175
4616
  }
4617
+ /** Changes an account. Resolves to false if it is gone meanwhile. */
4176
4618
  async updateAccount(id, patch) {
4177
- await this.store.update("account", { id }, patch);
4619
+ return await this.store.update("account", { id }, patch) === 1;
4178
4620
  }
4179
4621
  /**
4180
4622
  * Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
@@ -4207,6 +4649,11 @@ var Users = class {
4207
4649
  await this.store.create("verification", record);
4208
4650
  return { token, code };
4209
4651
  }
4652
+ /** The user ID a link token belongs to, without using the verification up; null if invalid, expired or used. */
4653
+ async peekLinkToken(token, purpose) {
4654
+ const record = await this.store.findOne("verification", { id: hashLinkToken(token), purpose });
4655
+ return record && record.expiresAt > Date.now() ? record.userId : null;
4656
+ }
4210
4657
  /** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
4211
4658
  async consumeLinkToken(token, purpose) {
4212
4659
  const id = hashLinkToken(token);
@@ -4253,82 +4700,213 @@ var Users = class {
4253
4700
  }
4254
4701
  };
4255
4702
 
4256
- // src/webhooks.ts
4257
- import { createHmac as createHmac2, randomBytes as randomBytes3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
4258
- var WEBHOOK_TYPES = [
4259
- "email.verify",
4260
- "email.reset",
4261
- "email.already_registered",
4262
- "signup.before",
4263
- "user.created",
4264
- "email.verified",
4265
- "email.password_reset",
4266
- "user.signed_in",
4267
- "user.deleted",
4268
- "user.claims_changed"
4269
- ];
4270
- var WEBHOOK_TIMEOUT_MS = 1e4;
4271
- var MAX_LOCALE_LENGTH = 35;
4272
- function localeOf(value) {
4273
- return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
4703
+ // src/routes/helpers.ts
4704
+ async function body(c) {
4705
+ const data = await c.req.json().catch(() => null);
4706
+ return data && typeof data === "object" ? data : {};
4707
+ }
4708
+ function error(c, status, code, message2) {
4709
+ return c.json({ error: code, message: message2 }, status);
4710
+ }
4711
+ var unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
4712
+ var accepted = (c) => c.json({}, 202);
4713
+ var noSession = (c) => error(c, 401, "no_session", "Please sign in first.");
4714
+ function redirectTarget(ctx, c, value) {
4715
+ const url = ctx.allowedUrl(value);
4716
+ return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
4717
+ }
4718
+ var MAX_NAME_LENGTH = 100;
4719
+ function nameOf(value) {
4720
+ return typeof value === "string" && value.trim() ? value.trim().slice(0, MAX_NAME_LENGTH) : null;
4721
+ }
4722
+ function signInAnswer(c, step) {
4723
+ if ("disabled" in step) return error(c, 403, "method_disabled", "This way of signing in is switched off.");
4724
+ if ("next" in step) {
4725
+ 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);
4726
+ }
4727
+ return c.json({ user: step.user });
4728
+ }
4729
+
4730
+ // src/routes/emails.ts
4731
+ var MAIL_INTERVAL_MS = 60 * 1e3;
4732
+ var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
4733
+ var RESET_TTL_MS = 30 * 60 * 1e3;
4734
+ var VERIFY_LINK_PARAM = "madauth_verify";
4735
+ var RESET_LINK_PARAM = "madauth_reset";
4736
+ function createEmails(ctx, users, webhook) {
4737
+ const sendEmail = async (user, type, data) => {
4738
+ if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
4739
+ const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
4740
+ if (!result.ok) {
4741
+ console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
4742
+ return false;
4743
+ }
4744
+ await users.updateUser(user.id, { lastMailAt: Date.now() });
4745
+ return true;
4746
+ };
4747
+ return {
4748
+ sendEmail,
4749
+ async sendLinkEmail(user, purpose, target, locale) {
4750
+ if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
4751
+ const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
4752
+ const { token, code } = await users.issueVerification(user.id, purpose, ttl);
4753
+ const link = new URL(target);
4754
+ link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
4755
+ return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
4756
+ link: link.href,
4757
+ code,
4758
+ expiresAt: Date.now() + ttl,
4759
+ site: target.host,
4760
+ locale
4761
+ });
4762
+ },
4763
+ async sendAccountEmail(user, type, target, locale) {
4764
+ if (!webhook.wants(type)) return true;
4765
+ const methods = await users.signInMethods(user.id, await ctx.settings.methods());
4766
+ return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
4767
+ },
4768
+ async consume(data, purpose) {
4769
+ if (typeof data.token === "string" && data.token) {
4770
+ return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
4771
+ }
4772
+ if (isValidEmail(data.email) && typeof data.code === "string") {
4773
+ const user = await users.findByEmail(normalizeEmail(data.email));
4774
+ if (!user) return { userId: null, via: "code" };
4775
+ return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
4776
+ }
4777
+ return { userId: null, via: "code" };
4778
+ },
4779
+ invalidVerification(c, { via, locked }) {
4780
+ if (locked) {
4781
+ return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
4782
+ }
4783
+ 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.");
4784
+ }
4785
+ };
4786
+ }
4787
+
4788
+ // src/routes/email.ts
4789
+ function emailRoutes(app, ctx, users, webhook) {
4790
+ const emails = createEmails(ctx, users, webhook);
4791
+ const signUpMethod = async (userId) => await users.passwordAccount(userId) ? "password" : "totp";
4792
+ app.post("/auth/email/send-verification", async (c) => {
4793
+ const data = await body(c);
4794
+ const target = redirectTarget(ctx, c, data.redirectTo);
4795
+ if (target instanceof Response) return target;
4796
+ if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
4797
+ const user = await users.findByEmail(normalizeEmail(data.email));
4798
+ if (user && !user.emailVerified && await ctx.enabled(c, await signUpMethod(user.id))) {
4799
+ if (!await emails.sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
4800
+ }
4801
+ return accepted(c);
4802
+ });
4803
+ const disabled = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
4804
+ app.post("/auth/email/verify", async (c) => {
4805
+ const data = await body(c);
4806
+ 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;
4807
+ if (waiting && !await ctx.enabled(c, await signUpMethod(waiting))) return disabled(c);
4808
+ const consumed = await emails.consume(data, "verify");
4809
+ const { userId, via } = consumed;
4810
+ const user = userId ? await users.findById(userId) : null;
4811
+ if (!user) return emails.invalidVerification(c, consumed);
4812
+ const method = await signUpMethod(user.id);
4813
+ if (!await ctx.enabled(c, method)) return disabled(c);
4814
+ await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
4815
+ const verified = { ...user, emailVerified: true, wrongCodes: 0 };
4816
+ await ctx.emit("email.verified", { user: toMadauthUser(verified), via });
4817
+ return signInAnswer(c, await ctx.secondStep(c, verified, method === "password" ? ["pwd"] : [], method));
4818
+ });
4819
+ }
4820
+
4821
+ // src/google.ts
4822
+ var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
4823
+ var googleJwks;
4824
+ function googleKeys() {
4825
+ googleJwks ??= createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs"));
4826
+ return googleJwks;
4827
+ }
4828
+ async function verifyGoogleIdToken(idToken, opts) {
4829
+ let payload;
4830
+ try {
4831
+ ({ payload } = await jwtVerify(idToken, opts.keys ?? googleKeys(), {
4832
+ issuer: GOOGLE_ISSUERS,
4833
+ audience: opts.clientId,
4834
+ algorithms: ["RS256", "ES256"]
4835
+ }));
4836
+ } catch (e) {
4837
+ return { ok: false, error: "verification_failed", reason: e.message };
4838
+ }
4839
+ if (typeof payload.sub !== "string" || !payload.sub) {
4840
+ return { ok: false, error: "verification_failed", reason: "missing sub" };
4841
+ }
4842
+ if (payload.nonce !== opts.nonce) {
4843
+ return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
4844
+ }
4845
+ if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
4846
+ return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
4847
+ }
4848
+ const profile = { sub: payload.sub, email: payload.email };
4849
+ if (typeof payload.name === "string") profile.name = payload.name;
4850
+ if (typeof payload.picture === "string") profile.picture = payload.picture;
4851
+ return { ok: true, profile };
4274
4852
  }
4275
- var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
4276
- var SECRET_PREFIX = "whsec_";
4277
- var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
4278
- var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
4279
- function generateWebhookSecret() {
4280
- return `${SECRET_PREFIX}${randomBytes3(32).toString("base64")}`;
4853
+
4854
+ // src/user.ts
4855
+ var SESSION_COOKIE = "madauth_session";
4856
+ var SESSION_TYP = "madauth-session+jwt";
4857
+ var RENEWAL_COOKIE = "madauth_renewal";
4858
+ var RENEWAL_TYP = "madauth-renewal+jwt";
4859
+ var EXPIRY_COOKIE = "madauth_session_expires";
4860
+
4861
+ // src/tokens.ts
4862
+ function randomString(bytes = 32) {
4863
+ return base64url_exports.encode(crypto.getRandomValues(new Uint8Array(bytes)));
4281
4864
  }
4282
- function checkWebhookSecret(secret) {
4283
- if (!secret.startsWith(SECRET_PREFIX)) return `must start with "${SECRET_PREFIX}"`;
4284
- const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
4285
- if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
4286
- return null;
4865
+ async function signToken(keys, issuer, typ, claims, ttlSeconds) {
4866
+ return new SignJWT(claims).setProtectedHeader({ alg: SIGNING_ALG, kid: keys.kid, typ }).setIssuer(issuer).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).sign(keys.privateKey);
4287
4867
  }
4288
- function hmac2(secret, content) {
4289
- const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
4290
- return createHmac2("sha256", key).update(content).digest("base64");
4868
+ async function readToken(keys, issuer, typ, token) {
4869
+ if (!token) return null;
4870
+ try {
4871
+ const { payload } = await jwtVerify(token, keys.publicKey, { issuer, typ, algorithms: [SIGNING_ALG] });
4872
+ return payload;
4873
+ } catch {
4874
+ return null;
4875
+ }
4291
4876
  }
4292
- function signWebhook(secret, id, timestamp, body) {
4293
- return `v1,${hmac2(secret, `${id}.${timestamp}.${body}`)}`;
4877
+ function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
4878
+ const claims = {
4879
+ ...extra,
4880
+ sub: user.id,
4881
+ email: user.email,
4882
+ email_verified: user.email ? true : void 0,
4883
+ name: user.name,
4884
+ picture: user.picture,
4885
+ claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
4886
+ amr
4887
+ };
4888
+ return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
4294
4889
  }
4295
- function createWebhookClient(settings, fetchImpl = fetch) {
4296
- const wants = (type) => settings.events.has(type);
4297
- return {
4298
- wants,
4299
- async call(type, data, timeoutMs) {
4300
- if (!wants(type)) return { ok: true, body: void 0 };
4301
- const id = `msg_${randomUUID()}`;
4302
- const timestamp = Math.floor(Date.now() / 1e3);
4303
- const body = JSON.stringify({ type, data });
4304
- let response;
4305
- try {
4306
- response = await fetchImpl(settings.url, {
4307
- method: "POST",
4308
- headers: {
4309
- "Content-Type": "application/json",
4310
- "webhook-id": id,
4311
- "webhook-timestamp": String(timestamp),
4312
- "webhook-signature": signWebhook(settings.secret, id, timestamp, body)
4313
- },
4314
- body,
4315
- signal: AbortSignal.timeout(timeoutMs)
4316
- });
4317
- } catch (e) {
4318
- const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
4319
- return { ok: false, reason };
4320
- }
4321
- const text = await response.text().catch(() => "");
4322
- if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
4323
- let parsed;
4324
- try {
4325
- parsed = text ? JSON.parse(text) : void 0;
4326
- } catch {
4327
- parsed = void 0;
4328
- }
4329
- return { ok: true, body: parsed };
4330
- }
4890
+ function signRenewal(keys, issuer, ttlSeconds, user, amr, extra = {}) {
4891
+ const claims = {
4892
+ ...extra,
4893
+ sub: user.id,
4894
+ email: user.email,
4895
+ email_verified: user.email ? true : void 0,
4896
+ name: user.name,
4897
+ picture: user.picture,
4898
+ amr
4331
4899
  };
4900
+ return signToken(keys, issuer, RENEWAL_TYP, claims, ttlSeconds);
4901
+ }
4902
+ function userFromClaims(claims) {
4903
+ const user = { id: claims.sub };
4904
+ if (claims.email) user.email = claims.email;
4905
+ if (claims.name) user.name = claims.name;
4906
+ if (claims.picture) user.picture = claims.picture;
4907
+ if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
4908
+ if (Array.isArray(claims.amr)) user.amr = claims.amr.filter((value) => typeof value === "string");
4909
+ return user;
4332
4910
  }
4333
4911
 
4334
4912
  // src/routes/google.ts
@@ -4341,6 +4919,7 @@ var OAUTH_TTL = 10 * 60;
4341
4919
  var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
4342
4920
  var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
4343
4921
  var REDIRECT_ERROR_PARAM = "madauth_error";
4922
+ var REDIRECT_NEXT_PARAM = "madauth_next";
4344
4923
  async function pkceChallenge(verifier) {
4345
4924
  const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
4346
4925
  return base64url_exports.encode(new Uint8Array(digest2));
@@ -4352,7 +4931,7 @@ function googleRoutes(app, ctx, google) {
4352
4931
  const resolveUser = async (profile, locale) => {
4353
4932
  const key = googleAccountKey(profile.sub);
4354
4933
  const emailNormalized = normalizeEmail(profile.email);
4355
- const signedIn = (user) => ({ ok: true, user: toMadauthUser(user, profile), sv: user.sessionVersion });
4934
+ const signedIn = (user) => ({ ok: true, user });
4356
4935
  const account = await users.findAccountByKey(key);
4357
4936
  if (account) {
4358
4937
  const user = await users.findById(account.userId);
@@ -4365,6 +4944,11 @@ function googleRoutes(app, ctx, google) {
4365
4944
  for (let attempt = 0; attempt < 2; attempt++) {
4366
4945
  const existing = await users.findByEmail(emailNormalized);
4367
4946
  if (existing) {
4947
+ if (existing.emailVerified && await users.hasAccounts(existing.id)) {
4948
+ const methods = await users.signInMethods(existing.id, await ctx.settings.methods());
4949
+ const how = methods.map((method) => ({ password: "a password", totp: "an authenticator app", google: "Google" })[method]).join(" or ");
4950
+ return { ok: false, error: "other_method", message: `This e-mail address signs in with ${how || "another method"}. Use that instead of Google.`, methods };
4951
+ }
4368
4952
  await users.verifyByProvider(existing);
4369
4953
  if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
4370
4954
  const linked = await users.findAccountByKey(key);
@@ -4396,7 +4980,7 @@ function googleRoutes(app, ctx, google) {
4396
4980
  };
4397
4981
  const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
4398
4982
  app.post("/auth/google/nonce", async (c) => {
4399
- if (!await ctx.enabled("google")) return disabled(c);
4983
+ if (!await ctx.enabled(c, "google")) return disabled(c);
4400
4984
  const nonce = randomString();
4401
4985
  const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
4402
4986
  setCookie(c, NONCE_COOKIE, token, {
@@ -4409,16 +4993,16 @@ function googleRoutes(app, ctx, google) {
4409
4993
  return c.json({ nonce });
4410
4994
  });
4411
4995
  app.post("/auth/google/verify", async (c) => {
4412
- if (!await ctx.enabled("google")) return disabled(c);
4413
- const body = await c.req.json().catch(() => ({}));
4414
- if (typeof body.credential !== "string" || !body.credential) {
4996
+ if (!await ctx.enabled(c, "google")) return disabled(c);
4997
+ const body2 = await c.req.json().catch(() => ({}));
4998
+ if (typeof body2.credential !== "string" || !body2.credential) {
4415
4999
  return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
4416
5000
  }
4417
5001
  const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
4418
5002
  if (!nonceClaims) {
4419
5003
  return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
4420
5004
  }
4421
- const result = await verifyGoogleIdToken(body.credential, {
5005
+ const result = await verifyGoogleIdToken(body2.credential, {
4422
5006
  clientId: google.clientId,
4423
5007
  nonce: nonceClaims.nonce,
4424
5008
  keys: config.jwksResolver
@@ -4427,17 +5011,16 @@ function googleRoutes(app, ctx, google) {
4427
5011
  return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
4428
5012
  }
4429
5013
  deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
4430
- const resolved = await resolveUser(result.profile, localeOf(body.locale));
5014
+ const resolved = await resolveUser(result.profile, localeOf(body2.locale));
4431
5015
  if (!resolved.ok) {
5016
+ if (resolved.error === "other_method") return c.json({ error: resolved.error, message: resolved.message, methods: resolved.methods }, 403);
4432
5017
  return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
4433
5018
  }
4434
- const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
4435
- await ctx.emit("user.signed_in", { user, method: "google" });
4436
- return c.json({ user });
5019
+ return signInAnswer(c, await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile));
4437
5020
  });
4438
5021
  const requireCodeFlow = async (c, next) => {
4439
5022
  if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
4440
- if (!await ctx.enabled("google")) return disabled(c);
5023
+ if (!await ctx.enabled(c, "google")) return disabled(c);
4441
5024
  await next();
4442
5025
  };
4443
5026
  app.get("/auth/google/start", requireCodeFlow, async (c) => {
@@ -4475,9 +5058,10 @@ function googleRoutes(app, ctx, google) {
4475
5058
  const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
4476
5059
  if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
4477
5060
  deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
4478
- const back = (error) => {
5061
+ const back = (error2, next) => {
4479
5062
  const url = new URL(oauth.returnTo);
4480
- if (error) url.hash = `${REDIRECT_ERROR_PARAM}=${error}`;
5063
+ if (error2) url.hash = `${REDIRECT_ERROR_PARAM}=${error2}`;
5064
+ if (next) url.hash = `${REDIRECT_NEXT_PARAM}=${next}`;
4481
5065
  return c.redirect(url.href, 302);
4482
5066
  };
4483
5067
  if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
@@ -4511,80 +5095,38 @@ function googleRoutes(app, ctx, google) {
4511
5095
  if (!result.ok) return back(result.error);
4512
5096
  const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
4513
5097
  if (!resolved.ok) return back(resolved.error);
4514
- const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
4515
- await ctx.emit("user.signed_in", { user, method: "google" });
5098
+ const step = await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile);
5099
+ if ("disabled" in step) return back("method_disabled");
5100
+ if ("next" in step) return back(void 0, step.next);
4516
5101
  return back();
4517
5102
  });
4518
5103
  }
4519
5104
 
4520
- // src/routes/password.ts
5105
+ // src/routes/lockout.ts
4521
5106
  var FREE_ATTEMPTS = 5;
4522
5107
  var MAX_LOCK_MS = 15 * 60 * 1e3;
4523
- var MAIL_INTERVAL_MS = 60 * 1e3;
4524
- var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
4525
- var RESET_TTL_MS = 30 * 60 * 1e3;
4526
- var VERIFY_LINK_PARAM = "madauth_verify";
4527
- var RESET_LINK_PARAM = "madauth_reset";
4528
- var MAX_NAME_LENGTH = 100;
5108
+ async function countSignInAttempt(c, users, account, now = Date.now()) {
5109
+ const tooMany = (message2) => c.json({ error: "too_many_attempts", message: message2 }, 429);
5110
+ if (account.lockedUntil > now) {
5111
+ const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
5112
+ return tooMany(`Too many failed attempts. Try again in ${seconds} seconds.`);
5113
+ }
5114
+ const failed = account.failedAttempts + 1;
5115
+ const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
5116
+ if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
5117
+ return tooMany("Too many attempts at once. Please try again.");
5118
+ }
5119
+ return null;
5120
+ }
5121
+
5122
+ // src/routes/password.ts
4529
5123
  function passwordRoutes(app, ctx, password, users, webhook) {
4530
5124
  const { minLength } = password;
4531
- const disabled = (c) => c.json({ error: "method_disabled", message: "E-mail & password sign-in is switched off." }, 403);
5125
+ const emails = createEmails(ctx, users, webhook);
4532
5126
  app.use("/auth/password/*", async (c, next) => {
4533
- if (!await ctx.enabled("password")) return disabled(c);
5127
+ if (!await ctx.enabled(c, "password")) return error(c, 403, "method_disabled", "E-mail & password sign-in is switched off.");
4534
5128
  await next();
4535
5129
  });
4536
- const body = async (c) => {
4537
- const data = await c.req.json().catch(() => null);
4538
- return data && typeof data === "object" ? data : {};
4539
- };
4540
- const error = (c, status, code, message2) => c.json({ error: code, message: message2 }, status);
4541
- const unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
4542
- const accepted = (c) => c.json({}, 202);
4543
- const redirectTarget = (c, value) => {
4544
- const url = ctx.allowedUrl(value);
4545
- return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
4546
- };
4547
- const sendEmail = async (user, type, data) => {
4548
- if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
4549
- const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
4550
- if (!result.ok) {
4551
- console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
4552
- return false;
4553
- }
4554
- await users.updateUser(user.id, { lastMailAt: Date.now() });
4555
- return true;
4556
- };
4557
- const sendLinkEmail = async (user, purpose, target, locale) => {
4558
- if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
4559
- const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
4560
- const { token, code } = await users.issueVerification(user.id, purpose, ttl);
4561
- const link = new URL(target);
4562
- link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
4563
- return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
4564
- link: link.href,
4565
- code,
4566
- expiresAt: Date.now() + ttl,
4567
- site: target.host,
4568
- locale
4569
- });
4570
- };
4571
- const consume = async (data, purpose) => {
4572
- if (typeof data.token === "string" && data.token) {
4573
- return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
4574
- }
4575
- if (isValidEmail(data.email) && typeof data.code === "string") {
4576
- const user = await users.findByEmail(normalizeEmail(data.email));
4577
- if (!user) return { userId: null, via: "code" };
4578
- return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
4579
- }
4580
- return { userId: null, via: "code" };
4581
- };
4582
- const invalidVerification = (c, { via, locked }) => {
4583
- if (locked) {
4584
- return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
4585
- }
4586
- 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.");
4587
- };
4588
5130
  app.post("/auth/password/signin", async (c) => {
4589
5131
  const data = await body(c);
4590
5132
  const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
@@ -4596,16 +5138,8 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4596
5138
  await verifyAgainstDummy(pw);
4597
5139
  return wrong();
4598
5140
  }
4599
- const now = Date.now();
4600
- if (account.lockedUntil > now) {
4601
- const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
4602
- return error(c, 429, "too_many_attempts", `Too many failed attempts. Try again in ${seconds} seconds.`);
4603
- }
4604
- const failed = account.failedAttempts + 1;
4605
- const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
4606
- if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
4607
- return error(c, 429, "too_many_attempts", "Too many attempts at once. Please try again.");
4608
- }
5141
+ const refused = await countSignInAttempt(c, users, account);
5142
+ if (refused) return refused;
4609
5143
  const { ok, needsRehash } = await verifyPassword(pw, account.secret);
4610
5144
  if (!ok) return wrong();
4611
5145
  await users.updateAccount(account.id, {
@@ -4616,28 +5150,24 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4616
5150
  if (!user.emailVerified) {
4617
5151
  return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
4618
5152
  }
4619
- const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
4620
- await ctx.emit("user.signed_in", { user: result, method: "password" });
4621
- return c.json({ user: result });
5153
+ return signInAnswer(c, await ctx.secondStep(c, user, ["pwd"], "password"));
4622
5154
  });
4623
5155
  app.post("/auth/password/signup", async (c) => {
4624
5156
  const data = await body(c);
4625
- const target = redirectTarget(c, data.redirectTo);
5157
+ const target = redirectTarget(ctx, c, data.redirectTo);
4626
5158
  if (target instanceof Response) return target;
4627
5159
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
4628
5160
  const policy = checkPasswordPolicy(data.password, minLength);
4629
5161
  if (policy) return error(c, 400, "weak_password", policy);
4630
- const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
5162
+ const name = nameOf(data.name);
4631
5163
  const email = data.email.trim();
4632
5164
  const locale = localeOf(data.locale);
4633
5165
  const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
4634
5166
  if (!check2.ok) return check2.reason === "rejected" ? error(c, 403, "signup_rejected", check2.message) : unavailable(c);
4635
5167
  const passwordHash = await hashPassword(data.password);
4636
5168
  const existing = await users.findByEmail(normalizeEmail(email));
4637
- if (existing?.emailVerified) {
4638
- if (!webhook.wants("email.already_registered")) return accepted(c);
4639
- const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
4640
- return sent ? accepted(c) : unavailable(c);
5169
+ if (existing?.emailVerified && await users.hasAccounts(existing.id)) {
5170
+ return await emails.sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
4641
5171
  }
4642
5172
  if (existing) {
4643
5173
  if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
@@ -4645,7 +5175,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4645
5175
  if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
4646
5176
  else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
4647
5177
  await users.updateUser(existing.id, { name });
4648
- return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
5178
+ return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
4649
5179
  }
4650
5180
  const user = await users.createUser({
4651
5181
  email,
@@ -4656,38 +5186,19 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4656
5186
  });
4657
5187
  if (!user) return accepted(c);
4658
5188
  await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
4659
- return await sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
4660
- });
4661
- app.post("/auth/password/send-verification", async (c) => {
4662
- const data = await body(c);
4663
- const target = redirectTarget(c, data.redirectTo);
4664
- if (target instanceof Response) return target;
4665
- if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
4666
- const user = await users.findByEmail(normalizeEmail(data.email));
4667
- if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
4668
- if (!await sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
4669
- }
4670
- return accepted(c);
4671
- });
4672
- app.post("/auth/password/verify-email", async (c) => {
4673
- const consumed = await consume(await body(c), "verify");
4674
- const { userId, via } = consumed;
4675
- const user = userId ? await users.findById(userId) : null;
4676
- if (!user) return invalidVerification(c, consumed);
4677
- await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
4678
- const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
4679
- await ctx.emit("email.verified", { user: result, via });
4680
- await ctx.emit("user.signed_in", { user: result, method: "password" });
4681
- return c.json({ user: result });
5189
+ return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
4682
5190
  });
4683
5191
  app.post("/auth/password/send-reset", async (c) => {
4684
5192
  const data = await body(c);
4685
- const target = redirectTarget(c, data.redirectTo);
5193
+ const target = redirectTarget(ctx, c, data.redirectTo);
4686
5194
  if (target instanceof Response) return target;
4687
5195
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
4688
5196
  const user = await users.findByEmail(normalizeEmail(data.email));
4689
- if (user && (user.emailVerified || await users.passwordAccount(user.id))) {
4690
- if (!await sendLinkEmail(user, "reset", target, localeOf(data.locale))) return unavailable(c);
5197
+ const locale = localeOf(data.locale);
5198
+ if (user && await users.passwordAccount(user.id)) {
5199
+ if (!await emails.sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
5200
+ } else if (user) {
5201
+ if (!await emails.sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
4691
5202
  }
4692
5203
  return accepted(c);
4693
5204
  });
@@ -4695,89 +5206,359 @@ function passwordRoutes(app, ctx, password, users, webhook) {
4695
5206
  const data = await body(c);
4696
5207
  const policy = checkPasswordPolicy(data.password, minLength);
4697
5208
  if (policy) return error(c, 400, "weak_password", policy);
4698
- const consumed = await consume(data, "reset");
5209
+ const consumed = await emails.consume(data, "reset");
4699
5210
  const user = consumed.userId ? await users.findById(consumed.userId) : null;
4700
- if (!user) return invalidVerification(c, consumed);
5211
+ const account = user ? await users.passwordAccount(user.id) : null;
5212
+ if (!user || !account) return emails.invalidVerification(c, consumed);
4701
5213
  const secret = await hashPassword(data.password);
4702
- const account = await users.passwordAccount(user.id);
4703
- if (account) await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 });
4704
- else await users.linkAccount(user.id, { key: passwordAccountKey(user.id), secret });
5214
+ if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return emails.invalidVerification(c, consumed);
4705
5215
  const sessionVersion = user.sessionVersion + 1;
4706
5216
  await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
4707
5217
  await users.clearVerifications(user.id);
4708
- const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
4709
- await ctx.emit("email.password_reset", { user: result });
4710
- await ctx.emit("user.signed_in", { user: result, method: "password" });
4711
- return c.json({ user: result });
5218
+ const reset = { ...user, emailVerified: true, sessionVersion, wrongCodes: 0 };
5219
+ await ctx.emit("email.password_reset", { user: toMadauthUser(reset) });
5220
+ return signInAnswer(c, await ctx.secondStep(c, reset, ["pwd"], "password"));
4712
5221
  });
4713
5222
  }
4714
5223
 
4715
- // src/settings.ts
4716
- var SIGN_IN_METHODS = ["google", "password"];
4717
- var METHODS_SETTING = "methods";
4718
- function parseMethodSettings(input) {
4719
- if (!input || typeof input !== "object" || Array.isArray(input)) return null;
4720
- const settings = {};
4721
- for (const [key, value] of Object.entries(input)) {
4722
- if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
4723
- settings[key] = value;
5224
+ // src/totp.ts
5225
+ import { createCipheriv, createDecipheriv, createHmac as createHmac3, hkdfSync as hkdfSync2, randomBytes as randomBytes4, randomInt as randomInt2 } from "crypto";
5226
+ var TOTP_STEP_SECONDS = 30;
5227
+ var TOTP_DIGITS = 6;
5228
+ var TOTP_WINDOW = 1;
5229
+ var RECOVERY_CODES = 10;
5230
+ var SECRET_BYTES = 20;
5231
+ var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
5232
+ var RECOVERY_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
5233
+ var RECOVERY_CODE_LENGTH = 10;
5234
+ var ENCRYPTION_VERSION = "v1";
5235
+ var IV_BYTES = 12;
5236
+ var TAG_BYTES = 16;
5237
+ function base32Encode(bytes) {
5238
+ let bits = 0;
5239
+ let value = 0;
5240
+ let out = "";
5241
+ for (const byte of bytes) {
5242
+ value = (value << 8 | byte) & 65535;
5243
+ bits += 8;
5244
+ while (bits >= 5) {
5245
+ out += BASE32[value >>> bits - 5 & 31];
5246
+ bits -= 5;
5247
+ }
4724
5248
  }
4725
- return settings;
5249
+ if (bits > 0) out += BASE32[value << 5 - bits & 31];
5250
+ return out;
4726
5251
  }
4727
- var MAX_TRIES = 3;
4728
- var Settings = class {
4729
- constructor(store) {
4730
- this.store = store;
5252
+ function hotp(secret, counter, digits = TOTP_DIGITS) {
5253
+ const message2 = Buffer.alloc(8);
5254
+ message2.writeBigUInt64BE(BigInt(counter));
5255
+ const digest2 = createHmac3("sha1", secret).update(message2).digest();
5256
+ const offset = digest2[digest2.length - 1] & 15;
5257
+ const binary = (digest2[offset] & 127) << 24 | digest2[offset + 1] << 16 | digest2[offset + 2] << 8 | digest2[offset + 3];
5258
+ return (binary % 10 ** digits).toString().padStart(digits, "0");
5259
+ }
5260
+ function stepOf(nowMs) {
5261
+ return Math.floor(nowMs / 1e3 / TOTP_STEP_SECONDS);
5262
+ }
5263
+ function totp(secret, step, digits = TOTP_DIGITS) {
5264
+ return hotp(secret, step, digits);
5265
+ }
5266
+ function verifyTotp(secret, code, nowMs, window = TOTP_WINDOW) {
5267
+ const given = code.replace(/\s/g, "");
5268
+ if (!new RegExp(`^\\d{${TOTP_DIGITS}}$`).test(given)) return null;
5269
+ const now = stepOf(nowMs);
5270
+ let found = null;
5271
+ for (let step = now - window; step <= now + window; step++) {
5272
+ if (safeEqual(totp(secret, step), given) && found === null) found = step;
5273
+ }
5274
+ return found;
5275
+ }
5276
+ function generateTotpSecret() {
5277
+ return randomBytes4(SECRET_BYTES);
5278
+ }
5279
+ function otpauthUri(options) {
5280
+ const issuer = encodeURIComponent(options.issuer);
5281
+ const account = encodeURIComponent(options.account);
5282
+ const secret = base32Encode(options.secret);
5283
+ return `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_STEP_SECONDS}`;
5284
+ }
5285
+ function deriveTotpKey(signingKeyD) {
5286
+ return Buffer.from(hkdfSync2("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth totp secret", 32));
5287
+ }
5288
+ function encryptSecret(key, secret) {
5289
+ const iv = randomBytes4(IV_BYTES);
5290
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
5291
+ const encrypted = Buffer.concat([cipher.update(secret), cipher.final()]);
5292
+ const parts = [iv, encrypted, cipher.getAuthTag()].map((part) => part.toString("base64url"));
5293
+ return [ENCRYPTION_VERSION, ...parts].join(".");
5294
+ }
5295
+ function decryptSecret(key, stored) {
5296
+ const [version, iv, encrypted, tag2, ...rest] = stored.split(".");
5297
+ if (version !== ENCRYPTION_VERSION || !iv || !encrypted || !tag2 || rest.length) return null;
5298
+ const ivBytes = Buffer.from(iv, "base64url");
5299
+ const tagBytes = Buffer.from(tag2, "base64url");
5300
+ if (ivBytes.length !== IV_BYTES || tagBytes.length !== TAG_BYTES) return null;
5301
+ try {
5302
+ const decipher = createDecipheriv("aes-256-gcm", key, ivBytes);
5303
+ decipher.setAuthTag(tagBytes);
5304
+ return Buffer.concat([decipher.update(Buffer.from(encrypted, "base64url")), decipher.final()]);
5305
+ } catch {
5306
+ return null;
4731
5307
  }
4732
- /** The setting's value, or undefined if it was never set. */
4733
- async get(id) {
4734
- return (await this.read(id)).value;
5308
+ }
5309
+ function generateRecoveryCodes() {
5310
+ const codes = /* @__PURE__ */ new Set();
5311
+ while (codes.size < RECOVERY_CODES) {
5312
+ let code = "";
5313
+ for (let i = 0; i < RECOVERY_CODE_LENGTH; i++) code += RECOVERY_ALPHABET[randomInt2(RECOVERY_ALPHABET.length)];
5314
+ codes.add(`${code.slice(0, 5)}-${code.slice(5)}`);
4735
5315
  }
4736
- /** The stored JSON and what it holds; both undefined if the setting was never set. */
4737
- async read(id) {
4738
- const record = await this.store.findOne("setting", { id });
4739
- if (typeof record?.value !== "string") return {};
4740
- try {
4741
- return { json: record.value, value: JSON.parse(record.value) };
4742
- } catch {
4743
- return { json: record.value };
4744
- }
5316
+ return [...codes];
5317
+ }
5318
+ function normalizeRecoveryCode(text) {
5319
+ if (typeof text !== "string") return null;
5320
+ const code = text.toLowerCase().replace(/[\s-]/g, "");
5321
+ return code.length === RECOVERY_CODE_LENGTH && [...code].every((char) => RECOVERY_ALPHABET.includes(char)) ? code : null;
5322
+ }
5323
+ function hashRecoveryCode(codeKey, code) {
5324
+ return hashCode(codeKey, `recovery:${code}`);
5325
+ }
5326
+
5327
+ // src/routes/totp.ts
5328
+ var CHALLENGE_COOKIE = "madauth_challenge";
5329
+ var CHALLENGE_TYP = "madauth-challenge+jwt";
5330
+ var CHALLENGE_TTL = 10 * 60;
5331
+ var SETUP_COOKIE = "madauth_totp_setup";
5332
+ var SETUP_TYP = "madauth-totp-setup+jwt";
5333
+ var SETUP_TTL = 10 * 60;
5334
+ var COOKIE_PATH = "/auth/totp";
5335
+ var cookieOptions = (secure) => ({ path: COOKIE_PATH, httpOnly: true, secure, sameSite: "Strict" });
5336
+ async function setChallenge(c, ctx, challenge) {
5337
+ const token = await signToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, { ...challenge }, CHALLENGE_TTL);
5338
+ setCookie(c, CHALLENGE_COOKIE, token, { ...cookieOptions(ctx.secure), maxAge: CHALLENGE_TTL });
5339
+ }
5340
+ function clearChallenge(c, secure) {
5341
+ deleteCookie(c, CHALLENGE_COOKIE, cookieOptions(secure));
5342
+ }
5343
+ async function readChallenge(c, ctx, next) {
5344
+ const claims = await readToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, getCookie(c, CHALLENGE_COOKIE));
5345
+ return claims && claims.next === next && typeof claims.sub === "string" && Array.isArray(claims.amr) ? claims : null;
5346
+ }
5347
+ async function checkCode(c, ctx, keys, user, account, data, wrong = () => error(c, 401, "code_invalid", "The code is wrong or was already used.")) {
5348
+ const { users } = ctx;
5349
+ const refused = await countSignInAttempt(c, users, account);
5350
+ if (refused) return refused;
5351
+ if (typeof data.recoveryCode === "string") {
5352
+ const code = normalizeRecoveryCode(data.recoveryCode);
5353
+ if (!code || !await users.consumeRecoveryCode(account, hashRecoveryCode(keys.codeKey, code))) return wrong();
5354
+ await users.updateAccount(account.id, { failedAttempts: 0, lockedUntil: 0 });
5355
+ const remaining = await users.countRecoveryCodes(account);
5356
+ await ctx.emit("totp.recovery_code_used", { user: toMadauthUser(user), remaining });
5357
+ return "recovery_code";
5358
+ }
5359
+ if (typeof data.code !== "string" || !account.secret) return wrong();
5360
+ const secret = decryptSecret(keys.totpKey, account.secret);
5361
+ if (!secret) {
5362
+ console.error(`[madauth] The authenticator secret of user ${user.id} can't be read: was MADAUTH_SIGNING_KEY changed?`);
5363
+ return wrong();
4745
5364
  }
4746
- /**
4747
- * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
4748
- * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
4749
- * again with the newer value. Resolves to the new value, or null if `apply` refused.
4750
- * `updatedBy` is the address of the admin, or null for the command line.
4751
- */
4752
- async change(id, apply, updatedBy) {
4753
- for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
4754
- const { json, value } = await this.read(id);
4755
- const next = apply(value);
4756
- if (next === null) return null;
4757
- const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
4758
- const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
4759
- if (written) return next;
5365
+ const step = verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW);
5366
+ if (step === null || step <= (account.lastUsedStep ?? 0) || !await users.useTotpStep(account, step)) return wrong();
5367
+ return "totp";
5368
+ }
5369
+ function totpRoutes(app, ctx, keys) {
5370
+ const { config, secure, users } = ctx;
5371
+ const { issuer } = config;
5372
+ const cookie = cookieOptions(secure);
5373
+ const methodsNow = (c) => ctx.methods(c);
5374
+ app.use(`${COOKIE_PATH}/*`, async (c, next) => {
5375
+ if (!authenticatorOn(ctx.configured, await methodsNow(c))) {
5376
+ return error(c, 403, "method_disabled", "The authenticator app is switched off.");
4760
5377
  }
4761
- throw new Error(`Setting "${id}" kept changing; try again.`);
4762
- }
4763
- /** Replaces the setting. */
4764
- async set(id, value, updatedBy) {
4765
- await this.change(id, () => value, updatedBy);
4766
- }
4767
- /** Which sign-in methods are on; a method that was never switched off is on. */
4768
- async methods() {
4769
- return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
4770
- }
4771
- /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
4772
- async changeMethods(apply, updatedBy) {
4773
- return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
5378
+ await next();
5379
+ });
5380
+ const standalone = async (c) => isOn("totp", ctx.configured, await methodsNow(c));
5381
+ const notStandalone = (c) => error(c, 403, "method_disabled", "Signing in with the authenticator app alone is switched off.");
5382
+ const methodStillOn = async (c, challenge) => isOn(challenge.method, ctx.configured, await methodsNow(c));
5383
+ const methodOff = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
5384
+ const signedIn = async (c) => {
5385
+ const user = await ctx.currentUser(c);
5386
+ return user ? users.findById(user.id) : null;
5387
+ };
5388
+ const whoSetsUp = async (c) => {
5389
+ const challenge = await readChallenge(c, ctx, "totp-setup");
5390
+ const waiting = challenge ? await users.findById(challenge.sub) : null;
5391
+ if (challenge && waiting && waiting.sessionVersion === challenge.sv) return { user: waiting, challenge };
5392
+ const user = await signedIn(c);
5393
+ return user ? { user, challenge: null } : null;
5394
+ };
5395
+ const newRecoveryCodes = () => {
5396
+ const recoveryCodes = generateRecoveryCodes();
5397
+ return { recoveryCodes, hashes: recoveryCodes.map((code) => hashRecoveryCode(keys.codeKey, normalizeRecoveryCode(code))) };
5398
+ };
5399
+ const issuerLabel = (c) => {
5400
+ if (config.totpIssuer) return config.totpIssuer;
5401
+ const origin = c.req.header("origin");
5402
+ return URL.canParse(origin ?? "") ? new URL(origin).hostname : new URL(issuer).hostname;
5403
+ };
5404
+ const finishSignIn = async (c, user, challenge, via) => {
5405
+ const result = await ctx.startSession(c, toMadauthUser(user, challenge.profile), [...challenge.amr, "otp"], { sv: user.sessionVersion });
5406
+ await ctx.emit("user.signed_in", { user: result, method: challenge.method, ...challenge.amr.length ? { secondFactor: via } : {} });
5407
+ return result;
5408
+ };
5409
+ app.post(`${COOKIE_PATH}/setup`, async (c) => {
5410
+ const who = await whoSetsUp(c);
5411
+ if (!who) return noSession(c);
5412
+ if (who.challenge && !await methodStillOn(c, who.challenge)) return methodOff(c);
5413
+ const existing = await users.totpAccount(who.user.id);
5414
+ let approved;
5415
+ if (existing) {
5416
+ const previous = await readToken(await ctx.keys, issuer, SETUP_TYP, getCookie(c, SETUP_COOKIE));
5417
+ const holds = previous?.sub === who.user.id && typeof previous.approved === "number" && Date.now() - previous.approved < SETUP_TTL * 1e3;
5418
+ if (holds) {
5419
+ approved = previous.approved;
5420
+ } else {
5421
+ const data = await body(c);
5422
+ if (data.code === void 0 && data.recoveryCode === void 0) {
5423
+ return error(c, 401, "code_required", "Enter a code from your current authenticator app (or a recovery code) to replace it.");
5424
+ }
5425
+ const via = await checkCode(c, ctx, keys, who.user, existing, data);
5426
+ if (via instanceof Response) return via;
5427
+ approved = Date.now();
5428
+ }
5429
+ }
5430
+ const secret = generateTotpSecret();
5431
+ const claims = {
5432
+ sub: who.user.id,
5433
+ setup: newId("tsu"),
5434
+ secret: encryptSecret(keys.totpKey, secret),
5435
+ ...existing ? { replaces: existing.setupId } : {},
5436
+ ...approved ? { approved } : {}
5437
+ };
5438
+ const token = await signToken(await ctx.keys, issuer, SETUP_TYP, claims, SETUP_TTL);
5439
+ setCookie(c, SETUP_COOKIE, token, { ...cookie, maxAge: SETUP_TTL });
5440
+ return c.json({ secret: base32Encode(secret), uri: otpauthUri({ issuer: issuerLabel(c), account: who.user.email, secret }) });
5441
+ });
5442
+ app.post(`${COOKIE_PATH}/confirm`, async (c) => {
5443
+ const who = await whoSetsUp(c);
5444
+ if (!who) return noSession(c);
5445
+ const { user, challenge } = who;
5446
+ if (challenge && !await methodStillOn(c, challenge)) return methodOff(c);
5447
+ const data = await body(c);
5448
+ const setup = await readToken(await ctx.keys, issuer, SETUP_TYP, getCookie(c, SETUP_COOKIE));
5449
+ const secret = setup && setup.sub === user.id && typeof setup.secret === "string" && typeof setup.setup === "string" ? decryptSecret(keys.totpKey, setup.secret) : null;
5450
+ if (!setup || !secret) return error(c, 400, "setup_expired", "The setup has expired or was started in another browser. Please start again.");
5451
+ const step = typeof data.code === "string" ? verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW) : null;
5452
+ if (step === null) return error(c, 400, "code_invalid", "The code is wrong. Enter the current code from the app.");
5453
+ const { recoveryCodes, hashes } = newRecoveryCodes();
5454
+ await users.addRecoveryCodes(user.id, setup.setup, hashes);
5455
+ const patch = { secret: setup.secret, setupId: setup.setup, failedAttempts: 0, lockedUntil: 0, lastUsedStep: step };
5456
+ const current = setup.replaces === void 0 ? null : await users.totpAccount(user.id);
5457
+ 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);
5458
+ if (!written) {
5459
+ await users.clearRecoveryCodes(user.id, { only: setup.setup });
5460
+ deleteCookie(c, SETUP_COOKIE, cookie);
5461
+ return error(c, 400, "setup_expired", "Your authenticator app was set up or removed elsewhere meanwhile. Please start again.");
5462
+ }
5463
+ await users.clearRecoveryCodes(user.id, { except: setup.setup });
5464
+ deleteCookie(c, SETUP_COOKIE, cookie);
5465
+ await ctx.emit("totp.enabled", { user: toMadauthUser(user) });
5466
+ if (!challenge) return c.json({ recoveryCodes });
5467
+ const result = await finishSignIn(c, user, challenge, "totp");
5468
+ return c.json({ user: result, recoveryCodes });
5469
+ });
5470
+ app.post(`${COOKIE_PATH}/verify`, async (c) => {
5471
+ const expired = () => error(c, 401, "challenge_expired", "The sign-in has expired. Please sign in again.");
5472
+ const challenge = await readChallenge(c, ctx, "totp");
5473
+ if (!challenge) return expired();
5474
+ if (!await methodStillOn(c, challenge)) return methodOff(c);
5475
+ const user = await users.findById(challenge.sub);
5476
+ const account = user && user.sessionVersion === challenge.sv ? await users.totpAccount(user.id) : null;
5477
+ if (!user || !account) return expired();
5478
+ const via = await checkCode(c, ctx, keys, user, account, await body(c));
5479
+ if (via instanceof Response) return via;
5480
+ return c.json({ user: await finishSignIn(c, user, challenge, via) });
5481
+ });
5482
+ app.post(`${COOKIE_PATH}/signin`, async (c) => {
5483
+ if (!await standalone(c)) return notStandalone(c);
5484
+ const data = await body(c);
5485
+ const wrong = () => error(c, 401, "invalid_credentials", "E-mail or code is wrong.");
5486
+ if (!isValidEmail(data.email)) return wrong();
5487
+ const user = await users.findByEmail(normalizeEmail(data.email));
5488
+ const account = user ? await users.totpAccount(user.id) : null;
5489
+ if (!user || !user.emailVerified || !account?.secret) return wrong();
5490
+ const via = await checkCode(c, ctx, keys, user, account, data, wrong);
5491
+ if (via instanceof Response) return via;
5492
+ const result = await ctx.startSession(c, toMadauthUser(user), ["otp"], { sv: user.sessionVersion });
5493
+ await ctx.emit("user.signed_in", { user: result, method: "totp" });
5494
+ return c.json({ user: result });
5495
+ });
5496
+ app.post(`${COOKIE_PATH}/remove`, async (c) => {
5497
+ const user = await signedIn(c);
5498
+ if (!user) return noSession(c);
5499
+ const methods = await methodsNow(c);
5500
+ const required = METHODS_WITH_SECOND_FACTOR.filter((method) => isOn(method, ctx.configured, methods) && policyOf(method, methods) === "required");
5501
+ if (required.length) {
5502
+ const names = required.map((method) => method === "google" ? "Google" : "e-mail & password");
5503
+ return error(c, 403, "required_by_policy", `Signing in with ${names.join(" and ")} requires the authenticator app, so it can't be removed.`);
5504
+ }
5505
+ const account = await users.totpAccount(user.id);
5506
+ if (!account) return c.json({});
5507
+ if (!(await users.signInMethods(user.id, methods)).some((method) => method !== "totp" && isOn(method, ctx.configured, methods))) {
5508
+ 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.");
5509
+ }
5510
+ const via = await checkCode(c, ctx, keys, user, account, await body(c));
5511
+ if (via instanceof Response) return via;
5512
+ await users.removeTotp(user.id);
5513
+ await ctx.emit("totp.disabled", { user: toMadauthUser(user) });
5514
+ return c.json({});
5515
+ });
5516
+ app.post(`${COOKIE_PATH}/recovery-codes`, async (c) => {
5517
+ const user = await signedIn(c);
5518
+ if (!user) return noSession(c);
5519
+ const account = await users.totpAccount(user.id);
5520
+ if (!account) return error(c, 400, "no_authenticator", "No authenticator app is set up.");
5521
+ const via = await checkCode(c, ctx, keys, user, account, await body(c));
5522
+ if (via instanceof Response) return via;
5523
+ const { recoveryCodes, hashes } = newRecoveryCodes();
5524
+ await users.replaceRecoveryCodes(account, hashes);
5525
+ return c.json({ recoveryCodes });
5526
+ });
5527
+ app.get(`${COOKIE_PATH}/status`, async (c) => {
5528
+ const user = await signedIn(c);
5529
+ if (!user) return noSession(c);
5530
+ const account = await users.totpAccount(user.id);
5531
+ return c.json({ enabled: !!account, recoveryCodesLeft: account ? await users.countRecoveryCodes(account) : 0 });
5532
+ });
5533
+ if (ctx.webhook && ctx.emailVerification) {
5534
+ const emails = createEmails(ctx, users, ctx.webhook);
5535
+ app.post(`${COOKIE_PATH}/signup`, async (c) => {
5536
+ if (!await standalone(c)) return notStandalone(c);
5537
+ const data = await body(c);
5538
+ const target = redirectTarget(ctx, c, data.redirectTo);
5539
+ if (target instanceof Response) return target;
5540
+ if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
5541
+ const name = nameOf(data.name);
5542
+ const email = data.email.trim();
5543
+ const locale = localeOf(data.locale);
5544
+ const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "totp" });
5545
+ if (!check2.ok) return check2.reason === "rejected" ? error(c, 403, "signup_rejected", check2.message) : unavailable(c);
5546
+ const existing = await users.findByEmail(normalizeEmail(email));
5547
+ if (existing?.emailVerified && await users.hasAccounts(existing.id)) {
5548
+ return await emails.sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
5549
+ }
5550
+ if (existing) {
5551
+ if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
5552
+ if (!existing.emailVerified) await users.unlinkAccount(passwordAccountKey(existing.id));
5553
+ await users.updateUser(existing.id, { name });
5554
+ return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
5555
+ }
5556
+ const user = await users.createUser({ email, emailNormalized: normalizeEmail(email), name, emailVerified: false });
5557
+ if (!user) return accepted(c);
5558
+ await ctx.emit("user.created", { user: toMadauthUser(user), method: "totp" });
5559
+ return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
5560
+ });
4774
5561
  }
4775
- };
4776
- function isOn(method, available, methods) {
4777
- return available(method) && methods[method] !== false;
4778
- }
4779
- function anyOn(available, methods) {
4780
- return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
4781
5562
  }
4782
5563
 
4783
5564
  // src/app.ts
@@ -4787,9 +5568,14 @@ function createApp(config) {
4787
5568
  const keys = importSigningKeys(config.signingKey);
4788
5569
  const secure = issuer.startsWith("https:");
4789
5570
  const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
4790
- const users = new Users(config.store, deriveCodeKey(config.signingKey.d));
4791
- const settings = new Settings(config.store);
4792
- const available = (method) => method === "google" ? !!config.google : !!config.password;
5571
+ const codeKey = deriveCodeKey(config.signingKey.d);
5572
+ const users = new Users(config.store, codeKey);
5573
+ const methods = methodConfig(config);
5574
+ const settings = new Settings(config.store, initialMethods(methods));
5575
+ const configured = (method) => isConfigured(methods, method);
5576
+ const signsUp = (method) => canSignUp(methods, method);
5577
+ const emailVerification = sendsVerificationEmails(config);
5578
+ const totpKeys = { totpKey: deriveTotpKey(config.signingKey.d), codeKey };
4793
5579
  const issueSession = async (c, user, amr, extra) => {
4794
5580
  const signingKeys = await keys;
4795
5581
  const [sessionToken, renewalToken] = await Promise.all([
@@ -4824,6 +5610,29 @@ function createApp(config) {
4824
5610
  deleteCookie(c, RENEWAL_COOKIE, { path: "/auth", secure });
4825
5611
  deleteCookie(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
4826
5612
  };
5613
+ const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c, cookie));
5614
+ const checked = async (claims) => {
5615
+ const stored = await users.findById(claims.sub);
5616
+ if (!stored || stored.sessionVersion !== claims.sv) return null;
5617
+ const { claims: _, ...user } = userFromClaims(claims);
5618
+ const current = claimsFromJson(stored.claims);
5619
+ return Object.keys(current).length ? { ...user, claims: current } : user;
5620
+ };
5621
+ const currentSession = async (c) => {
5622
+ const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
5623
+ const user = session ? await checked(session) : null;
5624
+ const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
5625
+ if (upToDate) return user;
5626
+ const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
5627
+ const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
5628
+ const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
5629
+ if (renewal && renewed) {
5630
+ await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
5631
+ return renewed;
5632
+ }
5633
+ return user;
5634
+ };
5635
+ const methodsOf = /* @__PURE__ */ new WeakMap();
4827
5636
  const ctx = {
4828
5637
  config,
4829
5638
  keys,
@@ -4841,14 +5650,42 @@ function createApp(config) {
4841
5650
  return url;
4842
5651
  },
4843
5652
  async startSession(c, user, amr, extra) {
5653
+ clearChallenge(c, secure);
4844
5654
  await issueSession(c, user, amr, extra);
4845
- return user;
5655
+ return { ...user, amr };
5656
+ },
5657
+ async secondStep(c, user, amr, method, profile) {
5658
+ const methods2 = await this.methods(c);
5659
+ if (!isOn(method, configured, methods2)) return { disabled: true };
5660
+ const enrolled = await users.totpAccount(user.id);
5661
+ const policy = method === "totp" ? "required" : policyOf(method, methods2);
5662
+ const next = policy === "none" ? null : enrolled ? "totp" : policy === "required" ? "totp-setup" : null;
5663
+ if (next) {
5664
+ clearSession(c);
5665
+ const known = { ...profile?.name ? { name: profile.name } : {}, ...profile?.picture ? { picture: profile.picture } : {} };
5666
+ await setChallenge(c, this, { sub: user.id, sv: user.sessionVersion, amr, method, next, ...Object.keys(known).length ? { profile: known } : {} });
5667
+ return { next, method };
5668
+ }
5669
+ const result = await this.startSession(c, toMadauthUser(user, profile), amr, { sv: user.sessionVersion });
5670
+ await this.emit("user.signed_in", { user: result, method });
5671
+ return { user: result };
4846
5672
  },
5673
+ currentUser: currentSession,
4847
5674
  users,
4848
5675
  settings,
4849
5676
  webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
4850
- async enabled(method) {
4851
- return isOn(method, available, await settings.methods());
5677
+ configured,
5678
+ emailVerification,
5679
+ methods(c) {
5680
+ let reading = methodsOf.get(c.req.raw);
5681
+ if (!reading) {
5682
+ reading = settings.methods();
5683
+ methodsOf.set(c.req.raw, reading);
5684
+ }
5685
+ return reading;
5686
+ },
5687
+ async enabled(c, method) {
5688
+ return isOn(method, configured, await this.methods(c));
4852
5689
  },
4853
5690
  async checkSignup(data) {
4854
5691
  if (!this.webhook?.wants("signup.before")) return { ok: true };
@@ -4866,7 +5703,9 @@ function createApp(config) {
4866
5703
  },
4867
5704
  async emit(type, data) {
4868
5705
  if (!this.webhook?.wants(type)) return;
4869
- const result = await this.webhook.call(type, data, EVENT_TIMEOUT_MS);
5706
+ const user = data.user;
5707
+ const payload = user && typeof user === "object" && "amr" in user ? { ...data, user: { ...user, amr: void 0 } } : data;
5708
+ const result = await this.webhook.call(type, payload, EVENT_TIMEOUT_MS);
4870
5709
  if (!result.ok) console.error(`[madauth] Webhook "${type}" failed: ${result.reason}`);
4871
5710
  }
4872
5711
  };
@@ -4894,37 +5733,20 @@ function createApp(config) {
4894
5733
  return c.json({ keys: [publicJwk] });
4895
5734
  });
4896
5735
  app.get("/auth/config", async (c) => {
4897
- const methods = await settings.methods();
4898
- const on = (method) => isOn(method, available, methods);
5736
+ const current = await ctx.methods(c);
5737
+ const on = (method) => isOn(method, configured, current);
4899
5738
  return c.json({
4900
- google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
4901
- password: on("password") ? { minLength: config.password.minLength } : null
5739
+ google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret, secondFactor: policyOf("google", current) } : null,
5740
+ password: on("password") ? { minLength: config.password.minLength, secondFactor: policyOf("password", current) } : null,
5741
+ // The app can be set up when it is in use at all; `signIn` says whether it signs in on its own.
5742
+ totp: authenticatorOn(configured, current) ? { signIn: on("totp"), signUp: on("totp") && emailVerification } : null,
5743
+ email: { verification: emailVerification }
4902
5744
  });
4903
5745
  });
5746
+ if (emailVerification && ctx.webhook) emailRoutes(app, ctx, users, ctx.webhook);
4904
5747
  if (config.google) googleRoutes(app, ctx, config.google);
4905
5748
  if (config.password && ctx.webhook) passwordRoutes(app, ctx, config.password, users, ctx.webhook);
4906
- const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c, cookie));
4907
- const checked = async (claims) => {
4908
- const stored = await users.findById(claims.sub);
4909
- if (!stored || stored.sessionVersion !== claims.sv) return null;
4910
- const { claims: _, ...user } = userFromClaims(claims);
4911
- const current = claimsFromJson(stored.claims);
4912
- return Object.keys(current).length ? { ...user, claims: current } : user;
4913
- };
4914
- const currentSession = async (c) => {
4915
- const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
4916
- const user = session ? await checked(session) : null;
4917
- const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
4918
- if (upToDate) return user;
4919
- const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
4920
- const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
4921
- const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
4922
- if (renewal && renewed) {
4923
- await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
4924
- return renewed;
4925
- }
4926
- return user;
4927
- };
5749
+ totpRoutes(app, ctx, totpKeys);
4928
5750
  app.get("/auth/session", async (c) => {
4929
5751
  const user = await currentSession(c);
4930
5752
  if (!user) {
@@ -4935,11 +5757,23 @@ function createApp(config) {
4935
5757
  });
4936
5758
  app.post("/auth/logout", (c) => {
4937
5759
  clearSession(c);
5760
+ clearChallenge(c, secure);
4938
5761
  return c.body(null, 204);
4939
5762
  });
4940
5763
  app.post("/auth/account/delete", async (c) => {
4941
5764
  const user = await currentSession(c);
4942
5765
  if (!user) return c.json({ error: "no_session" }, 401);
5766
+ const stored = await users.findById(user.id);
5767
+ const protects = stored && authenticatorOn(configured, await ctx.methods(c));
5768
+ const authenticator = protects ? await users.totpAccount(user.id) : null;
5769
+ if (stored && authenticator) {
5770
+ const data = await body(c);
5771
+ if (data.code === void 0 && data.recoveryCode === void 0) {
5772
+ return error(c, 401, "code_required", "Enter a code from your authenticator app (or a recovery code) to delete your account.");
5773
+ }
5774
+ const via = await checkCode(c, ctx, totpKeys, stored, authenticator, data);
5775
+ if (via instanceof Response) return via;
5776
+ }
4943
5777
  await users.deleteById(user.id);
4944
5778
  clearSession(c);
4945
5779
  await ctx.emit("user.deleted", { user });
@@ -4955,10 +5789,6 @@ function createApp(config) {
4955
5789
  }
4956
5790
  return { email };
4957
5791
  };
4958
- const body = async (c) => {
4959
- const data = await c.req.json().catch(() => null);
4960
- return data && typeof data === "object" ? data : {};
4961
- };
4962
5792
  const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
4963
5793
  const noUser = (c) => c.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
4964
5794
  app.post("/auth/admin/claims/get", async (c) => {
@@ -4992,9 +5822,16 @@ function createApp(config) {
4992
5822
  await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
4993
5823
  return c.json({ email, userId: user.id, claims });
4994
5824
  });
4995
- const settingsAnswer = (methods) => ({
5825
+ const settingsAnswer = (methods2) => ({
4996
5826
  methods: Object.fromEntries(
4997
- SIGN_IN_METHODS.map((method) => [method, { available: available(method), enabled: isOn(method, available, methods) }])
5827
+ SIGN_IN_METHODS.map((method) => [
5828
+ method,
5829
+ {
5830
+ configured: configured(method),
5831
+ enabled: isOn(method, configured, methods2),
5832
+ ...method === "totp" ? {} : { secondFactor: policyOf(method, methods2) }
5833
+ }
5834
+ ])
4998
5835
  )
4999
5836
  });
5000
5837
  app.post("/auth/admin/settings/get", async (c) => {
@@ -5007,212 +5844,30 @@ function createApp(config) {
5007
5844
  if ("answer" in caller) return caller.answer;
5008
5845
  const data = await body(c);
5009
5846
  const invalid2 = (message2) => c.json({ error: "invalid_settings", message: message2 }, 400);
5010
- const changes = parseMethodSettings(data.methods);
5011
- if (!changes) return invalid2(`methods must be an object with true or false for ${SIGN_IN_METHODS.join(" and/or ")}.`);
5012
- const methods = await settings.changeMethods((current) => {
5013
- const next = { ...current, ...changes };
5014
- return anyOn(available, next) ? next : null;
5015
- }, caller.email);
5016
- if (!methods) return invalid2("At least one sign-in method must stay on.");
5017
- return c.json(settingsAnswer(methods));
5847
+ const next = parseMethodSettings(data.methods, { booleans: true });
5848
+ if (!next) {
5849
+ return invalid2(
5850
+ `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.`
5851
+ );
5852
+ }
5853
+ const methods2 = await settings.changeMethods(() => anyOn(signsUp, next) ? next : null, caller.email);
5854
+ if (!methods2) return invalid2("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.");
5855
+ return c.json(settingsAnswer(methods2));
5856
+ });
5857
+ app.post("/auth/admin/totp/remove", async (c) => {
5858
+ const caller = await admin(c);
5859
+ if ("answer" in caller) return caller.answer;
5860
+ const data = await body(c);
5861
+ if (!isValidEmail(data.email)) return invalidEmail(c);
5862
+ const user = await users.findByEmail(normalizeEmail(data.email));
5863
+ if (!user) return noUser(c);
5864
+ const removed = await users.removeTotp(user.id);
5865
+ if (removed) await ctx.emit("totp.disabled", { user: toMadauthUser(user), by: caller.email });
5866
+ return c.json({ email: user.emailNormalized, userId: user.id, enabled: false });
5018
5867
  });
5019
5868
  return app;
5020
5869
  }
5021
5870
 
5022
- // src/config.ts
5023
- async function resolveEnv(source = process.env) {
5024
- return typeof source === "function" ? source() : source;
5025
- }
5026
- var DEFAULT_SESSION_TTL = 8 * 60 * 60;
5027
- var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
5028
- var DEFAULT_PASSWORD_MIN_LENGTH = 8;
5029
- var ConfigError = class extends Error {
5030
- constructor() {
5031
- super(...arguments);
5032
- this.name = "ConfigError";
5033
- }
5034
- };
5035
- function parseOrigin(name, value) {
5036
- let url;
5037
- try {
5038
- url = new URL(value);
5039
- } catch {
5040
- throw new ConfigError(`${name}: "${value}" is not a URL.`);
5041
- }
5042
- if (url.protocol !== "https:" && url.protocol !== "http:") {
5043
- throw new ConfigError(`${name}: "${value}" must be an http(s) URL.`);
5044
- }
5045
- return url.origin;
5046
- }
5047
- function readEnv(env, name) {
5048
- return env[name]?.trim().replace(/^(['"])(.*)\1$/s, "$2").trim() || void 0;
5049
- }
5050
- async function loadConfig(env, overrides = {}) {
5051
- const read = (name) => readEnv(env, name);
5052
- const require2 = (name) => {
5053
- const value = read(name);
5054
- if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
5055
- return value;
5056
- };
5057
- const issuerValue = require2("MADAUTH_ISSUER");
5058
- parseOrigin("MADAUTH_ISSUER", issuerValue);
5059
- const issuer = issuerValue.replace(/\/+$/, "");
5060
- const keyValue = read("MADAUTH_SIGNING_KEY");
5061
- if (!keyValue) {
5062
- const key = await generateSigningKey();
5063
- throw new ConfigError(
5064
- `MADAUTH_SIGNING_KEY is not set. Here is a newly generated key you can use (keep it secret and use the same key on every instance):
5065
-
5066
- MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
5067
- `
5068
- );
5069
- }
5070
- let signingKey;
5071
- try {
5072
- signingKey = JSON.parse(keyValue);
5073
- assertPrivateSigningJwk(signingKey);
5074
- } catch (e) {
5075
- const reason = e instanceof SyntaxError ? "is not valid JSON" : e.message;
5076
- throw new ConfigError(`MADAUTH_SIGNING_KEY ${reason}. Generate one with: npx @madauth/server generate-key`);
5077
- }
5078
- const allowedOrigins = require2("ALLOWED_ORIGINS").split(",").map((o) => o.trim()).filter(Boolean).map((o) => parseOrigin("ALLOWED_ORIGINS", o));
5079
- const clientId = read("GOOGLE_CLIENT_ID");
5080
- if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
5081
- throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
5082
- }
5083
- const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
5084
- const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
5085
- const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
5086
- if (!clientId && !password) {
5087
- throw new ConfigError(
5088
- `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered. See docs/server.md.`
5089
- );
5090
- }
5091
- const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
5092
- if (!store) {
5093
- throw new ConfigError(
5094
- "DATABASE_URL is not set. madAuth stores every user, whichever way they sign in: set e.g. DATABASE_URL=sqlite:./madauth.db or DATABASE_URL=dynamodb:<table>. See docs/server.md."
5095
- );
5096
- }
5097
- const ttlValue = read("SESSION_TTL");
5098
- const sessionTtlSeconds = ttlValue ? Number(ttlValue) : DEFAULT_SESSION_TTL;
5099
- if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
5100
- throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
5101
- }
5102
- const renewalValue = read("SESSION_RENEWAL_TTL");
5103
- const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
5104
- if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
5105
- throw new ConfigError(
5106
- `SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
5107
- );
5108
- }
5109
- return {
5110
- issuer,
5111
- signingKey,
5112
- allowedOrigins,
5113
- sessionTtlSeconds,
5114
- renewalTtlSeconds,
5115
- cookieDomain: read("COOKIE_DOMAIN"),
5116
- google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
5117
- store,
5118
- password,
5119
- webhook
5120
- };
5121
- }
5122
- function passwordMinLength(value) {
5123
- const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
5124
- if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
5125
- throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
5126
- }
5127
- return minLength;
5128
- }
5129
- async function storeFromDatabaseUrl(url) {
5130
- if (!url) return void 0;
5131
- if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
5132
- if (!url.startsWith("sqlite:")) {
5133
- throw new ConfigError(
5134
- `DATABASE_URL must start with "sqlite:" (e.g. sqlite:/data/madauth.db) or "dynamodb:" (e.g. dynamodb:madauth) but is "${url}". For other databases, pass your own store adapter (see "Custom store adapter" in docs/server.md).`
5135
- );
5136
- }
5137
- const path = url.slice("sqlite:".length);
5138
- if (!path) throw new ConfigError("DATABASE_URL needs a file path, e.g. sqlite:/data/madauth.db");
5139
- const { createSqliteAdapter: createSqliteAdapter2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
5140
- try {
5141
- return createSqliteAdapter2(path);
5142
- } catch (e) {
5143
- throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
5144
- }
5145
- }
5146
- async function dynamoDbStore(tableName2) {
5147
- if (!tableName2) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
5148
- if (!/^[\w.-]{3,255}$/.test(tableName2)) {
5149
- throw new ConfigError(
5150
- `DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName2}"`
5151
- );
5152
- }
5153
- try {
5154
- await import("@aws-sdk/client-dynamodb");
5155
- } catch (e) {
5156
- throw new ConfigError(
5157
- `DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
5158
- );
5159
- }
5160
- const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
5161
- return createDynamoDbAdapter2({ tableName: tableName2 });
5162
- }
5163
- var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
5164
- var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
5165
- function webhookFromEnv(url, secret, events) {
5166
- if (!url) {
5167
- if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
5168
- return void 0;
5169
- }
5170
- let parsed;
5171
- try {
5172
- parsed = new URL(url);
5173
- } catch {
5174
- throw new ConfigError(`WEBHOOK_URL: "${url}" is not a URL.`);
5175
- }
5176
- if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname))) {
5177
- throw new ConfigError(`WEBHOOK_URL must be an https URL (http only for localhost) but is "${url}".`);
5178
- }
5179
- if (!secret) {
5180
- throw new ConfigError(
5181
- `WEBHOOK_SECRET is not set. Here is a newly generated secret you can use (keep it secret; your webhook receiver needs the same one):
5182
-
5183
- WEBHOOK_SECRET=${generateWebhookSecret()}
5184
- `
5185
- );
5186
- }
5187
- const problem = checkWebhookSecret(secret);
5188
- if (problem) {
5189
- throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
5190
- }
5191
- const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
5192
- const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
5193
- if (!selected.size) {
5194
- throw new ConfigError(
5195
- `WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. E-mail & password sign-in is on when they include ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
5196
-
5197
- WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
5198
-
5199
- For one that only wants to know who signed in:
5200
-
5201
- WEBHOOK_EVENTS=user.signed_in
5202
-
5203
- ` + allTypes
5204
- );
5205
- }
5206
- const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
5207
- if (unknown.length) {
5208
- const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
5209
- throw new ConfigError(
5210
- `WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
5211
- );
5212
- }
5213
- return { url: parsed.href, secret, events: selected };
5214
- }
5215
-
5216
5871
  // src/entry/lambda.ts
5217
5872
  function createHandler(options = {}) {
5218
5873
  const { env, ...overrides } = options;