@madauth/server 0.2.0-beta.1 → 0.3.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.
@@ -10,28 +10,39 @@ import {
10
10
  signSession,
11
11
  signToken,
12
12
  userFromClaims
13
- } from "./chunk-7CTAB3QV.js";
13
+ } from "./chunk-DCDTO5LG.js";
14
14
  import {
15
15
  ADMIN_ROLE,
16
16
  MAX_PASSWORD_LENGTH,
17
- Roles,
17
+ SIGN_IN_METHODS,
18
+ Settings,
18
19
  Users,
20
+ anyOn,
19
21
  checkPasswordPolicy,
22
+ claimsFromJson,
20
23
  deriveCodeKey,
24
+ googleAccountKey,
21
25
  hashPassword,
26
+ isAdmin,
27
+ isOn,
22
28
  isValidEmail,
23
29
  normalizeEmail,
24
- parseRoles,
25
- sameRoles,
30
+ parseClaims,
31
+ parseMethodSettings,
32
+ passwordAccountKey,
33
+ sameClaims,
34
+ toMadauthUser,
26
35
  verifyAgainstDummy,
27
36
  verifyPassword
28
- } from "./chunk-5CKEMC6M.js";
37
+ } from "./chunk-ODITZLMM.js";
29
38
  import {
30
39
  importSigningKeys
31
40
  } from "./chunk-JDQLSL36.js";
32
41
  import {
33
- createWebhookClient
34
- } from "./chunk-6EJDVZCD.js";
42
+ WEBHOOK_TIMEOUT_MS,
43
+ createWebhookClient,
44
+ localeOf
45
+ } from "./chunk-NPNM26J3.js";
35
46
 
36
47
  // src/routes/google.ts
37
48
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
@@ -62,14 +73,13 @@ async function verifyGoogleIdToken(idToken, opts) {
62
73
  if (payload.nonce !== opts.nonce) {
63
74
  return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
64
75
  }
65
- if (payload.email_verified !== true) {
76
+ if (typeof payload.email !== "string" || !payload.email || payload.email_verified !== true) {
66
77
  return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
67
78
  }
68
- const user = { id: `google:${payload.sub}` };
69
- if (typeof payload.email === "string") user.email = payload.email;
70
- if (typeof payload.name === "string") user.name = payload.name;
71
- if (typeof payload.picture === "string") user.picture = payload.picture;
72
- return { ok: true, user };
79
+ const profile = { sub: payload.sub, email: payload.email };
80
+ if (typeof payload.name === "string") profile.name = payload.name;
81
+ if (typeof payload.picture === "string") profile.picture = payload.picture;
82
+ return { ok: true, profile };
73
83
  }
74
84
 
75
85
  // src/routes/google.ts
@@ -87,10 +97,57 @@ async function pkceChallenge(verifier) {
87
97
  return base64url.encode(new Uint8Array(digest));
88
98
  }
89
99
  function googleRoutes(app, ctx, google) {
90
- const { config, keys, secure } = ctx;
100
+ const { config, keys, secure, users } = ctx;
91
101
  const { issuer } = config;
92
102
  const callbackUrl = `${issuer}/auth/google/callback`;
103
+ const resolveUser = async (profile, locale) => {
104
+ const key = googleAccountKey(profile.sub);
105
+ const emailNormalized = normalizeEmail(profile.email);
106
+ const signedIn = (user) => ({ ok: true, user: toMadauthUser(user, profile), sv: user.sessionVersion });
107
+ const account = await users.findAccountByKey(key);
108
+ if (account) {
109
+ const user = await users.findById(account.userId);
110
+ if (user) {
111
+ if (account.email !== profile.email) await users.updateAccount(account.id, { email: profile.email });
112
+ return signedIn(user);
113
+ }
114
+ await users.deleteAccount(account.id);
115
+ }
116
+ for (let attempt = 0; attempt < 2; attempt++) {
117
+ const existing = await users.findByEmail(emailNormalized);
118
+ if (existing) {
119
+ await users.verifyByProvider(existing);
120
+ if (!await users.linkAccount(existing.id, { key, email: profile.email })) {
121
+ const linked = await users.findAccountByKey(key);
122
+ const owner = linked && await users.findById(linked.userId);
123
+ if (!owner) return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
124
+ return signedIn(owner);
125
+ }
126
+ return signedIn(existing);
127
+ }
128
+ if (attempt === 0) {
129
+ const check = await ctx.checkSignup({ email: profile.email, name: profile.name, locale, method: "google" });
130
+ if (!check.ok) {
131
+ return check.reason === "rejected" ? { ok: false, error: "signup_rejected", message: check.message } : { ok: false, error: "temporarily_unavailable", message: "Signing up is not possible right now. Please try again later." };
132
+ }
133
+ }
134
+ const user = await users.createUser({
135
+ email: profile.email,
136
+ emailNormalized,
137
+ name: profile.name ?? null,
138
+ emailVerified: true,
139
+ account: { key, email: profile.email }
140
+ });
141
+ if (user) {
142
+ await ctx.emit("user.created", { user: toMadauthUser(user), method: "google" });
143
+ return signedIn(user);
144
+ }
145
+ }
146
+ return { ok: false, error: "temporarily_unavailable", message: "Please try again." };
147
+ };
148
+ const disabled = (c) => c.json({ error: "method_disabled", message: "Google sign-in is switched off." }, 403);
93
149
  app.post("/auth/google/nonce", async (c) => {
150
+ if (!await ctx.enabled("google")) return disabled(c);
94
151
  const nonce = randomString();
95
152
  const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
96
153
  setCookie(c, NONCE_COOKIE, token, {
@@ -103,6 +160,7 @@ function googleRoutes(app, ctx, google) {
103
160
  return c.json({ nonce });
104
161
  });
105
162
  app.post("/auth/google/verify", async (c) => {
163
+ if (!await ctx.enabled("google")) return disabled(c);
106
164
  const body = await c.req.json().catch(() => ({}));
107
165
  if (typeof body.credential !== "string" || !body.credential) {
108
166
  return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
@@ -120,12 +178,17 @@ function googleRoutes(app, ctx, google) {
120
178
  return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
121
179
  }
122
180
  deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
123
- const user = await ctx.startSession(c, result.user, ["google"]);
181
+ const resolved = await resolveUser(result.profile, localeOf(body.locale));
182
+ if (!resolved.ok) {
183
+ return c.json({ error: resolved.error, message: resolved.message }, resolved.error === "signup_rejected" ? 403 : 503);
184
+ }
185
+ const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
124
186
  await ctx.emit("user.signed_in", { user, method: "google" });
125
187
  return c.json({ user });
126
188
  });
127
189
  const requireCodeFlow = async (c, next) => {
128
190
  if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
191
+ if (!await ctx.enabled("google")) return disabled(c);
129
192
  await next();
130
193
  };
131
194
  app.get("/auth/google/start", requireCodeFlow, async (c) => {
@@ -135,7 +198,8 @@ function googleRoutes(app, ctx, google) {
135
198
  state: randomString(),
136
199
  nonce: randomString(),
137
200
  verifier: randomString(),
138
- returnTo: returnUrl.href
201
+ returnTo: returnUrl.href,
202
+ locale: localeOf(c.req.query("locale"))
139
203
  };
140
204
  const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
141
205
  setCookie(c, OAUTH_COOKIE, token, {
@@ -196,7 +260,9 @@ function googleRoutes(app, ctx, google) {
196
260
  keys: config.jwksResolver
197
261
  });
198
262
  if (!result.ok) return back(result.error);
199
- const user = await ctx.startSession(c, result.user, ["google"]);
263
+ const resolved = await resolveUser(result.profile, typeof oauth.locale === "string" ? oauth.locale : void 0);
264
+ if (!resolved.ok) return back(resolved.error);
265
+ const user = await ctx.startSession(c, resolved.user, ["google"], { sv: resolved.sv });
200
266
  await ctx.emit("user.signed_in", { user, method: "google" });
201
267
  return back();
202
268
  });
@@ -213,18 +279,16 @@ var MAX_LOCK_MS = 15 * 60 * 1e3;
213
279
  var MAIL_INTERVAL_MS = 60 * 1e3;
214
280
  var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
215
281
  var RESET_TTL_MS = 30 * 60 * 1e3;
216
- var WEBHOOK_TIMEOUT_MS = 1e4;
217
282
  var VERIFY_LINK_PARAM = "madauth_verify";
218
283
  var RESET_LINK_PARAM = "madauth_reset";
219
284
  var MAX_NAME_LENGTH = 100;
220
- var MAX_LOCALE_LENGTH = 35;
221
- function toMadauthUser(user) {
222
- const result = { id: user.id, email: user.email };
223
- if (user.name) result.name = user.name;
224
- return result;
225
- }
226
285
  function passwordRoutes(app, ctx, password, users, webhook) {
227
286
  const { minLength } = password;
287
+ const disabled = (c) => c.json({ error: "method_disabled", message: "E-mail & password sign-in is switched off." }, 403);
288
+ app.use("/auth/password/*", async (c, next) => {
289
+ if (!await ctx.enabled("password")) return disabled(c);
290
+ await next();
291
+ });
228
292
  const body = async (c) => {
229
293
  const data = await c.req.json().catch(() => null);
230
294
  return data && typeof data === "object" ? data : {};
@@ -260,7 +324,6 @@ function passwordRoutes(app, ctx, password, users, webhook) {
260
324
  locale
261
325
  });
262
326
  };
263
- const localeOf = (data) => typeof data.locale === "string" && data.locale.length <= MAX_LOCALE_LENGTH ? data.locale : void 0;
264
327
  const consume = async (data, purpose) => {
265
328
  if (typeof data.token === "string" && data.token) {
266
329
  return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
@@ -322,19 +385,9 @@ function passwordRoutes(app, ctx, password, users, webhook) {
322
385
  if (policy) return error(c, 400, "weak_password", policy);
323
386
  const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
324
387
  const email = data.email.trim();
325
- const locale = localeOf(data);
326
- if (webhook.wants("signup.before")) {
327
- const check = await webhook.call("signup.before", { email, name: name ?? void 0, locale }, WEBHOOK_TIMEOUT_MS);
328
- if (!check.ok) {
329
- console.error(`[madauth] Webhook "signup.before" failed: ${check.reason}`);
330
- return unavailable(c);
331
- }
332
- const answer = check.body;
333
- if (answer?.allow === false) {
334
- const message = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
335
- return error(c, 403, "signup_rejected", message);
336
- }
337
- }
388
+ const locale = localeOf(data.locale);
389
+ const check = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
390
+ if (!check.ok) return check.reason === "rejected" ? error(c, 403, "signup_rejected", check.message) : unavailable(c);
338
391
  const passwordHash = await hashPassword(data.password);
339
392
  const existing = await users.findByEmail(normalizeEmail(email));
340
393
  if (existing?.emailVerified) {
@@ -346,18 +399,19 @@ function passwordRoutes(app, ctx, password, users, webhook) {
346
399
  if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
347
400
  const account = await users.passwordAccount(existing.id);
348
401
  if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
402
+ else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
349
403
  await users.updateUser(existing.id, { name });
350
404
  return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
351
405
  }
352
- const user = await users.createPasswordUser({
406
+ const user = await users.createUser({
353
407
  email,
354
408
  emailNormalized: normalizeEmail(email),
355
409
  name,
356
- passwordHash,
357
- emailVerified: false
410
+ emailVerified: false,
411
+ account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
358
412
  });
359
413
  if (!user) return accepted(c);
360
- await ctx.emit("user.created", { user: toMadauthUser(user) });
414
+ await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
361
415
  return await sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
362
416
  });
363
417
  app.post("/auth/password/send-verification", async (c) => {
@@ -367,7 +421,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
367
421
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
368
422
  const user = await users.findByEmail(normalizeEmail(data.email));
369
423
  if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
370
- if (!await sendLinkEmail(user, "verify", target, localeOf(data))) return unavailable(c);
424
+ if (!await sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
371
425
  }
372
426
  return accepted(c);
373
427
  });
@@ -388,8 +442,8 @@ function passwordRoutes(app, ctx, password, users, webhook) {
388
442
  if (target instanceof Response) return target;
389
443
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
390
444
  const user = await users.findByEmail(normalizeEmail(data.email));
391
- if (user && await users.passwordAccount(user.id)) {
392
- if (!await sendLinkEmail(user, "reset", target, localeOf(data))) return unavailable(c);
445
+ if (user && (user.emailVerified || await users.passwordAccount(user.id))) {
446
+ if (!await sendLinkEmail(user, "reset", target, localeOf(data.locale))) return unavailable(c);
393
447
  }
394
448
  return accepted(c);
395
449
  });
@@ -399,14 +453,16 @@ function passwordRoutes(app, ctx, password, users, webhook) {
399
453
  if (policy) return error(c, 400, "weak_password", policy);
400
454
  const consumed = await consume(data, "reset");
401
455
  const user = consumed.userId ? await users.findById(consumed.userId) : null;
402
- const account = user ? await users.passwordAccount(user.id) : null;
403
- if (!user || !account) return invalidVerification(c, consumed);
404
- await users.updateAccount(account.id, { secret: await hashPassword(data.password), failedAttempts: 0, lockedUntil: 0 });
456
+ if (!user) return invalidVerification(c, consumed);
457
+ const secret = await hashPassword(data.password);
458
+ const account = await users.passwordAccount(user.id);
459
+ if (account) await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 });
460
+ else await users.linkAccount(user.id, { key: passwordAccountKey(user.id), secret });
405
461
  const sessionVersion = user.sessionVersion + 1;
406
462
  await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
407
463
  await users.clearVerifications(user.id);
408
464
  const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
409
- await ctx.emit("password.reset", { user: result });
465
+ await ctx.emit("email.password_reset", { user: result });
410
466
  await ctx.emit("user.signed_in", { user: result, method: "password" });
411
467
  return c.json({ user: result });
412
468
  });
@@ -419,12 +475,9 @@ function createApp(config) {
419
475
  const keys = importSigningKeys(config.signingKey);
420
476
  const secure = issuer.startsWith("https:");
421
477
  const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
422
- const roles = config.password ? new Roles(config.password.store) : void 0;
423
- const withRoles = async (user) => {
424
- const { roles: _, ...rest } = user;
425
- const current = roles && user.email ? await roles.get(normalizeEmail(user.email)) : [];
426
- return current.length ? { ...rest, roles: current } : rest;
427
- };
478
+ const users = new Users(config.store, deriveCodeKey(config.signingKey.d));
479
+ const settings = new Settings(config.store);
480
+ const available = (method) => method === "google" ? !!config.google : !!config.password;
428
481
  const issueSession = async (c, user, amr, extra) => {
429
482
  const signingKeys = await keys;
430
483
  const [sessionToken, renewalToken] = await Promise.all([
@@ -476,12 +529,29 @@ function createApp(config) {
476
529
  return url;
477
530
  },
478
531
  async startSession(c, user, amr, extra) {
479
- const signedIn = await withRoles(user);
480
- await issueSession(c, signedIn, amr, extra);
481
- return signedIn;
532
+ await issueSession(c, user, amr, extra);
533
+ return user;
482
534
  },
483
- users: config.password ? new Users(config.password.store, deriveCodeKey(config.signingKey.d)) : void 0,
535
+ users,
536
+ settings,
484
537
  webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
538
+ async enabled(method) {
539
+ return isOn(method, available, await settings.methods());
540
+ },
541
+ async checkSignup(data) {
542
+ if (!this.webhook?.wants("signup.before")) return { ok: true };
543
+ const check = await this.webhook.call("signup.before", data, WEBHOOK_TIMEOUT_MS);
544
+ if (!check.ok) {
545
+ console.error(`[madauth] Webhook "signup.before" failed: ${check.reason}`);
546
+ return { ok: false, reason: "unavailable" };
547
+ }
548
+ const answer = check.body;
549
+ if (answer?.allow === false) {
550
+ const message = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
551
+ return { ok: false, reason: "rejected", message };
552
+ }
553
+ return { ok: true };
554
+ },
485
555
  async emit(type, data) {
486
556
  if (!this.webhook?.wants(type)) return;
487
557
  const result = await this.webhook.call(type, data, EVENT_TIMEOUT_MS);
@@ -511,29 +581,28 @@ function createApp(config) {
511
581
  c.header("Cache-Control", "public, max-age=3600");
512
582
  return c.json({ keys: [publicJwk] });
513
583
  });
514
- app.get(
515
- "/auth/config",
516
- (c) => c.json({
517
- google: config.google ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
518
- password: config.password ? { minLength: config.password.minLength } : null
519
- })
520
- );
584
+ app.get("/auth/config", async (c) => {
585
+ const methods = await settings.methods();
586
+ const on = (method) => isOn(method, available, methods);
587
+ return c.json({
588
+ google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
589
+ password: on("password") ? { minLength: config.password.minLength } : null
590
+ });
591
+ });
521
592
  if (config.google) googleRoutes(app, ctx, config.google);
522
- if (config.password && ctx.users && ctx.webhook) passwordRoutes(app, ctx, config.password, ctx.users, ctx.webhook);
593
+ if (config.password && ctx.webhook) passwordRoutes(app, ctx, config.password, users, ctx.webhook);
523
594
  const tokenClaims = async (c, cookie, typ) => readToken(await keys, issuer, typ, getCookie2(c, cookie));
524
- const hasEnded = async (claims) => {
525
- if (!claims.sub.startsWith("usr_")) return false;
526
- const stored = await ctx.users?.findById(claims.sub);
527
- return !stored || stored.sessionVersion !== claims.sv;
528
- };
529
595
  const checked = async (claims) => {
530
- const [ended, user] = await Promise.all([hasEnded(claims), withRoles(userFromClaims(claims))]);
531
- return ended ? null : user;
596
+ const stored = await users.findById(claims.sub);
597
+ if (!stored || stored.sessionVersion !== claims.sv) return null;
598
+ const { claims: _, ...user } = userFromClaims(claims);
599
+ const current = claimsFromJson(stored.claims);
600
+ return Object.keys(current).length ? { ...user, claims: current } : user;
532
601
  };
533
602
  const currentSession = async (c) => {
534
603
  const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
535
604
  const user = session ? await checked(session) : null;
536
- const upToDate = session && user && sameRoles(user.roles ?? [], session.roles ?? []) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
605
+ const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
537
606
  if (upToDate) return user;
538
607
  const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
539
608
  const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
@@ -559,56 +628,82 @@ function createApp(config) {
559
628
  app.post("/auth/account/delete", async (c) => {
560
629
  const user = await currentSession(c);
561
630
  if (!user) return c.json({ error: "no_session" }, 401);
562
- if (roles && user.email) await roles.remove(normalizeEmail(user.email));
563
- const passwordUserId = ctx.users && user.email ? await ctx.users.deleteByEmail(normalizeEmail(user.email)) : null;
631
+ await users.deleteById(user.id);
564
632
  clearSession(c);
565
- await ctx.emit("user.deleted", { user, passwordUserId: passwordUserId ?? void 0 });
633
+ await ctx.emit("user.deleted", { user });
566
634
  return c.body(null, 204);
567
635
  });
568
- if (roles) {
569
- const admin = async (c) => {
570
- const user = await currentSession(c);
571
- if (!user) return { answer: c.json({ error: "no_session" }, 401) };
572
- const email = user.email ? normalizeEmail(user.email) : void 0;
573
- if (!email || !user.roles?.includes(ADMIN_ROLE)) {
574
- return { answer: c.json({ error: "forbidden", message: `Only users with the role "${ADMIN_ROLE}" can manage roles.` }, 403) };
575
- }
576
- return { email };
577
- };
578
- const body = async (c) => {
579
- const data = await c.req.json().catch(() => null);
580
- return data && typeof data === "object" ? data : {};
581
- };
582
- const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
583
- app.post("/auth/admin/roles/get", async (c) => {
584
- const caller = await admin(c);
585
- if ("answer" in caller) return caller.answer;
586
- const data = await body(c);
587
- if (!isValidEmail(data.email)) return invalidEmail(c);
588
- const email = normalizeEmail(data.email);
589
- return c.json({ email, roles: await roles.get(email) });
590
- });
591
- app.post("/auth/admin/roles/set", async (c) => {
592
- const caller = await admin(c);
593
- if ("answer" in caller) return caller.answer;
594
- const data = await body(c);
595
- if (!isValidEmail(data.email)) return invalidEmail(c);
596
- const names = parseRoles(data.roles);
597
- if (!names) {
598
- return c.json(
599
- {
600
- error: "invalid_roles",
601
- message: 'roles must be a list of names: lower-case letters, digits, "-" and "_", starting with a letter.'
602
- },
603
- 400
604
- );
605
- }
606
- const email = normalizeEmail(data.email);
607
- await roles.set(email, names, caller.email);
608
- await ctx.emit("roles.changed", { email, roles: names, by: caller.email });
609
- return c.json({ email, roles: names });
610
- });
611
- }
636
+ const admin = async (c) => {
637
+ const user = await currentSession(c);
638
+ if (!user) return { answer: c.json({ error: "no_session" }, 401) };
639
+ const email = user.email ? normalizeEmail(user.email) : void 0;
640
+ if (!email || !isAdmin(user)) {
641
+ const message = `Only users with the role "${ADMIN_ROLE}" can manage claims and settings.`;
642
+ return { answer: c.json({ error: "forbidden", message }, 403) };
643
+ }
644
+ return { email };
645
+ };
646
+ const body = async (c) => {
647
+ const data = await c.req.json().catch(() => null);
648
+ return data && typeof data === "object" ? data : {};
649
+ };
650
+ const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
651
+ const noUser = (c) => c.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
652
+ app.post("/auth/admin/claims/get", async (c) => {
653
+ const caller = await admin(c);
654
+ if ("answer" in caller) return caller.answer;
655
+ const data = await body(c);
656
+ if (!isValidEmail(data.email)) return invalidEmail(c);
657
+ const user = await users.findByEmail(normalizeEmail(data.email));
658
+ if (!user) return noUser(c);
659
+ return c.json({ email: user.emailNormalized, userId: user.id, claims: claimsFromJson(user.claims) });
660
+ });
661
+ app.post("/auth/admin/claims/set", async (c) => {
662
+ const caller = await admin(c);
663
+ if ("answer" in caller) return caller.answer;
664
+ const data = await body(c);
665
+ if (!isValidEmail(data.email)) return invalidEmail(c);
666
+ const claims = parseClaims(data.claims);
667
+ if (!claims) {
668
+ return c.json(
669
+ {
670
+ error: "invalid_claims",
671
+ 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 "_".'
672
+ },
673
+ 400
674
+ );
675
+ }
676
+ const user = await users.findByEmail(normalizeEmail(data.email));
677
+ if (!user) return noUser(c);
678
+ await users.updateUser(user.id, { claims: Object.keys(claims).length ? JSON.stringify(claims) : null });
679
+ const email = user.emailNormalized;
680
+ await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
681
+ return c.json({ email, userId: user.id, claims });
682
+ });
683
+ const settingsAnswer = (methods) => ({
684
+ methods: Object.fromEntries(
685
+ SIGN_IN_METHODS.map((method) => [method, { available: available(method), enabled: isOn(method, available, methods) }])
686
+ )
687
+ });
688
+ app.post("/auth/admin/settings/get", async (c) => {
689
+ const caller = await admin(c);
690
+ if ("answer" in caller) return caller.answer;
691
+ return c.json(settingsAnswer(await settings.methods()));
692
+ });
693
+ app.post("/auth/admin/settings/set", async (c) => {
694
+ const caller = await admin(c);
695
+ if ("answer" in caller) return caller.answer;
696
+ const data = await body(c);
697
+ const invalid = (message) => c.json({ error: "invalid_settings", message }, 400);
698
+ const changes = parseMethodSettings(data.methods);
699
+ if (!changes) return invalid(`methods must be an object with true or false for ${SIGN_IN_METHODS.join(" and/or ")}.`);
700
+ const methods = await settings.changeMethods((current) => {
701
+ const next = { ...current, ...changes };
702
+ return anyOn(available, next) ? next : null;
703
+ }, caller.email);
704
+ if (!methods) return invalid("At least one sign-in method must stay on.");
705
+ return c.json(settingsAnswer(methods));
706
+ });
612
707
  return app;
613
708
  }
614
709
 
@@ -1,6 +1,6 @@
1
1
  import { JWK, JWTVerifyGetKey } from 'jose';
2
- import { S as StoreAdapter } from './schema-BSgYVRpd.js';
3
- import { W as WebhookSettings } from './webhook-ltwulgcB.js';
2
+ import { S as StoreAdapter } from './schema-3yhCc2jH.js';
3
+ import { W as WebhookSettings } from './webhook-DCDP6SaE.js';
4
4
 
5
5
  interface MadauthConfig {
6
6
  /** Public base URL of the server (MADAUTH_ISSUER). */
@@ -22,13 +22,14 @@ interface MadauthConfig {
22
22
  /** Enables the server-side code flow (GOOGLE_CLIENT_SECRET). */
23
23
  clientSecret?: string;
24
24
  };
25
- /** E-mail & password sign-in; off without a store (DATABASE_URL or the `store` option). */
25
+ /** Where the users, their accounts, claims and the settings are kept (DATABASE_URL or the `store` option). */
26
+ store: StoreAdapter;
27
+ /** E-mail & password sign-in; off unless the webhook sends the e-mails (`email.verify` and `email.reset`). */
26
28
  password?: {
27
29
  /** PASSWORD_MIN_LENGTH. */
28
30
  minLength: number;
29
- store: StoreAdapter;
30
31
  };
31
- /** Where madAuth sends e-mails, sign-up checks and events (WEBHOOK_URL); required for password sign-in. */
32
+ /** Where madAuth sends e-mails, sign-up checks and events (WEBHOOK_URL). */
32
33
  webhook?: WebhookSettings;
33
34
  /** Test hook: resolves Google's signing keys. Defaults to Google's published JWKS. */
34
35
  jwksResolver?: JWTVerifyGetKey;
@@ -42,7 +43,7 @@ declare const envVars: {
42
43
  readonly ALLOWED_ORIGINS: true;
43
44
  readonly GOOGLE_CLIENT_ID: false;
44
45
  readonly GOOGLE_CLIENT_SECRET: false;
45
- readonly DATABASE_URL: false;
46
+ readonly DATABASE_URL: true;
46
47
  readonly PASSWORD_MIN_LENGTH: false;
47
48
  readonly WEBHOOK_URL: false;
48
49
  readonly WEBHOOK_SECRET: false;
package/dist/dynamodb.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  madauthSchema
3
- } from "./chunk-XNBWZZHT.js";
3
+ } from "./chunk-CD7Y4T66.js";
4
4
 
5
5
  // src/store/dynamodb.ts
6
6
  var SORT_KEY = "#";
@@ -1,7 +1,7 @@
1
- import { E as EntryOptions } from '../config-BkxADgtp.js';
1
+ import { E as EntryOptions } from '../config-DEBYi8hb.js';
2
2
  import 'jose';
3
- import '../schema-BSgYVRpd.js';
4
- import '../webhook-ltwulgcB.js';
3
+ import '../schema-3yhCc2jH.js';
4
+ import '../webhook-DCDP6SaE.js';
5
5
 
6
6
  /**
7
7
  * Registers madAuth as an Azure Functions v4 HTTP function that handles every route. Pass `store` to use your
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  createApp
3
- } from "../chunk-YALS7OMV.js";
4
- import "../chunk-7CTAB3QV.js";
3
+ } from "../chunk-ZSBKNV5W.js";
4
+ import "../chunk-DCDTO5LG.js";
5
5
  import {
6
6
  loadConfig,
7
7
  resolveEnv
8
- } from "../chunk-5CKEMC6M.js";
8
+ } from "../chunk-ODITZLMM.js";
9
9
  import "../chunk-JDQLSL36.js";
10
- import "../chunk-6EJDVZCD.js";
10
+ import "../chunk-NPNM26J3.js";
11
11
 
12
12
  // src/entry/azure.ts
13
13
  import { app } from "@azure/functions";
@@ -1,8 +1,8 @@
1
1
  import { LambdaEvent, LambdaContext, handle } from 'hono/aws-lambda';
2
- import { E as EntryOptions } from '../config-BkxADgtp.js';
2
+ import { E as EntryOptions } from '../config-DEBYi8hb.js';
3
3
  import 'jose';
4
- import '../schema-BSgYVRpd.js';
5
- import '../webhook-ltwulgcB.js';
4
+ import '../schema-3yhCc2jH.js';
5
+ import '../webhook-DCDP6SaE.js';
6
6
 
7
7
  type LambdaHandler = (event: LambdaEvent, context: LambdaContext) => ReturnType<ReturnType<typeof handle>>;
8
8
  /**
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  createApp
3
- } from "../chunk-YALS7OMV.js";
4
- import "../chunk-7CTAB3QV.js";
3
+ } from "../chunk-ZSBKNV5W.js";
4
+ import "../chunk-DCDTO5LG.js";
5
5
  import {
6
6
  loadConfig,
7
7
  resolveEnv
8
- } from "../chunk-5CKEMC6M.js";
8
+ } from "../chunk-ODITZLMM.js";
9
9
  import "../chunk-JDQLSL36.js";
10
- import "../chunk-6EJDVZCD.js";
10
+ import "../chunk-NPNM26J3.js";
11
11
 
12
12
  // src/entry/lambda.ts
13
13
  import { handle } from "hono/aws-lambda";
@@ -1,7 +1,7 @@
1
- import { E as EntryOptions } from '../config-BkxADgtp.js';
1
+ import { E as EntryOptions } from '../config-DEBYi8hb.js';
2
2
  import 'jose';
3
- import '../schema-BSgYVRpd.js';
4
- import '../webhook-ltwulgcB.js';
3
+ import '../schema-3yhCc2jH.js';
4
+ import '../webhook-DCDP6SaE.js';
5
5
 
6
6
  type StartOptions = EntryOptions;
7
7
  /**