clay-server 5.1.0 → 5.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/lib/account-auth-policy.js +21 -0
  2. package/lib/account-auth-socket.js +29 -0
  3. package/lib/account-auth-throttle.js +44 -0
  4. package/lib/account-auth-transport.js +48 -0
  5. package/lib/account-login-page.js +28 -0
  6. package/lib/account-passkey-challenges.js +31 -0
  7. package/lib/account-passkey-page.js +89 -0
  8. package/lib/account-passkey-policy.js +39 -0
  9. package/lib/account-password-page.js +69 -0
  10. package/lib/account-passwords.js +36 -0
  11. package/lib/account-remembered.js +77 -0
  12. package/lib/account-session-page.js +51 -0
  13. package/lib/account-session-store.js +82 -0
  14. package/lib/daemon.js +2 -0
  15. package/lib/migrate-single-user.js +3 -23
  16. package/lib/project-server-settings.js +1 -0
  17. package/lib/project-user-knowledge.js +2 -1
  18. package/lib/public/auth/account-login.css +98 -0
  19. package/lib/public/auth/account-login.js +104 -0
  20. package/lib/public/css/mate-conversation.css +2 -2
  21. package/lib/public/css/user-settings-sessions.css +42 -0
  22. package/lib/public/index.html +17 -5
  23. package/lib/public/modules/account-auth-settings.js +71 -0
  24. package/lib/public/modules/account-transport-settings.js +51 -0
  25. package/lib/public/modules/app-messages.js +4 -0
  26. package/lib/public/modules/passkey-browser.js +69 -0
  27. package/lib/public/modules/passkey-server-settings.js +57 -0
  28. package/lib/public/modules/project-logs-render.js +3 -2
  29. package/lib/public/modules/server-settings-controls.js +10 -0
  30. package/lib/public/modules/server-settings.js +7 -2
  31. package/lib/public/modules/user-settings-account.js +14 -1
  32. package/lib/public/modules/user-settings-sessions.js +120 -0
  33. package/lib/public/modules/user-settings.js +3 -0
  34. package/lib/public/modules/you-activity.js +3 -1
  35. package/lib/public/modules/you.js +91 -6
  36. package/lib/public/style.css +1 -0
  37. package/lib/server-account-passkeys.js +186 -0
  38. package/lib/server-account-passwords.js +127 -0
  39. package/lib/server-account-security.js +53 -0
  40. package/lib/server-auth.js +107 -79
  41. package/lib/server-settings.js +2 -1
  42. package/lib/server.js +2 -0
  43. package/lib/user-knowledge-service.js +23 -1
  44. package/lib/users-auth.js +38 -3
  45. package/lib/users-create.js +97 -0
  46. package/lib/users-server-pin.js +37 -0
  47. package/lib/users.js +11 -84
  48. package/package.json +2 -1
@@ -0,0 +1,186 @@
1
+ var crypto = require('crypto');
2
+ var webauthn = require('@simplewebauthn/server');
3
+ var passwords = require('./account-passwords');
4
+ var policyStore = require('./account-passkey-policy');
5
+
6
+ function attachAccountPasskeys(ctx) {
7
+ var users = ctx.users;
8
+ var http = ctx.http;
9
+ var challenges = require('./account-passkey-challenges').attachChallenges(ctx);
10
+ function policy() { return policyStore.configured(users.loadUsers()); }
11
+ function eligible(user) { return user && user.passwordHash && !user.mustChangePin; }
12
+ function current(req, original) {
13
+ var user = ctx.getUser(req);
14
+ if (!eligible(user) || original && (user.id !== original.id || user.passwordHash !== original.passwordHash)) {
15
+ throw new Error('Your account credentials changed. Sign in again.');
16
+ }
17
+ return user;
18
+ }
19
+ async function reauthenticate(req, data, admin) {
20
+ var user = current(req);
21
+ if (admin && user.role !== 'admin') throw new Error('Administrator access required.');
22
+ http.throttle(req, user.username);
23
+ if (!await passwords.verify(data.currentPassword, user.passwordHash)) throw new Error('Current password is incorrect.');
24
+ user = current(req, user);
25
+ if (admin && user.role !== 'admin') throw new Error('Administrator access required.');
26
+ http.clearAttempts(req, user.username);
27
+ return user;
28
+ }
29
+ function samePolicy(expected) {
30
+ var now = policy();
31
+ if (!now || now.origin !== expected.origin || now.rpID !== expected.rpID) throw new Error('Passkey settings changed. Please retry.');
32
+ }
33
+ function label(value) {
34
+ if (typeof value !== 'string' || !value.trim() || Array.from(value.trim()).length > 80) throw new Error('Give this passkey a name of 1–80 characters.');
35
+ return value.trim();
36
+ }
37
+ function responseData(data) {
38
+ var response = data.response;
39
+ if (!response || typeof response.id !== 'string' || !response.response || typeof response.response.clientDataJSON !== 'string') throw new Error('Invalid passkey response.');
40
+ var client = JSON.parse(Buffer.from(response.response.clientDataJSON, 'base64url').toString('utf8'));
41
+ if (client.crossOrigin || client.topOrigin) throw new Error('Open Clay directly to use a passkey.');
42
+ return response;
43
+ }
44
+ function list(user) {
45
+ return (user.passkeys || []).map(function (key) { return { id: key.id, name: key.name, createdAt: key.createdAt, lastUsedAt: key.lastUsedAt || null }; });
46
+ }
47
+ async function registerOptions(req, res, data, config) {
48
+ var user = await reauthenticate(req, data);
49
+ var name = label(data.name);
50
+ if ((user.passkeys || []).length >= 10) throw new Error('You can register up to 10 passkeys.');
51
+ var handle = user.passkeyUserHandle || crypto.randomBytes(32).toString('base64url');
52
+ var options = await webauthn.generateRegistrationOptions({
53
+ rpName: 'Clay', rpID: config.rpID, userID: Buffer.from(handle, 'base64url'), userName: user.username,
54
+ attestationType: 'none', timeout: 60000, supportedAlgorithmIDs: [-7, -257],
55
+ authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
56
+ excludeCredentials: (user.passkeys || []).map(function (key) { return { id: key.id, transports: key.transports }; }),
57
+ });
58
+ current(req, user); samePolicy(config);
59
+ var cookie = challenges.issue(req, config, { purpose: 'register', challenge: options.challenge, userId: user.id,
60
+ passwordHash: user.passwordHash, session: ctx.sessionToken(req), handle: handle, name: name });
61
+ http.reply(res, 200, { options: options }, cookie);
62
+ }
63
+ async function registerVerify(req, res, data, config) {
64
+ var proof = challenges.take(req, config, 'register');
65
+ var user = current(req, { id: proof.userId, passwordHash: proof.passwordHash });
66
+ if (ctx.sessionToken(req) !== proof.session) throw new Error('Use the same signed-in session to register this passkey.');
67
+ var response = responseData(data);
68
+ var result;
69
+ try {
70
+ result = await webauthn.verifyRegistrationResponse({ response: response, expectedChallenge: proof.challenge,
71
+ expectedOrigin: config.origin, expectedRPID: config.rpID, requireUserVerification: true, supportedAlgorithmIDs: [-7, -257] });
72
+ } catch (error) { throw new Error('Could not verify this passkey. Please try registering again.'); }
73
+ if (!result.verified) throw new Error('Could not verify this passkey.');
74
+ current(req, user); samePolicy(config);
75
+ if (proof.until <= Date.now()) throw new Error('Registration expired. Please try again.');
76
+ var state = users.loadUsers();
77
+ user = state.users.find(function (entry) { return entry.id === user.id; });
78
+ var info = result.registrationInfo; var key = info.credential;
79
+ if (key.id !== response.id || state.users.some(function (entry) { return (entry.passkeys || []).some(function (saved) { return saved.id === key.id; }); })) {
80
+ throw new Error('This passkey is already registered or has an invalid identifier.');
81
+ }
82
+ if ((user.passkeys || []).length >= 10 || user.passkeyUserHandle && user.passkeyUserHandle !== proof.handle) throw new Error('Passkeys changed. Please register again.');
83
+ user.passkeyUserHandle = proof.handle;
84
+ user.passkeys = (user.passkeys || []).concat([{ id: key.id, publicKey: Buffer.from(key.publicKey).toString('base64url'),
85
+ counter: key.counter, transports: key.transports, name: proof.name, createdAt: new Date().toISOString(),
86
+ deviceType: info.credentialDeviceType, backedUp: info.credentialBackedUp }]);
87
+ users.saveUsers(state);
88
+ http.reply(res, 200, { ok: true, passkeys: list(user) }, challenges.cookie('', config.origin));
89
+ }
90
+ async function loginOptions(req, res, config) {
91
+ http.throttle(req, 'passkey:' + ctx.clientAddress(req));
92
+ var options = await webauthn.generateAuthenticationOptions({ rpID: config.rpID, userVerification: 'required', timeout: 60000 });
93
+ samePolicy(config);
94
+ http.reply(res, 200, { options: options }, challenges.issue(req, config, { purpose: 'login', challenge: options.challenge }));
95
+ }
96
+ async function loginVerify(req, res, data, config) {
97
+ var proof = challenges.take(req, config, 'login');
98
+ var response = responseData(data);
99
+ var state = users.loadUsers();
100
+ var user = state.users.find(function (entry) { return (entry.passkeys || []).some(function (key) { return key.id === response.id; }); });
101
+ if (!eligible(user) || response.response.userHandle !== user.passkeyUserHandle) throw new Error('Could not sign in with this passkey.');
102
+ var key = user.passkeys.find(function (entry) { return entry.id === response.id; });
103
+ var result;
104
+ try {
105
+ result = await webauthn.verifyAuthenticationResponse({ response: response, expectedChallenge: proof.challenge,
106
+ expectedOrigin: config.origin, expectedRPID: config.rpID, requireUserVerification: true,
107
+ credential: { id: key.id, publicKey: new Uint8Array(Buffer.from(key.publicKey, 'base64url')), counter: key.counter, transports: key.transports } });
108
+ } catch (error) { throw new Error('Could not sign in with this passkey. Try again or use your password.'); }
109
+ if (!result.verified || result.authenticationInfo.credentialDeviceType !== key.deviceType) throw new Error('Could not sign in with this passkey.');
110
+ samePolicy(config);
111
+ state = users.loadUsers();
112
+ var latest = state.users.find(function (entry) { return entry.id === user.id; });
113
+ var latestKey = latest && (latest.passkeys || []).find(function (entry) { return entry.id === key.id; });
114
+ if (!eligible(latest) || latest.passwordHash !== user.passwordHash || !latestKey || latestKey.publicKey !== key.publicKey ||
115
+ latestKey.counter !== key.counter || proof.until <= Date.now()) throw new Error('Account credentials changed. Please try again.');
116
+ latestKey.counter = result.authenticationInfo.newCounter;
117
+ latestKey.backedUp = result.authenticationInfo.credentialBackedUp;
118
+ latestKey.lastUsedAt = new Date().toISOString();
119
+ users.saveUsers(state);
120
+ var session = ctx.createSession(latest.id, latest.passwordHash, key.id, config.origin.indexOf('https:') === 0, req);
121
+ http.clearAttempts(req, 'passkey:' + ctx.clientAddress(req));
122
+ http.reply(res, 200, { ok: true }, session.cookies.concat([challenges.cookie('', config.origin)]));
123
+ }
124
+ async function manage(req, res, data, config, remove) {
125
+ var user = await reauthenticate(req, data);
126
+ samePolicy(config);
127
+ var state = users.loadUsers();
128
+ user = state.users.find(function (entry) { return entry.id === user.id; });
129
+ var key = (user.passkeys || []).find(function (entry) { return entry.id === data.id; });
130
+ if (!key) throw new Error('Passkey not found.');
131
+ if (remove) user.passkeys = user.passkeys.filter(function (entry) { return entry.id !== key.id; });
132
+ else key.name = label(data.name);
133
+ users.saveUsers(state);
134
+ // Removing a passkey also invalidates its sessions through the live session gate.
135
+ var cookie = remove ? ctx.createSession(user.id, user.passwordHash, null, config.origin.indexOf('https:') === 0, req).cookies : undefined;
136
+ http.reply(res, 200, { ok: true, passkeys: list(user) }, cookie);
137
+ }
138
+ function handleRequest(req, res, url) {
139
+ if (url === '/auth/passkey-browser.js' && req.method === 'GET') {
140
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' });
141
+ res.end(require('fs').readFileSync(require('path').join(__dirname, 'public/modules/passkey-browser.js'), 'utf8')); return true;
142
+ }
143
+ if (url === '/auth/logout' && req.method === 'POST') challenges.clear(req);
144
+ if (url === '/account/passkeys' && req.method === 'GET') {
145
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'" });
146
+ res.end(eligible(ctx.getUser(req)) ? require('./account-passkey-page').render() : require('./account-password-page').render());
147
+ return true;
148
+ }
149
+ if (url === '/auth/passkeys/status' && req.method === 'GET') { http.reply(res, 200, { origin: policy() && policy().origin }); return true; }
150
+ if (url === '/api/passkey-policy') {
151
+ var admin = ctx.getUser(req);
152
+ if (!admin || admin.role !== 'admin') { http.reply(res, 403, { error: 'Administrator access required.' }); return true; }
153
+ if (req.method === 'GET') {
154
+ var state = users.loadUsers();
155
+ http.reply(res, 200, { available: state.accountAuthMethod === 'password', origin: policy() && policy().origin, locked: policyStore.count(state) > 0 }); return true;
156
+ }
157
+ if (req.method !== 'PUT') { http.reply(res, 405, { error: 'Method not allowed.' }); return true; }
158
+ http.read(req, res, async function (data) {
159
+ if (req.headers['sec-fetch-site'] === 'cross-site') throw new Error('Open Clay directly to change passkey settings.');
160
+ await reauthenticate(req, data, true);
161
+ var saved = policyStore.save(users, data.origin);
162
+ http.reply(res, 200, { ok: true, available: true, origin: saved.origin, locked: policyStore.count(users.loadUsers()) > 0 });
163
+ }); return true;
164
+ }
165
+ if (url.indexOf('/api/passkeys') !== 0 && url.indexOf('/auth/passkeys/') !== 0) return false;
166
+ var config = policy();
167
+ if (!config) { http.reply(res, 409, { error: 'Your administrator needs to configure passkeys first.' }); return true; }
168
+ if (url === '/api/passkeys' && req.method === 'GET') {
169
+ var user = ctx.getUser(req);
170
+ http.reply(res, eligible(user) ? 200 : 401, eligible(user) ? { origin: config.origin, passkeys: list(user) } : { error: 'Sign in to manage passkeys.' }); return true;
171
+ }
172
+ if (req.method !== 'POST') { http.reply(res, 405, { error: 'Method not allowed.' }); return true; }
173
+ if (req.headers.origin !== config.origin || req.headers['sec-fetch-site'] === 'cross-site') { http.reply(res, 403, { error: 'Use the configured Clay address to use passkeys.' }); return true; }
174
+ http.read(req, res, async function (data) {
175
+ if (url === '/api/passkeys/register/options') return registerOptions(req, res, data, config);
176
+ if (url === '/api/passkeys/register/verify') return registerVerify(req, res, data, config);
177
+ if (url === '/auth/passkeys/login/options') return loginOptions(req, res, config);
178
+ if (url === '/auth/passkeys/login/verify') return loginVerify(req, res, data, config);
179
+ if (url === '/api/passkeys/remove' || url === '/api/passkeys/rename') return manage(req, res, data, config, url.endsWith('/remove'));
180
+ http.reply(res, 404, { error: 'Passkey endpoint not found.' });
181
+ }, 32768);
182
+ return true;
183
+ }
184
+ return { handleRequest: handleRequest };
185
+ }
186
+ module.exports = { attachAccountPasskeys: attachAccountPasskeys };
@@ -0,0 +1,127 @@
1
+ var crypto = require('crypto');
2
+ var passwords = require('./account-passwords');
3
+ var attachPolicy = require('./account-auth-policy').attachPolicy;
4
+
5
+ function attachAccountPasswords(ctx) {
6
+ var users = ctx.users;
7
+ var policy = attachPolicy(users);
8
+ var pending = new Map();
9
+
10
+ var cookieName = ctx.devMode ? 'clay_password_transition_dev' : 'clay_password_transition';
11
+ function reply(res, code, data, cookies) {
12
+ var headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' };
13
+ if (cookies) headers['Set-Cookie'] = cookies;
14
+ res.writeHead(code, headers); res.end(JSON.stringify(data));
15
+ }
16
+ function transitionCookie(token, req) {
17
+ return cookieName + '=' + token + '; Path=/; HttpOnly; SameSite=Strict; Max-Age=' + (token ? '600' : '0') + (ctx.tlsOptions || req && ctx.transport.info(req).secure ? '; Secure' : '');
18
+ }
19
+ function read(req, res, action, maxBytes) {
20
+ if ((req.headers['content-type'] || '').split(';')[0] !== 'application/json') { reply(res, 415, { error: 'JSON required.' }); return; }
21
+ var body = ''; var oversized = false;
22
+ req.on('data', function (chunk) { if (oversized) return; body += chunk; if (Buffer.byteLength(body) > (maxBytes || 4096)) { oversized = true; reply(res, 413, { error: 'Request too large.' }); } });
23
+ req.on('end', function () {
24
+ if (oversized) return;
25
+ var data; try { data = JSON.parse(body); } catch (error) { reply(res, 400, { error: 'Invalid request.' }); return; }
26
+ if (!data || typeof data !== 'object' || Array.isArray(data)) { reply(res, 400, { error: 'Invalid request.' }); return; }
27
+ Promise.resolve().then(function () { return action(data); }).catch(function (error) { reply(res, error.status || 400, { error: error.message || 'Could not complete sign-in.' }); });
28
+ });
29
+ }
30
+ function throttle(req, username) { ctx.throttle.consume(req, username); }
31
+ function clearAttempts(req, username) { ctx.throttle.success(req, username); }
32
+ function grant(user, res, req) {
33
+ var session = ctx.createSession(user.id, user.passwordHash, null, ctx.transport.info(req).secure, req);
34
+ reply(res, 200, { ok: true }, session.cookies.concat([transitionCookie('', req)]));
35
+ }
36
+ function startTransition(user, res, req) {
37
+ var now = Date.now();
38
+ pending.forEach(function (entry, key) { if (entry.until <= now) pending.delete(key); });
39
+ if (pending.size >= 1000) throw new Error('Sign-in is busy. Please retry shortly.');
40
+ var token = crypto.randomBytes(32).toString('hex');
41
+ pending.set(token, { userId: user.id, pinHash: user.pinHash, until: now + 600000, busy: false });
42
+ reply(res, 200, { ok: true, passwordRequired: true }, transitionCookie(token, req));
43
+ }
44
+ function handleRequest(req, res, url) {
45
+ if (req.method === 'POST' && url === '/auth/logout') pending.delete(ctx.parseCookies(req)[cookieName]);
46
+ if (url === '/api/account-auth-policy') {
47
+ var admin = ctx.getUser(req);
48
+ if (!admin || admin.role !== 'admin') { reply(res, 403, { error: 'Administrator access required.' }); return true; }
49
+ if (req.method === 'GET') { reply(res, 200, { method: policy.enabled() ? 'password' : 'pin', available: policy.available() }); return true; }
50
+ if (req.method !== 'PUT') { reply(res, 405, { error: 'Method not allowed.' }); return true; }
51
+ read(req, res, function (data) {
52
+ if (data.method !== 'password') throw new Error('Only the password transition is supported.');
53
+ policy.activate();
54
+ reply(res, 200, { ok: true, method: 'password' });
55
+ if (ctx.onPolicyChanged) ctx.onPolicyChanged();
56
+ });
57
+ return true;
58
+ }
59
+ if (!policy.enabled()) return false;
60
+ if (req.method === 'GET' && url === '/account/password') {
61
+ var signedIn = ctx.getUser(req);
62
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
63
+ res.end(signedIn ? require('./account-password-page').render(true) : require('./account-login-page').render({ password: true }));
64
+ return true;
65
+ }
66
+ if (req.method === 'POST' && url === '/auth/password-change') {
67
+ read(req, res, async function (data) {
68
+ var user = ctx.getUser(req);
69
+ if (!user) throw new Error('Sign in again to change your password.');
70
+ throttle(req, user.username);
71
+ if (!await passwords.verify(data.currentPassword, user.passwordHash)) throw new Error('Current password is incorrect.');
72
+ if (data.password !== data.confirmPassword) throw new Error('Passwords do not match.');
73
+ var hash = await passwords.hash(data.password);
74
+ var state = users.loadUsers();
75
+ var current = state.users.find(function (entry) { return entry.id === user.id; });
76
+ if (!current || current.passwordHash !== user.passwordHash || current.mustChangePin) throw new Error('Credentials changed. Sign in again.');
77
+ current.passwordHash = hash; users.saveUsers(state);
78
+ ctx.revokeSessions(user.id); clearAttempts(req, user.username); grant(current, res, req);
79
+ });
80
+ return true;
81
+ }
82
+ if (url === '/api/user/pin') {
83
+ reply(res, 409, { error: 'This server uses account passwords. Contact an administrator for credential recovery.' });
84
+ return true;
85
+ }
86
+ if (req.method === 'POST' && url === '/auth/login') {
87
+ read(req, res, async function (data) {
88
+ throttle(req, data.username);
89
+ var user = typeof data.username === 'string' ? users.findUserByUsername(data.username) : null;
90
+ if (data.pin !== undefined) {
91
+ if (!user || user.passwordHash && !user.mustChangePin || typeof data.pin !== 'string' || !/^\d{6}$/.test(data.pin) || !users.verifyPin(data.pin, user.pinHash, user)) throw new Error('Invalid username or account PIN.');
92
+ clearAttempts(req, user.username); startTransition(user, res, req); return;
93
+ }
94
+ var originalHash = user && user.passwordHash;
95
+ if (!await passwords.verify(data.password, originalHash)) throw new Error('Invalid username or password.');
96
+ user = users.findUserById(user.id);
97
+ if (!user || user.passwordHash !== originalHash || user.mustChangePin) throw new Error('Account credentials changed. Please sign in again.');
98
+ clearAttempts(req, user.username); grant(user, res, req);
99
+ });
100
+ return true;
101
+ }
102
+ if (req.method === 'POST' && url === '/auth/password-transition') {
103
+ read(req, res, async function (data) {
104
+ var token = ctx.parseCookies(req)[cookieName];
105
+ var proof = pending.get(token);
106
+ if (!proof || proof.until <= Date.now() || proof.busy) throw new Error('Verify your account PIN again.');
107
+ proof.busy = true;
108
+ try {
109
+ if (data.password !== data.confirmPassword) throw new Error('Passwords do not match.');
110
+ var hash = await passwords.hash(data.password);
111
+ var state = users.loadUsers();
112
+ var user = state.users.find(function (entry) { return entry.id === proof.userId; });
113
+ if (!user || user.pinHash !== proof.pinHash || user.passwordHash && !user.mustChangePin || proof.until <= Date.now()) throw new Error('Account credentials changed. Verify your PIN again.');
114
+ user.passwordHash = hash; user.pinHash = null; user.mustChangePin = false;
115
+ users.saveUsers(state);
116
+ pending.delete(token);
117
+ ctx.revokeSessions(user.id);
118
+ grant(user, res, req);
119
+ } finally { proof.busy = false; }
120
+ });
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+ return { handleRequest: handleRequest, policy: policy, http: { read: read, reply: reply, throttle: throttle, clearAttempts: clearAttempts } };
126
+ }
127
+ module.exports = { attachAccountPasswords: attachAccountPasswords };
@@ -0,0 +1,53 @@
1
+ var passwords = require('./account-passwords');
2
+ function attachAccountSecurity(ctx) {
3
+ function sessionList(req, user) {
4
+ var current = ctx.sessions.get(ctx.sessionToken(req), false);
5
+ return ctx.sessions.list(user.id).filter(function (entry) { return !!ctx.validSessionUser(entry); }).map(function (entry) {
6
+ return { id: entry.id, current: !!current && entry.id === current.id, userAgent: entry.userAgent || '',
7
+ address: entry.address, createdAt: entry.migrated ? null : entry.createdAt,
8
+ lastSeenAt: entry.migrated ? entry.observedAt || null : entry.lastSeenAt,
9
+ unknownDevice: !!entry.migrated && !entry.observedAt,
10
+ expiresAt: entry.expiresAt, method: entry.passkeyId ? 'Passkey' : entry.credentialVersion ? 'Password' : 'Account sign-in', migrated: !!entry.migrated };
11
+ }).sort(function (a, b) { return Number(b.current) - Number(a.current) || (b.lastSeenAt || 0) - (a.lastSeenAt || 0); });
12
+ }
13
+ async function verify(req, data, original) {
14
+ ctx.http.throttle(req, original.username);
15
+ if (!original.passwordHash || !await passwords.verify(data.currentPassword, original.passwordHash)) throw new Error('Current password is incorrect.');
16
+ var current = ctx.getUser(req);
17
+ if (!current || current.id !== original.id || current.passwordHash !== original.passwordHash) throw new Error('Sign in again to continue.');
18
+ ctx.http.clearAttempts(req, current.username); return current;
19
+ }
20
+ function handleRequest(req, res, url) {
21
+ if (url !== '/api/account-sessions' && url !== '/api/account-transport' && url !== '/account/sessions') return false;
22
+ var user = ctx.getUser(req);
23
+ if (!user) { ctx.http.reply(res, 401, { error: 'Sign in again to manage your account.' }); return true; }
24
+ if (url === '/account/sessions' && req.method === 'GET') {
25
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'" });
26
+ res.end(require('./account-session-page').render()); return true;
27
+ }
28
+ if (url === '/api/account-transport') {
29
+ if (user.role !== 'admin') { ctx.http.reply(res, 403, { error: 'Administrator access required.' }); return true; }
30
+ if (req.method === 'GET') { ctx.http.reply(res, 200, ctx.transport.settings()); return true; }
31
+ if (req.method !== 'PUT') { ctx.http.reply(res, 405, { error: 'Method not allowed.' }); return true; }
32
+ ctx.http.read(req, res, async function (data) {
33
+ var admin = await verify(req, data, user);
34
+ if (admin.role !== 'admin') throw new Error('Administrator access required.');
35
+ var saved = ctx.transport.save(data); ctx.http.reply(res, 200, Object.assign({ ok: true }, saved));
36
+ }); return true;
37
+ }
38
+ if (req.method === 'GET') { ctx.http.reply(res, 200, { sessions: sessionList(req, user), maximumDays: 30, idleDays: 7 }); return true; }
39
+ if (req.method !== 'POST') { ctx.http.reply(res, 405, { error: 'Method not allowed.' }); return true; }
40
+ ctx.http.read(req, res, function (data) {
41
+ var active = ctx.getUser(req);
42
+ if (!active || active.id !== user.id) throw new Error('Sign in again to manage sessions.');
43
+ var current = ctx.sessions.get(ctx.sessionToken(req), false);
44
+ if (!current || typeof data.id !== 'string') throw new Error('Invalid session.');
45
+ if (data.id !== 'others' && !ctx.sessions.list(user.id).some(function (entry) { return entry.id === data.id; })) throw new Error('Session not found.');
46
+ ctx.sessions.revoke(user.id, data.id, current.id);
47
+ ctx.http.reply(res, 200, { ok: true, signedOut: data.id === current.id, sessions: sessionList(req, user) });
48
+ });
49
+ return true;
50
+ }
51
+ return { handleRequest: handleRequest };
52
+ }
53
+ module.exports = { attachAccountSecurity: attachAccountSecurity };