clay-server 5.1.1-beta.1 → 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 (47) 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/user-settings-sessions.css +42 -0
  21. package/lib/public/index.html +17 -5
  22. package/lib/public/modules/account-auth-settings.js +71 -0
  23. package/lib/public/modules/account-transport-settings.js +51 -0
  24. package/lib/public/modules/app-messages.js +4 -0
  25. package/lib/public/modules/passkey-browser.js +69 -0
  26. package/lib/public/modules/passkey-server-settings.js +57 -0
  27. package/lib/public/modules/project-logs-render.js +3 -2
  28. package/lib/public/modules/server-settings-controls.js +10 -0
  29. package/lib/public/modules/server-settings.js +7 -2
  30. package/lib/public/modules/user-settings-account.js +14 -1
  31. package/lib/public/modules/user-settings-sessions.js +120 -0
  32. package/lib/public/modules/user-settings.js +3 -0
  33. package/lib/public/modules/you-activity.js +3 -1
  34. package/lib/public/modules/you.js +91 -6
  35. package/lib/public/style.css +1 -0
  36. package/lib/server-account-passkeys.js +186 -0
  37. package/lib/server-account-passwords.js +127 -0
  38. package/lib/server-account-security.js +53 -0
  39. package/lib/server-auth.js +107 -79
  40. package/lib/server-settings.js +2 -1
  41. package/lib/server.js +2 -0
  42. package/lib/user-knowledge-service.js +23 -1
  43. package/lib/users-auth.js +38 -3
  44. package/lib/users-create.js +97 -0
  45. package/lib/users-server-pin.js +37 -0
  46. package/lib/users.js +11 -84
  47. package/package.json +2 -1
@@ -0,0 +1,21 @@
1
+ function attachPolicy(users) {
2
+ function enabled() { return users.loadUsers().accountAuthMethod === 'password'; }
3
+ function available() {
4
+ if (!users.isMultiUser() || users.getSoloNoPinUser()) return false;
5
+ var data = users.loadUsers();
6
+ var config = require('./config').loadConfig() || {};
7
+ return !(data.users.length === 1 && config.pinHash && config.singleUserMigratedUserId === data.users[0].id);
8
+ }
9
+ function requiresPassword(user) { return !!user && enabled(); }
10
+ function activate() {
11
+ if (!available()) throw new Error('Account password policy does not apply to solo or Server PIN access.');
12
+ var data = users.loadUsers();
13
+ if (data.users.some(function (user) { return !user.pinHash && !user.passwordHash; })) {
14
+ throw new Error('Some accounts have no account PIN. Set a temporary account PIN for them before enabling passwords.');
15
+ }
16
+ data.accountAuthMethod = 'password';
17
+ users.saveUsers(data);
18
+ }
19
+ return { enabled: enabled, available: available, requiresPassword: requiresPassword, activate: activate };
20
+ }
21
+ module.exports = { attachPolicy: attachPolicy };
@@ -0,0 +1,29 @@
1
+ function guardSocket(ws, req, isAuthed, touchSession) {
2
+ req.claySessionPassive = true;
3
+ var send = ws.send;
4
+ var emit = ws.emit;
5
+ var closed = false;
6
+ function check() {
7
+ if (closed) return false;
8
+ if (isAuthed(req)) return true;
9
+ closed = true;
10
+ if (ws.readyState === 1) send.call(ws, JSON.stringify({ type: 'account_auth_required' }));
11
+ ws.close(4001, 'Account sign-in required');
12
+ return false;
13
+ }
14
+ ws.send = function () { if (check()) return send.apply(ws, arguments); };
15
+ ws.emit = function (name) {
16
+ if (name === 'message') {
17
+ if (!check()) return false;
18
+ var message;
19
+ try { message = JSON.parse(String(arguments[1])); } catch (error) { message = null; }
20
+ if (touchSession && message && ['ping', 'pong', 'heartbeat'].indexOf(message.type) === -1) touchSession(req);
21
+ }
22
+ return emit.apply(ws, arguments);
23
+ };
24
+ var timer = setInterval(check, 1000);
25
+ if (timer.unref) timer.unref();
26
+ ws.on('close', function () { closed = true; clearInterval(timer); });
27
+ return check();
28
+ }
29
+ module.exports = { guardSocket: guardSocket };
@@ -0,0 +1,44 @@
1
+ var fs = require('fs');
2
+ var path = require('path');
3
+ var crypto = require('crypto');
4
+ var WINDOW = 15 * 60000;
5
+ function attachThrottle(ctx) {
6
+ function key(value) { return crypto.createHash('sha256').update(value).digest('hex'); }
7
+ function load() {
8
+ try {
9
+ var data = JSON.parse(fs.readFileSync(ctx.file, 'utf8'));
10
+ if (!data || typeof data !== 'object' || Array.isArray(data) || Object.values(data).some(function (entry) { return !entry || !Number.isFinite(entry.count) || !Number.isFinite(entry.until); })) throw new Error('Invalid limiter data.');
11
+ return data;
12
+ }
13
+ catch (error) { if (error.code === 'ENOENT') return {}; throw new Error('Sign-in protection is unavailable. Please retry later.'); }
14
+ }
15
+ function save(data) {
16
+ try {
17
+ fs.mkdirSync(path.dirname(ctx.file), { recursive: true });
18
+ fs.writeFileSync(ctx.file + '.tmp', JSON.stringify(data), { mode: 0o600 }); fs.renameSync(ctx.file + '.tmp', ctx.file);
19
+ } catch (error) { throw new Error('Could not save sign-in protection. Please retry later.'); }
20
+ }
21
+ function prune(data) { Object.keys(data).forEach(function (id) { if (data[id].until <= Date.now()) delete data[id]; }); }
22
+ function consumeKeys(keys) {
23
+ var data = load(); prune(data);
24
+ if (Object.keys(data).length >= 10000) throw new Error('Sign-in is busy. Please retry later.');
25
+ keys.forEach(function (value) {
26
+ var id = key(value.name); var entry = data[id] || { count: 0, until: Date.now() + WINDOW };
27
+ if (entry.count >= value.max) { var error = new Error('Too many attempts. Retry in 15 minutes.'); error.status = 429; throw error; }
28
+ entry.count++; data[id] = entry;
29
+ });
30
+ save(data);
31
+ }
32
+ function consume(req, username) {
33
+ var keys = [{ name: 'ip:' + ctx.clientAddress(req), max: 100 }];
34
+ if (username) keys.push({ name: 'account:' + String(username).toLowerCase().slice(0, 100), max: 10 });
35
+ consumeKeys(keys);
36
+ }
37
+ function success(req, username) {
38
+ // Successful logins must not erase the IP budget for attacks against other accounts.
39
+ if (!username) return;
40
+ var data = load(); delete data[key('account:' + String(username).toLowerCase().slice(0, 100))]; prune(data); save(data);
41
+ }
42
+ return { consume: consume, success: success };
43
+ }
44
+ module.exports = { attachThrottle: attachThrottle };
@@ -0,0 +1,48 @@
1
+ var net = require('net');
2
+ var normalizeOrigin = require('./network-origins').normalizeAllowedOrigins;
3
+ function address(value) {
4
+ var result = String(value || '').replace(/^::ffff:/, '');
5
+ return net.isIP(result) ? result : '';
6
+ }
7
+ function loopback(value) { return value === '::1' || /^127\./.test(value); }
8
+ function attachTransport(ctx) {
9
+ function settings() { return ctx.users.loadUsers().accountTransport || { origin: '', trustedProxies: [] }; }
10
+ function info(req) {
11
+ var config = settings(); var peer = address(req.socket && req.socket.remoteAddress);
12
+ var proxy = !!peer && (config.trustedProxies || []).indexOf(peer) !== -1 && req.headers['x-forwarded-proto'] !== undefined;
13
+ var secure = !!(ctx.tlsOptions || req.socket && req.socket.encrypted);
14
+ if (proxy) secure = req.headers['x-forwarded-proto'] === 'https';
15
+ var origin = '';
16
+ try { origin = proxy ? config.origin : normalizeOrigin([(secure ? 'https://' : 'http://') + req.headers.host])[0]; } catch (error) { /* Invalid Host is not an authentication origin. */ }
17
+ var local = false;
18
+ try { var hostname = new URL(origin).hostname; local = !proxy && loopback(peer) && (hostname === 'localhost' || hostname === '[::1]' || /^127\./.test(hostname)); } catch (error) { /* Invalid origin remains denied. */ }
19
+ var client = peer;
20
+ if (proxy) {
21
+ // The configured immediate proxy must overwrite this single-address header.
22
+ client = address(req.headers['x-real-ip']) || peer;
23
+ }
24
+ return { origin: origin, secure: secure, local: local, clientAddress: client, proxy: proxy };
25
+ }
26
+ function allows(req, write) {
27
+ var value = info(req);
28
+ if (!value.origin || !value.secure && !value.local) return false;
29
+ if (req.headers.origin && req.headers.origin !== value.origin) return false;
30
+ if (!write) return true;
31
+ if (req.headers['sec-fetch-site'] && ['same-origin', 'none'].indexOf(req.headers['sec-fetch-site']) === -1) return false;
32
+ // Fetch writes send Origin; same-origin legacy clients may send Referer instead.
33
+ if (req.headers.origin) return req.headers.origin === value.origin;
34
+ if (req.headers.referer) { try { return new URL(req.headers.referer).origin === value.origin; } catch (error) { return false; } }
35
+ return false;
36
+ }
37
+ function save(data) {
38
+ var origin = data.origin ? normalizeOrigin([data.origin])[0] : '';
39
+ if (origin && new URL(origin).protocol !== 'https:') throw new Error('The external account address must use HTTPS.');
40
+ if (!Array.isArray(data.trustedProxies) || data.trustedProxies.length > 16) throw new Error('Enter up to 16 exact proxy IP addresses.');
41
+ var proxies = data.trustedProxies.map(function (value) { var ip = address(value); if (!ip) throw new Error('Trusted proxies must be exact IP addresses.'); return ip; });
42
+ if (proxies.length && !origin) throw new Error('An HTTPS address is required for a trusted proxy.');
43
+ var state = ctx.users.loadUsers(); state.accountTransport = { origin: origin, trustedProxies: Array.from(new Set(proxies)) }; ctx.users.saveUsers(state);
44
+ return state.accountTransport;
45
+ }
46
+ return { info: info, allows: allows, settings: settings, save: save, clientAddress: function (req) { return info(req).clientAddress; } };
47
+ }
48
+ module.exports = { attachTransport: attachTransport };
@@ -0,0 +1,28 @@
1
+ var fs = require('fs');
2
+ var path = require('path');
3
+ var assets = {
4
+ '/auth/account-login.js': ['public/auth/account-login.js', 'text/javascript'],
5
+ '/auth/account-login.css': ['public/auth/account-login.css', 'text/css'],
6
+ '/auth/login-symbol.png': ['public/clay-studio-symbol.png', 'image/png'],
7
+ '/auth/login-wordmark.svg': ['public/clay-studio-wordmark-bold.svg', 'image/svg+xml'],
8
+ '/auth/login-font.woff2': ['public/fonts/geist/Geist-Variable.woff2', 'font/woff2'],
9
+ };
10
+ function handleAsset(req, res, url) {
11
+ if (req.method !== 'GET' || !Object.prototype.hasOwnProperty.call(assets, url)) return false;
12
+ res.writeHead(200, { 'Content-Type': assets[url][1], 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
13
+ res.end(fs.readFileSync(path.join(__dirname, assets[url][0]))); return true;
14
+ }
15
+ function render(options) {
16
+ return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign in · Clay</title><link rel="stylesheet" href="/auth/account-login.css"></head><body>' +
17
+ '<main id="login" data-stage="welcome" data-password="' + !!options.password + '" data-email="' + !!options.email + '">' +
18
+ '<header class="brand"><div class="brand-lockup"><img class="symbol" src="/auth/login-symbol.png" alt=""><img class="wordmark" src="/auth/login-wordmark.svg" alt="Clay Studio" width="4742" height="1156"></div></header>' +
19
+ '<section class="panel"><div class="panel-header"><button id="back" class="back" aria-label="Back" title="Back" hidden><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 5-7 7 7 7M5 12h14"/></svg></button><h1 id="heading" tabindex="-1">Sign in</h1></div><p id="description">Choose how to sign in.</p>' +
20
+ '<div id="welcome" class="view"><div id="accounts" aria-label="Remembered accounts"></div><button id="passkey" class="primary method" hidden>Continue with a passkey</button><button id="account" class="primary method">Sign in with an account</button><button id="email-method" class="quiet" hidden>Email me a sign-in code</button></div>' +
21
+ '<form id="identity" class="view" hidden><label for="username">Username</label><input id="username" name="username" autocomplete="username" autocapitalize="off" spellcheck="false" maxlength="100" required><button class="primary">Continue</button></form>' +
22
+ '<form id="credential" class="view" hidden><div class="identity-chip"><span id="selected-avatar" class="avatar"></span><div><strong id="selected-name"></strong><small id="selected-username"></small></div></div><input id="credential-username" class="offscreen" aria-label="Username" name="username" autocomplete="username" tabindex="-1"><label id="secret-label" for="secret">Password</label><div class="secret-field"><input id="secret" name="password" type="password" autocomplete="current-password" maxlength="256" required><button id="reveal" type="button" aria-pressed="false">Show</button></div><button class="primary">Sign in</button><button id="use-pin" class="quiet" type="button" hidden>I still use an account PIN</button></form>' +
23
+ '<form id="enroll" class="view" hidden><label for="new-password">New password</label><input id="new-password" type="password" autocomplete="new-password" maxlength="256" required><label for="confirm-password">Confirm password</label><input id="confirm-password" type="password" autocomplete="new-password" maxlength="256" required><button class="primary">Save password and continue</button></form>' +
24
+ '<form id="email" class="view" hidden><label for="email-address">Email address</label><input id="email-address" type="email" autocomplete="email" maxlength="100" required><button class="primary">Send sign-in code</button></form>' +
25
+ '<form id="code" class="view" hidden><label for="otp">Sign-in code</label><input id="otp" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required><button class="primary">Sign in</button><button id="resend" class="quiet" type="button">Send another code</button></form>' +
26
+ '<p id="status" role="status"></p><p id="error" role="alert"></p></section><footer><details><summary>Help signing in</summary><p>Contact your server administrator if you cannot sign in. You can forget a remembered account on this browser without deleting the account.</p></details><span id="server-address"></span></footer></main><script type="module" src="/auth/account-login.js"></script></body></html>';
27
+ }
28
+ module.exports = { render: render, handleAsset: handleAsset };
@@ -0,0 +1,31 @@
1
+ var crypto = require('crypto');
2
+
3
+ function attachChallenges(ctx) {
4
+ var entries = new Map();
5
+ var name = ctx.devMode ? 'clay_passkey_dev' : 'clay_passkey';
6
+ function cookie(token, origin) {
7
+ return name + '=' + token + '; Path=/; HttpOnly; SameSite=Strict; Max-Age=' + (token ? '300' : '0') +
8
+ (origin.indexOf('https:') === 0 ? '; Secure' : '');
9
+ }
10
+ function clear(req) { entries.delete(ctx.parseCookies(req)[name]); }
11
+ function issue(req, policy, proof) {
12
+ var now = Date.now();
13
+ entries.forEach(function (entry, token) { if (entry.until <= now) entries.delete(token); });
14
+ clear(req);
15
+ if (entries.size >= 1000) throw new Error('Passkey sign-in is busy. Please retry shortly.');
16
+ var token = crypto.randomBytes(32).toString('hex');
17
+ entries.set(token, Object.assign({}, proof, policy, { until: now + 300000 }));
18
+ return cookie(token, policy.origin);
19
+ }
20
+ function take(req, policy, purpose) {
21
+ var token = ctx.parseCookies(req)[name];
22
+ var proof = entries.get(token);
23
+ entries.delete(token);
24
+ if (!proof || proof.until <= Date.now() || proof.purpose !== purpose || proof.origin !== policy.origin || proof.rpID !== policy.rpID) {
25
+ throw new Error('This passkey request expired or was already used. Please try again.');
26
+ }
27
+ return proof;
28
+ }
29
+ return { issue: issue, take: take, clear: clear, cookie: cookie };
30
+ }
31
+ module.exports = { attachChallenges: attachChallenges };
@@ -0,0 +1,89 @@
1
+ function client(api) {
2
+ var list = document.getElementById('passkeys');
3
+ var form = document.getElementById('passkey-form');
4
+ var name = document.getElementById('passkey-name');
5
+ var password = document.getElementById('current-password');
6
+ var button = document.getElementById('save');
7
+ var cancel = document.getElementById('cancel');
8
+ var status = document.getElementById('status');
9
+ var selected = null;
10
+ var action = 'register';
11
+ var canRegister = api.supported();
12
+ function reset() {
13
+ selected = null; action = 'register'; name.value = ''; password.value = '';
14
+ document.getElementById('editor-heading').textContent = 'Add a passkey';
15
+ document.getElementById('name-row').hidden = false;
16
+ name.required = true; button.textContent = 'Add passkey'; cancel.hidden = true;
17
+ document.getElementById('editor-hint').textContent = 'Verify your current password to add a passkey.';
18
+ form.hidden = !canRegister;
19
+ }
20
+ function edit(key, mode) {
21
+ form.hidden = false;
22
+ selected = key.id; action = mode; password.value = ''; name.value = key.name;
23
+ document.getElementById('editor-heading').textContent = (mode === 'remove' ? 'Remove ' : 'Rename ') + key.name;
24
+ document.getElementById('name-row').hidden = mode === 'remove'; name.required = mode !== 'remove';
25
+ button.textContent = mode === 'remove' ? 'Remove passkey' : 'Save name'; cancel.hidden = false;
26
+ document.getElementById('editor-hint').textContent = mode === 'remove' ? 'Sessions signed in with this passkey will end. Your password will still work.' : 'Verify your current password to rename this passkey.';
27
+ (mode === 'remove' ? password : name).focus();
28
+ }
29
+ function render(keys) {
30
+ list.replaceChildren();
31
+ if (!keys.length) { var empty = document.createElement('p'); empty.textContent = 'No passkeys registered yet.'; list.appendChild(empty); }
32
+ keys.forEach(function (key) {
33
+ var row = document.createElement('li'); var detail = document.createElement('div');
34
+ var title = document.createElement('strong'); title.textContent = key.name;
35
+ var last = document.createElement('small'); last.textContent = key.lastUsedAt ? 'Last used ' + new Date(key.lastUsedAt).toLocaleDateString() : 'Not used to sign in yet';
36
+ detail.append(title, last); row.appendChild(detail);
37
+ ['rename', 'remove'].forEach(function (mode) {
38
+ var control = document.createElement('button'); control.type = 'button'; control.className = 'secondary';
39
+ control.textContent = mode === 'rename' ? 'Rename' : 'Remove';
40
+ control.setAttribute('aria-label', control.textContent + ' ' + key.name);
41
+ control.onclick = function () { edit(key, mode); }; row.appendChild(control);
42
+ });
43
+ list.appendChild(row);
44
+ });
45
+ }
46
+ function busy(value) {
47
+ document.querySelectorAll('button, input').forEach(function (element) { element.disabled = value; });
48
+ }
49
+ cancel.onclick = function () { reset(); name.focus(); };
50
+ form.onsubmit = async function (event) {
51
+ event.preventDefault(); busy(true); status.textContent = 'Verifying…';
52
+ try {
53
+ var result;
54
+ if (action === 'register') {
55
+ result = await api.request('/api/passkeys/register/options', { name: name.value, currentPassword: password.value });
56
+ password.value = '';
57
+ status.textContent = 'Follow your device’s instructions to create a passkey.';
58
+ var response = await api.createPasskey(result.options);
59
+ result = await api.request('/api/passkeys/register/verify', { response: response });
60
+ } else result = await api.request('/api/passkeys/' + action, { id: selected, name: name.value, currentPassword: password.value });
61
+ render(result.passkeys); reset(); status.textContent = 'Passkeys updated.';
62
+ } catch (error) { status.textContent = api.passkeyError(error); }
63
+ finally { password.value = ''; busy(false); }
64
+ };
65
+ busy(true);
66
+ api.request('/api/passkeys').then(function (data) {
67
+ render(data.passkeys);
68
+ if (data.origin !== location.origin) {
69
+ status.textContent = 'Open ' + data.origin + ' to manage your passkeys.';
70
+ busy(true); form.hidden = true; return;
71
+ }
72
+ busy(false);
73
+ if (!canRegister) {
74
+ status.textContent = 'This browser cannot create passkeys. You can still rename or remove registered passkeys, or sign in with your password.';
75
+ form.hidden = true;
76
+ }
77
+ }).catch(function (error) { status.textContent = error.message; });
78
+ }
79
+ function render() {
80
+ return require('./account-password-page').shell('Passkeys',
81
+ '<h1>Passkeys</h1><p>Sign in using your device’s fingerprint, face recognition or screen lock. Your password remains available.</p>' +
82
+ '<ul id="passkeys" aria-label="Registered passkeys"></ul><form id="passkey-form"><h2 id="editor-heading">Add a passkey</h2>' +
83
+ '<p id="editor-hint">Verify your current password to add a passkey.</p><div id="name-row"><label for="passkey-name">Passkey name</label><input id="passkey-name" maxlength="80" placeholder="For example, personal laptop" required></div>' +
84
+ '<label for="current-password">Current password</label><input id="current-password" type="password" autocomplete="current-password" required>' +
85
+ '<div class="actions"><button id="save" type="submit">Add passkey</button><button id="cancel" type="button" class="secondary" hidden>Cancel</button></div></form>' +
86
+ '<p id="status" role="status" aria-live="polite"></p><a href="/">Back to Clay</a>' +
87
+ '<script type="module">import * as api from "/auth/passkey-browser.js";(' + client.toString() + ')(api);</script>');
88
+ }
89
+ module.exports = { render: render };
@@ -0,0 +1,39 @@
1
+ var net = require('net');
2
+ var normalizeOrigin = require('./network-origins').normalizeAllowedOrigins;
3
+
4
+ function parseOrigin(value) {
5
+ var origin = normalizeOrigin([value])[0];
6
+ var url = new URL(origin);
7
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost')) {
8
+ throw new Error('Use an HTTPS address, or http://localhost for local development.');
9
+ }
10
+ if (net.isIP(url.hostname) || url.hostname[0] === '[') throw new Error('Passkeys require a hostname, not an IP address.');
11
+ return { origin: origin, rpID: url.hostname };
12
+ }
13
+
14
+ function configured(data) {
15
+ if (data.accountAuthMethod !== 'password' || !data.passkeyPolicy) return null;
16
+ try {
17
+ var policy = parseOrigin(data.passkeyPolicy.origin);
18
+ return policy.rpID === data.passkeyPolicy.rpID ? policy : null;
19
+ } catch (error) { return null; }
20
+ }
21
+
22
+ function count(data) {
23
+ return data.users.reduce(function (total, user) { return total + (user.passkeys || []).length; }, 0);
24
+ }
25
+
26
+ function save(users, origin) {
27
+ var next = parseOrigin(origin);
28
+ var data = users.loadUsers();
29
+ if (data.accountAuthMethod !== 'password') throw new Error('Enable account passwords first.');
30
+ var previous = configured(data);
31
+ if (count(data) && (!previous || previous.origin !== next.origin)) {
32
+ throw new Error('Remove existing passkeys before changing the sign-in address. Password sign-in remains available.');
33
+ }
34
+ data.passkeyPolicy = next;
35
+ users.saveUsers(data);
36
+ return next;
37
+ }
38
+
39
+ module.exports = { parseOrigin: parseOrigin, configured: configured, count: count, save: save };
@@ -0,0 +1,69 @@
1
+ function client(changing) {
2
+ var form = document.getElementById('account-form');
3
+ var username = document.getElementById('username');
4
+ var secret = document.getElementById('secret');
5
+ var confirm = document.getElementById('confirm-password');
6
+ var mode = document.getElementById('use-pin');
7
+ var button = document.getElementById('submit');
8
+ var error = document.getElementById('error');
9
+ var enrolling = false;
10
+ if (changing) {
11
+ enrolling = true;
12
+ document.getElementById('heading').textContent = 'Change your password';
13
+ document.getElementById('description').textContent = 'Use 15–128 characters. Saving signs out your other sessions.';
14
+ document.getElementById('username-row').hidden = true; username.required = false;
15
+ document.getElementById('mode-row').hidden = true;
16
+ document.getElementById('current-row').hidden = false;
17
+ document.getElementById('confirm-row').hidden = false;
18
+ document.getElementById('secret-label').textContent = 'New password or passphrase';
19
+ secret.autocomplete = 'new-password'; button.textContent = 'Save password';
20
+ }
21
+ function update() {
22
+ document.getElementById('secret-label').textContent = mode.checked ? 'Current account PIN' : 'Password';
23
+ secret.autocomplete = mode.checked ? 'off' : 'current-password';
24
+ secret.value = ''; error.textContent = '';
25
+ button.textContent = mode.checked ? 'Verify PIN' : 'Sign in';
26
+ }
27
+ mode.onchange = update;
28
+ form.onsubmit = async function (event) {
29
+ event.preventDefault(); button.disabled = true; error.textContent = '';
30
+ try {
31
+ var body = enrolling ? { password: secret.value, confirmPassword: confirm.value } : { username: username.value };
32
+ if (!enrolling) body[mode.checked ? 'pin' : 'password'] = secret.value;
33
+ if (changing) body.currentPassword = document.getElementById('current-password').value;
34
+ var response = await fetch(changing ? '/auth/password-change' : enrolling ? '/auth/password-transition' : '/auth/login', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
35
+ var data = await response.json();
36
+ if (!response.ok || !data.ok) throw new Error(data.error || 'Could not sign in.');
37
+ secret.value = '';
38
+ if (!data.passwordRequired) { location.href = '/'; return; }
39
+ enrolling = true;
40
+ document.getElementById('passkey-login').hidden = true;
41
+ document.getElementById('heading').textContent = 'Set your password';
42
+ document.getElementById('description').textContent = 'Use 15–128 characters. A long passphrase works well. Your account PIN is replaced only after saving.';
43
+ username.readOnly = true;
44
+ document.getElementById('mode-row').hidden = true;
45
+ document.getElementById('secret-label').textContent = 'New password or passphrase';
46
+ secret.autocomplete = 'new-password';
47
+ document.getElementById('confirm-row').hidden = false;
48
+ button.textContent = 'Save password and continue'; secret.focus();
49
+ } catch (failure) { error.textContent = failure.message; }
50
+ finally { button.disabled = false; }
51
+ };
52
+ }
53
+ function shell(title, body) {
54
+ return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + title + ' — Clay</title>' +
55
+ '<style>:root{color-scheme:light dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:light-dark(#f2f2ef,#171717);color:light-dark(#171717,#eee);font:14px system-ui}main{width:min(100%,400px);padding:24px}h1{font-size:20px;margin:0 0 8px}p{font-size:13px;line-height:1.5;opacity:.75}label{display:block;font-size:13px;margin:16px 0 6px}input:not([type=checkbox]){width:100%;height:36px;border:1px solid #8886;border-radius:8px;padding:0 10px;background:light-dark(#fff,#242424);color:inherit;font:inherit}button{min-height:36px;width:100%;border:0;border-radius:8px;background:#5857fc;color:white;font:inherit;padding:8px 12px;margin-top:20px;cursor:pointer}button:disabled{opacity:.6;cursor:wait}:focus-visible{outline:2px solid #5857fc;outline-offset:3px}#error{color:light-dark(#a12c2c,#ff9292);min-height:20px;opacity:1}a{color:inherit}h2{font-size:16px;margin:24px 0 8px}ul{list-style:none;padding:0}li{display:flex;align-items:center;gap:8px;padding:12px 0;border-bottom:1px solid #8884}li div{flex:1;min-width:0;overflow-wrap:anywhere}small{display:block;opacity:.7;margin-top:4px}.secondary{background:transparent;color:inherit;border:1px solid #8886}li button{width:auto;margin:0;font-size:12px;min-height:32px;padding:4px 8px}.actions{display:flex;gap:8px}#status{overflow-wrap:anywhere}[hidden]{display:none!important}</style></head><body><main>' +
56
+ body + '</main></body></html>';
57
+ }
58
+ function render(changing) {
59
+ return shell('Account sign-in',
60
+ '<h1 id="heading">Account sign-in</h1><p id="description">This server uses account passwords. Still using an account PIN? Verify it once to set your password.</p>' +
61
+ '<form id="account-form"><div id="username-row"><label for="username">Username</label><input id="username" autocomplete="username" maxlength="100" required autofocus></div>' +
62
+ '<div id="current-row" hidden><label for="current-password">Current password</label><input id="current-password" type="password" autocomplete="current-password" maxlength="256"></div>' +
63
+ '<label id="mode-row"><input id="use-pin" type="checkbox"> I still use an account PIN</label>' +
64
+ '<label id="secret-label" for="secret">Password</label><input id="secret" type="password" autocomplete="current-password" required maxlength="256">' +
65
+ '<div id="confirm-row" hidden><label for="confirm-password">Confirm password</label><input id="confirm-password" type="password" autocomplete="new-password" maxlength="256"></div>' +
66
+ '<p id="error" role="alert"></p><button id="submit" type="submit">Sign in</button></form><button id="passkey-login" type="button" class="secondary" hidden>Sign in with a passkey</button><p>Forgot your credentials? Contact your server administrator for account recovery.</p><a href="/">Start again</a>' +
67
+ '<script>(' + client.toString() + ')(' + (changing ? 'true' : 'false') + ');</script><script type="module">import {initPasskeyLogin} from "/auth/passkey-browser.js";initPasskeyLogin();</script>');
68
+ }
69
+ module.exports = { render: render, shell: shell };
@@ -0,0 +1,36 @@
1
+ var crypto = require('crypto');
2
+ var active = 0;
3
+ var PREFIX = 'password-scrypt-v1:';
4
+
5
+ function validate(password) {
6
+ if (typeof password !== 'string' || Array.from(password).length < 15 || Array.from(password).length > 128) {
7
+ throw new Error('Use 15–128 characters. Spaces and passphrases are welcome.');
8
+ }
9
+ }
10
+
11
+ function derive(password, salt) {
12
+ if (active >= 2) return Promise.reject(new Error('Sign-in is busy. Please retry shortly.'));
13
+ active++;
14
+ return new Promise(function (resolve, reject) {
15
+ crypto.scrypt(password, salt, 64, { N: 32768, r: 8, p: 3, maxmem: 64 * 1024 * 1024 }, function (error, key) {
16
+ active--;
17
+ if (error) reject(error); else resolve(key);
18
+ });
19
+ });
20
+ }
21
+
22
+ async function hash(password) {
23
+ validate(password);
24
+ var salt = crypto.randomBytes(16).toString('hex');
25
+ return PREFIX + salt + ':' + (await derive(password, salt)).toString('hex');
26
+ }
27
+
28
+ async function verify(password, stored) {
29
+ if (typeof password !== 'string' || password.length > 512) return false;
30
+ var parts = /^password-scrypt-v1:([a-f0-9]{32}):([a-f0-9]{128})$/.exec(stored || '');
31
+ // Unknown accounts still perform the same bounded work.
32
+ var key = await derive(password, parts ? parts[1] : '0'.repeat(32));
33
+ return !!parts && crypto.timingSafeEqual(key, Buffer.from(parts[2], 'hex'));
34
+ }
35
+
36
+ module.exports = { hash: hash, verify: verify, validate: validate };
@@ -0,0 +1,77 @@
1
+ var fs = require('fs');
2
+ var path = require('path');
3
+ var crypto = require('crypto');
4
+ var AGE = 90 * 86400000;
5
+ function attachRemembered(ctx) {
6
+ var name = ctx.devMode ? 'clay_accounts_dev' : 'clay_accounts';
7
+ function digest(token) { return crypto.createHash('sha256').update(token).digest('hex'); }
8
+ function token(req) { var value = ctx.parseCookies(req)[name]; return /^[a-f0-9]{64}$/.test(value || '') ? value : null; }
9
+ function load() {
10
+ try {
11
+ var data = JSON.parse(fs.readFileSync(ctx.file, 'utf8'));
12
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid remembered account data.');
13
+ Object.keys(data).forEach(function (key) {
14
+ var entry = data[key];
15
+ if (!/^[a-f0-9]{64}$/.test(key) || !entry || !Number.isFinite(entry.until) || !Array.isArray(entry.ids)) delete data[key];
16
+ });
17
+ return data;
18
+ }
19
+ catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
20
+ }
21
+ function save(data) {
22
+ Object.keys(data).forEach(function (key) { if (!data[key] || data[key].until <= Date.now()) delete data[key]; });
23
+ fs.mkdirSync(path.dirname(ctx.file), { recursive: true });
24
+ fs.writeFileSync(ctx.file + '.tmp', JSON.stringify(data), { mode: 0o600 }); fs.renameSync(ctx.file + '.tmp', ctx.file);
25
+ }
26
+ function cookie(value, req) {
27
+ return name + '=' + value + '; Path=/; HttpOnly; SameSite=Strict; Max-Age=' + (value ? AGE / 1000 : 0) + (ctx.transport.info(req).secure ? '; Secure' : '');
28
+ }
29
+ function remember(userId, req) {
30
+ if (!req) return null;
31
+ // Remembering a name is optional; a storage failure must not fail a valid login.
32
+ try {
33
+ var data = load(); var previous = token(req); var old = previous && data[digest(previous)];
34
+ var ids = old && old.until > Date.now() && Array.isArray(old.ids) ? old.ids : [];
35
+ var next = crypto.randomBytes(32).toString('hex');
36
+ if (previous) delete data[digest(previous)];
37
+ data[digest(next)] = { until: Date.now() + AGE, ids: [userId].concat(ids.filter(function (id) { return id !== userId; })).slice(0, 5) };
38
+ var keys = Object.keys(data).sort(function (a, b) { return data[a].until - data[b].until; });
39
+ keys.slice(0, Math.max(0, keys.length - 10000)).forEach(function (key) { delete data[key]; });
40
+ save(data); return cookie(next, req);
41
+ } catch (error) { return null; }
42
+ }
43
+ function accounts(req) {
44
+ var value = token(req); var entry = value && load()[digest(value)];
45
+ if (!entry || entry.until <= Date.now() || !Array.isArray(entry.ids)) return [];
46
+ return entry.ids.map(function (id) { return ctx.users.findUserById(id); }).filter(Boolean).map(function (user) {
47
+ return { id: user.id, username: user.username, name: user.profile && user.profile.name || user.displayName || user.username };
48
+ });
49
+ }
50
+ function handleRequest(req, res, url) {
51
+ if (url !== '/auth/accounts') return false;
52
+ function reply(code, data, cookies) { var headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }; if (cookies) headers['Set-Cookie'] = cookies; res.writeHead(code, headers); res.end(JSON.stringify(data)); }
53
+ if (!ctx.users.isMultiUser()) { reply(200, { accounts: [] }); return true; }
54
+ var origin = ctx.transport.info(req).origin;
55
+ if ((req.headers.origin && req.headers.origin !== origin) || req.headers['sec-fetch-site'] === 'cross-site') { reply(403, { error: 'Use the same Clay address.' }); return true; }
56
+ if (req.method === 'GET') {
57
+ try { reply(200, { accounts: accounts(req) }); } catch (error) { reply(503, { error: 'Remembered accounts are unavailable. You can still sign in.' }); }
58
+ return true;
59
+ }
60
+ if (req.method !== 'POST') { reply(405, { error: 'Method not allowed.' }); return true; }
61
+ if (!origin || req.headers.origin !== origin) { reply(403, { error: 'Use the same Clay address.' }); return true; }
62
+ ctx.read(req, res, function (body) {
63
+ if (typeof body.forget !== 'string' || !body.forget) { reply(400, { error: 'Choose an account to forget.' }); return; }
64
+ try {
65
+ var value = token(req); var data = load(); var key = value && digest(value); var entry = key && data[key];
66
+ if (entry) {
67
+ entry.ids = entry.ids.filter(function (id) { return id !== body.forget; });
68
+ if (!entry.ids.length) delete data[key];
69
+ save(data);
70
+ }
71
+ reply(200, { accounts: accounts(req) }, !entry || !entry.ids.length ? cookie('', req) : undefined);
72
+ } catch (error) { reply(503, { error: 'Could not forget this account. Please try again.' }); }
73
+ }); return true;
74
+ }
75
+ return { remember: remember, handleRequest: handleRequest };
76
+ }
77
+ module.exports = { attachRemembered: attachRemembered };
@@ -0,0 +1,51 @@
1
+ function client() {
2
+ var list = document.getElementById('sessions'); var status = document.getElementById('status');
3
+ var confirmation = document.getElementById('confirmation'); var target = null; var otherCount = 0;
4
+ async function request(data) {
5
+ var response = await fetch('/api/account-sessions', data ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) } : {});
6
+ var result = await response.json(); if (!response.ok) throw new Error(result.error || 'Could not manage sessions.'); return result;
7
+ }
8
+ function describe(value) {
9
+ if (!value) return 'Unknown device';
10
+ var browser = /Edg\//.test(value) ? 'Edge' : /Firefox\//.test(value) ? 'Firefox' : /Chrome\//.test(value) ? 'Chrome' : /Safari\//.test(value) ? 'Safari' : 'Browser';
11
+ var system = /Android/.test(value) ? 'Android' : /iPhone|iPad/.test(value) ? 'iOS' : /Windows/.test(value) ? 'Windows' : /Macintosh/.test(value) ? 'macOS' : /Linux/.test(value) ? 'Linux' : '';
12
+ return value === 'Earlier session' ? value : browser + (system ? ' on ' + system : '');
13
+ }
14
+ function choose(id) { target = id; confirmation.hidden = false; document.getElementById('confirm').focus(); }
15
+ function render(entries) {
16
+ list.replaceChildren();
17
+ entries.forEach(function (entry) {
18
+ var row = document.createElement('li'); var detail = document.createElement('div'); var title = document.createElement('strong');
19
+ title.textContent = describe(entry.userAgent) + (entry.current ? ' · This session' : ''); detail.appendChild(title);
20
+ var info = document.createElement('small');
21
+ info.textContent = entry.method + (entry.lastSeenAt ? ' · Last active ' + new Date(entry.lastSeenAt).toLocaleString() : ' · Previous activity unknown') + (entry.address ? ' · Last observed IP ' + entry.address : ''); detail.appendChild(info);
22
+ var expiry = document.createElement('small');
23
+ expiry.textContent = entry.expiresAt ? 'Expires ' + new Date(entry.expiresAt).toLocaleDateString() + (entry.migrated ? ' · Earlier session given a transition period' : '') : 'Compatibility session'; detail.appendChild(expiry);
24
+ var button = document.createElement('button'); button.className = 'secondary'; button.textContent = 'Sign out';
25
+ button.setAttribute('aria-label', 'Sign out ' + describe(entry.userAgent) + (entry.current ? ' current session' : ''));
26
+ button.onclick = function () { choose(entry.id); }; row.append(detail, button); list.appendChild(row);
27
+ });
28
+ otherCount = entries.filter(function (entry) { return !entry.current; }).length;
29
+ document.getElementById('others').disabled = otherCount === 0;
30
+ }
31
+ document.getElementById('others').onclick = function () { choose('others'); };
32
+ document.getElementById('cancel').onclick = function () { target = null; confirmation.hidden = true; document.getElementById('others').focus(); };
33
+ document.getElementById('confirm').onclick = async function () {
34
+ document.querySelectorAll('button').forEach(function (button) { button.disabled = true; });
35
+ status.textContent = 'Signing out…';
36
+ try {
37
+ var result = await request({ id: target });
38
+ if (result.signedOut) { location.href = '/'; return; }
39
+ confirmation.hidden = true; target = null; render(result.sessions); status.textContent = 'Selected sessions signed out.';
40
+ } catch (error) { status.textContent = error.message; }
41
+ finally { document.querySelectorAll('button').forEach(function (button) { button.disabled = false; }); document.getElementById('others').disabled = otherCount === 0; }
42
+ };
43
+ request().then(function (data) { render(data.sessions); }).catch(function (error) { status.textContent = error.message; });
44
+ }
45
+ function render() {
46
+ return require('./account-password-page').shell('Signed-in devices', '<h1>Signed-in devices</h1><p>Account sessions expire after 30 days, or after 7 days without use. Each entry is a browser session, not a verified physical device.</p>' +
47
+ '<ul id="sessions" aria-label="Signed-in sessions"></ul><button id="others" class="secondary" disabled>Sign out other sessions</button>' +
48
+ '<div id="confirmation" role="group" aria-label="Confirm sign-out" hidden><p>Sign out the selected sessions? Their open connections will close.</p><div class="actions"><button id="confirm">Sign out</button><button id="cancel" class="secondary">Cancel</button></div></div>' +
49
+ '<p id="status" role="status"></p><a href="/">Back to Clay</a><script>(' + client.toString() + ')();</script>');
50
+ }
51
+ module.exports = { render: render };