@madauth/server 0.2.0-beta.2 → 0.3.0-beta.2

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.
@@ -4107,8 +4107,9 @@ var init_schema = __esm({
4107
4107
  "src/store/schema.ts"() {
4108
4108
  "use strict";
4109
4109
  madauthSchema = {
4110
- version: 3,
4110
+ version: 4,
4111
4111
  models: {
4112
+ /** Someone who signed in, whichever way: the e-mail address is the identity shared by the sign-in methods. */
4112
4113
  user: {
4113
4114
  fields: {
4114
4115
  id: { type: "string", primaryKey: true },
@@ -4120,10 +4121,12 @@ var init_schema = __esm({
4120
4121
  lastMailAt: { type: "number" },
4121
4122
  /** Wrong e-mail codes in a row, counted across e-mails (since version 2). */
4122
4123
  wrongCodes: { type: "number" },
4124
+ /** What admins attached to the user, as JSON, e.g. `{"roles":["admin"]}` (since version 4). */
4125
+ claims: { type: "string", nullable: true },
4123
4126
  createdAt: { type: "number" }
4124
4127
  }
4125
4128
  },
4126
- /** How a user signs in. `key` is e.g. `password:<userId>`; later `google:<sub>` or `totp:<userId>`. */
4129
+ /** How a user signs in. `key` is `password:<userId>` or `google:<sub>`; later e.g. `totp:<userId>`. */
4127
4130
  account: {
4128
4131
  fields: {
4129
4132
  id: { type: "string", primaryKey: true },
@@ -4132,6 +4135,8 @@ var init_schema = __esm({
4132
4135
  secret: { type: "string", nullable: true },
4133
4136
  failedAttempts: { type: "number" },
4134
4137
  lockedUntil: { type: "number" },
4138
+ /** The address the provider reported at the last sign-in, e.g. Google's current primary address (since version 4). */
4139
+ email: { type: "string", nullable: true },
4135
4140
  createdAt: { type: "number" }
4136
4141
  }
4137
4142
  },
@@ -4146,16 +4151,13 @@ var init_schema = __esm({
4146
4151
  expiresAt: { type: "number" }
4147
4152
  }
4148
4153
  },
4149
- /**
4150
- * The roles of an e-mail address (since version 3). `id` is the normalized address, so the roles
4151
- * apply whichever way its owner signs in. `roles` holds the names separated by spaces.
4152
- */
4153
- role: {
4154
+ /** A setting admins change at runtime, as JSON (since version 4), e.g. `methods`: which sign-in methods are on. */
4155
+ setting: {
4154
4156
  fields: {
4155
4157
  id: { type: "string", primaryKey: true },
4156
- roles: { type: "string" },
4158
+ value: { type: "string" },
4157
4159
  updatedAt: { type: "number" },
4158
- /** The address of the admin who set them; empty when set from the command line. */
4160
+ /** The address of the admin who set it; empty when set from the command line. */
4159
4161
  updatedBy: { type: "string", nullable: true }
4160
4162
  }
4161
4163
  }
@@ -4210,8 +4212,30 @@ var init_sql = __esm({
4210
4212
  sql: (dialect) => `ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("wrongCodes")} ${columnTypes[dialect].number} NOT NULL DEFAULT 0;`
4211
4213
  },
4212
4214
  {
4215
+ // Roles were per e-mail address in their own table; since version 4 they are one of the user's claims.
4213
4216
  version: 3,
4214
- sql: (dialect) => createTablesSql(dialect, { version: 3, models: { role: madauthSchema.models.role } })
4217
+ sql: (dialect) => createTablesSql(dialect, {
4218
+ version: 3,
4219
+ models: {
4220
+ role: {
4221
+ fields: {
4222
+ id: { type: "string", primaryKey: true },
4223
+ roles: { type: "string" },
4224
+ updatedAt: { type: "number" },
4225
+ updatedBy: { type: "string", nullable: true }
4226
+ }
4227
+ }
4228
+ }
4229
+ })
4230
+ },
4231
+ {
4232
+ version: 4,
4233
+ sql: (dialect) => [
4234
+ `ALTER TABLE ${tableName("user")} ADD COLUMN ${columnName("claims")} ${columnTypes[dialect].string};`,
4235
+ `ALTER TABLE ${tableName("account")} ADD COLUMN ${columnName("email")} ${columnTypes[dialect].string};`,
4236
+ `DROP TABLE ${tableName("role")};`,
4237
+ createTablesSql(dialect, { version: 4, models: { setting: madauthSchema.models.setting } })
4238
+ ].join("\n")
4215
4239
  }
4216
4240
  ];
4217
4241
  }
@@ -6531,6 +6555,63 @@ var cors = (options) => {
6531
6555
  };
6532
6556
  };
6533
6557
 
6558
+ // src/claims.ts
6559
+ var ADMIN_ROLE = "admin";
6560
+ var MAX_ROLES = 20;
6561
+ var MAX_CLAIMS_LENGTH = 2048;
6562
+ var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
6563
+ var CLAIM_NAME = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
6564
+ function parseRoles(input) {
6565
+ if (!Array.isArray(input)) return null;
6566
+ if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
6567
+ const roles = [...new Set(input)].sort();
6568
+ return roles.length <= MAX_ROLES ? roles : null;
6569
+ }
6570
+ function parseClaims(input) {
6571
+ if (!isJsonObject(input)) return null;
6572
+ const claims = {};
6573
+ for (const key of Object.keys(input).sort()) {
6574
+ const value = input[key];
6575
+ if (!CLAIM_NAME.test(key) || !isJsonValue(value)) return null;
6576
+ if (key === "roles") {
6577
+ const roles = parseRoles(value);
6578
+ if (!roles) return null;
6579
+ claims[key] = roles;
6580
+ } else {
6581
+ claims[key] = value;
6582
+ }
6583
+ }
6584
+ return JSON.stringify(claims).length <= MAX_CLAIMS_LENGTH ? claims : null;
6585
+ }
6586
+ function isJsonObject(value) {
6587
+ return !!value && typeof value === "object" && !Array.isArray(value);
6588
+ }
6589
+ function isJsonValue(value) {
6590
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
6591
+ if (typeof value === "number") return Number.isFinite(value);
6592
+ if (Array.isArray(value)) return value.every(isJsonValue);
6593
+ return isJsonObject(value) && Object.values(value).every(isJsonValue);
6594
+ }
6595
+ function sameClaims(a, b) {
6596
+ return JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
6597
+ }
6598
+ function claimsFromJson(json) {
6599
+ if (!json) return {};
6600
+ try {
6601
+ const parsed = JSON.parse(json);
6602
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
6603
+ } catch {
6604
+ return {};
6605
+ }
6606
+ }
6607
+ function rolesOf(user) {
6608
+ const roles = user.claims?.roles;
6609
+ return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
6610
+ }
6611
+ function isAdmin(user) {
6612
+ return rolesOf(user).includes(ADMIN_ROLE);
6613
+ }
6614
+
6534
6615
  // ../../node_modules/jose/dist/webapi/lib/buffer_utils.js
6535
6616
  var encoder = new TextEncoder();
6536
6617
  var decoder = new TextDecoder();
@@ -7737,45 +7818,6 @@ function safeEqual(a, b) {
7737
7818
  return x.length === y.length && timingSafeEqual(x, y);
7738
7819
  }
7739
7820
 
7740
- // src/roles.ts
7741
- var ADMIN_ROLE = "admin";
7742
- var MAX_ROLES = 20;
7743
- var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
7744
- function parseRoles(input) {
7745
- if (!Array.isArray(input)) return null;
7746
- if (!input.every((role) => typeof role === "string" && ROLE_NAME.test(role))) return null;
7747
- const roles = [...new Set(input)].sort();
7748
- return roles.length <= MAX_ROLES ? roles : null;
7749
- }
7750
- function sameRoles(a, b) {
7751
- return a.length === b.length && a.every((role, i) => role === b[i]);
7752
- }
7753
- var Roles = class {
7754
- constructor(store) {
7755
- this.store = store;
7756
- }
7757
- /** The address's roles, sorted; empty if it has none. */
7758
- async get(emailNormalized) {
7759
- const record = await this.store.findOne("role", { id: emailNormalized });
7760
- return typeof record?.roles === "string" && record.roles ? record.roles.split(" ").filter(Boolean) : [];
7761
- }
7762
- /** Replaces the address's roles. `updatedBy` is the address of the admin, or null for the command line. */
7763
- async set(emailNormalized, roles, updatedBy) {
7764
- if (!roles.length) {
7765
- await this.remove(emailNormalized);
7766
- return;
7767
- }
7768
- const data = { roles: roles.join(" "), updatedAt: Date.now(), updatedBy };
7769
- if (await this.store.update("role", { id: emailNormalized }, data) === 1) return;
7770
- if (!await this.store.create("role", { id: emailNormalized, ...data })) {
7771
- await this.store.update("role", { id: emailNormalized }, data);
7772
- }
7773
- }
7774
- async remove(emailNormalized) {
7775
- await this.store.delete("role", { id: emailNormalized });
7776
- }
7777
- };
7778
-
7779
7821
  // src/google.ts
7780
7822
  var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
7781
7823
  var googleJwks;
@@ -7800,14 +7842,13 @@ async function verifyGoogleIdToken(idToken, opts) {
7800
7842
  if (payload.nonce !== opts.nonce) {
7801
7843
  return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
7802
7844
  }
7803
- if (payload.email_verified !== true) {
7845
+ if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
7804
7846
  return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
7805
7847
  }
7806
- const user = { id: `google:${payload.sub}` };
7807
- if (typeof payload.email === "string") user.email = payload.email;
7808
- if (typeof payload.name === "string") user.name = payload.name;
7809
- if (typeof payload.picture === "string") user.picture = payload.picture;
7810
- return { ok: true, user };
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 };
7811
7852
  }
7812
7853
 
7813
7854
  // src/user.ts
@@ -7841,7 +7882,7 @@ function signSession(keys, issuer, ttlSeconds, user, amr, extra = {}) {
7841
7882
  email_verified: user.email ? true : void 0,
7842
7883
  name: user.name,
7843
7884
  picture: user.picture,
7844
- roles: user.roles?.length ? user.roles : void 0,
7885
+ claims: user.claims && Object.keys(user.claims).length ? user.claims : void 0,
7845
7886
  amr
7846
7887
  };
7847
7888
  return signToken(keys, issuer, SESSION_TYP, claims, ttlSeconds);
@@ -7863,353 +7904,100 @@ function userFromClaims(claims) {
7863
7904
  if (claims.email) user.email = claims.email;
7864
7905
  if (claims.name) user.name = claims.name;
7865
7906
  if (claims.picture) user.picture = claims.picture;
7866
- if (Array.isArray(claims.roles) && claims.roles.length) user.roles = claims.roles;
7907
+ if (claims.claims && typeof claims.claims === "object" && Object.keys(claims.claims).length) user.claims = claims.claims;
7867
7908
  return user;
7868
7909
  }
7869
7910
 
7870
- // src/routes/google.ts
7871
- var NONCE_COOKIE = "madauth_nonce";
7872
- var NONCE_TYP = "madauth-nonce+jwt";
7873
- var NONCE_TTL = 5 * 60;
7874
- var OAUTH_COOKIE = "madauth_oauth";
7875
- var OAUTH_TYP = "madauth-oauth-state+jwt";
7876
- var OAUTH_TTL = 10 * 60;
7877
- var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
7878
- var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
7879
- var REDIRECT_ERROR_PARAM = "madauth_error";
7880
- async function pkceChallenge(verifier) {
7881
- const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
7882
- return base64url_exports.encode(new Uint8Array(digest2));
7883
- }
7884
- function googleRoutes(app2, ctx, google) {
7885
- const { config, keys, secure } = ctx;
7886
- const { issuer } = config;
7887
- const callbackUrl = `${issuer}/auth/google/callback`;
7888
- app2.post("/auth/google/nonce", async (c) => {
7889
- const nonce = randomString();
7890
- const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
7891
- setCookie(c, NONCE_COOKIE, token, {
7892
- path: "/auth/google",
7893
- httpOnly: true,
7894
- secure,
7895
- sameSite: "Strict",
7896
- maxAge: NONCE_TTL
7897
- });
7898
- return c.json({ nonce });
7899
- });
7900
- app2.post("/auth/google/verify", async (c) => {
7901
- const body = await c.req.json().catch(() => ({}));
7902
- if (typeof body.credential !== "string" || !body.credential) {
7903
- return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
7904
- }
7905
- const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
7906
- if (!nonceClaims) {
7907
- return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
7908
- }
7909
- const result = await verifyGoogleIdToken(body.credential, {
7910
- clientId: google.clientId,
7911
- nonce: nonceClaims.nonce,
7912
- keys: config.jwksResolver
7913
- });
7914
- if (!result.ok) {
7915
- return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
7916
- }
7917
- deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
7918
- const user = await ctx.startSession(c, result.user, ["google"]);
7919
- await ctx.emit("user.signed_in", { user, method: "google" });
7920
- return c.json({ user });
7921
- });
7922
- const requireCodeFlow = async (c, next) => {
7923
- if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
7924
- await next();
7925
- };
7926
- app2.get("/auth/google/start", requireCodeFlow, async (c) => {
7927
- const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
7928
- if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
7929
- const oauth = {
7930
- state: randomString(),
7931
- nonce: randomString(),
7932
- verifier: randomString(),
7933
- returnTo: returnUrl.href
7934
- };
7935
- const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
7936
- setCookie(c, OAUTH_COOKIE, token, {
7937
- path: "/auth/google",
7938
- httpOnly: true,
7939
- secure,
7940
- sameSite: "Lax",
7941
- maxAge: OAUTH_TTL
7942
- });
7943
- const url = new URL(GOOGLE_AUTH_ENDPOINT);
7944
- url.search = new URLSearchParams({
7945
- client_id: google.clientId,
7946
- redirect_uri: callbackUrl,
7947
- response_type: "code",
7948
- scope: "openid email profile",
7949
- state: oauth.state,
7950
- nonce: oauth.nonce,
7951
- code_challenge: await pkceChallenge(oauth.verifier),
7952
- code_challenge_method: "S256"
7953
- }).toString();
7954
- return c.redirect(url.href, 302);
7955
- });
7956
- app2.get("/auth/google/callback", requireCodeFlow, async (c) => {
7957
- const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
7958
- if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
7959
- deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
7960
- const back = (error) => {
7961
- const url = new URL(oauth.returnTo);
7962
- if (error) url.hash = `${REDIRECT_ERROR_PARAM}=${error}`;
7963
- return c.redirect(url.href, 302);
7964
- };
7965
- if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
7966
- const code = c.req.query("code");
7967
- if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
7968
- let idToken;
7911
+ // src/users.ts
7912
+ import { randomBytes as randomBytes2 } from "crypto";
7913
+
7914
+ // src/settings.ts
7915
+ var SIGN_IN_METHODS = ["google", "password"];
7916
+ var METHODS_SETTING = "methods";
7917
+ function parseMethodSettings(input) {
7918
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
7919
+ 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;
7923
+ }
7924
+ return settings;
7925
+ }
7926
+ var MAX_TRIES = 3;
7927
+ var Settings = class {
7928
+ constructor(store) {
7929
+ this.store = store;
7930
+ }
7931
+ /** The setting's value, or undefined if it was never set. */
7932
+ async get(id) {
7933
+ return (await this.read(id)).value;
7934
+ }
7935
+ /** The stored JSON and what it holds; both undefined if the setting was never set. */
7936
+ async read(id) {
7937
+ const record = await this.store.findOne("setting", { id });
7938
+ if (typeof record?.value !== "string") return {};
7969
7939
  try {
7970
- const res = await fetch(GOOGLE_TOKEN_ENDPOINT, {
7971
- method: "POST",
7972
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
7973
- body: new URLSearchParams({
7974
- code,
7975
- client_id: google.clientId,
7976
- client_secret: google.clientSecret,
7977
- redirect_uri: callbackUrl,
7978
- grant_type: "authorization_code",
7979
- code_verifier: oauth.verifier
7980
- })
7981
- });
7982
- if (!res.ok) return back("verification_failed");
7983
- ({ id_token: idToken } = await res.json());
7940
+ return { json: record.value, value: JSON.parse(record.value) };
7984
7941
  } catch {
7985
- return back("verification_failed");
7942
+ return { json: record.value };
7986
7943
  }
7987
- if (typeof idToken !== "string") return back("verification_failed");
7988
- const result = await verifyGoogleIdToken(idToken, {
7989
- clientId: google.clientId,
7990
- nonce: oauth.nonce,
7991
- keys: config.jwksResolver
7992
- });
7993
- if (!result.ok) return back(result.error);
7994
- const user = await ctx.startSession(c, result.user, ["google"]);
7995
- await ctx.emit("user.signed_in", { user, method: "google" });
7996
- return back();
7997
- });
7944
+ }
7945
+ /**
7946
+ * Changes the setting: `apply` gets the current value and returns the new one, or null to refuse the
7947
+ * change. The write only lands if nobody changed the setting since `apply` read it; otherwise `apply` runs
7948
+ * again with the newer value. Resolves to the new value, or null if `apply` refused.
7949
+ * `updatedBy` is the address of the admin, or null for the command line.
7950
+ */
7951
+ async change(id, apply, updatedBy) {
7952
+ for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
7953
+ const { json, value } = await this.read(id);
7954
+ const next = apply(value);
7955
+ if (next === null) return null;
7956
+ const patch = { value: JSON.stringify(next), updatedAt: Date.now(), updatedBy };
7957
+ const written = json === void 0 ? await this.store.create("setting", { id, ...patch }) : await this.store.update("setting", { id, value: json }, patch) === 1;
7958
+ if (written) return next;
7959
+ }
7960
+ throw new Error(`Setting "${id}" kept changing; try again.`);
7961
+ }
7962
+ /** Replaces the setting. */
7963
+ async set(id, value, updatedBy) {
7964
+ await this.change(id, () => value, updatedBy);
7965
+ }
7966
+ /** Which sign-in methods are on; a method that was never switched off is on. */
7967
+ async methods() {
7968
+ return parseMethodSettings(await this.get(METHODS_SETTING)) ?? {};
7969
+ }
7970
+ /** Changes which methods are on, see {@link change}; `apply` gets the methods as they are now. */
7971
+ async changeMethods(apply, updatedBy) {
7972
+ return this.change(METHODS_SETTING, (current) => apply(parseMethodSettings(current) ?? {}), updatedBy);
7973
+ }
7974
+ };
7975
+ function isOn(method, available, methods) {
7976
+ return available(method) && methods[method] !== false;
7977
+ }
7978
+ function anyOn(available, methods) {
7979
+ return SIGN_IN_METHODS.some((method) => isOn(method, available, methods));
7998
7980
  }
7999
7981
 
8000
- // src/routes/password.ts
8001
- var FREE_ATTEMPTS = 5;
8002
- var MAX_LOCK_MS = 15 * 60 * 1e3;
8003
- var MAIL_INTERVAL_MS = 60 * 1e3;
8004
- var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
8005
- var RESET_TTL_MS = 30 * 60 * 1e3;
8006
- var WEBHOOK_TIMEOUT_MS = 1e4;
8007
- var VERIFY_LINK_PARAM = "madauth_verify";
8008
- var RESET_LINK_PARAM = "madauth_reset";
8009
- var MAX_NAME_LENGTH = 100;
8010
- var MAX_LOCALE_LENGTH = 35;
8011
- function toMadauthUser(user) {
7982
+ // src/users.ts
7983
+ var MAX_CODE_ATTEMPTS = 5;
7984
+ var MAX_WRONG_CODES = 10;
7985
+ var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
7986
+ function passwordAccountKey(userId) {
7987
+ return `password:${userId}`;
7988
+ }
7989
+ function googleAccountKey(sub) {
7990
+ return `google:${sub}`;
7991
+ }
7992
+ function toMadauthUser(user, profile = {}) {
8012
7993
  const result = { id: user.id, email: user.email };
8013
- if (user.name) result.name = user.name;
7994
+ const name = user.name ?? profile.name;
7995
+ if (name) result.name = name;
7996
+ if (profile.picture) result.picture = profile.picture;
7997
+ const claims = claimsFromJson(user.claims);
7998
+ if (Object.keys(claims).length) result.claims = claims;
8014
7999
  return result;
8015
8000
  }
8016
- function passwordRoutes(app2, ctx, password, users, webhook) {
8017
- const { minLength } = password;
8018
- const body = async (c) => {
8019
- const data = await c.req.json().catch(() => null);
8020
- return data && typeof data === "object" ? data : {};
8021
- };
8022
- const error = (c, status, code, message2) => c.json({ error: code, message: message2 }, status);
8023
- const unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
8024
- const accepted = (c) => c.json({}, 202);
8025
- const redirectTarget = (c, value) => {
8026
- const url = ctx.allowedUrl(value);
8027
- return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
8028
- };
8029
- const sendEmail = async (user, type, data) => {
8030
- if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
8031
- const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
8032
- if (!result.ok) {
8033
- console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
8034
- return false;
8035
- }
8036
- await users.updateUser(user.id, { lastMailAt: Date.now() });
8037
- return true;
8038
- };
8039
- const sendLinkEmail = async (user, purpose, target, locale) => {
8040
- if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
8041
- const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
8042
- const { token, code } = await users.issueVerification(user.id, purpose, ttl);
8043
- const link = new URL(target);
8044
- link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
8045
- return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
8046
- link: link.href,
8047
- code,
8048
- expiresAt: Date.now() + ttl,
8049
- site: target.host,
8050
- locale
8051
- });
8052
- };
8053
- const localeOf = (data) => typeof data.locale === "string" && data.locale.length <= MAX_LOCALE_LENGTH ? data.locale : void 0;
8054
- const consume = async (data, purpose) => {
8055
- if (typeof data.token === "string" && data.token) {
8056
- return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
8057
- }
8058
- if (isValidEmail(data.email) && typeof data.code === "string") {
8059
- const user = await users.findByEmail(normalizeEmail(data.email));
8060
- if (!user) return { userId: null, via: "code" };
8061
- return { ...await users.consumeCode(user, purpose, data.code.replace(/\s/g, "")), via: "code" };
8062
- }
8063
- return { userId: null, via: "code" };
8064
- };
8065
- const invalidVerification = (c, { via, locked }) => {
8066
- if (locked) {
8067
- return error(c, 429, "codes_locked", "Too many wrong codes. Please ask for a new e-mail and use the link in it.");
8068
- }
8069
- 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.");
8070
- };
8071
- app2.post("/auth/password/signin", async (c) => {
8072
- const data = await body(c);
8073
- const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
8074
- const pw = typeof data.password === "string" ? data.password : "";
8075
- if (!isValidEmail(data.email) || !pw || pw.length > MAX_PASSWORD_LENGTH * 4) return wrong();
8076
- const user = await users.findByEmail(normalizeEmail(data.email));
8077
- const account = user ? await users.passwordAccount(user.id) : null;
8078
- if (!user || !account?.secret) {
8079
- await verifyAgainstDummy(pw);
8080
- return wrong();
8081
- }
8082
- const now = Date.now();
8083
- if (account.lockedUntil > now) {
8084
- const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
8085
- return error(c, 429, "too_many_attempts", `Too many failed attempts. Try again in ${seconds} seconds.`);
8086
- }
8087
- const failed = account.failedAttempts + 1;
8088
- const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
8089
- if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
8090
- return error(c, 429, "too_many_attempts", "Too many attempts at once. Please try again.");
8091
- }
8092
- const { ok, needsRehash } = await verifyPassword(pw, account.secret);
8093
- if (!ok) return wrong();
8094
- await users.updateAccount(account.id, {
8095
- failedAttempts: 0,
8096
- lockedUntil: 0,
8097
- ...needsRehash ? { secret: await hashPassword(pw) } : {}
8098
- });
8099
- if (!user.emailVerified) {
8100
- return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
8101
- }
8102
- const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
8103
- await ctx.emit("user.signed_in", { user: result, method: "password" });
8104
- return c.json({ user: result });
8105
- });
8106
- app2.post("/auth/password/signup", async (c) => {
8107
- const data = await body(c);
8108
- const target = redirectTarget(c, data.redirectTo);
8109
- if (target instanceof Response) return target;
8110
- if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
8111
- const policy = checkPasswordPolicy(data.password, minLength);
8112
- if (policy) return error(c, 400, "weak_password", policy);
8113
- const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
8114
- const email = data.email.trim();
8115
- const locale = localeOf(data);
8116
- if (webhook.wants("signup.before")) {
8117
- const check2 = await webhook.call("signup.before", { email, name: name ?? void 0, locale }, WEBHOOK_TIMEOUT_MS);
8118
- if (!check2.ok) {
8119
- console.error(`[madauth] Webhook "signup.before" failed: ${check2.reason}`);
8120
- return unavailable(c);
8121
- }
8122
- const answer = check2.body;
8123
- if (answer?.allow === false) {
8124
- const message2 = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
8125
- return error(c, 403, "signup_rejected", message2);
8126
- }
8127
- }
8128
- const passwordHash = await hashPassword(data.password);
8129
- const existing = await users.findByEmail(normalizeEmail(email));
8130
- if (existing?.emailVerified) {
8131
- if (!webhook.wants("email.already_registered")) return accepted(c);
8132
- const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
8133
- return sent ? accepted(c) : unavailable(c);
8134
- }
8135
- if (existing) {
8136
- if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
8137
- const account = await users.passwordAccount(existing.id);
8138
- if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
8139
- await users.updateUser(existing.id, { name });
8140
- return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
8141
- }
8142
- const user = await users.createPasswordUser({
8143
- email,
8144
- emailNormalized: normalizeEmail(email),
8145
- name,
8146
- passwordHash,
8147
- emailVerified: false
8148
- });
8149
- if (!user) return accepted(c);
8150
- await ctx.emit("user.created", { user: toMadauthUser(user) });
8151
- return await sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
8152
- });
8153
- app2.post("/auth/password/send-verification", async (c) => {
8154
- const data = await body(c);
8155
- const target = redirectTarget(c, data.redirectTo);
8156
- if (target instanceof Response) return target;
8157
- if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
8158
- const user = await users.findByEmail(normalizeEmail(data.email));
8159
- if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
8160
- if (!await sendLinkEmail(user, "verify", target, localeOf(data))) return unavailable(c);
8161
- }
8162
- return accepted(c);
8163
- });
8164
- app2.post("/auth/password/verify-email", async (c) => {
8165
- const consumed = await consume(await body(c), "verify");
8166
- const { userId, via } = consumed;
8167
- const user = userId ? await users.findById(userId) : null;
8168
- if (!user) return invalidVerification(c, consumed);
8169
- await users.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
8170
- const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: user.sessionVersion });
8171
- await ctx.emit("email.verified", { user: result, via });
8172
- await ctx.emit("user.signed_in", { user: result, method: "password" });
8173
- return c.json({ user: result });
8174
- });
8175
- app2.post("/auth/password/send-reset", async (c) => {
8176
- const data = await body(c);
8177
- const target = redirectTarget(c, data.redirectTo);
8178
- if (target instanceof Response) return target;
8179
- if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
8180
- const user = await users.findByEmail(normalizeEmail(data.email));
8181
- if (user && await users.passwordAccount(user.id)) {
8182
- if (!await sendLinkEmail(user, "reset", target, localeOf(data))) return unavailable(c);
8183
- }
8184
- return accepted(c);
8185
- });
8186
- app2.post("/auth/password/reset", async (c) => {
8187
- const data = await body(c);
8188
- const policy = checkPasswordPolicy(data.password, minLength);
8189
- if (policy) return error(c, 400, "weak_password", policy);
8190
- const consumed = await consume(data, "reset");
8191
- const user = consumed.userId ? await users.findById(consumed.userId) : null;
8192
- const account = user ? await users.passwordAccount(user.id) : null;
8193
- if (!user || !account) return invalidVerification(c, consumed);
8194
- await users.updateAccount(account.id, { secret: await hashPassword(data.password), failedAttempts: 0, lockedUntil: 0 });
8195
- const sessionVersion = user.sessionVersion + 1;
8196
- await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
8197
- await users.clearVerifications(user.id);
8198
- const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
8199
- await ctx.emit("password.reset", { user: result });
8200
- await ctx.emit("user.signed_in", { user: result, method: "password" });
8201
- return c.json({ user: result });
8202
- });
8203
- }
8204
-
8205
- // src/users.ts
8206
- import { randomBytes as randomBytes2 } from "crypto";
8207
- var MAX_CODE_ATTEMPTS = 5;
8208
- var MAX_WRONG_CODES = 10;
8209
- var newId = (prefix) => `${prefix}_${randomBytes2(16).toString("base64url")}`;
8210
- function passwordAccountKey(userId) {
8211
- return `password:${userId}`;
8212
- }
8213
8001
  var Users = class {
8214
8002
  /** `codeKey` keys the e-mail codes (see deriveCodeKey); only needed to issue and check them. */
8215
8003
  constructor(store, codeKey) {
@@ -8227,11 +8015,22 @@ var Users = class {
8227
8015
  return await this.store.findOne("user", { id });
8228
8016
  }
8229
8017
  async passwordAccount(userId) {
8230
- return await this.store.findOne("account", { key: passwordAccountKey(userId) });
8018
+ return this.findAccountByKey(passwordAccountKey(userId));
8231
8019
  }
8232
- /** Creates a user with a password. Resolves to null if the e-mail address is taken. */
8233
- async createPasswordUser(data) {
8234
- const now = Date.now();
8020
+ /** How the user signs in, from their accounts: e.g. `['google']` for a user without a password. */
8021
+ async signInMethods(userId) {
8022
+ 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));
8025
+ }
8026
+ async findAccountByKey(key) {
8027
+ return await this.store.findOne("account", { key });
8028
+ }
8029
+ /**
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>`.
8032
+ */
8033
+ async createUser(data) {
8235
8034
  const user = {
8236
8035
  id: newId("usr"),
8237
8036
  email: data.email,
@@ -8241,45 +8040,66 @@ var Users = class {
8241
8040
  sessionVersion: 0,
8242
8041
  lastMailAt: 0,
8243
8042
  wrongCodes: 0,
8244
- createdAt: now
8043
+ claims: null,
8044
+ createdAt: Date.now()
8245
8045
  };
8246
8046
  if (!await this.store.create("user", user)) return null;
8247
- const account = {
8248
- id: newId("acc"),
8249
- userId: user.id,
8250
- key: passwordAccountKey(user.id),
8251
- secret: data.passwordHash,
8252
- failedAttempts: 0,
8253
- lockedUntil: 0,
8254
- createdAt: now
8255
- };
8256
- if (!await this.store.create("account", account)) {
8047
+ const account = typeof data.account === "function" ? data.account(user.id) : data.account;
8048
+ if (!await this.linkAccount(user.id, account)) {
8257
8049
  await this.store.delete("user", { id: user.id });
8258
8050
  return null;
8259
8051
  }
8260
8052
  return user;
8261
8053
  }
8262
8054
  /**
8263
- * Deletes the user with this e-mail address and everything stored for them. Resolves to the deleted
8264
- * user's ID, or null if there is no such user (any more).
8055
+ * A provider verified the user's address: an unconfirmed password sign-up with it, whose password nobody
8056
+ * has proven, is dropped (the account and its pending links and codes), and the user counts as verified.
8057
+ */
8058
+ async verifyByProvider(user) {
8059
+ if (user.emailVerified) return;
8060
+ await this.store.delete("account", { key: passwordAccountKey(user.id) });
8061
+ await this.clearVerifications(user.id);
8062
+ await this.updateUser(user.id, { emailVerified: true, wrongCodes: 0 });
8063
+ }
8064
+ /** Removes an account record, e.g. one left behind by a deletion that did not finish. */
8065
+ async deleteAccount(id) {
8066
+ await this.store.delete("account", { id });
8067
+ }
8068
+ /** Adds a sign-in method to a user. Resolves to null if the account key is taken. */
8069
+ async linkAccount(userId, account) {
8070
+ const record = {
8071
+ id: newId("acc"),
8072
+ userId,
8073
+ key: account.key,
8074
+ secret: account.secret ?? null,
8075
+ failedAttempts: 0,
8076
+ lockedUntil: 0,
8077
+ email: account.email ?? null,
8078
+ createdAt: Date.now()
8079
+ };
8080
+ return await this.store.create("account", record) ? record : null;
8081
+ }
8082
+ /**
8083
+ * Deletes the user and everything stored for them. Resolves to false if there is no such user (any more).
8265
8084
  */
8266
- async deleteByEmail(emailNormalized) {
8267
- const user = await this.findByEmail(emailNormalized);
8268
- if (!user) return null;
8269
- if (await this.store.delete("user", { id: user.id }) !== 1) return null;
8085
+ async deleteById(id) {
8086
+ const user = await this.findById(id);
8087
+ if (!user) return false;
8088
+ if (await this.store.delete("user", { id: user.id }) !== 1) return false;
8270
8089
  try {
8271
8090
  await this.store.delete("account", { userId: user.id });
8272
8091
  await this.store.delete("verification", { userId: user.id });
8273
8092
  } catch (e) {
8274
8093
  console.error(`[madauth] Deleted user ${user.id}, but not all of their records: ${e.message}`);
8275
8094
  }
8276
- return user.id;
8095
+ return true;
8277
8096
  }
8278
8097
  async updateUser(id, patch) {
8279
8098
  await this.store.update("user", { id }, patch);
8280
8099
  }
8100
+ /** Changes an account. Resolves to false if it is gone meanwhile. */
8281
8101
  async updateAccount(id, patch) {
8282
- await this.store.update("account", { id }, patch);
8102
+ return await this.store.update("account", { id }, patch) === 1;
8283
8103
  }
8284
8104
  /**
8285
8105
  * Counts a sign-in attempt: writes `patch` only if the account's `failedAttempts` is still the value that
@@ -8337,98 +8157,490 @@ var Users = class {
8337
8157
  if (attempts >= MAX_CODE_ATTEMPTS) await this.store.delete("verification", { id: record.id });
8338
8158
  return { userId: null, locked: wrongCodes >= MAX_WRONG_CODES };
8339
8159
  }
8340
- return { userId: await this.consume(record), locked: false };
8341
- }
8342
- /** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
8343
- async countWrongCode(user) {
8344
- let current = user;
8345
- for (let attempt = 0; attempt < 3 && current; attempt++) {
8346
- const wrongCodes = (current.wrongCodes ?? 0) + 1;
8347
- if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
8348
- return wrongCodes;
8349
- }
8350
- current = await this.findById(user.id);
8160
+ return { userId: await this.consume(record), locked: false };
8161
+ }
8162
+ /** Counts a code for the user and resolves to the new count; null if other requests kept changing it. */
8163
+ async countWrongCode(user) {
8164
+ let current = user;
8165
+ for (let attempt = 0; attempt < 3 && current; attempt++) {
8166
+ const wrongCodes = (current.wrongCodes ?? 0) + 1;
8167
+ if (await this.store.update("user", { id: user.id, wrongCodes: current.wrongCodes ?? null }, { wrongCodes }) === 1) {
8168
+ return wrongCodes;
8169
+ }
8170
+ current = await this.findById(user.id);
8171
+ }
8172
+ return null;
8173
+ }
8174
+ async consume(record) {
8175
+ const deleted = await this.store.delete("verification", { id: record.id });
8176
+ if (deleted !== 1 || record.expiresAt <= Date.now()) return null;
8177
+ return record.userId;
8178
+ }
8179
+ };
8180
+
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;
8200
+ }
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")}`;
8207
+ }
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;
8213
+ }
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");
8217
+ }
8218
+ function signWebhook(secret, id, timestamp, body) {
8219
+ return `v1,${hmac2(secret, `${id}.${timestamp}.${body}`)}`;
8220
+ }
8221
+ function createWebhookClient(settings, fetchImpl = fetch) {
8222
+ const wants = (type) => settings.events.has(type);
8223
+ 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 };
8246
+ }
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;
8254
+ }
8255
+ return { ok: true, body: parsed };
8256
+ }
8257
+ };
8258
+ }
8259
+
8260
+ // src/routes/google.ts
8261
+ var NONCE_COOKIE = "madauth_nonce";
8262
+ var NONCE_TYP = "madauth-nonce+jwt";
8263
+ var NONCE_TTL = 5 * 60;
8264
+ var OAUTH_COOKIE = "madauth_oauth";
8265
+ var OAUTH_TYP = "madauth-oauth-state+jwt";
8266
+ var OAUTH_TTL = 10 * 60;
8267
+ var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
8268
+ var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
8269
+ var REDIRECT_ERROR_PARAM = "madauth_error";
8270
+ async function pkceChallenge(verifier) {
8271
+ const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
8272
+ return base64url_exports.encode(new Uint8Array(digest2));
8273
+ }
8274
+ function googleRoutes(app2, ctx, google) {
8275
+ const { config, keys, secure, users } = ctx;
8276
+ const { issuer } = config;
8277
+ const callbackUrl = `${issuer}/auth/google/callback`;
8278
+ const resolveUser = async (profile, locale) => {
8279
+ const key = googleAccountKey(profile.sub);
8280
+ const emailNormalized = normalizeEmail(profile.email);
8281
+ const signedIn = (user) => ({ ok: true, user: toMadauthUser(user, profile), sv: user.sessionVersion });
8282
+ const account = await users.findAccountByKey(key);
8283
+ if (account) {
8284
+ const user = await users.findById(account.userId);
8285
+ if (user) {
8286
+ if (account.email !== profile.email) await users.updateAccount(account.id, { email: profile.email });
8287
+ return signedIn(user);
8288
+ }
8289
+ await users.deleteAccount(account.id);
8290
+ }
8291
+ for (let attempt = 0; attempt < 2; attempt++) {
8292
+ const existing = await users.findByEmail(emailNormalized);
8293
+ if (existing) {
8294
+ await users.verifyByProvider(existing);
8295
+ if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
8296
+ const linked = await users.findAccountByKey(key);
8297
+ const owner = linked && await users.findById(linked.userId);
8298
+ if (!owner) return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
8299
+ return signedIn(owner);
8300
+ }
8301
+ return signedIn(existing);
8302
+ }
8303
+ if (attempt === 0) {
8304
+ const check2 = await ctx.checkSignup({ email: profile.email, name: profile.name, locale, method: "google" });
8305
+ if (!check2.ok) {
8306
+ return check2.reason === "rejected" ? { ok: false, error: "signup_rejected", message: check2.message } : { ok: false, error: "temporarily_unavailable", message: "Signing up is not possible right now. Please try again later." };
8307
+ }
8308
+ }
8309
+ const user = await users.createUser({
8310
+ email: profile.email,
8311
+ emailNormalized,
8312
+ name: profile.name ?? null,
8313
+ emailVerified: true,
8314
+ account: { key, email: profile.email }
8315
+ });
8316
+ if (user) {
8317
+ await ctx.emit("user.created", { user: toMadauthUser(user), method: "google" });
8318
+ return signedIn(user);
8319
+ }
8320
+ }
8321
+ return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
8322
+ };
8323
+ const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
8324
+ app2.post("/auth/google/nonce", async (c) => {
8325
+ if (!await ctx.enabled("google")) return disabled(c);
8326
+ const nonce = randomString();
8327
+ const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
8328
+ setCookie(c, NONCE_COOKIE, token, {
8329
+ path: "/auth/google",
8330
+ httpOnly: true,
8331
+ secure,
8332
+ sameSite: "Strict",
8333
+ maxAge: NONCE_TTL
8334
+ });
8335
+ return c.json({ nonce });
8336
+ });
8337
+ 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) {
8341
+ return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
8342
+ }
8343
+ const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
8344
+ if (!nonceClaims) {
8345
+ return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
8346
+ }
8347
+ const result = await verifyGoogleIdToken(body.credential, {
8348
+ clientId: google.clientId,
8349
+ nonce: nonceClaims.nonce,
8350
+ keys: config.jwksResolver
8351
+ });
8352
+ if (!result.ok) {
8353
+ return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
8354
+ }
8355
+ deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
8356
+ const resolved = await resolveUser(result.profile, localeOf(body.locale));
8357
+ if (!resolved.ok) {
8358
+ return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
8359
+ }
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 });
8363
+ });
8364
+ const requireCodeFlow = async (c, next) => {
8365
+ if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
8366
+ if (!await ctx.enabled("google")) return disabled(c);
8367
+ await next();
8368
+ };
8369
+ app2.get("/auth/google/start", requireCodeFlow, async (c) => {
8370
+ const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
8371
+ if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
8372
+ const oauth = {
8373
+ state: randomString(),
8374
+ nonce: randomString(),
8375
+ verifier: randomString(),
8376
+ returnTo: returnUrl.href,
8377
+ locale: localeOf(c.req.query("locale"))
8378
+ };
8379
+ const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
8380
+ setCookie(c, OAUTH_COOKIE, token, {
8381
+ path: "/auth/google",
8382
+ httpOnly: true,
8383
+ secure,
8384
+ sameSite: "Lax",
8385
+ maxAge: OAUTH_TTL
8386
+ });
8387
+ const url = new URL(GOOGLE_AUTH_ENDPOINT);
8388
+ url.search = new URLSearchParams({
8389
+ client_id: google.clientId,
8390
+ redirect_uri: callbackUrl,
8391
+ response_type: "code",
8392
+ scope: "openid email profile",
8393
+ state: oauth.state,
8394
+ nonce: oauth.nonce,
8395
+ code_challenge: await pkceChallenge(oauth.verifier),
8396
+ code_challenge_method: "S256"
8397
+ }).toString();
8398
+ return c.redirect(url.href, 302);
8399
+ });
8400
+ app2.get("/auth/google/callback", requireCodeFlow, async (c) => {
8401
+ const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
8402
+ if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
8403
+ deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
8404
+ const back = (error) => {
8405
+ const url = new URL(oauth.returnTo);
8406
+ if (error) url.hash = `${REDIRECT_ERROR_PARAM}=${error}`;
8407
+ return c.redirect(url.href, 302);
8408
+ };
8409
+ if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
8410
+ const code = c.req.query("code");
8411
+ if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
8412
+ let idToken;
8413
+ try {
8414
+ const res = await fetch(GOOGLE_TOKEN_ENDPOINT, {
8415
+ method: "POST",
8416
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
8417
+ body: new URLSearchParams({
8418
+ code,
8419
+ client_id: google.clientId,
8420
+ client_secret: google.clientSecret,
8421
+ redirect_uri: callbackUrl,
8422
+ grant_type: "authorization_code",
8423
+ code_verifier: oauth.verifier
8424
+ })
8425
+ });
8426
+ if (!res.ok) return back("verification_failed");
8427
+ ({ id_token: idToken } = await res.json());
8428
+ } catch {
8429
+ return back("verification_failed");
8430
+ }
8431
+ if (typeof idToken !== "string") return back("verification_failed");
8432
+ const result = await verifyGoogleIdToken(idToken, {
8433
+ clientId: google.clientId,
8434
+ nonce: oauth.nonce,
8435
+ keys: config.jwksResolver
8436
+ });
8437
+ if (!result.ok) return back(result.error);
8438
+ const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
8439
+ 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" });
8442
+ return back();
8443
+ });
8444
+ }
8445
+
8446
+ // src/routes/password.ts
8447
+ var FREE_ATTEMPTS = 5;
8448
+ 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;
8455
+ function passwordRoutes(app2, ctx, password, users, webhook) {
8456
+ const { minLength } = password;
8457
+ const disabled = (c) => c.json({ error: "method_disabled", message: "E-mail & password sign-in is switched off." }, 403);
8458
+ app2.use("/auth/password/*", async (c, next) => {
8459
+ if (!await ctx.enabled("password")) return disabled(c);
8460
+ await next();
8461
+ });
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" };
8351
8510
  }
8352
- return null;
8353
- }
8354
- async consume(record) {
8355
- const deleted = await this.store.delete("verification", { id: record.id });
8356
- if (deleted !== 1 || record.expiresAt <= Date.now()) return null;
8357
- return record.userId;
8358
- }
8359
- };
8360
-
8361
- // src/webhooks.ts
8362
- import { createHmac as createHmac2, randomBytes as randomBytes3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
8363
- var WEBHOOK_TYPES = [
8364
- "email.verify",
8365
- "email.reset",
8366
- "email.already_registered",
8367
- "signup.before",
8368
- "user.created",
8369
- "email.verified",
8370
- "password.reset",
8371
- "user.signed_in",
8372
- "user.deleted",
8373
- "roles.changed"
8374
- ];
8375
- var REQUIRED_EMAIL_TYPES = ["email.verify", "email.reset"];
8376
- var SECRET_PREFIX = "whsec_";
8377
- var DEV_WEBHOOK_RECEIVER_URL = "https://github.com/inouiw/madAuth-webhooks/tree/main/dev-receiver";
8378
- var WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
8379
- function generateWebhookSecret() {
8380
- return `${SECRET_PREFIX}${randomBytes3(32).toString("base64")}`;
8381
- }
8382
- function checkWebhookSecret(secret) {
8383
- if (!secret.startsWith(SECRET_PREFIX)) return `must start with "${SECRET_PREFIX}"`;
8384
- const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
8385
- if (key.length < 24 || key.length > 64) return "must contain 24 to 64 bytes in base64";
8386
- return null;
8387
- }
8388
- function hmac2(secret, content) {
8389
- const key = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
8390
- return createHmac2("sha256", key).update(content).digest("base64");
8391
- }
8392
- function signWebhook(secret, id, timestamp, body) {
8393
- return `v1,${hmac2(secret, `${id}.${timestamp}.${body}`)}`;
8394
- }
8395
- function createWebhookClient(settings, fetchImpl = fetch) {
8396
- const wants = (type) => settings.events.has(type);
8397
- return {
8398
- wants,
8399
- async call(type, data, timeoutMs) {
8400
- if (!wants(type)) return { ok: true, body: void 0 };
8401
- const id = `msg_${randomUUID()}`;
8402
- const timestamp = Math.floor(Date.now() / 1e3);
8403
- const body = JSON.stringify({ type, data });
8404
- let response;
8405
- try {
8406
- response = await fetchImpl(settings.url, {
8407
- method: "POST",
8408
- headers: {
8409
- "Content-Type": "application/json",
8410
- "webhook-id": id,
8411
- "webhook-timestamp": String(timestamp),
8412
- "webhook-signature": signWebhook(settings.secret, id, timestamp, body)
8413
- },
8414
- body,
8415
- signal: AbortSignal.timeout(timeoutMs)
8416
- });
8417
- } catch (e) {
8418
- const reason = e.name === "TimeoutError" ? `no answer within ${timeoutMs} ms` : e.message;
8419
- return { ok: false, reason };
8420
- }
8421
- const text = await response.text().catch(() => "");
8422
- if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
8423
- let parsed;
8424
- try {
8425
- parsed = text ? JSON.parse(text) : void 0;
8426
- } catch {
8427
- parsed = void 0;
8428
- }
8429
- return { ok: true, body: parsed };
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.");
8430
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.");
8431
8518
  };
8519
+ app2.post("/auth/password/signin", async (c) => {
8520
+ const data = await body(c);
8521
+ const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
8522
+ const pw = typeof data.password === "string" ? data.password : "";
8523
+ if (!isValidEmail(data.email) || !pw || pw.length > MAX_PASSWORD_LENGTH * 4) return wrong();
8524
+ const user = await users.findByEmail(normalizeEmail(data.email));
8525
+ const account = user ? await users.passwordAccount(user.id) : null;
8526
+ if (!user || !account?.secret) {
8527
+ await verifyAgainstDummy(pw);
8528
+ return wrong();
8529
+ }
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
+ }
8540
+ const { ok, needsRehash } = await verifyPassword(pw, account.secret);
8541
+ if (!ok) return wrong();
8542
+ await users.updateAccount(account.id, {
8543
+ failedAttempts: 0,
8544
+ lockedUntil: 0,
8545
+ ...needsRehash ? { secret: await hashPassword(pw) } : {}
8546
+ });
8547
+ if (!user.emailVerified) {
8548
+ return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
8549
+ }
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 });
8553
+ });
8554
+ app2.post("/auth/password/signup", async (c) => {
8555
+ const data = await body(c);
8556
+ const target = redirectTarget(c, data.redirectTo);
8557
+ if (target instanceof Response) return target;
8558
+ if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
8559
+ const policy = checkPasswordPolicy(data.password, minLength);
8560
+ 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;
8562
+ const email = data.email.trim();
8563
+ const locale = localeOf(data.locale);
8564
+ const check2 = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
8565
+ if (!check2.ok) return check2.reason === "rejected" ? error(c, 403, "signup_rejected", check2.message) : unavailable(c);
8566
+ const passwordHash = await hashPassword(data.password);
8567
+ 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);
8570
+ }
8571
+ if (existing) {
8572
+ if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
8573
+ const account = await users.passwordAccount(existing.id);
8574
+ if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
8575
+ else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
8576
+ await users.updateUser(existing.id, { name });
8577
+ return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
8578
+ }
8579
+ const user = await users.createUser({
8580
+ email,
8581
+ emailNormalized: normalizeEmail(email),
8582
+ name,
8583
+ emailVerified: false,
8584
+ account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
8585
+ });
8586
+ if (!user) return accepted(c);
8587
+ 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 });
8611
+ });
8612
+ app2.post("/auth/password/send-reset", async (c) => {
8613
+ const data = await body(c);
8614
+ const target = redirectTarget(c, data.redirectTo);
8615
+ if (target instanceof Response) return target;
8616
+ if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
8617
+ const user = await users.findByEmail(normalizeEmail(data.email));
8618
+ const locale = localeOf(data.locale);
8619
+ if (user && await users.passwordAccount(user.id)) {
8620
+ if (!await sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
8621
+ } else if (user) {
8622
+ if (!await sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
8623
+ }
8624
+ return accepted(c);
8625
+ });
8626
+ app2.post("/auth/password/reset", async (c) => {
8627
+ const data = await body(c);
8628
+ const policy = checkPasswordPolicy(data.password, minLength);
8629
+ if (policy) return error(c, 400, "weak_password", policy);
8630
+ const consumed = await consume(data, "reset");
8631
+ const user = consumed.userId ? await users.findById(consumed.userId) : null;
8632
+ const account = user ? await users.passwordAccount(user.id) : null;
8633
+ if (!user || !account) return invalidVerification(c, consumed);
8634
+ const secret = await hashPassword(data.password);
8635
+ if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return invalidVerification(c, consumed);
8636
+ const sessionVersion = user.sessionVersion + 1;
8637
+ await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
8638
+ 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 });
8643
+ });
8432
8644
  }
8433
8645
 
8434
8646
  // src/app.ts
@@ -8438,12 +8650,9 @@ function createApp(config) {
8438
8650
  const keys = importSigningKeys(config.signingKey);
8439
8651
  const secure = issuer.startsWith("https:");
8440
8652
  const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
8441
- const roles = config.password ? new Roles(config.password.store) : void 0;
8442
- const withRoles = async (user) => {
8443
- const { roles: _, ...rest } = user;
8444
- const current = roles && user.email ? await roles.get(normalizeEmail(user.email)) : [];
8445
- return current.length ? { ...rest, roles: current } : rest;
8446
- };
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;
8447
8656
  const issueSession = async (c, user, amr, extra) => {
8448
8657
  const signingKeys = await keys;
8449
8658
  const [sessionToken, renewalToken] = await Promise.all([
@@ -8495,12 +8704,29 @@ function createApp(config) {
8495
8704
  return url;
8496
8705
  },
8497
8706
  async startSession(c, user, amr, extra) {
8498
- const signedIn = await withRoles(user);
8499
- await issueSession(c, signedIn, amr, extra);
8500
- return signedIn;
8707
+ await issueSession(c, user, amr, extra);
8708
+ return user;
8501
8709
  },
8502
- users: config.password ? new Users(config.password.store, deriveCodeKey(config.signingKey.d)) : void 0,
8710
+ users,
8711
+ settings,
8503
8712
  webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
8713
+ async enabled(method) {
8714
+ return isOn(method, available, await settings.methods());
8715
+ },
8716
+ async checkSignup(data) {
8717
+ if (!this.webhook?.wants("signup.before")) return { ok: true };
8718
+ const check2 = await this.webhook.call("signup.before", data, WEBHOOK_TIMEOUT_MS);
8719
+ if (!check2.ok) {
8720
+ console.error(`[madauth] Webhook "signup.before" failed: ${check2.reason}`);
8721
+ return { ok: false, reason: "unavailable" };
8722
+ }
8723
+ const answer = check2.body;
8724
+ if (answer?.allow === false) {
8725
+ const message2 = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
8726
+ return { ok: false, reason: "rejected", message: message2 };
8727
+ }
8728
+ return { ok: true };
8729
+ },
8504
8730
  async emit(type, data) {
8505
8731
  if (!this.webhook?.wants(type)) return;
8506
8732
  const result = await this.webhook.call(type, data, EVENT_TIMEOUT_MS);
@@ -8530,29 +8756,28 @@ function createApp(config) {
8530
8756
  c.header("Cache-Control", "public, max-age=3600");
8531
8757
  return c.json({ keys: [publicJwk] });
8532
8758
  });
8533
- app2.get(
8534
- "/auth/config",
8535
- (c) => c.json({
8536
- google: config.google ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
8537
- password: config.password ? { minLength: config.password.minLength } : null
8538
- })
8539
- );
8759
+ app2.get("/auth/config", async (c) => {
8760
+ const methods = await settings.methods();
8761
+ const on = (method) => isOn(method, available, methods);
8762
+ 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
8765
+ });
8766
+ });
8540
8767
  if (config.google) googleRoutes(app2, ctx, config.google);
8541
- if (config.password && ctx.users && ctx.webhook) passwordRoutes(app2, ctx, config.password, ctx.users, ctx.webhook);
8768
+ if (config.password && ctx.webhook) passwordRoutes(app2, ctx, config.password, users, ctx.webhook);
8542
8769
  const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie(c, cookie));
8543
- const hasEnded = async (claims) => {
8544
- if (!claims.sub.startsWith("usr_")) return false;
8545
- const stored = await ctx.users?.findById(claims.sub);
8546
- return !stored || stored.sessionVersion !== claims.sv;
8547
- };
8548
8770
  const checked = async (claims) => {
8549
- const [ended, user] = await Promise.all([hasEnded(claims), withRoles(userFromClaims(claims))]);
8550
- return ended ? null : user;
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;
8551
8776
  };
8552
8777
  const currentSession = async (c) => {
8553
8778
  const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
8554
8779
  const user = session ? await checked(session) : null;
8555
- const upToDate = session && user && sameRoles(user.roles ?? [], session.roles ?? []) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
8780
+ const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
8556
8781
  if (upToDate) return user;
8557
8782
  const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
8558
8783
  const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
@@ -8578,56 +8803,82 @@ function createApp(config) {
8578
8803
  app2.post("/auth/account/delete", async (c) => {
8579
8804
  const user = await currentSession(c);
8580
8805
  if (!user) return c.json({ error: "no_session" }, 401);
8581
- if (roles && user.email) await roles.remove(normalizeEmail(user.email));
8582
- const passwordUserId = ctx.users && user.email ? await ctx.users.deleteByEmail(normalizeEmail(user.email)) : null;
8806
+ await users.deleteById(user.id);
8583
8807
  clearSession(c);
8584
- await ctx.emit("user.deleted", { user, passwordUserId: passwordUserId ?? void 0 });
8808
+ await ctx.emit("user.deleted", { user });
8585
8809
  return c.body(null, 204);
8586
8810
  });
8587
- if (roles) {
8588
- const admin = async (c) => {
8589
- const user = await currentSession(c);
8590
- if (!user) return { answer: c.json({ error: "no_session" }, 401) };
8591
- const email = user.email ? normalizeEmail(user.email) : void 0;
8592
- if (!email || !user.roles?.includes(ADMIN_ROLE)) {
8593
- return { answer: c.json({ error: "forbidden", message: `Only users with the role "${ADMIN_ROLE}" can manage roles.` }, 403) };
8594
- }
8595
- return { email };
8596
- };
8597
- const body = async (c) => {
8598
- const data = await c.req.json().catch(() => null);
8599
- return data && typeof data === "object" ? data : {};
8600
- };
8601
- const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
8602
- app2.post("/auth/admin/roles/get", async (c) => {
8603
- const caller = await admin(c);
8604
- if ("answer" in caller) return caller.answer;
8605
- const data = await body(c);
8606
- if (!isValidEmail(data.email)) return invalidEmail(c);
8607
- const email = normalizeEmail(data.email);
8608
- return c.json({ email, roles: await roles.get(email) });
8609
- });
8610
- app2.post("/auth/admin/roles/set", async (c) => {
8611
- const caller = await admin(c);
8612
- if ("answer" in caller) return caller.answer;
8613
- const data = await body(c);
8614
- if (!isValidEmail(data.email)) return invalidEmail(c);
8615
- const names = parseRoles(data.roles);
8616
- if (!names) {
8617
- return c.json(
8618
- {
8619
- error: "invalid_roles",
8620
- message: 'roles must be a list of names: lower-case letters, digits, "-" and "_", starting with a letter.'
8621
- },
8622
- 400
8623
- );
8624
- }
8625
- const email = normalizeEmail(data.email);
8626
- await roles.set(email, names, caller.email);
8627
- await ctx.emit("roles.changed", { email, roles: names, by: caller.email });
8628
- return c.json({ email, roles: names });
8629
- });
8630
- }
8811
+ const admin = async (c) => {
8812
+ const user = await currentSession(c);
8813
+ if (!user) return { answer: c.json({ error: "no_session" }, 401) };
8814
+ const email = user.email ? normalizeEmail(user.email) : void 0;
8815
+ if (!email || !isAdmin(user)) {
8816
+ const message2 = `Only users with the role "${ADMIN_ROLE}" can manage claims and settings.`;
8817
+ return { answer: c.json({ error: "forbidden", message: message2 }, 403) };
8818
+ }
8819
+ return { email };
8820
+ };
8821
+ const body = async (c) => {
8822
+ const data = await c.req.json().catch(() => null);
8823
+ return data && typeof data === "object" ? data : {};
8824
+ };
8825
+ const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
8826
+ const noUser = (c) => c.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
8827
+ app2.post("/auth/admin/claims/get", async (c) => {
8828
+ const caller = await admin(c);
8829
+ if ("answer" in caller) return caller.answer;
8830
+ const data = await body(c);
8831
+ if (!isValidEmail(data.email)) return invalidEmail(c);
8832
+ const user = await users.findByEmail(normalizeEmail(data.email));
8833
+ if (!user) return noUser(c);
8834
+ return c.json({ email: user.emailNormalized, userId: user.id, claims: claimsFromJson(user.claims) });
8835
+ });
8836
+ app2.post("/auth/admin/claims/set", async (c) => {
8837
+ const caller = await admin(c);
8838
+ if ("answer" in caller) return caller.answer;
8839
+ const data = await body(c);
8840
+ if (!isValidEmail(data.email)) return invalidEmail(c);
8841
+ const claims = parseClaims(data.claims);
8842
+ if (!claims) {
8843
+ return c.json(
8844
+ {
8845
+ error: "invalid_claims",
8846
+ message: 'claims must be a JSON object of at most 2048 characters whose keys are names (letters, digits and "_", starting with a letter). "roles" must be a list of names of lower-case letters, digits, "-" and "_".'
8847
+ },
8848
+ 400
8849
+ );
8850
+ }
8851
+ const user = await users.findByEmail(normalizeEmail(data.email));
8852
+ if (!user) return noUser(c);
8853
+ await users.updateUser(user.id, { claims: Object.keys(claims).length ? JSON.stringify(claims) : null });
8854
+ const email = user.emailNormalized;
8855
+ await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
8856
+ return c.json({ email, userId: user.id, claims });
8857
+ });
8858
+ const settingsAnswer = (methods) => ({
8859
+ methods: Object.fromEntries(
8860
+ SIGN_IN_METHODS.map((method) => [method, { available: available(method), enabled: isOn(method, available, methods) }])
8861
+ )
8862
+ });
8863
+ app2.post("/auth/admin/settings/get", async (c) => {
8864
+ const caller = await admin(c);
8865
+ if ("answer" in caller) return caller.answer;
8866
+ return c.json(settingsAnswer(await settings.methods()));
8867
+ });
8868
+ app2.post("/auth/admin/settings/set", async (c) => {
8869
+ const caller = await admin(c);
8870
+ if ("answer" in caller) return caller.answer;
8871
+ const data = await body(c);
8872
+ 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));
8881
+ });
8631
8882
  return app2;
8632
8883
  }
8633
8884
 
@@ -8692,19 +8943,18 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
8692
8943
  if (clientId && !clientId.endsWith(".apps.googleusercontent.com")) {
8693
8944
  throw new ConfigError(`GOOGLE_CLIENT_ID must end with ".apps.googleusercontent.com" but is "${clientId}".`);
8694
8945
  }
8695
- const sendsEmails = !!(overrides.store || read("DATABASE_URL"));
8696
- const webhook = webhookFromEnv(read("WEBHOOK_URL"), read("WEBHOOK_SECRET"), read("WEBHOOK_EVENTS"), sendsEmails);
8697
- if (sendsEmails && !webhook) {
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) {
8698
8950
  throw new ConfigError(
8699
- `WEBHOOK_URL is not set. E-mail & password sign-in sends its e-mails through your webhook. For development, run the receiver at ${DEV_WEBHOOK_RECEIVER_URL} and set WEBHOOK_URL=http://localhost:8790/webhook and WEBHOOK_EVENTS=${REQUIRED_EMAIL_TYPES.join(",")},email.already_registered. See "Webhooks" in docs/server.md.`
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.`
8700
8952
  );
8701
8953
  }
8702
8954
  const store = overrides.store ?? await storeFromDatabaseUrl(read("DATABASE_URL"));
8703
- let password;
8704
- if (store) password = { minLength: passwordMinLength(read("PASSWORD_MIN_LENGTH")), store };
8705
- if (!clientId && !password) {
8955
+ if (!store) {
8706
8956
  throw new ConfigError(
8707
- "No sign-in method is configured. Set GOOGLE_CLIENT_ID for Google sign-in and/or DATABASE_URL (e.g. sqlite:./madauth.db) for e-mail & password sign-in. See docs/server.md."
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."
8708
8958
  );
8709
8959
  }
8710
8960
  const ttlValue = read("SESSION_TTL");
@@ -8727,6 +8977,7 @@ MADAUTH_SIGNING_KEY='${JSON.stringify(key)}'
8727
8977
  renewalTtlSeconds,
8728
8978
  cookieDomain: read("COOKIE_DOMAIN"),
8729
8979
  google: clientId ? { clientId, clientSecret: read("GOOGLE_CLIENT_SECRET") } : void 0,
8980
+ store,
8730
8981
  password,
8731
8982
  webhook
8732
8983
  };
@@ -8772,8 +9023,9 @@ async function dynamoDbStore(tableName2) {
8772
9023
  const { createDynamoDbAdapter: createDynamoDbAdapter2 } = await Promise.resolve().then(() => (init_dynamodb(), dynamodb_exports));
8773
9024
  return createDynamoDbAdapter2({ tableName: tableName2 });
8774
9025
  }
9026
+ var RENAMED_TYPES = { "password.reset": "email.password_reset", "roles.changed": "user.claims_changed" };
8775
9027
  var LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "host.docker.internal"];
8776
- function webhookFromEnv(url, secret, events, sendsEmails) {
9028
+ function webhookFromEnv(url, secret, events) {
8777
9029
  if (!url) {
8778
9030
  if (secret || events) throw new ConfigError("WEBHOOK_SECRET and WEBHOOK_EVENTS need WEBHOOK_URL.");
8779
9031
  return void 0;
@@ -8803,24 +9055,22 @@ WEBHOOK_SECRET=${generateWebhookSecret()}
8803
9055
  const allTypes = `All types: ${WEBHOOK_TYPES.join(", ")}. See "Webhooks" in docs/server.md.`;
8804
9056
  if (!selected.size) {
8805
9057
  throw new ConfigError(
8806
- "WEBHOOK_EVENTS is not set. List the types your webhook receiver handles; only these are sent. " + (sendsEmails ? `E-mail & password sign-in needs ${REQUIRED_EMAIL_TYPES.join(" and ")}. For a receiver that sends the e-mails:
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
8807
9061
 
8808
- WEBHOOK_EVENTS=email.verify,email.reset,email.already_registered
9062
+ For one that only wants to know who signed in:
8809
9063
 
8810
- ` : "For example:\n\nWEBHOOK_EVENTS=user.signed_in\n\n") + allTypes
9064
+ WEBHOOK_EVENTS=user.signed_in
9065
+
9066
+ ` + allTypes
8811
9067
  );
8812
9068
  }
8813
9069
  const unknown = [...selected].filter((e) => !WEBHOOK_TYPES.includes(e));
8814
9070
  if (unknown.length) {
8815
- throw new ConfigError(`WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. Known types: ${WEBHOOK_TYPES.join(", ")}.`);
8816
- }
8817
- const missing = sendsEmails ? REQUIRED_EMAIL_TYPES.filter((type) => !selected.has(type)) : [];
8818
- if (missing.length) {
9071
+ const renamed = unknown.filter((e) => e in RENAMED_TYPES).map((e) => `${e} is now ${RENAMED_TYPES[e]}`);
8819
9072
  throw new ConfigError(
8820
- `WEBHOOK_EVENTS lacks ${missing.join(" and ")}. E-mail & password sign-in can't work without these e-mails, so your webhook receiver must send them:
8821
-
8822
- WEBHOOK_EVENTS=${[...selected, ...missing].join(",")}
8823
- `
9073
+ `WEBHOOK_EVENTS: unknown type ${unknown.join(", ")}. ${renamed.length ? `${renamed.join("; ")}. ` : ""}Known types: ${WEBHOOK_TYPES.join(", ")}.`
8824
9074
  );
8825
9075
  }
8826
9076
  return { url: parsed.href, secret, events: selected };