@madauth/server 0.3.0-beta.2 → 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.
@@ -1457,9 +1457,9 @@ var require_azure_functions = __commonJS({
1457
1457
  let normalizedProperties;
1458
1458
  try {
1459
1459
  normalizedProperties = (0, toolProperties_1.normalizeToolProperties)(mcpToolTriggerOptions.toolProperties);
1460
- } catch (error) {
1461
- if (error instanceof Error && (error.message.includes("Property type is required") || error.message.includes("Property type must be specified"))) {
1462
- throw error;
1460
+ } catch (error2) {
1461
+ if (error2 instanceof Error && (error2.message.includes("Property type is required") || error2.message.includes("Property type must be specified"))) {
1462
+ throw error2;
1463
1463
  }
1464
1464
  normalizedProperties = void 0;
1465
1465
  }
@@ -2158,8 +2158,8 @@ var require_azure_functions = __commonJS({
2158
2158
  return handler(new LogHookContext_1.LogHookContext(coreContext));
2159
2159
  });
2160
2160
  } catch (err) {
2161
- const error = (0, errors_1.ensureErrorType)(err);
2162
- if (error.name === "RangeError" && error.isAzureFunctionsSystemError) {
2161
+ const error2 = (0, errors_1.ensureErrorType)(err);
2162
+ if (error2.name === "RangeError" && error2.isAzureFunctionsSystemError) {
2163
2163
  throw new errors_1.AzFuncSystemError(`Log hooks require Azure Functions Host v4.34 or higher.`);
2164
2164
  } else {
2165
2165
  throw err;
@@ -2261,19 +2261,19 @@ var require_azure_functions = __commonJS({
2261
2261
  if (!nativeReq) {
2262
2262
  const url = (0, nonNull_1.nonNullProp)(init, "url");
2263
2263
  const method = (0, nonNull_1.nonNullProp)(init, "method");
2264
- let body;
2264
+ let body2;
2265
2265
  const lowerMethod = method.toLowerCase();
2266
2266
  if (lowerMethod !== "get" && lowerMethod !== "head") {
2267
2267
  if ((_a = init.body) === null || _a === void 0 ? void 0 : _a.bytes) {
2268
- body = Buffer.from((_b = init.body) === null || _b === void 0 ? void 0 : _b.bytes);
2268
+ body2 = Buffer.from((_b = init.body) === null || _b === void 0 ? void 0 : _b.bytes);
2269
2269
  } else if ((_c = init.body) === null || _c === void 0 ? void 0 : _c.string) {
2270
- body = init.body.string;
2270
+ body2 = init.body.string;
2271
2271
  }
2272
2272
  } else if (((_e = (_d = init.body) === null || _d === void 0 ? void 0 : _d.bytes) === null || _e === void 0 ? void 0 : _e.length) || ((_f = init.body) === null || _f === void 0 ? void 0 : _f.string)) {
2273
2273
  (0, workerSystemLog_1.workerSystemLog)("warning", `Discarding the body of the incoming ${method} request because GET and HEAD requests cannot have a body.`);
2274
2274
  }
2275
2275
  nativeReq = new Request(url, {
2276
- body,
2276
+ body: body2,
2277
2277
  method,
2278
2278
  headers: (0, fromRpcNullable_1.fromNullableMapping)(init.nullableHeaders, init.headers)
2279
2279
  });
@@ -2352,10 +2352,10 @@ var require_azure_functions = __commonJS({
2352
2352
  throw new errors_1.AzFuncSystemError(`Expected headers "${hostHeaderName}" and "${protoHeaderName}" to be set.`);
2353
2353
  }
2354
2354
  const url = `${proto}://${host}${(0, nonNull_1.nonNullProp)(proxyReq, "url")}`;
2355
- let body;
2355
+ let body2;
2356
2356
  const lowerMethod = (_a = proxyReq.method) === null || _a === void 0 ? void 0 : _a.toLowerCase();
2357
2357
  if (lowerMethod !== "get" && lowerMethod !== "head") {
2358
- body = proxyReq;
2358
+ body2 = proxyReq;
2359
2359
  }
2360
2360
  const { Query: rpcQueryIgnored, Headers: rpcHeaders } = triggerMetadata, rpcParams = __rest(triggerMetadata, ["Query", "Headers"]);
2361
2361
  let headers;
@@ -2365,7 +2365,7 @@ var require_azure_functions = __commonJS({
2365
2365
  }
2366
2366
  const nativeReq = new Request(url, {
2367
2367
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
2368
- body,
2368
+ body: body2,
2369
2369
  duplex: "half",
2370
2370
  method: (0, nonNull_1.nonNullProp)(proxyReq, "method"),
2371
2371
  headers
@@ -4107,7 +4107,7 @@ var init_schema = __esm({
4107
4107
  "src/store/schema.ts"() {
4108
4108
  "use strict";
4109
4109
  madauthSchema = {
4110
- version: 4,
4110
+ version: 5,
4111
4111
  models: {
4112
4112
  /** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
4113
4113
  user: {
@@ -4126,7 +4126,10 @@ var init_schema = __esm({
4126
4126
  createdAt: { type: "number" }
4127
4127
  }
4128
4128
  },
4129
- /** How a user signs in. `key` is `password:<userId>` or `google:<sub>`; later e.g. `totp:<userId>`. */
4129
+ /**
4130
+ * How a user signs in. `key` is `password:<userId>`, `google:<sub>` or `totp:<userId>` (the authenticator
4131
+ * app, whose `secret` is encrypted).
4132
+ */
4130
4133
  account: {
4131
4134
  fields: {
4132
4135
  id: { type: "string", primaryKey: true },
@@ -4137,6 +4140,25 @@ var init_schema = __esm({
4137
4140
  lockedUntil: { type: "number" },
4138
4141
  /** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
4139
4142
  email: { type: "string", nullable: true },
4143
+ /** The time step of the last authenticator code that was accepted, so a code works once; 0 before (since version 5). */
4144
+ lastUsedStep: { type: "number" },
4145
+ /**
4146
+ * The setup an authenticator secret came from; its recovery codes carry the same id, so the app and
4147
+ * its codes always belong together. Empty for other accounts (since version 5).
4148
+ */
4149
+ setupId: { type: "string", nullable: true },
4150
+ createdAt: { type: "number" }
4151
+ }
4152
+ },
4153
+ /**
4154
+ * A recovery code of a user's authenticator app (since version 5). `id` is the code's keyed hash; the
4155
+ * record goes when the code is used. It works only while the account's `setupId` is its own.
4156
+ */
4157
+ recoveryCode: {
4158
+ fields: {
4159
+ id: { type: "string", primaryKey: true },
4160
+ userId: { type: "string", index: true },
4161
+ setupId: { type: "string" },
4140
4162
  createdAt: { type: "number" }
4141
4163
  }
4142
4164
  },
@@ -4168,7 +4190,7 @@ var init_schema = __esm({
4168
4190
 
4169
4191
  // src/store/sql.ts
4170
4192
  function tableName(model) {
4171
- return `madauth_${model}`;
4193
+ return `madauth_${columnName(model)}`;
4172
4194
  }
4173
4195
  function columnName(field) {
4174
4196
  return field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
@@ -4236,6 +4258,15 @@ var init_sql = __esm({
4236
4258
  `DROP TABLE ${tableName("role")};`,
4237
4259
  createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
4238
4260
  ].join("\n")
4261
+ },
4262
+ {
4263
+ // The authenticator app: which code was used last, and the recovery codes.
4264
+ version: 5,
4265
+ sql: (dialect) => [
4266
+ `ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("lastUsedStep")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`,
4267
+ `ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("setupId")} ${columnTypes[dialect].string};`,
4268
+ createTablesSql(dialect, { version: 5, models: { recoveryCode: madauthSchema.models.recoveryCode } })
4269
+ ].join("\n")
4239
4270
  }
4240
4271
  ];
4241
4272
  }
@@ -4388,16 +4419,16 @@ function stillMatches(where) {
4388
4419
  });
4389
4420
  return { text: parts.join(" AND "), names, values };
4390
4421
  }
4391
- function failedWrite(error) {
4392
- const { name, CancellationReasons } = error;
4422
+ function failedWrite(error2) {
4423
+ const { name, CancellationReasons } = error2;
4393
4424
  if (name === "ConditionalCheckFailedException") return "refused";
4394
- if (name && RETRYABLE_ERRORS.includes(name)) return { retry: error };
4425
+ if (name && RETRYABLE_ERRORS.includes(name)) return { retry: error2 };
4395
4426
  if (name === "TransactionCanceledException") {
4396
4427
  const reasons = (CancellationReasons ?? []).map((r) => r.Code ?? "None").filter((code) => code !== "None");
4397
4428
  if (reasons.includes("ConditionalCheckFailed")) return "refused";
4398
- if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry: error };
4429
+ if (reasons.length && reasons.every((code) => RETRYABLE_REASONS.includes(code))) return { retry: error2 };
4399
4430
  }
4400
- throw error;
4431
+ throw error2;
4401
4432
  }
4402
4433
  function createDynamoDbAdapter(options) {
4403
4434
  const { tableName: tableName2 } = options;
@@ -4918,10 +4949,10 @@ var HonoRequest = class {
4918
4949
  const { bodyCache, raw: raw2 } = this;
4919
4950
  const cachedBody = bodyCache[key];
4920
4951
  if (cachedBody) return cachedBody;
4921
- for (const anyCachedKey in bodyCache) return bodyCache[anyCachedKey].then((body) => {
4922
- if (anyCachedKey === "json") body = JSON.stringify(body);
4952
+ for (const anyCachedKey in bodyCache) return bodyCache[anyCachedKey].then((body2) => {
4953
+ if (anyCachedKey === "json") body2 = JSON.stringify(body2);
4923
4954
  const contentType = anyCachedKey === "formData" ? void 0 : raw2.headers.get("content-type");
4924
- return new Response(body, { headers: contentType ? { "Content-Type": contentType } : void 0 })[key]();
4955
+ return new Response(body2, { headers: contentType ? { "Content-Type": contentType } : void 0 })[key]();
4925
4956
  });
4926
4957
  return bodyCache[key] = raw2[key]();
4927
4958
  };
@@ -5146,7 +5177,7 @@ var setDefaultContentType = (contentType, headers) => {
5146
5177
  ...headers
5147
5178
  };
5148
5179
  };
5149
- var createResponseInstance = (body, init) => new Response(body, init);
5180
+ var createResponseInstance = (body2, init) => new Response(body2, init);
5150
5181
  var Context = class {
5151
5182
  #rawRequest;
5152
5183
  #req;
@@ -7522,8 +7553,8 @@ async function importWithAlgCache(cache2, jwk, entry) {
7522
7553
  if (key.type !== "public")
7523
7554
  throw new JWKSInvalid("JSON Web Key Set members must be public keys");
7524
7555
  return cached[alg] = key, key;
7525
- }).catch((error) => {
7526
- throw cached[alg] === pending && delete cached[alg], error;
7556
+ }).catch((error2) => {
7557
+ throw cached[alg] === pending && delete cached[alg], error2;
7527
7558
  });
7528
7559
  cached[alg] = pending;
7529
7560
  }
@@ -7549,14 +7580,14 @@ function createLocalJWKSet(jwks) {
7549
7580
  if (!length)
7550
7581
  throw new JWKSNoMatchingKey();
7551
7582
  if (length !== 1) {
7552
- const error = new JWKSMultipleMatchingKeys();
7553
- throw error[Symbol.asyncIterator] = async function* () {
7583
+ const error2 = new JWKSMultipleMatchingKeys();
7584
+ throw error2[Symbol.asyncIterator] = async function* () {
7554
7585
  for (const jwk2 of candidates)
7555
7586
  try {
7556
7587
  yield await importWithAlgCache(cached, jwk2, entry);
7557
7588
  } catch {
7558
7589
  }
7559
- }, error;
7590
+ }, error2;
7560
7591
  }
7561
7592
  return importWithAlgCache(cached, jwk, entry);
7562
7593
  }, "jwks", {
@@ -7743,190 +7774,417 @@ async function importSigningKeys(jwk) {
7743
7774
  };
7744
7775
  }
7745
7776
 
7746
- // src/password.ts
7747
- import { createHash, createHmac, hkdfSync, randomBytes, randomInt, scrypt, timingSafeEqual } from "crypto";
7748
- var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
7749
- var KEY_LENGTH = 64;
7750
- var SALT_LENGTH = 16;
7751
- var MAX_PASSWORD_LENGTH = 256;
7752
- var MAX_EMAIL_LENGTH = 254;
7753
- function derive2(password, salt, { logN, r, p }) {
7754
- const N = 2 ** logN;
7755
- return new Promise(
7756
- (resolve, reject) => scrypt(
7757
- password.normalize("NFC"),
7758
- salt,
7759
- KEY_LENGTH,
7760
- { N, r, p, maxmem: 256 * N * r + 1024 * 1024 },
7761
- (err, key) => err ? reject(err) : resolve(key)
7762
- )
7763
- );
7764
- }
7765
- async function hashPassword(password, params = HASH_PARAMS) {
7766
- const salt = randomBytes(SALT_LENGTH);
7767
- const key = await derive2(password, salt, params);
7768
- return ["scrypt", params.logN, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
7769
- }
7770
- async function verifyPassword(password, stored) {
7771
- const [scheme, logN, r, p, salt, hash] = stored.split("$");
7772
- const params = { logN: Number(logN), r: Number(r), p: Number(p) };
7773
- if (scheme !== "scrypt" || !salt || !hash || !Object.values(params).every((n) => Number.isInteger(n) && n > 0)) {
7774
- return { ok: false, needsRehash: false };
7775
- }
7776
- const expected = Buffer.from(hash, "base64url");
7777
- const key = await derive2(password, Buffer.from(salt, "base64url"), params);
7778
- const ok = key.length === expected.length && timingSafeEqual(key, expected);
7779
- const needsRehash = params.logN !== HASH_PARAMS.logN || params.r !== HASH_PARAMS.r || params.p !== HASH_PARAMS.p;
7780
- return { ok, needsRehash };
7777
+ // src/webhooks.ts
7778
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
7779
+ var WEBHOOK_TYPES = [
7780
+ "email.verify",
7781
+ "email.reset",
7782
+ "email.already_registered",
7783
+ "email.no_password",
7784
+ "signup.before",
7785
+ "user.created",
7786
+ "email.verified",
7787
+ "email.password_reset",
7788
+ "user.signed_in",
7789
+ "user.deleted",
7790
+ "user.claims_changed",
7791
+ "totp.enabled",
7792
+ "totp.disabled",
7793
+ "totp.recovery_code_used"
7794
+ ];
7795
+ var WEBHOOK_TIMEOUT_MS = 1e4;
7796
+ var MAX_LOCALE_LENGTH = 35;
7797
+ function localeOf(value) {
7798
+ return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
7781
7799
  }
7782
- var dummyHash;
7783
- async function verifyAgainstDummy(password) {
7784
- dummyHash ??= hashPassword(randomBytes(16).toString("hex"));
7785
- await verifyPassword(password, await dummyHash);
7800
+ var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
7801
+ var SECRET_PREFIX = "whsec_";
7802
+ var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
7803
+ var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
7804
+ function generateWebhookSecret() {
7805
+ return `${SECRET_PREFIX}${randomBytes(32).toString("base64")}`;
7786
7806
  }
7787
- function checkPasswordPolicy(password, minLength) {
7788
- if (typeof password !== "string") return "password is missing";
7789
- const length = [...password].length;
7790
- if (length < minLength) return `The password must have at least ${minLength} characters.`;
7791
- if (length > MAX_PASSWORD_LENGTH) return `The password must have at most ${MAX_PASSWORD_LENGTH} characters.`;
7807
+ function checkWebhookSecret(secret) {
7808
+ if (!secret.startsWith(SECRET_PREFIX)) return `must start with "${SECRET_PREFIX}"`;
7809
+ const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
7810
+ if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
7792
7811
  return null;
7793
7812
  }
7794
- function normalizeEmail(email) {
7795
- return email.trim().toLowerCase();
7813
+ function hmac2(secret, content) {
7814
+ const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
7815
+ return createHmac("sha256", key).update(content).digest("base64");
7796
7816
  }
7797
- function isValidEmail(email) {
7798
- return typeof email === "string" && email.trim().length <= MAX_EMAIL_LENGTH && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
7817
+ function signWebhook(secret, id, timestamp, body2) {
7818
+ return `v1,${hmac2(secret, `${id}.${timestamp}.${body2}`)}`;
7799
7819
  }
7800
- function generateLinkToken() {
7801
- return randomBytes(32).toString("base64url");
7820
+ function createWebhookClient(settings, fetchImpl = fetch) {
7821
+ const wants = (type) => settings.events.has(type);
7822
+ return {
7823
+ wants,
7824
+ async call(type, data, timeoutMs) {
7825
+ if (!wants(type)) return { ok: true, body: void 0 };
7826
+ const id = `msg_${randomUUID()}`;
7827
+ const timestamp = Math.floor(Date.now() / 1e3);
7828
+ const body2 = JSON.stringify({ type, data });
7829
+ let response;
7830
+ try {
7831
+ response = await fetchImpl(settings.url, {
7832
+ method: "POST",
7833
+ headers: {
7834
+ "Content-Type": "application/json",
7835
+ "webhook-id": id,
7836
+ "webhook-timestamp": String(timestamp),
7837
+ "webhook-signature": signWebhook(settings.secret, id, timestamp, body2)
7838
+ },
7839
+ body: body2,
7840
+ signal: AbortSignal.timeout(timeoutMs)
7841
+ });
7842
+ } catch (e) {
7843
+ const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
7844
+ return { ok: false, reason };
7845
+ }
7846
+ const text = await response.text().catch(() => "");
7847
+ if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
7848
+ let parsed;
7849
+ try {
7850
+ parsed = text ? JSON.parse(text) : void 0;
7851
+ } catch {
7852
+ parsed = void 0;
7853
+ }
7854
+ return { ok: true, body: parsed };
7855
+ }
7856
+ };
7802
7857
  }
7803
- function hashLinkToken(token) {
7804
- return createHash("sha256").update(token).digest("base64url");
7858
+
7859
+ // src/config.ts
7860
+ function sendsVerificationEmails(config) {
7861
+ return !!config.webhook?.events.has("email.verify");
7805
7862
  }
7806
- function generateCode() {
7807
- return randomInt(0, 1e6).toString().padStart(6, "0");
7863
+ function methodConfig(config) {
7864
+ return { google: config.google, password: config.password, emailVerification: sendsVerificationEmails(config) };
7808
7865
  }
7809
- function deriveCodeKey(signingKeyD) {
7810
- return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth e-mail code", 32));
7866
+ async function resolveEnv(source = process.env) {
7867
+ return typeof source === "function" ? source() : source;
7811
7868
  }
7812
- function hashCode(key, code) {
7813
- return createHmac("sha256", key).update(code).digest("base64url");
7869
+ var DEFAULT_SESSION_TTL = 8 * 60 * 60;
7870
+ var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
7871
+ var DEFAULT_PASSWORD_MIN_LENGTH = 8;
7872
+ var ConfigError = class extends Error {
7873
+ constructor() {
7874
+ super(...arguments);
7875
+ this.name = "ConfigError";
7876
+ }
7877
+ };
7878
+ function parseOrigin(name, value) {
7879
+ let url;
7880
+ try {
7881
+ url = new URL(value);
7882
+ } catch {
7883
+ throw new ConfigError(`${name}: "${value}" is not a URL.`);
7884
+ }
7885
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
7886
+ throw new ConfigError(`${name}: "${value}" must be an http(s) URL.`);
7887
+ }
7888
+ return url.origin;
7814
7889
  }
7815
- function safeEqual(a, b) {
7816
- const x = Buffer.from(a);
7817
- const y = Buffer.from(b);
7818
- return x.length === y.length && timingSafeEqual(x, y);
7890
+ function readEnv(env, name) {
7891
+ return env[name]?.trim().replace(/^(['"])(.*)\1$/s, "$2").trim() || void 0;
7819
7892
  }
7893
+ async function loadConfig(env, overrides = {}) {
7894
+ const read = (name) => readEnv(env, name);
7895
+ const require2 = (name) => {
7896
+ const value = read(name);
7897
+ if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
7898
+ return value;
7899
+ };
7900
+ const issuerValue = require2("MADAUTH_ISSUER");
7901
+ parseOrigin("MADAUTH_ISSUER", issuerValue);
7902
+ const issuer = issuerValue.replace(/\/+$/, "");
7903
+ const keyValue = read("MADAUTH_SIGNING_KEY");
7904
+ if (!keyValue) {
7905
+ const key = await generateSigningKey();
7906
+ throw new ConfigError(
7907
+ `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):
7820
7908
 
7821
- // src/google.ts
7822
- var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
7823
- var googleJwks;
7824
- function googleKeys() {
7825
- googleJwks ??= createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs"));
7826
- return googleJwks;
7827
- }
7828
- async function verifyGoogleIdToken(idToken, opts) {
7829
- let payload;
7909
+ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
7910
+ `
7911
+ );
7912
+ }
7913
+ let signingKey;
7830
7914
  try {
7831
- ({ payload } = await jwtVerify(idToken, opts.keys ?? googleKeys(), {
7832
- issuer: GOOGLE_ISSUERS,
7833
- audience: opts.clientId,
7834
- algorithms: ["RS256", "ES256"]
7835
- }));
7915
+ signingKey = JSON.parse(keyValue);
7916
+ assertPrivateSigningJwk(signingKey);
7836
7917
  } catch (e) {
7837
- return { ok: false, error: "verification_failed", reason: e.message };
7918
+ const reason = e instanceof SyntaxError ? "is not valid JSON" : e.message;
7919
+ throw new ConfigError(`MADAUTH_SIGNING_KEY ${reason}. Generate one with: npx @madauth/server generate-key`);
7838
7920
  }
7839
- if (typeof payload.sub !== "string" || !payload.sub) {
7840
- return { ok: false, error: "verification_failed", reason: "missing sub" };
7921
+ const allowedOrigins = require2("ALLOWED_ORIGINS").split(",").map((o) => o.trim()).filter(Boolean).map((o) => parseOrigin("ALLOWED_ORIGINS", o));
7922
+ const clientId = read("GOOGLE_CLIENT_ID");
7923
+ if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
7924
+ throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
7841
7925
  }
7842
- if (payload.nonce !== opts.nonce) {
7843
- return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
7926
+ const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
7927
+ const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
7928
+ const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
7929
+ const emailVerification = !!webhook && webhook.events.has("email.verify");
7930
+ if (!clientId && !password && !emailVerification) {
7931
+ throw new ConfigError(
7932
+ `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.`
7933
+ );
7844
7934
  }
7845
- if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
7846
- return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
7935
+ const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
7936
+ if (!store) {
7937
+ throw new ConfigError(
7938
+ "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."
7939
+ );
7847
7940
  }
7848
- const profile = { sub: payload.sub, email: payload.email };
7849
- if (typeof payload.name === "string") profile.name = payload.name;
7850
- if (typeof payload.picture === "string") profile.picture = payload.picture;
7851
- return { ok: true, profile };
7852
- }
7853
-
7854
- // src/user.ts
7855
- var SESSION_COOKIE = "madauth_session";
7856
- var SESSION_TYP = "madauth-session+jwt";
7857
- var RENEWAL_COOKIE = "madauth_renewal";
7858
- var RENEWAL_TYP = "madauth-renewal+jwt";
7859
- var EXPIRY_COOKIE = "madauth_session_expires";
7860
-
7861
- // src/tokens.ts
7862
- function randomString(bytes = 32) {
7863
- return base64url_exports.encode(crypto.getRandomValues(new Uint8Array(bytes)));
7864
- }
7865
- async function signToken(keys, issuer, typ, claims, ttlSeconds) {
7866
- return new SignJWT(claims).setProtectedHeader({ alg: SIGNING_ALG, kid: keys.kid, typ }).setIssuer(issuer).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).sign(keys.privateKey);
7867
- }
7868
- async function readToken(keys, issuer, typ, token) {
7869
- if (!token) return null;
7870
- try {
7871
- const { payload } = await jwtVerify(token, keys.publicKey, { issuer, typ, algorithms: [SIGNING_ALG] });
7872
- return payload;
7873
- } catch {
7874
- return null;
7941
+ const ttlValue = read("SESSION_TTL");
7942
+ const sessionTtlSeconds = ttlValue ? Number(ttlValue) : DEFAULT_SESSION_TTL;
7943
+ if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
7944
+ throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
7875
7945
  }
7876
- }
7877
- function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
7878
- const claims = {
7879
- ...extra,
7880
- sub: user.id,
7881
- email: user.email,
7882
- email_verified: user.email ? true : void 0,
7883
- name: user.name,
7884
- picture: user.picture,
7885
- claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
7886
- amr
7946
+ const renewalValue = read("SESSION_RENEWAL_TTL");
7947
+ const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
7948
+ if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
7949
+ throw new ConfigError(
7950
+ `SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
7951
+ );
7952
+ }
7953
+ return {
7954
+ issuer,
7955
+ signingKey,
7956
+ allowedOrigins,
7957
+ sessionTtlSeconds,
7958
+ renewalTtlSeconds,
7959
+ cookieDomain: read("COOKIE_DOMAIN"),
7960
+ totpIssuer: read("TOTP_ISSUER"),
7961
+ google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
7962
+ store,
7963
+ password,
7964
+ webhook
7887
7965
  };
7888
- return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
7889
7966
  }
7890
- function signRenewal(keys, issuer, ttlSeconds, user, amr, extra = {}) {
7891
- const claims = {
7892
- ...extra,
7893
- sub: user.id,
7894
- email: user.email,
7895
- email_verified: user.email ? true : void 0,
7896
- name: user.name,
7897
- picture: user.picture,
7898
- amr
7899
- };
7900
- return signToken(keys, issuer, RENEWAL_TYP, claims, ttlSeconds);
7967
+ function passwordMinLength(value) {
7968
+ const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
7969
+ if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
7970
+ throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
7971
+ }
7972
+ return minLength;
7901
7973
  }
7902
- function userFromClaims(claims) {
7903
- const user = { id: claims.sub };
7904
- if (claims.email) user.email = claims.email;
7905
- if (claims.name) user.name = claims.name;
7906
- if (claims.picture) user.picture = claims.picture;
7907
- if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
7908
- return user;
7974
+ async function storeFromDatabaseUrl(url) {
7975
+ if (!url) return void 0;
7976
+ if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
7977
+ if (!url.startsWith("sqlite:")) {
7978
+ throw new ConfigError(
7979
+ `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).`
7980
+ );
7981
+ }
7982
+ const path = url.slice("sqlite:".length);
7983
+ if (!path) throw new ConfigError("DATABASE_URL needs a file path, e.g. sqlite:/data/madauth.db");
7984
+ const { createSqliteAdapter: createSqliteAdapter2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
7985
+ try {
7986
+ return createSqliteAdapter2(path);
7987
+ } catch (e) {
7988
+ throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
7989
+ }
7990
+ }
7991
+ async function dynamoDbStore(tableName2) {
7992
+ if (!tableName2) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
7993
+ if (!/^[\w.-]{3,255}$/.test(tableName2)) {
7994
+ throw new ConfigError(
7995
+ `DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName2}"`
7996
+ );
7997
+ }
7998
+ try {
7999
+ await import("@aws-sdk/client-dynamodb");
8000
+ } catch (e) {
8001
+ throw new ConfigError(
8002
+ `DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
8003
+ );
8004
+ }
8005
+ const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
8006
+ return createDynamoDbAdapter2({ tableName: tableName2 });
8007
+ }
8008
+ var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
8009
+ var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
8010
+ function webhookFromEnv(url, secret, events) {
8011
+ if (!url) {
8012
+ if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
8013
+ return void 0;
8014
+ }
8015
+ let parsed;
8016
+ try {
8017
+ parsed = new URL(url);
8018
+ } catch {
8019
+ throw new ConfigError(`WEBHOOK_URL: "${url}" is not a URL.`);
8020
+ }
8021
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname))) {
8022
+ throw new ConfigError(`WEBHOOK_URL must be an https URL (http only for localhost) but is "${url}".`);
8023
+ }
8024
+ if (!secret) {
8025
+ throw new ConfigError(
8026
+ `WEBHOOK_SECRET is not set. Here is a newly generated secret you can use (keep it secret; your webhook receiver needs the same one):
8027
+
8028
+ WEBHOOK_SECRET=${generateWebhookSecret()}
8029
+ `
8030
+ );
8031
+ }
8032
+ const problem = checkWebhookSecret(secret);
8033
+ if (problem) {
8034
+ throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
8035
+ }
8036
+ const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
8037
+ const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
8038
+ if (!selected.size) {
8039
+ throw new ConfigError(
8040
+ `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:
8041
+
8042
+ WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
8043
+
8044
+ For one that only wants to know who signed in:
8045
+
8046
+ WEBHOOK_EVENTS=user.signed_in
8047
+
8048
+ ` + allTypes
8049
+ );
8050
+ }
8051
+ const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
8052
+ if (unknown.length) {
8053
+ const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
8054
+ throw new ConfigError(
8055
+ `WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
8056
+ );
8057
+ }
8058
+ return { url: parsed.href, secret, events: selected };
8059
+ }
8060
+
8061
+ // src/password.ts
8062
+ import { createHash, createHmac as createHmac2, hkdfSync, randomBytes as randomBytes2, randomInt, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
8063
+ var HASH_PARAMS = { logN: 15, r: 8, p: 3 };
8064
+ var KEY_LENGTH = 64;
8065
+ var SALT_LENGTH = 16;
8066
+ var MAX_PASSWORD_LENGTH = 256;
8067
+ var MAX_EMAIL_LENGTH = 254;
8068
+ function derive2(password, salt, { logN, r, p }) {
8069
+ const N = 2 ** logN;
8070
+ return new Promise(
8071
+ (resolve, reject) => scrypt(
8072
+ password.normalize("NFC"),
8073
+ salt,
8074
+ KEY_LENGTH,
8075
+ { N, r, p, maxmem: 256 * N * r + 1024 * 1024 },
8076
+ (err, key) => err ? reject(err) : resolve(key)
8077
+ )
8078
+ );
8079
+ }
8080
+ async function hashPassword(password, params = HASH_PARAMS) {
8081
+ const salt = randomBytes2(SALT_LENGTH);
8082
+ const key = await derive2(password, salt, params);
8083
+ return ["scrypt", params.logN, params.r, params.p, salt.toString("base64url"), key.toString("base64url")].join("$");
8084
+ }
8085
+ async function verifyPassword(password, stored) {
8086
+ const [scheme, logN, r, p, salt, hash] = stored.split("$");
8087
+ const params = { logN: Number(logN), r: Number(r), p: Number(p) };
8088
+ if (scheme !== "scrypt" || !salt || !hash || !Object.values(params).every((n) => Number.isInteger(n) && n > 0)) {
8089
+ return { ok: false, needsRehash: false };
8090
+ }
8091
+ const expected = Buffer.from(hash, "base64url");
8092
+ const key = await derive2(password, Buffer.from(salt, "base64url"), params);
8093
+ const ok = key.length === expected.length && timingSafeEqual2(key, expected);
8094
+ const needsRehash = params.logN !== HASH_PARAMS.logN || params.r !== HASH_PARAMS.r || params.p !== HASH_PARAMS.p;
8095
+ return { ok, needsRehash };
8096
+ }
8097
+ var dummyHash;
8098
+ async function verifyAgainstDummy(password) {
8099
+ dummyHash ??= hashPassword(randomBytes2(16).toString("hex"));
8100
+ await verifyPassword(password, await dummyHash);
8101
+ }
8102
+ function checkPasswordPolicy(password, minLength) {
8103
+ if (typeof password !== "string") return "password is missing";
8104
+ const length = [...password].length;
8105
+ if (length < minLength) return `The password must have at least ${minLength} characters.`;
8106
+ if (length > MAX_PASSWORD_LENGTH) return `The password must have at most ${MAX_PASSWORD_LENGTH} characters.`;
8107
+ return null;
8108
+ }
8109
+ function normalizeEmail(email) {
8110
+ return email.trim().toLowerCase();
8111
+ }
8112
+ function isValidEmail(email) {
8113
+ return typeof email === "string" && email.trim().length <= MAX_EMAIL_LENGTH && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
8114
+ }
8115
+ function generateLinkToken() {
8116
+ return randomBytes2(32).toString("base64url");
8117
+ }
8118
+ function hashLinkToken(token) {
8119
+ return createHash("sha256").update(token).digest("base64url");
8120
+ }
8121
+ function generateCode() {
8122
+ return randomInt(0, 1e6).toString().padStart(6, "0");
8123
+ }
8124
+ function deriveCodeKey(signingKeyD) {
8125
+ return Buffer.from(hkdfSync("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth e-mail code", 32));
8126
+ }
8127
+ function hashCode(key, code) {
8128
+ return createHmac2("sha256", key).update(code).digest("base64url");
8129
+ }
8130
+ function safeEqual(a, b) {
8131
+ const x = Buffer.from(a);
8132
+ const y = Buffer.from(b);
8133
+ return x.length === y.length && timingSafeEqual2(x, y);
7909
8134
  }
7910
8135
 
7911
8136
  // src/users.ts
7912
- import { randomBytes as randomBytes2 } from "crypto";
8137
+ import { randomBytes as randomBytes3 } from "crypto";
7913
8138
 
7914
8139
  // src/settings.ts
7915
- var SIGN_IN_METHODS = ["google", "password"];
8140
+ var SIGN_IN_METHODS = ["google", "password", "totp"];
8141
+ var METHODS_WITH_SECOND_FACTOR = ["google", "password"];
8142
+ var SECOND_FACTOR_POLICIES = ["none", "optional", "required"];
7916
8143
  var METHODS_SETTING = "methods";
7917
- function parseMethodSettings(input) {
8144
+ var isSignInMethod = (value) => SIGN_IN_METHODS.includes(value);
8145
+ var isPolicy = (value) => SECOND_FACTOR_POLICIES.includes(value);
8146
+ function parseMethodSettings(input, options = {}) {
7918
8147
  if (!input || typeof input !== "object" || Array.isArray(input)) return null;
7919
8148
  const settings = {};
7920
- for (const [key, value] of Object.entries(input)) {
7921
- if (!SIGN_IN_METHODS.includes(key) || typeof value !== "boolean") return null;
7922
- settings[key] = value;
8149
+ for (const [method, value] of Object.entries(input)) {
8150
+ if (!isSignInMethod(method)) return null;
8151
+ if (typeof value === "boolean") {
8152
+ if (!options.booleans) return null;
8153
+ if (value) settings[method] = {};
8154
+ continue;
8155
+ }
8156
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
8157
+ const setting = {};
8158
+ for (const [field, fieldValue] of Object.entries(value)) {
8159
+ if (field !== "secondFactor" || method === "totp" || !isPolicy(fieldValue)) return null;
8160
+ setting.secondFactor = fieldValue;
8161
+ }
8162
+ settings[method] = setting;
7923
8163
  }
7924
8164
  return settings;
7925
8165
  }
8166
+ function isConfigured(config, method) {
8167
+ if (method === "google") return !!config.google;
8168
+ if (method === "password") return !!config.password;
8169
+ return true;
8170
+ }
8171
+ function canSignUp(config, method) {
8172
+ return method === "totp" ? !!config.emailVerification : isConfigured(config, method);
8173
+ }
8174
+ function initialMethods(config) {
8175
+ const methods = {};
8176
+ if (config.google) methods.google = { secondFactor: "none" };
8177
+ if (config.password) methods.password = { secondFactor: "none" };
8178
+ if (!config.google && !config.password && config.emailVerification) methods.totp = {};
8179
+ return methods;
8180
+ }
7926
8181
  var MAX_TRIES = 3;
7927
8182
  var Settings = class {
7928
- constructor(store) {
8183
+ /** `initial` is what counts until the methods are set, see {@link initialMethods}. */
8184
+ constructor(store, initial = {}) {
7929
8185
  this.store = store;
8186
+ this.initial = initial;
8187
+ this.warned = false;
7930
8188
  }
7931
8189
  /** The setting's value, or undefined if it was never set. */
7932
8190
  async get(id) {
@@ -7963,32 +8221,53 @@ var Settings = class {
7963
8221
  async set(id, value, updatedBy) {
7964
8222
  await this.change(id, () => value, updatedBy);
7965
8223
  }
7966
- /** Which sign-in methods are on; a method that was never switched off is on. */
8224
+ /** Which sign-in methods are on, with their settings; the initial ones until an admin set them. */
7967
8225
  async methods() {
7968
- return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
8226
+ return this.current(await this.get(METHODS_SETTING));
7969
8227
  }
7970
8228
  /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
7971
8229
  async changeMethods(apply, updatedBy) {
7972
- return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
8230
+ return this.change(METHODS_SETTING, (current) => apply(this.current(current)), updatedBy);
8231
+ }
8232
+ /** The stored methods, or the initial ones if nothing (usable) is stored. */
8233
+ current(stored) {
8234
+ if (stored === void 0) return this.initial;
8235
+ const parsed = parseMethodSettings(stored);
8236
+ if (parsed) return parsed;
8237
+ if (!this.warned) {
8238
+ this.warned = true;
8239
+ 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.");
8240
+ }
8241
+ return this.initial;
7973
8242
  }
7974
8243
  };
7975
- function isOn(method, available, methods) {
7976
- return available(method) && methods[method] !== false;
8244
+ function isOn(method, configured, methods) {
8245
+ return configured(method) && methods[method] !== void 0;
8246
+ }
8247
+ function policyOf(method, methods) {
8248
+ return methods[method]?.secondFactor ?? "none";
7977
8249
  }
7978
- function anyOn(available, methods) {
7979
- return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
8250
+ function anyOn(signsUp, methods) {
8251
+ return SIGN_IN_METHODS.some((method) => signsUp(method) && methods[method] !== void 0);
8252
+ }
8253
+ function authenticatorOn(configured, methods) {
8254
+ if (isOn("totp", configured, methods)) return true;
8255
+ return METHODS_WITH_SECOND_FACTOR.some((method) => isOn(method, configured, methods) && policyOf(method, methods) !== "none");
7980
8256
  }
7981
8257
 
7982
8258
  // src/users.ts
7983
8259
  var MAX_CODE_ATTEMPTS = 5;
7984
8260
  var MAX_WRONG_CODES = 10;
7985
- var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
8261
+ var newId = (prefix) => `${prefix}_${randomBytes3(16).toString("base64url")}`;
7986
8262
  function passwordAccountKey(userId) {
7987
8263
  return `password:${userId}`;
7988
8264
  }
7989
8265
  function googleAccountKey(sub) {
7990
8266
  return `google:${sub}`;
7991
8267
  }
8268
+ function totpAccountKey(userId) {
8269
+ return `totp:${userId}`;
8270
+ }
7992
8271
  function toMadauthUser(user, profile = {}) {
7993
8272
  const result = { id: user.id, email: user.email };
7994
8273
  const name = user.name ?? profile.name;
@@ -8017,18 +8296,32 @@ var Users = class {
8017
8296
  async passwordAccount(userId) {
8018
8297
  return this.findAccountByKey(passwordAccountKey(userId));
8019
8298
  }
8020
- /** How the user signs in, from their accounts: e.g. `['google']` for a user without a password. */
8021
- async signInMethods(userId) {
8299
+ /** The user's authenticator app, if they set one up. */
8300
+ async totpAccount(userId) {
8301
+ return this.findAccountByKey(totpAccountKey(userId));
8302
+ }
8303
+ /** Whether the user has any way to sign in; a sign-up with the authenticator app has none until it is set up. */
8304
+ async hasAccounts(userId) {
8305
+ return (await this.store.findMany("account", { userId }, { limit: 1 })).length > 0;
8306
+ }
8307
+ /**
8308
+ * The ways the user can sign in, from their accounts: e.g. `['google']` for a user without a password.
8309
+ * The authenticator app counts only when it signs in on its own (`methods` lists it); as a second
8310
+ * factor it is a step of another method, not a way in.
8311
+ */
8312
+ async signInMethods(userId, methods) {
8022
8313
  const accounts = await this.store.findMany("account", { userId });
8023
- const methods = accounts.map((account) => String(account.key).split(":")[0]);
8024
- return SIGN_IN_METHODS.filter((method) => methods.includes(method));
8314
+ const keys = accounts.map((account) => String(account.key).split(":")[0]);
8315
+ return SIGN_IN_METHODS.filter((method) => keys.includes(method) && (method !== "totp" || methods.totp !== void 0));
8025
8316
  }
8026
8317
  async findAccountByKey(key) {
8027
8318
  return await this.store.findOne("account", { key });
8028
8319
  }
8029
8320
  /**
8030
- * Creates a user with their first account. Resolves to null if the e-mail address or the account key is
8031
- * taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or e.g. `google:<sub>`.
8321
+ * Creates a user, with their first account if given. Resolves to null if the e-mail address or the
8322
+ * account key is taken. `account.key` is `password:<userId>` for a password (its hash as `secret`), or
8323
+ * e.g. `google:<sub>`. A sign-up with the authenticator app creates the user without an account: the app
8324
+ * is set up once the address is confirmed.
8032
8325
  */
8033
8326
  async createUser(data) {
8034
8327
  const user = {
@@ -8044,6 +8337,7 @@ var Users = class {
8044
8337
  createdAt: Date.now()
8045
8338
  };
8046
8339
  if (!await this.store.create("user", user)) return null;
8340
+ if (!data.account) return user;
8047
8341
  const account = typeof data.account === "function" ? data.account(user.id) : data.account;
8048
8342
  if (!await this.linkAccount(user.id, account)) {
8049
8343
  await this.store.delete("user", { id: user.id });
@@ -8065,6 +8359,16 @@ var Users = class {
8065
8359
  async deleteAccount(id) {
8066
8360
  await this.store.delete("account", { id });
8067
8361
  }
8362
+ /** Removes a sign-in method by its key. Resolves to false if there was none. */
8363
+ async unlinkAccount(key) {
8364
+ return await this.store.delete("account", { key }) === 1;
8365
+ }
8366
+ /** Removes the user's authenticator app and recovery codes. Resolves to false if they had no app. */
8367
+ async removeTotp(userId) {
8368
+ const removed = await this.unlinkAccount(totpAccountKey(userId));
8369
+ await this.clearRecoveryCodes(userId);
8370
+ return removed;
8371
+ }
8068
8372
  /** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
8069
8373
  async linkAccount(userId, account) {
8070
8374
  const record = {
@@ -8075,10 +8379,70 @@ var Users = class {
8075
8379
  failedAttempts: 0,
8076
8380
  lockedUntil: 0,
8077
8381
  email: account.email ?? null,
8382
+ lastUsedStep: account.lastUsedStep ?? 0,
8383
+ setupId: account.setupId ?? null,
8078
8384
  createdAt: Date.now()
8079
8385
  };
8080
8386
  return await this.store.create("account", record) ? record : null;
8081
8387
  }
8388
+ /**
8389
+ * Writes the secret of a confirmed setup over the authenticator account, only if the account still has the
8390
+ * setup it had when this setup started (`replaces`): the write count decides, so of two setups confirmed
8391
+ * at once exactly one wins, and the other is told to start again.
8392
+ */
8393
+ async confirmTotpSetup(accountId, replaces, patch) {
8394
+ return await this.store.update("account", { id: accountId, setupId: replaces }, patch) === 1;
8395
+ }
8396
+ /**
8397
+ * Marks the time step of an accepted authenticator code as used and resets the failed attempts, only if
8398
+ * no other request accepted a code meanwhile: the write count decides, so the same code sent twice signs
8399
+ * in once.
8400
+ */
8401
+ async useTotpStep(account, step) {
8402
+ const changed = await this.store.update(
8403
+ "account",
8404
+ { id: account.id, lastUsedStep: account.lastUsedStep ?? null },
8405
+ { lastUsedStep: step, failedAttempts: 0, lockedUntil: 0 }
8406
+ );
8407
+ return changed === 1;
8408
+ }
8409
+ /** Replaces the user's recovery codes with new ones, given as their hashes. */
8410
+ /** Stores the recovery codes of a setup (their hashes), next to whatever codes the user has. */
8411
+ async addRecoveryCodes(userId, setupId, hashes) {
8412
+ const createdAt = Date.now();
8413
+ for (const id of hashes) await this.store.create("recoveryCode", { id, userId, setupId, createdAt });
8414
+ }
8415
+ /** New recovery codes for the user's current app: the codes they had before stop working. */
8416
+ async replaceRecoveryCodes(account, hashes) {
8417
+ const before = await this.store.findMany("recoveryCode", { userId: account.userId });
8418
+ await this.addRecoveryCodes(account.userId, account.setupId ?? "", hashes);
8419
+ for (const old of before) await this.store.delete("recoveryCode", { id: old.id });
8420
+ }
8421
+ /**
8422
+ * Uses up the recovery code with this hash for the user's authenticator account. Resolves to false if it
8423
+ * is not theirs, belongs to another setup than the account's, or was used already.
8424
+ */
8425
+ async consumeRecoveryCode(account, hash) {
8426
+ const record = await this.store.findOne("recoveryCode", { id: hash });
8427
+ if (!record || record.userId !== account.userId || record.setupId !== (account.setupId ?? "")) return false;
8428
+ return await this.store.delete("recoveryCode", { id: hash }) === 1;
8429
+ }
8430
+ /** How many recovery codes of the account's setup are left. */
8431
+ async countRecoveryCodes(account) {
8432
+ const codes = await this.store.findMany("recoveryCode", { userId: account.userId });
8433
+ return codes.filter((code) => code.setupId === (account.setupId ?? "")).length;
8434
+ }
8435
+ /** Deletes the user's recovery codes: all of them, or only those of one setup, or all but one setup's. */
8436
+ async clearRecoveryCodes(userId, which = {}) {
8437
+ if (which.only === void 0 && which.except === void 0) {
8438
+ await this.store.delete("recoveryCode", { userId });
8439
+ return;
8440
+ }
8441
+ for (const code of await this.store.findMany("recoveryCode", { userId })) {
8442
+ const match2 = which.only !== void 0 ? code.setupId === which.only : code.setupId !== which.except;
8443
+ if (match2) await this.store.delete("recoveryCode", { id: code.id });
8444
+ }
8445
+ }
8082
8446
  /**
8083
8447
  * Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
8084
8448
  */
@@ -8089,6 +8453,7 @@ var Users = class {
8089
8453
  try {
8090
8454
  await this.store.delete("account", { userId: user.id });
8091
8455
  await this.store.delete("verification", { userId: user.id });
8456
+ await this.store.delete("recoveryCode", { userId: user.id });
8092
8457
  } catch (e) {
8093
8458
  console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
8094
8459
  }
@@ -8132,6 +8497,11 @@ var Users = class {
8132
8497
  await this.store.create("verification", record);
8133
8498
  return { token, code };
8134
8499
  }
8500
+ /** The user ID a link token belongs to, without using the verification up; null if invalid, expired or used. */
8501
+ async peekLinkToken(token, purpose) {
8502
+ const record = await this.store.findOne("verification", { id: hashLinkToken(token), purpose });
8503
+ return record && record.expiresAt > Date.now() ? record.userId : null;
8504
+ }
8135
8505
  /** Uses up the verification a link token belongs to. Resolves to its user ID, or null if invalid, expired or used. */
8136
8506
  async consumeLinkToken(token, purpose) {
8137
8507
  const id = hashLinkToken(token);
@@ -8178,83 +8548,213 @@ var Users = class {
8178
8548
  }
8179
8549
  };
8180
8550
 
8181
- // src/webhooks.ts
8182
- import { createHmac as createHmac2, randomBytes as randomBytes3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
8183
- var WEBHOOK_TYPES = [
8184
- "email.verify",
8185
- "email.reset",
8186
- "email.already_registered",
8187
- "email.no_password",
8188
- "signup.before",
8189
- "user.created",
8190
- "email.verified",
8191
- "email.password_reset",
8192
- "user.signed_in",
8193
- "user.deleted",
8194
- "user.claims_changed"
8195
- ];
8196
- var WEBHOOK_TIMEOUT_MS = 1e4;
8197
- var MAX_LOCALE_LENGTH = 35;
8198
- function localeOf(value) {
8199
- return typeof value === "string" && value.length <= MAX_LOCALE_LENGTH ? value : void 0;
8551
+ // src/routes/helpers.ts
8552
+ async function body(c) {
8553
+ const data = await c.req.json().catch(() => null);
8554
+ return data && typeof data === "object" ? data : {};
8200
8555
  }
8201
- var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
8202
- var SECRET_PREFIX = "whsec_";
8203
- var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
8204
- var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
8205
- function generateWebhookSecret() {
8206
- return `${SECRET_PREFIX}${randomBytes3(32).toString("base64")}`;
8556
+ function error(c, status, code, message2) {
8557
+ return c.json({ error: code, message: message2 }, status);
8207
8558
  }
8208
- function checkWebhookSecret(secret) {
8209
- if (!secret.startsWith(SECRET_PREFIX)) return `must start with "${SECRET_PREFIX}"`;
8210
- const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
8211
- if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
8212
- return null;
8559
+ var unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
8560
+ var accepted = (c) => c.json({}, 202);
8561
+ var noSession = (c) => error(c, 401, "no_session", "Please sign in first.");
8562
+ function redirectTarget(ctx, c, value) {
8563
+ const url = ctx.allowedUrl(value);
8564
+ return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
8213
8565
  }
8214
- function hmac2(secret, content) {
8215
- const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
8216
- return createHmac2("sha256", key).update(content).digest("base64");
8566
+ var MAX_NAME_LENGTH = 100;
8567
+ function nameOf(value) {
8568
+ return typeof value === "string" && value.trim() ? value.trim().slice(0, MAX_NAME_LENGTH) : null;
8217
8569
  }
8218
- function signWebhook(secret, id, timestamp, body) {
8219
- return `v1,${hmac2(secret, `${id}.${timestamp}.${body}`)}`;
8570
+ function signInAnswer(c, step) {
8571
+ if ("disabled" in step) return error(c, 403, "method_disabled", "This way of signing in is switched off.");
8572
+ if ("next" in step) {
8573
+ 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);
8574
+ }
8575
+ return c.json({ user: step.user });
8220
8576
  }
8221
- function createWebhookClient(settings, fetchImpl = fetch) {
8222
- const wants = (type) => settings.events.has(type);
8577
+
8578
+ // src/routes/emails.ts
8579
+ var MAIL_INTERVAL_MS = 60 * 1e3;
8580
+ var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
8581
+ var RESET_TTL_MS = 30 * 60 * 1e3;
8582
+ var VERIFY_LINK_PARAM = "madauth_verify";
8583
+ var RESET_LINK_PARAM = "madauth_reset";
8584
+ function createEmails(ctx, users, webhook) {
8585
+ const sendEmail = async (user, type, data) => {
8586
+ if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
8587
+ const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
8588
+ if (!result.ok) {
8589
+ console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
8590
+ return false;
8591
+ }
8592
+ await users.updateUser(user.id, { lastMailAt: Date.now() });
8593
+ return true;
8594
+ };
8223
8595
  return {
8224
- wants,
8225
- async call(type, data, timeoutMs) {
8226
- if (!wants(type)) return { ok: true, body: void 0 };
8227
- const id = `msg_${randomUUID()}`;
8228
- const timestamp = Math.floor(Date.now() / 1e3);
8229
- const body = JSON.stringify({ type, data });
8230
- let response;
8231
- try {
8232
- response = await fetchImpl(settings.url, {
8233
- method: "POST",
8234
- headers: {
8235
- "Content-Type": "application/json",
8236
- "webhook-id": id,
8237
- "webhook-timestamp": String(timestamp),
8238
- "webhook-signature": signWebhook(settings.secret, id, timestamp, body)
8239
- },
8240
- body,
8241
- signal: AbortSignal.timeout(timeoutMs)
8242
- });
8243
- } catch (e) {
8244
- const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
8245
- return { ok: false, reason };
8596
+ sendEmail,
8597
+ async sendLinkEmail(user, purpose, target, locale) {
8598
+ if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
8599
+ const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
8600
+ const { token, code } = await users.issueVerification(user.id, purpose, ttl);
8601
+ const link = new URL(target);
8602
+ link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
8603
+ return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
8604
+ link: link.href,
8605
+ code,
8606
+ expiresAt: Date.now() + ttl,
8607
+ site: target.host,
8608
+ locale
8609
+ });
8610
+ },
8611
+ async sendAccountEmail(user, type, target, locale) {
8612
+ if (!webhook.wants(type)) return true;
8613
+ const methods = await users.signInMethods(user.id, await ctx.settings.methods());
8614
+ return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
8615
+ },
8616
+ async consume(data, purpose) {
8617
+ if (typeof data.token === "string" && data.token) {
8618
+ return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
8246
8619
  }
8247
- const text = await response.text().catch(() => "");
8248
- if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
8249
- let parsed;
8250
- try {
8251
- parsed = text ? JSON.parse(text) : void 0;
8252
- } catch {
8253
- parsed = void 0;
8620
+ if (isValidEmail(data.email) && typeof data.code === "string") {
8621
+ const user = await users.findByEmail(normalizeEmail(data.email));
8622
+ if (!user) return { userId: null, via: "code" };
8623
+ return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
8254
8624
  }
8255
- return { ok: true, body: parsed };
8625
+ return { userId: null, via: "code" };
8626
+ },
8627
+ invalidVerification(c, { via, locked }) {
8628
+ if (locked) {
8629
+ return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
8630
+ }
8631
+ 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.");
8632
+ }
8633
+ };
8634
+ }
8635
+
8636
+ // src/routes/email.ts
8637
+ function emailRoutes(app2, ctx, users, webhook) {
8638
+ const emails = createEmails(ctx, users, webhook);
8639
+ const signUpMethod = async (userId) => await users.passwordAccount(userId) ? "password" : "totp";
8640
+ app2.post("/auth/email/send-verification", async (c) => {
8641
+ const data = await body(c);
8642
+ const target = redirectTarget(ctx, c, data.redirectTo);
8643
+ if (target instanceof Response) return target;
8644
+ if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
8645
+ const user = await users.findByEmail(normalizeEmail(data.email));
8646
+ if (user && !user.emailVerified && await ctx.enabled(c, await signUpMethod(user.id))) {
8647
+ if (!await emails.sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
8256
8648
  }
8649
+ return accepted(c);
8650
+ });
8651
+ const disabled = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
8652
+ app2.post("/auth/email/verify", async (c) => {
8653
+ const data = await body(c);
8654
+ 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;
8655
+ if (waiting && !await ctx.enabled(c, await signUpMethod(waiting))) return disabled(c);
8656
+ const consumed = await emails.consume(data, "verify");
8657
+ const { userId, via } = consumed;
8658
+ const user = userId ? await users.findById(userId) : null;
8659
+ if (!user) return emails.invalidVerification(c, consumed);
8660
+ const method = await signUpMethod(user.id);
8661
+ if (!await ctx.enabled(c, method)) return disabled(c);
8662
+ await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
8663
+ const verified = { ...user, emailVerified: true, wrongCodes: 0 };
8664
+ await ctx.emit("email.verified", { user: toMadauthUser(verified), via });
8665
+ return signInAnswer(c, await ctx.secondStep(c, verified, method === "password" ? ["pwd"] : [], method));
8666
+ });
8667
+ }
8668
+
8669
+ // src/google.ts
8670
+ var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
8671
+ var googleJwks;
8672
+ function googleKeys() {
8673
+ googleJwks ??= createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs"));
8674
+ return googleJwks;
8675
+ }
8676
+ async function verifyGoogleIdToken(idToken, opts) {
8677
+ let payload;
8678
+ try {
8679
+ ({ payload } = await jwtVerify(idToken, opts.keys ?? googleKeys(), {
8680
+ issuer: GOOGLE_ISSUERS,
8681
+ audience: opts.clientId,
8682
+ algorithms: ["RS256", "ES256"]
8683
+ }));
8684
+ } catch (e) {
8685
+ return { ok: false, error: "verification_failed", reason: e.message };
8686
+ }
8687
+ if (typeof payload.sub !== "string" || !payload.sub) {
8688
+ return { ok: false, error: "verification_failed", reason: "missing sub" };
8689
+ }
8690
+ if (payload.nonce !== opts.nonce) {
8691
+ return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
8692
+ }
8693
+ if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
8694
+ return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
8695
+ }
8696
+ const profile = { sub: payload.sub, email: payload.email };
8697
+ if (typeof payload.name === "string") profile.name = payload.name;
8698
+ if (typeof payload.picture === "string") profile.picture = payload.picture;
8699
+ return { ok: true, profile };
8700
+ }
8701
+
8702
+ // src/user.ts
8703
+ var SESSION_COOKIE = "madauth_session";
8704
+ var SESSION_TYP = "madauth-session+jwt";
8705
+ var RENEWAL_COOKIE = "madauth_renewal";
8706
+ var RENEWAL_TYP = "madauth-renewal+jwt";
8707
+ var EXPIRY_COOKIE = "madauth_session_expires";
8708
+
8709
+ // src/tokens.ts
8710
+ function randomString(bytes = 32) {
8711
+ return base64url_exports.encode(crypto.getRandomValues(new Uint8Array(bytes)));
8712
+ }
8713
+ async function signToken(keys, issuer, typ, claims, ttlSeconds) {
8714
+ return new SignJWT(claims).setProtectedHeader({ alg: SIGNING_ALG, kid: keys.kid, typ }).setIssuer(issuer).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).sign(keys.privateKey);
8715
+ }
8716
+ async function readToken(keys, issuer, typ, token) {
8717
+ if (!token) return null;
8718
+ try {
8719
+ const { payload } = await jwtVerify(token, keys.publicKey, { issuer, typ, algorithms: [SIGNING_ALG] });
8720
+ return payload;
8721
+ } catch {
8722
+ return null;
8723
+ }
8724
+ }
8725
+ function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
8726
+ const claims = {
8727
+ ...extra,
8728
+ sub: user.id,
8729
+ email: user.email,
8730
+ email_verified: user.email ? true : void 0,
8731
+ name: user.name,
8732
+ picture: user.picture,
8733
+ claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
8734
+ amr
8735
+ };
8736
+ return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
8737
+ }
8738
+ function signRenewal(keys, issuer, ttlSeconds, user, amr, extra = {}) {
8739
+ const claims = {
8740
+ ...extra,
8741
+ sub: user.id,
8742
+ email: user.email,
8743
+ email_verified: user.email ? true : void 0,
8744
+ name: user.name,
8745
+ picture: user.picture,
8746
+ amr
8257
8747
  };
8748
+ return signToken(keys, issuer, RENEWAL_TYP, claims, ttlSeconds);
8749
+ }
8750
+ function userFromClaims(claims) {
8751
+ const user = { id: claims.sub };
8752
+ if (claims.email) user.email = claims.email;
8753
+ if (claims.name) user.name = claims.name;
8754
+ if (claims.picture) user.picture = claims.picture;
8755
+ if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
8756
+ if (Array.isArray(claims.amr)) user.amr = claims.amr.filter((value) => typeof value === "string");
8757
+ return user;
8258
8758
  }
8259
8759
 
8260
8760
  // src/routes/google.ts
@@ -8267,6 +8767,7 @@ var OAUTH_TTL = 10 * 60;
8267
8767
  var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
8268
8768
  var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
8269
8769
  var REDIRECT_ERROR_PARAM = "madauth_error";
8770
+ var REDIRECT_NEXT_PARAM = "madauth_next";
8270
8771
  async function pkceChallenge(verifier) {
8271
8772
  const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
8272
8773
  return base64url_exports.encode(new Uint8Array(digest2));
@@ -8278,7 +8779,7 @@ function googleRoutes(app2, ctx, google) {
8278
8779
  const resolveUser = async (profile, locale) => {
8279
8780
  const key = googleAccountKey(profile.sub);
8280
8781
  const emailNormalized = normalizeEmail(profile.email);
8281
- const signedIn = (user) => ({ ok: true, user: toMadauthUser(user, profile), sv: user.sessionVersion });
8782
+ const signedIn = (user) => ({ ok: true, user });
8282
8783
  const account = await users.findAccountByKey(key);
8283
8784
  if (account) {
8284
8785
  const user = await users.findById(account.userId);
@@ -8291,6 +8792,11 @@ function googleRoutes(app2, ctx, google) {
8291
8792
  for (let attempt = 0; attempt < 2; attempt++) {
8292
8793
  const existing = await users.findByEmail(emailNormalized);
8293
8794
  if (existing) {
8795
+ if (existing.emailVerified && await users.hasAccounts(existing.id)) {
8796
+ const methods = await users.signInMethods(existing.id, await ctx.settings.methods());
8797
+ const how = methods.map((method) => ({ password: "a password", totp: "an authenticator app", google: "Google" })[method]).join(" or ");
8798
+ return { ok: false, error: "other_method", message: `This e-mail address signs in with ${how || "another method"}. Use that instead of Google.`, methods };
8799
+ }
8294
8800
  await users.verifyByProvider(existing);
8295
8801
  if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
8296
8802
  const linked = await users.findAccountByKey(key);
@@ -8322,7 +8828,7 @@ function googleRoutes(app2, ctx, google) {
8322
8828
  };
8323
8829
  const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
8324
8830
  app2.post("/auth/google/nonce", async (c) => {
8325
- if (!await ctx.enabled("google")) return disabled(c);
8831
+ if (!await ctx.enabled(c, "google")) return disabled(c);
8326
8832
  const nonce = randomString();
8327
8833
  const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
8328
8834
  setCookie(c, NONCE_COOKIE, token, {
@@ -8335,16 +8841,16 @@ function googleRoutes(app2, ctx, google) {
8335
8841
  return c.json({ nonce });
8336
8842
  });
8337
8843
  app2.post("/auth/google/verify", async (c) => {
8338
- if (!await ctx.enabled("google")) return disabled(c);
8339
- const body = await c.req.json().catch(() => ({}));
8340
- if (typeof body.credential !== "string" || !body.credential) {
8844
+ if (!await ctx.enabled(c, "google")) return disabled(c);
8845
+ const body2 = await c.req.json().catch(() => ({}));
8846
+ if (typeof body2.credential !== "string" || !body2.credential) {
8341
8847
  return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
8342
8848
  }
8343
8849
  const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
8344
8850
  if (!nonceClaims) {
8345
8851
  return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
8346
8852
  }
8347
- const result = await verifyGoogleIdToken(body.credential, {
8853
+ const result = await verifyGoogleIdToken(body2.credential, {
8348
8854
  clientId: google.clientId,
8349
8855
  nonce: nonceClaims.nonce,
8350
8856
  keys: config.jwksResolver
@@ -8353,17 +8859,16 @@ function googleRoutes(app2, ctx, google) {
8353
8859
  return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
8354
8860
  }
8355
8861
  deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
8356
- const resolved = await resolveUser(result.profile, localeOf(body.locale));
8862
+ const resolved = await resolveUser(result.profile, localeOf(body2.locale));
8357
8863
  if (!resolved.ok) {
8864
+ if (resolved.error === "other_method") return c.json({ error: resolved.error, message: resolved.message, methods: resolved.methods }, 403);
8358
8865
  return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
8359
8866
  }
8360
- const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
8361
- await ctx.emit("user.signed_in", { user, method: "google" });
8362
- return c.json({ user });
8867
+ return signInAnswer(c, await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile));
8363
8868
  });
8364
8869
  const requireCodeFlow = async (c, next) => {
8365
8870
  if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
8366
- if (!await ctx.enabled("google")) return disabled(c);
8871
+ if (!await ctx.enabled(c, "google")) return disabled(c);
8367
8872
  await next();
8368
8873
  };
8369
8874
  app2.get("/auth/google/start", requireCodeFlow, async (c) => {
@@ -8401,9 +8906,10 @@ function googleRoutes(app2, ctx, google) {
8401
8906
  const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
8402
8907
  if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
8403
8908
  deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
8404
- const back = (error) => {
8909
+ const back = (error2, next) => {
8405
8910
  const url = new URL(oauth.returnTo);
8406
- if (error) url.hash = `${REDIRECT_ERROR_PARAM}=${error}`;
8911
+ if (error2) url.hash = `${REDIRECT_ERROR_PARAM}=${error2}`;
8912
+ if (next) url.hash = `${REDIRECT_NEXT_PARAM}=${next}`;
8407
8913
  return c.redirect(url.href, 302);
8408
8914
  };
8409
8915
  if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
@@ -8437,85 +8943,38 @@ function googleRoutes(app2, ctx, google) {
8437
8943
  if (!result.ok) return back(result.error);
8438
8944
  const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
8439
8945
  if (!resolved.ok) return back(resolved.error);
8440
- const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
8441
- await ctx.emit("user.signed_in", { user, method: "google" });
8946
+ const step = await ctx.secondStep(c, resolved.user, ["google"], "google", result.profile);
8947
+ if ("disabled" in step) return back("method_disabled");
8948
+ if ("next" in step) return back(void 0, step.next);
8442
8949
  return back();
8443
8950
  });
8444
8951
  }
8445
8952
 
8446
- // src/routes/password.ts
8953
+ // src/routes/lockout.ts
8447
8954
  var FREE_ATTEMPTS = 5;
8448
8955
  var MAX_LOCK_MS = 15 * 60 * 1e3;
8449
- var MAIL_INTERVAL_MS = 60 * 1e3;
8450
- var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
8451
- var RESET_TTL_MS = 30 * 60 * 1e3;
8452
- var VERIFY_LINK_PARAM = "madauth_verify";
8453
- var RESET_LINK_PARAM = "madauth_reset";
8454
- var MAX_NAME_LENGTH = 100;
8956
+ async function countSignInAttempt(c, users, account, now = Date.now()) {
8957
+ const tooMany = (message2) => c.json({ error: "too_many_attempts", message: message2 }, 429);
8958
+ if (account.lockedUntil > now) {
8959
+ const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
8960
+ return tooMany(`Too many failed attempts. Try again in ${seconds} seconds.`);
8961
+ }
8962
+ const failed = account.failedAttempts + 1;
8963
+ const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
8964
+ if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
8965
+ return tooMany("Too many attempts at once. Please try again.");
8966
+ }
8967
+ return null;
8968
+ }
8969
+
8970
+ // src/routes/password.ts
8455
8971
  function passwordRoutes(app2, ctx, password, users, webhook) {
8456
8972
  const { minLength } = password;
8457
- const disabled = (c) => c.json({ error: "method_disabled", message: "E-mail & password sign-in is switched off." }, 403);
8973
+ const emails = createEmails(ctx, users, webhook);
8458
8974
  app2.use("/auth/password/*", async (c, next) => {
8459
- if (!await ctx.enabled("password")) return disabled(c);
8975
+ if (!await ctx.enabled(c, "password")) return error(c, 403, "method_disabled", "E-mail & password sign-in is switched off.");
8460
8976
  await next();
8461
8977
  });
8462
- const body = async (c) => {
8463
- const data = await c.req.json().catch(() => null);
8464
- return data && typeof data === "object" ? data : {};
8465
- };
8466
- const error = (c, status, code, message2) => c.json({ error: code, message: message2 }, status);
8467
- const unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
8468
- const accepted = (c) => c.json({}, 202);
8469
- const redirectTarget = (c, value) => {
8470
- const url = ctx.allowedUrl(value);
8471
- return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
8472
- };
8473
- const sendEmail = async (user, type, data) => {
8474
- if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
8475
- const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
8476
- if (!result.ok) {
8477
- console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
8478
- return false;
8479
- }
8480
- await users.updateUser(user.id, { lastMailAt: Date.now() });
8481
- return true;
8482
- };
8483
- const sendLinkEmail = async (user, purpose, target, locale) => {
8484
- if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
8485
- const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
8486
- const { token, code } = await users.issueVerification(user.id, purpose, ttl);
8487
- const link = new URL(target);
8488
- link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
8489
- return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
8490
- link: link.href,
8491
- code,
8492
- expiresAt: Date.now() + ttl,
8493
- site: target.host,
8494
- locale
8495
- });
8496
- };
8497
- const sendAccountEmail = async (user, type, target, locale) => {
8498
- if (!webhook.wants(type)) return true;
8499
- const methods = await users.signInMethods(user.id);
8500
- return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
8501
- };
8502
- const consume = async (data, purpose) => {
8503
- if (typeof data.token === "string" && data.token) {
8504
- return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
8505
- }
8506
- if (isValidEmail(data.email) && typeof data.code === "string") {
8507
- const user = await users.findByEmail(normalizeEmail(data.email));
8508
- if (!user) return { userId: null, via: "code" };
8509
- return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
8510
- }
8511
- return { userId: null, via: "code" };
8512
- };
8513
- const invalidVerification = (c, { via, locked }) => {
8514
- if (locked) {
8515
- return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
8516
- }
8517
- 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.");
8518
- };
8519
8978
  app2.post("/auth/password/signin", async (c) => {
8520
8979
  const data = await body(c);
8521
8980
  const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
@@ -8527,16 +8986,8 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
8527
8986
  await verifyAgainstDummy(pw);
8528
8987
  return wrong();
8529
8988
  }
8530
- const now = Date.now();
8531
- if (account.lockedUntil > now) {
8532
- const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
8533
- return error(c, 429, "too_many_attempts", `Too many failed attempts. Try again in ${seconds} seconds.`);
8534
- }
8535
- const failed = account.failedAttempts + 1;
8536
- const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
8537
- if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
8538
- return error(c, 429, "too_many_attempts", "Too many attempts at once. Please try again.");
8539
- }
8989
+ const refused = await countSignInAttempt(c, users, account);
8990
+ if (refused) return refused;
8540
8991
  const { ok, needsRehash } = await verifyPassword(pw, account.secret);
8541
8992
  if (!ok) return wrong();
8542
8993
  await users.updateAccount(account.id, {
@@ -8547,26 +8998,24 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
8547
8998
  if (!user.emailVerified) {
8548
8999
  return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
8549
9000
  }
8550
- const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
8551
- await ctx.emit("user.signed_in", { user: result, method: "password" });
8552
- return c.json({ user: result });
9001
+ return signInAnswer(c, await ctx.secondStep(c, user, ["pwd"], "password"));
8553
9002
  });
8554
9003
  app2.post("/auth/password/signup", async (c) => {
8555
9004
  const data = await body(c);
8556
- const target = redirectTarget(c, data.redirectTo);
9005
+ const target = redirectTarget(ctx, c, data.redirectTo);
8557
9006
  if (target instanceof Response) return target;
8558
9007
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
8559
9008
  const policy = checkPasswordPolicy(data.password, minLength);
8560
9009
  if (policy) return error(c, 400, "weak_password", policy);
8561
- const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
9010
+ const name = nameOf(data.name);
8562
9011
  const email = data.email.trim();
8563
9012
  const locale = localeOf(data.locale);
8564
9013
  const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
8565
9014
  if (!check2.ok) return check2.reason === "rejected" ? error(c, 403, "signup_rejected", check2.message) : unavailable(c);
8566
9015
  const passwordHash = await hashPassword(data.password);
8567
9016
  const existing = await users.findByEmail(normalizeEmail(email));
8568
- if (existing?.emailVerified) {
8569
- return await sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
9017
+ if (existing?.emailVerified && await users.hasAccounts(existing.id)) {
9018
+ return await emails.sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
8570
9019
  }
8571
9020
  if (existing) {
8572
9021
  if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
@@ -8574,7 +9023,7 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
8574
9023
  if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
8575
9024
  else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
8576
9025
  await users.updateUser(existing.id, { name });
8577
- return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
9026
+ return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
8578
9027
  }
8579
9028
  const user = await users.createUser({
8580
9029
  email,
@@ -8585,41 +9034,19 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
8585
9034
  });
8586
9035
  if (!user) return accepted(c);
8587
9036
  await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
8588
- return await sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
8589
- });
8590
- app2.post("/auth/password/send-verification", async (c) => {
8591
- const data = await body(c);
8592
- const target = redirectTarget(c, data.redirectTo);
8593
- if (target instanceof Response) return target;
8594
- if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
8595
- const user = await users.findByEmail(normalizeEmail(data.email));
8596
- if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
8597
- if (!await sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
8598
- }
8599
- return accepted(c);
8600
- });
8601
- app2.post("/auth/password/verify-email", async (c) => {
8602
- const consumed = await consume(await body(c), "verify");
8603
- const { userId, via } = consumed;
8604
- const user = userId ? await users.findById(userId) : null;
8605
- if (!user) return invalidVerification(c, consumed);
8606
- await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
8607
- const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
8608
- await ctx.emit("email.verified", { user: result, via });
8609
- await ctx.emit("user.signed_in", { user: result, method: "password" });
8610
- return c.json({ user: result });
9037
+ return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
8611
9038
  });
8612
9039
  app2.post("/auth/password/send-reset", async (c) => {
8613
9040
  const data = await body(c);
8614
- const target = redirectTarget(c, data.redirectTo);
9041
+ const target = redirectTarget(ctx, c, data.redirectTo);
8615
9042
  if (target instanceof Response) return target;
8616
9043
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
8617
9044
  const user = await users.findByEmail(normalizeEmail(data.email));
8618
9045
  const locale = localeOf(data.locale);
8619
9046
  if (user && await users.passwordAccount(user.id)) {
8620
- if (!await sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
9047
+ if (!await emails.sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
8621
9048
  } else if (user) {
8622
- if (!await sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
9049
+ if (!await emails.sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
8623
9050
  }
8624
9051
  return accepted(c);
8625
9052
  });
@@ -8627,71 +9054,438 @@ function passwordRoutes(app2, ctx, password, users, webhook) {
8627
9054
  const data = await body(c);
8628
9055
  const policy = checkPasswordPolicy(data.password, minLength);
8629
9056
  if (policy) return error(c, 400, "weak_password", policy);
8630
- const consumed = await consume(data, "reset");
9057
+ const consumed = await emails.consume(data, "reset");
8631
9058
  const user = consumed.userId ? await users.findById(consumed.userId) : null;
8632
9059
  const account = user ? await users.passwordAccount(user.id) : null;
8633
- if (!user || !account) return invalidVerification(c, consumed);
9060
+ if (!user || !account) return emails.invalidVerification(c, consumed);
8634
9061
  const secret = await hashPassword(data.password);
8635
- if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return invalidVerification(c, consumed);
9062
+ if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return emails.invalidVerification(c, consumed);
8636
9063
  const sessionVersion = user.sessionVersion + 1;
8637
9064
  await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
8638
9065
  await users.clearVerifications(user.id);
8639
- const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
8640
- await ctx.emit("email.password_reset", { user: result });
8641
- await ctx.emit("user.signed_in", { user: result, method: "password" });
8642
- return c.json({ user: result });
9066
+ const reset = { ...user, emailVerified: true, sessionVersion, wrongCodes: 0 };
9067
+ await ctx.emit("email.password_reset", { user: toMadauthUser(reset) });
9068
+ return signInAnswer(c, await ctx.secondStep(c, reset, ["pwd"], "password"));
8643
9069
  });
8644
9070
  }
8645
9071
 
8646
- // src/app.ts
8647
- var EVENT_TIMEOUT_MS = 5e3;
8648
- function createApp(config) {
8649
- const { issuer, allowedOrigins, sessionTtlSeconds, renewalTtlSeconds, cookieDomain } = config;
8650
- const keys = importSigningKeys(config.signingKey);
8651
- const secure = issuer.startsWith("https:");
8652
- const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
8653
- const users = new Users(config.store, deriveCodeKey(config.signingKey.d));
8654
- const settings = new Settings(config.store);
8655
- const available = (method) => method === "google" ? !!config.google : !!config.password;
8656
- const issueSession = async (c, user, amr, extra) => {
8657
- const signingKeys = await keys;
8658
- const [sessionToken, renewalToken] = await Promise.all([
8659
- signSession(signingKeys, issuer, sessionTtlSeconds, user, amr, extra),
8660
- signRenewal(signingKeys, issuer, renewalTtlSeconds, user, amr, extra)
8661
- ]);
8662
- setCookie(c, SESSION_COOKIE, sessionToken, {
8663
- path: "/",
8664
- domain: cookieDomain,
8665
- httpOnly: true,
8666
- secure,
8667
- sameSite: "Lax",
8668
- maxAge: sessionTtlSeconds
8669
- });
8670
- setCookie(c, RENEWAL_COOKIE, renewalToken, {
8671
- path: "/auth",
8672
- httpOnly: true,
8673
- secure,
8674
- sameSite: "Lax",
8675
- maxAge: renewalTtlSeconds
8676
- });
8677
- setCookie(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
8678
- path: "/",
8679
- domain: cookieDomain,
8680
- secure,
8681
- sameSite: "Lax",
8682
- maxAge: renewalTtlSeconds
8683
- });
8684
- };
8685
- const clearSession = (c) => {
8686
- deleteCookie(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
8687
- deleteCookie(c, RENEWAL_COOKIE, { path: "/auth", secure });
8688
- deleteCookie(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
8689
- };
8690
- const ctx = {
8691
- config,
8692
- keys,
8693
- secure,
8694
- isAllowedOrigin,
9072
+ // src/totp.ts
9073
+ import { createCipheriv, createDecipheriv, createHmac as createHmac3, hkdfSync as hkdfSync2, randomBytes as randomBytes4, randomInt as randomInt2 } from "crypto";
9074
+ var TOTP_STEP_SECONDS = 30;
9075
+ var TOTP_DIGITS = 6;
9076
+ var TOTP_WINDOW = 1;
9077
+ var RECOVERY_CODES = 10;
9078
+ var SECRET_BYTES = 20;
9079
+ var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
9080
+ var RECOVERY_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
9081
+ var RECOVERY_CODE_LENGTH = 10;
9082
+ var ENCRYPTION_VERSION = "v1";
9083
+ var IV_BYTES = 12;
9084
+ var TAG_BYTES = 16;
9085
+ function base32Encode(bytes) {
9086
+ let bits = 0;
9087
+ let value = 0;
9088
+ let out = "";
9089
+ for (const byte of bytes) {
9090
+ value = (value << 8 | byte) & 65535;
9091
+ bits += 8;
9092
+ while (bits >= 5) {
9093
+ out += BASE32[value >>> bits - 5 & 31];
9094
+ bits -= 5;
9095
+ }
9096
+ }
9097
+ if (bits > 0) out += BASE32[value << 5 - bits & 31];
9098
+ return out;
9099
+ }
9100
+ function hotp(secret, counter, digits = TOTP_DIGITS) {
9101
+ const message2 = Buffer.alloc(8);
9102
+ message2.writeBigUInt64BE(BigInt(counter));
9103
+ const digest2 = createHmac3("sha1", secret).update(message2).digest();
9104
+ const offset = digest2[digest2.length - 1] & 15;
9105
+ const binary = (digest2[offset] & 127) << 24 | digest2[offset + 1] << 16 | digest2[offset + 2] << 8 | digest2[offset + 3];
9106
+ return (binary % 10 ** digits).toString().padStart(digits, "0");
9107
+ }
9108
+ function stepOf(nowMs) {
9109
+ return Math.floor(nowMs / 1e3 / TOTP_STEP_SECONDS);
9110
+ }
9111
+ function totp(secret, step, digits = TOTP_DIGITS) {
9112
+ return hotp(secret, step, digits);
9113
+ }
9114
+ function verifyTotp(secret, code, nowMs, window = TOTP_WINDOW) {
9115
+ const given = code.replace(/\s/g, "");
9116
+ if (!new RegExp(`^\\d{${TOTP_DIGITS}}$`).test(given)) return null;
9117
+ const now = stepOf(nowMs);
9118
+ let found = null;
9119
+ for (let step = now - window; step <= now + window; step++) {
9120
+ if (safeEqual(totp(secret, step), given) && found === null) found = step;
9121
+ }
9122
+ return found;
9123
+ }
9124
+ function generateTotpSecret() {
9125
+ return randomBytes4(SECRET_BYTES);
9126
+ }
9127
+ function otpauthUri(options) {
9128
+ const issuer = encodeURIComponent(options.issuer);
9129
+ const account = encodeURIComponent(options.account);
9130
+ const secret = base32Encode(options.secret);
9131
+ return `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_STEP_SECONDS}`;
9132
+ }
9133
+ function deriveTotpKey(signingKeyD) {
9134
+ return Buffer.from(hkdfSync2("sha256", Buffer.from(signingKeyD, "base64url"), "", "madauth totp secret", 32));
9135
+ }
9136
+ function encryptSecret(key, secret) {
9137
+ const iv = randomBytes4(IV_BYTES);
9138
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
9139
+ const encrypted = Buffer.concat([cipher.update(secret), cipher.final()]);
9140
+ const parts = [iv, encrypted, cipher.getAuthTag()].map((part) => part.toString("base64url"));
9141
+ return [ENCRYPTION_VERSION, ...parts].join(".");
9142
+ }
9143
+ function decryptSecret(key, stored) {
9144
+ const [version, iv, encrypted, tag2, ...rest] = stored.split(".");
9145
+ if (version !== ENCRYPTION_VERSION || !iv || !encrypted || !tag2 || rest.length) return null;
9146
+ const ivBytes = Buffer.from(iv, "base64url");
9147
+ const tagBytes = Buffer.from(tag2, "base64url");
9148
+ if (ivBytes.length !== IV_BYTES || tagBytes.length !== TAG_BYTES) return null;
9149
+ try {
9150
+ const decipher = createDecipheriv("aes-256-gcm", key, ivBytes);
9151
+ decipher.setAuthTag(tagBytes);
9152
+ return Buffer.concat([decipher.update(Buffer.from(encrypted, "base64url")), decipher.final()]);
9153
+ } catch {
9154
+ return null;
9155
+ }
9156
+ }
9157
+ function generateRecoveryCodes() {
9158
+ const codes = /* @__PURE__ */ new Set();
9159
+ while (codes.size < RECOVERY_CODES) {
9160
+ let code = "";
9161
+ for (let i = 0; i < RECOVERY_CODE_LENGTH; i++) code += RECOVERY_ALPHABET[randomInt2(RECOVERY_ALPHABET.length)];
9162
+ codes.add(`${code.slice(0, 5)}-${code.slice(5)}`);
9163
+ }
9164
+ return [...codes];
9165
+ }
9166
+ function normalizeRecoveryCode(text) {
9167
+ if (typeof text !== "string") return null;
9168
+ const code = text.toLowerCase().replace(/[\s-]/g, "");
9169
+ return code.length === RECOVERY_CODE_LENGTH && [...code].every((char) => RECOVERY_ALPHABET.includes(char)) ? code : null;
9170
+ }
9171
+ function hashRecoveryCode(codeKey, code) {
9172
+ return hashCode(codeKey, `recovery:${code}`);
9173
+ }
9174
+
9175
+ // src/routes/totp.ts
9176
+ var CHALLENGE_COOKIE = "madauth_challenge";
9177
+ var CHALLENGE_TYP = "madauth-challenge+jwt";
9178
+ var CHALLENGE_TTL = 10 * 60;
9179
+ var SETUP_COOKIE = "madauth_totp_setup";
9180
+ var SETUP_TYP = "madauth-totp-setup+jwt";
9181
+ var SETUP_TTL = 10 * 60;
9182
+ var COOKIE_PATH = "/auth/totp";
9183
+ var cookieOptions = (secure) => ({ path: COOKIE_PATH, httpOnly: true, secure, sameSite: "Strict" });
9184
+ async function setChallenge(c, ctx, challenge) {
9185
+ const token = await signToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, { ...challenge }, CHALLENGE_TTL);
9186
+ setCookie(c, CHALLENGE_COOKIE, token, { ...cookieOptions(ctx.secure), maxAge: CHALLENGE_TTL });
9187
+ }
9188
+ function clearChallenge(c, secure) {
9189
+ deleteCookie(c, CHALLENGE_COOKIE, cookieOptions(secure));
9190
+ }
9191
+ async function readChallenge(c, ctx, next) {
9192
+ const claims = await readToken(await ctx.keys, ctx.config.issuer, CHALLENGE_TYP, getCookie(c, CHALLENGE_COOKIE));
9193
+ return claims && claims.next === next && typeof claims.sub === "string" && Array.isArray(claims.amr) ? claims : null;
9194
+ }
9195
+ async function checkCode(c, ctx, keys, user, account, data, wrong = () => error(c, 401, "code_invalid", "The code is wrong or was already used.")) {
9196
+ const { users } = ctx;
9197
+ const refused = await countSignInAttempt(c, users, account);
9198
+ if (refused) return refused;
9199
+ if (typeof data.recoveryCode === "string") {
9200
+ const code = normalizeRecoveryCode(data.recoveryCode);
9201
+ if (!code || !await users.consumeRecoveryCode(account, hashRecoveryCode(keys.codeKey, code))) return wrong();
9202
+ await users.updateAccount(account.id, { failedAttempts: 0, lockedUntil: 0 });
9203
+ const remaining = await users.countRecoveryCodes(account);
9204
+ await ctx.emit("totp.recovery_code_used", { user: toMadauthUser(user), remaining });
9205
+ return "recovery_code";
9206
+ }
9207
+ if (typeof data.code !== "string" || !account.secret) return wrong();
9208
+ const secret = decryptSecret(keys.totpKey, account.secret);
9209
+ if (!secret) {
9210
+ console.error(`[madauth] The authenticator secret of user ${user.id} can't be read: was MADAUTH_SIGNING_KEY changed?`);
9211
+ return wrong();
9212
+ }
9213
+ const step = verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW);
9214
+ if (step === null || step <= (account.lastUsedStep ?? 0) || !await users.useTotpStep(account, step)) return wrong();
9215
+ return "totp";
9216
+ }
9217
+ function totpRoutes(app2, ctx, keys) {
9218
+ const { config, secure, users } = ctx;
9219
+ const { issuer } = config;
9220
+ const cookie = cookieOptions(secure);
9221
+ const methodsNow = (c) => ctx.methods(c);
9222
+ app2.use(`${COOKIE_PATH}/*`, async (c, next) => {
9223
+ if (!authenticatorOn(ctx.configured, await methodsNow(c))) {
9224
+ return error(c, 403, "method_disabled", "The authenticator app is switched off.");
9225
+ }
9226
+ await next();
9227
+ });
9228
+ const standalone = async (c) => isOn("totp", ctx.configured, await methodsNow(c));
9229
+ const notStandalone = (c) => error(c, 403, "method_disabled", "Signing in with the authenticator app alone is switched off.");
9230
+ const methodStillOn = async (c, challenge) => isOn(challenge.method, ctx.configured, await methodsNow(c));
9231
+ const methodOff = (c) => error(c, 403, "method_disabled", "This way of signing in is switched off.");
9232
+ const signedIn = async (c) => {
9233
+ const user = await ctx.currentUser(c);
9234
+ return user ? users.findById(user.id) : null;
9235
+ };
9236
+ const whoSetsUp = async (c) => {
9237
+ const challenge = await readChallenge(c, ctx, "totp-setup");
9238
+ const waiting = challenge ? await users.findById(challenge.sub) : null;
9239
+ if (challenge && waiting && waiting.sessionVersion === challenge.sv) return { user: waiting, challenge };
9240
+ const user = await signedIn(c);
9241
+ return user ? { user, challenge: null } : null;
9242
+ };
9243
+ const newRecoveryCodes = () => {
9244
+ const recoveryCodes = generateRecoveryCodes();
9245
+ return { recoveryCodes, hashes: recoveryCodes.map((code) => hashRecoveryCode(keys.codeKey, normalizeRecoveryCode(code))) };
9246
+ };
9247
+ const issuerLabel = (c) => {
9248
+ if (config.totpIssuer) return config.totpIssuer;
9249
+ const origin = c.req.header("origin");
9250
+ return URL.canParse(origin ?? "") ? new URL(origin).hostname : new URL(issuer).hostname;
9251
+ };
9252
+ const finishSignIn = async (c, user, challenge, via) => {
9253
+ const result = await ctx.startSession(c, toMadauthUser(user, challenge.profile), [...challenge.amr, "otp"], { sv: user.sessionVersion });
9254
+ await ctx.emit("user.signed_in", { user: result, method: challenge.method, ...challenge.amr.length ? { secondFactor: via } : {} });
9255
+ return result;
9256
+ };
9257
+ app2.post(`${COOKIE_PATH}/setup`, async (c) => {
9258
+ const who = await whoSetsUp(c);
9259
+ if (!who) return noSession(c);
9260
+ if (who.challenge && !await methodStillOn(c, who.challenge)) return methodOff(c);
9261
+ const existing = await users.totpAccount(who.user.id);
9262
+ let approved;
9263
+ if (existing) {
9264
+ const previous = await readToken(await ctx.keys, issuer, SETUP_TYP, getCookie(c, SETUP_COOKIE));
9265
+ const holds = previous?.sub === who.user.id && typeof previous.approved === "number" && Date.now() - previous.approved < SETUP_TTL * 1e3;
9266
+ if (holds) {
9267
+ approved = previous.approved;
9268
+ } else {
9269
+ const data = await body(c);
9270
+ if (data.code === void 0 && data.recoveryCode === void 0) {
9271
+ return error(c, 401, "code_required", "Enter a code from your current authenticator app (or a recovery code) to replace it.");
9272
+ }
9273
+ const via = await checkCode(c, ctx, keys, who.user, existing, data);
9274
+ if (via instanceof Response) return via;
9275
+ approved = Date.now();
9276
+ }
9277
+ }
9278
+ const secret = generateTotpSecret();
9279
+ const claims = {
9280
+ sub: who.user.id,
9281
+ setup: newId("tsu"),
9282
+ secret: encryptSecret(keys.totpKey, secret),
9283
+ ...existing ? { replaces: existing.setupId } : {},
9284
+ ...approved ? { approved } : {}
9285
+ };
9286
+ const token = await signToken(await ctx.keys, issuer, SETUP_TYP, claims, SETUP_TTL);
9287
+ setCookie(c, SETUP_COOKIE, token, { ...cookie, maxAge: SETUP_TTL });
9288
+ return c.json({ secret: base32Encode(secret), uri: otpauthUri({ issuer: issuerLabel(c), account: who.user.email, secret }) });
9289
+ });
9290
+ app2.post(`${COOKIE_PATH}/confirm`, async (c) => {
9291
+ const who = await whoSetsUp(c);
9292
+ if (!who) return noSession(c);
9293
+ const { user, challenge } = who;
9294
+ if (challenge && !await methodStillOn(c, challenge)) return methodOff(c);
9295
+ const data = await body(c);
9296
+ const setup = await readToken(await ctx.keys, issuer, SETUP_TYP, getCookie(c, SETUP_COOKIE));
9297
+ const secret = setup && setup.sub === user.id && typeof setup.secret === "string" && typeof setup.setup === "string" ? decryptSecret(keys.totpKey, setup.secret) : null;
9298
+ if (!setup || !secret) return error(c, 400, "setup_expired", "The setup has expired or was started in another browser. Please start again.");
9299
+ const step = typeof data.code === "string" ? verifyTotp(secret, data.code, Date.now(), TOTP_WINDOW) : null;
9300
+ if (step === null) return error(c, 400, "code_invalid", "The code is wrong. Enter the current code from the app.");
9301
+ const { recoveryCodes, hashes } = newRecoveryCodes();
9302
+ await users.addRecoveryCodes(user.id, setup.setup, hashes);
9303
+ const patch = { secret: setup.secret, setupId: setup.setup, failedAttempts: 0, lockedUntil: 0, lastUsedStep: step };
9304
+ const current = setup.replaces === void 0 ? null : await users.totpAccount(user.id);
9305
+ 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);
9306
+ if (!written) {
9307
+ await users.clearRecoveryCodes(user.id, { only: setup.setup });
9308
+ deleteCookie(c, SETUP_COOKIE, cookie);
9309
+ return error(c, 400, "setup_expired", "Your authenticator app was set up or removed elsewhere meanwhile. Please start again.");
9310
+ }
9311
+ await users.clearRecoveryCodes(user.id, { except: setup.setup });
9312
+ deleteCookie(c, SETUP_COOKIE, cookie);
9313
+ await ctx.emit("totp.enabled", { user: toMadauthUser(user) });
9314
+ if (!challenge) return c.json({ recoveryCodes });
9315
+ const result = await finishSignIn(c, user, challenge, "totp");
9316
+ return c.json({ user: result, recoveryCodes });
9317
+ });
9318
+ app2.post(`${COOKIE_PATH}/verify`, async (c) => {
9319
+ const expired = () => error(c, 401, "challenge_expired", "The sign-in has expired. Please sign in again.");
9320
+ const challenge = await readChallenge(c, ctx, "totp");
9321
+ if (!challenge) return expired();
9322
+ if (!await methodStillOn(c, challenge)) return methodOff(c);
9323
+ const user = await users.findById(challenge.sub);
9324
+ const account = user && user.sessionVersion === challenge.sv ? await users.totpAccount(user.id) : null;
9325
+ if (!user || !account) return expired();
9326
+ const via = await checkCode(c, ctx, keys, user, account, await body(c));
9327
+ if (via instanceof Response) return via;
9328
+ return c.json({ user: await finishSignIn(c, user, challenge, via) });
9329
+ });
9330
+ app2.post(`${COOKIE_PATH}/signin`, async (c) => {
9331
+ if (!await standalone(c)) return notStandalone(c);
9332
+ const data = await body(c);
9333
+ const wrong = () => error(c, 401, "invalid_credentials", "E-mail or code is wrong.");
9334
+ if (!isValidEmail(data.email)) return wrong();
9335
+ const user = await users.findByEmail(normalizeEmail(data.email));
9336
+ const account = user ? await users.totpAccount(user.id) : null;
9337
+ if (!user || !user.emailVerified || !account?.secret) return wrong();
9338
+ const via = await checkCode(c, ctx, keys, user, account, data, wrong);
9339
+ if (via instanceof Response) return via;
9340
+ const result = await ctx.startSession(c, toMadauthUser(user), ["otp"], { sv: user.sessionVersion });
9341
+ await ctx.emit("user.signed_in", { user: result, method: "totp" });
9342
+ return c.json({ user: result });
9343
+ });
9344
+ app2.post(`${COOKIE_PATH}/remove`, async (c) => {
9345
+ const user = await signedIn(c);
9346
+ if (!user) return noSession(c);
9347
+ const methods = await methodsNow(c);
9348
+ const required = METHODS_WITH_SECOND_FACTOR.filter((method) => isOn(method, ctx.configured, methods) && policyOf(method, methods) === "required");
9349
+ if (required.length) {
9350
+ const names = required.map((method) => method === "google" ? "Google" : "e-mail & password");
9351
+ return error(c, 403, "required_by_policy", `Signing in with ${names.join(" and ")} requires the authenticator app, so it can't be removed.`);
9352
+ }
9353
+ const account = await users.totpAccount(user.id);
9354
+ if (!account) return c.json({});
9355
+ if (!(await users.signInMethods(user.id, methods)).some((method) => method !== "totp" && isOn(method, ctx.configured, methods))) {
9356
+ 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.");
9357
+ }
9358
+ const via = await checkCode(c, ctx, keys, user, account, await body(c));
9359
+ if (via instanceof Response) return via;
9360
+ await users.removeTotp(user.id);
9361
+ await ctx.emit("totp.disabled", { user: toMadauthUser(user) });
9362
+ return c.json({});
9363
+ });
9364
+ app2.post(`${COOKIE_PATH}/recovery-codes`, async (c) => {
9365
+ const user = await signedIn(c);
9366
+ if (!user) return noSession(c);
9367
+ const account = await users.totpAccount(user.id);
9368
+ if (!account) return error(c, 400, "no_authenticator", "No authenticator app is set up.");
9369
+ const via = await checkCode(c, ctx, keys, user, account, await body(c));
9370
+ if (via instanceof Response) return via;
9371
+ const { recoveryCodes, hashes } = newRecoveryCodes();
9372
+ await users.replaceRecoveryCodes(account, hashes);
9373
+ return c.json({ recoveryCodes });
9374
+ });
9375
+ app2.get(`${COOKIE_PATH}/status`, async (c) => {
9376
+ const user = await signedIn(c);
9377
+ if (!user) return noSession(c);
9378
+ const account = await users.totpAccount(user.id);
9379
+ return c.json({ enabled: !!account, recoveryCodesLeft: account ? await users.countRecoveryCodes(account) : 0 });
9380
+ });
9381
+ if (ctx.webhook && ctx.emailVerification) {
9382
+ const emails = createEmails(ctx, users, ctx.webhook);
9383
+ app2.post(`${COOKIE_PATH}/signup`, async (c) => {
9384
+ if (!await standalone(c)) return notStandalone(c);
9385
+ const data = await body(c);
9386
+ const target = redirectTarget(ctx, c, data.redirectTo);
9387
+ if (target instanceof Response) return target;
9388
+ if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
9389
+ const name = nameOf(data.name);
9390
+ const email = data.email.trim();
9391
+ const locale = localeOf(data.locale);
9392
+ const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "totp" });
9393
+ if (!check2.ok) return check2.reason === "rejected" ? error(c, 403, "signup_rejected", check2.message) : unavailable(c);
9394
+ const existing = await users.findByEmail(normalizeEmail(email));
9395
+ if (existing?.emailVerified && await users.hasAccounts(existing.id)) {
9396
+ return await emails.sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
9397
+ }
9398
+ if (existing) {
9399
+ if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
9400
+ if (!existing.emailVerified) await users.unlinkAccount(passwordAccountKey(existing.id));
9401
+ await users.updateUser(existing.id, { name });
9402
+ return await emails.sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
9403
+ }
9404
+ const user = await users.createUser({ email, emailNormalized: normalizeEmail(email), name, emailVerified: false });
9405
+ if (!user) return accepted(c);
9406
+ await ctx.emit("user.created", { user: toMadauthUser(user), method: "totp" });
9407
+ return await emails.sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
9408
+ });
9409
+ }
9410
+ }
9411
+
9412
+ // src/app.ts
9413
+ var EVENT_TIMEOUT_MS = 5e3;
9414
+ function createApp(config) {
9415
+ const { issuer, allowedOrigins, sessionTtlSeconds, renewalTtlSeconds, cookieDomain } = config;
9416
+ const keys = importSigningKeys(config.signingKey);
9417
+ const secure = issuer.startsWith("https:");
9418
+ const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
9419
+ const codeKey = deriveCodeKey(config.signingKey.d);
9420
+ const users = new Users(config.store, codeKey);
9421
+ const methods = methodConfig(config);
9422
+ const settings = new Settings(config.store, initialMethods(methods));
9423
+ const configured = (method) => isConfigured(methods, method);
9424
+ const signsUp = (method) => canSignUp(methods, method);
9425
+ const emailVerification = sendsVerificationEmails(config);
9426
+ const totpKeys = { totpKey: deriveTotpKey(config.signingKey.d), codeKey };
9427
+ const issueSession = async (c, user, amr, extra) => {
9428
+ const signingKeys = await keys;
9429
+ const [sessionToken, renewalToken] = await Promise.all([
9430
+ signSession(signingKeys, issuer, sessionTtlSeconds, user, amr, extra),
9431
+ signRenewal(signingKeys, issuer, renewalTtlSeconds, user, amr, extra)
9432
+ ]);
9433
+ setCookie(c, SESSION_COOKIE, sessionToken, {
9434
+ path: "/",
9435
+ domain: cookieDomain,
9436
+ httpOnly: true,
9437
+ secure,
9438
+ sameSite: "Lax",
9439
+ maxAge: sessionTtlSeconds
9440
+ });
9441
+ setCookie(c, RENEWAL_COOKIE, renewalToken, {
9442
+ path: "/auth",
9443
+ httpOnly: true,
9444
+ secure,
9445
+ sameSite: "Lax",
9446
+ maxAge: renewalTtlSeconds
9447
+ });
9448
+ setCookie(c, EXPIRY_COOKIE, String(Math.floor(Date.now() / 1e3) + sessionTtlSeconds), {
9449
+ path: "/",
9450
+ domain: cookieDomain,
9451
+ secure,
9452
+ sameSite: "Lax",
9453
+ maxAge: renewalTtlSeconds
9454
+ });
9455
+ };
9456
+ const clearSession = (c) => {
9457
+ deleteCookie(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
9458
+ deleteCookie(c, RENEWAL_COOKIE, { path: "/auth", secure });
9459
+ deleteCookie(c, EXPIRY_COOKIE, { path: "/", domain: cookieDomain, secure });
9460
+ };
9461
+ const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c, cookie));
9462
+ const checked = async (claims) => {
9463
+ const stored = await users.findById(claims.sub);
9464
+ if (!stored || stored.sessionVersion !== claims.sv) return null;
9465
+ const { claims: _, ...user } = userFromClaims(claims);
9466
+ const current = claimsFromJson(stored.claims);
9467
+ return Object.keys(current).length ? { ...user, claims: current } : user;
9468
+ };
9469
+ const currentSession = async (c) => {
9470
+ const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
9471
+ const user = session ? await checked(session) : null;
9472
+ const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
9473
+ if (upToDate) return user;
9474
+ const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
9475
+ const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
9476
+ const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
9477
+ if (renewal && renewed) {
9478
+ await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
9479
+ return renewed;
9480
+ }
9481
+ return user;
9482
+ };
9483
+ const methodsOf = /* @__PURE__ */ new WeakMap();
9484
+ const ctx = {
9485
+ config,
9486
+ keys,
9487
+ secure,
9488
+ isAllowedOrigin,
8695
9489
  allowedUrl(value) {
8696
9490
  let url;
8697
9491
  try {
@@ -8704,14 +9498,42 @@ function createApp(config) {
8704
9498
  return url;
8705
9499
  },
8706
9500
  async startSession(c, user, amr, extra) {
9501
+ clearChallenge(c, secure);
8707
9502
  await issueSession(c, user, amr, extra);
8708
- return user;
9503
+ return { ...user, amr };
9504
+ },
9505
+ async secondStep(c, user, amr, method, profile) {
9506
+ const methods2 = await this.methods(c);
9507
+ if (!isOn(method, configured, methods2)) return { disabled: true };
9508
+ const enrolled = await users.totpAccount(user.id);
9509
+ const policy = method === "totp" ? "required" : policyOf(method, methods2);
9510
+ const next = policy === "none" ? null : enrolled ? "totp" : policy === "required" ? "totp-setup" : null;
9511
+ if (next) {
9512
+ clearSession(c);
9513
+ const known = { ...profile?.name ? { name: profile.name } : {}, ...profile?.picture ? { picture: profile.picture } : {} };
9514
+ await setChallenge(c, this, { sub: user.id, sv: user.sessionVersion, amr, method, next, ...Object.keys(known).length ? { profile: known } : {} });
9515
+ return { next, method };
9516
+ }
9517
+ const result = await this.startSession(c, toMadauthUser(user, profile), amr, { sv: user.sessionVersion });
9518
+ await this.emit("user.signed_in", { user: result, method });
9519
+ return { user: result };
8709
9520
  },
9521
+ currentUser: currentSession,
8710
9522
  users,
8711
9523
  settings,
8712
9524
  webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
8713
- async enabled(method) {
8714
- return isOn(method, available, await settings.methods());
9525
+ configured,
9526
+ emailVerification,
9527
+ methods(c) {
9528
+ let reading = methodsOf.get(c.req.raw);
9529
+ if (!reading) {
9530
+ reading = settings.methods();
9531
+ methodsOf.set(c.req.raw, reading);
9532
+ }
9533
+ return reading;
9534
+ },
9535
+ async enabled(c, method) {
9536
+ return isOn(method, configured, await this.methods(c));
8715
9537
  },
8716
9538
  async checkSignup(data) {
8717
9539
  if (!this.webhook?.wants("signup.before")) return { ok: true };
@@ -8729,7 +9551,9 @@ function createApp(config) {
8729
9551
  },
8730
9552
  async emit(type, data) {
8731
9553
  if (!this.webhook?.wants(type)) return;
8732
- const result = await this.webhook.call(type, data, EVENT_TIMEOUT_MS);
9554
+ const user = data.user;
9555
+ const payload = user && typeof user === "object" && "amr" in user ? { ...data, user: { ...user, amr: void 0 } } : data;
9556
+ const result = await this.webhook.call(type, payload, EVENT_TIMEOUT_MS);
8733
9557
  if (!result.ok) console.error(`[madauth] Webhook "${type}" failed: ${result.reason}`);
8734
9558
  }
8735
9559
  };
@@ -8757,37 +9581,20 @@ function createApp(config) {
8757
9581
  return c.json({ keys: [publicJwk] });
8758
9582
  });
8759
9583
  app2.get("/auth/config", async (c) => {
8760
- const methods = await settings.methods();
8761
- const on = (method) => isOn(method, available, methods);
9584
+ const current = await ctx.methods(c);
9585
+ const on = (method) => isOn(method, configured, current);
8762
9586
  return c.json({
8763
- google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
8764
- password: on("password") ? { minLength: config.password.minLength } : null
9587
+ google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret, secondFactor: policyOf("google", current) } : null,
9588
+ password: on("password") ? { minLength: config.password.minLength, secondFactor: policyOf("password", current) } : null,
9589
+ // The app can be set up when it is in use at all; `signIn` says whether it signs in on its own.
9590
+ totp: authenticatorOn(configured, current) ? { signIn: on("totp"), signUp: on("totp") && emailVerification } : null,
9591
+ email: { verification: emailVerification }
8765
9592
  });
8766
9593
  });
9594
+ if (emailVerification && ctx.webhook) emailRoutes(app2, ctx, users, ctx.webhook);
8767
9595
  if (config.google) googleRoutes(app2, ctx, config.google);
8768
9596
  if (config.password && ctx.webhook) passwordRoutes(app2, ctx, config.password, users, ctx.webhook);
8769
- const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c, cookie));
8770
- const checked = async (claims) => {
8771
- const stored = await users.findById(claims.sub);
8772
- if (!stored || stored.sessionVersion !== claims.sv) return null;
8773
- const { claims: _, ...user } = userFromClaims(claims);
8774
- const current = claimsFromJson(stored.claims);
8775
- return Object.keys(current).length ? { ...user, claims: current } : user;
8776
- };
8777
- const currentSession = async (c) => {
8778
- const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
8779
- const user = session ? await checked(session) : null;
8780
- const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
8781
- if (upToDate) return user;
8782
- const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
8783
- const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
8784
- const renewed = sameSignIn ? user : renewal ? await checked(renewal) : null;
8785
- if (renewal && renewed) {
8786
- await issueSession(c, renewed, renewal.amr, { sv: renewal.sv });
8787
- return renewed;
8788
- }
8789
- return user;
8790
- };
9597
+ totpRoutes(app2, ctx, totpKeys);
8791
9598
  app2.get("/auth/session", async (c) => {
8792
9599
  const user = await currentSession(c);
8793
9600
  if (!user) {
@@ -8798,11 +9605,23 @@ function createApp(config) {
8798
9605
  });
8799
9606
  app2.post("/auth/logout", (c) => {
8800
9607
  clearSession(c);
9608
+ clearChallenge(c, secure);
8801
9609
  return c.body(null, 204);
8802
9610
  });
8803
9611
  app2.post("/auth/account/delete", async (c) => {
8804
9612
  const user = await currentSession(c);
8805
9613
  if (!user) return c.json({ error: "no_session" }, 401);
9614
+ const stored = await users.findById(user.id);
9615
+ const protects = stored && authenticatorOn(configured, await ctx.methods(c));
9616
+ const authenticator = protects ? await users.totpAccount(user.id) : null;
9617
+ if (stored && authenticator) {
9618
+ const data = await body(c);
9619
+ if (data.code === void 0 && data.recoveryCode === void 0) {
9620
+ return error(c, 401, "code_required", "Enter a code from your authenticator app (or a recovery code) to delete your account.");
9621
+ }
9622
+ const via = await checkCode(c, ctx, totpKeys, stored, authenticator, data);
9623
+ if (via instanceof Response) return via;
9624
+ }
8806
9625
  await users.deleteById(user.id);
8807
9626
  clearSession(c);
8808
9627
  await ctx.emit("user.deleted", { user });
@@ -8818,10 +9637,6 @@ function createApp(config) {
8818
9637
  }
8819
9638
  return { email };
8820
9639
  };
8821
- const body = async (c) => {
8822
- const data = await c.req.json().catch(() => null);
8823
- return data && typeof data === "object" ? data : {};
8824
- };
8825
9640
  const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
8826
9641
  const noUser = (c) => c.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
8827
9642
  app2.post("/auth/admin/claims/get", async (c) => {
@@ -8855,9 +9670,16 @@ function createApp(config) {
8855
9670
  await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
8856
9671
  return c.json({ email, userId: user.id, claims });
8857
9672
  });
8858
- const settingsAnswer = (methods) => ({
9673
+ const settingsAnswer = (methods2) => ({
8859
9674
  methods: Object.fromEntries(
8860
- SIGN_IN_METHODS.map((method) => [method, { available: available(method), enabled: isOn(method, available, methods) }])
9675
+ SIGN_IN_METHODS.map((method) => [
9676
+ method,
9677
+ {
9678
+ configured: configured(method),
9679
+ enabled: isOn(method, configured, methods2),
9680
+ ...method === "totp" ? {} : { secondFactor: policyOf(method, methods2) }
9681
+ }
9682
+ ])
8861
9683
  )
8862
9684
  });
8863
9685
  app2.post("/auth/admin/settings/get", async (c) => {
@@ -8870,212 +9692,30 @@ function createApp(config) {
8870
9692
  if ("answer" in caller) return caller.answer;
8871
9693
  const data = await body(c);
8872
9694
  const invalid2 = (message2) => c.json({ error: "invalid_settings", message: message2 }, 400);
8873
- const changes = parseMethodSettings(data.methods);
8874
- if (!changes) return invalid2(`methods must be an object with true or false for ${SIGN_IN_METHODS.join(" and/or ")}.`);
8875
- const methods = await settings.changeMethods((current) => {
8876
- const next = { ...current, ...changes };
8877
- return anyOn(available, next) ? next : null;
8878
- }, caller.email);
8879
- if (!methods) return invalid2("At least one sign-in method must stay on.");
8880
- return c.json(settingsAnswer(methods));
9695
+ const next = parseMethodSettings(data.methods, { booleans: true });
9696
+ if (!next) {
9697
+ return invalid2(
9698
+ `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.`
9699
+ );
9700
+ }
9701
+ const methods2 = await settings.changeMethods(() => anyOn(signsUp, next) ? next : null, caller.email);
9702
+ 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.");
9703
+ return c.json(settingsAnswer(methods2));
9704
+ });
9705
+ app2.post("/auth/admin/totp/remove", async (c) => {
9706
+ const caller = await admin(c);
9707
+ if ("answer" in caller) return caller.answer;
9708
+ const data = await body(c);
9709
+ if (!isValidEmail(data.email)) return invalidEmail(c);
9710
+ const user = await users.findByEmail(normalizeEmail(data.email));
9711
+ if (!user) return noUser(c);
9712
+ const removed = await users.removeTotp(user.id);
9713
+ if (removed) await ctx.emit("totp.disabled", { user: toMadauthUser(user), by: caller.email });
9714
+ return c.json({ email: user.emailNormalized, userId: user.id, enabled: false });
8881
9715
  });
8882
9716
  return app2;
8883
9717
  }
8884
9718
 
8885
- // src/config.ts
8886
- async function resolveEnv(source = process.env) {
8887
- return typeof source === "function" ? source() : source;
8888
- }
8889
- var DEFAULT_SESSION_TTL = 8 * 60 * 60;
8890
- var DEFAULT_RENEWAL_TTL = 30 * 24 * 60 * 60;
8891
- var DEFAULT_PASSWORD_MIN_LENGTH = 8;
8892
- var ConfigError = class extends Error {
8893
- constructor() {
8894
- super(...arguments);
8895
- this.name = "ConfigError";
8896
- }
8897
- };
8898
- function parseOrigin(name, value) {
8899
- let url;
8900
- try {
8901
- url = new URL(value);
8902
- } catch {
8903
- throw new ConfigError(`${name}: "${value}" is not a URL.`);
8904
- }
8905
- if (url.protocol !== "https:" && url.protocol !== "http:") {
8906
- throw new ConfigError(`${name}: "${value}" must be an http(s) URL.`);
8907
- }
8908
- return url.origin;
8909
- }
8910
- function readEnv(env, name) {
8911
- return env[name]?.trim().replace(/^(['"])(.*)\1$/s, "$2").trim() || void 0;
8912
- }
8913
- async function loadConfig(env, overrides = {}) {
8914
- const read = (name) => readEnv(env, name);
8915
- const require2 = (name) => {
8916
- const value = read(name);
8917
- if (!value) throw new ConfigError(`${name} is not set. See docs/server.md.`);
8918
- return value;
8919
- };
8920
- const issuerValue = require2("MADAUTH_ISSUER");
8921
- parseOrigin("MADAUTH_ISSUER", issuerValue);
8922
- const issuer = issuerValue.replace(/\/+$/, "");
8923
- const keyValue = read("MADAUTH_SIGNING_KEY");
8924
- if (!keyValue) {
8925
- const key = await generateSigningKey();
8926
- throw new ConfigError(
8927
- `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):
8928
-
8929
- MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
8930
- `
8931
- );
8932
- }
8933
- let signingKey;
8934
- try {
8935
- signingKey = JSON.parse(keyValue);
8936
- assertPrivateSigningJwk(signingKey);
8937
- } catch (e) {
8938
- const reason = e instanceof SyntaxError ? "is not valid JSON" : e.message;
8939
- throw new ConfigError(`MADAUTH_SIGNING_KEY ${reason}. Generate one with: npx @madauth/server generate-key`);
8940
- }
8941
- const allowedOrigins = require2("ALLOWED_ORIGINS").split(",").map((o) => o.trim()).filter(Boolean).map((o) => parseOrigin("ALLOWED_ORIGINS", o));
8942
- const clientId = read("GOOGLE_CLIENT_ID");
8943
- if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
8944
- throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
8945
- }
8946
- const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"));
8947
- const sendsEmails = !!webhook && REQUIRED_EMAIL_TYPES.every((type) => webhook.events.has(type));
8948
- const password = sendsEmails ? { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")) } : void 0;
8949
- if (!clientId && !password) {
8950
- throw new ConfigError(
8951
- `No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in, and/or a webhook that sends the e-mails for e-mail & password sign-in: for development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered,email.no_password. See docs/server.md.`
8952
- );
8953
- }
8954
- const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
8955
- if (!store) {
8956
- throw new ConfigError(
8957
- "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."
8958
- );
8959
- }
8960
- const ttlValue = read("SESSION_TTL");
8961
- const sessionTtlSeconds = ttlValue ? Number(ttlValue) : DEFAULT_SESSION_TTL;
8962
- if (!Number.isInteger(sessionTtlSeconds) || sessionTtlSeconds < 60) {
8963
- throw new ConfigError(`SESSION_TTL must be a whole number of seconds (at least 60) but is "${ttlValue}".`);
8964
- }
8965
- const renewalValue = read("SESSION_RENEWAL_TTL");
8966
- const renewalTtlSeconds = renewalValue ? Number(renewalValue) : Math.max(DEFAULT_RENEWAL_TTL, sessionTtlSeconds);
8967
- if (!Number.isInteger(renewalTtlSeconds) || renewalTtlSeconds < sessionTtlSeconds) {
8968
- throw new ConfigError(
8969
- `SESSION_RENEWAL_TTL must be a whole number of seconds, not less than SESSION_TTL (${sessionTtlSeconds}), but is "${renewalValue}".`
8970
- );
8971
- }
8972
- return {
8973
- issuer,
8974
- signingKey,
8975
- allowedOrigins,
8976
- sessionTtlSeconds,
8977
- renewalTtlSeconds,
8978
- cookieDomain: read("COOKIE_DOMAIN"),
8979
- google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
8980
- store,
8981
- password,
8982
- webhook
8983
- };
8984
- }
8985
- function passwordMinLength(value) {
8986
- const minLength = value ? Number(value) : DEFAULT_PASSWORD_MIN_LENGTH;
8987
- if (!Number.isInteger(minLength) || minLength < 1 || minLength > 128) {
8988
- throw new ConfigError(`PASSWORD_MIN_LENGTH must be a whole number from 1 to 128 but is "${value}".`);
8989
- }
8990
- return minLength;
8991
- }
8992
- async function storeFromDatabaseUrl(url) {
8993
- if (!url) return void 0;
8994
- if (url.startsWith("dynamodb:")) return dynamoDbStore(url.slice("dynamodb:".length));
8995
- if (!url.startsWith("sqlite:")) {
8996
- throw new ConfigError(
8997
- `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).`
8998
- );
8999
- }
9000
- const path = url.slice("sqlite:".length);
9001
- if (!path) throw new ConfigError("DATABASE_URL needs a file path, e.g. sqlite:/data/madauth.db");
9002
- const { createSqliteAdapter: createSqliteAdapter2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
9003
- try {
9004
- return createSqliteAdapter2(path);
9005
- } catch (e) {
9006
- throw new ConfigError(`DATABASE_URL: could not open "${path}": ${e.message}`);
9007
- }
9008
- }
9009
- async function dynamoDbStore(tableName2) {
9010
- if (!tableName2) throw new ConfigError("DATABASE_URL needs a table name, e.g. dynamodb:madauth");
9011
- if (!/^[\w.-]{3,255}$/.test(tableName2)) {
9012
- throw new ConfigError(
9013
- `DATABASE_URL=dynamodb: needs a DynamoDB table name (3 to 255 letters, digits, _ . -) but has "${tableName2}"`
9014
- );
9015
- }
9016
- try {
9017
- await import("@aws-sdk/client-dynamodb");
9018
- } catch (e) {
9019
- throw new ConfigError(
9020
- `DATABASE_URL=dynamodb: needs the AWS SDK. Install it next to madAuth: npm install @aws-sdk/client-dynamodb (${e.message})`
9021
- );
9022
- }
9023
- const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
9024
- return createDynamoDbAdapter2({ tableName: tableName2 });
9025
- }
9026
- var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
9027
- var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
9028
- function webhookFromEnv(url, secret, events) {
9029
- if (!url) {
9030
- if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
9031
- return void 0;
9032
- }
9033
- let parsed;
9034
- try {
9035
- parsed = new URL(url);
9036
- } catch {
9037
- throw new ConfigError(`WEBHOOK_URL: "${url}" is not a URL.`);
9038
- }
9039
- if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && LOCAL_HOSTS.includes(parsed.hostname))) {
9040
- throw new ConfigError(`WEBHOOK_URL must be an https URL (http only for localhost) but is "${url}".`);
9041
- }
9042
- if (!secret) {
9043
- throw new ConfigError(
9044
- `WEBHOOK_SECRET is not set. Here is a newly generated secret you can use (keep it secret; your webhook receiver needs the same one):
9045
-
9046
- WEBHOOK_SECRET=${generateWebhookSecret()}
9047
- `
9048
- );
9049
- }
9050
- const problem = checkWebhookSecret(secret);
9051
- if (problem) {
9052
- throw new ConfigError(`WEBHOOK_SECRET ${problem}. Generate one with: npx @madauth/server generate-webhook-secret`);
9053
- }
9054
- const selected = new Set((events ?? "").split(",").map((e) => e.trim()).filter(Boolean));
9055
- const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
9056
- if (!selected.size) {
9057
- throw new ConfigError(
9058
- `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:
9059
-
9060
- WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered,email.no_password
9061
-
9062
- For one that only wants to know who signed in:
9063
-
9064
- WEBHOOK_EVENTS=user.signed_in
9065
-
9066
- ` + allTypes
9067
- );
9068
- }
9069
- const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
9070
- if (unknown.length) {
9071
- const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
9072
- throw new ConfigError(
9073
- `WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
9074
- );
9075
- }
9076
- return { url: parsed.href, secret, events: selected };
9077
- }
9078
-
9079
9719
  // src/entry/azure-handler.ts
9080
9720
  var sameSiteValues = { strict: "Strict", lax: "Lax", none: "None" };
9081
9721
  function parseSetCookie(header) {