@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,1601 @@
1
+ import { adminAccountOperation } from "./admin-account-store.js";
2
+ import { abuseOperation } from "./abuse-store.js";
3
+ import { abuseKey } from "./abuse.js";
4
+ import { queryUsers } from "./user-query.js";
5
+ import { manualRecoveryOperation } from "./manual-recovery-store.js";
6
+ import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { createHash } from 'node:crypto';
9
+ import { open, lstat, realpath } from 'node:fs/promises';
10
+ import { resolve, dirname, basename, join } from 'node:path';
11
+ export class AuthError extends Error {
12
+ status;
13
+ code;
14
+ constructor(status, code) { super(code); this.status = status; this.code = code; }
15
+ }
16
+ export function patched(version) { const [a = 0, b = 0, c = 0] = version.split('.').map(Number); return a > 3 || a === 3 && (b > 51 || b === 51 && c >= 3 || b === 50 && c >= 7 || b === 44 && c >= 6); }
17
+ export async function openAuthStore(options) {
18
+ if (!isMainThread)
19
+ throw new AuthError(503, 'auth_store_unavailable');
20
+ if (!patched(process.versions.sqlite || ''))
21
+ throw new AuthError(503, 'patched_sqlite_required');
22
+ const requested = resolve(options.database), parent = await realpath(dirname(requested)), database = join(parent, basename(requested));
23
+ try {
24
+ const file = await open(database, 'wx', 0o600);
25
+ await file.close();
26
+ }
27
+ catch (error) {
28
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST'))
29
+ throw error;
30
+ }
31
+ const info = await lstat(database);
32
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || (process.platform !== 'win32' && (info.mode & 0o077) !== 0))
33
+ throw new AuthError(400, 'invalid_auth_database');
34
+ const worker = new Worker(new URL(import.meta.url), { workerData: { ...options, database, authStore: true }, env: {}, execArgv: [], stdout: true, stderr: true, resourceLimits: { maxOldGenerationSizeMb: 64 } });
35
+ worker.stdout.resume();
36
+ worker.stderr.resume();
37
+ let sequence = 0, closed = false;
38
+ const pending = new Map();
39
+ const fail = () => {
40
+ closed = true;
41
+ for (const p of pending.values()) {
42
+ clearTimeout(p.timer);
43
+ p.reject(new AuthError(503, 'auth_store_unavailable'));
44
+ }
45
+ pending.clear();
46
+ };
47
+ worker.on('error', fail);
48
+ worker.on('exit', fail);
49
+ await new Promise((accept, reject) => {
50
+ const timer = setTimeout(() => { void worker.terminate(); reject(new AuthError(503, 'auth_store_unavailable')); }, 15000);
51
+ worker.once('message', (message) => {
52
+ clearTimeout(timer);
53
+ if (message.ready)
54
+ accept();
55
+ else {
56
+ void worker.terminate();
57
+ reject(new AuthError(503, ['auth_configuration_changed', 'configuration_approval_mismatch', 'configuration_roles_invalid', 'configuration_admin_required'].includes(message.error ?? '') ? message.error : 'auth_store_unavailable'));
58
+ }
59
+ });
60
+ worker.once('error', () => { clearTimeout(timer); reject(new AuthError(503, 'auth_store_unavailable')); });
61
+ });
62
+ worker.on('message', (message) => {
63
+ const p = pending.get(message.id);
64
+ if (!p)
65
+ return;
66
+ pending.delete(message.id);
67
+ clearTimeout(p.timer);
68
+ if (message.error)
69
+ p.reject(new AuthError(message.error.status, message.error.code));
70
+ else
71
+ p.resolve(message.value);
72
+ });
73
+ return { call(operation, args = {}) {
74
+ if (closed)
75
+ return Promise.reject(new AuthError(503, 'auth_store_closed'));
76
+ if (pending.size >= 32)
77
+ return Promise.reject(new AuthError(503, 'auth_store_busy'));
78
+ return new Promise((accept, reject) => { const id = ++sequence, timer = setTimeout(() => { fail(); void worker.terminate(); }, 10000); pending.set(id, { resolve: value => accept(value), reject, timer }); worker.postMessage({ id, operation, args }); });
79
+ }, async close() {
80
+ if (closed)
81
+ return;
82
+ closed = true;
83
+ fail();
84
+ await worker.terminate();
85
+ } };
86
+ }
87
+ if (!isMainThread && workerData?.authStore) {
88
+ const port = parentPort;
89
+ const options = workerData;
90
+ let db;
91
+ let initializing = true;
92
+ let dispatchTransaction = false;
93
+ let configurationRevision = '';
94
+ const roles = options.roles, permissions = (names) => [...new Set(names.flatMap(name => roles[name] || []))];
95
+ const admin = (names) => permissions(names).includes('*');
96
+ const error = (status, code) => { throw new AuthError(status, code); };
97
+ const num = (value) => Number(value);
98
+ const decode = (row) => row ? JSON.parse(String(row.data)) : null;
99
+ const account = (id) => decode(db.prepare('SELECT data FROM auth_accounts WHERE id=?').get(id));
100
+ const save = (record) => db.prepare('UPDATE auth_accounts SET data=?,status=?,administrator=? WHERE id=?').run(JSON.stringify(record), record.status, Number(admin(record.roles)), record.id);
101
+ const metric = (event, method, now, count = 1) => {
102
+ const day = Math.floor(now / 86400000);
103
+ if (!['password', 'passkey', 'oidc', 'email-code', 'unknown'].includes(method))
104
+ method = 'unknown';
105
+ db.prepare('DELETE FROM auth_daily_metrics WHERE day<?').run(day - 29);
106
+ db.prepare('INSERT INTO auth_daily_metrics(day,method,event,count) VALUES(?,?,?,?) ON CONFLICT(day,method,event) DO UPDATE SET count=count+excluded.count').run(day, method, event, count);
107
+ };
108
+ const methodActivity = (kind, methodId, accountId, time, added = false) => {
109
+ db.prepare('INSERT INTO auth_method_activity(kind,method_id,account_id,added,last_used) VALUES(?,?,?,?,?) ON CONFLICT(kind,method_id) DO UPDATE SET last_used=excluded.last_used').run(kind, methodId, accountId, added ? time : null, added ? null : time);
110
+ };
111
+ const audit = (actor, action, subject, now, reason = '') => {
112
+ db.prepare('INSERT INTO auth_audit(actor,action,subject,created,reason) VALUES(?,?,?,?,?)').run(actor, action, subject, now, reason);
113
+ db.prepare('DELETE FROM auth_audit WHERE id <= (SELECT max(id)-100000 FROM auth_audit)').run();
114
+ if (['account.register', 'admin.bootstrap', 'registration.approved', 'admin.account_created', 'account.external_register', 'accounts.imported'].includes(action))
115
+ metric('signup', action === 'account.external_register' ? 'oidc' : action === 'accounts.imported' ? 'unknown' : 'password', now, action === 'accounts.imported' ? Number(subject) : 1);
116
+ };
117
+ const transaction = (fn) => {
118
+ if (dispatchTransaction)
119
+ return fn();
120
+ db.exec('BEGIN IMMEDIATE');
121
+ try {
122
+ const activeKey = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='auth_meta'").get() ? db.prepare("SELECT value FROM auth_meta WHERE key='activeEncryptionKey'").get()?.value : undefined;
123
+ if (activeKey && activeKey !== options.activeKey && !initializing)
124
+ error(503, 'stale_encryption_key');
125
+ const result = fn();
126
+ db.exec('COMMIT');
127
+ return result;
128
+ }
129
+ catch (e) {
130
+ db.exec('ROLLBACK');
131
+ throw e;
132
+ }
133
+ };
134
+ const active = (id) => {
135
+ const user = account(id);
136
+ if (!user || user.status !== 'active')
137
+ error(401, 'invalid_credentials');
138
+ return user;
139
+ };
140
+ const session = (hash, now) => {
141
+ const found = db.prepare('SELECT id,hash,account_id AS accountId,created,authenticated_at AS authenticatedAt,expires,last_seen AS lastSeen,device_label AS deviceLabel,impersonator_id AS impersonatorId,actor_version AS actorVersion,recovery_enrollment AS recoveryEnrollment,primary_method AS primaryMethod,primary_credential AS primaryCredentialId,mfa_authenticated_at AS mfaAuthenticatedAt,mfa_version AS mfaVersion FROM auth_sessions WHERE hash=? AND expires>?').get(hash, now);
142
+ if (!found || now - Number(found.lastSeen) >= options.sessionIdleMs)
143
+ return null;
144
+ const user = account(String(found.accountId));
145
+ if (found.impersonatorId) {
146
+ const actor = account(String(found.impersonatorId)), p = actor ? permissions(actor.roles) : [];
147
+ if (!actor || actor.status !== 'active' || actor.version !== found.actorVersion || !p.includes('*') && !p.includes('auth.users.impersonate') || !user || permissions(user.roles).some(permission => permission === '*' || permission.startsWith('auth.') || permission.startsWith('admin.')))
148
+ return null;
149
+ }
150
+ return user?.status === 'active' ? { user, session: found } : null;
151
+ };
152
+ const passkeyFactor = (user) => Boolean(options.securityPolicy.allowPasskeySecondFactor && user.mfaPasskeys?.length);
153
+ const restricted = (user) => user.mfaRecoveryRequired || options.securityPolicy.requireEmailVerification && !user.emailVerified || options.securityPolicy.requireMfa && !user.totpSecret && !passkeyFactor(user);
154
+ const fresh = (hash, now, enrollment = false) => {
155
+ const found = session(hash, now);
156
+ if (found?.session.impersonatorId)
157
+ error(403, 'impersonation_restricted');
158
+ if (!found || now - found.session.authenticatedAt > 300000)
159
+ error(401, 'fresh_authentication_required');
160
+ if (enrollment && found.user.mfaRecoveryRequired && !found.session.recoveryEnrollment)
161
+ error(403, 'recovery_enrollment_proof_required');
162
+ if (restricted(found.user) && (!enrollment || options.securityPolicy.requireEmailVerification && !found.user.emailVerified))
163
+ error(403, 'enrollment_required');
164
+ return found;
165
+ };
166
+ const authorizeCase = (actor, target, nextRoles) => {
167
+ if (restricted(actor))
168
+ error(403, 'enrollment_required');
169
+ if (target.status === 'pending-delete')
170
+ error(409, 'account_pending_deletion');
171
+ const p = permissions(actor.roles);
172
+ if (actor.id === target.id || !p.includes('*') && (!p.includes('auth.cases.manage') || !p.includes('auth.users.manage')))
173
+ error(403, 'permission_denied');
174
+ for (const permission of [...permissions(target.roles), ...permissions(nextRoles ?? target.roles)])
175
+ if (!p.includes('*') && !p.includes(permission))
176
+ error(403, 'delegation_ceiling_exceeded');
177
+ };
178
+ const passkeyProof = (user, value, update) => {
179
+ const proof = value;
180
+ const credential = proof && db.prepare('SELECT data,counter FROM auth_passkeys WHERE id=? AND account_id=?').get(String(proof.credentialId), user.id);
181
+ if (!credential || proof.kind !== 'passkey' || proof.version !== user.version || num(credential.counter) !== proof.expectedCounter || createHash('sha256').update(String(JSON.parse(String(credential.data)).publicKey)).digest('hex') !== proof.publicKeyHash || !Number.isSafeInteger(proof.newCounter) || proof.newCounter < 0 || !(proof.expectedCounter === 0 && proof.newCounter === 0) && proof.newCounter <= proof.expectedCounter)
182
+ error(401, 'stale_auth_proof');
183
+ if (update)
184
+ db.prepare('UPDATE auth_passkeys SET counter=? WHERE id=? AND account_id=? AND counter=?').run(proof.newCounter, String(proof.credentialId), user.id, proof.expectedCounter);
185
+ return proof.credentialId;
186
+ };
187
+ const takeSecondFactor = (user, args, now, enrollmentId) => {
188
+ if (!options.securityPolicy.allowPasskeySecondFactor)
189
+ error(401, 'invalid_second_factor');
190
+ const row = db.prepare('SELECT * FROM auth_second_factor_proofs WHERE hash=? AND browser=? AND account_id=? AND expires>?').get(String(args.secondFactorHash), String(args.secondFactorBrowser), user.id, now);
191
+ if (!row || row.version !== user.version)
192
+ error(401, 'invalid_second_factor');
193
+ const proof = JSON.parse(String(row.proof)), primaryCredential = args.session?.primaryCredentialId;
194
+ if (enrollmentId ? proof.credentialId !== enrollmentId : !user.mfaPasskeys?.includes(proof.credentialId))
195
+ error(401, 'invalid_second_factor');
196
+ if (primaryCredential === proof.credentialId)
197
+ error(401, 'independent_second_factor_required');
198
+ passkeyProof(user, proof, true);
199
+ methodActivity('passkey', String(proof.credentialId), user.id, now);
200
+ db.prepare('DELETE FROM auth_second_factor_proofs WHERE hash=?').run(String(args.secondFactorHash));
201
+ };
202
+ const consumeFactor = (user, args, now) => {
203
+ if (args.secondFactorHash) {
204
+ takeSecondFactor(user, args, now);
205
+ return true;
206
+ }
207
+ if (args.trustedDeviceHash) {
208
+ if (!options.securityPolicy.trustedDeviceTtlMs || user.mfaRecoveryRequired || !user.totpSecret && !passkeyFactor(user) || !db.prepare('SELECT id FROM auth_trusted_devices WHERE hash=? AND account_id=? AND version=? AND expires>?').get(String(args.trustedDeviceHash), user.id, user.version, now))
209
+ error(401, 'invalid_trusted_device');
210
+ if (!args.session || args.oldHash)
211
+ error(401, 'invalid_trusted_device');
212
+ args.session.authenticatedAt = 0;
213
+ return false;
214
+ }
215
+ if (!user.totpSecret) {
216
+ if (passkeyFactor(user))
217
+ error(401, 'second_factor_required');
218
+ return false;
219
+ }
220
+ if (args.recoveryHash) {
221
+ if (db.prepare('DELETE FROM auth_recovery WHERE hash=? AND account_id=?').run(String(args.recoveryHash), user.id).changes !== 1)
222
+ error(401, 'invalid_credentials');
223
+ }
224
+ else {
225
+ const counter = Number(args.counter);
226
+ if (!Number.isSafeInteger(counter) || counter <= user.totpCounter)
227
+ error(401, 'invalid_credentials');
228
+ user.totpCounter = counter;
229
+ save(user);
230
+ }
231
+ return true;
232
+ };
233
+ const realMfa = (hash, now) => {
234
+ const found = fresh(hash, now);
235
+ if (!found.session.mfaAuthenticatedAt || now - found.session.mfaAuthenticatedAt > 300000 || found.session.mfaVersion !== found.user.version)
236
+ error(403, 'fresh_second_factor_required');
237
+ return found;
238
+ };
239
+ const addSession = (value) => {
240
+ let newDevice = false;
241
+ if (value.deviceHash) {
242
+ newDevice = !db.prepare('SELECT hash FROM auth_devices WHERE account_id=? AND hash=?').get(value.accountId, value.deviceHash);
243
+ db.prepare('INSERT INTO auth_devices VALUES(?,?,?,?) ON CONFLICT(account_id,hash) DO UPDATE SET label=excluded.label,last_seen=excluded.last_seen').run(value.accountId, value.deviceHash, value.deviceLabel ?? 'Browser', value.created);
244
+ db.prepare('DELETE FROM auth_devices WHERE account_id=? AND hash NOT IN (SELECT hash FROM auth_devices WHERE account_id=? ORDER BY last_seen DESC,hash LIMIT 20)').run(value.accountId, value.accountId);
245
+ }
246
+ db.prepare('DELETE FROM auth_sessions WHERE hash IN (SELECT hash FROM auth_sessions WHERE expires<=? LIMIT 1000)').run(value.created);
247
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=? AND id NOT IN (SELECT id FROM auth_sessions WHERE account_id=? ORDER BY created DESC,id DESC LIMIT 19)').run(value.accountId, value.accountId);
248
+ db.prepare('INSERT INTO auth_sessions(hash,id,account_id,created,authenticated_at,expires,impersonator_id,actor_version,last_seen,device_label,recovery_enrollment,primary_method,primary_credential,mfa_authenticated_at,mfa_version) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)').run(value.hash, value.id, value.accountId, value.created, value.authenticatedAt, value.expires, value.impersonatorId ?? null, value.actorVersion ?? null, value.created, value.deviceLabel ?? null, value.recoveryEnrollment ?? 0, value.primaryMethod ?? 'unknown', value.primaryCredentialId ?? null, value.mfaAuthenticatedAt ?? 0, value.mfaVersion ?? 0);
249
+ return newDevice;
250
+ };
251
+ try {
252
+ db = new DatabaseSync(options.database, { allowExtension: false });
253
+ db.exec('PRAGMA busy_timeout=1000; PRAGMA foreign_keys=ON; PRAGMA trusted_schema=OFF;');
254
+ const version = db.prepare('PRAGMA user_version').get()?.user_version, application = db.prepare('PRAGMA application_id').get()?.application_id;
255
+ const empty = db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE type='table'").get()?.n === 0;
256
+ if (!(version === 1 && application === 1430345032) && !(version === 0 && application === 0 && empty))
257
+ error(503, 'unsupported_auth_database');
258
+ db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;');
259
+ if (empty)
260
+ transaction(() => {
261
+ db.exec(`CREATE TABLE auth_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
262
+ CREATE TABLE auth_accounts(id TEXT PRIMARY KEY,email TEXT NOT NULL UNIQUE,status TEXT NOT NULL,administrator INTEGER NOT NULL,data TEXT NOT NULL);
263
+ CREATE INDEX auth_admin ON auth_accounts(administrator,status);
264
+ CREATE TABLE auth_sessions(hash TEXT PRIMARY KEY,id TEXT NOT NULL UNIQUE,account_id TEXT NOT NULL REFERENCES auth_accounts(id) ON DELETE CASCADE,created INTEGER NOT NULL,authenticated_at INTEGER NOT NULL,expires INTEGER NOT NULL,impersonator_id TEXT,actor_version INTEGER,last_seen INTEGER NOT NULL DEFAULT 0,device_label TEXT);
265
+ CREATE INDEX auth_session_account ON auth_sessions(account_id,created);CREATE INDEX auth_session_expiry ON auth_sessions(expires);
266
+ CREATE TABLE auth_tokens(hash TEXT PRIMARY KEY,account_id TEXT NOT NULL REFERENCES auth_accounts(id) ON DELETE CASCADE,purpose TEXT NOT NULL,expires INTEGER NOT NULL,version INTEGER NOT NULL);
267
+ CREATE INDEX auth_token_account ON auth_tokens(account_id,purpose);CREATE INDEX auth_token_expiry ON auth_tokens(expires);
268
+ CREATE TABLE auth_recovery(hash TEXT PRIMARY KEY,account_id TEXT NOT NULL REFERENCES auth_accounts(id) ON DELETE CASCADE);
269
+ CREATE INDEX auth_recovery_account ON auth_recovery(account_id);
270
+ CREATE TABLE auth_attempts(key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);CREATE INDEX auth_attempt_expiry ON auth_attempts(expires);
271
+ CREATE TABLE auth_audit(id INTEGER PRIMARY KEY AUTOINCREMENT,actor TEXT NOT NULL,action TEXT NOT NULL,subject TEXT NOT NULL,created INTEGER NOT NULL,reason TEXT NOT NULL);
272
+ CREATE TABLE auth_flows(id TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL);CREATE INDEX auth_flow_expiry ON auth_flows(expires);
273
+ CREATE TABLE auth_external(provider TEXT NOT NULL,subject TEXT NOT NULL,account_id TEXT NOT NULL REFERENCES auth_accounts(id) ON DELETE CASCADE,PRIMARY KEY(provider,subject));CREATE INDEX auth_external_account ON auth_external(account_id);
274
+ CREATE TABLE auth_passkeys(id TEXT PRIMARY KEY,account_id TEXT NOT NULL REFERENCES auth_accounts(id) ON DELETE CASCADE,data TEXT NOT NULL,counter INTEGER NOT NULL);CREATE INDEX auth_passkey_account ON auth_passkeys(account_id);
275
+ PRAGMA application_id=1430345032;PRAGMA user_version=1;`);
276
+ });
277
+ db.exec('CREATE TABLE IF NOT EXISTS auth_daily_metrics(day INTEGER NOT NULL,method TEXT NOT NULL,event TEXT NOT NULL,count INTEGER NOT NULL,PRIMARY KEY(day,method,event));CREATE TABLE IF NOT EXISTS auth_devices(account_id TEXT NOT NULL REFERENCES auth_accounts(id) ON DELETE CASCADE,hash TEXT NOT NULL,label TEXT NOT NULL,last_seen INTEGER NOT NULL,PRIMARY KEY(account_id,hash));CREATE TABLE IF NOT EXISTS auth_email_changes(account_id TEXT PRIMARY KEY REFERENCES auth_accounts(id) ON DELETE CASCADE,email TEXT NOT NULL,verification_hash TEXT UNIQUE NOT NULL,cancel_hash TEXT UNIQUE NOT NULL,activate_after INTEGER NOT NULL,expires INTEGER NOT NULL,version INTEGER NOT NULL);CREATE TABLE IF NOT EXISTS auth_email_codes(hash TEXT PRIMARY KEY,account_id TEXT NOT NULL UNIQUE REFERENCES auth_accounts(id) ON DELETE CASCADE,code_hash TEXT NOT NULL,expires INTEGER NOT NULL,attempts INTEGER NOT NULL,version INTEGER NOT NULL);CREATE TABLE IF NOT EXISTS auth_cases(id TEXT PRIMARY KEY,data TEXT NOT NULL);CREATE TABLE IF NOT EXISTS auth_invites(hash TEXT PRIMARY KEY,email TEXT NOT NULL UNIQUE,expires INTEGER NOT NULL);CREATE TABLE IF NOT EXISTS auth_waitlist(id TEXT PRIMARY KEY,email TEXT NOT NULL UNIQUE,password_hash TEXT NOT NULL,created INTEGER NOT NULL,profile TEXT);');
278
+ if (!db.prepare('PRAGMA table_info(auth_sessions)').all().some(row => row.name === 'impersonator_id'))
279
+ db.exec('ALTER TABLE auth_sessions ADD COLUMN impersonator_id TEXT;ALTER TABLE auth_sessions ADD COLUMN actor_version INTEGER;');
280
+ if (!db.prepare('PRAGMA table_info(auth_waitlist)').all().some(row => row.name === 'profile'))
281
+ db.exec('ALTER TABLE auth_waitlist ADD COLUMN profile TEXT;');
282
+ if (!db.prepare('PRAGMA table_info(auth_sessions)').all().some(row => row.name === 'last_seen'))
283
+ db.exec('ALTER TABLE auth_sessions ADD COLUMN last_seen INTEGER NOT NULL DEFAULT 0;ALTER TABLE auth_sessions ADD COLUMN device_label TEXT;UPDATE auth_sessions SET last_seen=created;');
284
+ if (!db.prepare('PRAGMA table_info(auth_waitlist)').all().some(row => row.name === 'email_verified'))
285
+ db.exec('ALTER TABLE auth_waitlist ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0;ALTER TABLE auth_waitlist ADD COLUMN passkey TEXT;');
286
+ db.exec('CREATE TABLE IF NOT EXISTS auth_manual_recovery(hash TEXT PRIMARY KEY,case_id TEXT NOT NULL UNIQUE REFERENCES auth_cases(id) ON DELETE CASCADE,account_id TEXT NOT NULL REFERENCES auth_accounts(id) ON DELETE CASCADE,version INTEGER NOT NULL,approver_id TEXT NOT NULL,maker_version INTEGER NOT NULL,approver_version INTEGER NOT NULL,active INTEGER NOT NULL,expires INTEGER NOT NULL);CREATE INDEX IF NOT EXISTS auth_manual_recovery_expiry ON auth_manual_recovery(expires);');
287
+ db.exec('CREATE TABLE IF NOT EXISTS auth_factor_recovery(account_id TEXT PRIMARY KEY REFERENCES auth_accounts(id) ON DELETE CASCADE,verification_hash TEXT NOT NULL UNIQUE,cancel_hash TEXT NOT NULL UNIQUE,browser_hash TEXT NOT NULL,version INTEGER NOT NULL,complete_after INTEGER,expires INTEGER NOT NULL);CREATE INDEX IF NOT EXISTS auth_factor_recovery_expiry ON auth_factor_recovery(expires);');
288
+ if (!db.prepare('PRAGMA table_info(auth_sessions)').all().some(row => row.name === 'primary_method'))
289
+ db.exec("ALTER TABLE auth_sessions ADD COLUMN primary_method TEXT NOT NULL DEFAULT 'unknown';ALTER TABLE auth_sessions ADD COLUMN primary_credential TEXT;ALTER TABLE auth_sessions ADD COLUMN mfa_authenticated_at INTEGER NOT NULL DEFAULT 0;ALTER TABLE auth_sessions ADD COLUMN mfa_version INTEGER NOT NULL DEFAULT 0;");
290
+ db.exec('CREATE TABLE IF NOT EXISTS auth_second_factor_proofs(hash TEXT PRIMARY KEY,browser TEXT NOT NULL,account_id TEXT NOT NULL REFERENCES auth_accounts(id) ON DELETE CASCADE,version INTEGER NOT NULL,proof TEXT NOT NULL,expires INTEGER NOT NULL);CREATE INDEX IF NOT EXISTS auth_second_factor_expiry ON auth_second_factor_proofs(expires);CREATE TABLE IF NOT EXISTS auth_trusted_devices(hash TEXT PRIMARY KEY,id TEXT NOT NULL UNIQUE,account_id TEXT NOT NULL REFERENCES auth_accounts(id) ON DELETE CASCADE,version INTEGER NOT NULL,label TEXT NOT NULL,created INTEGER NOT NULL,expires INTEGER NOT NULL);CREATE INDEX IF NOT EXISTS auth_trusted_expiry ON auth_trusted_devices(expires);');
291
+ db.exec('CREATE TABLE IF NOT EXISTS auth_admin_operations(id TEXT PRIMARY KEY,data TEXT NOT NULL,expires INTEGER NOT NULL);CREATE INDEX IF NOT EXISTS auth_admin_operations_expiry ON auth_admin_operations(expires);');
292
+ db.exec('CREATE TABLE IF NOT EXISTS auth_method_activity(kind TEXT NOT NULL,method_id TEXT NOT NULL,account_id TEXT NOT NULL REFERENCES auth_accounts(id) ON DELETE CASCADE,added INTEGER,last_used INTEGER,PRIMARY KEY(kind,method_id));CREATE INDEX IF NOT EXISTS auth_method_activity_account ON auth_method_activity(account_id);');
293
+ db.exec('CREATE TABLE IF NOT EXISTS auth_abuse(key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL,blocked_until INTEGER NOT NULL);CREATE INDEX IF NOT EXISTS auth_abuse_expiry ON auth_abuse(expires);');
294
+ db.exec('CREATE TABLE IF NOT EXISTS auth_signups(hash TEXT PRIMARY KEY,browser TEXT NOT NULL,email TEXT NOT NULL,account_id TEXT NOT NULL,step TEXT NOT NULL,expires INTEGER NOT NULL,code_hash TEXT NOT NULL,code_expires INTEGER NOT NULL,attempts INTEGER NOT NULL,eligible INTEGER NOT NULL,invitation_hash TEXT NOT NULL,credential TEXT,challenge TEXT);CREATE INDEX IF NOT EXISTS auth_signups_expiry ON auth_signups(expires);');
295
+ if (!db.prepare('PRAGMA table_info(auth_sessions)').all().some(row => row.name === 'recovery_enrollment'))
296
+ db.exec('ALTER TABLE auth_sessions ADD COLUMN recovery_enrollment INTEGER NOT NULL DEFAULT 0');
297
+ const configuration = createHash('sha256').update(JSON.stringify({ roles: Object.fromEntries(Object.keys(roles).sort().map(name => [name, [...roles[name]].sort()])), defaultRole: options.defaultRole, registration: options.registration, ...(options.configurationTag !== undefined ? { configurationTag: options.configurationTag } : {}), ...(options.sessionTtlMs !== 86400000 || options.sessionIdleMs !== 1800000 ? { sessionLimits: { absoluteMs: options.sessionTtlMs, idleMs: options.sessionIdleMs } } : {}), ...(options.securityPolicy.allowManualRecovery || options.securityPolicy.allowPasskeySecondFactor || options.securityPolicy.trustedDeviceTtlMs || options.securityPolicy.allowEmailFactorRecovery || options.securityPolicy.requireEmailVerification || options.securityPolicy.requireMfa || options.securityPolicy.deletionGraceMs !== 604800000 ? { securityPolicy: options.securityPolicy } : {}) })).digest('hex');
298
+ configurationRevision = transaction(() => {
299
+ const previous = db.prepare("SELECT value FROM auth_meta WHERE key='configuration'").get()?.value;
300
+ const currentDefinition = db.prepare("SELECT value FROM auth_meta WHERE key='configurationDefinition'").get()?.value ?? previous;
301
+ const sameDefinition = Boolean(previous) && currentDefinition === configuration;
302
+ let nextRevision = String(previous ?? configuration);
303
+ const approval = options.approveConfigurationChangeFrom;
304
+ const lastMigration = JSON.parse(String(db.prepare("SELECT value FROM auth_meta WHERE key='configurationMigration'").get()?.value ?? 'null'));
305
+ if (approval && approval !== previous && !(sameDefinition && lastMigration?.from === approval && lastMigration.to === previous))
306
+ error(503, 'configuration_approval_mismatch');
307
+ if (previous && !sameDefinition) {
308
+ nextRevision = createHash('sha256').update('urlcode-auth-configuration-v1\0' + String(previous) + '\0' + configuration).digest('hex');
309
+ if (approval !== previous)
310
+ error(503, 'auth_configuration_changed');
311
+ const priorAdministrators = num(db.prepare("SELECT count(*) AS n FROM auth_accounts WHERE administrator=1 AND status='active'").get()?.n);
312
+ let nextAdministrators = 0;
313
+ for (const row of db.prepare('SELECT data FROM auth_accounts').iterate()) {
314
+ const user = JSON.parse(String(row.data));
315
+ if (!Array.isArray(user.roles) || user.roles.some(role => !Object.hasOwn(roles, role)) || !Number.isSafeInteger(user.version) || user.version >= Number.MAX_SAFE_INTEGER)
316
+ error(503, 'configuration_roles_invalid');
317
+ if (user.status === 'active' && admin(user.roles))
318
+ nextAdministrators++;
319
+ }
320
+ if (priorAdministrators > 0 && nextAdministrators === 0)
321
+ error(503, 'configuration_admin_required');
322
+ const counts = {};
323
+ for (const table of ['auth_sessions', 'auth_tokens', 'auth_flows', 'auth_email_codes', 'auth_invites', 'auth_email_changes', 'auth_waitlist', 'auth_signups', 'auth_factor_recovery', 'auth_second_factor_proofs', 'auth_trusted_devices', 'auth_manual_recovery', 'auth_abuse', 'auth_admin_operations'])
324
+ counts[table] = num(db.prepare('SELECT count(*) AS n FROM ' + table).get()?.n);
325
+ const adminRoles = Object.keys(roles).filter(role => roles[role].includes('*'));
326
+ const adminSql = adminRoles.length ? "EXISTS(SELECT 1 FROM json_each(auth_accounts.data,'$.roles') WHERE value IN (" + adminRoles.map(() => '?').join(',') + '))' : '0';
327
+ db.prepare("UPDATE auth_accounts SET data=json_set(json_remove(data,'$.totpPending','$.totpPendingUntil'),'$.version',json_extract(data,'$.version')+1),administrator=" + adminSql).run(...adminRoles);
328
+ db.prepare("DELETE FROM auth_tokens WHERE purpose<>'cancel-deletion' OR expires<=? OR NOT EXISTS(SELECT 1 FROM auth_accounts WHERE auth_accounts.id=auth_tokens.account_id AND status='pending-delete')").run(options.configurationChangeAt);
329
+ db.prepare("UPDATE auth_tokens SET version=(SELECT json_extract(data,'$.version') FROM auth_accounts WHERE id=auth_tokens.account_id) WHERE purpose='cancel-deletion'").run();
330
+ const cancellationTokens = num(db.prepare('SELECT count(*) AS n FROM auth_tokens').get()?.n);
331
+ counts.auth_tokens = (counts.auth_tokens ?? 0) - cancellationTokens;
332
+ for (const table of ['auth_sessions', 'auth_flows', 'auth_email_codes', 'auth_invites', 'auth_email_changes', 'auth_waitlist', 'auth_signups', 'auth_factor_recovery', 'auth_second_factor_proofs', 'auth_trusted_devices', 'auth_manual_recovery', 'auth_abuse', 'auth_admin_operations'])
333
+ db.prepare('DELETE FROM ' + table).run();
334
+ counts.auth_cases = Number(db.prepare("UPDATE auth_cases SET data=json_set(data,'$.status','closed') WHERE json_extract(data,'$.status')='pending'").run().changes);
335
+ counts.auth_cases += Number(db.prepare("UPDATE auth_cases SET data=json_set(data,'$.status','closed','$.recovery.state','cancelled') WHERE json_extract(data,'$.action')='restore-access' AND json_extract(data,'$.recovery.state') IN ('delivery','ready')").run().changes);
336
+ db.prepare("UPDATE auth_meta SET value=? WHERE key='configuration'").run(nextRevision);
337
+ db.prepare("INSERT INTO auth_meta VALUES('configurationMigration',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(JSON.stringify({ from: previous, to: nextRevision }));
338
+ audit('operator', 'configuration.changed', nextRevision, options.configurationChangeAt, JSON.stringify({ from: previous, revoked: counts, preservedCancellationTokens: cancellationTokens }));
339
+ }
340
+ else if (!previous) {
341
+ if (approval || num(db.prepare('SELECT count(*) AS n FROM auth_accounts').get()?.n) > 0)
342
+ error(503, 'configuration_approval_mismatch');
343
+ db.prepare("INSERT INTO auth_meta VALUES('configuration',?)").run(configuration);
344
+ }
345
+ const cipherKey = (value) => value.includes('.') ? value.split('.')[0] : 'legacy';
346
+ const required = new Set();
347
+ for (const row of db.prepare('SELECT data FROM auth_accounts').iterate()) {
348
+ const user = JSON.parse(String(row.data));
349
+ for (const value of [user.totpSecret, user.totpPending])
350
+ if (value)
351
+ required.add(cipherKey(value));
352
+ }
353
+ for (const row of db.prepare('SELECT data FROM auth_flows').iterate())
354
+ required.add(cipherKey(String(row.data)));
355
+ const known = JSON.parse(String(db.prepare("SELECT value FROM auth_meta WHERE key='encryptionKeys'").get()?.value ?? '{}'));
356
+ const currentActive = db.prepare("SELECT value FROM auth_meta WHERE key='activeEncryptionKey'").get()?.value;
357
+ const usedKeys = new Set(JSON.parse(String(db.prepare("SELECT value FROM auth_meta WHERE key='usedEncryptionKeys'").get()?.value ?? '[]')));
358
+ if (typeof currentActive === 'string')
359
+ usedKeys.add(currentActive);
360
+ if (currentActive && currentActive !== options.activeKey && usedKeys.has(options.activeKey))
361
+ error(503, 'stale_encryption_key');
362
+ for (const name of required)
363
+ if (!options.keyFingerprints[name])
364
+ error(503, 'auth_configuration_changed');
365
+ for (const [name, fingerprint] of Object.entries(options.keyFingerprints))
366
+ if (known[name] && known[name] !== fingerprint)
367
+ error(503, 'auth_configuration_changed');
368
+ db.prepare("INSERT INTO auth_meta VALUES('encryptionKeys',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(JSON.stringify({ ...known, ...options.keyFingerprints }));
369
+ usedKeys.add(options.activeKey);
370
+ db.prepare("INSERT INTO auth_meta VALUES('usedEncryptionKeys',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(JSON.stringify([...usedKeys]));
371
+ db.prepare("INSERT INTO auth_meta VALUES('activeEncryptionKey',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(options.activeKey);
372
+ db.prepare("INSERT INTO auth_meta VALUES('configurationDefinition',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(configuration);
373
+ return nextRevision;
374
+ });
375
+ initializing = false;
376
+ port.postMessage({ ready: true });
377
+ }
378
+ catch (e) {
379
+ port.postMessage({ error: e instanceof AuthError ? e.code : 'auth_store_unavailable' });
380
+ port.close();
381
+ }
382
+ port.on('message', ({ id, operation, args }) => {
383
+ try {
384
+ db.exec('BEGIN IMMEDIATE');
385
+ dispatchTransaction = true;
386
+ if (db.prepare("SELECT value FROM auth_meta WHERE key='configuration'").get()?.value !== configurationRevision)
387
+ error(503, 'stale_auth_configuration');
388
+ if (db.prepare("SELECT value FROM auth_meta WHERE key='activeEncryptionKey'").get()?.value !== options.activeKey)
389
+ error(503, 'stale_encryption_key');
390
+ const now = Number(args.now);
391
+ let value;
392
+ const manual = manualRecoveryOperation(operation, args, { db, now, enabled: options.securityPolicy.allowManualRecovery === true, account, active, fresh, authorizeCase, save, addSession, audit, isAdministrator: user => admin(user.roles), isRestricted: restricted, fail: error });
393
+ const abuse = abuseOperation(operation, args, db, options.securityPolicy.abuse, error);
394
+ const administration = adminAccountOperation(operation, args, { db, now, deletionGraceMs: options.securityPolicy.deletionGraceMs, roles: options.roles, account, fresh, isRestricted: restricted, save, audit, fail: error });
395
+ if (manual)
396
+ value = manual.value;
397
+ else if (abuse)
398
+ value = abuse.value;
399
+ else if (administration)
400
+ value = administration.value;
401
+ else
402
+ switch (operation) {
403
+ case 'factorRecoveryBegin': {
404
+ if (!options.securityPolicy.allowEmailFactorRecovery)
405
+ error(403, 'factor_recovery_disabled');
406
+ db.prepare('DELETE FROM auth_factor_recovery WHERE account_id IN (SELECT account_id FROM auth_factor_recovery WHERE expires<=? LIMIT 1000)').run(now);
407
+ const user = decode(db.prepare('SELECT data FROM auth_accounts WHERE email=?').get(String(args.email)));
408
+ if (!user || user.status !== 'active' || !user.emailVerified || (!user.totpSecret && !passkeyFactor(user) && !user.mfaRecoveryRequired)) {
409
+ value = false;
410
+ break;
411
+ }
412
+ if (db.prepare('SELECT account_id FROM auth_factor_recovery WHERE account_id=?').get(user.id)) {
413
+ value = false;
414
+ break;
415
+ }
416
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_factor_recovery').get()?.n) >= 10000)
417
+ error(503, 'auth_capacity_reached');
418
+ db.prepare('INSERT INTO auth_factor_recovery VALUES(?,?,?,?,?,NULL,?)').run(user.id, String(args.verification), String(args.cancellation), String(args.browser), user.version, now + 172800000);
419
+ audit(user.id, 'factor_recovery.requested', user.id, now);
420
+ value = true;
421
+ break;
422
+ }
423
+ case 'factorRecoveryConfirm': {
424
+ if (!options.securityPolicy.allowEmailFactorRecovery)
425
+ error(403, 'factor_recovery_disabled');
426
+ const row = db.prepare('SELECT * FROM auth_factor_recovery WHERE verification_hash=? AND browser_hash=? AND expires>?').get(String(args.verification), String(args.browser), now);
427
+ if (!row)
428
+ error(400, 'invalid_recovery_token');
429
+ const user = active(String(row.account_id));
430
+ if (user.version !== row.version || !user.emailVerified || (!user.totpSecret && !passkeyFactor(user) && !user.mfaRecoveryRequired))
431
+ error(409, 'recovery_account_changed');
432
+ const completeAfter = row.complete_after === null ? now + 86400000 : num(row.complete_after), expires = row.complete_after === null ? now + 172800000 : num(row.expires);
433
+ db.prepare('UPDATE auth_factor_recovery SET complete_after=?,expires=? WHERE account_id=?').run(completeAfter, expires, user.id);
434
+ audit(user.id, 'factor_recovery.email_proved', user.id, now);
435
+ value = { completeAfter, expires };
436
+ break;
437
+ }
438
+ case 'factorRecoveryCancel': {
439
+ if (!options.securityPolicy.allowEmailFactorRecovery)
440
+ error(403, 'factor_recovery_disabled');
441
+ const row = db.prepare('SELECT account_id FROM auth_factor_recovery WHERE cancel_hash=? AND expires>?').get(String(args.cancellation), now);
442
+ if (!row)
443
+ error(400, 'invalid_recovery_token');
444
+ db.prepare('DELETE FROM auth_factor_recovery WHERE account_id=?').run(String(row.account_id));
445
+ audit(String(row.account_id), 'factor_recovery.cancelled', String(row.account_id), now);
446
+ value = true;
447
+ break;
448
+ }
449
+ case 'factorRecoveryComplete': {
450
+ if (!options.securityPolicy.allowEmailFactorRecovery)
451
+ error(403, 'factor_recovery_disabled');
452
+ const row = db.prepare('SELECT * FROM auth_factor_recovery WHERE verification_hash=? AND browser_hash=? AND expires>?').get(String(args.verification), String(args.browser), now);
453
+ if (!row)
454
+ error(400, 'invalid_recovery_token');
455
+ if (row.complete_after === null || num(row.complete_after) > now)
456
+ error(409, 'factor_recovery_cooldown');
457
+ const user = active(String(row.account_id));
458
+ if (user.version !== row.version || !user.emailVerified || (!user.totpSecret && !passkeyFactor(user) && !user.mfaRecoveryRequired))
459
+ error(409, 'recovery_account_changed');
460
+ delete user.totpSecret;
461
+ delete user.mfaPasskeys;
462
+ delete user.totpPending;
463
+ delete user.totpPendingUntil;
464
+ user.totpCounter = -1;
465
+ user.mfaRecoveryRequired = true;
466
+ user.version++;
467
+ save(user);
468
+ for (const table of ['auth_sessions', 'auth_tokens', 'auth_recovery', 'auth_email_codes', 'auth_email_changes', 'auth_factor_recovery'])
469
+ db.prepare('DELETE FROM ' + table + ' WHERE account_id=?').run(user.id);
470
+ const sessionValue = { ...args.session, accountId: user.id, recoveryEnrollment: 1 };
471
+ addSession(sessionValue);
472
+ audit(user.id, 'factor_recovery.enrollment_required', user.id, now);
473
+ value = user;
474
+ break;
475
+ }
476
+ case 'configurationRevision':
477
+ value = configurationRevision;
478
+ break;
479
+ case 'account':
480
+ value = account(String(args.id));
481
+ break;
482
+ case 'email':
483
+ value = decode(db.prepare('SELECT data FROM auth_accounts WHERE email=?').get(String(args.email)));
484
+ break;
485
+ case 'authenticate':
486
+ value = transaction(() => {
487
+ const found = session(String(args.hash), now);
488
+ if (found && now - Number(found.session.lastSeen) >= Math.min(60000, options.sessionIdleMs / 4))
489
+ db.prepare('UPDATE auth_sessions SET last_seen=? WHERE hash=?').run(now, String(args.hash));
490
+ return found;
491
+ });
492
+ break;
493
+ case 'attempt':
494
+ value = transaction(() => {
495
+ db.prepare('DELETE FROM auth_attempts WHERE key IN (SELECT key FROM auth_attempts WHERE expires<=? LIMIT 1000)').run(now);
496
+ const prior = db.prepare('SELECT count FROM auth_attempts WHERE key=? AND expires>?').get(String(args.key), now);
497
+ if (prior && num(prior.count) >= 10)
498
+ error(429, 'authentication_rate_limited');
499
+ if (!prior && num(db.prepare('SELECT count(*) AS n FROM auth_attempts').get()?.n) >= 100000)
500
+ error(503, 'auth_capacity_reached');
501
+ db.prepare('INSERT INTO auth_attempts VALUES(?,1,?) ON CONFLICT(key) DO UPDATE SET count=CASE WHEN expires<=? THEN 1 ELSE count+1 END,expires=CASE WHEN expires<=? THEN ? ELSE expires END').run(String(args.key), now + 900000, now, now, now + 900000);
502
+ return true;
503
+ });
504
+ break;
505
+ case 'factorProof': {
506
+ if (!options.securityPolicy.allowPasskeySecondFactor)
507
+ error(403, 'second_factor_unavailable');
508
+ const proof = args.proof, row = db.prepare('SELECT account_id FROM auth_passkeys WHERE id=?').get(proof.credentialId);
509
+ if (!row)
510
+ error(401, 'invalid_second_factor');
511
+ const user = active(String(row.account_id));
512
+ passkeyProof(user, proof, false);
513
+ db.prepare('DELETE FROM auth_second_factor_proofs WHERE hash IN (SELECT hash FROM auth_second_factor_proofs WHERE expires<=? LIMIT 1000)').run(now);
514
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_second_factor_proofs').get()?.n) >= 10000)
515
+ error(503, 'auth_capacity_reached');
516
+ db.prepare('INSERT INTO auth_second_factor_proofs VALUES(?,?,?,?,?,?)').run(String(args.hash), String(args.browser), user.id, user.version, JSON.stringify(proof), now + 300000);
517
+ value = true;
518
+ break;
519
+ }
520
+ case 'setPasskeyFactor': {
521
+ if (!options.securityPolicy.allowPasskeySecondFactor)
522
+ error(403, 'second_factor_unavailable');
523
+ const found = args.enabled ? fresh(String(args.hash), now, true) : realMfa(String(args.hash), now), user = found.user;
524
+ if (!db.prepare('SELECT id FROM auth_passkeys WHERE id=? AND account_id=?').get(String(args.credentialId), user.id))
525
+ error(404, 'sign_in_method_not_found');
526
+ if (args.enabled) {
527
+ if ((!found.session.primaryMethod || found.session.primaryMethod === 'unknown') && !found.session.recoveryEnrollment || found.session.primaryCredentialId === args.credentialId)
528
+ error(401, 'independent_second_factor_required');
529
+ takeSecondFactor(user, { ...args, session: found.session }, now, String(args.credentialId));
530
+ user.mfaPasskeys = [...new Set([...(user.mfaPasskeys ?? []), String(args.credentialId)])];
531
+ delete user.mfaRecoveryRequired;
532
+ }
533
+ else {
534
+ if (options.securityPolicy.requireMfa && !user.totpSecret && user.mfaPasskeys?.length === 1 && user.mfaPasskeys.includes(String(args.credentialId)))
535
+ error(409, 'last_required_factor');
536
+ user.mfaPasskeys = (user.mfaPasskeys ?? []).filter(id => id !== args.credentialId);
537
+ }
538
+ user.version++;
539
+ save(user);
540
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=? AND hash<>?').run(user.id, String(args.hash));
541
+ db.prepare('UPDATE auth_sessions SET mfa_authenticated_at=?,mfa_version=?,recovery_enrollment=0 WHERE hash=?').run(now, user.version, String(args.hash));
542
+ audit(user.id, args.enabled ? 'passkey.factor_enabled' : 'passkey.factor_disabled', user.id, now);
543
+ value = true;
544
+ break;
545
+ }
546
+ case 'rememberDevice': {
547
+ if (!options.securityPolicy.trustedDeviceTtlMs)
548
+ error(403, 'trusted_device_unavailable');
549
+ const { user } = realMfa(String(args.hash), now);
550
+ if (!user.totpSecret && !passkeyFactor(user))
551
+ error(403, 'second_factor_required');
552
+ if (Number(args.expires) > now + options.securityPolicy.trustedDeviceTtlMs || Number(args.expires) <= now)
553
+ error(400, 'invalid_trusted_device');
554
+ db.prepare('DELETE FROM auth_trusted_devices WHERE account_id=? AND (expires<=? OR version<>?)').run(user.id, now, user.version);
555
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_trusted_devices WHERE account_id=?').get(user.id)?.n) >= 20)
556
+ error(409, 'trusted_device_limit');
557
+ db.prepare('INSERT INTO auth_trusted_devices VALUES(?,?,?,?,?,?,?)').run(String(args.deviceHash), String(args.deviceId), user.id, user.version, String(args.label), now, Number(args.expires));
558
+ audit(user.id, 'device.trusted', String(args.deviceId), now);
559
+ value = true;
560
+ break;
561
+ }
562
+ case 'trustedDevices': {
563
+ const found = session(String(args.hash), now);
564
+ if (!found || found.session.impersonatorId)
565
+ error(401, 'invalid_credentials');
566
+ value = db.prepare('SELECT id,label,created,expires FROM auth_trusted_devices WHERE account_id=? AND version=? AND expires>? ORDER BY created DESC LIMIT 20').all(found.user.id, found.user.version, now);
567
+ break;
568
+ }
569
+ case 'revokeTrustedDevice': {
570
+ const { user } = realMfa(String(args.hash), now);
571
+ db.prepare('DELETE FROM auth_trusted_devices WHERE id=? AND account_id=?').run(String(args.deviceId), user.id);
572
+ audit(user.id, 'device.trust_revoked', String(args.deviceId), now);
573
+ value = true;
574
+ break;
575
+ }
576
+ case 'signupBegin': {
577
+ db.prepare('DELETE FROM auth_signups WHERE hash IN (SELECT hash FROM auth_signups WHERE expires<=? LIMIT 1000)').run(now);
578
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_signups').get()?.n) >= 10000)
579
+ error(503, 'auth_capacity_reached');
580
+ const existing = Boolean(db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(String(args.email)));
581
+ const eligible = Boolean(args.eligible) && (options.registration.mode !== 'invite-only' || Boolean(db.prepare('SELECT hash FROM auth_invites WHERE hash=? AND email=? AND expires>?').get(String(args.invitationHash), String(args.email), now)));
582
+ db.prepare('INSERT INTO auth_signups VALUES(?,?,?,?,?,?,?,?,0,?,?,NULL,NULL)').run(String(args.hash), String(args.browser), String(args.email), String(args.accountId), String(args.step), Number(args.expires), String(args.codeHash), now + 600000, Number(eligible && !existing), String(args.invitationHash));
583
+ value = { existing, eligible };
584
+ break;
585
+ }
586
+ case 'signupRead':
587
+ value = db.prepare('SELECT account_id,email,step,expires,challenge FROM auth_signups WHERE hash=? AND browser=? AND expires>?').get(String(args.hash), String(args.browser), now) ?? null;
588
+ break;
589
+ case 'signupVerify': {
590
+ const row = db.prepare("SELECT * FROM auth_signups WHERE hash=? AND browser=? AND expires>? AND step='verify-email' AND code_expires>? AND attempts<5").get(String(args.hash), String(args.browser), now, now);
591
+ value = null;
592
+ if (row) {
593
+ db.prepare('UPDATE auth_signups SET attempts=attempts+1 WHERE hash=?').run(String(args.hash));
594
+ if (row.code_hash === args.codeHash) {
595
+ db.prepare("UPDATE auth_signups SET step='credential',code_hash='' WHERE hash=?").run(String(args.hash));
596
+ value = { ...row, step: 'credential' };
597
+ }
598
+ }
599
+ break;
600
+ }
601
+ case 'signupChallenge': {
602
+ const result = db.prepare("UPDATE auth_signups SET challenge=? WHERE hash=? AND browser=? AND expires>? AND step='credential'").run(String(args.challenge), String(args.hash), String(args.browser), now);
603
+ if (!result.changes)
604
+ error(400, 'invalid_signup_flow');
605
+ value = true;
606
+ break;
607
+ }
608
+ case 'signupCredential': {
609
+ const row = db.prepare("SELECT * FROM auth_signups WHERE hash=? AND browser=? AND expires>? AND step='credential'").get(String(args.hash), String(args.browser), now);
610
+ if (!row || args.credential && (!row.challenge || row.challenge !== args.challenge))
611
+ error(400, 'invalid_signup_flow');
612
+ const credential = args.credential ? { passkey: args.credential } : { passwordHash: args.passwordHash };
613
+ db.prepare("UPDATE auth_signups SET step='profile',credential=?,challenge=NULL WHERE hash=?").run(JSON.stringify(credential), String(args.hash));
614
+ value = true;
615
+ break;
616
+ }
617
+ case 'signupComplete': {
618
+ const row = db.prepare("SELECT * FROM auth_signups WHERE hash=? AND browser=? AND expires>? AND step='profile'").get(String(args.hash), String(args.browser), now);
619
+ if (!row)
620
+ error(400, 'invalid_signup_flow');
621
+ db.prepare('DELETE FROM auth_signups WHERE hash=?').run(String(args.hash));
622
+ if (!row.eligible || db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(String(row.email))) {
623
+ value = null;
624
+ break;
625
+ }
626
+ const credential = JSON.parse(String(row.credential));
627
+ if (options.registration.mode === 'waitlist') {
628
+ if (db.prepare('SELECT id FROM auth_waitlist WHERE email=?').get(String(row.email))) {
629
+ value = null;
630
+ break;
631
+ }
632
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_waitlist').get()?.n) >= 10000)
633
+ error(503, 'auth_capacity_reached');
634
+ db.prepare('INSERT INTO auth_waitlist(id,email,password_hash,created,profile,email_verified,passkey) VALUES(?,?,?,?,?,?,?)').run(String(row.account_id), String(row.email), credential.passwordHash ?? '', now, JSON.stringify(args.profile), Number(options.securityPolicy.requireEmailVerification), credential.passkey ? JSON.stringify(credential.passkey) : null);
635
+ audit('anonymous', 'registration.requested', String(row.account_id), now);
636
+ value = null;
637
+ break;
638
+ }
639
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_accounts').get()?.n) >= 100000)
640
+ error(503, 'auth_capacity_reached');
641
+ if (options.registration.mode === 'invite-only' && db.prepare('DELETE FROM auth_invites WHERE hash=? AND email=? AND expires>?').run(String(row.invitation_hash), String(row.email), now).changes !== 1)
642
+ error(403, 'registration_unavailable');
643
+ const user = { id: String(row.account_id), email: String(row.email), emailVerified: options.securityPolicy.requireEmailVerification, status: 'active', roles: [options.defaultRole], created: now, passwordHash: credential.passwordHash ?? '', version: 1, totpCounter: -1, profile: args.profile };
644
+ db.prepare('INSERT INTO auth_accounts VALUES(?,?,?,?,?)').run(user.id, user.email, user.status, 0, JSON.stringify(user));
645
+ if (credential.passkey) {
646
+ const passkey = credential.passkey;
647
+ if (db.prepare('SELECT id FROM auth_passkeys WHERE id=?').get(passkey.id))
648
+ error(409, 'credential_already_registered');
649
+ db.prepare('INSERT INTO auth_passkeys VALUES(?,?,?,?)').run(passkey.id, user.id, JSON.stringify(passkey), passkey.counter);
650
+ methodActivity('passkey', String(passkey.id), user.id, now, true);
651
+ }
652
+ if (credential.passkey)
653
+ Object.assign(args.session, { primaryMethod: 'passkey', primaryCredentialId: credential.passkey.id });
654
+ if (args.session.accountId !== user.id)
655
+ error(400, 'invalid_signup_flow');
656
+ const newDevice = addSession(args.session);
657
+ audit(user.id, 'account.register', user.id, now);
658
+ value = { ...user, ...(newDevice ? { newDevice: true } : {}) };
659
+ break;
660
+ }
661
+ case 'create':
662
+ value = transaction(() => {
663
+ if (args.bootstrap && num(db.prepare('SELECT count(*) AS n FROM auth_accounts').get()?.n) !== 0)
664
+ error(409, 'bootstrap_unavailable');
665
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_accounts').get()?.n) >= 100000)
666
+ error(503, 'auth_capacity_reached');
667
+ const user = args.user;
668
+ if (args.invitationHash) {
669
+ const invite = db.prepare('DELETE FROM auth_invites WHERE hash=? AND email=? AND expires>?').run(String(args.invitationHash), user.email, now);
670
+ if (invite.changes !== 1)
671
+ error(403, 'registration_unavailable');
672
+ }
673
+ if (db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(user.email))
674
+ error(409, 'registration_unavailable');
675
+ db.prepare('INSERT INTO auth_accounts VALUES(?,?,?,?,?)').run(user.id, user.email, user.status, Number(admin(user.roles)), JSON.stringify(user));
676
+ const newDevice = addSession(args.session);
677
+ audit(user.id, args.bootstrap ? 'admin.bootstrap' : 'account.register', user.id, now);
678
+ return { ...user, ...(typeof newDevice !== 'undefined' && newDevice ? { newDevice: true } : {}) };
679
+ });
680
+ break;
681
+ case 'login':
682
+ value = transaction(() => {
683
+ const user = active(String(args.accountId));
684
+ if (args.abuseKey && db.prepare('SELECT key FROM auth_abuse WHERE key=? AND blocked_until>? AND expires>?').get(String(args.abuseKey), now, now))
685
+ error(429, 'auth_backoff');
686
+ if (args.proof) {
687
+ const proof = args.proof;
688
+ if (user.version !== proof.version)
689
+ error(401, 'stale_auth_proof');
690
+ if (proof.kind === 'oidc') {
691
+ methodActivity('oidc', String(proof.provider) + '\0' + String(proof.subject), user.id, now);
692
+ if (!db.prepare('SELECT account_id FROM auth_external WHERE provider=? AND subject=? AND account_id=?').get(String(proof.provider), String(proof.subject), user.id))
693
+ error(401, 'stale_auth_proof');
694
+ }
695
+ else if (proof.kind === 'passkey') {
696
+ methodActivity('passkey', String(proof.credentialId), user.id, now);
697
+ const credential = db.prepare('SELECT data,counter FROM auth_passkeys WHERE id=? AND account_id=?').get(String(proof.credentialId), user.id);
698
+ if (!credential || num(credential.counter) !== proof.expectedCounter || createHash('sha256').update(String(JSON.parse(String(credential.data)).publicKey)).digest('hex') !== proof.publicKeyHash)
699
+ error(401, 'stale_auth_proof');
700
+ db.prepare('UPDATE auth_passkeys SET counter=? WHERE id=? AND account_id=? AND counter=?').run(Number(proof.newCounter), String(proof.credentialId), user.id, Number(proof.expectedCounter));
701
+ }
702
+ else
703
+ error(400, 'invalid_auth_proof');
704
+ }
705
+ if (args.oldHash) {
706
+ const previous = session(String(args.oldHash), now);
707
+ if (previous?.session.recoveryEnrollment)
708
+ args.session.recoveryEnrollment = 1;
709
+ if (!previous || previous.user.id !== user.id || previous.session.impersonatorId)
710
+ error(401, 'invalid_credentials');
711
+ }
712
+ if (user.version !== args.version || user.passwordHash !== args.passwordHash)
713
+ error(401, 'invalid_credentials');
714
+ const actualMfa = consumeFactor(user, args, now);
715
+ if (args.upgradedHash) {
716
+ user.passwordHash = String(args.upgradedHash);
717
+ user.version++;
718
+ save(user);
719
+ }
720
+ if (args.oldHash)
721
+ db.prepare('DELETE FROM auth_sessions WHERE hash=? AND account_id=?').run(String(args.oldHash), user.id);
722
+ if (actualMfa)
723
+ Object.assign(args.session, { mfaAuthenticatedAt: now, mfaVersion: user.version });
724
+ const newDevice = addSession(args.session);
725
+ db.prepare('DELETE FROM auth_attempts WHERE key=?').run(String(args.attemptKey));
726
+ if (args.abuseKey)
727
+ db.prepare('DELETE FROM auth_abuse WHERE key=?').run(String(args.abuseKey));
728
+ audit(user.id, args.oldHash ? 'session.step_up' : 'session.login', user.id, now);
729
+ if (!args.oldHash)
730
+ metric('success', args.proof ? String(args.proof.kind) : 'password', now);
731
+ return { ...user, ...(typeof newDevice !== 'undefined' && newDevice ? { newDevice: true } : {}) };
732
+ });
733
+ break;
734
+ case 'logout':
735
+ value = transaction(() => {
736
+ const found = session(String(args.hash), now);
737
+ db.prepare('DELETE FROM auth_sessions WHERE hash=?').run(String(args.hash));
738
+ if (found)
739
+ audit(found.session.impersonatorId ?? found.user.id, 'session.logout', found.session.id, now);
740
+ return true;
741
+ });
742
+ break;
743
+ case 'revoke':
744
+ value = transaction(() => {
745
+ const user = account(String(args.accountId));
746
+ if (user) {
747
+ user.version++;
748
+ save(user);
749
+ }
750
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=?').run(String(args.accountId));
751
+ audit(String(args.accountId), 'session.revoke_all', String(args.accountId), now);
752
+ return true;
753
+ });
754
+ break;
755
+ case 'issueToken':
756
+ value = transaction(() => {
757
+ const user = decode(db.prepare('SELECT data FROM auth_accounts WHERE email=?').get(String(args.email)));
758
+ if (!user || user.status !== 'active')
759
+ return false;
760
+ db.prepare('DELETE FROM auth_tokens WHERE hash IN (SELECT hash FROM auth_tokens WHERE expires<=? LIMIT 1000)').run(now);
761
+ db.prepare('DELETE FROM auth_tokens WHERE account_id=? AND purpose=?').run(user.id, String(args.purpose));
762
+ db.prepare('INSERT INTO auth_tokens VALUES(?,?,?,?,?)').run(String(args.hash), user.id, String(args.purpose), now + 1800000, user.version);
763
+ audit(user.id, 'token.issue', String(args.purpose), now);
764
+ return true;
765
+ });
766
+ break;
767
+ case 'consumeToken':
768
+ value = transaction(() => {
769
+ const token = db.prepare('SELECT * FROM auth_tokens WHERE hash=? AND purpose=? AND expires>?').get(String(args.hash), String(args.purpose), now);
770
+ if (!token)
771
+ error(400, 'invalid_token');
772
+ const user = active(String(token.account_id));
773
+ if (user.version !== token.version)
774
+ error(400, 'invalid_token');
775
+ db.prepare('DELETE FROM auth_tokens WHERE hash=?').run(String(args.hash));
776
+ if (args.purpose === 'verify-email') {
777
+ user.emailVerified = true;
778
+ if (options.securityPolicy.requireEmailVerification)
779
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=?').run(user.id);
780
+ }
781
+ else {
782
+ user.passwordHash = String(args.passwordHash);
783
+ db.prepare('DELETE FROM auth_abuse WHERE key=?').run(abuseKey('password', user.email));
784
+ db.prepare('DELETE FROM auth_attempts WHERE key=?').run(createHash('sha256').update('urlcode-auth-attempt:login:' + user.email).digest('hex'));
785
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=?').run(user.id);
786
+ }
787
+ user.version++;
788
+ save(user);
789
+ audit(user.id, args.purpose === 'verify-email' ? 'email.verified' : 'password.reset', user.id, now);
790
+ return user;
791
+ });
792
+ break;
793
+ case 'totpBegin':
794
+ value = transaction(() => {
795
+ const { user } = fresh(String(args.hash), now, true);
796
+ if (user.totpSecret)
797
+ error(409, 'totp_already_enabled');
798
+ user.totpPending = String(args.secret);
799
+ user.totpPendingUntil = now + 600000;
800
+ user.version++;
801
+ save(user);
802
+ audit(user.id, 'totp.begin', user.id, now);
803
+ return user;
804
+ });
805
+ break;
806
+ case 'totpConfirm':
807
+ value = transaction(() => {
808
+ const { user } = fresh(String(args.hash), now, true);
809
+ if (user.version !== args.version || !user.totpPending || !user.totpPendingUntil || user.totpPendingUntil <= now)
810
+ error(400, 'invalid_totp_setup');
811
+ user.totpSecret = user.totpPending;
812
+ delete user.mfaRecoveryRequired;
813
+ delete user.totpPending;
814
+ delete user.totpPendingUntil;
815
+ user.totpCounter = Number(args.counter);
816
+ user.version++;
817
+ save(user);
818
+ db.prepare('DELETE FROM auth_recovery WHERE account_id=?').run(user.id);
819
+ for (const hash of args.recoveryHashes)
820
+ db.prepare('INSERT INTO auth_recovery VALUES(?,?)').run(hash, user.id);
821
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=? AND hash<>?').run(user.id, String(args.hash));
822
+ db.prepare('UPDATE auth_sessions SET mfa_authenticated_at=?,mfa_version=? WHERE hash=?').run(now, user.version, String(args.hash));
823
+ audit(user.id, 'totp.enabled', user.id, now);
824
+ return true;
825
+ });
826
+ break;
827
+ case 'totpDisable':
828
+ value = transaction(() => {
829
+ const { user } = fresh(String(args.hash), now);
830
+ if (user.version !== args.version || !user.totpSecret)
831
+ error(401, 'invalid_credentials');
832
+ consumeFactor(user, args, now);
833
+ delete user.totpSecret;
834
+ user.totpCounter = -1;
835
+ user.version++;
836
+ save(user);
837
+ db.prepare('DELETE FROM auth_recovery WHERE account_id=?').run(user.id);
838
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=? AND hash<>?').run(user.id, String(args.hash));
839
+ audit(user.id, 'totp.disabled', user.id, now);
840
+ return true;
841
+ });
842
+ break;
843
+ case 'admin':
844
+ value = transaction(() => {
845
+ const actor = fresh(String(args.hash), now).user, target = account(String(args.accountId));
846
+ if (!target)
847
+ error(404, 'account_not_found');
848
+ if (target.status === 'pending-delete')
849
+ error(409, 'account_pending_deletion');
850
+ const actorPermissions = permissions(actor.roles);
851
+ if (!actorPermissions.includes('*') && !actorPermissions.includes('auth.users.manage'))
852
+ error(403, 'permission_denied');
853
+ if (actor.id === target.id)
854
+ error(403, 'self_administration_denied');
855
+ const nextRoles = args.roles || target.roles;
856
+ for (const p of [...permissions(target.roles), ...permissions(nextRoles)])
857
+ if (!actorPermissions.includes('*') && !actorPermissions.includes(p))
858
+ error(403, 'delegation_ceiling_exceeded');
859
+ const nextStatus = args.status || target.status;
860
+ if (admin(target.roles) && target.status === 'active' && (!admin(nextRoles) || nextStatus !== 'active') && num(db.prepare("SELECT count(*) AS n FROM auth_accounts WHERE administrator=1 AND status='active'").get()?.n) <= 1)
861
+ error(409, 'last_administrator_required');
862
+ target.roles = nextRoles;
863
+ target.status = nextStatus;
864
+ target.version++;
865
+ save(target);
866
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=?').run(target.id);
867
+ audit(actor.id, args.roles ? 'admin.roles' : 'admin.status', target.id, now, String(args.reason || ''));
868
+ return target;
869
+ });
870
+ break;
871
+ case 'adminRevoke':
872
+ value = transaction(() => {
873
+ const actor = fresh(String(args.hash), now).user, p = permissions(actor.roles);
874
+ if (!p.includes('*') && !p.includes('auth.sessions.manage'))
875
+ error(403, 'permission_denied');
876
+ const target = account(String(args.accountId));
877
+ if (!target)
878
+ error(404, 'account_not_found');
879
+ for (const permission of permissions(target.roles))
880
+ if (!p.includes('*') && !p.includes(permission))
881
+ error(403, 'delegation_ceiling_exceeded');
882
+ target.version++;
883
+ save(target);
884
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=?').run(target.id);
885
+ audit(actor.id, 'admin.sessions.revoke', target.id, now, String(args.reason || ''));
886
+ return true;
887
+ });
888
+ break;
889
+ case 'putFlow':
890
+ value = transaction(() => {
891
+ db.prepare('DELETE FROM auth_flows WHERE id IN (SELECT id FROM auth_flows WHERE expires<=? LIMIT 1000)').run(now);
892
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_flows').get()?.n) >= 10000)
893
+ error(503, 'auth_capacity_reached');
894
+ db.prepare('INSERT INTO auth_flows VALUES(?,?,?,?)').run(String(args.id), String(args.kind), String(args.data), Number(args.expires));
895
+ return true;
896
+ });
897
+ break;
898
+ case 'consumeFlow':
899
+ value = transaction(() => {
900
+ const row = db.prepare('SELECT data FROM auth_flows WHERE id=? AND kind=? AND expires>?').get(String(args.id), String(args.kind), now);
901
+ if (!row)
902
+ return null;
903
+ db.prepare('DELETE FROM auth_flows WHERE id=?').run(String(args.id));
904
+ return row.data;
905
+ });
906
+ break;
907
+ case 'external': {
908
+ const row = db.prepare('SELECT account_id FROM auth_external WHERE provider=? AND subject=?').get(String(args.provider), String(args.subject));
909
+ value = row ? account(String(row.account_id)) : null;
910
+ break;
911
+ }
912
+ case 'linkExternal':
913
+ value = transaction(() => {
914
+ const { user } = fresh(String(args.hash), now);
915
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_external WHERE account_id=?').get(user.id)?.n) >= 16)
916
+ error(409, 'credential_limit');
917
+ if (db.prepare('SELECT account_id FROM auth_external WHERE provider=? AND subject=?').get(String(args.provider), String(args.subject)))
918
+ error(409, 'identity_already_linked');
919
+ db.prepare('INSERT INTO auth_external VALUES(?,?,?)').run(String(args.provider), String(args.subject), user.id);
920
+ methodActivity('oidc', String(args.provider) + '\0' + String(args.subject), user.id, now, true);
921
+ user.version++;
922
+ save(user);
923
+ audit(user.id, 'identity.linked', user.id, now);
924
+ return true;
925
+ });
926
+ break;
927
+ case 'createExternal':
928
+ value = transaction(() => {
929
+ const user = args.user;
930
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_accounts').get()?.n) >= 100000)
931
+ error(503, 'auth_capacity_reached');
932
+ if (db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(user.email))
933
+ error(409, 'explicit_identity_link_required');
934
+ if (db.prepare('SELECT account_id FROM auth_external WHERE provider=? AND subject=?').get(String(args.provider), String(args.subject)))
935
+ error(409, 'identity_already_linked');
936
+ db.prepare('INSERT INTO auth_accounts VALUES(?,?,?,?,?)').run(user.id, user.email, user.status, 0, JSON.stringify(user));
937
+ db.prepare('INSERT INTO auth_external VALUES(?,?,?)').run(String(args.provider), String(args.subject), user.id);
938
+ methodActivity('oidc', String(args.provider) + '\0' + String(args.subject), user.id, now, true);
939
+ audit(user.id, 'account.external_register', user.id, now);
940
+ return user;
941
+ });
942
+ break;
943
+ case 'addPasskey':
944
+ value = transaction(() => {
945
+ const { user } = fresh(String(args.hash), now, true), credential = args.credential;
946
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_passkeys WHERE account_id=?').get(user.id)?.n) >= 16)
947
+ error(409, 'credential_limit');
948
+ if (db.prepare('SELECT id FROM auth_passkeys WHERE id=?').get(String(credential.id)))
949
+ error(409, 'credential_already_registered');
950
+ db.prepare('INSERT INTO auth_passkeys VALUES(?,?,?,?)').run(String(credential.id), user.id, JSON.stringify(credential), Number(credential.counter));
951
+ methodActivity('passkey', String(credential.id), user.id, now, true);
952
+ user.version++;
953
+ save(user);
954
+ audit(user.id, 'passkey.added', user.id, now);
955
+ return true;
956
+ });
957
+ break;
958
+ case 'getPasskey': {
959
+ const row = db.prepare('SELECT * FROM auth_passkeys WHERE id=?').get(String(args.id));
960
+ const owner = row ? account(String(row.account_id)) : null;
961
+ value = row && owner ? { accountId: String(row.account_id), credential: { ...JSON.parse(String(row.data)), counter: Number(row.counter) }, proof: { kind: 'passkey', version: owner.version, credentialId: String(row.id), publicKeyHash: createHash('sha256').update(String(JSON.parse(String(row.data)).publicKey)).digest('hex'), expectedCounter: Number(row.counter) } } : null;
962
+ break;
963
+ }
964
+ case 'listPasskeys':
965
+ value = db.prepare('SELECT data,counter FROM auth_passkeys WHERE account_id=? LIMIT 16').all(String(args.accountId)).map(row => ({ ...JSON.parse(String(row.data)), counter: Number(row.counter), secondFactor: Boolean(options.securityPolicy.allowPasskeySecondFactor && account(String(args.accountId))?.mfaPasskeys?.includes(String(JSON.parse(String(row.data)).id))) }));
966
+ break;
967
+ case 'advancePasskey':
968
+ value = transaction(() => {
969
+ const result = db.prepare('UPDATE auth_passkeys SET counter=? WHERE id=? AND counter=?').run(Number(args.newCounter), String(args.id), Number(args.expectedCounter));
970
+ if (result.changes !== 1)
971
+ error(409, 'passkey_counter_changed');
972
+ return true;
973
+ });
974
+ break;
975
+ case 'updateProfile':
976
+ value = transaction(() => {
977
+ const { user } = fresh(String(args.hash), now);
978
+ if (user.version !== args.version)
979
+ error(409, 'account_changed');
980
+ user.profile = args.profile;
981
+ user.version++;
982
+ save(user);
983
+ audit(user.id, 'profile.updated', user.id, now);
984
+ return true;
985
+ });
986
+ break;
987
+ case 'impersonate':
988
+ value = transaction(() => {
989
+ const actor = fresh(String(args.hash), now).user, target = active(String(args.accountId)), p = permissions(actor.roles);
990
+ if (!options.registration.allowImpersonation || actor.id === target.id || !p.includes('*') && !p.includes('auth.users.impersonate') || permissions(target.roles).some(permission => permission === '*' || permission.startsWith('auth.') || permission.startsWith('admin.')))
991
+ error(403, 'impersonation_denied');
992
+ const issued = { ...args.session, impersonatorId: actor.id, actorVersion: actor.version };
993
+ addSession(issued);
994
+ audit(actor.id, 'impersonation.started', target.id, now, String(args.reason));
995
+ return { user: target, session: issued };
996
+ });
997
+ break;
998
+ case 'createCase':
999
+ value = transaction(() => {
1000
+ const actor = fresh(String(args.hash), now).user, target = account(String(args.accountId));
1001
+ if (!target)
1002
+ error(404, 'account_not_found');
1003
+ authorizeCase(actor, target, args.roles);
1004
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_cases').get()?.n) >= 10000)
1005
+ error(503, 'auth_capacity_reached');
1006
+ const item = { id: String(args.id), accountId: target.id, action: String(args.action), ...(args.roles ? { roles: args.roles } : {}), reason: String(args.reason), makerId: actor.id, status: 'pending', created: now, expires: now + 3600000, targetVersion: target.version };
1007
+ db.prepare('INSERT INTO auth_cases VALUES(?,?)').run(item.id, JSON.stringify(item));
1008
+ audit(actor.id, 'case.created', target.id, now, String(args.reason));
1009
+ return item;
1010
+ });
1011
+ break;
1012
+ case 'case': {
1013
+ const row = db.prepare('SELECT data FROM auth_cases WHERE id=?').get(String(args.id));
1014
+ value = row ? JSON.parse(String(row.data)) : null;
1015
+ break;
1016
+ }
1017
+ case 'cases':
1018
+ value = db.prepare("SELECT data FROM auth_cases WHERE id>? AND json_extract(data,'$.action')!='restore-access' ORDER BY id LIMIT ?").all(String(args.after), Number(args.limit)).map(row => JSON.parse(String(row.data)));
1019
+ break;
1020
+ case 'approveCase':
1021
+ value = transaction(() => {
1022
+ const approver = fresh(String(args.hash), now).user, row = db.prepare('SELECT data FROM auth_cases WHERE id=?').get(String(args.id));
1023
+ if (!row)
1024
+ error(404, 'case_not_found');
1025
+ const item = JSON.parse(String(row.data));
1026
+ if (item.action === 'restore-access')
1027
+ error(400, 'recovery_approval_required');
1028
+ if (item.status !== 'pending' || item.expires <= now)
1029
+ error(409, 'case_unavailable');
1030
+ if (item.makerId === approver.id)
1031
+ error(403, 'distinct_approver_required');
1032
+ const maker = active(item.makerId), target = account(item.accountId);
1033
+ if (!target || target.version !== item.targetVersion)
1034
+ error(409, 'case_target_changed');
1035
+ authorizeCase(maker, target, item.roles);
1036
+ authorizeCase(approver, target, item.roles);
1037
+ const nextRoles = item.action === 'roles' ? item.roles : target.roles, nextStatus = item.action === 'lock' ? 'locked' : item.action === 'unlock' ? 'active' : target.status;
1038
+ if (admin(target.roles) && target.status === 'active' && (!admin(nextRoles) || nextStatus !== 'active') && num(db.prepare("SELECT count(*) AS n FROM auth_accounts WHERE administrator=1 AND status='active'").get()?.n) <= 1)
1039
+ error(409, 'last_administrator_required');
1040
+ if (item.action === 'reset-factors') {
1041
+ if (!target.passwordHash && num(db.prepare('SELECT count(*) AS n FROM auth_external WHERE account_id=?').get(target.id)?.n) === 0 && num(db.prepare('SELECT count(*) AS n FROM auth_passkeys WHERE account_id=?').get(target.id)?.n) > 0)
1042
+ error(409, 'last_sign_in_method');
1043
+ delete target.totpSecret;
1044
+ delete target.mfaPasskeys;
1045
+ delete target.totpPending;
1046
+ delete target.totpPendingUntil;
1047
+ target.totpCounter = -1;
1048
+ db.prepare('DELETE FROM auth_recovery WHERE account_id=?').run(target.id);
1049
+ db.prepare('DELETE FROM auth_passkeys WHERE account_id=?').run(target.id);
1050
+ db.prepare("DELETE FROM auth_method_activity WHERE kind='passkey' AND account_id=?").run(target.id);
1051
+ }
1052
+ target.roles = nextRoles;
1053
+ target.status = nextStatus;
1054
+ target.version++;
1055
+ save(target);
1056
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=?').run(target.id);
1057
+ item.status = 'applied';
1058
+ item.approverId = approver.id;
1059
+ db.prepare('UPDATE auth_cases SET data=? WHERE id=?').run(JSON.stringify(item), item.id);
1060
+ audit(approver.id, 'case.approved', target.id, now, String(args.reason));
1061
+ audit(maker.id, 'case.executed', target.id, now, item.id);
1062
+ return item;
1063
+ });
1064
+ break;
1065
+ case 'importUsers':
1066
+ value = transaction(() => {
1067
+ const users = args.users;
1068
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_accounts').get()?.n) + users.length > 100000)
1069
+ error(503, 'auth_capacity_reached');
1070
+ const seen = new Set();
1071
+ for (const user of users) {
1072
+ if (seen.has(user.email) || db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(user.email))
1073
+ error(409, 'import_collision');
1074
+ seen.add(user.email);
1075
+ }
1076
+ for (const user of users)
1077
+ db.prepare('INSERT INTO auth_accounts VALUES(?,?,?,?,?)').run(user.id, user.email, user.status, 0, JSON.stringify(user));
1078
+ audit('operator', 'accounts.imported', String(users.length), now);
1079
+ return { imported: users.length };
1080
+ });
1081
+ break;
1082
+ case 'invite':
1083
+ value = transaction(() => {
1084
+ const actor = fresh(String(args.actorHash), now).user, p = permissions(actor.roles);
1085
+ if (!p.includes('*') && !p.includes('auth.users.create'))
1086
+ error(403, 'permission_denied');
1087
+ for (const permission of permissions([options.defaultRole]))
1088
+ if (!p.includes('*') && !p.includes(permission))
1089
+ error(403, 'delegation_ceiling_exceeded');
1090
+ db.prepare('DELETE FROM auth_invites WHERE expires<=?').run(now);
1091
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_invites').get()?.n) >= 10000)
1092
+ error(503, 'auth_capacity_reached');
1093
+ db.prepare('DELETE FROM auth_invites WHERE email=?').run(String(args.email));
1094
+ db.prepare('INSERT INTO auth_invites VALUES(?,?,?)').run(String(args.hash), String(args.email), now + 86400000);
1095
+ audit(actor.id, 'registration.invited', String(args.email), now);
1096
+ return true;
1097
+ });
1098
+ break;
1099
+ case 'requestRegistration':
1100
+ value = transaction(() => {
1101
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_waitlist').get()?.n) >= 10000)
1102
+ error(503, 'auth_capacity_reached');
1103
+ if (db.prepare('SELECT id FROM auth_waitlist WHERE email=?').get(String(args.email)) || db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(String(args.email)))
1104
+ error(409, 'registration_unavailable');
1105
+ db.prepare('INSERT INTO auth_waitlist(id,email,password_hash,created,profile) VALUES(?,?,?,?,?)').run(String(args.id), String(args.email), String(args.passwordHash), now, args.profile ? JSON.stringify(args.profile) : null);
1106
+ return { id: args.id };
1107
+ });
1108
+ break;
1109
+ case 'registrationRequests':
1110
+ value = db.prepare('SELECT id,email,created FROM auth_waitlist WHERE id>? ORDER BY id LIMIT ?').all(String(args.after), Number(args.limit));
1111
+ break;
1112
+ case 'approveRegistration':
1113
+ value = transaction(() => {
1114
+ const actor = fresh(String(args.hash), now).user, p = permissions(actor.roles);
1115
+ if (!p.includes('*') && !p.includes('auth.users.manage'))
1116
+ error(403, 'permission_denied');
1117
+ for (const permission of permissions([options.defaultRole]))
1118
+ if (!p.includes('*') && !p.includes(permission))
1119
+ error(403, 'delegation_ceiling_exceeded');
1120
+ const request = db.prepare('SELECT * FROM auth_waitlist WHERE id=?').get(String(args.requestId));
1121
+ if (!request)
1122
+ error(404, 'registration_request_not_found');
1123
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_accounts').get()?.n) >= 100000)
1124
+ error(503, 'auth_capacity_reached');
1125
+ const user = { id: String(request.id), email: String(request.email), emailVerified: Boolean(request.email_verified), status: 'active', roles: [options.defaultRole], created: now, passwordHash: String(request.password_hash), version: 1, totpCounter: -1, ...(request.profile ? { profile: JSON.parse(String(request.profile)) } : {}) };
1126
+ if (db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(user.email))
1127
+ error(409, 'registration_unavailable');
1128
+ db.prepare('INSERT INTO auth_accounts VALUES(?,?,?,?,?)').run(user.id, user.email, user.status, 0, JSON.stringify(user));
1129
+ if (request.passkey) {
1130
+ const passkey = JSON.parse(String(request.passkey));
1131
+ if (db.prepare('SELECT id FROM auth_passkeys WHERE id=?').get(passkey.id))
1132
+ error(409, 'credential_already_registered');
1133
+ db.prepare('INSERT INTO auth_passkeys VALUES(?,?,?,?)').run(passkey.id, user.id, JSON.stringify(passkey), passkey.counter);
1134
+ methodActivity('passkey', String(passkey.id), user.id, now, true);
1135
+ }
1136
+ db.prepare('DELETE FROM auth_waitlist WHERE id=?').run(user.id);
1137
+ audit(actor.id, 'registration.approved', user.id, now, String(args.reason || ''));
1138
+ return user;
1139
+ });
1140
+ break;
1141
+ case 'rotationRows': {
1142
+ const rows = [];
1143
+ const prefix = String(args.activeKey) + '.';
1144
+ for (const row of db.prepare('SELECT id,data FROM auth_accounts').iterate()) {
1145
+ const user = JSON.parse(String(row.data));
1146
+ for (const field of ['totpSecret', 'totpPending']) {
1147
+ const value = user[field];
1148
+ if (value && !value.startsWith(prefix) && rows.length < 100)
1149
+ rows.push({ type: 'account', id: user.id, field, value, context: 'totp:' + user.id });
1150
+ }
1151
+ if (rows.length >= 100)
1152
+ break;
1153
+ }
1154
+ if (rows.length < 100)
1155
+ for (const row of db.prepare('SELECT id,kind,data FROM auth_flows WHERE substr(data,1,?)<>? LIMIT ?').all(prefix.length, prefix, 100 - rows.length))
1156
+ rows.push({ type: 'flow', id: String(row.id), field: 'data', value: String(row.data), context: 'flow:' + row.id + ':' + row.kind });
1157
+ value = rows;
1158
+ break;
1159
+ }
1160
+ case 'rotationApply':
1161
+ value = transaction(() => {
1162
+ let changed = 0;
1163
+ for (const row of args.replacements) {
1164
+ if (row.type === 'account') {
1165
+ const user = account(row.id);
1166
+ if (user && user[row.field] === row.value) {
1167
+ user[row.field] = row.replacement;
1168
+ save(user);
1169
+ changed++;
1170
+ }
1171
+ }
1172
+ else
1173
+ changed += Number(db.prepare('UPDATE auth_flows SET data=? WHERE id=? AND data=?').run(row.replacement, row.id, row.value).changes);
1174
+ }
1175
+ const prefix = String(args.activeKey) + '.';
1176
+ let remaining = 0;
1177
+ for (const row of db.prepare('SELECT data FROM auth_accounts').iterate()) {
1178
+ const user = JSON.parse(String(row.data));
1179
+ for (const field of ['totpSecret', 'totpPending'])
1180
+ if (user[field] && !user[field].startsWith(prefix))
1181
+ remaining++;
1182
+ }
1183
+ remaining += num(db.prepare('SELECT count(*) AS n FROM auth_flows WHERE substr(data,1,?)<>?').get(prefix.length, prefix)?.n);
1184
+ audit('operator', 'encryption.rotated', String(changed), now);
1185
+ return { changed, remaining };
1186
+ });
1187
+ break;
1188
+ case 'identities':
1189
+ value = db.prepare('SELECT provider,subject FROM auth_external WHERE account_id=? LIMIT 16').all(String(args.accountId));
1190
+ break;
1191
+ case 'issueEmailCode':
1192
+ value = transaction(() => {
1193
+ const user = decode(db.prepare('SELECT data FROM auth_accounts WHERE email=?').get(String(args.email)));
1194
+ if (!user || user.status !== 'active')
1195
+ return false;
1196
+ db.prepare('DELETE FROM auth_email_codes WHERE account_id=?').run(user.id);
1197
+ db.prepare('INSERT INTO auth_email_codes VALUES(?,?,?,?,0,?)').run(String(args.hash), user.id, String(args.codeHash), now + 600000, user.version);
1198
+ audit(user.id, 'email_code.issued', user.id, now);
1199
+ return true;
1200
+ });
1201
+ break;
1202
+ case 'checkEmailCode':
1203
+ value = transaction(() => {
1204
+ const row = db.prepare('SELECT * FROM auth_email_codes WHERE hash=? AND expires>?').get(String(args.hash), now);
1205
+ if (!row || num(row.attempts) >= 5)
1206
+ return null;
1207
+ db.prepare('UPDATE auth_email_codes SET attempts=attempts+1 WHERE hash=?').run(String(args.hash));
1208
+ if (row.code_hash !== args.codeHash)
1209
+ return null;
1210
+ const user = account(String(row.account_id));
1211
+ return user && user.status === 'active' && user.version === row.version ? user : null;
1212
+ });
1213
+ break;
1214
+ case 'codeLogin':
1215
+ value = transaction(() => {
1216
+ const row = db.prepare('SELECT * FROM auth_email_codes WHERE hash=? AND code_hash=? AND expires>? AND attempts<=5').get(String(args.hash), String(args.codeHash), now);
1217
+ if (!row)
1218
+ error(400, 'invalid_code');
1219
+ const user = active(String(row.account_id));
1220
+ if (user.version !== args.version || user.version !== row.version)
1221
+ error(400, 'invalid_code');
1222
+ const actualMfa = consumeFactor(user, args, now);
1223
+ db.prepare('DELETE FROM auth_email_codes WHERE hash=?').run(String(args.hash));
1224
+ if (options.securityPolicy.requireEmailVerification && !user.emailVerified) {
1225
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=?').run(user.id);
1226
+ user.version++;
1227
+ }
1228
+ user.emailVerified = true;
1229
+ save(user);
1230
+ if (actualMfa)
1231
+ Object.assign(args.session, { mfaAuthenticatedAt: now, mfaVersion: user.version });
1232
+ const newDevice = addSession(args.session);
1233
+ audit(user.id, 'session.email_login', user.id, now);
1234
+ metric('success', 'email-code', now);
1235
+ return { ...user, ...(typeof newDevice !== 'undefined' && newDevice ? { newDevice: true } : {}) };
1236
+ });
1237
+ break;
1238
+ case 'changePassword':
1239
+ value = transaction(() => {
1240
+ const { user } = fresh(String(args.hash), now);
1241
+ if (user.version !== args.version)
1242
+ error(401, 'invalid_credentials');
1243
+ consumeFactor(user, args, now);
1244
+ user.passwordHash = String(args.passwordHash);
1245
+ user.version++;
1246
+ save(user);
1247
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=?').run(user.id);
1248
+ db.prepare('DELETE FROM auth_tokens WHERE account_id=?').run(user.id);
1249
+ audit(user.id, 'password.changed', user.id, now);
1250
+ return true;
1251
+ });
1252
+ break;
1253
+ case 'deleteAccount':
1254
+ value = transaction(() => {
1255
+ const { user } = fresh(String(args.hash), now);
1256
+ if (user.version !== args.version)
1257
+ error(401, 'invalid_credentials');
1258
+ consumeFactor(user, args, now);
1259
+ if (admin(user.roles) && num(db.prepare("SELECT count(*) AS n FROM auth_accounts WHERE administrator=1 AND status='active'").get()?.n) <= 1)
1260
+ error(409, 'last_administrator_required');
1261
+ user.status = 'pending-delete';
1262
+ user.deleteAfter = now + options.securityPolicy.deletionGraceMs;
1263
+ user.version++;
1264
+ save(user);
1265
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=?').run(user.id);
1266
+ db.prepare('DELETE FROM auth_tokens WHERE account_id=?').run(user.id);
1267
+ db.prepare("INSERT INTO auth_tokens VALUES(?,?,'cancel-deletion',?,?)").run(String(args.cancelHash), user.id, user.deleteAfter, user.version);
1268
+ audit(user.id, 'account.deletion_scheduled', user.id, now);
1269
+ return { deleteAfter: user.deleteAfter };
1270
+ });
1271
+ break;
1272
+ case 'cancelDeletion':
1273
+ value = transaction(() => {
1274
+ const row = db.prepare("SELECT * FROM auth_tokens WHERE hash=? AND purpose='cancel-deletion' AND expires>?").get(String(args.hash), now);
1275
+ if (!row)
1276
+ error(400, 'invalid_token');
1277
+ const user = account(String(row.account_id));
1278
+ if (!user || user.status !== 'pending-delete' || user.version !== row.version)
1279
+ error(400, 'invalid_token');
1280
+ user.status = 'active';
1281
+ delete user.deleteAfter;
1282
+ user.version++;
1283
+ save(user);
1284
+ db.prepare('DELETE FROM auth_tokens WHERE hash=?').run(String(args.hash));
1285
+ audit(user.id, 'account.deletion_cancelled', user.id, now);
1286
+ return true;
1287
+ });
1288
+ break;
1289
+ case 'purgeDeleted':
1290
+ value = transaction(() => {
1291
+ let purged = 0;
1292
+ const deleted = [];
1293
+ for (const row of db.prepare("SELECT data FROM auth_accounts WHERE status='pending-delete' AND json_extract(data,'$.deleteAfter')<=? LIMIT ?").all(now, Number(args.limit))) {
1294
+ const user = decode(row);
1295
+ if (!user.deleteAfter || user.deleteAfter > now)
1296
+ continue;
1297
+ if (admin(user.roles) && num(db.prepare("SELECT count(*) AS n FROM auth_accounts WHERE administrator=1 AND status='active'").get()?.n) === 0)
1298
+ continue;
1299
+ db.prepare('DELETE FROM auth_accounts WHERE id=?').run(user.id);
1300
+ audit('operator', 'account.deleted', user.id, now);
1301
+ purged++;
1302
+ deleted.push(user.id);
1303
+ }
1304
+ return { purged, deleted };
1305
+ });
1306
+ break;
1307
+ case 'users':
1308
+ try {
1309
+ value = queryUsers(db, args);
1310
+ }
1311
+ catch {
1312
+ error(400, 'invalid_user_cursor_or_filter');
1313
+ }
1314
+ break;
1315
+ case 'removeMethod':
1316
+ value = transaction(() => {
1317
+ const { user } = fresh(String(args.hash), now);
1318
+ const count = Number(Boolean(user.passwordHash)) + num(db.prepare('SELECT count(*) AS n FROM auth_passkeys WHERE account_id=?').get(user.id)?.n) + num(db.prepare('SELECT count(*) AS n FROM auth_external WHERE account_id=?').get(user.id)?.n);
1319
+ if (count <= 1)
1320
+ error(409, 'last_sign_in_method');
1321
+ if (args.credentialId && user.mfaPasskeys?.includes(String(args.credentialId))) {
1322
+ if (options.securityPolicy.requireMfa && !user.totpSecret && user.mfaPasskeys.length === 1)
1323
+ error(409, 'last_required_factor');
1324
+ user.mfaPasskeys = user.mfaPasskeys.filter(id => id !== args.credentialId);
1325
+ }
1326
+ if (!user.passwordHash && !user.totpSecret && user.mfaPasskeys?.length) {
1327
+ const keys = db.prepare('SELECT id FROM auth_passkeys WHERE account_id=?').all(user.id).map(row => String(row.id)).filter(id => id !== args.credentialId);
1328
+ const external = db.prepare('SELECT provider,subject FROM auth_external WHERE account_id=?').all(user.id).filter(row => row.provider !== args.provider || row.subject !== args.subject);
1329
+ if (!external.length && !keys.some(primary => user.mfaPasskeys.some(factor => factor !== primary && keys.includes(factor))))
1330
+ error(409, 'last_sign_in_method');
1331
+ }
1332
+ const removed = args.credentialId ? db.prepare('DELETE FROM auth_passkeys WHERE id=? AND account_id=?').run(String(args.credentialId), user.id) : db.prepare('DELETE FROM auth_external WHERE provider=? AND subject=? AND account_id=?').run(String(args.provider), String(args.subject), user.id);
1333
+ if (removed.changes !== 1)
1334
+ error(404, 'sign_in_method_not_found');
1335
+ db.prepare('DELETE FROM auth_method_activity WHERE kind=? AND method_id=? AND account_id=?').run(args.credentialId ? 'passkey' : 'oidc', args.credentialId ? String(args.credentialId) : String(args.provider) + '\0' + String(args.subject), user.id);
1336
+ user.version++;
1337
+ save(user);
1338
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=? AND hash<>?').run(user.id, String(args.hash));
1339
+ audit(user.id, args.credentialId ? 'passkey.removed' : 'identity.unlinked', user.id, now);
1340
+ return true;
1341
+ });
1342
+ break;
1343
+ case 'caseEdit':
1344
+ value = transaction(() => {
1345
+ const actor = fresh(String(args.hash), now).user, row = db.prepare('SELECT data FROM auth_cases WHERE id=?').get(String(args.id));
1346
+ if (!row)
1347
+ error(404, 'case_not_found');
1348
+ const item = JSON.parse(String(row.data));
1349
+ const target = account(item.accountId);
1350
+ if (!target)
1351
+ error(404, 'account_not_found');
1352
+ authorizeCase(actor, target, item.roles);
1353
+ if (item.status !== 'pending' && !(item.action === 'restore-access' && item.status === 'applied' && ['delivery', 'ready'].includes(item.recovery?.state ?? '')))
1354
+ error(409, 'case_unavailable');
1355
+ if (args.close) {
1356
+ item.status = 'closed';
1357
+ if (item.action === 'restore-access' && item.recovery) {
1358
+ item.recovery.state = 'cancelled';
1359
+ db.prepare('DELETE FROM auth_manual_recovery WHERE case_id=?').run(String(args.id));
1360
+ }
1361
+ }
1362
+ else {
1363
+ if ((item.notes?.length ?? 0) >= 20)
1364
+ error(409, 'case_note_limit');
1365
+ item.notes = [...(item.notes ?? []), { actorId: actor.id, note: String(args.reason), created: now }];
1366
+ }
1367
+ db.prepare('UPDATE auth_cases SET data=? WHERE id=?').run(JSON.stringify(item), String(args.id));
1368
+ audit(actor.id, args.close ? 'case.closed' : 'case.note_added', item.accountId, now, String(args.reason));
1369
+ return item;
1370
+ });
1371
+ break;
1372
+ case 'cleanup':
1373
+ value = transaction(() => {
1374
+ let remaining = Number(args.limit), removed = 0;
1375
+ for (const [table, predicate, params] of [['auth_sessions', 'expires<=? OR last_seen<=?', [now, now - options.sessionIdleMs]], ['auth_tokens', 'expires<=?', [now]], ['auth_flows', 'expires<=?', [now]], ['auth_signups', 'expires<=?', [now]], ['auth_second_factor_proofs', 'expires<=?', [now]], ['auth_trusted_devices', 'expires<=?', [now]], ['auth_factor_recovery', 'expires<=?', [now]], ['auth_manual_recovery', 'expires<=?', [now]], ['auth_email_codes', 'expires<=?', [now]], ['auth_email_changes', 'expires<=?', [now]], ['auth_invites', 'expires<=?', [now]], ['auth_attempts', 'expires<=?', [now]], ['auth_abuse', 'expires<=?', [now]], ['auth_admin_operations', 'expires<=?', [now]], ['auth_cases', "json_extract(data,'$.expires')<=?", [now - 2592000000]]]) {
1376
+ if (remaining <= 0)
1377
+ break;
1378
+ const changed = Number(db.prepare('DELETE FROM ' + table + ' WHERE rowid IN (SELECT rowid FROM ' + table + ' WHERE ' + predicate + ' LIMIT ?)').run(...params, remaining).changes);
1379
+ removed += changed;
1380
+ remaining -= changed;
1381
+ }
1382
+ return { removed };
1383
+ });
1384
+ break;
1385
+ case 'requestEmailChange':
1386
+ value = transaction(() => {
1387
+ const { user } = fresh(String(args.hash), now);
1388
+ if (user.version !== args.version)
1389
+ error(409, 'account_changed');
1390
+ if (user.email === args.email)
1391
+ error(400, 'email_unchanged');
1392
+ if (db.prepare('SELECT account_id FROM auth_email_changes WHERE account_id=? AND expires>?').get(user.id, now))
1393
+ error(409, 'email_change_pending');
1394
+ if (db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(String(args.email)))
1395
+ error(409, 'email_unavailable');
1396
+ consumeFactor(user, args, now);
1397
+ db.prepare('DELETE FROM auth_email_changes WHERE account_id=?').run(user.id);
1398
+ db.prepare('INSERT INTO auth_email_changes VALUES(?,?,?,?,?,?,?)').run(user.id, String(args.email), String(args.verificationHash), String(args.cancelHash), now + 86400000, now + 172800000, user.version);
1399
+ audit(user.id, 'email.change_requested', user.id, now);
1400
+ return true;
1401
+ });
1402
+ break;
1403
+ case 'confirmEmailChange':
1404
+ value = transaction(() => {
1405
+ const row = db.prepare('SELECT * FROM auth_email_changes WHERE verification_hash=? AND expires>?').get(String(args.hash), now);
1406
+ if (!row)
1407
+ error(400, 'invalid_token');
1408
+ if (num(row.activate_after) > now)
1409
+ error(409, 'email_change_cooldown');
1410
+ const user = active(String(row.account_id));
1411
+ if (user.version !== row.version)
1412
+ error(409, 'account_changed');
1413
+ if (db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(String(row.email)))
1414
+ error(409, 'email_unavailable');
1415
+ user.email = String(row.email);
1416
+ user.emailVerified = true;
1417
+ user.version++;
1418
+ db.prepare('UPDATE auth_accounts SET email=? WHERE id=?').run(user.email, user.id);
1419
+ save(user);
1420
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=?').run(user.id);
1421
+ db.prepare('DELETE FROM auth_tokens WHERE account_id=?').run(user.id);
1422
+ db.prepare('DELETE FROM auth_email_codes WHERE account_id=?').run(user.id);
1423
+ db.prepare('DELETE FROM auth_email_changes WHERE account_id=?').run(user.id);
1424
+ audit(user.id, 'email.changed', user.id, now);
1425
+ return user;
1426
+ });
1427
+ break;
1428
+ case 'cancelEmailChange':
1429
+ value = transaction(() => {
1430
+ const row = db.prepare('SELECT account_id FROM auth_email_changes WHERE cancel_hash=? AND expires>?').get(String(args.hash), now);
1431
+ if (!row)
1432
+ error(400, 'invalid_token');
1433
+ db.prepare('DELETE FROM auth_email_changes WHERE account_id=?').run(String(row.account_id));
1434
+ audit(String(row.account_id), 'email.change_cancelled', String(row.account_id), now);
1435
+ return true;
1436
+ });
1437
+ break;
1438
+ case 'adminCreateUser':
1439
+ value = transaction(() => {
1440
+ const actor = fresh(String(args.hash), now).user, p = permissions(actor.roles);
1441
+ if (!p.includes('*') && !p.includes('auth.users.create'))
1442
+ error(403, 'permission_denied');
1443
+ for (const permission of permissions([options.defaultRole]))
1444
+ if (!p.includes('*') && !p.includes(permission))
1445
+ error(403, 'delegation_ceiling_exceeded');
1446
+ const user = args.user;
1447
+ if (num(db.prepare('SELECT count(*) AS n FROM auth_accounts').get()?.n) >= 100000)
1448
+ error(503, 'auth_capacity_reached');
1449
+ if (db.prepare('SELECT id FROM auth_accounts WHERE email=?').get(user.email))
1450
+ error(409, 'registration_unavailable');
1451
+ db.prepare('INSERT INTO auth_accounts VALUES(?,?,?,?,?)').run(user.id, user.email, user.status, 0, JSON.stringify(user));
1452
+ db.prepare("INSERT INTO auth_tokens VALUES(?,?,'reset-password',?,?)").run(String(args.setupHash), user.id, now + 1800000, user.version);
1453
+ audit(actor.id, 'admin.account_created', user.id, now, String(args.reason));
1454
+ return user;
1455
+ });
1456
+ break;
1457
+ case 'revokeSession':
1458
+ value = transaction(() => {
1459
+ const actor = fresh(String(args.hash), now).user, row = db.prepare('SELECT account_id FROM auth_sessions WHERE id=?').get(String(args.sessionId));
1460
+ if (!row)
1461
+ error(404, 'session_not_found');
1462
+ const target = account(String(row.account_id));
1463
+ if (args.admin) {
1464
+ const p = permissions(actor.roles);
1465
+ if (!p.includes('*') && !p.includes('auth.sessions.manage'))
1466
+ error(403, 'permission_denied');
1467
+ for (const permission of permissions(target.roles))
1468
+ if (!p.includes('*') && !p.includes(permission))
1469
+ error(403, 'delegation_ceiling_exceeded');
1470
+ }
1471
+ else if (target.id !== actor.id)
1472
+ error(403, 'permission_denied');
1473
+ db.prepare('DELETE FROM auth_sessions WHERE id=?').run(String(args.sessionId));
1474
+ audit(actor.id, args.admin ? 'admin.session_revoked' : 'session.revoked', target.id, now, String(args.reason ?? ''));
1475
+ return true;
1476
+ });
1477
+ break;
1478
+ case 'adminAddNote': {
1479
+ const actor = fresh(String(args.hash), now).user, target = account(String(args.accountId));
1480
+ if (!target)
1481
+ error(404, 'account_not_found');
1482
+ const granted = permissions(actor.roles);
1483
+ if (!granted.includes('*') && !granted.includes('auth.users.manage'))
1484
+ error(403, 'permission_denied');
1485
+ for (const permission of permissions(target.roles))
1486
+ if (!granted.includes('*') && !granted.includes(permission))
1487
+ error(403, 'delegation_ceiling_exceeded');
1488
+ audit(actor.id, 'admin.note', target.id, now, String(args.reason));
1489
+ value = true;
1490
+ break;
1491
+ }
1492
+ case 'adminReveal': {
1493
+ const actor = fresh(String(args.hash), now).user, target = account(String(args.accountId));
1494
+ if (!target)
1495
+ error(404, 'account_not_found');
1496
+ const granted = permissions(actor.roles);
1497
+ if (!granted.includes('*') && (!granted.includes('auth.users.read') || !granted.includes('auth.users.reveal')))
1498
+ error(403, 'permission_denied');
1499
+ for (const permission of permissions(target.roles))
1500
+ if (!granted.includes('*') && !granted.includes(permission))
1501
+ error(403, 'delegation_ceiling_exceeded');
1502
+ audit(actor.id, 'admin.identifier_revealed', target.id, now, String(args.reason));
1503
+ value = { id: target.id, email: target.email };
1504
+ break;
1505
+ }
1506
+ case 'adminExport':
1507
+ value = transaction(() => {
1508
+ const actor = fresh(String(args.hash), now).user, target = account(String(args.accountId));
1509
+ if (!target)
1510
+ error(404, 'account_not_found');
1511
+ const p = permissions(actor.roles);
1512
+ if (!p.includes('*') && !p.includes('auth.users.export'))
1513
+ error(403, 'permission_denied');
1514
+ for (const permission of permissions(target.roles))
1515
+ if (!p.includes('*') && !p.includes(permission))
1516
+ error(403, 'delegation_ceiling_exceeded');
1517
+ audit(actor.id, 'admin.account_exported', target.id, now, String(args.reason));
1518
+ return { user: target, sessions: db.prepare('SELECT id,created,authenticated_at AS authenticatedAt,expires,last_seen AS lastSeen,device_label AS deviceLabel FROM auth_sessions WHERE account_id=? AND expires>? AND last_seen>? LIMIT 20').all(target.id, now, now - options.sessionIdleMs), identities: db.prepare('SELECT provider,subject FROM auth_external WHERE account_id=? LIMIT 16').all(target.id) };
1519
+ });
1520
+ break;
1521
+ case 'signInFailure':
1522
+ value = transaction(() => { metric('failure', String(args.method), now); return true; });
1523
+ break;
1524
+ case 'adminBulk':
1525
+ value = transaction(() => {
1526
+ const actor = fresh(String(args.hash), now).user, p = permissions(actor.roles), action = String(args.action);
1527
+ if (!p.includes('*') && !p.includes(action === 'revoke-sessions' ? 'auth.sessions.manage' : 'auth.users.manage'))
1528
+ error(403, 'permission_denied');
1529
+ const targets = args.accountIds.map(account);
1530
+ for (const target of targets) {
1531
+ if (!target)
1532
+ error(404, 'account_not_found');
1533
+ if (target.id === actor.id)
1534
+ error(403, 'self_administration_denied');
1535
+ if (target.status === 'pending-delete')
1536
+ error(409, 'account_pending_deletion');
1537
+ for (const permission of permissions(target.roles))
1538
+ if (!p.includes('*') && !p.includes(permission))
1539
+ error(403, 'delegation_ceiling_exceeded');
1540
+ }
1541
+ if (action === 'lock') {
1542
+ const remaining = num(db.prepare("SELECT count(*) AS n FROM auth_accounts WHERE administrator=1 AND status='active'").get()?.n) - targets.filter(target => target.status === 'active' && admin(target.roles)).length;
1543
+ if (remaining < 1 && targets.some(target => target.status === 'active' && admin(target.roles)))
1544
+ error(409, 'last_administrator_required');
1545
+ }
1546
+ for (const target of targets) {
1547
+ if (action !== 'revoke-sessions')
1548
+ target.status = action === 'lock' ? 'locked' : 'active';
1549
+ target.version++;
1550
+ save(target);
1551
+ db.prepare('DELETE FROM auth_sessions WHERE account_id=?').run(target.id);
1552
+ audit(actor.id, 'admin.bulk.' + action, target.id, now, String(args.reason));
1553
+ }
1554
+ return { affected: targets.length };
1555
+ });
1556
+ break;
1557
+ case 'dashboard': {
1558
+ const counts = db.prepare("SELECT count(*) AS users,coalesce(sum(status='active'),0) AS active,coalesce(sum(status='locked'),0) AS locked,coalesce(sum(status='pending-delete'),0) AS pendingDeletion FROM auth_accounts").get();
1559
+ const firstDay = Math.floor(now / 86400000) - 29;
1560
+ const daily = Array.from({ length: 30 }, (_, offset) => ({ day: new Date((firstDay + offset) * 86400000).toISOString().slice(0, 10), signUps: 0, signIns: 0, failedSignIns: 0, methods: [] }));
1561
+ for (const row of db.prepare("SELECT day,method,sum(CASE WHEN event='signup' THEN count ELSE 0 END) AS signUps,sum(CASE WHEN event='success' THEN count ELSE 0 END) AS signIns,sum(CASE WHEN event='failure' THEN count ELSE 0 END) AS failedSignIns FROM auth_daily_metrics WHERE day>=? AND day<=? GROUP BY day,method ORDER BY day,method LIMIT 150").all(firstDay, firstDay + 29)) {
1562
+ const bucket = daily[num(row.day) - firstDay];
1563
+ const method = { method: String(row.method), signUps: num(row.signUps), signIns: num(row.signIns), failedSignIns: num(row.failedSignIns) };
1564
+ bucket.signUps += method.signUps;
1565
+ bucket.signIns += method.signIns;
1566
+ bucket.failedSignIns += method.failedSignIns;
1567
+ bucket.methods.push(method);
1568
+ }
1569
+ value = { ...counts, daily, sessions: num(db.prepare('SELECT count(*) AS n FROM auth_sessions WHERE expires>? AND last_seen>?').get(now, now - options.sessionIdleMs)?.n), waitlist: num(db.prepare('SELECT count(*) AS n FROM auth_waitlist').get()?.n) };
1570
+ break;
1571
+ }
1572
+ case 'allSessions':
1573
+ value = db.prepare(`SELECT s.id,s.account_id AS accountId,a.email,s.created,s.authenticated_at AS authenticatedAt,s.expires,s.last_seen AS lastSeen,s.device_label AS deviceLabel FROM auth_sessions s JOIN auth_accounts a ON a.id=s.account_id WHERE s.id>? AND s.expires>? AND s.last_seen>? AND (?='' OR s.account_id=?) AND instr(lower(COALESCE(s.device_label,'')),lower(?))>0 AND s.created>=? AND s.created<=? ORDER BY s.id LIMIT ?`).all(String(args.after), now, now - options.sessionIdleMs, String(args.accountId), String(args.accountId), String(args.device), Number(args.createdFrom), Number(args.createdTo), Number(args.limit));
1574
+ break;
1575
+ case 'devices':
1576
+ value = db.prepare('SELECT hash AS id,label,last_seen AS lastSeen FROM auth_devices WHERE account_id=? ORDER BY last_seen DESC LIMIT 20').all(String(args.accountId));
1577
+ break;
1578
+ case 'sessions':
1579
+ value = db.prepare('SELECT id,created,authenticated_at AS authenticatedAt,expires,last_seen AS lastSeen,device_label AS deviceLabel FROM auth_sessions WHERE account_id=? AND expires>? AND last_seen>? ORDER BY created DESC LIMIT 20').all(String(args.accountId), now, now - options.sessionIdleMs);
1580
+ break;
1581
+ case 'audit':
1582
+ value = db.prepare("SELECT id,actor,action,subject,created,reason FROM auth_audit WHERE id>? AND (?='' OR actor=?) AND (?='' OR subject=?) AND (?='' OR action=?) AND created>=? AND created<=? ORDER BY id LIMIT ?").all(Number(args.after || 0), String(args.actor), String(args.actor), String(args.subject), String(args.subject), String(args.action), String(args.action), Number(args.from), Number(args.to), Number(args.limit));
1583
+ break;
1584
+ default: error(400, 'unsupported_auth_operation');
1585
+ }
1586
+ db.exec('COMMIT');
1587
+ dispatchTransaction = false;
1588
+ port.postMessage({ id, value });
1589
+ }
1590
+ catch (e) {
1591
+ if (dispatchTransaction) {
1592
+ try {
1593
+ db.exec('ROLLBACK');
1594
+ }
1595
+ catch { /* Closed database remains unavailable. */ }
1596
+ dispatchTransaction = false;
1597
+ }
1598
+ port.postMessage({ id, error: { status: e instanceof AuthError ? e.status : 503, code: e instanceof AuthError ? e.code : 'auth_store_unavailable' } });
1599
+ }
1600
+ });
1601
+ }