@jimhoyd/urlcode-auth 0.1.0-alpha.1 → 0.1.0-alpha.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.
Files changed (73) hide show
  1. package/dist/abuse-http.d.ts +8 -0
  2. package/dist/abuse-http.js +74 -0
  3. package/dist/abuse-store.d.ts +5 -0
  4. package/dist/abuse-store.js +40 -0
  5. package/dist/abuse.d.ts +27 -0
  6. package/dist/abuse.js +34 -0
  7. package/dist/admin-account-operations.d.ts +83 -0
  8. package/dist/admin-account-operations.js +50 -0
  9. package/dist/admin-account-store.d.ts +22 -0
  10. package/dist/admin-account-store.js +185 -0
  11. package/dist/auth-baseline.d.ts +30 -0
  12. package/dist/auth-baseline.js +153 -0
  13. package/dist/auth-core.d.ts +655 -0
  14. package/dist/auth-core.js +1066 -0
  15. package/dist/auth-flows.d.ts +30 -0
  16. package/dist/auth-flows.js +228 -0
  17. package/dist/auth-signup.d.ts +11 -0
  18. package/dist/auth-signup.js +145 -0
  19. package/dist/auth-store.d.ts +81 -0
  20. package/dist/auth-store.js +1601 -0
  21. package/dist/auth-templates.d.ts +13 -0
  22. package/dist/auth-templates.js +74 -0
  23. package/dist/auth-ui.d.ts +106 -0
  24. package/dist/auth-ui.js +205 -0
  25. package/dist/auth.d.ts +49 -0
  26. package/dist/auth.js +503 -0
  27. package/dist/backup.d.ts +18 -0
  28. package/dist/backup.js +121 -0
  29. package/dist/challenge-ui.d.ts +11 -0
  30. package/dist/challenge-ui.js +18 -0
  31. package/dist/challenge.d.ts +21 -0
  32. package/dist/challenge.js +65 -0
  33. package/dist/cli.d.ts +2 -0
  34. package/dist/cli.js +137 -0
  35. package/dist/deployment-check.d.ts +16 -0
  36. package/dist/deployment-check.js +41 -0
  37. package/dist/disposable-domain-data.d.ts +1 -0
  38. package/dist/disposable-domain-data.js +8886 -0
  39. package/dist/disposable-domains.d.ts +3 -0
  40. package/dist/disposable-domains.js +17 -0
  41. package/dist/email-copy.d.ts +114 -0
  42. package/dist/email-copy.js +58 -0
  43. package/dist/factor-recovery.d.ts +46 -0
  44. package/dist/factor-recovery.js +71 -0
  45. package/dist/index.d.ts +42 -0
  46. package/dist/index.js +18 -0
  47. package/dist/manual-recovery-store.d.ts +25 -0
  48. package/dist/manual-recovery-store.js +129 -0
  49. package/dist/manual-recovery.d.ts +87 -0
  50. package/dist/manual-recovery.js +35 -0
  51. package/dist/oidc.d.ts +31 -0
  52. package/dist/oidc.js +54 -0
  53. package/dist/passkeys.d.ts +24 -0
  54. package/dist/passkeys.js +29 -0
  55. package/dist/password-policy.d.ts +7 -0
  56. package/dist/password-policy.js +72 -0
  57. package/dist/presentation.d.ts +15 -0
  58. package/dist/presentation.js +458 -0
  59. package/dist/presets.d.ts +18 -0
  60. package/dist/presets.js +17 -0
  61. package/dist/providers.d.ts +13 -0
  62. package/dist/providers.js +15 -0
  63. package/dist/registration.d.ts +45 -0
  64. package/dist/registration.js +130 -0
  65. package/dist/scaffold.d.ts +44 -0
  66. package/dist/scaffold.js +212 -0
  67. package/dist/second-factor-flows.d.ts +28 -0
  68. package/dist/second-factor-flows.js +76 -0
  69. package/dist/senders.d.ts +86 -0
  70. package/dist/senders.js +153 -0
  71. package/dist/user-query.d.ts +28 -0
  72. package/dist/user-query.js +81 -0
  73. package/package.json +1 -1
@@ -0,0 +1,1066 @@
1
+ import { createAdminAccountOperations } from "./admin-account-operations.js";
2
+ import { isIP } from 'node:net';
3
+ import { abuseKey, normalizeAbusePolicy } from "./abuse.js";
4
+ import { validateUserQuery } from "./user-query.js";
5
+ import { validateRecoveryEvidence } from "./manual-recovery.js";
6
+ import { isDisposableEmailDomain, disposableDomainsRevision } from "./disposable-domains.js";
7
+ import { randomBytes, randomInt, randomUUID, createHash, createHmac, createCipheriv, createDecipheriv, scrypt, pbkdf2, timingSafeEqual } from 'node:crypto';
8
+ import { compare as bcryptCompare } from 'bcryptjs';
9
+ import { domainToASCII } from 'node:url';
10
+ import { TOTP, Secret } from 'otpauth';
11
+ import { createRegistrationPolicy } from "./registration.js";
12
+ import { AuthError, openAuthStore } from "./auth-store.js";
13
+ export { AuthError };
14
+ const fail = (status, code) => { throw new AuthError(status, code); };
15
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
16
+ const token = () => randomBytes(32).toString('base64url');
17
+ const validToken = (value) => typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value);
18
+ const id = (value) => {
19
+ if (typeof value !== 'string' || value.length > 256 || !value || /[\x00-\x20\x7f]/.test(value))
20
+ fail(400, 'invalid_identifier');
21
+ return value;
22
+ };
23
+ export function normalizeEmail(value) {
24
+ if (typeof value !== 'string' || value.length > 254)
25
+ fail(400, 'invalid_email');
26
+ const parts = value.normalize('NFKC').trim().split('@');
27
+ if (parts.length !== 2)
28
+ fail(400, 'invalid_email');
29
+ const local = parts[0].toLowerCase(), domain = domainToASCII(parts[1]).toLowerCase();
30
+ if (!local || local.length > 64 || /[\s\x00-\x1f\x7f"(),:;<>\[\]\\]/.test(local) || !domain || domain.length > 253 || domain.split('.').some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) || local.length + domain.length + 1 > 254)
31
+ fail(400, 'invalid_email');
32
+ return local + '@' + domain;
33
+ }
34
+ function password(value) {
35
+ if (typeof value !== 'string' || value.length > 1024 || [...value].length < 15 || [...value].length > 256 || Buffer.byteLength(value) > 1024)
36
+ fail(400, 'password_length_invalid');
37
+ }
38
+ let hashing = 0;
39
+ let passwordChecks = 0;
40
+ let lifecycleActive = 0;
41
+ async function derive(value, salt) {
42
+ if (hashing >= 2)
43
+ fail(503, 'password_hash_busy');
44
+ hashing++;
45
+ try {
46
+ return await new Promise((resolve, reject) => scrypt(value, salt, 32, { N: 131072, r: 8, p: 1, maxmem: 160 * 1024 * 1024 }, (error, key) => error ? reject(new AuthError(503, 'password_hash_unavailable')) : resolve(key)));
47
+ }
48
+ finally {
49
+ hashing--;
50
+ }
51
+ }
52
+ async function hashPassword(value, validate = true) {
53
+ if (validate)
54
+ password(value);
55
+ const salt = randomBytes(16), key = await derive(value, salt);
56
+ return `scrypt-v1$${salt.toString('base64url')}$${key.toString('base64url')}`;
57
+ }
58
+ function validPasswordHash(encoded) {
59
+ if (typeof encoded !== 'string' || encoded.length > 512)
60
+ return false;
61
+ if (/^scrypt-v1\$[A-Za-z0-9_-]{22}\$[A-Za-z0-9_-]{43}$/.test(encoded))
62
+ return true;
63
+ const bcrypt = /^\$2[aby]\$(\d{2})\$[./A-Za-z0-9]{53}$/.exec(encoded);
64
+ if (bcrypt)
65
+ return Number(bcrypt[1]) >= 10 && Number(bcrypt[1]) <= 14;
66
+ const pb = /^pbkdf2-sha256\$(\d{6,7})\$([A-Za-z0-9_-]{22,86})\$([A-Za-z0-9_-]{43})$/.exec(encoded);
67
+ return Boolean(pb) && Number(pb[1]) >= 600000 && Number(pb[1]) <= 1000000;
68
+ }
69
+ async function verifyPassword(value, encoded) {
70
+ if (typeof value !== 'string' || Buffer.byteLength(value) > 1024)
71
+ fail(401, 'invalid_credentials');
72
+ if (encoded && validPasswordHash(encoded) && !encoded.startsWith('scrypt-v1$')) {
73
+ if (hashing >= 2)
74
+ fail(503, 'password_hash_busy');
75
+ hashing++;
76
+ try {
77
+ if (encoded.startsWith('$2')) {
78
+ if (Buffer.byteLength(value) > 72)
79
+ return false;
80
+ return await bcryptCompare(value, encoded);
81
+ }
82
+ const parts = encoded.split('$'), derived = await new Promise((resolve, reject) => pbkdf2(value, Buffer.from(parts[2], 'base64url'), Number(parts[1]), 32, 'sha256', (error, key) => error ? reject(new AuthError(503, 'password_hash_unavailable')) : resolve(key)));
83
+ return timingSafeEqual(derived, Buffer.from(parts[3], 'base64url'));
84
+ }
85
+ finally {
86
+ hashing--;
87
+ }
88
+ }
89
+ const parts = encoded?.split('$'), valid = encoded && validPasswordHash(encoded) && parts?.[0] === 'scrypt-v1';
90
+ const key = await derive(value, valid ? Buffer.from(parts[1], 'base64url') : Buffer.alloc(16));
91
+ return Boolean(valid) && timingSafeEqual(key, Buffer.from(parts[2], 'base64url'));
92
+ }
93
+ const basePublicUser = (user) => ({ id: user.id, email: user.email, emailVerified: user.emailVerified, status: user.status, roles: [...user.roles], created: user.created, totpEnabled: Boolean(user.totpSecret) });
94
+ export async function createAuthService(options) {
95
+ if (options.approveConfigurationChangeFrom !== undefined && (typeof options.approveConfigurationChangeFrom !== 'string' || !/^[a-f0-9]{64}$/.test(options.approveConfigurationChangeFrom)))
96
+ fail(400, 'invalid_configuration_approval');
97
+ if (options.configurationTag !== undefined && (typeof options.configurationTag !== 'string' || options.configurationTag.length < 1 || options.configurationTag.length > 128 || /[\x00-\x1f\x7f]/.test(options.configurationTag)))
98
+ fail(400, 'invalid_configuration_tag');
99
+ for (const value of [options.requireEmailVerification, options.requireMfa, options.blockDisposableEmails])
100
+ if (value !== undefined && typeof value !== 'boolean')
101
+ fail(400, 'invalid_security_policy');
102
+ const deletionGraceMs = options.deletionGraceMs ?? 604800000;
103
+ if (!Number.isSafeInteger(deletionGraceMs) || deletionGraceMs < 86400000 || deletionGraceMs > 2592000000)
104
+ fail(400, 'invalid_deletion_grace');
105
+ if (options.allowManualRecovery !== undefined && typeof options.allowManualRecovery !== 'boolean')
106
+ fail(400, 'invalid_manual_recovery_policy');
107
+ if (options.allowEmailFactorRecovery !== undefined && typeof options.allowEmailFactorRecovery !== 'boolean')
108
+ fail(400, 'invalid_factor_recovery_policy');
109
+ if (options.allowPasskeySecondFactor !== undefined && typeof options.allowPasskeySecondFactor !== 'boolean')
110
+ fail(400, 'invalid_security_policy');
111
+ const trustedDeviceTtlMs = options.trustedDeviceTtlMs ?? 0;
112
+ if (!Number.isSafeInteger(trustedDeviceTtlMs) || trustedDeviceTtlMs < 0 || trustedDeviceTtlMs > 2592000000 || trustedDeviceTtlMs > 0 && trustedDeviceTtlMs < 60000)
113
+ fail(400, 'invalid_trusted_device_policy');
114
+ const abusePolicy = normalizeAbusePolicy(options.abuse);
115
+ const securityPolicy = Object.freeze({ ...(abusePolicy ? { abuse: abusePolicy } : {}), ...(options.allowManualRecovery === true ? { allowManualRecovery: true } : {}), ...(options.allowPasskeySecondFactor ? { allowPasskeySecondFactor: true } : {}), ...(trustedDeviceTtlMs ? { trustedDeviceTtlMs } : {}), ...(options.allowEmailFactorRecovery === true ? { allowEmailFactorRecovery: true } : {}), requireEmailVerification: options.requireEmailVerification === true, requireMfa: options.requireMfa === true, deletionGraceMs });
116
+ const supplied = options.encryptionKeys ?? (options.encryptionKey ? { legacy: options.encryptionKey } : {}), activeKey = options.activeEncryptionKey ?? 'legacy';
117
+ const keys = Object.create(null);
118
+ if (Object.keys(supplied).length < 1 || Object.keys(supplied).length > 8)
119
+ fail(400, 'auth_encryption_key_required');
120
+ for (const [name, value] of Object.entries(supplied)) {
121
+ if (!/^[a-zA-Z0-9_-]{1,32}$/.test(name) || !(value instanceof Uint8Array) || value.byteLength !== 32)
122
+ fail(400, 'auth_encryption_key_required');
123
+ keys[name] = Buffer.from(value);
124
+ }
125
+ if (!keys[activeKey])
126
+ fail(400, 'auth_encryption_key_required');
127
+ if (!options.roles || typeof options.roles !== 'object' || Array.isArray(options.roles) || Object.keys(options.roles).length > 64)
128
+ fail(400, 'invalid_roles');
129
+ const roles = Object.create(null);
130
+ for (const [name, list] of Object.entries(options.roles)) {
131
+ if (!/^[a-z][a-z0-9_-]{0,63}$/.test(name) || !Array.isArray(list) || list.length > 128 || list.some(p => typeof p !== 'string' || !/^\*$|^[a-z][a-z0-9_.:-]{0,127}$/.test(p)))
132
+ fail(400, 'invalid_roles');
133
+ roles[name] = [...new Set(list)];
134
+ }
135
+ const defaultRole = options.defaultRole ?? 'user';
136
+ if (!Object.hasOwn(roles, defaultRole) || roles[defaultRole].some(p => p === '*' || p.startsWith('auth.')))
137
+ fail(400, 'unsafe_default_role');
138
+ const mode = options.registrationMode ?? 'open';
139
+ if (!['open', 'invite-only', 'waitlist', 'off'].includes(mode))
140
+ fail(400, 'invalid_registration_mode');
141
+ const domains = (values) => {
142
+ if (values !== undefined && (!Array.isArray(values) || values.length > 100))
143
+ fail(400, 'invalid_email_domains');
144
+ return (values ?? []).map(value => normalizeEmail('test@' + value).split('@')[1]);
145
+ };
146
+ const emailList = (values) => {
147
+ if (values !== undefined && (!Array.isArray(values) || values.length > 1000))
148
+ fail(400, 'invalid_email_list');
149
+ return (values ?? []).map(normalizeEmail);
150
+ };
151
+ const allowedEmails = emailList(options.allowedEmails), blockedEmails = emailList(options.blockedEmails);
152
+ const allowed = domains(options.allowedEmailDomains), blocked = domains(options.blockedEmailDomains);
153
+ const permittedEmail = (value) => {
154
+ const email = normalizeEmail(value), domain = email.split('@')[1];
155
+ if (options.blockDisposableEmails && isDisposableEmailDomain(domain) || blockedEmails.includes(email) || (allowedEmails.length && !allowedEmails.includes(email)) || blocked.includes(domain) || (allowed.length && !allowed.includes(domain)))
156
+ fail(403, 'registration_unavailable');
157
+ return email;
158
+ };
159
+ const ttl = options.sessionTtlMs ?? 86400000;
160
+ if (!Number.isSafeInteger(ttl) || ttl < 60000 || ttl > 2592000000)
161
+ fail(400, 'invalid_session_ttl');
162
+ const idle = options.sessionIdleMs ?? Math.min(ttl, 1800000);
163
+ if (!Number.isSafeInteger(idle) || idle < 60000 || idle > ttl)
164
+ fail(400, 'invalid_session_idle');
165
+ const key = keys[activeKey], now = () => {
166
+ const value = (options.now ?? Date.now)();
167
+ if (!Number.isSafeInteger(value) || value < 0)
168
+ fail(503, 'invalid_clock');
169
+ return value;
170
+ };
171
+ const store = await openAuthStore({ database: options.database, ...(options.approveConfigurationChangeFrom ? { approveConfigurationChangeFrom: options.approveConfigurationChangeFrom } : {}), configurationChangeAt: now(), ...(options.configurationTag !== undefined ? { configurationTag: options.configurationTag } : {}), roles, defaultRole, sessionTtlMs: ttl, sessionIdleMs: idle, securityPolicy, registration: { ...(options.blockDisposableEmails ? { disposableDomainsRevision } : {}), mode, allowed, blocked, allowedEmails, blockedEmails, allowImpersonation: options.allowImpersonation === true }, activeKey, keyFingerprints: Object.fromEntries(Object.entries(keys).map(([name, value]) => [name, createHmac('sha256', value).update('urlcode-auth-store-v1').digest('hex')])) });
172
+ let closed = false;
173
+ const hookStats = { accepted: 0, dropped: 0, failed: 0, timedOut: 0 };
174
+ const hookControllers = new Set();
175
+ const lifecycle = (event) => {
176
+ if (!options.onLifecycle)
177
+ return;
178
+ if (closed || lifecycleActive >= 4) {
179
+ hookStats.dropped++;
180
+ return;
181
+ }
182
+ lifecycleActive++;
183
+ hookStats.accepted++;
184
+ const controller = new AbortController();
185
+ hookControllers.add(controller);
186
+ let timedOut = false;
187
+ const timer = setTimeout(() => { timedOut = true; hookStats.timedOut++; controller.abort(); }, 5000);
188
+ timer.unref();
189
+ void Promise.resolve().then(() => options.onLifecycle({ ...event }, { signal: controller.signal })).catch(() => {
190
+ if (!timedOut)
191
+ hookStats.failed++;
192
+ }).finally(() => { clearTimeout(timer); hookControllers.delete(controller); lifecycleActive--; });
193
+ };
194
+ const check = () => {
195
+ if (closed)
196
+ fail(503, 'auth_service_closed');
197
+ };
198
+ const profilePolicy = options.registrationPolicy ?? createRegistrationPolicy();
199
+ const validateProfile = (input, existing) => {
200
+ try {
201
+ return profilePolicy.validate(input, { now: now(), ...(existing ? { existing } : {}) });
202
+ }
203
+ catch {
204
+ return fail(400, 'invalid_registration_profile');
205
+ }
206
+ };
207
+ const publicUser = (user) => ({ ...basePublicUser(user), ...(securityPolicy.allowPasskeySecondFactor && user.mfaPasskeys?.length ? { passkeyMfaEnabled: true } : {}), ...(user.profile ? { profile: profilePolicy.publicProfile(user.profile) } : {}) });
208
+ const newPassword = async (value) => {
209
+ password(value);
210
+ if (options.checkPassword) {
211
+ if (passwordChecks >= 4)
212
+ fail(503, 'password_check_busy');
213
+ passwordChecks++;
214
+ const controller = new AbortController();
215
+ let timer;
216
+ const pending = Promise.resolve().then(() => options.checkPassword(value, { signal: controller.signal }));
217
+ void pending.finally(() => { passwordChecks--; }).catch(() => { });
218
+ try {
219
+ await Promise.race([pending, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new AuthError(503, 'password_check_unavailable')); }, 5000); })]);
220
+ }
221
+ catch (error) {
222
+ if (error instanceof AuthError && error.code === 'password_check_unavailable')
223
+ throw error;
224
+ fail(400, 'password_not_allowed');
225
+ }
226
+ finally {
227
+ if (timer)
228
+ clearTimeout(timer);
229
+ }
230
+ }
231
+ return hashPassword(value);
232
+ };
233
+ const perms = (names) => [...new Set(names.flatMap(name => roles[name] || []))];
234
+ const restrictions = (user) => [...(securityPolicy.requireEmailVerification && !user.emailVerified ? ['verify-email'] : []), ...((user.mfaRecoveryRequired || securityPolicy.requireMfa && !user.totpSecret && !(securityPolicy.allowPasskeySecondFactor && user.mfaPasskeys?.length)) ? ['enroll-mfa'] : [])];
235
+ const principal = (user, session) => { const pending = restrictions(user); return { id: user.id, email: user.email, emailVerified: user.emailVerified, roles: pending.length ? [] : [...user.roles], permissions: pending.length ? [] : session.impersonatorId ? perms(user.roles).filter(p => p !== '*' && !p.startsWith('auth.') && !p.startsWith('admin.')) : perms(user.roles), ...(pending.length ? { restrictions: pending } : {}), ...(session.impersonatorId ? { impersonatorId: session.impersonatorId } : {}), sessionId: session.id, authenticatedAt: session.authenticatedAt }; };
236
+ const sessionFor = (accountId, device) => {
237
+ if (device && (!validToken(device.id) || device.label !== undefined && (typeof device.label !== 'string' || device.label.length > 160 || /[\x00-\x1f\x7f]/.test(device.label))))
238
+ fail(400, 'invalid_device');
239
+ const raw = token(), created = now();
240
+ return { raw, value: { id: randomUUID(), hash: digest(raw), accountId, created, authenticatedAt: created, primaryMethod: 'password', expires: created + ttl, ...(device ? { deviceHash: digest(device.id), deviceLabel: device.label ?? 'Browser' } : {}) } };
241
+ };
242
+ const seal = (value, context) => { const nonce = randomBytes(12), cipher = createCipheriv('aes-256-gcm', key, nonce); cipher.setAAD(Buffer.from(context)); const bytes = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); return activeKey + '.' + Buffer.concat([nonce, cipher.getAuthTag(), bytes]).toString('base64url'); };
243
+ const unseal = (value, context) => {
244
+ try {
245
+ const parts = value.split('.'), keyId = parts.length === 2 ? parts[0] : 'legacy', secretKey = keys[keyId];
246
+ if (!secretKey)
247
+ throw new Error();
248
+ const bytes = Buffer.from(parts.at(-1), 'base64url');
249
+ if (bytes.length < 29)
250
+ throw new Error();
251
+ const cipher = createDecipheriv('aes-256-gcm', secretKey, bytes.subarray(0, 12));
252
+ cipher.setAuthTag(bytes.subarray(12, 28));
253
+ cipher.setAAD(Buffer.from(context));
254
+ return Buffer.concat([cipher.update(bytes.subarray(28)), cipher.final()]).toString('utf8');
255
+ }
256
+ catch {
257
+ return fail(503, 'auth_secret_unavailable');
258
+ }
259
+ };
260
+ const lookupSession = async (raw, fresh = false, enrollment = false) => {
261
+ check();
262
+ if (!validToken(raw))
263
+ return fail(401, 'invalid_credentials');
264
+ const value = await store.call('authenticate', { hash: digest(raw), now: now() });
265
+ if (!value)
266
+ return fail(401, 'invalid_credentials');
267
+ if (value.session.impersonatorId && fresh)
268
+ fail(403, 'impersonation_restricted');
269
+ if (fresh && now() - value.session.authenticatedAt > 300000)
270
+ fail(401, 'fresh_authentication_required');
271
+ if (enrollment && value.user.mfaRecoveryRequired && !value.session.recoveryEnrollment)
272
+ fail(403, 'recovery_enrollment_proof_required');
273
+ if (fresh) {
274
+ const pending = restrictions(value.user);
275
+ if (pending.length && (!enrollment || pending.includes('verify-email')))
276
+ fail(403, 'enrollment_required');
277
+ }
278
+ return value;
279
+ };
280
+ const attempt = async (value) => { const attemptKey = createHash('sha256').update('urlcode-auth-attempt:' + value).digest('hex'); await store.call('attempt', { key: attemptKey, now: now() }); return attemptKey; };
281
+ const counter = (secret, code, accountId) => {
282
+ if (typeof code !== 'string' || !/^\d{6}$/.test(code))
283
+ return fail(401, 'invalid_credentials');
284
+ const totp = new TOTP({ secret: unseal(secret, 'totp:' + accountId), algorithm: 'SHA1', digits: 6, period: 30 });
285
+ const timestamp = now(), delta = totp.validate({ token: code, window: 1, timestamp });
286
+ if (delta === null)
287
+ return fail(401, 'invalid_credentials');
288
+ return Math.floor(timestamp / 30000) + delta;
289
+ };
290
+ const factor = (user, input, allowTrusted = false) => {
291
+ if (input.secondFactor) {
292
+ if (!securityPolicy.allowPasskeySecondFactor || !validToken(input.secondFactor.token) || !/^[a-f0-9]{64}$/.test(input.secondFactor.browserHash))
293
+ fail(401, 'invalid_second_factor');
294
+ return { secondFactorHash: digest(input.secondFactor.token), secondFactorBrowser: input.secondFactor.browserHash };
295
+ }
296
+ if (allowTrusted && input.trustedDevice && !input.totp && !input.recoveryCode && (user.totpSecret || securityPolicy.allowPasskeySecondFactor && user.mfaPasskeys?.length)) {
297
+ if (!trustedDeviceTtlMs || !validToken(input.trustedDevice))
298
+ fail(401, 'invalid_trusted_device');
299
+ return { trustedDeviceHash: digest(input.trustedDevice) };
300
+ }
301
+ if (!user.totpSecret) {
302
+ if (securityPolicy.allowPasskeySecondFactor && user.mfaPasskeys?.length)
303
+ fail(401, 'second_factor_required');
304
+ return {};
305
+ }
306
+ if (input.recoveryCode) {
307
+ if (!/^[A-Za-z0-9_-]{22}$/.test(input.recoveryCode))
308
+ fail(401, 'invalid_credentials');
309
+ return { recoveryHash: digest(input.recoveryCode) };
310
+ }
311
+ return { counter: counter(user.totpSecret, input.totp ?? '', user.id) };
312
+ };
313
+ const create = async (input, bootstrap = false) => {
314
+ check();
315
+ if (!bootstrap && (mode === 'off' || mode === 'waitlist' || mode === 'invite-only' && !validToken(input.invitationToken)))
316
+ fail(403, 'registration_unavailable');
317
+ const email = permittedEmail(input.email), passwordHash = await newPassword(input.password), created = now(), accountId = randomUUID();
318
+ let assigned = [defaultRole];
319
+ if (bootstrap) {
320
+ const admin = Object.keys(roles).find(name => roles[name].includes('*'));
321
+ if (!admin)
322
+ fail(400, 'administrator_role_required');
323
+ assigned = [admin];
324
+ }
325
+ const user = { id: accountId, email, emailVerified: false, status: 'active', roles: assigned, created, passwordHash, version: 1, totpCounter: -1, ...(!bootstrap && (options.registrationPolicy || input.profile) ? { profile: validateProfile(input.profile ?? {}) } : {}) }, session = sessionFor(accountId, input.device);
326
+ const stored = await store.call('create', { user, session: session.value, bootstrap, ...(!bootstrap && mode === 'invite-only' ? { invitationHash: digest(input.invitationToken) } : {}), now: now() });
327
+ lifecycle({ type: 'sign-up', accountId: stored.id });
328
+ return { user: publicUser(stored), token: session.raw, principal: principal(stored, session.value), ...(stored.newDevice ? { newDevice: true } : {}) };
329
+ };
330
+ const login = async (input, oldToken) => {
331
+ check();
332
+ const email = normalizeEmail(input.email), backoffKey = abuseKey('password', email);
333
+ if (abusePolicy?.passwordBackoff && await store.call('abuseBackoffCheck', { key: backoffKey, now: now() }))
334
+ return fail(429, 'auth_backoff');
335
+ try {
336
+ const attemptKey = await attempt('login:' + email), user = await store.call('email', { email });
337
+ const verified = await verifyPassword(input.password, user?.passwordHash);
338
+ if (!verified || !user || user.status !== 'active')
339
+ return fail(401, 'invalid_credentials');
340
+ const fact = factor(user, input, !oldToken), session = sessionFor(user.id, input.device), upgradedHash = !user.passwordHash.startsWith('scrypt-v1$') ? await hashPassword(input.password, false) : undefined;
341
+ if (fact.trustedDeviceHash)
342
+ session.value.authenticatedAt = 0;
343
+ const stored = await store.call('login', { accountId: user.id, version: user.version, passwordHash: user.passwordHash, ...(upgradedHash ? { upgradedHash } : {}), ...fact, session: session.value, attemptKey, ...(abusePolicy?.passwordBackoff ? { abuseKey: backoffKey } : {}), ...(oldToken ? { oldHash: digest(oldToken) } : {}), now: now() });
344
+ return { user: publicUser(stored), token: session.raw, principal: principal(stored, session.value), ...(stored.newDevice ? { newDevice: true } : {}) };
345
+ }
346
+ catch (error) {
347
+ if (abusePolicy?.passwordBackoff && error instanceof AuthError && error.status === 401)
348
+ await store.call('abuseFailure', { key: backoffKey, now: now() });
349
+ throw error;
350
+ }
351
+ };
352
+ const pagination = (options = {}) => {
353
+ const limit = options.limit ?? 50;
354
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100 || typeof (options.after ?? '') !== 'string' || (options.after?.length ?? 0) > 256)
355
+ fail(400, 'invalid_page');
356
+ return { limit, after: options.after ?? '' };
357
+ };
358
+ const reason = (value) => {
359
+ if (value !== undefined && (typeof value !== 'string' || value.length > 256 || /[\x00-\x1f\x7f]/.test(value)))
360
+ fail(400, 'invalid_reason');
361
+ return value ?? '';
362
+ };
363
+ const external = (provider, subject) => {
364
+ if (!/^[a-z][a-z0-9_-]{0,63}$/.test(provider) || typeof subject !== 'string' || !subject || subject.length > 512 || /[\x00-\x1f\x7f]/.test(subject))
365
+ fail(400, 'invalid_external_identity');
366
+ };
367
+ const validateProof = (value, method) => {
368
+ if (!value || value.kind !== method || !Number.isSafeInteger(value.version) || value.version < 1)
369
+ fail(400, 'invalid_auth_proof');
370
+ if (value.kind === 'oidc')
371
+ external(value.provider, value.subject);
372
+ else if (value.kind === 'passkey') {
373
+ if (typeof value.credentialId !== 'string' || !value.credentialId || value.credentialId.length > 2048 || !(/^[a-f0-9]{64}$/.test(value.publicKeyHash)) || ![value.expectedCounter, value.newCounter].every(counter => Number.isSafeInteger(counter) && counter >= 0 && counter <= 4294967295) || !(value.expectedCounter === 0 && value.newCounter === 0) && value.newCounter <= value.expectedCounter)
374
+ fail(400, 'invalid_auth_proof');
375
+ }
376
+ else
377
+ fail(400, 'invalid_auth_proof');
378
+ return structuredClone(value);
379
+ };
380
+ const signupBinding = (input) => {
381
+ check();
382
+ if (!validToken(input.flowId) || typeof input.browserHash !== 'string' || !/^[a-f0-9]{64}$/.test(input.browserHash))
383
+ fail(400, 'invalid_signup_flow');
384
+ return { hash: digest(input.flowId), browser: input.browserHash, now: now() };
385
+ };
386
+ const signupState = (row, flowId) => ({ flowId, accountId: row.account_id, email: row.email, step: row.step, expires: row.expires });
387
+ const signupRead = async (input, step) => {
388
+ const row = await store.call('signupRead', signupBinding(input));
389
+ if (!row || step && row.step !== step)
390
+ fail(400, 'invalid_signup_flow');
391
+ return row;
392
+ };
393
+ const service = {
394
+ ...createAdminAccountOperations({ store, check, now, roles, permittedEmail }),
395
+ getAbusePolicy: () => abusePolicy ? structuredClone(abusePolicy) : undefined,
396
+ async admitAuthRequest(input) {
397
+ check();
398
+ const limits = [];
399
+ const needsClient = abusePolicy?.client || input.signupEmail !== undefined && abusePolicy?.signupClient;
400
+ if (needsClient && (typeof input.client !== 'string' || !isIP(input.client)))
401
+ return fail(503, 'trusted_client_required');
402
+ if (abusePolicy?.client)
403
+ limits.push({ ...abusePolicy.client, key: abuseKey('client', input.client), ...(abusePolicy.challengeAfter !== undefined ? { challengeAfter: abusePolicy.challengeAfter } : {}) });
404
+ if (input.signupEmail !== undefined) {
405
+ const email = permittedEmail(input.signupEmail), domain = email.slice(email.lastIndexOf('@') + 1);
406
+ if (abusePolicy?.signupClient)
407
+ limits.push({ ...abusePolicy.signupClient, key: abuseKey('signup-client', input.client) });
408
+ if (abusePolicy?.signupDomain)
409
+ limits.push({ ...abusePolicy.signupDomain, key: abuseKey('signup-domain', domain) });
410
+ }
411
+ return limits.length ? store.call('abuseAdmit', { limits, now: now() }) : { challengeRequired: false };
412
+ },
413
+ async createSecondFactorProof(input) {
414
+ check();
415
+ if (!securityPolicy.allowPasskeySecondFactor || typeof input.browserHash !== 'string' || !/^[a-f0-9]{64}$/.test(input.browserHash))
416
+ fail(400, 'invalid_second_factor');
417
+ const proof = validateProof(input.proof, 'passkey'), raw = token();
418
+ await store.call('factorProof', { hash: digest(raw), browser: input.browserHash, proof, now: now() });
419
+ return raw;
420
+ },
421
+ async setPasskeySecondFactor(input) {
422
+ if (!securityPolicy.allowPasskeySecondFactor || !validToken(input.token) || typeof input.enabled !== 'boolean' || typeof input.credentialId !== 'string' || !input.credentialId || input.credentialId.length > 2048)
423
+ fail(400, 'invalid_second_factor');
424
+ const { user } = await lookupSession(input.token, true, input.enabled);
425
+ const fact = input.enabled ? factor(user, input.secondFactor ? { secondFactor: input.secondFactor } : {}) : {};
426
+ if (input.enabled && !fact.secondFactorHash)
427
+ fail(401, 'second_factor_required');
428
+ await store.call('setPasskeyFactor', { hash: digest(input.token), credentialId: input.credentialId, enabled: input.enabled, ...fact, now: now() });
429
+ },
430
+ async rememberDevice(input) {
431
+ if (!trustedDeviceTtlMs || !validToken(input.token) || input.label !== undefined && (typeof input.label !== 'string' || input.label.length > 160 || /[\x00-\x1f\x7f]/.test(input.label)))
432
+ fail(400, 'invalid_trusted_device');
433
+ const raw = token(), expires = now() + trustedDeviceTtlMs;
434
+ await store.call('rememberDevice', { hash: digest(input.token), deviceHash: digest(raw), deviceId: randomUUID(), label: input.label ?? 'Browser', expires, now: now() });
435
+ return { token: raw, expires };
436
+ },
437
+ async listTrustedDevices(raw) {
438
+ if (!validToken(raw))
439
+ fail(401, 'invalid_credentials');
440
+ return store.call('trustedDevices', { hash: digest(raw), now: now() });
441
+ },
442
+ async revokeTrustedDevice(input) {
443
+ if (!validToken(input.token))
444
+ fail(401, 'invalid_credentials');
445
+ await store.call('revokeTrustedDevice', { hash: digest(input.token), deviceId: id(input.deviceId), now: now() });
446
+ },
447
+ getManualRecoveryEnabled: () => securityPolicy.allowManualRecovery === true,
448
+ async createRecoveryCase(input) { check(); if (!securityPolicy.allowManualRecovery)
449
+ fail(403, 'manual_recovery_disabled'); if (!validToken(input.actorToken))
450
+ fail(401, 'invalid_credentials'); const why = reason(input.reason); if (!why.trim())
451
+ fail(400, 'invalid_case'); let evidence; try {
452
+ evidence = validateRecoveryEvidence(input.evidence);
453
+ }
454
+ catch {
455
+ fail(400, 'invalid_recovery_evidence');
456
+ } return store.call('manualRecoveryCreate', { hash: digest(input.actorToken), accountId: id(input.accountId), email: permittedEmail(input.email), evidence, reason: why, id: randomUUID(), now: now() }); },
457
+ async listRecoveryCases(options) { check(); const page = pagination(options), cases = await store.call('manualRecoveryList', { ...page, now: now() }); return { cases, ...(cases.length === page.limit ? { next: cases.at(-1).id } : {}) }; },
458
+ async approveRecoveryCase(input) { check(); if (!validToken(input.actorToken))
459
+ fail(401, 'invalid_credentials'); const why = reason(input.reason); if (!why.trim())
460
+ fail(400, 'invalid_case'); const raw = token(), result = await store.call('manualRecoveryApprove', { hash: digest(input.actorToken), id: id(input.caseId), reason: why, tokenHash: digest(raw), now: now() }); return { ...result, token: raw }; },
461
+ async activateRecoveryCase(input) { check(); if (!validToken(input.actorToken) || !validToken(input.token))
462
+ fail(401, 'invalid_credentials'); await store.call('manualRecoveryActivate', { hash: digest(input.actorToken), id: id(input.caseId), tokenHash: digest(input.token), now: now() }); },
463
+ async cancelRecoveryCredential(input) { check(); if (!validToken(input.actorToken) || !validToken(input.token))
464
+ fail(401, 'invalid_credentials'); await store.call('manualRecoveryCancel', { hash: digest(input.actorToken), id: id(input.caseId), tokenHash: digest(input.token), now: now() }); },
465
+ async redeemRecoveryCase(input) { check(); if (!securityPolicy.allowManualRecovery)
466
+ fail(403, 'manual_recovery_disabled'); if (!validToken(input.token))
467
+ fail(401, 'invalid_recovery'); await store.call('manualRecoveryCheck', { tokenHash: digest(input.token), now: now() }); await attempt('manual-recovery:' + digest(input.token)); const passwordHash = await newPassword(input.password), raw = token(), timestamp = now(), value = { id: randomUUID(), hash: digest(raw), accountId: '', created: timestamp, authenticatedAt: timestamp, expires: timestamp + Math.min(ttl, 1800000), recoveryEnrollment: 1, primaryMethod: 'recovery', mfaAuthenticatedAt: 0, mfaVersion: 0 }; const user = await store.call('manualRecoveryRedeem', { tokenHash: digest(input.token), passwordHash, session: value, now: timestamp }); value.accountId = user.id; return { user: publicUser(user), token: raw, principal: principal(user, value) }; },
468
+ getFactorRecoveryEnabled: () => securityPolicy.allowEmailFactorRecovery === true,
469
+ async beginFactorRecovery(input) {
470
+ check();
471
+ if (!securityPolicy.allowEmailFactorRecovery)
472
+ fail(403, 'factor_recovery_disabled');
473
+ if (!validToken(input.browserToken))
474
+ fail(400, 'invalid_recovery_browser');
475
+ const email = normalizeEmail(input.email);
476
+ await attempt('factor-recovery:' + email);
477
+ const verificationToken = token(), cancelToken = token();
478
+ const issued = await store.call('factorRecoveryBegin', { email, browser: digest(input.browserToken), verification: digest(verificationToken), cancellation: digest(cancelToken), now: now() });
479
+ return { verificationToken: issued ? verificationToken : null, cancelToken: issued ? cancelToken : null };
480
+ },
481
+ async confirmFactorRecovery(input) {
482
+ check();
483
+ if (!securityPolicy.allowEmailFactorRecovery)
484
+ fail(403, 'factor_recovery_disabled');
485
+ if (!validToken(input.token) || !validToken(input.browserToken))
486
+ fail(400, 'invalid_recovery_token');
487
+ return store.call('factorRecoveryConfirm', { verification: digest(input.token), browser: digest(input.browserToken), now: now() });
488
+ },
489
+ async cancelFactorRecovery(raw) {
490
+ check();
491
+ if (!securityPolicy.allowEmailFactorRecovery)
492
+ fail(403, 'factor_recovery_disabled');
493
+ if (!validToken(raw))
494
+ fail(400, 'invalid_recovery_token');
495
+ await store.call('factorRecoveryCancel', { cancellation: digest(raw), now: now() });
496
+ },
497
+ async completeFactorRecovery(input) {
498
+ check();
499
+ if (!securityPolicy.allowEmailFactorRecovery)
500
+ fail(403, 'factor_recovery_disabled');
501
+ if (!validToken(input.token) || !validToken(input.browserToken))
502
+ fail(400, 'invalid_recovery_token');
503
+ const raw = token(), timestamp = now();
504
+ const value = { id: randomUUID(), hash: digest(raw), accountId: '', created: timestamp, authenticatedAt: timestamp, expires: timestamp + Math.min(ttl, 1800000) };
505
+ const user = await store.call('factorRecoveryComplete', { verification: digest(input.token), browser: digest(input.browserToken), session: value, now: timestamp });
506
+ value.accountId = user.id;
507
+ return { user: publicUser(user), token: raw, principal: principal(user, value) };
508
+ },
509
+ async beginSignup(input) {
510
+ check();
511
+ if (typeof input.browserHash !== 'string' || !/^[a-f0-9]{64}$/.test(input.browserHash))
512
+ fail(400, 'invalid_signup_flow');
513
+ if (mode === 'off')
514
+ fail(403, 'registration_unavailable');
515
+ const email = normalizeEmail(input.email);
516
+ let eligible = true;
517
+ try {
518
+ permittedEmail(email);
519
+ }
520
+ catch {
521
+ eligible = false;
522
+ }
523
+ await attempt('signup:' + email);
524
+ const flowId = randomBytes(32).toString('base64url'), accountId = randomUUID(), code = String(randomInt(1000000)).padStart(6, '0');
525
+ const step = securityPolicy.requireEmailVerification ? 'verify-email' : 'credential';
526
+ const expires = now() + 1800000;
527
+ const result = await store.call('signupBegin', { hash: digest(flowId), browser: input.browserHash, email, accountId, codeHash: digest(flowId + ':' + code), step, expires, eligible, invitationHash: validToken(input.invitationToken) ? digest(input.invitationToken) : '', now: now() });
528
+ return { flowId, accountId, email, step, expires, ...(result.existing ? { delivery: { kind: 'registration-attempt', email } } : result.eligible && securityPolicy.requireEmailVerification ? { delivery: { kind: 'signup-code', email, code } } : {}) };
529
+ },
530
+ async getSignup(input) {
531
+ const row = await store.call('signupRead', signupBinding(input));
532
+ return row ? signupState(row, input.flowId) : null;
533
+ },
534
+ async verifySignup(input) {
535
+ const args = signupBinding(input);
536
+ const row = await store.call('signupVerify', { ...args, codeHash: digest(input.flowId + ':' + (typeof input.code === 'string' && /^\d{6}$/.test(input.code) ? input.code : 'invalid')) });
537
+ if (!row)
538
+ fail(400, 'invalid_signup_code');
539
+ return signupState(row, input.flowId);
540
+ },
541
+ async setSignupPassword(input) {
542
+ await signupRead(input, 'credential');
543
+ const passwordHash = await newPassword(input.password);
544
+ await store.call('signupCredential', { ...signupBinding(input), passwordHash });
545
+ return signupState(await signupRead(input, 'profile'), input.flowId);
546
+ },
547
+ async setSignupPasskeyChallenge(input) {
548
+ if (typeof input.challenge !== 'string' || !/^[A-Za-z0-9_-]{32,1024}$/.test(input.challenge))
549
+ fail(400, 'invalid_passkey');
550
+ await store.call('signupChallenge', { ...signupBinding(input), challenge: input.challenge });
551
+ return signupState(await signupRead(input, 'credential'), input.flowId);
552
+ },
553
+ async getSignupPasskeyChallenge(input) {
554
+ const row = await signupRead(input, 'credential');
555
+ if (!row.challenge)
556
+ fail(400, 'invalid_signup_flow');
557
+ return { state: signupState(row, input.flowId), challenge: row.challenge };
558
+ },
559
+ async setSignupPasskey(input) {
560
+ if (typeof input.challenge !== 'string' || !/^[A-Za-z0-9_-]{32,1024}$/.test(input.challenge))
561
+ fail(400, 'invalid_passkey');
562
+ const c = input.credential;
563
+ if (!c || typeof c.id !== 'string' || !c.id || c.id.length > 2048 || typeof c.publicKey !== 'string' || !c.publicKey || c.publicKey.length > 8192 || !Number.isSafeInteger(c.counter) || c.counter < 0 || c.transports && (!Array.isArray(c.transports) || c.transports.length > 8 || c.transports.some(t => !['ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', 'usb'].includes(t))))
564
+ fail(400, 'invalid_passkey');
565
+ const credential = { id: c.id, publicKey: c.publicKey, counter: c.counter, ...(c.transports ? { transports: c.transports } : {}) };
566
+ await store.call('signupCredential', { ...signupBinding(input), challenge: input.challenge, credential });
567
+ return signupState(await signupRead(input, 'profile'), input.flowId);
568
+ },
569
+ async completeSignup(input) {
570
+ const row = await signupRead(input, 'profile'), profile = validateProfile(input.profile ?? {}), session = sessionFor(row.account_id, input.device);
571
+ const stored = await store.call('signupComplete', { ...signupBinding(input), profile, session: session.value });
572
+ if (!stored)
573
+ return null;
574
+ lifecycle({ type: 'sign-up', accountId: stored.id });
575
+ return { user: publicUser(stored), token: session.raw, principal: principal(stored, session.value), ...(stored.newDevice ? { newDevice: true } : {}) };
576
+ },
577
+ register: input => create(input), bootstrapAdmin: input => create(input, true), login,
578
+ async stepUp(input) {
579
+ const value = await lookupSession(input.token);
580
+ if (value.session.impersonatorId)
581
+ fail(403, 'impersonation_restricted');
582
+ return login({ email: value.user.email, password: input.password, ...(input.totp ? { totp: input.totp } : {}), ...(input.recoveryCode ? { recoveryCode: input.recoveryCode } : {}), ...(input.secondFactor ? { secondFactor: input.secondFactor } : {}) }, input.token);
583
+ },
584
+ async authenticate(raw) {
585
+ check();
586
+ if (!validToken(raw))
587
+ return null;
588
+ const value = await store.call('authenticate', { hash: digest(raw), now: now() });
589
+ return value ? principal(value.user, value.session) : null;
590
+ },
591
+ async logout(raw) {
592
+ check();
593
+ if (validToken(raw))
594
+ await store.call('logout', { hash: digest(raw), now: now() });
595
+ },
596
+ async revokeSessions(accountId) { check(); await store.call('revoke', { accountId: id(accountId), now: now() }); },
597
+ async listUsers(options) {
598
+ check();
599
+ if (options?.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 100))
600
+ fail(400, 'invalid_page');
601
+ let filters;
602
+ try {
603
+ filters = validateUserQuery(options);
604
+ }
605
+ catch {
606
+ return fail(400, 'invalid_user_filter');
607
+ }
608
+ if (filters.role !== undefined && !Object.hasOwn(roles, filters.role))
609
+ fail(400, 'invalid_filter');
610
+ const result = await store.call('users', { ...filters });
611
+ return { users: result.users.map(user => ({ ...publicUser(user), ...(typeof user.observedLastSeen === 'number' ? { observedLastSeen: user.observedLastSeen } : {}) })), ...(result.next ? { next: result.next } : {}) };
612
+ },
613
+ async getUser(accountId) { check(); const row = await store.call('account', { id: id(accountId) }); return row ? publicUser(row) : null; }, getRoles: () => structuredClone(roles),
614
+ async listDevices(accountId) {
615
+ check();
616
+ return store.call('devices', { accountId: id(accountId) });
617
+ },
618
+ async listSessions(accountId) { check(); return store.call('sessions', { accountId: id(accountId), now: now() }); },
619
+ async listAudit(options) {
620
+ check();
621
+ const page = pagination(options);
622
+ if (page.after && !/^\d{1,16}$/.test(page.after))
623
+ fail(400, 'invalid_page');
624
+ for (const value of [options?.actor, options?.subject, options?.action])
625
+ if (value !== undefined && (typeof value !== 'string' || value.length > 256 || /[\x00-\x1f]/.test(value)))
626
+ fail(400, 'invalid_filter');
627
+ for (const value of [options?.from, options?.to])
628
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
629
+ fail(400, 'invalid_filter');
630
+ if (options?.from !== undefined && options?.to !== undefined && options.from > options.to)
631
+ fail(400, 'invalid_filter');
632
+ const rows = await store.call('audit', { ...page, actor: options?.actor ?? '', subject: options?.subject ?? '', action: options?.action ?? '', from: options?.from ?? 0, to: options?.to ?? Number.MAX_SAFE_INTEGER });
633
+ return { events: rows, ...(rows.length === page.limit ? { next: String(rows.at(-1).id) } : {}) };
634
+ },
635
+ async issueToken(input) {
636
+ check();
637
+ if (!['verify-email', 'reset-password'].includes(input.purpose))
638
+ fail(400, 'invalid_token_purpose');
639
+ const email = normalizeEmail(input.email);
640
+ await attempt('token:' + email);
641
+ const raw = token(), issued = await store.call('issueToken', { email, purpose: input.purpose, hash: digest(raw), now: now() });
642
+ return { token: issued ? raw : null };
643
+ },
644
+ async consumeVerification(raw) {
645
+ check();
646
+ if (!validToken(raw))
647
+ fail(400, 'invalid_token');
648
+ return publicUser(await store.call('consumeToken', { hash: digest(raw), purpose: 'verify-email', now: now() }));
649
+ },
650
+ async resetPassword(input) {
651
+ check();
652
+ if (!validToken(input.token))
653
+ fail(400, 'invalid_token');
654
+ const passwordHash = await newPassword(input.password);
655
+ return publicUser(await store.call('consumeToken', { hash: digest(input.token), purpose: 'reset-password', passwordHash, now: now() }));
656
+ },
657
+ async beginTotp(raw) { const { user } = await lookupSession(raw, true, true); await attempt('totp:' + user.id); const secret = new Secret({ size: 20 }), totp = new TOTP({ issuer: 'URLCode', label: user.email, secret }); await store.call('totpBegin', { hash: digest(raw), secret: seal(secret.base32, 'totp:' + user.id), now: now() }); return { secret: secret.base32, otpauthUrl: totp.toString() }; },
658
+ async confirmTotp(input) {
659
+ const { user } = await lookupSession(input.token, true, true);
660
+ await attempt('totp:' + user.id);
661
+ if (!user.totpPending)
662
+ fail(400, 'invalid_totp_setup');
663
+ const step = counter(user.totpPending, input.code, user.id), recoveryCodes = Array.from({ length: 10 }, () => randomBytes(16).toString('base64url'));
664
+ await store.call('totpConfirm', { hash: digest(input.token), version: user.version, counter: step, recoveryHashes: recoveryCodes.map(digest), now: now() });
665
+ return { recoveryCodes };
666
+ },
667
+ async disableTotp(input) {
668
+ const { user } = await lookupSession(input.token, true);
669
+ await attempt('totp:' + user.id);
670
+ if (user.passwordHash && !await verifyPassword(input.password, user.passwordHash) || !user.totpSecret)
671
+ fail(401, 'invalid_credentials');
672
+ const fact = input.secondFactor ? factor(user, input) : { counter: counter(user.totpSecret, input.code ?? '', user.id) };
673
+ await store.call('totpDisable', { hash: digest(input.token), version: user.version, ...fact, now: now() });
674
+ },
675
+ async adminSetRoles(input) {
676
+ check();
677
+ if (!validToken(input.actorToken) || !Array.isArray(input.roles) || !input.roles.length || input.roles.length > 32 || input.roles.some(name => !Object.hasOwn(roles, name)))
678
+ fail(400, 'invalid_roles');
679
+ return publicUser(await store.call('admin', { hash: digest(input.actorToken), accountId: id(input.accountId), roles: [...new Set(input.roles)], reason: reason(input.reason), now: now() }));
680
+ },
681
+ async adminSetStatus(input) {
682
+ check();
683
+ if (!validToken(input.actorToken) || !['active', 'locked'].includes(input.status))
684
+ fail(400, 'invalid_account_status');
685
+ return publicUser(await store.call('admin', { hash: digest(input.actorToken), accountId: id(input.accountId), status: input.status, reason: reason(input.reason), now: now() }));
686
+ },
687
+ async adminRevokeSessions(input) {
688
+ check();
689
+ if (!validToken(input.actorToken))
690
+ fail(401, 'invalid_credentials');
691
+ await store.call('adminRevoke', { hash: digest(input.actorToken), accountId: id(input.accountId), reason: reason(input.reason), now: now() });
692
+ },
693
+ async putFlow(input) {
694
+ check();
695
+ id(input.id);
696
+ id(input.kind);
697
+ const data = JSON.stringify(input.data);
698
+ if (typeof data !== 'string' || Buffer.byteLength(data) > 16384 || !Number.isSafeInteger(input.expires) || input.expires <= now() || input.expires > now() + 600000)
699
+ fail(400, 'invalid_auth_flow');
700
+ await store.call('putFlow', { id: input.id, kind: input.kind, data: seal(data, 'flow:' + input.id + ':' + input.kind), expires: input.expires, now: now() });
701
+ },
702
+ async consumeFlow(flowId, kind) { check(); id(flowId); id(kind); const value = await store.call('consumeFlow', { id: flowId, kind, now: now() }); return value === null ? null : JSON.parse(unseal(value, 'flow:' + flowId + ':' + kind)); },
703
+ async findExternal(provider, subject) { check(); external(provider, subject); const row = await store.call('external', { provider, subject }); return row ? publicUser(row) : null; },
704
+ async linkExternal(input) {
705
+ check();
706
+ external(input.provider, input.subject);
707
+ if (!validToken(input.actorToken))
708
+ fail(401, 'invalid_credentials');
709
+ await store.call('linkExternal', { hash: digest(input.actorToken), provider: input.provider, subject: input.subject, now: now() });
710
+ },
711
+ async createExternalAccount(input) {
712
+ check();
713
+ if (mode !== 'open')
714
+ fail(403, 'registration_unavailable');
715
+ external(input.provider, input.subject);
716
+ if (input.emailVerified !== true)
717
+ fail(400, 'verified_provider_email_required');
718
+ const user = { id: randomUUID(), email: permittedEmail(input.email), emailVerified: true, status: 'active', roles: [defaultRole], created: now(), passwordHash: '', version: 1, totpCounter: -1, ...(options.registrationPolicy || input.profile ? { profile: validateProfile(input.profile ?? {}) } : {}) };
719
+ const saved = await store.call('createExternal', { user, provider: input.provider, subject: input.subject, now: now() });
720
+ lifecycle({ type: 'sign-up', accountId: saved.id });
721
+ return publicUser(saved);
722
+ },
723
+ async getExternalProof(provider, subject) { check(); external(provider, subject); const user = await store.call('external', { provider, subject }); return user ? { user: publicUser(user), proof: { kind: 'oidc', version: user.version, provider, subject } } : null; },
724
+ async issueSession(accountId, input) {
725
+ check();
726
+ const proof = validateProof(input.proof, input.method);
727
+ if (!['passkey', 'oidc'].includes(input.method))
728
+ fail(400, 'invalid_auth_method');
729
+ const user = await store.call('account', { id: id(accountId) });
730
+ if (!user || user.status !== 'active')
731
+ fail(401, 'invalid_credentials');
732
+ const attemptKey = await attempt('login:' + user.email), fact = factor(user, input, true), session = sessionFor(accountId, input.device);
733
+ session.value.primaryMethod = input.method;
734
+ if (proof.kind === 'passkey')
735
+ session.value.primaryCredentialId = proof.credentialId;
736
+ if (fact.trustedDeviceHash)
737
+ session.value.authenticatedAt = 0;
738
+ const stored = await store.call('login', { accountId, proof, version: user.version, passwordHash: user.passwordHash, ...fact, session: session.value, attemptKey, now: now() });
739
+ return { user: publicUser(stored), token: session.raw, principal: principal(stored, session.value), ...(stored.newDevice ? { newDevice: true } : {}) };
740
+ },
741
+ async addPasskey(input) {
742
+ check();
743
+ if (!validToken(input.actorToken) || !input.credential || typeof input.credential.id !== 'string' || input.credential.id.length > 2048 || !input.credential.id || typeof input.credential.publicKey !== 'string' || input.credential.publicKey.length > 8192 || !Number.isSafeInteger(input.credential.counter) || input.credential.counter < 0 || (input.credential.transports && (input.credential.transports.length > 8 || input.credential.transports.some(t => !['ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', 'usb'].includes(t)))))
744
+ fail(400, 'invalid_passkey');
745
+ await store.call('addPasskey', { hash: digest(input.actorToken), credential: { id: input.credential.id, publicKey: input.credential.publicKey, counter: input.credential.counter, ...(input.credential.transports ? { transports: input.credential.transports } : {}) }, now: now() });
746
+ },
747
+ async getPasskey(credentialId) {
748
+ check();
749
+ if (typeof credentialId !== 'string' || credentialId.length > 2048)
750
+ fail(400, 'invalid_passkey');
751
+ return store.call('getPasskey', { id: credentialId });
752
+ },
753
+ async listPasskeys(accountId) { check(); return store.call('listPasskeys', { accountId: id(accountId) }); },
754
+ async advancePasskeyCounter(input) {
755
+ check();
756
+ if (typeof input.id !== 'string' || !input.id || input.id.length > 2048 || !Number.isSafeInteger(input.expectedCounter) || input.expectedCounter < 0 || !Number.isSafeInteger(input.newCounter) || input.newCounter < 0 || !(input.expectedCounter === 0 && input.newCounter === 0) && input.newCounter <= input.expectedCounter)
757
+ fail(400, 'invalid_passkey_counter');
758
+ await store.call('advancePasskey', { ...input, now: now() });
759
+ },
760
+ async changePassword(input) {
761
+ const { user } = await lookupSession(input.token, true);
762
+ await attempt('login:' + user.email);
763
+ if (!await verifyPassword(input.currentPassword, user.passwordHash))
764
+ fail(401, 'invalid_credentials');
765
+ const fact = factor(user, input), passwordHash = await newPassword(input.password);
766
+ await store.call('changePassword', { hash: digest(input.token), version: user.version, passwordHash, ...fact, now: now() });
767
+ },
768
+ async exportAccount(raw) {
769
+ const { user } = await lookupSession(raw, true);
770
+ return { user: publicUser(user), sessions: await service.listSessions(user.id), passkeys: (await service.listPasskeys(user.id)).map(({ id, transports }) => ({ id, ...(transports ? { transports } : {}) })), identities: await store.call('identities', { accountId: user.id }) };
771
+ },
772
+ async deleteAccount(input) {
773
+ const { user } = await lookupSession(input.token, true);
774
+ await attempt('login:' + user.email);
775
+ if (user.passwordHash && !await verifyPassword(input.password ?? '', user.passwordHash))
776
+ fail(401, 'invalid_credentials');
777
+ const cancelToken = token();
778
+ const deleted = await store.call('deleteAccount', { hash: digest(input.token), cancelHash: digest(cancelToken), version: user.version, ...factor(user, input), now: now() });
779
+ return { cancelToken, deleteAfter: deleted.deleteAfter };
780
+ },
781
+ async cancelDeletion(raw) {
782
+ check();
783
+ if (!validToken(raw))
784
+ fail(400, 'invalid_token');
785
+ await store.call('cancelDeletion', { hash: digest(raw), now: now() });
786
+ },
787
+ async purgeDeleted(options = {}) {
788
+ check();
789
+ const limit = options.limit ?? 100;
790
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
791
+ fail(400, 'invalid_page');
792
+ const result = await store.call('purgeDeleted', { limit, now: now() });
793
+ for (const accountId of result.deleted)
794
+ lifecycle({ type: 'delete', accountId });
795
+ return { purged: result.purged };
796
+ },
797
+ async issueEmailCode(input) { check(); const email = normalizeEmail(input.email); await attempt('token:' + email); const flowId = token(), code = String(randomInt(1000000)).padStart(6, '0'); const issued = await store.call('issueEmailCode', { email, hash: digest(flowId), codeHash: digest(flowId + ':' + code), now: now() }); return { flowId, code: issued ? code : null }; },
798
+ async consumeEmailCode(input) {
799
+ check();
800
+ if (!validToken(input.flowId) || typeof input.code !== 'string' || !/^\d{6}$/.test(input.code))
801
+ fail(400, 'invalid_code');
802
+ const user = await store.call('checkEmailCode', { hash: digest(input.flowId), codeHash: digest(input.flowId + ':' + input.code), now: now() });
803
+ if (!user)
804
+ fail(400, 'invalid_code');
805
+ const session = sessionFor(user.id, input.device), fact = factor(user, input, true);
806
+ session.value.primaryMethod = 'email-code';
807
+ if (fact.trustedDeviceHash)
808
+ session.value.authenticatedAt = 0;
809
+ const stored = await store.call('codeLogin', { hash: digest(input.flowId), codeHash: digest(input.flowId + ':' + input.code), version: user.version, ...fact, session: session.value, now: now() });
810
+ return { user: publicUser(stored), token: session.raw, principal: principal(stored, session.value), ...(stored.newDevice ? { newDevice: true } : {}) };
811
+ },
812
+ async createCase(input) {
813
+ check();
814
+ const why = reason(input.reason);
815
+ if (!why.trim() || !validToken(input.actorToken) || !['reset-factors', 'lock', 'unlock', 'roles'].includes(input.action) || input.action === 'roles' && (!Array.isArray(input.roles) || !input.roles.length || input.roles.length > 32 || input.roles.some(name => !Object.hasOwn(roles, name))))
816
+ fail(400, 'invalid_case');
817
+ return store.call('createCase', { hash: digest(input.actorToken), accountId: id(input.accountId), action: input.action, ...(input.roles ? { roles: input.roles } : {}), reason: why, id: randomUUID(), now: now() });
818
+ },
819
+ async listCases(options) { check(); const page = pagination(options), cases = await store.call('cases', page); return { cases, ...(cases.length === page.limit ? { next: cases.at(-1).id } : {}) }; },
820
+ async getCase(caseId) { check(); return store.call('case', { id: id(caseId) }); },
821
+ async approveCase(input) {
822
+ check();
823
+ const why = reason(input.reason);
824
+ if (!why.trim() || !validToken(input.actorToken))
825
+ fail(400, 'invalid_case');
826
+ return store.call('approveCase', { hash: digest(input.actorToken), id: id(input.caseId), reason: why, now: now() });
827
+ },
828
+ async createImpersonation(input) {
829
+ check();
830
+ const why = reason(input.reason);
831
+ if (options.allowImpersonation !== true || !why.trim() || !validToken(input.actorToken))
832
+ fail(403, 'impersonation_denied');
833
+ const session = sessionFor(id(input.accountId));
834
+ session.value.expires = now() + 600000;
835
+ session.value.authenticatedAt = 0;
836
+ const result = await store.call('impersonate', { hash: digest(input.actorToken), accountId: input.accountId, session: session.value, reason: why, now: now() });
837
+ return { user: publicUser(result.user), token: session.raw, principal: principal(result.user, result.session) };
838
+ },
839
+ async adminBulk(input) {
840
+ check();
841
+ const why = reason(input.reason);
842
+ if (!validToken(input.actorToken) || !why.trim() || !['lock', 'unlock', 'revoke-sessions'].includes(input.action) || !Array.isArray(input.accountIds) || input.accountIds.length < 1 || input.accountIds.length > 50 || new Set(input.accountIds).size !== input.accountIds.length)
843
+ fail(400, 'invalid_bulk_action');
844
+ const accountIds = input.accountIds.map(id);
845
+ return store.call('adminBulk', { hash: digest(input.actorToken), accountIds, action: input.action, reason: why, now: now() });
846
+ },
847
+ async dashboard() {
848
+ check();
849
+ return store.call('dashboard', { now: now() });
850
+ },
851
+ async listAllSessions(options) {
852
+ check();
853
+ const filters = options ?? {};
854
+ if (filters.accountId !== undefined)
855
+ id(filters.accountId);
856
+ if (filters.device !== undefined && (typeof filters.device !== 'string' || filters.device.length > 128 || /[\x00-\x1f\x7f]/.test(filters.device)))
857
+ fail(400, 'invalid_session_filter');
858
+ for (const value of [filters.createdFrom, filters.createdTo])
859
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
860
+ fail(400, 'invalid_session_filter');
861
+ if (filters.createdFrom !== undefined && filters.createdTo !== undefined && filters.createdFrom > filters.createdTo)
862
+ fail(400, 'invalid_session_filter');
863
+ const page = pagination(options), sessions = await store.call('allSessions', { ...page, accountId: filters.accountId ?? '', device: filters.device ?? '', createdFrom: filters.createdFrom ?? 0, createdTo: filters.createdTo ?? Number.MAX_SAFE_INTEGER, now: now() });
864
+ return { sessions, ...(sessions.length === page.limit ? { next: sessions.at(-1).id } : {}) };
865
+ },
866
+ async importUsers(users) {
867
+ check();
868
+ if (!Array.isArray(users) || !users.length || users.length > 100)
869
+ fail(400, 'invalid_import');
870
+ const rows = users.map(input => {
871
+ if (!input || !validPasswordHash(input.passwordHash) || input.emailVerified !== undefined && typeof input.emailVerified !== 'boolean')
872
+ fail(400, 'invalid_import');
873
+ return { id: randomUUID(), email: permittedEmail(input.email), emailVerified: input.emailVerified ?? false, status: 'active', roles: [defaultRole], created: now(), passwordHash: input.passwordHash, version: 1, totpCounter: -1 };
874
+ });
875
+ const result = await store.call('importUsers', { users: rows, now: now() });
876
+ for (const user of rows)
877
+ lifecycle({ type: 'sign-up', accountId: user.id });
878
+ return result;
879
+ },
880
+ async getProfile(raw) { const { user } = await lookupSession(raw, true); return profilePolicy.publicProfile(user.profile ?? { metadata: {} }); },
881
+ async updateProfile(input) { const { user } = await lookupSession(input.token, true), profile = validateProfile(input.profile, user.profile); await store.call('updateProfile', { hash: digest(input.token), profile, version: user.version, now: now() }); return profilePolicy.publicProfile(profile); },
882
+ async getConfigurationRevision() { check(); return store.call('configurationRevision'); },
883
+ getSecurityPolicy: () => ({ ...securityPolicy }),
884
+ getHookStats: () => ({ ...hookStats }),
885
+ getRegistrationSchema: () => profilePolicy.publicSchema(),
886
+ getRegistrationMode: () => mode,
887
+ async requestRegistration(input) {
888
+ check();
889
+ if (mode !== 'waitlist')
890
+ fail(403, 'registration_unavailable');
891
+ const email = permittedEmail(input.email), passwordHash = await newPassword(input.password);
892
+ return store.call('requestRegistration', { id: randomUUID(), email, passwordHash, ...(options.registrationPolicy || input.profile ? { profile: validateProfile(input.profile ?? {}) } : {}), now: now() });
893
+ },
894
+ async listRegistrationRequests(options) {
895
+ check();
896
+ const page = pagination(options), requests = await store.call('registrationRequests', page);
897
+ return { requests, ...(requests.length === page.limit ? { next: requests.at(-1).id } : {}) };
898
+ },
899
+ async approveRegistration(input) {
900
+ check();
901
+ if (mode !== 'waitlist' || !validToken(input.actorToken))
902
+ fail(403, 'registration_unavailable');
903
+ const user = await store.call('approveRegistration', { hash: digest(input.actorToken), requestId: id(input.requestId), reason: reason(input.reason), now: now() });
904
+ lifecycle({ type: 'sign-up', accountId: user.id });
905
+ return publicUser(user);
906
+ },
907
+ async invite(input) {
908
+ check();
909
+ if (mode !== 'invite-only' || !validToken(input.actorToken))
910
+ fail(403, 'registration_unavailable');
911
+ const raw = token();
912
+ await store.call('invite', { actorHash: digest(input.actorToken), email: permittedEmail(input.email), hash: digest(raw), now: now() });
913
+ return { token: raw };
914
+ },
915
+ async completeStepUp(input) {
916
+ const proof = validateProof(input.proof, input.method), { user, session: previous } = await lookupSession(input.token);
917
+ if (input.method !== 'passkey' || user.id !== input.accountId)
918
+ fail(403, 'step_up_denied');
919
+ if (previous.impersonatorId)
920
+ fail(403, 'impersonation_restricted');
921
+ const attemptKey = await attempt('login:' + user.email), fact = factor(user, input), session = sessionFor(user.id);
922
+ session.value.primaryMethod = 'passkey';
923
+ if (proof.kind === 'passkey')
924
+ session.value.primaryCredentialId = proof.credentialId;
925
+ const stored = await store.call('login', { accountId: user.id, proof, version: user.version, passwordHash: user.passwordHash, ...fact, session: session.value, oldHash: digest(input.token), attemptKey, now: now() });
926
+ return { user: publicUser(stored), token: session.raw, principal: principal(stored, session.value) };
927
+ },
928
+ async removePasskey(input) {
929
+ check();
930
+ if (!validToken(input.token) || typeof input.credentialId !== 'string' || !input.credentialId || input.credentialId.length > 2048)
931
+ fail(400, 'invalid_passkey');
932
+ await store.call('removeMethod', { hash: digest(input.token), credentialId: input.credentialId, now: now() });
933
+ },
934
+ async unlinkExternal(input) {
935
+ check();
936
+ external(input.provider, input.subject);
937
+ if (!validToken(input.token))
938
+ fail(401, 'invalid_credentials');
939
+ await store.call('removeMethod', { hash: digest(input.token), provider: input.provider, subject: input.subject, now: now() });
940
+ },
941
+ async closeCase(input) {
942
+ check();
943
+ const why = reason(input.reason);
944
+ if (!why.trim() || !validToken(input.actorToken))
945
+ fail(400, 'invalid_case');
946
+ return store.call('caseEdit', { hash: digest(input.actorToken), id: id(input.caseId), reason: why, close: true, now: now() });
947
+ },
948
+ async addCaseNote(input) {
949
+ check();
950
+ const note = reason(input.note);
951
+ if (!note.trim() || !validToken(input.actorToken))
952
+ fail(400, 'invalid_case');
953
+ return store.call('caseEdit', { hash: digest(input.actorToken), id: id(input.caseId), reason: note, now: now() });
954
+ },
955
+ async cleanup(options = {}) {
956
+ check();
957
+ const limit = options.limit ?? 100;
958
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
959
+ fail(400, 'invalid_page');
960
+ return store.call('cleanup', { limit, now: now() });
961
+ },
962
+ async requestEmailChange(input) {
963
+ const { user } = await lookupSession(input.token, true);
964
+ await attempt('login:' + user.email);
965
+ if (user.passwordHash && !await verifyPassword(input.password ?? '', user.passwordHash))
966
+ fail(401, 'invalid_credentials');
967
+ const email = permittedEmail(input.email), verificationToken = token(), cancelToken = token(), activateAfter = now() + 86400000;
968
+ await store.call('requestEmailChange', { hash: digest(input.token), version: user.version, email, verificationHash: digest(verificationToken), cancelHash: digest(cancelToken), ...factor(user, input), now: now() });
969
+ return { oldEmail: user.email, newEmail: email, verificationToken, cancelToken, activateAfter };
970
+ },
971
+ async confirmEmailChange(raw) {
972
+ check();
973
+ if (!validToken(raw))
974
+ fail(400, 'invalid_token');
975
+ return publicUser(await store.call('confirmEmailChange', { hash: digest(raw), now: now() }));
976
+ },
977
+ async cancelEmailChange(raw) {
978
+ check();
979
+ if (!validToken(raw))
980
+ fail(400, 'invalid_token');
981
+ await store.call('cancelEmailChange', { hash: digest(raw), now: now() });
982
+ },
983
+ async adminCreateUser(input) {
984
+ check();
985
+ const why = reason(input.reason);
986
+ if (!why.trim() || !validToken(input.actorToken))
987
+ fail(400, 'invalid_administration');
988
+ const setupToken = token(), user = { id: randomUUID(), email: permittedEmail(input.email), emailVerified: false, status: 'active', roles: [defaultRole], created: now(), passwordHash: '', version: 1, totpCounter: -1 };
989
+ const saved = await store.call('adminCreateUser', { hash: digest(input.actorToken), user, setupHash: digest(setupToken), reason: why, now: now() });
990
+ lifecycle({ type: 'sign-up', accountId: saved.id });
991
+ return { user: publicUser(saved), setupToken };
992
+ },
993
+ async revokeSession(input) {
994
+ check();
995
+ if (!validToken(input.token))
996
+ fail(401, 'invalid_credentials');
997
+ await store.call('revokeSession', { hash: digest(input.token), sessionId: id(input.sessionId), now: now() });
998
+ },
999
+ async adminRevokeSession(input) {
1000
+ check();
1001
+ const why = reason(input.reason);
1002
+ if (!why.trim() || !validToken(input.actorToken))
1003
+ fail(400, 'invalid_administration');
1004
+ await store.call('revokeSession', { hash: digest(input.actorToken), sessionId: id(input.sessionId), admin: true, reason: why, now: now() });
1005
+ },
1006
+ async adminAddNote(input) {
1007
+ check();
1008
+ if (!validToken(input.actorToken))
1009
+ fail(401, 'invalid_session');
1010
+ if (typeof input.reason !== 'string' || !input.reason.trim() || input.reason.length > 256 || /[\x00-\x1f\x7f]/.test(input.reason))
1011
+ fail(400, 'invalid_reason');
1012
+ await store.call('adminAddNote', { hash: digest(input.actorToken), accountId: id(input.accountId), reason: input.reason.trim(), now: now() });
1013
+ },
1014
+ async adminReveal(input) {
1015
+ check();
1016
+ if (!validToken(input.actorToken))
1017
+ fail(401, 'invalid_session');
1018
+ if (typeof input.reason !== 'string' || !input.reason.trim() || input.reason.length > 256 || /[\x00-\x1f\x7f]/.test(input.reason))
1019
+ fail(400, 'invalid_reason');
1020
+ return store.call('adminReveal', { hash: digest(input.actorToken), accountId: id(input.accountId), reason: input.reason.trim(), now: now() });
1021
+ },
1022
+ async adminExport(input) {
1023
+ check();
1024
+ const why = reason(input.reason);
1025
+ if (!why.trim() || !validToken(input.actorToken))
1026
+ fail(400, 'invalid_administration');
1027
+ const result = await store.call('adminExport', { hash: digest(input.actorToken), accountId: id(input.accountId), reason: why, now: now() });
1028
+ return { ...result, user: publicUser(result.user) };
1029
+ },
1030
+ async rotateEncryptionKey() {
1031
+ check();
1032
+ const rows = await store.call('rotationRows', { activeKey });
1033
+ const replacements = rows.map(row => ({ ...row, replacement: seal(unseal(row.value, row.context), row.context) }));
1034
+ return store.call('rotationApply', { activeKey, replacements, now: now() });
1035
+ },
1036
+ async close() {
1037
+ if (closed)
1038
+ return;
1039
+ closed = true;
1040
+ for (const controller of hookControllers)
1041
+ controller.abort();
1042
+ await store.close();
1043
+ for (const value of Object.values(keys))
1044
+ value.fill(0);
1045
+ },
1046
+ };
1047
+ const withFailureMetric = async (method, run) => {
1048
+ try {
1049
+ return await run();
1050
+ }
1051
+ catch (error) {
1052
+ if (error instanceof AuthError && [400, 401, 429].includes(error.status)) {
1053
+ try {
1054
+ await store.call('signInFailure', { method, now: now() });
1055
+ }
1056
+ catch { /* Observability cannot change the authentication result. */ }
1057
+ }
1058
+ throw error;
1059
+ }
1060
+ };
1061
+ const passwordLogin = service.login, externalLogin = service.issueSession, codeLogin = service.consumeEmailCode;
1062
+ service.login = input => withFailureMetric('password', () => passwordLogin(input));
1063
+ service.issueSession = (accountId, input) => withFailureMetric(input.method === 'passkey' ? 'passkey' : 'oidc', () => externalLogin(accountId, input));
1064
+ service.consumeEmailCode = input => withFailureMetric('email-code', () => codeLogin(input));
1065
+ return service;
1066
+ }