@k2b/cloud 0.17.0 → 0.18.0

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.
@@ -34,7 +34,8 @@ type CreateUserData = {
34
34
  provider: UserProvider;
35
35
  profile: UserProfile;
36
36
  admin?: boolean;
37
- email: string;
37
+ /** Optional only for local full accounts while `user.local_email_optional` is on. */
38
+ email?: string | null;
38
39
  givenname: string;
39
40
  sn: string;
40
41
  displayName?: string;
@@ -47,7 +48,8 @@ type UpdateUserData = {
47
48
  givenname?: string;
48
49
  sn?: string;
49
50
  displayName?: string;
50
- mail?: string;
51
+ /** `null` removes the address of a local account when its policy allows that. */
52
+ mail?: string | null;
51
53
  ipa?: {
52
54
  phone?: string;
53
55
  address?: {
@@ -456,6 +458,8 @@ export const create = async (params: {
456
458
  return { ok: true, data: { user } };
457
459
  }
458
460
 
461
+ const email = params.data.email?.trim();
462
+ if (!email) return { ok: false, error: "FreeIPA accounts need an email address.", status: 400 };
459
463
  const serviceSession = await getServiceIpaSession();
460
464
  if (!serviceSession.ok) return serviceSession;
461
465
 
@@ -464,7 +468,7 @@ export const create = async (params: {
464
468
  profile: params.data.profile,
465
469
  accountExpires,
466
470
  data: {
467
- email: params.data.email,
471
+ email,
468
472
  givenname: params.data.givenname,
469
473
  sn: params.data.sn,
470
474
  displayName: params.data.displayName,
@@ -489,12 +493,14 @@ export const update = async (params: { id: string; data: UpdateUserData }): Prom
489
493
  if (!user) return { ok: false, error: "User not found", status: 404 };
490
494
 
491
495
  if (user.provider === "ipa") {
496
+ const { mail, ...data } = params.data;
497
+ if (mail === null) return { ok: false, error: "FreeIPA accounts need an email address.", status: 400 };
492
498
  const serviceSession = await getServiceIpaSession();
493
499
  if (!serviceSession.ok) return serviceSession;
494
500
  return providers.ipa.users.update({
495
501
  ipaSession: serviceSession.data,
496
502
  id: params.id,
497
- data: params.data,
503
+ data: mail === undefined ? data : { ...data, mail },
498
504
  });
499
505
  }
500
506
 
@@ -578,6 +584,16 @@ export const setExpiry = async (params: {
578
584
  });
579
585
  };
580
586
 
587
+ /**
588
+ * Administrators never receive sign-in tokens for the shared emergency
589
+ * `admin` identity. Break-glass access uses `ADMIN_LOGIN_TOKEN`, so everyday
590
+ * actions stay attributed to the administrator who performs them.
591
+ */
592
+ const emergencyAdminSignInError = (user: { uid: string }): MutationResult<never> | null =>
593
+ user.uid === providers.local.users.EMERGENCY_ADMIN_UID
594
+ ? { ok: false, error: "The emergency admin account has no login tokens or links. Use the admin login token instead.", status: 403 }
595
+ : null;
596
+
581
597
  export const sendLoginLink = async (params: {
582
598
  id: string;
583
599
  notificationSender: AccountsNotificationSender;
@@ -588,9 +604,11 @@ export const sendLoginLink = async (params: {
588
604
  if (user.provider !== "local") {
589
605
  return { ok: false, error: "Login links are only available for local accounts", status: 400 };
590
606
  }
607
+ const emergencyError = emergencyAdminSignInError(user);
608
+ if (emergencyError) return emergencyError;
591
609
  if (!user.mail) return { ok: false, error: "A local account requires an email address to receive a login link", status: 400 };
592
610
 
593
- const token = await providers.local.auth.createMagicLinkToken({ email: user.mail, ttlSeconds: 300 });
611
+ const token = await providers.local.auth.createAccountLoginToken({ userId: user.id, ttlSeconds: 300 });
594
612
  const rawAppUrl = await settings.get<string>("app.url");
595
613
  const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
596
614
  try {
@@ -615,15 +633,11 @@ export const createLoginToken = async (params: {
615
633
  if (user.provider !== "local") {
616
634
  return { ok: false, error: "Login tokens are only available for local accounts", status: 400 };
617
635
  }
618
- if (!user.mail) {
619
- return { ok: false, error: "A local account requires an email address before a login token can be created", status: 400 };
620
- }
636
+ const emergencyError = emergencyAdminSignInError(user);
637
+ if (emergencyError) return emergencyError;
621
638
 
622
639
  const expiresInSeconds = 300;
623
- const token = await providers.local.auth.createMagicLinkToken({
624
- email: user.mail,
625
- ttlSeconds: expiresInSeconds,
626
- });
640
+ const token = await providers.local.auth.createAccountLoginToken({ userId: user.id, ttlSeconds: expiresInSeconds });
627
641
  const rawAppUrl = await settings.get<string>("app.url");
628
642
  const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
629
643
 
@@ -115,6 +115,8 @@ type PairingRow = {
115
115
  initiated_by: string;
116
116
  initiator_sid: string;
117
117
  assisted: boolean;
118
+ /** The target account's epoch at start; a sign-out everywhere moves it and voids the pairing. */
119
+ auth_epoch: string | null;
118
120
  secret_hash: string;
119
121
  state: string;
120
122
  expires_at: Date;
@@ -137,6 +139,8 @@ type LoginRow = {
137
139
  expires_at: Date;
138
140
  };
139
141
  export type AppApprovalActor = { userId: string; sid: string; admin: boolean };
142
+ /** Administration of another account's devices needs current admin authority, not a recent session. */
143
+ export type AppDeviceAdministrator = Pick<AppApprovalActor, "userId" | "admin">;
140
144
  export type AppDeviceEnrollmentNotice = { deviceId: string; userId: string; name: string; assisted: boolean };
141
145
  /** Best-effort wake-ups for browsers waiting on a login decision. Postgres stays authoritative. */
142
146
  export type AppLoginDecisionHints = {
@@ -250,7 +254,7 @@ export const createAppApprovalService = (
250
254
  (pairing.assisted && (!actor.admin || !cfg.adminPairing))
251
255
  )
252
256
  return reject("FORBIDDEN", 403);
253
- await eligible(tx, pairing.user_id);
257
+ return eligible(tx, pairing.user_id);
254
258
  };
255
259
  const activeDevice = async (tx: SQL, id: string, issuer: string): Promise<DeviceRow> => {
256
260
  const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices WHERE id = ${id}::uuid AND issuer = ${issuer} FOR UPDATE`;
@@ -282,14 +286,17 @@ export const createAppApprovalService = (
282
286
  cleanup,
283
287
  maintain: async (notify: (notice: AppDeviceEnrollmentNotice) => Promise<unknown>, signal?: AbortSignal) => {
284
288
  await cleanup();
285
- const rows = await db<
286
- DeviceRow[]
287
- >`SELECT * FROM auth.app_devices WHERE notified_at IS NULL ORDER BY created_at LIMIT ${limits.pageSize}`;
289
+ const rows = await db<(DeviceRow & { has_email: boolean })[]>`
290
+ SELECT d.*, COALESCE(btrim(u.mail), '') <> '' AS has_email
291
+ FROM auth.app_devices d JOIN auth.users u ON u.id = d.user_id
292
+ WHERE d.notified_at IS NULL ORDER BY d.created_at LIMIT ${limits.pageSize}`;
288
293
  for (const row of rows) {
289
294
  signal?.throwIfAborted();
290
295
  // The notification owner deduplicates by deviceId. A crash after send
291
296
  // but before this marker is safe; no external effect precedes enrollment.
292
- await notify({ deviceId: row.id, userId: row.user_id, name: row.name, assisted: row.assisted });
297
+ // The notice is email-only, so an account without email relies on the
298
+ // enrollment audit entry instead of a delivery that can only fail.
299
+ if (row.has_email) await notify({ deviceId: row.id, userId: row.user_id, name: row.name, assisted: row.assisted });
293
300
  await db`UPDATE auth.app_devices SET notified_at=now() WHERE id=${row.id}::uuid AND notified_at IS NULL`;
294
301
  }
295
302
  },
@@ -314,13 +321,13 @@ export const createAppApprovalService = (
314
321
  await fresh(tx, actor);
315
322
  if (targetId !== actor.userId && (!actor.admin || !cfg.adminPairing)) return reject("FORBIDDEN", 403);
316
323
  await tx`SELECT id FROM auth.users WHERE id = ${targetId}::uuid FOR UPDATE`;
317
- await eligible(tx, targetId);
324
+ const target = await eligible(tx, targetId);
318
325
  const [count] = await tx<
319
326
  { count: number }[]
320
327
  >`SELECT count(*)::int AS count FROM auth.app_pairings WHERE issuer = ${cfg.issuer} AND user_id = ${targetId}::uuid AND expires_at > now() AND state IN ('pending','claimed')`;
321
328
  if ((count?.count ?? 0) >= limits.pendingPerAccount) return reject("LIMIT_REACHED", 429);
322
- await tx`INSERT INTO auth.app_pairings(id, issuer, user_id, initiated_by, initiator_sid, assisted, secret_hash, expires_at)
323
- VALUES (${id}::uuid,${cfg.issuer},${targetId}::uuid,${actor.userId}::uuid,${actor.sid}::uuid,${targetId !== actor.userId},${hash(token)},${expiresAt})`;
329
+ await tx`INSERT INTO auth.app_pairings(id, issuer, user_id, initiated_by, initiator_sid, assisted, auth_epoch, secret_hash, expires_at)
330
+ VALUES (${id}::uuid,${cfg.issuer},${targetId}::uuid,${actor.userId}::uuid,${actor.sid}::uuid,${targetId !== actor.userId},${target.auth_epoch},${hash(token)},${expiresAt})`;
324
331
  await record(tx, "pairing.start", actor.userId, id, { targetUserId: targetId, assisted: targetId !== actor.userId });
325
332
  return { protocol: APP_APPROVAL_PROTOCOL, issuer: cfg.issuer, pairingId: id, secret: token, expiresAt: iso(expiresAt) };
326
333
  });
@@ -356,7 +363,8 @@ export const createAppApprovalService = (
356
363
  return db.begin(async (tx) => {
357
364
  const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
358
365
  if (!p || new Date(p.expires_at).getTime() <= Date.now()) return reject("UNAVAILABLE", 404);
359
- await authorizePairing(tx, actor, p, cfg);
366
+ // A pairing from before the account was signed out everywhere reads as expired.
367
+ if ((await authorizePairing(tx, actor, p, cfg)).auth_epoch !== p.auth_epoch) return reject("UNAVAILABLE", 404);
360
368
  return { state: p.state, name: p.name, comparison: p.comparison, deviceId: p.device_id, userId: p.user_id };
361
369
  });
362
370
  },
@@ -373,8 +381,11 @@ export const createAppApprovalService = (
373
381
  !p.name
374
382
  )
375
383
  return reject("CONFLICT", 409);
376
- await authorizePairing(tx, actor, p, cfg);
384
+ // Lock the account before checking the session and epoch: a concurrent sign-out
385
+ // everywhere either revokes this device after it exists, or fails these checks.
386
+ // The epoch also voids assisted pairings, whose administrator session outlives it.
377
387
  await tx`SELECT id FROM auth.users WHERE id = ${p.user_id}::uuid FOR UPDATE`;
388
+ if ((await authorizePairing(tx, actor, p, cfg)).auth_epoch !== p.auth_epoch) return reject("CONFLICT", 409);
378
389
  const [count] = await tx<
379
390
  { count: number }[]
380
391
  >`SELECT count(*)::int AS count FROM auth.app_devices WHERE issuer = ${cfg.issuer} AND user_id = ${p.user_id}::uuid AND revoked_at IS NULL`;
@@ -423,6 +434,31 @@ export const createAppApprovalService = (
423
434
  await record(tx, name === undefined ? "device.revoke" : "device.rename", actor.userId, id);
424
435
  });
425
436
  },
437
+ /** Active devices of any account, for administrators. Bounded by the active-device limit. */
438
+ listUserDevices: async (actor: AppDeviceAdministrator, userId: string): Promise<AppDeviceView[]> => {
439
+ if (!actor.admin) return reject("FORBIDDEN", 403);
440
+ const cfg = await config(false);
441
+ const rows = await db<
442
+ DeviceRow[]
443
+ >`SELECT * FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL ORDER BY created_at, id LIMIT ${limits.devicesPerAccount}`;
444
+ return rows.map(view);
445
+ },
446
+ /** Idempotent: `revoked` is true only for the call that revoked the device. Works while the account is
447
+ * expired or app sign-in is disabled, because revocation only removes access. */
448
+ revokeUserDevice: async (actor: AppDeviceAdministrator, userId: string, id: string) => {
449
+ if (!actor.admin) return reject("FORBIDDEN", 403);
450
+ const cfg = await config(false);
451
+ return db.begin(async (tx) => {
452
+ const [device] = await tx<
453
+ DeviceRow[]
454
+ >`SELECT * FROM auth.app_devices WHERE id=${id}::uuid AND issuer=${cfg.issuer} AND user_id=${userId}::uuid FOR UPDATE`;
455
+ if (!device) return reject("UNAVAILABLE", 404);
456
+ if (device.revoked_at) return { device: view(device), revoked: false };
457
+ const [row] = await tx<DeviceRow[]>`UPDATE auth.app_devices SET revoked_at=now() WHERE id=${id}::uuid RETURNING *`;
458
+ await record(tx, "device.revoke", actor.userId, id, { targetUserId: userId });
459
+ return { device: view(row!), revoked: true };
460
+ });
461
+ },
426
462
  startLogin: async (identifier: string, category: AccountCategory) => {
427
463
  const cfg = await config();
428
464
  await cleanup();
@@ -488,6 +524,17 @@ export const createAppApprovalService = (
488
524
  await tx`UPDATE auth.app_devices SET push_token=${command.token} WHERE id=${device.id}::uuid`;
489
525
  return { state: "updated" as const };
490
526
  }
527
+ if (command.operation === "account") {
528
+ // Only the account this device signs in; activeDevice already checked it exists and may use app approval.
529
+ const [user] = await tx<
530
+ { uid: string; display_name: string; mail: string | null }[]
531
+ >`SELECT uid, display_name, mail FROM auth.users WHERE id=${device.user_id}::uuid`;
532
+ if (!user) return reject("FORBIDDEN", 403);
533
+ return {
534
+ account: { uid: user.uid, displayName: user.display_name, mail: user.mail || null },
535
+ device: { name: device.name, createdAt: iso(device.created_at) },
536
+ };
537
+ }
491
538
  if (command.operation === "revoke") {
492
539
  await tx`UPDATE auth.app_devices SET revoked_at=now() WHERE id=${device.id}::uuid`;
493
540
  await record(tx, "device.revoke", device.user_id, device.id);
@@ -0,0 +1,41 @@
1
+ import { logger } from "../logging";
2
+
3
+ const log = logger("auth:deferred");
4
+
5
+ /**
6
+ * Enumeration-safe requests answer before their lookup and delivery run. That
7
+ * work takes a few Postgres and Valkey round trips, so the backlog stays near
8
+ * zero; it reaches this limit only while a backend stalls. Further requests
9
+ * are then dropped until the backlog shrinks. Their answer is the same either
10
+ * way, so dropping reveals nothing.
11
+ */
12
+ export const MAX_DEFERRED_REQUESTS = 100;
13
+
14
+ const running = new Set<Promise<void>>();
15
+ let dropped = 0;
16
+
17
+ /**
18
+ * Starts `work` without holding up the caller's answer. The returned promise
19
+ * settles when the work has finished or was dropped; it never rejects.
20
+ */
21
+ export const run = (label: string, work: () => Promise<void>): Promise<void> => {
22
+ if (running.size >= MAX_DEFERRED_REQUESTS) {
23
+ // One entry per overload episode; a log row per dropped request would add load.
24
+ if (dropped++ === 0) log.warn("Dropping sign-in requests while the backlog is full", { limit: MAX_DEFERRED_REQUESTS });
25
+ return Promise.resolve();
26
+ }
27
+ if (dropped > 0) {
28
+ log.warn("Accepting sign-in requests again", { dropped });
29
+ dropped = 0;
30
+ }
31
+ const task: Promise<void> = work()
32
+ .catch((error) => log.error(`${label} failed`, { error: error instanceof Error ? error.message : String(error) }))
33
+ .finally(() => running.delete(task));
34
+ running.add(task);
35
+ return task;
36
+ };
37
+
38
+ /** Waits for accepted work so a graceful shutdown does not lose it. */
39
+ export const drain = async (): Promise<void> => {
40
+ await Promise.all(running);
41
+ };
@@ -7,6 +7,7 @@ import { accounts } from "../accounts";
7
7
  import { logger } from "../logging";
8
8
  import { providers } from "../providers";
9
9
  import * as settings from "../settings";
10
+ import * as deferred from "./deferred";
10
11
  import type { AuthNotificationSender } from "./notification-sender";
11
12
 
12
13
  const log = logger("auth:magic-link");
@@ -60,13 +61,24 @@ const resolveEmailForUsername = async (username: string): Promise<string | null>
60
61
  return mail ? normalizeEmail(mail) : null;
61
62
  };
62
63
 
63
- export const request = async (
64
- params: { email: string; redirectTo?: string; locale?: string; category?: "guest" | "login" },
65
- notificationSender: AuthNotificationSender,
66
- ): Promise<{ ok: true } | { ok: false; status: 400; message: string }> => {
64
+ type SignInLinkRequest = { email: string; redirectTo?: string; locale?: string; category?: "guest" | "login" };
65
+
66
+ /**
67
+ * Accepts a sign-in link request without revealing whether an account exists.
68
+ * The lookup and delivery run after this returns, so existing, unknown and
69
+ * mail-less accounts get the same response in the same time. `settled`
70
+ * resolves when that bounded background work has finished or was dropped
71
+ * under overload; it never rejects.
72
+ */
73
+ export const request = (params: SignInLinkRequest, notificationSender: AuthNotificationSender): { ok: true; settled: Promise<void> } => ({
74
+ ok: true,
75
+ settled: deferred.run("Sign-in link request", () => deliverSignInLink(params, notificationSender)),
76
+ });
77
+
78
+ const deliverSignInLink = async (params: SignInLinkRequest, notificationSender: AuthNotificationSender): Promise<void> => {
67
79
  const identifier = params.email.trim();
68
80
  const email = identifier.includes("@") ? normalizeEmail(identifier) : await resolveEmailForUsername(identifier);
69
- if (!email) return { ok: true };
81
+ if (!email) return;
70
82
  const hasIpaUser = await hasIpaAccountForEmail(email);
71
83
  const userRows = hasIpaUser
72
84
  ? []
@@ -77,25 +89,25 @@ export const request = async (
77
89
  const allowSelfRegistration = await settings.get<boolean>("user.allow_self_registration");
78
90
 
79
91
  if (hasIpaUser) {
80
- if (!(await isAccountCategoryAllowed({ provider: "ipa", profile: "user" }))) return { ok: true };
92
+ if (!(await isAccountCategoryAllowed({ provider: "ipa", profile: "user" }))) return;
81
93
  if (await claimIpaHintCooldown(email)) {
82
- void sendIpaEmailLoginHint({ email, redirectTo: params.redirectTo, locale: params.locale }, notificationSender).catch((error) => {
94
+ await sendIpaEmailLoginHint({ email, redirectTo: params.redirectTo, locale: params.locale }, notificationSender).catch((error) => {
83
95
  log.warn("Failed to send FreeIPA email-login hint", {
84
96
  email,
85
97
  error: error instanceof Error ? error.message : String(error),
86
98
  });
87
99
  });
88
100
  }
89
- return { ok: true };
101
+ return;
90
102
  }
91
103
 
92
104
  if (localUser && ((params.category && accountCategory(localUser) !== params.category) || !(await isAccountCategoryAllowed(localUser))))
93
- return { ok: true };
105
+ return;
94
106
  if (
95
107
  !localUser &&
96
108
  (!allowSelfRegistration || params.category === "login" || !(await isAccountCategoryAllowed({ provider: "local", profile: "guest" })))
97
109
  ) {
98
- return { ok: true };
110
+ return;
99
111
  }
100
112
 
101
113
  const token = await providers.local.auth.createMagicLinkToken({ email, category: params.category, ttlSeconds: 300 });
@@ -106,27 +118,51 @@ export const request = async (
106
118
  const result = await notificationSender.sendMagicLink({ email, token, magicLink, locale: params.locale });
107
119
  if (result.status === "error") log.error("Magic link delivery failed", { notificationId: result.id });
108
120
  } catch (error) {
109
- // Keep the response generic to prevent account enumeration. The durable
110
- // sender records accepted delivery failures; pre-persistence failures land here.
121
+ // The durable sender records accepted delivery failures; pre-persistence failures land here.
111
122
  log.error("Magic link notification could not be accepted", {
112
123
  error: error instanceof Error ? error.message : String(error),
113
124
  });
114
125
  }
115
-
116
- return { ok: true };
117
126
  };
118
127
 
119
- export const verify = async (params: {
120
- token: string;
121
- }): Promise<
122
- | { ok: true; userId: string; user: User; email: string; createdGuest: boolean }
128
+ type VerifyResult =
129
+ | { ok: true; userId: string; user: User; email: string | null; createdGuest: boolean }
123
130
  | { ok: false; status: 401; message: string }
124
- | { ok: false; status: number; message: string }
125
- > => {
131
+ | { ok: false; status: number; message: string };
132
+
133
+ const signIn = async (params: {
134
+ userId: string;
135
+ email?: string;
136
+ createdGuest?: boolean;
137
+ category?: "guest" | "login";
138
+ }): Promise<VerifyResult> => {
139
+ const user = await accounts.users.get({ id: params.userId });
140
+ if (!user) {
141
+ return { ok: false, status: 401, message: "User not found" };
142
+ }
143
+ if (!(await isAccountCategoryAllowed(user)) || (params.category && accountCategory(user) !== params.category))
144
+ return { ok: false, status: 403, message: "This account cannot sign in through this category. Contact an administrator." };
145
+ return { ok: true, userId: params.userId, user, email: params.email ?? user.mail, createdGuest: params.createdGuest ?? false };
146
+ };
147
+
148
+ /** Administrator-issued tokens name the account itself, so they work without an email address. */
149
+ const verifyAccountToken = async (userId: string): Promise<VerifyResult> => {
150
+ const [row] = await sql<{ expired: boolean }[]>`
151
+ SELECT account_expires IS NOT NULL AND account_expires <= now() AS expired
152
+ FROM auth.users
153
+ WHERE id = ${userId}::uuid AND provider = 'local'
154
+ `;
155
+ if (!row) return { ok: false, status: 401, message: "Invalid or expired token" };
156
+ if (row.expired) return { ok: false, status: 403, message: "Your account has expired. Contact an administrator." };
157
+ return signIn({ userId });
158
+ };
159
+
160
+ export const verify = async (params: { token: string }): Promise<VerifyResult> => {
126
161
  const payload = await providers.local.auth.consumeMagicLinkToken(params.token);
127
162
  if (!payload) {
128
163
  return { ok: false, status: 401, message: "Invalid or expired token" };
129
164
  }
165
+ if ("userId" in payload) return verifyAccountToken(payload.userId);
130
166
 
131
167
  const { email, category } = payload;
132
168
  const normalizedEmail = normalizeEmail(email);
@@ -186,12 +222,5 @@ export const verify = async (params: {
186
222
  createdGuest = true;
187
223
  }
188
224
 
189
- const user = await accounts.users.get({ id: userId });
190
- if (!user) {
191
- return { ok: false, status: 401, message: "User not found" };
192
- }
193
- if (!(await isAccountCategoryAllowed(user)) || (category && accountCategory(user) !== category))
194
- return { ok: false, status: 403, message: "This account cannot sign in through this category. Contact an administrator." };
195
-
196
- return { ok: true, userId, user, email, createdGuest };
225
+ return signIn({ userId, email, createdGuest, category });
197
226
  };
@@ -8,6 +8,7 @@ import { logger } from "../logging";
8
8
  import { providers } from "../providers";
9
9
  import { session } from "../session";
10
10
  import * as settings from "../settings";
11
+ import * as deferred from "./deferred";
11
12
  import * as ipaFlow from "./ipa";
12
13
  import type { AuthNotificationDeliveryResult, AuthNotificationSender } from "./notification-sender";
13
14
 
@@ -15,7 +16,8 @@ const log = logger("auth:password-reset");
15
16
 
16
17
  const REQUEST_TTL_SECONDS = 900;
17
18
  const REQUEST_COOLDOWN_SECONDS = 60;
18
- const GENERIC_MESSAGE = "If this account can reset a password, a reset link has been sent.";
19
+ const GENERIC_MESSAGE =
20
+ "If this account can reset a password, a reset link has been sent. If no message arrives, contact an administrator.";
19
21
 
20
22
  type ResetTarget = {
21
23
  userId: string;
@@ -161,27 +163,42 @@ const changeTemporaryPassword = async (params: {
161
163
  };
162
164
  };
163
165
 
164
- export const request = async (
165
- params: { email: string; redirectTo?: string; locale?: string },
166
+ type ResetRequest = { email: string; redirectTo?: string; locale?: string };
167
+
168
+ /**
169
+ * Accepts a reset request without revealing whether an eligible account
170
+ * exists. The lookup and delivery run after this returns, so every address
171
+ * gets the same message in the same time. `settled` resolves when that
172
+ * bounded background work has finished or was dropped under overload; it
173
+ * never rejects.
174
+ */
175
+ export const request = (
176
+ params: ResetRequest,
166
177
  notificationSender: AuthNotificationSender,
167
- ): Promise<{ ok: true; message: string }> => {
168
- if (!(await isAccountCategoryAllowed({ provider: "ipa", profile: "user" }))) return { ok: true, message: GENERIC_MESSAGE };
178
+ ): { ok: true; message: string; settled: Promise<void> } => ({
179
+ ok: true,
180
+ message: GENERIC_MESSAGE,
181
+ settled: deferred.run("Password reset request", () => deliverResetLink(params, notificationSender)),
182
+ });
183
+
184
+ const deliverResetLink = async (params: ResetRequest, notificationSender: AuthNotificationSender): Promise<void> => {
185
+ if (!(await isAccountCategoryAllowed({ provider: "ipa", profile: "user" }))) return;
169
186
  const email = normalizeEmail(params.email);
170
187
  if (await isInCooldown(email)) {
171
188
  log.info("Password reset request ignored during cooldown");
172
- return { ok: true, message: GENERIC_MESSAGE };
189
+ return;
173
190
  }
174
191
 
175
192
  const freeIpaConfig = await getFreeIpaConfig();
176
193
  if (!freeIpaConfig.enabled || !freeIpaConfig.configured) {
177
194
  log.info("Password reset request accepted while FreeIPA is unavailable");
178
- return { ok: true, message: GENERIC_MESSAGE };
195
+ return;
179
196
  }
180
197
 
181
198
  const target = await resolveResetTarget(email);
182
199
  if (!target) {
183
200
  log.info("Password reset request accepted without eligible target");
184
- return { ok: true, message: GENERIC_MESSAGE };
201
+ return;
185
202
  }
186
203
 
187
204
  try {
@@ -194,7 +211,6 @@ export const request = async (
194
211
  error: error instanceof Error ? error.message : String(error),
195
212
  });
196
213
  }
197
- return { ok: true, message: GENERIC_MESSAGE };
198
214
  };
199
215
 
200
216
  export const complete = async (params: { token?: string; newPassword: string }): Promise<ResetAttemptSuccess | ResetAttemptFailure> => {
@@ -194,7 +194,7 @@ export type {
194
194
 
195
195
  export { latestTopicCursor } from "./topic-cursor";
196
196
  export { readAccountCategoryPolicy, isAccountCategoryAllowed } from "./account-category-policy";
197
- export { appApproval, type AppDeviceEnrollmentNotice } from "./app-approval";
197
+ export { AppApprovalError, appApproval, type AppDeviceAdministrator, type AppDeviceEnrollmentNotice } from "./app-approval";
198
198
  export { legalConsent } from "./legal-consent";
199
199
 
200
200
  /** Core-owned app bar administration; service methods enforce administrator access. */
@@ -1,4 +1,5 @@
1
1
  import * as settings from "../settings";
2
+ import { offlineHtml } from "./offline-html";
2
3
 
3
4
  export type GotenbergRenderErrorCode =
4
5
  | "bad_input"
@@ -131,9 +132,9 @@ function htmlForm(input: RenderHtmlToPdfInput, config: GotenbergConfig): FormDat
131
132
  (input.assets ?? []).reduce((sum, asset) => sum + asset.data.size, 0);
132
133
  if (total > config.maxHtmlBytes) throw new GotenbergRenderError("html_too_large", "HTML and assets exceed the configured input budget.");
133
134
  const form = new FormData();
134
- form.append("files", new Blob([input.html], { type: "text/html" }), "index.html");
135
- if (input.headerHtml?.trim()) form.append("files", new Blob([input.headerHtml], { type: "text/html" }), "header.html");
136
- if (input.footerHtml?.trim()) form.append("files", new Blob([input.footerHtml], { type: "text/html" }), "footer.html");
135
+ form.append("files", new Blob([offlineHtml(input.html)], { type: "text/html" }), "index.html");
136
+ if (input.headerHtml?.trim()) form.append("files", new Blob([offlineHtml(input.headerHtml)], { type: "text/html" }), "header.html");
137
+ if (input.footerHtml?.trim()) form.append("files", new Blob([offlineHtml(input.footerHtml)], { type: "text/html" }), "footer.html");
137
138
  const names = new Set(["index.html", "header.html", "footer.html", "factur-x.xml"]);
138
139
  for (const asset of input.assets ?? []) {
139
140
  const name = fileName(asset.name);
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Offline mode for every HTML document Cloud sends to Gotenberg.
3
+ *
4
+ * Chromium in Gotenberg would otherwise run scripts and load every URL the
5
+ * HTML names. The prepended policy allows only inline styles plus local files
6
+ * and data URLs for styles, images, and fonts. Local files are the request's
7
+ * named assets; Gotenberg's default deny list keeps file access inside its
8
+ * working directory under `/tmp`. Removing elements that run code, navigate,
9
+ * embed documents, or open connections is a second layer that does not rely
10
+ * on the policy, which matters for header and footer templates that Chromium
11
+ * renders apart from the document.
12
+ *
13
+ * MathML (`math`) and the SVG elements that hold HTML (`foreignObject`,
14
+ * `desc`) go as well; they have no print use in Cloud documents. SVG shapes,
15
+ * images, `title`, and links stay, so graphics still render.
16
+ */
17
+ const POLICY =
18
+ "default-src 'none'; style-src 'unsafe-inline' file: data:; img-src file: data:; font-src file: data:; base-uri 'none'; form-action 'none'";
19
+ const OFFLINE_META = `<meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${POLICY}">`;
20
+
21
+ /**
22
+ * The caller's doctype when it leads the document after whitespace, comments,
23
+ * and an XML declaration. Repeating it ahead of the policy keeps the caller's
24
+ * rendering mode: HTML without a doctype still renders in quirks mode. Comments
25
+ * end where HTML ends them, including `<!-->` and `<!--->`; `<?…>` ends at the
26
+ * first `>`. A single forward scan keeps the time linear in the input size.
27
+ */
28
+ const leadingDoctype = (html: string): string => {
29
+ let at = 0;
30
+ for (;;) {
31
+ while (/\s/.test(html.charAt(at))) at += 1;
32
+ if (html.startsWith("<!--", at)) {
33
+ if (html.startsWith(">", at + 4)) at += 5;
34
+ else if (html.startsWith("->", at + 4)) at += 6;
35
+ else {
36
+ const dashes = html.indexOf("-->", at + 4);
37
+ const bang = html.indexOf("--!>", at + 4);
38
+ if (dashes < 0 && bang < 0) return "";
39
+ at = dashes >= 0 && (bang < 0 || dashes < bang) ? dashes + 3 : bang + 4;
40
+ }
41
+ } else if (html.startsWith("<?", at)) {
42
+ const end = html.indexOf(">", at + 2);
43
+ if (end < 0) return "";
44
+ at = end + 1;
45
+ } else {
46
+ if (html.slice(at, at + 9).toLowerCase() !== "<!doctype") return "";
47
+ const end = html.indexOf(">", at + 9);
48
+ return end < 0 ? "" : html.slice(at, end + 1);
49
+ }
50
+ }
51
+ };
52
+
53
+ /** An empty comment stands in for a removed element, so the text on either side stays separate. */
54
+ const REMOVED = "<!---->";
55
+
56
+ export const offlineHtml = (html: string): string =>
57
+ leadingDoctype(html) +
58
+ OFFLINE_META +
59
+ new HTMLRewriter()
60
+ // Chromium parses noscript content as markup when JavaScript is disabled.
61
+ // math, foreignObject, and desc: see the module comment.
62
+ .on("script, noscript, meta, base, iframe, frame, frameset, object, embed, math, foreignObject, desc", {
63
+ element(element) {
64
+ element.replace(REMOVED, { html: true });
65
+ },
66
+ })
67
+ .on("link", {
68
+ element(element) {
69
+ // The policy limits a stylesheet to named assets and data URLs. Other
70
+ // link types can preload, prefetch, or connect outside of it.
71
+ if (element.getAttribute("rel")?.trim().toLowerCase() !== "stylesheet") element.replace(REMOVED, { html: true });
72
+ },
73
+ })
74
+ .transform(html);
@@ -1,24 +1,33 @@
1
1
  import { redis } from "bun";
2
2
 
3
+ /** Requested by email or username on the login page; resolves the account through its email at use. */
4
+ export type EmailLoginTokenPayload = { email: string; category?: "guest" | "login" };
5
+ /** Issued by an administrator for one account; works without an email address. */
6
+ export type AccountLoginTokenPayload = { userId: string };
7
+
8
+ const loginTokenKey = (token: string) => `email-login:${token}`;
9
+
3
10
  export const createMagicLinkToken = async (params: {
4
11
  email: string;
5
12
  category?: "guest" | "login";
6
13
  ttlSeconds?: number;
7
14
  }): Promise<string> => {
8
15
  const token = crypto.randomUUID();
9
- await redis.set(
10
- `email-login:${token}`,
11
- JSON.stringify({ email: params.email, category: params.category }),
12
- "EX",
13
- params.ttlSeconds ?? 300,
14
- );
16
+ await redis.set(loginTokenKey(token), JSON.stringify({ email: params.email, category: params.category }), "EX", params.ttlSeconds ?? 300);
17
+ return token;
18
+ };
19
+
20
+ /** Shares the email-link key space, so both token kinds use the same link and verify endpoint. */
21
+ export const createAccountLoginToken = async (params: { userId: string; ttlSeconds?: number }): Promise<string> => {
22
+ const token = crypto.randomUUID();
23
+ await redis.set(loginTokenKey(token), JSON.stringify({ userId: params.userId }), "EX", params.ttlSeconds ?? 300);
15
24
  return token;
16
25
  };
17
26
 
18
- export const consumeMagicLinkToken = async (token: string): Promise<{ email: string; category?: "guest" | "login" } | null> => {
19
- const raw = await redis.getdel(`email-login:${token}`);
27
+ export const consumeMagicLinkToken = async (token: string): Promise<EmailLoginTokenPayload | AccountLoginTokenPayload | null> => {
28
+ const raw = await redis.getdel(loginTokenKey(token));
20
29
  if (!raw) return null;
21
- return JSON.parse(raw) as { email: string; category?: "guest" | "login" };
30
+ return JSON.parse(raw) as EmailLoginTokenPayload | AccountLoginTokenPayload;
22
31
  };
23
32
 
24
33
  type PasswordResetPayload = {