@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.
@@ -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-TI35AZ2U.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-3QRKWLR7.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,11 @@ 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;
327
+ const sendAccountEmail = async (user, type, target, locale) => {
328
+ if (!webhook.wants(type)) return true;
329
+ const methods = await users.signInMethods(user.id);
330
+ return sendEmail(user, type, { link: target.href, site: target.host, locale, methods });
331
+ };
264
332
  const consume = async (data, purpose) => {
265
333
  if (typeof data.token === "string" && data.token) {
266
334
  return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
@@ -322,42 +390,31 @@ function passwordRoutes(app, ctx, password, users, webhook) {
322
390
  if (policy) return error(c, 400, "weak_password", policy);
323
391
  const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
324
392
  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
- }
393
+ const locale = localeOf(data.locale);
394
+ const check = await ctx.checkSignup({ email, name: name ?? void 0, locale, method: "password" });
395
+ if (!check.ok) return check.reason === "rejected" ? error(c, 403, "signup_rejected", check.message) : unavailable(c);
338
396
  const passwordHash = await hashPassword(data.password);
339
397
  const existing = await users.findByEmail(normalizeEmail(email));
340
398
  if (existing?.emailVerified) {
341
- if (!webhook.wants("email.already_registered")) return accepted(c);
342
- const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
343
- return sent ? accepted(c) : unavailable(c);
399
+ return await sendAccountEmail(existing, "email.already_registered", target, locale) ? accepted(c) : unavailable(c);
344
400
  }
345
401
  if (existing) {
346
402
  if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
347
403
  const account = await users.passwordAccount(existing.id);
348
404
  if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
405
+ else await users.linkAccount(existing.id, { key: passwordAccountKey(existing.id), secret: passwordHash });
349
406
  await users.updateUser(existing.id, { name });
350
407
  return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
351
408
  }
352
- const user = await users.createPasswordUser({
409
+ const user = await users.createUser({
353
410
  email,
354
411
  emailNormalized: normalizeEmail(email),
355
412
  name,
356
- passwordHash,
357
- emailVerified: false
413
+ emailVerified: false,
414
+ account: (userId) => ({ key: passwordAccountKey(userId), secret: passwordHash })
358
415
  });
359
416
  if (!user) return accepted(c);
360
- await ctx.emit("user.created", { user: toMadauthUser(user) });
417
+ await ctx.emit("user.created", { user: toMadauthUser(user), method: "password" });
361
418
  return await sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
362
419
  });
363
420
  app.post("/auth/password/send-verification", async (c) => {
@@ -367,7 +424,7 @@ function passwordRoutes(app, ctx, password, users, webhook) {
367
424
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
368
425
  const user = await users.findByEmail(normalizeEmail(data.email));
369
426
  if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
370
- if (!await sendLinkEmail(user, "verify", target, localeOf(data))) return unavailable(c);
427
+ if (!await sendLinkEmail(user, "verify", target, localeOf(data.locale))) return unavailable(c);
371
428
  }
372
429
  return accepted(c);
373
430
  });
@@ -388,8 +445,11 @@ function passwordRoutes(app, ctx, password, users, webhook) {
388
445
  if (target instanceof Response) return target;
389
446
  if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
390
447
  const user = await users.findByEmail(normalizeEmail(data.email));
448
+ const locale = localeOf(data.locale);
391
449
  if (user && await users.passwordAccount(user.id)) {
392
- if (!await sendLinkEmail(user, "reset", target, localeOf(data))) return unavailable(c);
450
+ if (!await sendLinkEmail(user, "reset", target, locale)) return unavailable(c);
451
+ } else if (user) {
452
+ if (!await sendAccountEmail(user, "email.no_password", target, locale)) return unavailable(c);
393
453
  }
394
454
  return accepted(c);
395
455
  });
@@ -401,12 +461,13 @@ function passwordRoutes(app, ctx, password, users, webhook) {
401
461
  const user = consumed.userId ? await users.findById(consumed.userId) : null;
402
462
  const account = user ? await users.passwordAccount(user.id) : null;
403
463
  if (!user || !account) return invalidVerification(c, consumed);
404
- await users.updateAccount(account.id, { secret: await hashPassword(data.password), failedAttempts: 0, lockedUntil: 0 });
464
+ const secret = await hashPassword(data.password);
465
+ if (!await users.updateAccount(account.id, { secret, failedAttempts: 0, lockedUntil: 0 })) return invalidVerification(c, consumed);
405
466
  const sessionVersion = user.sessionVersion + 1;
406
467
  await users.updateUser(user.id, { emailVerified: true, sessionVersion, wrongCodes: 0 });
407
468
  await users.clearVerifications(user.id);
408
469
  const result = await ctx.startSession(c, toMadauthUser(user), ["pwd"], { sv: sessionVersion });
409
- await ctx.emit("password.reset", { user: result });
470
+ await ctx.emit("email.password_reset", { user: result });
410
471
  await ctx.emit("user.signed_in", { user: result, method: "password" });
411
472
  return c.json({ user: result });
412
473
  });
@@ -419,12 +480,9 @@ function createApp(config) {
419
480
  const keys = importSigningKeys(config.signingKey);
420
481
  const secure = issuer.startsWith("https:");
421
482
  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
- };
483
+ const users = new Users(config.store, deriveCodeKey(config.signingKey.d));
484
+ const settings = new Settings(config.store);
485
+ const available = (method) => method === "google" ? !!config.google : !!config.password;
428
486
  const issueSession = async (c, user, amr, extra) => {
429
487
  const signingKeys = await keys;
430
488
  const [sessionToken, renewalToken] = await Promise.all([
@@ -476,12 +534,29 @@ function createApp(config) {
476
534
  return url;
477
535
  },
478
536
  async startSession(c, user, amr, extra) {
479
- const signedIn = await withRoles(user);
480
- await issueSession(c, signedIn, amr, extra);
481
- return signedIn;
537
+ await issueSession(c, user, amr, extra);
538
+ return user;
482
539
  },
483
- users: config.password ? new Users(config.password.store, deriveCodeKey(config.signingKey.d)) : void 0,
540
+ users,
541
+ settings,
484
542
  webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
543
+ async enabled(method) {
544
+ return isOn(method, available, await settings.methods());
545
+ },
546
+ async checkSignup(data) {
547
+ if (!this.webhook?.wants("signup.before")) return { ok: true };
548
+ const check = await this.webhook.call("signup.before", data, WEBHOOK_TIMEOUT_MS);
549
+ if (!check.ok) {
550
+ console.error(`[madauth] Webhook "signup.before" failed: ${check.reason}`);
551
+ return { ok: false, reason: "unavailable" };
552
+ }
553
+ const answer = check.body;
554
+ if (answer?.allow === false) {
555
+ const message = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
556
+ return { ok: false, reason: "rejected", message };
557
+ }
558
+ return { ok: true };
559
+ },
485
560
  async emit(type, data) {
486
561
  if (!this.webhook?.wants(type)) return;
487
562
  const result = await this.webhook.call(type, data, EVENT_TIMEOUT_MS);
@@ -511,29 +586,28 @@ function createApp(config) {
511
586
  c.header("Cache-Control", "public, max-age=3600");
512
587
  return c.json({ keys: [publicJwk] });
513
588
  });
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
- );
589
+ app.get("/auth/config", async (c) => {
590
+ const methods = await settings.methods();
591
+ const on = (method) => isOn(method, available, methods);
592
+ return c.json({
593
+ google: on("google") ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
594
+ password: on("password") ? { minLength: config.password.minLength } : null
595
+ });
596
+ });
521
597
  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);
598
+ if (config.password && ctx.webhook) passwordRoutes(app, ctx, config.password, users, ctx.webhook);
523
599
  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
600
  const checked = async (claims) => {
530
- const [ended, user] = await Promise.all([hasEnded(claims), withRoles(userFromClaims(claims))]);
531
- return ended ? null : user;
601
+ const stored = await users.findById(claims.sub);
602
+ if (!stored || stored.sessionVersion !== claims.sv) return null;
603
+ const { claims: _, ...user } = userFromClaims(claims);
604
+ const current = claimsFromJson(stored.claims);
605
+ return Object.keys(current).length ? { ...user, claims: current } : user;
532
606
  };
533
607
  const currentSession = async (c) => {
534
608
  const session = await tokenClaims(c, SESSION_COOKIE, SESSION_TYP);
535
609
  const user = session ? await checked(session) : null;
536
- const upToDate = session && user && sameRoles(user.roles ?? [], session.roles ?? []) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
610
+ const upToDate = session && user && sameClaims(user.claims, session.claims) && Date.now() / 1e3 - session.iat <= sessionTtlSeconds / 2;
537
611
  if (upToDate) return user;
538
612
  const renewal = await tokenClaims(c, RENEWAL_COOKIE, RENEWAL_TYP);
539
613
  const sameSignIn = session && user && renewal && renewal.sub === session.sub && renewal.sv === session.sv && renewal.email === session.email;
@@ -559,56 +633,82 @@ function createApp(config) {
559
633
  app.post("/auth/account/delete", async (c) => {
560
634
  const user = await currentSession(c);
561
635
  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;
636
+ await users.deleteById(user.id);
564
637
  clearSession(c);
565
- await ctx.emit("user.deleted", { user, passwordUserId: passwordUserId ?? void 0 });
638
+ await ctx.emit("user.deleted", { user });
566
639
  return c.body(null, 204);
567
640
  });
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
- }
641
+ const admin = async (c) => {
642
+ const user = await currentSession(c);
643
+ if (!user) return { answer: c.json({ error: "no_session" }, 401) };
644
+ const email = user.email ? normalizeEmail(user.email) : void 0;
645
+ if (!email || !isAdmin(user)) {
646
+ const message = `Only users with the role "${ADMIN_ROLE}" can manage claims and settings.`;
647
+ return { answer: c.json({ error: "forbidden", message }, 403) };
648
+ }
649
+ return { email };
650
+ };
651
+ const body = async (c) => {
652
+ const data = await c.req.json().catch(() => null);
653
+ return data && typeof data === "object" ? data : {};
654
+ };
655
+ const invalidEmail = (c) => c.json({ error: "invalid_email", message: "This is not a valid e-mail address." }, 400);
656
+ const noUser = (c) => c.json({ error: "user_not_found", message: "No user has this e-mail address." }, 404);
657
+ app.post("/auth/admin/claims/get", async (c) => {
658
+ const caller = await admin(c);
659
+ if ("answer" in caller) return caller.answer;
660
+ const data = await body(c);
661
+ if (!isValidEmail(data.email)) return invalidEmail(c);
662
+ const user = await users.findByEmail(normalizeEmail(data.email));
663
+ if (!user) return noUser(c);
664
+ return c.json({ email: user.emailNormalized, userId: user.id, claims: claimsFromJson(user.claims) });
665
+ });
666
+ app.post("/auth/admin/claims/set", async (c) => {
667
+ const caller = await admin(c);
668
+ if ("answer" in caller) return caller.answer;
669
+ const data = await body(c);
670
+ if (!isValidEmail(data.email)) return invalidEmail(c);
671
+ const claims = parseClaims(data.claims);
672
+ if (!claims) {
673
+ return c.json(
674
+ {
675
+ error: "invalid_claims",
676
+ 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 "_".'
677
+ },
678
+ 400
679
+ );
680
+ }
681
+ const user = await users.findByEmail(normalizeEmail(data.email));
682
+ if (!user) return noUser(c);
683
+ await users.updateUser(user.id, { claims: Object.keys(claims).length ? JSON.stringify(claims) : null });
684
+ const email = user.emailNormalized;
685
+ await ctx.emit("user.claims_changed", { userId: user.id, email, claims, by: caller.email });
686
+ return c.json({ email, userId: user.id, claims });
687
+ });
688
+ const settingsAnswer = (methods) => ({
689
+ methods: Object.fromEntries(
690
+ SIGN_IN_METHODS.map((method) => [method, { available: available(method), enabled: isOn(method, available, methods) }])
691
+ )
692
+ });
693
+ app.post("/auth/admin/settings/get", async (c) => {
694
+ const caller = await admin(c);
695
+ if ("answer" in caller) return caller.answer;
696
+ return c.json(settingsAnswer(await settings.methods()));
697
+ });
698
+ app.post("/auth/admin/settings/set", async (c) => {
699
+ const caller = await admin(c);
700
+ if ("answer" in caller) return caller.answer;
701
+ const data = await body(c);
702
+ const invalid = (message) => c.json({ error: "invalid_settings", message }, 400);
703
+ const changes = parseMethodSettings(data.methods);
704
+ if (!changes) return invalid(`methods must be an object with true or false for ${SIGN_IN_METHODS.join(" and/or ")}.`);
705
+ const methods = await settings.changeMethods((current) => {
706
+ const next = { ...current, ...changes };
707
+ return anyOn(available, next) ? next : null;
708
+ }, caller.email);
709
+ if (!methods) return invalid("At least one sign-in method must stay on.");
710
+ return c.json(settingsAnswer(methods));
711
+ });
612
712
  return app;
613
713
  }
614
714